From 79b5fe7abca797789916a6ff7322157f31fc364f Mon Sep 17 00:00:00 2001 From: Marcin Tomiczek Date: Fri, 23 Feb 2024 13:35:32 +0100 Subject: [PATCH] Add tags API --- app/core/admin.py | 1 + app/core/migrations/0003_tag_recipe_tags.py | 28 ++++++ app/core/models.py | 12 +++ app/core/tests/test_models.py | 14 ++- app/recipe/serializers.py | 44 +++++++++- app/recipe/tests/test_recipe_api.py | 91 +++++++++++++++++++- app/recipe/tests/test_tags_api.py | 94 +++++++++++++++++++++ app/recipe/urls.py | 1 + app/recipe/views.py | 21 ++++- 9 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 app/core/migrations/0003_tag_recipe_tags.py create mode 100644 app/recipe/tests/test_tags_api.py diff --git a/app/core/admin.py b/app/core/admin.py index 98fa1ef..7253435 100644 --- a/app/core/admin.py +++ b/app/core/admin.py @@ -45,3 +45,4 @@ class UserAdmin(BaseUserAdmin): admin.site.register(models.User, UserAdmin) admin.site.register(models.Recipe) +admin.site.register(models.Tag) diff --git a/app/core/migrations/0003_tag_recipe_tags.py b/app/core/migrations/0003_tag_recipe_tags.py new file mode 100644 index 0000000..a3efa4c --- /dev/null +++ b/app/core/migrations/0003_tag_recipe_tags.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.9 on 2024-01-25 16:30 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_recipe'), + ] + + operations = [ + migrations.CreateModel( + name='Tag', + 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='tags', + field=models.ManyToManyField(to='core.tag'), + ), + ] diff --git a/app/core/models.py b/app/core/models.py index 478ed95..55080e5 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -53,6 +53,18 @@ class Recipe(models.Model): time_minutes = models.IntegerField() price = models.DecimalField(max_digits=5, decimal_places=2) link = models.CharField(max_length=255, blank=True) + tags = models.ManyToManyField("Tag") def __str__(self): return self.title + + +class Tag(models.Model): + """Tag for filtering 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 767c965..5ea3276 100644 --- a/app/core/tests/test_models.py +++ b/app/core/tests/test_models.py @@ -6,6 +6,11 @@ from core import models +def create_user(email="user@example.com", password="testpass123"): + """Create and return a new user.""" + return get_user_model().objects.create_user(email, password) + + class ModelTests(TestCase): """Test models.""" @@ -51,7 +56,7 @@ def test_create_superuser(self): def test_create_recipe(self): """Test creating a recipe is successful.""" - user = get_user_model().objects.create_user( + user = create_user( "test@example.com", "testpass123", ) recipe = models.Recipe.objects.create( @@ -63,3 +68,10 @@ def test_create_recipe(self): ) self.assertEqual(str(recipe), recipe.title) + + def test_create_tag(self): + """Test creating tag is successful.""" + user = create_user() + tag = models.Tag.objects.create(user=user, name="Tag1") + + self.assertEqual(str(tag), tag.name) diff --git a/app/recipe/serializers.py b/app/recipe/serializers.py index 49a6545..a47aacd 100644 --- a/app/recipe/serializers.py +++ b/app/recipe/serializers.py @@ -1,17 +1,57 @@ """Serializers for recipe APIs.""" from rest_framework import serializers -from core.models import Recipe +from core.models import Recipe, Tag + + +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"] + fields = ["id", "title", "time_minutes", "price", "link", "tags"] read_only_fields = ["id"] + def _get_or_create_tags(self, tags, recipe): + 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 a 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.""" diff --git a/app/recipe/tests/test_recipe_api.py b/app/recipe/tests/test_recipe_api.py index ae4fece..822bf0d 100644 --- a/app/recipe/tests/test_recipe_api.py +++ b/app/recipe/tests/test_recipe_api.py @@ -8,7 +8,7 @@ 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, @@ -48,7 +48,6 @@ class PublicRecipeApiTests(TestCase): def setUp(self): self.client = APIClient() - self.user = create_user(email="user@example.com", password="test123") def test_auth_required(self): """Test auth is required to call API.""" @@ -203,3 +202,91 @@ def test_delete_other_users_recipe_error(self): 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 creating a recipe with new tags.""" + payload = { + "title": "Thai Prawn Curry", + "time_minutes": 30, + "price": Decimal("2.50"), + "tags": [{"name": "Thai"}, {"name": "Dinner"}], + } + 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 recipe with existing tag.""" + tag_indian = Tag.objects.create(user=self.user, name="Indian") + payload = { + "title": "Pongal", + "time_minutes": 60, + "price": Decimal("4.50"), + "tags": [{"name": "Indian"}, {"name": "Breakfast"}] + } + 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_indian, recipe.tags.all()) + for tag in payload["tags"]: + exists = recipe.tags.filter( + name=tag["name"], + user=self.user, + ).exists() + self.assertTrue(exists) + + def test_create_tag_on_update(self): + """Test creating tag when updating new recipe.""" + recipe = create_recipe(user=self.user) + + payload = {"tags": [{"name": "Lunch"}]} + 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="Lunch") + self.assertIn(new_tag, recipe.tags.all()) + + def test_update_recipe_assign_tag(self): + """Test assigning existing tag when updating a recipe.""" + tag_breakfast = Tag.objects.create(user=self.user, name="Breakfast") + recipe = create_recipe(user=self.user) + recipe.tags.add(tag_breakfast) + + tag_lunch = Tag.objects.create(user=self.user, name="Lunch") + payload = {"tags": [{"name": "Lunch"}]} + 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="Dessert") + 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 new file mode 100644 index 0000000..a98dddf --- /dev/null +++ b/app/recipe/tests/test_tags_api.py @@ -0,0 +1,94 @@ +"""Tests for tags 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 Tag + +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) + + +class PublicTagsApiTests(TestCase): + """Test unauthenticated API requests.""" + + def setUp(self): + self.client = APIClient() + + def test_auth_required(self): + """Test auth is required to call API.""" + res = self.client.get(TAGS_URL) + + self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED) + + +class PrivateTagsApiTests(TestCase): + """Test authenticated API requests.""" + + def setUp(self): + self.user = create_user() + self.client = APIClient() + self.client.force_authenticate(self.user) + + def test_retrieve_tags(self): + """Test retrieving a list of tags.""" + Tag.objects.create(user=self.user, name="Vegan") + Tag.objects.create(user=self.user, name="Dessert") + + res = self.client.get(TAGS_URL) + + tags = Tag.objects.all().order_by('-name') + serializer = TagSerializer(tags, many=True) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertEqual(res.data, serializer.data) + + def test_tags_limited_to_user(self): + """Test list of tags is limited to authenticated user.""" + user2 = create_user(email="user2@example.com") + Tag.objects.create(user=user2, name="Fruity") + tag = Tag.objects.create(user=self.user, name="Comfort Food") + + res = self.client.get(TAGS_URL) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + 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="After Dinner") + + payload = {"name": "Dessert"} + 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="Breakfast") + + 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/urls.py b/app/recipe/urls.py index 7914014..acbc2d0 100644 --- a/app/recipe/urls.py +++ b/app/recipe/urls.py @@ -7,6 +7,7 @@ router = DefaultRouter() router.register("recipes", views.RecipeViewSet) +router.register("tags", views.TagViewSet) app_name = "recipe" diff --git a/app/recipe/views.py b/app/recipe/views.py index 006cc18..ffbfc21 100644 --- a/app/recipe/views.py +++ b/app/recipe/views.py @@ -1,9 +1,9 @@ """Views for recipe APIs.""" -from rest_framework import viewsets +from rest_framework import viewsets, mixins from rest_framework.authentication import TokenAuthentication from rest_framework.permissions import IsAuthenticated -from core.models import Recipe +from core.models import Recipe, Tag from . import serializers @@ -28,3 +28,20 @@ def get_serializer_class(self): def perform_create(self, serializer): """Create a new recipe.""" serializer.save(user=self.request.user) + + +class TagViewSet( + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + viewsets.GenericViewSet +): + """Manage tags in the database.""" + serializer_class = serializers.TagSerializer + queryset = Tag.objects.all() + authentication_classes = [TokenAuthentication] + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """Retrieve tags for authenticated user.""" + return self.queryset.filter(user=self.request.user).order_by("-name")