# Igredientes API - Añadir capacidad de agregar **ingredientes** a las recetas - Creación del modelo para los **ingredientes** - Añadir API *endpoints* para los **ingredientes** - Actualizar **recipe** *endpoint*, para agregar y listar ingredientes ## Endpoints - `/api/recipe/ingredients` | Método HTTP | Función | | - | - | | `GET` | Listar ingredientes | - `/api/recipe/ingredients/` | Método HTTP | Función | | - | - | | `GET` | Obtener detalles del ingrediente | | `PUT/PATCH` | Actualizar ingrediente | | `DELETE` | Borrar ingrediente | - `/api/recipe` | Método HTTP | Función | | - | - | | `POST`| Crear ingrediente (como parte de una receta) | - `/api/recipe/` | Método HTTP | Función | | - | - | | `PUT/PATCH` | Crear o modificar ingredientes | ## Test Tag Model [`tests/test_models.py`](../app/core/tests/test_models.py) ```py ... def test_create_ingredient(self): """Test creating an ingredient is successful.""" user = create_user() ingredient = models.Ingredient.objects.create( user=user, name='Ingrediente1' ) self.assertEqual(str(ingredient), ingredient.name) ``` ## Ingredients Model - `name` Nombre del ingrediente a crear - `user` Usuario creador/dueño del ingrediente [`core/models.py`](../app/core/models.py) ```py ... class Recipe(models.Model): ... ingredients = models.ManyToManyField('Ingredient') ... class Ingredient(models.Model): """Ingredient for recipes.""" name = models.CharField(max_length=255) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, ) def __str__(self): return self.name ``` ### 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/0004_ingredient_recipe_ingredients.py - Create model Ingredient - Add field ingredients to recipe ``` ### Agregar al administrador de django [`app/core/admin.py`](../app/core/admin.py) ```py admin.site.register(models.Ingredient) ``` ## Test para listar ingredientes [`tests/test_ingredients_api.py`](../app/core/tests/test_ingredients_api.py) ```py ... def create_user(email='user@example.com', password='testpass123'): """Create and return user.""" return get_user_model().objects.create_user(email=email, password=password) class PublicIngredientsApiTests(TestCase): """Test unanthenticated API requests.""" def setUp(self): self.client = APIClient() def test_auth_required(self): """Test auth is required for retrieving ingredients.""" res = self.client.get(INGREDIENTS_URL) self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) class PrivateIngredientsApiTests(TestCase): """Test authenticated API requests.""" def setUp(self): self.user = create_user() self.client = APIClient() self.client.force_authenticate(self.user) def test_retrieve_ingredients(self): """Test retrieving a list of ingredients.""" Ingredient.objects.create(user=self.user, name='Harina') Ingredient.objects.create(user=self.user, name='Panela') res = self.client.get(INGREDIENTS_URL) ingredients = Ingredient.objects.all().order_by('-name') serializer = IngredientSerializer(ingredients, many=True) self.assertEqual(res.status_code, status.HTTP_200_OK) self.assertEqual(res.data, serializer.data) def test_ingredients_limited_to_user(self): """Test list of ingredients is limited to authenticated user.""" user2 = create_user(email='user2@example.com') Ingredient.objects.create(user=user2, name='Sal') ingredient = Ingredient.objects.create(user=self.user, name='Pimienta') res = self.client.get(INGREDIENTS_URL) self.assertEqual(res.status_code, status.HTTP_200_OK) self.assertEqual(len(res.data), 1) self.assertEqual(res.data[0]['name'], ingredient.name) self.assertEqual(res.data[0]['id'], ingredient.id) ``` ### Serializador Ingredientes [`recipe/serializers.py`](../app/recipe/serializers.py) ```py ... class RecipeSerializer(serializers.ModelSerializer): """Serializer for recipes.""" tags = TagSerializer(many=True, required=False) class Meta: model = Recipe fields = ['id', 'title', 'time_minutes', 'price', 'link', 'tags'] read_only_fields = ['id'] ... ``` ### Vistas Ingredients [`recipe/views.py`](../app/recipe/views.py) ```py class IngredientViewSet(mixin.ListModelMixin, viewsets.GenericViewSet): """Manage ingredients in the database.""" serializer_class = serializers.IngredientSerializer queryset = Ingredient.objects.all() authentication_classes = [TokenAuthentication] permission_classes = [IsAuthenticated] def get_queryset(self): """Filter queryset to authenticated user.""" return self.queryset.filter(user=self.request.user).order_by('-name') ``` ### URls Ingredientes [recipe/urls.py](../app/recipe/urls.py) ```py ... router.register('ingredients', views.IngredientViewSet) ... ``` ## Actualizar Ingredientes ### Test actualizar ingredientes [`recipe/tests/test_ingredients_api.py`](../app/recipe/tests/test_ingredients_api.py) ```py ... def detail_url(ingredient_id): """Create and return an ingredient detail URL.""" return reverse('recipe:ingredient-detail', args=[ingredient_id]) ... def test_update_ingredient(self): """Test updating an ingredient.""" ingredient = Ingredient.objects.create(user=self.user, name='Coriandro') payload = {'name': 'Cilantro'} url = detail_url(ingredient.id) res = self.client.patch(url, payload) self.assertEqual(res.status_code, status.HTTP_200_OK) ingredient.refresh_from_db() self.assertEqual(ingredient.name, payload['name']) ``` ### Implementar funcionalidad actualizar ingredientes [`recipe/views.py`](../app/recipe/views.py) ```diff -class IngredientViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): +class IngredientViewSet(mixins.UpdateModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet): ``` ## Eliminar ingredientes ### Test eliminar ingredientes [`test_ingredients_api.py`](../app/recipe/tests/test_ingredients_api.py) ```py def test_delete_ingredient(self): """Test deleting an ingredient.""" ingredient = Ingredient.objects.create(user=self.user, name='Lechuga') url = detail_url(ingredient.id) res = self.client.delete(url) self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT) ingredients = Ingredient.objects.filter(user=self.user) self.assertFalse(ingredients.exists()) ``` ### Implementar funcionalidad actualizar ingredientes [`recipe/views.py`](../app/recipe/views.py) ```diff -class IngredientViewSet( +class IngredientViewSet(mixins.DestroyModelMixin, + mixins.UpdateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): ``` ## Creación de ingredientes ### Test crear ingrediente [`test_recipe_api.py`](../app/recipe/tests/test_recipe_api.py) ```py def test_create_recipe_with_new_ingredients(self): """Test creating a recipe with new ingredients.""" payload = { 'title': 'Camarones acaramelados', 'time_minutes': 45, 'price': Decimal('75.50'), 'ingredients': [{'name': 'Camarones'}, {'name': 'Azucar'}], } res = self.client.post(RECIPES_URL, payload, format='json') self.assertEqual(res.status_code, status.HTTP_201_CREATED) recipes = Recipe.objects.filter(user=self.user) self.assertEqual(recipe.ingredients.count(), 2) for ingredient in payload['ingredients']: exists = recipe.ingredients.filter( name=ingredient['name'], user=self.user, ).exists() self.assertTrue(exists) def test_create_recipe_with_existing_ingredient(self): """Test creating a new recipe with existing ingredient.""" ingredient = Ingredient.objects.create(user=self.user, name='Limón') payload = { 'title': 'Limonada', 'time_minutes': 15, 'price': '2.50', 'ingredients': [{'name': 'Limón'}, {'name': 'Azucar'}], } res = self.client.post(RECIPES_URL, payload, format='json') self.assertEqual(res.status_code, status.HTTP_201_CREATED) recipes = Recipe.objects.filter(user=self.user) self.assertEqual(recipes.count(), 1) recipe = recipes[0] self.assertEqual(recipe.ingredients.count(), 2) self.assertIn(ingredient, recipe.ingredients.all()) for ingredient in payload['ingredients']: exists = recipe.ingredients.filter( name=ingredient['name'], user=self.user, ).exists() self.assertTrue(exists) ``` ### Implementación funcionalidad crear ingredientes con receta [`recipe/serializers.py`](../app/recipe/serializers.py) ```diff ... class RecipeSerializer(serializers.ModelSerializer): """Serializer for recipes.""" tags = TagSerializer(many=True, required=False) + ingredients = IngredientSerializer(many=True) class Meta: model = Recipe - fields = ['id', 'title', 'time_minutes', 'price', 'link', 'tags'] + fields = ['id', 'title', 'time_minutes', 'price', 'link', 'tags', + 'ingredients'] read_only_fields = ['id'] ... + def _get_or_create_ingredients(self, ingredients, recipe): + """Handle getting or creating ingredients as needed.""" + auth_user = self.context['request'].user + for ingredient in ingredients: + ingredient_obj, created = Ingredient.objects.get_or_create( + user=auth_user, + **ingredient, + ) + recipe.ingredients.add(ingredient_obj) def create(self, validated_data): """Create a recipe.""" tags = validated_data.pop('tags', []) + ingredients = validated_data.pop('ingredients', []) recipe = Recipe.objects.create(**validated_data) self._get_or_create_tags(tags, recipe) + self._get_or_create_ingredients(ingredients, recipe) ... ``` ## Actualizar ingredientes ### Test actaulizar ingredientes [`recipe/tests/test_ingredients_api.py`](../app/recipe/tests/test_ingredients_api.py) ```py def test_create_ingredient_on_update(self): """Test creating an ingredient when updating a recipe.""" recipe = create_recipe(user=self.user) payload = {'ingredients': [{'name': 'Pomelo'}]} url = detail_url(recipe.id) res = self.client.patch(url, payload, format='json') self.assertEqual(res.status_code, status.HTTP_200_OK) new_ingredient = Ingredient.objects.get(user=self.user, name='Pomelo') self.assertIn(new_ingredient, recipe.ingredients.all()) def test_update_recipe_assign_ingredient(self): """Test assigning an existing ingredient when updating a recipe.""" ingredient1 = Ingredient.objects.create(user=self.user, name='Pimienta') recipe = create_recipe(user=self.user) recipe.ingredients.add(ingredient1) ingredient2 = Ingredient.objects.create(user=self.user, name='Ají') payload = {'ingredients': [{'name': 'Ají'}]} url = detail_url(recipe.id) res = self.client.patch(url,payload, format='json') self.assertEqual(res.status_code, status.HTTP_200_OK) self.assertIn(ingredient2, recipe.ingredients.all()) self.assertNotIn(ingredient1, recipe.ingredients.all()) def test_clear_recipe_ingredients(self): """Test clearing a recipes ingredients.""" ingredient = Ingredient.objects.create(user=self.user, name='Ajo') recipe = create_recipe(user=self.user) recipe.ingredients.add(ingredient) payload = {'ingredients': []} url = detail_url(recipe.id) res = self.client.patch(url, payload, format='json') self.assertEqual(res.status_code, status.HTTP_200_OK) self.assertEqual(recipe.ingredients.count(), 0) ``` ### Implementación actualizar ingredientes [`recipe/serializers.py`](../app/recipe/serializers.py) ```diff def create(self, validated_data): """Create a recipe.""" tags = validated_data.pop('tags', []) + ingredients = validated_data.pop('ingredients', []) recipe = Recipe.objects.create(**validated_data) self._get_or_create_tags(tags, recipe) + self._get_or_create_ingredients(ingredients, recipe) return recipe def update(self, instance, validated_data): """Update recipe.""" tags = validated_data.pop('tags', None) + ingredients = validated_data.pop('ingredients', None) if tags is not None: instance.tags.clear() self._get_or_create_tags(tags, instance) + if ingredients is not None: + instance.ingredients.clear() + self._get_or_create_ingredients(ingredients, instance) ``` ## Refactor Reestructuración para falicitar lectura, eficiencia y evita código duplicado y mejorar rendimiento TDD facilita esta labor, corriendo los tests para evaluar ### Area a refactorizar `TagViewSet/IngredientViewSet` Código muy similar, refactorización usando herencia [`recipe/views.py`](../app/recipe/views.py) ```diff + 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.""" + return self.queryset.filter(user=self.request.user).order_by('-name') class TagViewSet(BaseRecipeAtrrViewSet): """Manage tags in the database.""" serializer_class = serializers.TagSerializer queryset = Tag.objects.all() - authentication_classes = [TokenAuthentication] - permission_classes = [IsAuthenticated] - def get_queryset(self): - """Filter queryset to authenticated user.""" - return self.queryset.filter(user=self.request.user).order_by('-name') class IngredientViewSet(BaseRecipeAtrrViewSet): """Manage ingredients in the database.""" serializer_class = serializers.IngredientSerializer queryset = Ingredient.objects.all() - authentication_classes = [TokenAuthentication] - permission_classes = [IsAuthenticated] - def get_queryset(self): - """Filter queryset to authenticated user.""" - return self.queryset.filter(user=self.request.user).order_by('-name') ``` ## Levantar app y prueba navegador - `docker compose run` - URL `localhost:8000/api/docs` ### Swagger ![img](./imgs_readme/api_swagger_03.png) ### Django Admin ![img](./imgs_readme/django_admin_15.png) ---- - [Inicio](../README.md) - [User API](./01_user_api.md) - [Recipe API](./02_recipe_api.md) - [Tag API](./03_tag_api.md) - [**Ingredient API**](./04_ingredient_api.md) - [Image API](./05_image_api.md) - [Filters](./06_filters.md)