Implementación de filtros por tag y recetas

This commit is contained in:
devfzn 2023-10-12 00:44:46 -03:00
parent d20158552c
commit e22bb8f062
Signed by: devfzn
GPG Key ID: E070ECF4A754FDB1
13 changed files with 1599 additions and 1078 deletions

1052
01_user_api.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

302
06_filters.md Normal file
View File

@ -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)

1068
README.md

File diff suppressed because it is too large Load Diff

View File

View File

@ -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)

View File

@ -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."""

View File

@ -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)

View File

@ -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):

View File

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