recipes_api/04_ingredient_api.md

15 KiB

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/<id>

    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/<id>

    Método HTTP Función
    PUT/PATCH Crear o modificar ingredientes

Test Tag Model

tests/test_models.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

...

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

[+] 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

admin.site.register(models.Ingredient)

Test para listar ingredientes

tests/test_ingredients_api.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

...

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

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

...
router.register('ingredients', views.IngredientViewSet)

...

Actualizar Ingredientes

Test actualizar ingredientes

recipe/tests/test_ingredients_api.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

-class IngredientViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
+class IngredientViewSet(mixins.UpdateModelMixin,
+                        mixins.ListModelMixin,
+                        viewsets.GenericViewSet):

Eliminar ingredientes

Test eliminar ingredientes

test_ingredients_api.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

-class IngredientViewSet(
+class IngredientViewSet(mixins.DestroyModelMixin,
+                        mixins.UpdateModelMixin,
                         mixins.ListModelMixin,
                         viewsets.GenericViewSet):

Creación de ingredientes

Test crear ingrediente

test_recipe_api.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

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

    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

    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

+  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

Django Admin

img