diff --git a/README.md b/README.md index 43b2537..05ef62a 100644 --- a/README.md +++ b/README.md @@ -1853,5 +1853,6 @@ Ruta `localhost:8000/api/docs` ---- -- Segunda parte -> [Recetas](./README2.md) -- Tercera parte -> [Tags](./README3.md) +- 2da parte -> [Recetas](./README2.md) +- 3ra parte -> [Tags](./README3.md) +- 4ta parte -> [Ingredientes](./README4.md) diff --git a/README2.md b/README2.md index 78ecc5f..ad1c4b8 100644 --- a/README2.md +++ b/README2.md @@ -539,5 +539,6 @@ URL `localhost:8000/api/docs/` ---- -- Primera parte -> [Recepi API](./README.md) -- Tercera parte -> [Tags](./README3.md) +- 1ra parte -> [Recepi API](./README.md) +- 3ra parte -> [Tags](./README3.md) +- 4ta parte -> [Ingredientes](./README4.md) diff --git a/README3.md b/README3.md index 6e6c6a3..7fce063 100644 --- a/README3.md +++ b/README3.md @@ -1,11 +1,11 @@ # Tags API -- Añadir capadidad de agregar **tags** a las recetas +- Añadir capacidad de agregar **tags** a las recetas - Creación del modelo para los **tags** - Añadir *endpoints* para la API **tag** -- Actualizar el *endpoint* **recipe**, para agregar y listar tags +- Actualizar **recipe** *endpoint*, para agregar y listar tags -## Tag endpoint +## Endpoints - `/api/recipe/tags` @@ -514,3 +514,9 @@ URL `localhost:8000/api/docs` ### Django Admin ![img](./imgs_readme/django_admin_14.png) + +---- + +- 1ra parte -> [Recepi API](./README.md) +- 2da parte -> [Recetas](./README2.md) +- 4ta parte -> [Ingredientes](./README4.md) diff --git a/README4.md b/README4.md new file mode 100644 index 0000000..25a824a --- /dev/null +++ b/README4.md @@ -0,0 +1,514 @@ +# 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) + +---- + +- 1ra parte -> [Recepi API](./README.md) +- 2da parte -> [Recetas](./README2.md) +- 3ra parte -> [Tags](./README2.md) diff --git a/app/core/admin.py b/app/core/admin.py index d04dc7e..6ff8d7b 100644 --- a/app/core/admin.py +++ b/app/core/admin.py @@ -46,3 +46,4 @@ class UserAdmin(BaseUserAdmin): admin.site.register(models.User, UserAdmin) admin.site.register(models.Recipe) admin.site.register(models.Tag) +admin.site.register(models.Ingredient) diff --git a/app/core/migrations/0004_ingredient_recipe_ingredients.py b/app/core/migrations/0004_ingredient_recipe_ingredients.py new file mode 100644 index 0000000..374d66f --- /dev/null +++ b/app/core/migrations/0004_ingredient_recipe_ingredients.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.5 on 2023-10-10 20:04 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_tag_recipe_tags'), + ] + + operations = [ + migrations.CreateModel( + name='Ingredient', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='recipe', + name='ingredients', + field=models.ManyToManyField(to='core.ingredient'), + ), + ] diff --git a/app/core/models.py b/app/core/models.py index 7d0f606..ce588a7 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -57,6 +57,7 @@ class Recipe(models.Model): price = models.DecimalField(max_digits=5, decimal_places=2, blank=True) link = models.CharField(max_length=255, blank=True) tags = models.ManyToManyField('Tag') + ingredients = models.ManyToManyField('Ingredient') def __str__(self): return self.title @@ -72,3 +73,15 @@ class Tag(models.Model): def __str__(self): return self.name + + +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 diff --git a/app/core/tests/test_models.py b/app/core/tests/test_models.py index 6c26002..6e87110 100644 --- a/app/core/tests/test_models.py +++ b/app/core/tests/test_models.py @@ -17,8 +17,8 @@ def create_user(email='user@example.com', password='testpass123'): class ModelTests(TestCase): """Test models.""" - def test_create_user_with_email_sucessfull(self): - """Test creating a user with an email is sucessfull.""" + def test_create_user_with_email_successful(self): + """Test creating a user with an email is successful.""" email = 'test@example.com' password = 'testpass123' user = create_user(email, password) @@ -65,8 +65,18 @@ class ModelTests(TestCase): self.assertEqual(str(recipe), recipe.title) def test_create_tag(self): - """Test creating a tag is sucessfull.""" + """Test creating a tag is successful.""" user = create_user() tag = models.Tag.objects.create(user=user, name='Tag1') self.assertEqual(str(tag), tag.name) + + 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) diff --git a/app/recipe/serializers.py b/app/recipe/serializers.py index fb540a7..273b906 100644 --- a/app/recipe/serializers.py +++ b/app/recipe/serializers.py @@ -4,11 +4,21 @@ Serializers for recipe APIs from rest_framework import serializers from core.models import ( + Ingredient, Recipe, Tag, ) +class IngredientSerializer(serializers.ModelSerializer): + """Serializer for Ingredients.""" + + class Meta: + model = Ingredient + fields = ['id', 'name'] + read_only_fields = ['id'] + + class TagSerializer(serializers.ModelSerializer): """Serializer for tags.""" @@ -21,10 +31,12 @@ class TagSerializer(serializers.ModelSerializer): class RecipeSerializer(serializers.ModelSerializer): """Serializer for recipes.""" tags = TagSerializer(many=True, required=False) + ingredients = IngredientSerializer(many=True, required=False) 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_tags(self, tags, recipe): @@ -37,20 +49,36 @@ class RecipeSerializer(serializers.ModelSerializer): ) recipe.tags.add(tag_obj) + 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) 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) for attr, value in validated_data.items(): setattr(instance, attr, value) diff --git a/app/recipe/tests/test_ingredients_api.py b/app/recipe/tests/test_ingredients_api.py new file mode 100644 index 0000000..aada25a --- /dev/null +++ b/app/recipe/tests/test_ingredients_api.py @@ -0,0 +1,99 @@ +""" +Tests for the ingredients API. +""" +from django.contrib.auth import get_user_model +from django.urls import reverse +from django.test import TestCase + +from rest_framework import status +from rest_framework.test import APIClient + +from core.models import Ingredient + +from recipe.serializers import IngredientSerializer + + +INGREDIENTS_URL = reverse('recipe:ingredient-list') + + +def detail_url(ingredient_id): + """Create and return an ingredient detail URL.""" + return reverse('recipe:ingredient-detail', args=[ingredient_id]) + + +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) + + 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']) + + 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()) diff --git a/app/recipe/tests/test_recipe_api.py b/app/recipe/tests/test_recipe_api.py index e51d936..107be86 100644 --- a/app/recipe/tests/test_recipe_api.py +++ b/app/recipe/tests/test_recipe_api.py @@ -13,6 +13,7 @@ from rest_framework.test import APIClient from core.models import ( Recipe, Tag, + Ingredient, ) from recipe.serializers import ( @@ -295,3 +296,91 @@ class PrivateRecipeApiTests(TestCase): self.assertEqual(res.status_code, status.HTTP_200_OK) self.assertEqual(recipe.tags.count(), 0) + + 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(recipes.count(), 1) + recipe = recipes[0] + 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) + + 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) diff --git a/app/recipe/urls.py b/app/recipe/urls.py index 138b73c..b25db22 100644 --- a/app/recipe/urls.py +++ b/app/recipe/urls.py @@ -14,6 +14,7 @@ from recipe import views router = DefaultRouter() router.register('recipes', views.RecipeViewSet) router.register('tags', views.TagViewSet) +router.register('ingredients', views.IngredientViewSet) app_name = 'recipe' diff --git a/app/recipe/views.py b/app/recipe/views.py index 1cca514..a7bc61b 100644 --- a/app/recipe/views.py +++ b/app/recipe/views.py @@ -9,6 +9,7 @@ from rest_framework.authentication import TokenAuthentication from rest_framework.permissions import IsAuthenticated from core.models import ( + Ingredient, Recipe, Tag, ) @@ -37,16 +38,26 @@ class RecipeViewSet(viewsets.ModelViewSet): serializer.save(user=self.request.user) -class TagViewSet(mixins.DestroyModelMixin, - mixins.UpdateModelMixin, - mixins.ListModelMixin, - viewsets.GenericViewSet): - """Manage tags in the database.""" - serializer_class = serializers.TagSerializer - queryset = Tag.objects.all() +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() + + +class IngredientViewSet(BaseRecipeAtrrViewSet): + """Manage ingredients in the database.""" + serializer_class = serializers.IngredientSerializer + queryset = Ingredient.objects.all() diff --git a/imgs_readme/api_swagger_03.png b/imgs_readme/api_swagger_03.png new file mode 100644 index 0000000..226303a Binary files /dev/null and b/imgs_readme/api_swagger_03.png differ diff --git a/imgs_readme/django_admin_15.png b/imgs_readme/django_admin_15.png new file mode 100644 index 0000000..2e82f63 Binary files /dev/null and b/imgs_readme/django_admin_15.png differ