recipes_api/docs/01_user_api.md
2023-10-12 14:14:02 -03:00

28 KiB

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)

Implementación 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