# 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](../app/core/tests/tests_models.py) ```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](../app/core/models.py) ```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. ```py ... AUTH_USER_MODEL = 'core.User' ``` Crear migraciones `docker compose run --rm app sh -c "python manage.py makemigrations"` ```sh [+] 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](../app/core/migrations/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"` ```sh [+] 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 `, 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"` ```sh [+] 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](../app/core/tests/test_models.py) ```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](../app/core/models.py) para utilizar el método `normalize_email` que provee la clase **BaseUserManager** ```diff - 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](../app/core/tests/test_models.py) ```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](../app/core/models.py) y levantar excepción `ValueError` si usuario no ingresa un email ```py 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](../app/core/tests/test_models.py) ```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](../app/core/models.py) ```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"` ```sh [+] 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](./imgs_readme/django_admin_00.png) Se activa por modelo, en [`admin.py`](../app/core/admin.py) ### Personalización del administrador Se crea una clase basada en `ModelAdmin` o `UserAdmin` donde se sobrescribe o establecen variables de clase ejemplo ```py 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](./imgs_readme/django_admin_01.png) - `list_display` ![img](./imgs_readme/django_admin_02.png) - `fieldsets` ![img](./imgs_readme/django_admin_03.png) - `readonly_fields` ![img](./imgs_readme/django_admin_04.png) - `add_fieldsets` ![img](./imgs_readme/django_admin_05.png) ### Creando test para el administrador [`app/core/tests/test_models.py`](../app/core/tests/test_admin.py) ```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) ``` - Django docs [reversing admin urls](https://docs.djangoproject.com/en/4.2/ref/contrib/admin/#reversing-admin-urls) - Django docs [testing tools](https://docs.djangoproject.com/en/4.2/topics/testing/tools/#overview-and-a-quick-example) Correr test `docker compose run --rm app sh -c "python manage.py test"` ### Activar admin para core app En [`admin.py`](../app/core/admin.py) ```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](./imgs_readme/django_admin_06.png) - **CORE** Section ![img](./imgs_readme/django_admin_07.png) - **CORE** Usuarios, requiere modificar pues espera campos que el modelo no tiene ![img](./imgs_readme/django_admin_08.png) ### Modificar admin para que use los campos de usuario personalizado ```py 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](./imgs_readme/django_admin_09.png) - Cambio de lenguaje y timezone, y traducción `gettext_lazy` ![img](./imgs_readme/django_admin_10.png) - Requiere modificar pues espera campos que el modelo no tiene - ![img](./imgs_readme/django_admin_11.png) ### Test página de creación de usuario ```py 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](../app/core/admin.py) ```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](./imgs_readme/django_admin_12.png) - Panel de usuarios del administrador ![img](./imgs_readme/django_admin_13.png) ## 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 ```yml /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` ```py INSTALLED_APPS = [ ... 'core', 'rest_framework', 'drf_spectacular', ] ... REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', } ``` ### Activar las URLS ```py 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"` ```sh [+] 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`](../app/user/tests/test_user_api.py) ```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](../app/user/serializers.py) ```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](../app/user/views.py) ```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](../app/user/urls.py) ```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](../app/app/urls.py) ```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 ```mermaid %%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'basis'}}}%% flowchart subgraph " " CT["Create token (Post username/password)"] STOC["Store token on client"] ITIH["Include token in HTTP headers"] 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`](../app/user/tests/test_user_api.py) ```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](../app/app/settings.py) ```py INSTALLED_APPS = [ ... 'rest_framework', 'rest_framework.authtoken', # <--- 'drf_spectacular', 'user', ] ``` ### Creación del serlizador para token api - [user/serializer.py](../app/user/serializers.py) ```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](../app/user/views.py) ```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](../app/user/urls.py) ```py urlpatterns = [ ... path('token/', views.CreateTokenView.as_view(), name='token'), ] ``` ### Test administrar usuario - [test_user_api.py](../app/user/tests/test_user_api.py) ```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](../app/user/serializers.py) ```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](../app/user/views.py) ```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](../app/user/urls.py) ```py urlpatterns = [ ... path('me/', views.ManageUserView.as_view(), name='me'), ] ``` ## Pruebas en navegador Ruta `localhost:8000/api/docs` ![img](./imgs_readme/api_swagger_00.png) ---- - [Inicio](../README.md) - [**User API**](./01_user_api.md) - [Recipe API](./02_recipe_api.md) - [Tag API](./03_tag_api.md) - [Ingredient API](./04_ingredient_api.md) - [Image API](./05_image_api.md) - [Filters](./06_filters.md)