diff --git a/01_user_api.md b/01_user_api.md new file mode 100644 index 0000000..6661efc --- /dev/null +++ b/01_user_api.md @@ -0,0 +1,1052 @@ +# 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) +- [Tag API](./03_tag_api.md) +- [Ingredient API](./04_ingredient_api.md) +- [Image API](./05_image_api.md) +- [Filters](./06_filters.md) diff --git a/README2.md b/02_recipe_api.md similarity index 98% rename from README2.md rename to 02_recipe_api.md index 450faa1..cbabe2f 100644 --- a/README2.md +++ b/02_recipe_api.md @@ -539,8 +539,10 @@ URL `localhost:8000/api/docs/` ---- -- 1ra parte -> [API Recetas](./README.md) -- 3ra parte -> [Tags](./README3.md) -- 4ta parte -> [Ingredientes](./README4.md) -- 5ta parte -> [Imagenes](./README5.md) -- 6ta parte -> [filtrado](./README6.md) +- [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) diff --git a/README3.md b/03_tag_api.md similarity index 98% rename from README3.md rename to 03_tag_api.md index ad2336b..effb009 100644 --- a/README3.md +++ b/03_tag_api.md @@ -517,8 +517,10 @@ URL `localhost:8000/api/docs` ---- -- 1ra parte -> [API Recetas](./README.md) -- 2da parte -> [Recetas](./README2.md) -- 4ta parte -> [Ingredientes](./README4.md) -- 5ta parte -> [Imagenes](./README5.md) -- 6ta parte -> [Filtrado](./README6.md) +- [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) diff --git a/README4.md b/04_ingredient_api.md similarity index 98% rename from README4.md rename to 04_ingredient_api.md index 7deeda7..54dfab5 100644 --- a/README4.md +++ b/04_ingredient_api.md @@ -509,8 +509,10 @@ herencia ---- -- 1ra parte -> [API Recetas](./README.md) -- 2da parte -> [Recetas](./README2.md) -- 3ra parte -> [Tags](./README3.md) -- 5ta parte -> [Imagenes](./README5.md) -- 6ta parte -> [Filtrado](./README6.md) +- [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) diff --git a/README5.md b/05_image_api.md similarity index 97% rename from README5.md rename to 05_image_api.md index b50896a..7343496 100644 --- a/README5.md +++ b/05_image_api.md @@ -440,8 +440,10 @@ Levantar aplicación `docker compose up` y visitar `locahost:8000/api/docs` ---- -- 1ra parte -> [API Recetas](./README.md) -- 2da parte -> [Recetas](./README2.md) -- 3ra parte -> [Tags](./README3.md) -- 4ta parte -> [Ingredientes](./README4.md) -- 6ta parte -> [Filtrado](./README6.md) +- [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) diff --git a/06_filters.md b/06_filters.md new file mode 100644 index 0000000..5cf0973 --- /dev/null +++ b/06_filters.md @@ -0,0 +1,302 @@ +# Filters + +## Diseño + +- Filtro de recetas por ingredientes/tags +- Filtro de ingredientes/tags asignados a recetas, faciltando una lista para +elegir +- Definición de parametros **OpenAPI**, actualizar documentación + +### Requests de ejemplo + +- Filtrar recetas por **tags(s)** + - `GET` `/api/recipe/recipes/?tags=1,2,3` +- Filtrar recetas por **ingrediente(s)** + - `GET` `/api/recipe/recipes/?ingredients=1,2,3` +- Filtrar tags por **receta asignada** + - `GET` `/api/recipe/tags/?assigned_only=1` +- Filtrar ingredientes por **receta asignada** + - `GET` `/api/recipe/ingredients/?assigned_only=1` + +### OpenAPI Schema + +- *Schema* auto generada +- Configuracion manual de cierta documentación (custom query parmas filtering) +- Uso del decorador `extend_schema_view` de **DRF_Spectacular**. Ej. + + ```py + @extend_schema_view( + list=extend_schema( + parameters=[ + OpenApiParameter( + 'tags', + OpenApiTypes.STR, + description='Coma separated list of tags IDs to filter', + ), + OpenApiParameter( + 'ingredients', + OpenApiTypes.STR, + description='Coma separated list of ingredients IDs to filter', + ), + ] + ) + ) + class RecipeViewSet(viewsets.ModelViewSet): + """View for manage recipe APIs.""" + ... + ``` + +## Test filtros + +[`test_recipe_api.py`](./app/recipe/tests/test_recipe_api.py) + +```py +class PrivateRecipeApiTests(TestCase): + """Test authenticated API requests.""" + + ... + + def test_fitler_by_tags(self): + """Test filtering recipes by tags.""" + r1 = create_recipe(user=self.user, title='Sopa de Verduras') + r2 = create_recipe(user=self.user, title='Arroz con Huevo') + tag1 = Tag.objects.create(user=self.user, name='Vergan') + tag2 = Tag.objects.create(user=self.user, name='Vegetariana') + r1.tags.add(tag1) + r2.tags.add(tag2) + r3 = create_recipe(user=self.user, title='Pure con Prietas') + + params = {'tags': f'{tag1.id}, {tag2.id}'} + res = self.client.get(RECIPES_URL, params) + + s1 = RecipeSerializer(r1) + s2 = RecipeSerializer(r2) + s3 = RecipeSerializer(r3) + self.assertIn(s1.data, res.data) + self.assertIn(s2.data, res.data) + self.assertNotIn(s3.data, res.data) + + def test_filter_by_ingredients(self): + """Test filtering recipes by ingredients.""" + r1 = create_recipe(user=self.user, title='Porotos con rienda') + r2 = create_recipe(user=self.user, title='Pollo al jugo') + in1 = Ingredient.objects.create(user=self.user, name='Porotos') + in2 = Ingredient.objects.create(user=self.user, name='Pollo') + r1.ingredients.add(in1) + r2.ingredients.add(in2) + r3 = create_recipe(user=self.user, title='Lentejas con arroz') + + params = {'ingredients': f'{in1.id}, {in2.id}'} + res = self.client.get(RECIPES_URL, params) + + s1 = RecipeSerializer(r1) + s2 = RecipeSerializer(r2) + s3 = RecipeSerializer(r3) + self.assertIn(s1.data, res.data) + self.assertIn(s2.data, res.data) + self.assertNotIn(s3.data, res.data) +``` + +## Implementación filtros + +[`recipe/views.py`](./app/recipe/views.py) + +```py +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import ( + extend_schema_view, + extend_schema, + OpenApiParameter, +) +... + +@extend_schema_view( + list=extend_schema( + parameters=[ + OpenApiParameter( + 'tags', + OpenApiTypes.STR, + description='Lista separada por coma de tags IDs a filtrar' + ), + OpenApiParameter( + 'ingredients', + OpenApiTypes.STR, + description='Lista separada por coma de ingredientes IDs a \ + filtrar' + ), + ] + ) +) +class RecipeViewSet(viewsets.ModelViewSet): + ... + + def _params_to_ints(self, qs): + """Convert a list of strings to integers.""" + return [int(str_id) for str_id in qs.split(',')] + + def get_queryset(self): + """Retrieve recipes for authenticated user.""" + tags = self.request.query_params.get('tags') + ingredients = self.request.query_params.get('ingredients') + queryset = self.queryset + if tags: + tag_ids = self._params_to_ints(tags) + queryset = queryset.filter(tags__id__in=tag_ids) + if ingredients: + ingredients_ids = self._params_to_ints(ingredients) + queryset = queryset.filter(ingredients__id__in=ingredients_ids) + + return queryset.filter( + user=self.request.user + ).order_by('-id').distinct() +``` + +## Test para filtrar por tags e ingredientes + +[`test_ingredients_api.py`](./app/recipe/tests/test_ingredients_api.py) + +```py +from decimal import Decimal +from core.model import Recipe +... + + def test_filter_ingredients_assigned_to_recipes(self): + """Test listing ingredients by those assigned to recipes.""" + in1 = Ingredient.objects.create(user=self.user, name='Manzana') + in2 = Ingredient.objects.create(user=self.user, name='Pavo') + recipe = Recipe.objects.create( + title='Pure de Manzana', + time_minutes=5, + price=Decimal('4.5'), + user=self.user, + ) + recipe.ingredients.add(in1) + + res = self.client.get(INGREDIENTS_URL, {'assigned_only': 1}) + + s1 = IngredientSerializer(in1) + s2 = IngredientSerializer(in2) + self.assertIn(s1.data, res.data) + self.assertNotIn(s2.data, res.data) + + def test_filtered_ingredients_unique(self): + """Test filtered ingredients returns a unique list.""" + ing = Ingredient.objects.create(user=self.user, name='Huevo') + Ingredient.objects.create(user=self.user, name='Lentejas') + recipe1 = Recipe.objects.create( + title='Huevos a la copa', + time_minutes=4, + price=Decimal('1.0'), + user=self.user, + ) + recipe2 = Recipe.objects.create( + title='Huevos a cocidos', + time_minutes=5, + price=Decimal('1.0'), + user=self.user, + ) + recipe1.ingredients.add(ing) + recipe2.ingredients.add(ing) + + res = self.client.get(INGREDIENTS_URL, {'assigned_only': 1}) + + self.assertEqual(len(res.data), 1) +``` + +[`test_tags_api.py`](./app/recipe/tests/test_tags_api.py) + +```py +from decimal import Decimal +from core.model import Recipe +... + + def test_filter_tags_assigned_to_recipes(self): + """Test listing tags to those assigned to recipes.""" + tag1 = Tag.objects.create(user=self.user, name='Desayuno') + tag2 = Tag.objects.create(user=self.user, name='Almuerzo') + recipe = Recipe.objects.create( + title='Huevos Fritos', + time_minutes='5', + price=Decimal('2.5'), + user=self.user, + ) + recipe.tags.add(tag1) + + res = self.client.get(TAGS_URL, {'assigned_only': 1}) + + s1 = TagSerializer(tag1) + s2 = TagSerializer(tag2) + self.assertIn(s1.data, res.data) + self.assertNotIn(s2.data, res.data) + + def test_filter_tags_unique(self): + """Test filtered tags retunrs a unique list.""" + tag = Tag.objects.create(user=self.user, name='Desayuno') + Tag.objects.create(user=self.user, name='Almuerzo') + recipe1 = Recipe.objects.create( + title='Panqueques', + time_minutes='25', + price=Decimal('5.0'), + user=self.user, + ) + recipe2 = Recipe.objects.create( + title='Avena con fruta', + time_minutes='15', + price=Decimal('7.0'), + user=self.user, + ) + recipe1.tags.add(tag) + recipe2.tags.add(tag) + + res = self.client.get(TAGS_URL, {'assigned_only': 1}) + + self.assertEqual(len(res.data), 1) +``` + +## Implementación filtrado por tags e ingredientes + +[`recipe/views.py`](./app/recipe/views.py) + +```py +@extend_schema_view( + list=extend_schema( + parameters=[ + OpenApiParameter( + 'assigned_only', + OpenApiTypes.INT, enum=[0, 1], + description='Filtro por items asignados a recetas.' + ), + ] + ) +) +class BaseRecipeAtrrViewSet(mixins.DestroyModelMixin, + mixins.UpdateModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet): + """Base viewset for recipe attributes.""" + authentication_classes = [TokenAuthentication] + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """Filter queryset to authenticated user.""" + assigned_only = bool( + int(self.request.query_params.get('assigned_only', 0)) + ) + queryset = self.queryset + if assigned_only: + queryset = queryset.filter(recipe__isnull=False) + + return queryset.filter( + user=self.request.user + ).order_by('-name').distinct() +``` + +---- + +- [**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) diff --git a/README.md b/README.md index 75c2cd5..4208481 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,16 @@ # REST API Django - +## Contenido +- [**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) + + ## Tecnologias @@ -806,1055 +815,12 @@ class Ingredient(models.Model): ... ``` -## 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) -``` - -### Implementar 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) - ---- -- 2da parte -> [Recetas](./README2.md) -- 3ra parte -> [Tags](./README3.md) -- 4ta parte -> [Ingredientes](./README4.md) -- 5ta parte -> [Imagenes](./README5.md) -- 6ta parte -> [Fitrado](./README6.md) +- [**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) diff --git a/README6.md b/README6.md deleted file mode 100644 index e69de29..0000000 diff --git a/app/recipe/tests/test_ingredients_api.py b/app/recipe/tests/test_ingredients_api.py index aada25a..8aaa903 100644 --- a/app/recipe/tests/test_ingredients_api.py +++ b/app/recipe/tests/test_ingredients_api.py @@ -1,6 +1,8 @@ """ Tests for the ingredients API. """ +from decimal import Decimal + from django.contrib.auth import get_user_model from django.urls import reverse from django.test import TestCase @@ -8,7 +10,10 @@ from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient -from core.models import Ingredient +from core.models import ( + Ingredient, + Recipe, +) from recipe.serializers import IngredientSerializer @@ -97,3 +102,45 @@ class PrivateIngredientsApiTests(TestCase): self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT) ingredients = Ingredient.objects.filter(user=self.user) self.assertFalse(ingredients.exists()) + + def test_filter_ingredients_assigned_to_recipes(self): + """Test listing ingredients by those assigned to recipes.""" + in1 = Ingredient.objects.create(user=self.user, name='Manzana') + in2 = Ingredient.objects.create(user=self.user, name='Pavo') + recipe = Recipe.objects.create( + title='Pure de Manzana', + time_minutes=5, + price=Decimal('4.5'), + user=self.user, + ) + recipe.ingredients.add(in1) + + res = self.client.get(INGREDIENTS_URL, {'assigned_only': 1}) + + s1 = IngredientSerializer(in1) + s2 = IngredientSerializer(in2) + self.assertIn(s1.data, res.data) + self.assertNotIn(s2.data, res.data) + + def test_filtered_ingredients_unique(self): + """Test filtered ingredients returns a unique list.""" + ing = Ingredient.objects.create(user=self.user, name='Huevo') + Ingredient.objects.create(user=self.user, name='Lentejas') + recipe1 = Recipe.objects.create( + title='Huevos a la copa', + time_minutes=4, + price=Decimal('1.0'), + user=self.user, + ) + recipe2 = Recipe.objects.create( + title='Huevos a cocidos', + time_minutes=5, + price=Decimal('1.0'), + user=self.user, + ) + recipe1.ingredients.add(ing) + recipe2.ingredients.add(ing) + + res = self.client.get(INGREDIENTS_URL, {'assigned_only': 1}) + + self.assertEqual(len(res.data), 1) diff --git a/app/recipe/tests/test_recipe_api.py b/app/recipe/tests/test_recipe_api.py index e7c2fab..714012d 100644 --- a/app/recipe/tests/test_recipe_api.py +++ b/app/recipe/tests/test_recipe_api.py @@ -394,6 +394,46 @@ class PrivateRecipeApiTests(TestCase): self.assertEqual(res.status_code, status.HTTP_200_OK) self.assertEqual(recipe.ingredients.count(), 0) + def test_filter_by_tags(self): + """Test filtering recipes by tags.""" + r1 = create_recipe(user=self.user, title='Sopa de Verduras') + r2 = create_recipe(user=self.user, title='Arroz con Huevo') + tag1 = Tag.objects.create(user=self.user, name='Vergan') + tag2 = Tag.objects.create(user=self.user, name='Vegetariana') + r1.tags.add(tag1) + r2.tags.add(tag2) + r3 = create_recipe(user=self.user, title='Pure con Prietas') + + params = {'tags': f'{tag1.id}, {tag2.id}'} + res = self.client.get(RECIPES_URL, params) + + s1 = RecipeSerializer(r1) + s2 = RecipeSerializer(r2) + s3 = RecipeSerializer(r3) + self.assertIn(s1.data, res.data) + self.assertIn(s2.data, res.data) + self.assertNotIn(s3.data, res.data) + + def test_filter_by_ingredients(self): + """Test filtering recipes by ingredients.""" + r1 = create_recipe(user=self.user, title='Porotos con rienda') + r2 = create_recipe(user=self.user, title='Pollo al jugo') + in1 = Ingredient.objects.create(user=self.user, name='Porotos') + in2 = Ingredient.objects.create(user=self.user, name='Pollo') + r1.ingredients.add(in1) + r2.ingredients.add(in2) + r3 = create_recipe(user=self.user, title='Lentejas con arroz') + + params = {'ingredients': f'{in1.id}, {in2.id}'} + res = self.client.get(RECIPES_URL, params) + + s1 = RecipeSerializer(r1) + s2 = RecipeSerializer(r2) + s3 = RecipeSerializer(r3) + self.assertIn(s1.data, res.data) + self.assertIn(s2.data, res.data) + self.assertNotIn(s3.data, res.data) + class ImageUploadTest(TestCase): """Tests for the image upload API.""" diff --git a/app/recipe/tests/test_tags_api.py b/app/recipe/tests/test_tags_api.py index 7e39df7..9f5b35e 100644 --- a/app/recipe/tests/test_tags_api.py +++ b/app/recipe/tests/test_tags_api.py @@ -1,6 +1,8 @@ """ Tests for tags API """ +from decimal import Decimal + from django.contrib.auth import get_user_model from django.urls import reverse from django.test import TestCase @@ -8,7 +10,10 @@ from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient -from core.models import Tag +from core.models import ( + Tag, + Recipe, +) from recipe.serializers import TagSerializer @@ -94,3 +99,45 @@ class PrivateTagsApiTests(TestCase): self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT) tags = Tag.objects.filter(user=self.user) self.assertFalse(tags.exists()) + + def test_filter_tags_assigned_to_recipes(self): + """Test listing tags to those assigned to recipes.""" + tag1 = Tag.objects.create(user=self.user, name='Desayuno') + tag2 = Tag.objects.create(user=self.user, name='Almuerzo') + recipe = Recipe.objects.create( + title='Huevos Fritos', + time_minutes='5', + price=Decimal('2.5'), + user=self.user, + ) + recipe.tags.add(tag1) + + res = self.client.get(TAGS_URL, {'assigned_only': 1}) + + s1 = TagSerializer(tag1) + s2 = TagSerializer(tag2) + self.assertIn(s1.data, res.data) + self.assertNotIn(s2.data, res.data) + + def test_filter_tags_unique(self): + """Test filtered tags retunrs a unique list.""" + tag = Tag.objects.create(user=self.user, name='Desayuno') + Tag.objects.create(user=self.user, name='Almuerzo') + recipe1 = Recipe.objects.create( + title='Panqueques', + time_minutes='25', + price=Decimal('5.0'), + user=self.user, + ) + recipe2 = Recipe.objects.create( + title='Avena con fruta', + time_minutes='15', + price=Decimal('7.0'), + user=self.user, + ) + recipe1.tags.add(tag) + recipe2.tags.add(tag) + + res = self.client.get(TAGS_URL, {'assigned_only': 1}) + + self.assertEqual(len(res.data), 1) diff --git a/app/recipe/views.py b/app/recipe/views.py index f5e7ff4..c77db64 100644 --- a/app/recipe/views.py +++ b/app/recipe/views.py @@ -1,6 +1,12 @@ """ Views for the recipe APIs. """ +from drf_spectacular.utils import ( + extend_schema_view, + extend_schema, + OpenApiParameter, +) +from drf_spectacular.types import OpenApiTypes from rest_framework import ( viewsets, mixins, @@ -19,6 +25,23 @@ from core.models import ( from recipe import serializers +@extend_schema_view( + list=extend_schema( + parameters=[ + OpenApiParameter( + 'tags', + OpenApiTypes.STR, + description='Lista separada por coma de tags IDs a filtrar' + ), + OpenApiParameter( + 'ingredients', + OpenApiTypes.STR, + description='Lista separada por coma de ingredientes IDs a \ + filtrar' + ), + ] + ) +) class RecipeViewSet(viewsets.ModelViewSet): """View for manage recipe APIs.""" serializer_class = serializers.RecipeDetailSerializer @@ -26,9 +49,25 @@ class RecipeViewSet(viewsets.ModelViewSet): authentication_classes = [TokenAuthentication] permission_classes = [IsAuthenticated] + def _params_to_ints(self, qs): + """Convert a list of strings to integers.""" + return [int(str_id) for str_id in qs.split(',')] + def get_queryset(self): """Retrieve recipes for authenticated user.""" - return self.queryset.filter(user=self.request.user).order_by('-id') + tags = self.request.query_params.get('tags') + ingredients = self.request.query_params.get('ingredients') + queryset = self.queryset + if tags: + tag_ids = self._params_to_ints(tags) + queryset = queryset.filter(tags__id__in=tag_ids) + if ingredients: + ingredients_ids = self._params_to_ints(ingredients) + queryset = queryset.filter(ingredients__id__in=ingredients_ids) + + return queryset.filter( + user=self.request.user + ).order_by('-id').distinct() def get_serializer_class(self): """Return the serializer class for request.""" @@ -56,6 +95,17 @@ class RecipeViewSet(viewsets.ModelViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) +@extend_schema_view( + list=extend_schema( + parameters=[ + OpenApiParameter( + 'assigned_only', + OpenApiTypes.INT, enum=[0, 1], + description='Filtro por items asignados a recetas.' + ), + ] + ) +) class BaseRecipeAtrrViewSet(mixins.DestroyModelMixin, mixins.UpdateModelMixin, mixins.ListModelMixin, @@ -66,7 +116,16 @@ class BaseRecipeAtrrViewSet(mixins.DestroyModelMixin, def get_queryset(self): """Filter queryset to authenticated user.""" - return self.queryset.filter(user=self.request.user).order_by('-name') + assigned_only = bool( + int(self.request.query_params.get('assigned_only', 0)) + ) + queryset = self.queryset + if assigned_only: + queryset = queryset.filter(recipe__isnull=False) + + return queryset.filter( + user=self.request.user + ).order_by('-name').distinct() class TagViewSet(BaseRecipeAtrrViewSet): diff --git a/requirements.txt b/requirements.txt index 0f2e2bc..9156f2a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ Django==4.2.5 djangorestframework==3.14.0 -psycopg2>=2.9.9 -drf-spectacular>=0.16 -Pillow>=10 +psycopg2==2.9.9 +drf-spectacular==0.26.5 +Pillow==10.0.1