Skip to content

Commit

Permalink
Add tags API
Browse files Browse the repository at this point in the history
  • Loading branch information
marcintomiczek committed Feb 23, 2024
1 parent a15a7b7 commit 79b5fe7
Show file tree
Hide file tree
Showing 9 changed files with 299 additions and 7 deletions.
1 change: 1 addition & 0 deletions app/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ class UserAdmin(BaseUserAdmin):

admin.site.register(models.User, UserAdmin)
admin.site.register(models.Recipe)
admin.site.register(models.Tag)
28 changes: 28 additions & 0 deletions app/core/migrations/0003_tag_recipe_tags.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
12 changes: 12 additions & 0 deletions app/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 13 additions & 1 deletion app/core/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
from core import models


def create_user(email="[email protected]", password="testpass123"):
"""Create and return a new user."""
return get_user_model().objects.create_user(email, password)


class ModelTests(TestCase):
"""Test models."""

Expand Down Expand Up @@ -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(
"[email protected]", "testpass123",
)
recipe = models.Recipe.objects.create(
Expand All @@ -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)
44 changes: 42 additions & 2 deletions app/recipe/serializers.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand Down
91 changes: 89 additions & 2 deletions app/recipe/tests/test_recipe_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -48,7 +48,6 @@ class PublicRecipeApiTests(TestCase):

def setUp(self):
self.client = APIClient()
self.user = create_user(email="[email protected]", password="test123")

def test_auth_required(self):
"""Test auth is required to call API."""
Expand Down Expand Up @@ -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)
94 changes: 94 additions & 0 deletions app/recipe/tests/test_tags_api.py
Original file line number Diff line number Diff line change
@@ -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="[email protected]", 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="[email protected]")
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())
1 change: 1 addition & 0 deletions app/recipe/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

router = DefaultRouter()
router.register("recipes", views.RecipeViewSet)
router.register("tags", views.TagViewSet)

app_name = "recipe"

Expand Down
21 changes: 19 additions & 2 deletions app/recipe/views.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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")

0 comments on commit 79b5fe7

Please sign in to comment.