diff --git a/README3.md b/README3.md index c64082a..6e6c6a3 100644 --- a/README3.md +++ b/README3.md @@ -203,6 +203,314 @@ router.register('tags', views.TagViewSet) ... ``` -## Test Update Tags +## Actualizar tags + +### Test Update Tags + +[`test_tags_api.py`](./app/recipe/tests/test_tags_api.py) + +```py +... + +def detail_url(tag_id): + """Create and return a tag detail url.""" + return reverse('recipe:tag-detail', args=[tag_id]) + ... + +class PrivateTagsApiTests(TestCase): + ... + + def test_update_tag(self): + """Test updating a tag.""" + tag = Tag.objects.create(user=self.user, name='Despues de la cena') + + payload = {'name': 'Desierto'} + url = detail_url(tag.id) + res = self.client.patch(url, payload) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + tag.refresh_from_db() + self.assertEqual(tag.name, payload['name']) +``` + +### Implementación update tag + +[`recipe/views.py`](./app/recipe/views.py) + +```py +-class TagViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): ++class TagViewSet(mixins.UpdateModelMixin, ++ mixins.ListModelMixin, ++ viewsets.GenericViewSet): + """Manage tags in the database.""" + serializer_class = serializers.TagSerializer + ... +``` + +## Borrando Tags + +### Test Deleting tags + +[`test_tags_api.py`](./app/recipe/tests/test_tags_api.py) + +```py +def test_delete_tag(self): + """Test deleting a tag.""" + tag = Tag.objects.create(user=self.user, name='Desayuno') + + url = detail_url(tag.id) + res = self.client.delete(url) + + self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT) + tags = Tag.objects.filter(user=self.user) + self.assertFalse(tags.exists()) +``` + +### Implementación borrar tag + +[`recipe/views.py`](./app/recipe/views.py) + +```py +-class TagViewSet(mixins.UpdateModelMixin, ++class TagViewSet(mixins.DestroyModelMixin, ++ mixins.UpdateModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet): + """Manage tags in the database.""" + serializer_class = serializers.TagSerializer + ... +``` + +## Serializadores anidados + +- Es un serializador dentro de un serializador +- Usado para campos que son objetos + +Ejemplo JSON resonse + +```json +{ + "title": "Un titulo", + "user": "Usuario", + "tags": [ + {"name": "Tag 1"}, + {"name": "Tag 2"} + ] +} +``` + +```py +class TagSerializer(serializers.Serializer): + name = serializers.CharField(max_length=100) + +class RecipeSerializer(serializers.Serializer): + title = serializers.CharField(max_length=100) + user = serializers.CharField(max_length=100) + tags = TagSerializer(many=True) +``` + +### Limitaciones + +- Por defecto son **solo-lectura** +- Se debe crear lógica personalizada para hacerlos *writable* + +### Test crear tags a travez de la API recetas + +[`tests/test_recipe_api.py`](./app/recipe/tests/test_recipe_api.py) + +```py +from core.models import Tag + + ... + + def test_create_recipe_with_new_tags(self): + """Test create a recipe with new tags.""" + payload = { + 'title': 'Titulo receta de ejemplo tag anidado', + 'time_minutes': 1, + 'price': Decimal('2.50'), + 'tags': [{'name': 'anidado'}, {'name': 'cena'}] + } + 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.tags.count(), 2) + for tag in payload['tags']: + exists = recipe.tags.filter( + name=tag['name'], + user=self.user, + ).exists() + self.assertTrue(exists) + + def test_create_recipe_with_existing_tag(self): + """Test creating a recipe with existing tag.""" + tag_peru = Tag.objects.create(user=self.user, name='Peruana') + payload = { + 'title': 'Arroz con mariscos', + 'time_minutes': 45, + 'price': Decimal('8.50'), + 'tags': [{'name': 'Peruana'}, {'name': 'Almuerzo'}] + } + 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.tags.count(), 2) + self.assertIn(tag_peru, recipe.tags.all()) + for tag in payload['tags']: + exists = recipe.tags.filter( + name=tag['name'], + user=self.user, + ) + self.assertTrue(exists) +``` + +### Implementación creación de Tags al crear recetas + +[recipe/serializers.py](./app/recipe/serializers.py) + +```py +... + +class TagSerializer(serializers.ModelSerializer): + """Serializer for tags.""" + + class Meta: + model = Tag + fields = ['id', 'name'] + read_only_fields = ['id'] +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'] + + def create(self, validated_data): + """Create a recipe.""" + tags = validated_data.pop('tags', []) + recipe = Recipe.objects.create(**validated_data) + auth_user = self.context['request'].user + for tag in tags: + tag_obj, created = Tag.objects.get_or_create( + user=auth_user, + **tag, + ) + recipe.tags.add(tag_obj) + + return recipe +... +``` + +## Modificar tag asignado a receta + +### Test modificar tag asignado a receta + +[`recipe/tests/test_recipe_api.py`](./app/recipe/tests/test_recipe_api.py) + +```py + ... + + def test_create_tag_on_update(self): + """Test creating tag when updating a recipe.""" + recipe = create_recipe(user=self.user) + + payload = {'tags': [{'name': 'Cena'}]} + url = detail_url(recipe.id) + res = self.client.patch(url, payload, format='json') + + self.assertEqual(res.status_code, status.HTTP_200_OK) + new_tag = Tag.objects.get(user=self.user, name='Cena') + self.assertIn(new_tag, recipe.tags.all()) + + def test_update_recipe_assign_tag(self): + """Test assigning an existing tag when updating a recipe.""" + tag_breakfast = Tag.objects.create(user=self.user, name='Desayuno') + recipe = create_recipe(user=self.user) + recipe.tags.add(tag_breakfast) + + tag_lunch = Tag.objects.create(user=self.user, name='Cena') + payload = {'tags': [{'name': 'Cena'}]} + url = detail_url(recipe.id) + res = self.client.patch(url, payload, format='json') + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertIn(tag_lunch, recipe.tags.all()) + self.assertNotIn(tag_breakfast, recipe.tags.all()) + + def test_clear_recipe_tags(self): + """Test clearing a recipes tags.""" + tag = Tag.objets.create(user=self.user, name='Once') + recipe = create_recipe(user=self.user) + recipe.tags.add(tag) + + payload = {'tags': []} + 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.tags.count(), 0) +``` + +### Implementación modificar tag asignado a receta + +[`recipe/serializer.py`](./app/recipe/serializers.py) + +```py + ... + + def _get_or_create_tags(self, tags, recipe): + """Handle getting or creating tags as needed.""" + auth_user = self.context['request'].user + for tag in tags: + tag_obj, created = Tag.objects.get_or_create( + user=auth_user, + **tag, + ) + recipe.tags.add(tag_obj) + + + def create(self, validated_data): + """Create a recipe.""" + tags = validated_data.pop('tags', []) + recipe = Recipe.objects.create(**validated_data) + self._get_or_create_tags(tags, recipe) + + return recipe + + def update(self, instance, validated_data): + """Update recipe.""" + tags = validated_data.pop('tags', None) + if tags is not None: + instance.tags.clear() + self._get_or_create_tags(tags, instance) + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + + instance.save() + return instance + + ... +``` + +## Levantar app y prueba navegador + +URL `localhost:8000/api/docs` + +### Swagger + +![img](./imgs_readme/api_swagger_02.png) + +### Django Admin + +![img](./imgs_readme/django_admin_14.png) diff --git a/app/recipe/serializers.py b/app/recipe/serializers.py index d8d9dcc..fb540a7 100644 --- a/app/recipe/serializers.py +++ b/app/recipe/serializers.py @@ -9,22 +9,6 @@ from core.models import ( ) -class RecipeSerializer(serializers.ModelSerializer): - """Serializer for recipes.""" - - class Meta: - model = Recipe - fields = ['id', 'title', 'time_minutes', 'price', 'link'] - read_only_fields = ['id'] - - -class RecipeDetailSerializer(RecipeSerializer): - """Serializer for recipe detail view.""" - - class Meta(RecipeSerializer.Meta): - fields = RecipeSerializer.Meta.fields + ['description'] - - class TagSerializer(serializers.ModelSerializer): """Serializer for tags.""" @@ -32,3 +16,51 @@ class TagSerializer(serializers.ModelSerializer): model = Tag fields = ['id', 'name'] read_only_fields = ['id'] + + +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'] + + def _get_or_create_tags(self, tags, recipe): + """Handle getting or creating tags as needed.""" + auth_user = self.context['request'].user + for tag in tags: + tag_obj, created = Tag.objects.get_or_create( + user=auth_user, + **tag, + ) + recipe.tags.add(tag_obj) + + def create(self, validated_data): + """Create a recipe.""" + tags = validated_data.pop('tags', []) + recipe = Recipe.objects.create(**validated_data) + self._get_or_create_tags(tags, recipe) + + return recipe + + def update(self, instance, validated_data): + """Update recipe.""" + tags = validated_data.pop('tags', None) + if tags is not None: + instance.tags.clear() + self._get_or_create_tags(tags, instance) + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + + instance.save() + return instance + + +class RecipeDetailSerializer(RecipeSerializer): + """Serializer for recipe detail view.""" + + class Meta(RecipeSerializer.Meta): + fields = RecipeSerializer.Meta.fields + ['description'] diff --git a/app/recipe/tests/test_recipe_api.py b/app/recipe/tests/test_recipe_api.py index eb29bc3..e51d936 100644 --- a/app/recipe/tests/test_recipe_api.py +++ b/app/recipe/tests/test_recipe_api.py @@ -10,7 +10,10 @@ from django. urls import reverse from rest_framework import status from rest_framework.test import APIClient -from core.models import Recipe +from core.models import ( + Recipe, + Tag, +) from recipe.serializers import ( RecipeSerializer, @@ -206,3 +209,89 @@ class PrivateRecipeApiTests(TestCase): self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) self.assertTrue(Recipe.objects.filter(id=recipe.id).exists()) + + def test_create_recipe_with_new_tags(self): + """Test create a recipe with new tags.""" + payload = { + 'title': 'Titulo receta de ejemplo tag anidado', + 'time_minutes': 1, + 'price': Decimal('2.50'), + 'tags': [{'name': 'anidado'}, {'name': 'cena'}] + } + 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.tags.count(), 2) + for tag in payload['tags']: + exists = recipe.tags.filter( + name=tag['name'], + user=self.user, + ).exists() + self.assertTrue(exists) + + def test_create_recipe_with_existing_tags(self): + """Test creating a recipe with existing tag.""" + tag_peru = Tag.objects.create(user=self.user, name='Peruana') + payload = { + 'title': 'Arroz con mariscos', + 'time_minutes': 45, + 'price': Decimal('8.50'), + 'tags': [{'name': 'Peruana'}, {'name': 'Almuerzo'}] + } + 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.tags.count(), 2) + self.assertIn(tag_peru, recipe.tags.all()) + for tag in payload['tags']: + exists = recipe.tags.filter( + name=tag['name'], + user=self.user, + ) + self.assertTrue(exists) + + def test_create_tag_on_update(self): + """Test creating tag when updating a recipe.""" + recipe = create_recipe(user=self.user) + + payload = {'tags': [{'name': 'Cena'}]} + url = detail_url(recipe.id) + res = self.client.patch(url, payload, format='json') + + self.assertEqual(res.status_code, status.HTTP_200_OK) + new_tag = Tag.objects.get(user=self.user, name='Cena') + self.assertIn(new_tag, recipe.tags.all()) + + def test_update_recipe_assign_tag(self): + """Test assigning an existing tag when updating a recipe.""" + tag_breakfast = Tag.objects.create(user=self.user, name='Desayuno') + recipe = create_recipe(user=self.user) + recipe.tags.add(tag_breakfast) + + tag_lunch = Tag.objects.create(user=self.user, name='Cena') + payload = {'tags': [{'name': 'Cena'}]} + url = detail_url(recipe.id) + res = self.client.patch(url, payload, format='json') + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertIn(tag_lunch, recipe.tags.all()) + self.assertNotIn(tag_breakfast, recipe.tags.all()) + + def test_clear_recipe_tags(self): + """Test clearing a recipes tags.""" + tag = Tag.objects.create(user=self.user, name='Once') + recipe = create_recipe(user=self.user) + recipe.tags.add(tag) + + payload = {'tags': []} + 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.tags.count(), 0) diff --git a/app/recipe/tests/test_tags_api.py b/app/recipe/tests/test_tags_api.py index e895e8c..7e39df7 100644 --- a/app/recipe/tests/test_tags_api.py +++ b/app/recipe/tests/test_tags_api.py @@ -16,6 +16,11 @@ from recipe.serializers import TagSerializer TAGS_URL = reverse('recipe:tag-list') +def detail_url(tag_id): + """Create and return a tag detail url.""" + return reverse('recipe:tag-detail', args=[tag_id]) + + def create_user(email='user@example.com', password='testpass123'): """Create and return a user.""" return get_user_model().objects.create_user(email=email, password=password) @@ -66,3 +71,26 @@ class PrivateTagsApiTests(TestCase): self.assertEqual(len(res.data), 1) self.assertEqual(res.data[0]['name'], tag.name) self.assertEqual(res.data[0]['id'], tag.id) + + def test_update_tag(self): + """Test updating a tag.""" + tag = Tag.objects.create(user=self.user, name='Despues de la cena') + + payload = {'name': 'Desierto'} + url = detail_url(tag.id) + res = self.client.patch(url, payload) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + tag.refresh_from_db() + self.assertEqual(tag.name, payload['name']) + + def test_delete_tag(self): + """Test deleting a tag.""" + tag = Tag.objects.create(user=self.user, name='Desayuno') + + url = detail_url(tag.id) + res = self.client.delete(url) + + self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT) + tags = Tag.objects.filter(user=self.user) + self.assertFalse(tags.exists()) diff --git a/app/recipe/views.py b/app/recipe/views.py index c7739d5..1cca514 100644 --- a/app/recipe/views.py +++ b/app/recipe/views.py @@ -37,7 +37,10 @@ class RecipeViewSet(viewsets.ModelViewSet): serializer.save(user=self.request.user) -class TagViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): +class TagViewSet(mixins.DestroyModelMixin, + mixins.UpdateModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet): """Manage tags in the database.""" serializer_class = serializers.TagSerializer queryset = Tag.objects.all() diff --git a/imgs_readme/api_swagger_02.png b/imgs_readme/api_swagger_02.png new file mode 100644 index 0000000..ed1f310 Binary files /dev/null and b/imgs_readme/api_swagger_02.png differ diff --git a/imgs_readme/django_admin_14.png b/imgs_readme/django_admin_14.png new file mode 100644 index 0000000..5256ede Binary files /dev/null and b/imgs_readme/django_admin_14.png differ