recipes_api/README.md
devfzn 40bbaff60b
creación TAG API
test, model, list, serializers, views & urls
2023-10-10 00:08:52 -03:00

46 KiB

REST API Django

Tecnologias

%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'basis'}}}%%
flowchart
subgraph " "
direction TB
SW{Swagger-UI}

subgraph APP["App Container"]
RF("REST Framework")
DJ("Django")
PY("Python")
end

subgraph DBC["DB Container"]
DB[(PostgreSQL)]
end

RF <--> SW
RF <--> DJ <--> PY
DB <--> DJ
end

Estructura del proyecto

  • app Django project
  • app/core/ código compartido entre multiples apps
  • app/user/ código relativo al usuario
  • app/recipe/ código relativo a las recetas

TDD

Test Driven Develoment

%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'natural'}}}%%
flowchart
subgraph " "
direction LR
WT[Write Test]
RTF["Run Test
(Fails)"]
AF[Add Feature]
RTP["Run Test
(Passes)"]
RF[Refactor]
end
WT --> RTF --> AF --> RTP --> RF
RF --> RTP
  • Esto proporciona un mejor entendimiento del código
  • Permite realizar cambios con confianza
  • Reduco bugs

Unitests

  • Código que prueba código
    • Establecer condiciones/entradas
    • Correr fragmentos de código
    • Verificar salidas con assertions
  • Beneficios
    • Asegurar que el código corre como se espera
    • Atrapar bugs
    • Mejorar fiabilidad
    • Proporciona confianza

Docker

¿Por qué usar Docker?

  • Consistencia entre ambientes de desarrollo y producción
  • Facilita la colaboración entre desarrolladores
  • Todas las dependencias como código
    • Requerimientos de Python
    • Dependencias del S.O.
  • Facilidad para limpiar el sistema (post-dev)
  • Ahorro de tiempo

¿Como usar Docker?

  • Crear dockerfile
  • Crear docker compose configuration
  • Correr todos los comandos usando Docker compose

Docker con GitHub Actions

  • Docker Hub tiene un limite de acceso:
    • 100 pulls/6 hr para usuarios sin authentificación
    • 200 pulls/6 hr para usuarios con authentificación
  • GitHub Actions es un servicio compartido
    • 100 pulls/6 hr considera TODOS los usuarios
  • Autenticación con Docker Hub
    • Crear cuenta
    • Configurar credenciales
    • Login antes de correr un trabajo (job)
    • Obtener 200 pulls/6 hr gratis

Configurar Docker

  • Creación dockerfile
  • Lista de pasos para crear imagen
    • Escoger una imagen basada en python
    • Instalar dependencias
    • Establecer usuarios

Docker Compose

  • Como se debe utlizar la imagen de docker
  • Definir servicios
    • Nombre (ej. app)
    • Mapeo de puertos
    • Mapeo de volumenes
  • Correr todos los comandos a travez de Docker Compose
    ej. docker compose run --rm app sh -c "python manage.py collectstatic"
    • docker compose Ejecuta un comando de Docker Compose
    • run comienza un contenedor específico definido en la configuración
    • --rm remueve el contenedor
    • app es el nombre del servicio/aplicación
    • sh -c pasa una orden a la shell del container
    • "python manage.py ..." comando a correr dentro del contenedor

docker build .
docker compose build

Linting

  • Instalar flake8
  • requirements.dev.txt
  • Configuración flake8
  • Correr a travez de docker compose docker compose run --rm app sh -c "flake8"

Testing

  • Django test suite
  • Configurar test por cada aplicación Django
  • Correr a travez de docker compose docker compose run --rm app sh -c "python manage.py test"

Creación del proyecto Django

docker compose run -rm app sh -c "django-admin startproject app ."

Iniciar el servidor

docker compose up

GitHub Actions

  • Herramienta de automatización
  • Similar a Travis-CI, GitLab CI/CD, Jenkins
  • Ejecuta tareaas cunado el código cambia
  • Tareas automatizadas comunes:
    • Despliege/implementación
    • Code Linting
    • Tests Unitarios

Funciona con Trigger ej. push to GitHub

¿Como funciona?

%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'natural'}}}%%
flowchart
subgraph " "
direction LR
TG["<b>Trigger</b>
Push to GitHub"]
JB["<b>Job</b>
Run unit tests"]
RS["<b>Result</b>
Success/fail"]
end
TG ==> JB ==> RS

Costo

  • Se cobra por minutos de uso
  • 2.000 minutos gratis

Configuranción GitHub Actions

  • Creación de archivo checks.yml
    • Set Trigger
    • Añadir passos para correr pruebas y linting
  • Configurar DockerHub auth
    • Necesitado para jalar imagenes base
    • Limites:
      • Anónimos: 100/6h
      • Atentificado 200/6h
    • GitHub Actions usan IP compartida, la limitación aplica para todos los usuarios al autenticar con DockerHub se obtienen 200/6h de uso exclusivo

Django Tests

  • Basado en la biblioteca unittest
  • Caracteristicas añadidas de Django
    • Cliente del pruebas dummy web browser
    • Simula autenticación
    • Base de datos temporal
  • Caracteristicas añadidas de REST Framework
    • Cliente de pruebas de la API

¿Donde van los tests?

  • test.py por aplicación
  • O crear un subdirectorio tests/ para dividir las pruebas
  • Recordar
    • Solo usar tests.py o directorio tests/, no ambos
    • Los moduloes de prueba deben comenzar con test_
    • Los directorios de pruebas deben contener el archivo __init__.py
demo_code
├── app_one
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── models.py
│   ├── tests.py
│   ├── views.py
│   └── admin.py
└── app_two
    ├── tests
    │   ├── __init__.py
    │   ├── test_module_one.py
    │   └── test_module_two.py
    ├── __init__.py
    ├── admin.py
    ├── apps.py
    ├── models.py
    ├── views.py
    └── admin.py

Test DB

  • Codigo de pruebas que usa la base de datos
  • Base de datos específica para pruebas
%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'natural'}}}%%
flowchart
subgraph " "
direction LR
RT["<b>Runs Tests</b>"]
CD["<b>Clears data</b>"]
end
RT ==> CD ==> RT
  • Por defecto, esto ocurre para cada test

Clases de Test

  • SimpleTestCase
    • Sin integración con BD
    • Util si no se require una BD para la lógica a probar
    • Ahorra tiempo de ejecución
  • TestCase
    • Integración con BD
    • Util para probar código que usa la BD

Ej. test

""""
Unit test for views
""""
from django.test import SimpleTestCase
form app_two import views

class ViewsTests(SimpleTestCase):

    def test_make_list_unique(self):
        """ Test making a list unique. """
        sample_items = [1, 1, 2, 2, 3, 4, 5, 5]
        res = views.remove_duplicates(sample_items)
        self.assertEqual(res, [1, 2, 3, 4, 5])

python manage.py test

Creación del primer test

Modulo a testear calc.py con tests.py

"""
Sample tests
"""
from django.test import SimpleTestCase
from app import calc

class CalcTests(SimpleTestCase):
    """ Test the calc module. """

    def test_add_numbers(self):
        """ Test adding numbers together. """
        res = calc.add(5, 6)

        self.assertEqual(res, 11)

Correr el test

docker compose run --rm app sh -c "python manage.py test"

Found 1 test(s).
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

Usando TDD

  1. Crear la prueba para el comportamiento esperado tests.py
  2. La prueba debe fallar
  3. Crear el código para que el test pase (añadir la funcionalidad) calc.py

Mocking

  • Evita depender de servicios externos, pues estos
    • No garantizan disponibilidad
    • Pueden hacer que los tests sean impredecibles e incositentes
  • Evita consecuencias no intecionadas, por ejm.
    • Enviar mails accidentalmente
    • Sobrecargar servicios externos

ej.

%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'natural'}}}%%
flowchart
subgraph " "
direction LR
RU["<b>register_user()</b>"]
CIDB["<b>create_in_db()</b>"]
SWE["<b>send_welcome_email()</b>"]
MS[e-mail sent]

end
RU --> CIDB --> SWE --x MS
  • Previene el envio del correo electónico
  • Asegura que send_welcome_email() es llamado correctamente

Otro beneficio de Mocking

  • Acelera las pruebas
%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'natural'}}}%%
flowchart
subgraph " "
direction LR
CDB["<b>check_db()</b>"]
SLP["<b>sleep()</b>"]

end
CDB --x SLP --> CDB 

Como usar mock

  • Se usa unittest.mock
    • MagicMock/Mock reemplaza objetos reales
    • patch sobreescribe el código de las pruebas

Testing Web Request

Probando la API

  • Hacer peticiones reales
  • Comprobar resultados

Django REST Framework provee un cliente para la API basado en Django TestClient, este realiza los requests y permite verificar resultados. Incluso permite sobreescribir la autenticación, para probar la funcionalidad de la API, haciendo que esta asuma que se esta autentificado.

from django.test import SimpleTestCase
from rest_framework.test import APIClient

class TestViews(SimpleTestCase):

    def test_get_greetings(self):
        """ Test getting greetings. """
        client = APIClient()
        res = client.get('/greetings/')

        self.assertEqual(res.status_code, 200)
        self.assertEqual(
                res.data,
                ["Hello!", "Bonjour!", "Hola!"],
            )

Problemas comunes en las pruebas

El test no se ejecuta

System check identified no issues (0 silenced).

----------------------------------------------------------------------
Ran 0 test in 0.000s

OK
  • Se ejecutan menos tests que la cantidad creada

Razones posibles

  • Falta el archivo __init__.py en el directorio tests.py
  • Indentación de los casos de prueba
  • Prefijo test faltante en los metodos test_some_function(self):

ImportError al correr las pruebas

      raise ImportError(
  ImportError: 'tests' module incorrectly imported from ....
  Expected ....Is this module globally installed?
      )

Razon posible

  • Existencia de tests/ y tests.py en la misma aplicación

Configurando la base de datos

PostgreSQL se integra bien con django, es oficialmente soportada.

Se utiliza Docker compose, se define la configuración dentro del proyecto para que sea reutilizable. Datos persistentes utilizando volumes. Maneja la configuración de la red. Configuranción usando variables de entorno.

Arquitectura

%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'basis'}}}%%
flowchart
direction TB
subgraph "<b>Docker Compose</b>"
DB[(PostgreSQL)]
direction LR
APP("<b>App</b>
Django")
DB <-..-> APP
end

Conectividad de red

services:
  app:
    depends_on:
      - db
  
  db:
    image: postgres:16-alpine
  • Establecer depends_on para iniciar db primero
  • El servicio app puede usar el hostname de db

Volumes

  • Persistencia de datos
  • Mapea el directorio del contenedeor con la máquina local
  db:
    image: postgres:16-alphine
  volumes:
    - dev-db-data:/var/lib/postgresql/data

volumes:
  dev-db-data:
  dev-static-data:

Agregando el servicio base de datos

docker-compose.yml

services:
  app:
    build:
      context: .
      args:
        - DEV=true
    ports:
      - "8000:8000"
    volumes:
      - ./app:/app
    command: >
      sh -c "python manage.py runserver 0.0.0.0:8000"      
    environment:
      - DB_HOST=db
      - DB_NAME=devdb
      - DB_USER=devuser
      - DB_PASS=changeme
    depends_on:
      - db

  db:
    image: postgres:16-alpine
    volumes:
      - dev-db-data:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB=devdb
      - POSTGRES_USER=devuser
      - POSTGRES_PASSWORD=changeme


volumes:
  dev-db-data:

Probar que todo funcione correctamente docker compose up

Configuración de la BD en Django

  • Especificar como django conecta con la BD settings.py
    • Engine (tipo de BD)
    • Hostname (DB IP o dominio)
    • Port
    • DB name
    • DB user
    • DB password
  • Instalar las dependencias del conector
  • Actualizar los requerimientos
DATABASES = {
    'default': {
        'ENGINE' django.db.backends.postgresql',
        'HOST': os.environ.get('DB_HOST'),
        'NAME': os.environ.get('DB_NAME'),
        'USER': os.environ.get('DB_USER'),
        'PASSWORD': os.environ.get('DB_PASSWORD'),
    }
}

Psycopg2

Packete necesario para conectar Django con la base de datos PostgreSQL, es el conector mas popular en Python y es soportado por Django. Se puede instalar de las siguientes maneras:

  • psycopg2-binary (Ok para desarrollo, no para producción)
  • psycopg2 (se compila desde el código fuente, hay que satisfacer dependencias para esto)
  • Fácil de instalar con Docker

Lista de dependencias

  • Compilador C
  • python3-dev
  • libpq-dev

Equivalentes para Apine

  • postgresql-client
  • build-base
  • postgresql-dev
  • musl-dev

Equivalentes para Debian (python:3.12.0-slim-bookworm)

  • postgresql-client
  • python3-dev
  • libpq-dev
  • gcc confimar
  • python3-psycopg2 confirmar

Una buena practica es limpiar las dependencias que ya no serán necesarias

Previniendo Race Condition con docker compose

Usar depends_on asegura que el service comience (no asegura que la app este corriendo)

Docker services timeline

img

La solución es hace que Django espere a la base de datos db. Este chequea la disponibilidad de la base de datos y continua cuando esta disponible

img

Comando personalizado de administración de Django en app core

Creación de aplicación core

docker compose run --rm app sh -c "python manage.py startapp core"

Se eliminal el archvo tests.py para crear y usar el directorio app/core/tests/ donde se agrega el correspondiente archivo __init__.py

Se crean los directorios app/core/management/commands/ con los archivos __init__.py y commands.py

"""
Django command to wait for the DB to be available.
"""
import time

from psycopg2 import OperationalError as Psycopg2OpError

from django.db.utils import OperationalError
from django.core.management.base import BaseCommand

class Command(BaseCommand):
    """ Django commando to wait for database. """

    def handle(self, *args, **options):
        """Entrypoint for command."""
        self.stdout.write('Waiting for database...')
        db_up = False
        while db_up is False:
            try:
                self.check(databases=['default'])
                db_up = True
            except (Psycopg2OpError, OperationalError):
                self.stdout.write('Database unavailable, waiting 1 second...')
                time.sleep(1)

        self.stdout.write(self.style.SUCCESS('Database available!'))

Correr tests

docker compose run --rm app sh -c "python manage.py test"

[+] Creating 1/0
 ✔ Container django_rest_api_udemy-db-1  Running                  0.0s
Found 4 test(s).
System check identified no issues (0 silenced).
..Waiting for database...
Database unavailable, waiting 1 second...
Database unavailable, waiting 1 second...
Database unavailable, waiting 1 second...
Database unavailable, waiting 1 second...
Database unavailable, waiting 1 second...
Database available!
.Waiting for database...
Database available!
.
----------------------------------------------------------------------
Ran 4 tests in 0.005s

OK

Correr command

docker compose run --rm app sh -c "python manage.py wait_for_db"

[+] Creating 1/0
 ✔ Container django_rest_api_udemy-db-1  Running                  0.0s
Waiting for database...
Database available!

Correr linter docker compose run --rm app sh -c "flake8" y corregir

Migraciones de la base de datos

Django ORM

  • Object Reational Mapper (ORM)
  • Capa de abstracción para los datos
    • Django maneja la estructura y cambios de la BD
    • Ayuda a enfocarse en el código de Python
    • Permite usar otras bases de datos
%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'basis'}}}%%
flowchart
subgraph "ORM"
DM["Define models"]
GMF["Generate
migration files"]
SD["Setup database"]
STD["Store data"]
DM -.-> GMF -.-> SD -.-> STD
end

Models

  • Cada modelo mapea a una tabla
  • Los modelos contienen
    • Nombre
    • Campos
    • Otra metadata
    • Lógica en Python personalizada

Modelo ejemplo

class Ingredient(models.Model):
    """Ingredient for recipies."""
    name = models.CharField(max_length=255)
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )

Creación de las migraciones

  • Asegura que la app esta activa en settings.py
  • Se utiliza el CLI de Django python manage.py makemigrations
  • Aplicar migraciones python manage.py makemigrations
  • Correr despues de esperar por la base de datos

Actualización de Docker Compose y CI/CD

    ...
    command: >
      sh -c "python manage.py wait_for_db &&
             python manage.py migrate &&
             python manage.py runserver 0.0.0.0:8000"      
    ...

docker compose down

[+] Running 3/3
 ✔ Container django_rest_api_udemy-app-1  Removed  0.0s
 ✔ Container django_rest_api_udemy-db-1   Removed  0.1s
 ✔ Network django_rest_api_udemy_default  Removed  0.1s

docker compose up

Corriendo wait_for_db antes de los tests en GitHub Actions

checks.yml

    ...
      - name: Test
        run: >
        docker compose run --rm app sh -c "python manage.py wait_for_db &&
        python manage.py test"        
    ...

User Model

Autenticación Django

  • Sistema de autenticación built-in
  • Framework para características básicas
    • Registro
    • Login
    • Autorización
  • Se integran con el panel de administración de Django

Django user model

  • Es la fundación del sistema de autenticación de Django
  • Django incorpora por defecto un modelo de usuario
    • Utiliza un nombre de usuario en vez de un email
    • No es facil de personalizar
  • Creación de modelo de usuario personalizado para nuevos proyectos
    • Permite el uso de email en vz de nombre de usuario
    • Asegura compatibilidad del proyecto con posibles cambios del modelo usuario en versiones futuras

Creación del modelo

  • Basado en la clase AbstractBaseUser y PermissionsMixin
  • Creación de administrador personalizado
  • Se establece AUTH_USER_MODEL en settings.py para utlizar este modelo
  • Creación y ejecución de las migraciones

AbstractBaseUser

  • Proporciona las características de autenticación
  • No incluye campos

PermissionsMixin

  • Soporte para el sistema de permisos de Django
  • Incuye todos los campos y métodos necesarios

Problemas comunes

  • Correr migraciones antes de crear el modelo personalizado
    • Crear el modelo personalizado primero
  • Tipeo
  • Indentación

User Model personalizado

Campos de usuario

  • email EmailField
  • name CharField
  • is_active BooleanField
  • is_staff BooleanField

User Model administrador

  • Usado para administar objetos
  • Lógica personalizada para crear objetos
    • Hash passwords
  • Metodos para el CLI de Django
    • Create superuser

BaseUserManager

  • Clase base para administrar usuarios
  • Métodos útliles de ayuda
    • normalize_email para almacenar emails de forma consistente
  • Métodos a definir
    • create_user llamado al crear usuarios
    • create_superuser usado por el CLI para crear un super usuario (admin)

Agregando Unitetst para el modelo usuario personalizado

test_models.py

from django.test import TestCase
from django.contrib.auth import get_user_model

class ModelTests(TestCase):

    def test_create_user_with_email_sucessfull(self):
        email = 'test@example.com'
        password = 'testpass123'
        user = get_user_model().objects.create_user(
            email=email,
            password=password,
        )
        self.assertEqual(user.email, email)
        self.assertTrue(user.check_password(password))

Agregar usuario personalizado al proyecto

models.py

from django.db import models
from django.contrib.auth.models import (
    AbstractBaseUser,
    BaseUserManager,
    PermissionsMixin,
)


class UserManager(BaseUserManager):
    """Manager for users."""

    def create_user(self, email, password=None, **extra_fields):
        """Create, save and return a new user."""
        user = self.model(email=email, **extra_fields)
        user.set_password(password)
        user.save(using=self._db)

        return user


class User(AbstractBaseUser, PermissionsMixin):
    """User in the system."""
    email = models.EmailField(max_length=255, unique=True)
    name = models.CharField(max_length=255)
    is_active = models.BooleanField(default=True)
    is_staff = models.BooleanField(default=False)
    # Asignar el UserManager a esta clase User
    objects = UserManager()

    USERNAME_FIELD = 'email'

Actualizar settings.py para que Django utilize este modelo de autenticación agregando al final del archivo lo sgte.

...

AUTH_USER_MODEL = 'core.User'

Crear migraciones

docker compose run --rm app sh -c "python manage.py makemigrations"

[+] Creating 1/0
 ✔ Container recipes_api_django-db-1  Running  0.0s
Migrations for 'core':
  core/migrations/0001_initial.py
    - Create model User

Codigo autogenerado 0001_initial.py para app core

Aplicar migraciones

docker compose run --rm app sh -c "python manage.py wait_for_db && python manage.py migrate"

[+] Creating 1/0
 ✔ Container recipes_api_django-db-1  Running  0.0s
Waiting for database...
Database available!
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, core, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0001_initial... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying core.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying sessions.0001_initial... OK

En caso arrojar error por haber aplicado alguna migración previa se puede correr docker rm <db_volume>, si "esta en uso" primero correr docker compose down.

Los nombres se pueden ver con docker volume ls

Al correr los tests nuevamente docker compose run --rm app sh -c "python manage.py test"

[+] Creating 1/0
 ✔ Container recipes_api_django-db-1  Running  0.0s
Found 5 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...Waiting for database...
Database unavailable, waiting 1 second...
Database unavailable, waiting 1 second...
Database unavailable, waiting 1 second...
Database unavailable, waiting 1 second...
Database unavailable, waiting 1 second...
Database available!
.Waiting for database...
Database available!
.
----------------------------------------------------------------------
Ran 5 tests in 0.675s

OK
Destroying test database for alias 'default'...

Normalización de direcciones de email

Test normalize email addresses

test_models.py

    def test_new_user_email_normalized(self):
        """Test email is normalized for new users."""
        sample_emails = [
            ['test1@EXAMPLE.com', 'test1@example.com'],
            ['test2@Example.com', 'test2@example.com'],
            ['TEST3@EXAMPLE.COM', 'TEST3@example.com'],
            ['test4@example.COM', 'test4@example.com'],
        ]
        for email, expected in sample_emails:
            user = get_user_model().objects.create_user(email, 'sample123')
            self.assertEqual(user.email, expected)

Modificar el ceate_user de app/core/models.py para utilizar el método normalize_email que provee la clase BaseUserManager

- user = self.model(email=email, **extra_fields)
+ user = self.model(email=self.normalize_email(email), **extra_fields)

Requerir email

Test requerir email

test_models.py

    def test_new_user_withouth_email_raises_error(self):
        """Test that creating a user withouth an email raises a ValueError."""
        with self.assertRaises(ValueError):
        get_user_model().objects.create_user('', 'test123')

Modificar el ceate_user de app/core/models.py y levantar excepción ValueError si usuario no ingresa un email

    def create_user(self, email, password=None, **extra_fields):
        """Create, save and return a new user."""
        if not email:
            raise ValueError('User must have an email address.')
        user = self.model(email=self.normalize_email(email), **extra_fields)
        user.set_password(password)
        user.save(using=self._db)

Funcionalidad super usuario

Test creación de super usuario

test_models.py

    def test_create_superuser(self):
        """Test creating a superuser."""
        user = get_user_model().objects.create_superuser(
            'test@example.com',
            'test123',
        )
        self.assertTrue(user.is_superuser)
        self.assertTrue(user.is_staff)

Creación del método create_superuser para la clase UserManager en app/core/models.py

    def create_superuser(self, email, password):
        """Create and return a new superuser."""
        user = self.create_user(email, password)
        user.is_staff = True
        user.is_superuser = True
        user.save(using=self._db)
                      
        return user 

Probando el modelo de usuario

Correr docker compose up y en otra terminal docker compose run --rm app sh -c "python manage.py createsuperuser"

[+] Creating 1/0
 ✔ Container recipes_api_django-db-1  Running   0.0s
Email: admin@example.com
Password: 
Password (again): 
Superuser created successfully

Django Admin

Es la interfáz gráfica para los modelos creados en el proyecto, permite la administración basica C.R.U.D. Requiere muy poco cóidgo para ser usado

img

Se activa por modelo, en admin.py

Personalización del administrador

Se crea una clase basada en ModelAdmin o UserAdmin donde se sobrescribe o establecen variables de clase

ejemplo

class UserAdmin(BaseUserAdmin):
    """Define de admin pages for users."""
    ordering = ['id']
    list_display = ['emial', 'name']
    fieldsets = (
        (None, {'fields': ('email', 'password')}),
    )
    readonly_files = ['last_login']
    add_fieldsets = (
        (None, {
            'classes': ('wide',),
            'fields': (
                'email',
            ),
        })
    )
  • ordening img
  • list_display img
  • fieldsets img
  • readonly_fields img
  • add_fieldsets img

Creando test para el administrador

app/core/tests/test_models.py

class AdminSiteTests(TestCase):
    """Tests for Django admin."""

    def setUp(self):
        """Create user and client."""
        self.client = Client()
        self.admin_user = get_user_model().objects.create_superuser(
            email='admin@example.com',
            password='testpass123',
        )
        self.client.force_login(self.admin_user)
        self.user = get_user_model().objects.create_user(
            email='user@example.com',
            password='testpass123',
            name='Test User'
        )

    def test_users_list(self):
        """Test that users are listed on page."""
        url = reverse('admin:core_user_changelist')
        res = self.client.get(url)

        self.assertContains(res, self.user.name)
        self.assertContains(res, self.user.email)

Correr test docker compose run --rm app sh -c "python manage.py test"

Activar admin para core app

En admin.py

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from core import models

class UserAdmin(BaseUserAdmin):
    """Define the admin pages for users."""
    ordering = ['id']
    list_display = ['email', 'name']

admin.site.register(models.User, UserAdmin)
  • Admin img
  • CORE Section img
  • CORE Usuarios, requiere modificar pues espera campos que el modelo no tiene img

Modificar admin para que use los campos de usuario personalizado

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.utils.translation import gettext_lazy as _
from core import models

class UserAdmin(BaseUserAdmin):
    """Define the admin pages for users."""
    ordering = ['id']
    list_display = ['email', 'name']
    filedsets = (
        (None, {'fields': ('email', 'password')}),
        (
            _('Permissions'),
            {
                'fields': (
                    'is_active',
                    'is_staff',
                    'is_superuser',
                )
            }
        ),
        (_('Important dates', {'fields': ('last_login',)})),
    )
    readonly_fields = ['last_login']

admin.site.register(models.User, UserAdmin)
  • Administrador de usuario personalizado img

  • Cambio de lenguaje y timezone, y traducción gettext_lazy img

  • Requiere modificar pues espera campos que el modelo no tiene

  • img

Test página de creación de usuario

    def test_create_user_page(self):
        """Test the create user page works."""
        url = reverse('admin:core_user_add')
        res = self.client.get(url)

        self.assertEqual(res.status_code, 200)

Actualizar clase UserAdmin para que use los campos personalizados

app/core/admin.py

class UserAdmin(BaseUserAdmin):
    ...
    add_fieldsets = ( 
        (None, {
            'classes': ('wide',),
            'fields': (
                'email',
                'password1',
                'password2',
                'name',
                'is_active',
                'is_staff',
                'is_superuser',
            )
        }),
    )
    ...
  • Los test pasan

  • Página para crear usuarios img

  • Panel de usuarios del administrador img

Documentación de la API

Es necesario tener acceso a una buena documentación para que los desarrolladores puedan saber como usarla. Se documenta todo lo que sea necesario para usar la API

  • Endopoints disponibles (paths)
  • Métodos soportados GET, POST, PUT, PATCH, DELETE...
  • Formateo de payloads (inputs). Parametros, Post en formato JSON
  • Formateo de respuestas (outputs). Respuesta en formato JSON
  • Proceso Autenticación

Opiones de documentación

  • Manual
    • Documento de texto
    • Markdown
  • Automatizada
    • Usa la metadata del código (comments)
    • Genera páginas de documentación

Autodocs de DRF

  • Documentación Autogenerada (3rd party library)
    • drf-spectacular
  • Genera el "schema"
  • Interfaz web navegable
    • Test requests
    • Maneja la autenticación

Como funciona

  1. Creación del archivo schema
  2. Pasa el schema al GUI

Open API Schema

  • Estandar para describir APIs
  • Popular en la industria
  • Soportada por la mayoría de herramientas de documentación de API

Ejemplo Schema

fragmento

/api/recipe/ingredients/:
  get:
    oprationId: recipe_ingredients_list
    description: Manage ingredients in the database.
    parameteres:
    - in: query
      name: assigned_only
      schema:
        type: integer
        enum:
        - 0
        - 1
      description: Filter by item assigned to recipies
    tags:
    - recipe
    security:
    - tokenAuth: []
    responses:
      '200':
        content:
          application/json
            schema:
              type: array
              items:
                $ref: '#/components/schemas/Ingredient'
        description: ''
        ...

Implementación DRF

Se agrega dependencia en drf-spectacular>=0.16 en requirements.txt

Instalar app, en settings.py

INSTALLED_APPS = [
    ...
    'core',
    'rest_framework',
    'drf_spectacular',
]

...
REST_FRAMEWORK = {
    'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
}

Activar las URLS

from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
from django.contrib import admin
from django.urls import path

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/schema/', SpectacularAPIView.as_view(), name='api-schema'),
    path(
        'api/docs',
        SpectacularSwaggerView.as_view(url_name='api-schema'),
        name='api-docs'),
]
  • docker compose run
  • 127.0.0.1:8000/api/docs

User API

Diseño

  • Registro de usario
  • Creación de token de autenticación
  • Consultar y actualizar perfil

Endpoins

Endpoint Method Descripción
user/create POST Registrar nuevo usuario
user/token POST Crea un nuevo token
user/me/ PUT/PATCH Actualizar perfíl

Creación user app

docker compose run --rm app sh -c "python manage.py startapp user"

[+] Creating 1/0
 ✔ Container recipes_api_django-db-1  Created  0.0s
[+] Running 1/1
 ✔ Container recipes_api_django-db-1  Started  0.2s

Activar user app en settings.py

Test User API

app/user/tests/test_user_api.py

CREATE_USER_URL = reverse('user:create')

def create_user(**params):
    """Create and return a new user."""
    return get_user_model().objects.crate_user(**params)


class PublicUserApiTest(TestCase):
    """Test the public features of the user API."""

    def setUp(self):
        self.client = APIClient()

    def test_create_user_success(self):
        """Tests creating a user is successful."""
        payload = {
            'email': 'test@example.com',
            'password':'testpass123',
            'name': 'TestName',
        }
        res = self.client.post(CREATE_USER_URL, payload)

        self.assertEqual(res.status_code, status.HTTP_201_CREATED)
        user = get_user_model().objects.get(email=payload['email'])
        self.assertTrue(user.check_password(payload['password']))
        self.assertNotIn('password', res.data)

    def test_user_with_email_exists_error(self):
        """Test error returned if user with email exists."""
        payload = {
            'email': 'test@example.com',
            'password':'testpass123',
            'name': 'Test Name',
        }
        create_user(**payload)
        res = self.client.post(CREATE_USER_URL, payload)

        self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)

    def test_password_too_short_error(self):
        """Test an error is returned if password less than 5 chars."""
        payload = {
            'email': 'test@example.com',
            'password':'pw',
            'name': 'Test Name',
        }
        res = self.client.post(CREATE_USER_URL, payload)

        self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
        user_exists = get_user_model().objects.filter(
            email=payload['email']
        ).exists()
        self.assertFalse(user_exists)

Creando funcionalidad de User API

  • serializers.py

    from django.contrib.auth import get_user_model
    from rest_framework import serializers
    
    class UserSerializer(serializers.ModelSerializer):
        """Seralizer for the model object."""
    
        class Meta:
            model = get_user_model()
            fields = ['email', 'password', 'name']
            extra_kwargs = {'password': {'write_only': True, 'min_length': 5}}
    
        def create(self, validated_data):
           """Create and return a user with encrypted password"""
           return get_user_model().objects.create_user(**validated_data)
    
  • views.py

    from rest_framework import generics
    from user.serializers import UserSerializer
    
    class CreateUserView(generics.CreateAPIView):
        """Create a new user in the system."""
        serializer_class = UserSerializer
    
  • urls.py

    from django.urls import path
    from user import views
    
    app_name='user'
    urlpatterns = [
        path('create/', views.CreateUserView.as_view(), name='create')
    ]
    
  • app/urls.py

    ...
    from django.urls import include, path
    
    urlpatterns = [
        ...
        path('api/user/', include('user.urls')),
    ]
    

Autenticación

Tipo de autenticación Descripción
Básica Envía usuario y password en cada request
Token Usa un token en el encabezado HTTP
JSON Web Token (JWT) Usa un token de acceso
Sesión Usa cookies

En esta app se utilza Token por:

  • Balance entre simplicidad y seguridad
  • Soporte por defecto por DRF
  • Bién soportada por la mayoria de clientes
%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'basis'}}}%%
flowchart
subgraph " "

CT["<b>Create token</b>
(Post username/password)"]
STOC["<b>Store token on client</b>"]
ITIH["<b>Include token in HTTP headers</b>"]
CT .-> STOC .-> ITIH
end

Pros del uso de Token

  • Soporte por defecto
  • Simple de usar
  • Soportada por todos los clientes
  • Evita enviar datos de usuario/password en cada request

Cons del uso de Token

  • El token debe ser seguro
  • Requiere hacer peticiones a la base de datos

Login out

  • Sucede en el lado del cliente
  • Borra el token

Test token API

Agregar tests en app/user/tests/test_user_api.py

...
TOKEN_URL = reverse('user:token')

...

class PublicUserApiTest(TestCase):
    """Test the public features of the user API."""

    ...

    def test_create_token_for_user(self):
        """Test generate token for valid credentials."""
        user_details = {
            'name': 'Test Name',
            'email': 'test@example.com',
            'password':'test-user-password123',
        }
        create_user(**user_details)

        payload = {
            'email': user_details['email'],
            'password': user_details['password'],
        }
        res = self.client.post(TOKEN_URL, payload)

        self.assertIn('token', res.data)
        self.assertEqual(res.status_code, status.HTTP_200_OK)

    def test_create_token_bad_credentials(self):
        """Test returns error if credentials invalid."""
        create_user(email='test@example.com', password='goodpass')

        payload = {'email': 'test@example.com' ,'password': 'badpass'}
        res = self.client.post(TOKEN_URL, payload)

        self.assertNotIn('token', res.data)
        self.assertNotIn(res.status_code, status.HTTP_400_BAD_REQUEST)
    
    def test_create_token_blank_password(self):
        """Test posting a blank password returns an error."""
        payload = {'email': 'test@example.com' , 'password': ''}
        res = self.client.post(TOKEN_URL, payload)

        self.assertNotIn('token', res.data)
        self.assertNotIn(res.status_code, status.HTTP_400_BAD_REQUEST)

Implementar Token API

  • Añadir app rest_framework.authtoken en settings.py

    INSTALLED_APPS = [              
      ...
      'rest_framework',
      'rest_framework.authtoken', # <---
      'drf_spectacular',
      'user',
    ]
    

Creación del serlizador para token api

  • user/serializer.py

    ...
    
    class AuthTokenSerializer(serializers.Serializer):
        """Serializer for the user auth token."""
        email = serializer.EmailField()
        password = serializer.CharField(
            style={'input_type': 'password'},
            trim_whitespace=False,
        )
    
        def validate(self, attrs):
            """Validate and authenticate the user."""
            email = attrs.get('email')
            password = attrs.get('password')
            user = authenticate(
                request=self.context.get('request'),
                username=email,
                password=password,
            )
            if not user:
                msg = _('Unable to authenticate with provided credentials.')
                raise serializers.ValidationError(msg, code='authorization')
    
            attrs['user'] = user
            return attrs
    
  • vista user/views.py

    ...
    class CreateTokenView(ObtainAuthToken):
        """Create a new auth token for user."""
        serializer_class = AuthTokenSerializer
        renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES
    
  • urls user/urls.py

    urlpatterns = [
        ...
        path('token/', views.CreateTokenView.as_view(), name='token'),
    ]
    

Test administrar usuario

  • test_user_api.py

    ...
    ME_URL = reverse('user:me')
    
    ...
    
    class PrivateUserApiTests(TestCase):
        """Test API requests that require authentication."""
    
        def setUp(self):
            self.user = create_user(
                email='test@example.com',
                password='testpass123',
                name='Test Name',
            )
            self.client = APIClient()
            self.client.force_authenticate(user=self.user)
    
        def test_retrive_profile_success(self):
            """Test retrieving profile for logged in user."""
            res = self.client.get(ME_URL)
    
            self.assertEqual(res.status_code, status.HTTP_200_OK)
            self.assertEqual(
                res.data, {
                    'name': self.user.name,
                    'email': self.user.email,
            })
    
        def test_post_me_not_allowed(self):
            """Test POST is not allowed for the 'me' endpoint."""
            res = self.client.post(ME_URL, {})
    
            self.assertAlmostEqual(
                res.status_code,
                status.HTTP_405_METHOD_NOT_ALLOWED
            )
    
        def test_update_user_profile(self):
            """Test updating the user profile for the autenticated user."""
            payload = { 'name': 'Updated Name', 'password': 'newpassword123' }
    
            res = self.client.patch(ME_URL, payload)
    
            self.user.refresh_from_db()
            self.assertEqual(self.user.name, payload['name'])
            self.assertTrue(self.user.check_password(payload['password']))
            self.assertEqual(res.status_code, status.HTTP_200_OK)
    

Implementación API actualizar usuario

me endpoint

  • creación (sobrescritura) del método update serializer.py

    ...
    
    class UserSerializer(serializers.ModelSerializer):
    
        ...
    
        def create(self, validated_data):
            """Create and return a user with encrypted password"""
            return get_user_model().objects.create_user(**validated_data)
    
        def update(self, instance, validated_data):
            """Update and return user."""
            password = validated_data.pop('password', None)
            user = super().update(instance, validated_data)
    
            if password:
                user.set_password(password)
                user.save()
    
            return user
    
    ...
    
  • vistas views.py

    from rest_framework import generics, authentication, permissions
    ...
    
    class ManageUserView(generics.RetrieveUpdateAPIView):
        """Manage the autenticated user."""
        serializer_class = UserSerializer
        authentication_classes = [authentication.TokenAuthentication]
        permission_classes = [permissions.IsAuthenticated]
    
        def get_object(self):
            """Retrieve and return the authenticated user."""
            return self.request.user
    
  • urls urls.py

    urlpatterns = [
        ...
        path('me/', views.ManageUserView.as_view(), name='me'),
    ]
    

Pruebas en navegador

Ruta localhost:8000/api/docs

img