diff --git a/README.md b/README.md index 44e2636..43b2537 100644 --- a/README.md +++ b/README.md @@ -1854,4 +1854,4 @@ Ruta `localhost:8000/api/docs` ---- - Segunda parte -> [Recetas](./README2.md) -- Tercar parte -> [Tags](./README3.md) +- Tercera parte -> [Tags](./README3.md) diff --git a/README3.md b/README3.md index e69de29..c64082a 100644 --- a/README3.md +++ b/README3.md @@ -0,0 +1,208 @@ +# Tags API + +- Añadir capadidad 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 + +## Tag endpoint + +- `/api/recipe/tags` + + | Método HTTP | Función | + | - | - | + | `POST`| Crear tag | + | `PUT/PATCH` | Actualizar tag | + | `DELETE` | Borrar tag | + | `GET` | Listar tags disponibles | + + +## Test Tag Model + +[`core/tests/test_models.py`](./app/core/tests/test_models.py) + +```py +... + +def create_user(email='user@example.com', password='testpass123'): + """Create and return a new user.""" + return get_user_model().objects.create_user(email, password) + ... + + def test_create_tag(self): + """Test creating a tag is sucessfull.""" + user = create_user() + tag = models.Tag.objects.create(user=user, name='Tag1') + + self.assertEqual(str(tag), tag.name) +``` + +## Tag Model + +- `name` Nombre del tag a crear +- `user` Usuario creador/dueño del tag + +[`core/models.py`](./app/core/models.py) + +```py +... + +class Recipe(models.Model): + """Recipe object.""" + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + ) + title = models.CharField(max_length=255) + description = models.TextField(blank=True) + time_minutes = models.IntegerField() + price = models.DecimalField(max_digits=5, decimal_places=2, blank=True) + link = models.CharField(max_length=255, blank=True) + tags = models.ManyToManyField('Tag') # <--- Nueva linea + + 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 +``` + +### Crear migraciones + +`docker compose run --rm app sh -c python manage.py makemigrations` + +```py +[+] Creating 1/0 + ✔ Container recipes_api_django-db-1 Running 0.0s +Migrations for 'core': + core/migrations/0003_tag_recipe_tags.py + - Create model Tag + - Add field tags to recipe +``` + +### Registrar aplicación tag en el administador + +`app/core/admin.py` + +```py +admin.site.register(models.Tag) +``` + +## Test tags API + +[`recipe/tests/test_tags_api.py`](./app/recipe/tests/test_tags_api.py) + +```py +... + +TAGS_URL = reverse('recipe:tag-list') + +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 for retriveing tags.""" + 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='Omnivore') + + 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='Carnivore') + + 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) +``` + +### Serializador Tag API + +[`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'] +``` + +### Views tags APIs + +[`recipe/views.py`](./app/recipe/views.py) + +```py +... + +class TagViewSet(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): + """Filter queryset to authenticated user.""" + return self.queryset.filter(user=self.request.user).order_by('-id') +``` + +### URLS tags APIs + +[`recipe/urls.py`](./app/recipe/urls.py) + +```py +... +router.register('recipes', views.RecipeViewSet) +router.register('tags', views.TagViewSet) +... +``` + +## Test Update Tags + + diff --git a/app/core/admin.py b/app/core/admin.py index e5b9bfc..d04dc7e 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..cbd08c4 --- /dev/null +++ b/app/core/migrations/0003_tag_recipe_tags.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.5 on 2023-10-10 02:08 + +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 046c020..7d0f606 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -56,6 +56,19 @@ class Recipe(models.Model): time_minutes = models.IntegerField() price = models.DecimalField(max_digits=5, decimal_places=2, blank=True) 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 0ac5a3b..6c26002 100644 --- a/app/core/tests/test_models.py +++ b/app/core/tests/test_models.py @@ -9,6 +9,11 @@ from django.contrib.auth import get_user_model 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.""" @@ -16,10 +21,7 @@ class ModelTests(TestCase): """Test creating a user with an email is sucessfull.""" email = 'test@example.com' password = 'testpass123' - user = get_user_model().objects.create_user( - email=email, - password=password, - ) + user = create_user(email, password) self.assertEqual(user.email, email) self.assertTrue(user.check_password(password)) @@ -32,13 +34,13 @@ class ModelTests(TestCase): ['test4@example.COM', 'test4@example.com'], ] for email, expected in sample_emails: - user = get_user_model().objects.create_user(email, 'sample123') + user = create_user(email=email) self.assertEqual(user.email, expected) def test_new_user_withouth_email_raises_error(self): """Test that creating a user withouth an email raises a ValueError.""" with self.assertRaises(ValueError): - get_user_model().objects.create_user('', 'test123') + create_user('', 'test123') def test_create_superuser(self): """Test creating a superuser.""" @@ -51,10 +53,7 @@ class ModelTests(TestCase): def test_create_recipe(self): """Test creating a recipe is successful.""" - user = get_user_model().objects.create_user( - 'test@example.com', - 'test123', - ) + user = create_user() recipe = models.Recipe.objects.create( user=user, title='Nombre receta ejemplo', @@ -64,3 +63,10 @@ class ModelTests(TestCase): ) self.assertEqual(str(recipe), recipe.title) + + def test_create_tag(self): + """Test creating a tag is sucessfull.""" + 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 7ce5914..d8d9dcc 100644 --- a/app/recipe/serializers.py +++ b/app/recipe/serializers.py @@ -3,7 +3,10 @@ Serializers for recipe APIs """ from rest_framework import serializers -from core.models import Recipe +from core.models import ( + Recipe, + Tag, +) class RecipeSerializer(serializers.ModelSerializer): @@ -20,3 +23,12 @@ class RecipeDetailSerializer(RecipeSerializer): class Meta(RecipeSerializer.Meta): fields = RecipeSerializer.Meta.fields + ['description'] + + +class TagSerializer(serializers.ModelSerializer): + """Serializer for tags.""" + + class Meta: + model = Tag + fields = ['id', 'name'] + read_only_fields = ['id'] diff --git a/app/recipe/tests/test_tags_api.py b/app/recipe/tests/test_tags_api.py new file mode 100644 index 0000000..e895e8c --- /dev/null +++ b/app/recipe/tests/test_tags_api.py @@ -0,0 +1,68 @@ +""" +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 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 for retriveing tags.""" + 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='Omnivore') + + 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='Carnivore') + + 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) diff --git a/app/recipe/urls.py b/app/recipe/urls.py index b254fa3..138b73c 100644 --- a/app/recipe/urls.py +++ b/app/recipe/urls.py @@ -1,7 +1,10 @@ """ URL mappings for the recipe app. """ -from django.urls import path, include +from django.urls import ( + path, + include, +) from rest_framework.routers import DefaultRouter @@ -10,6 +13,7 @@ from recipe import views 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 0f7050a..c7739d5 100644 --- a/app/recipe/views.py +++ b/app/recipe/views.py @@ -1,11 +1,17 @@ """ Views for the 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 recipe import serializers @@ -29,3 +35,15 @@ class RecipeViewSet(viewsets.ModelViewSet): def perform_create(self, serializer): """Create a new recipe.""" serializer.save(user=self.request.user) + + +class TagViewSet(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): + """Filter queryset to authenticated user.""" + return self.queryset.filter(user=self.request.user).order_by('-name') diff --git a/imgs_readme/api_swagger_01.png b/imgs_readme/api_swagger_01.png index c9c3666..7a13e55 100644 Binary files a/imgs_readme/api_swagger_01.png and b/imgs_readme/api_swagger_01.png differ