1053 lines
28 KiB
Markdown
1053 lines
28 KiB
Markdown
# 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 <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"`
|
|
|
|
```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
|
|
|
|

|
|
|
|
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` 
|
|
- `list_display` 
|
|
- `fieldsets` 
|
|
- `readonly_fields` 
|
|
- `add_fieldsets` 
|
|
|
|
### 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**
|
|

|
|
- **CORE** Section
|
|

|
|
- **CORE** Usuarios, requiere modificar pues espera campos que el modelo no tiene
|
|

|
|
|
|
### 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
|
|

|
|
|
|
- Cambio de lenguaje y timezone, y traducción `gettext_lazy`
|
|

|
|
|
|
- Requiere modificar pues espera campos que el modelo no tiene
|
|
- 
|
|
|
|
### 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 
|
|
- Panel de usuarios del administrador 
|
|
|
|
## 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
|
|
|
|
<style>div.mermaid{text-align: center;}</style>
|
|
|
|
```mermaid
|
|
%%{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`](./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`
|
|
|
|

|
|
|
|
----
|
|
|
|
- [Inicio](./README.md)
|
|
- [**User API**](./01_user_api.md)
|
|
- [Recipe API](./02_recipe_api)
|
|
- [Tag API](./03_tag_api.md)
|
|
- [Ingredient API](./04_ingredient_api.md)
|
|
- [Image API](./05_image_api.md)
|
|
- [Filters](./06_filters.md)
|