diff --git a/README.md b/README.md index ef38980..44e2636 100644 --- a/README.md +++ b/README.md @@ -1853,4 +1853,5 @@ Ruta `localhost:8000/api/docs` ---- -Segunda parte -> [Recetas](./README2.md) +- Segunda parte -> [Recetas](./README2.md) +- Tercar parte -> [Tags](./README3.md) diff --git a/README2.md b/README2.md index 9647702..78ecc5f 100644 --- a/README2.md +++ b/README2.md @@ -18,7 +18,7 @@ - `PUT/PATCH` Actualizar receta - `DELETE` Borrar receta -### APIView vs Viewsets +## APIView vs Viewsets Una vista maneja un request a una URL, DRF usa clases con lógica reutilizable. DRF además soporta decoradores. @@ -149,10 +149,10 @@ RECIPES_URL = reverse('recipe:recipe-list') def create_recipe(user, **params): """Create and return a sample recipe.""" defaults = { - 'title': 'Titulo reseta de ejemplo', + 'title': 'Titulo receta de ejemplo', 'time_minutes': 31, 'price': Decimal('5.25'), - 'description': 'Descripción de ejmplo', + 'description': 'Descripción de ejemplo', 'link': 'https://defzn.kickto.net/blog', } defaults.update(params) @@ -283,3 +283,261 @@ urlpatterns = [ path('api/recipe', include('recipe.urls')), ] ``` + +### Test detalles receta API + +[`recipe/tests/test_recipe_api`](./app/recipe/tests/test_recipe_api.py) + +```py +... +from recipe.serializers import ( + RecipeSerializer, + RecipeDetailSerializer, +) + +... + +def detail_url(recipe_id): + """Create and return a recipe detail URL.""" + return reverse('recipe:recipe-deatil', args=[recipe_id]) + +... + +class PrivateRecipeApiTests(TestCase): + ... + + def test_get_recipe_detail(self): + """Test get recipe detail.""" + recipe = create_recipe(user=self.user) + + url = detail_url(recipe.id) + res = self.client.get(url) + + serializer = RecipeDetailSerializer(recipe) + self.assertEqual(res.data, serializer.data) +``` + +## Implementación Api detalles receta + +### Serializador para APIs detalles receta + +['recipe/serializer.py'](./app/recipe/serializers.py) + +```py +... + +class RecipeDetailSerializer(RecipeSerializer): + """Serializer for recipe detail view.""" + + class Meta(RecipeSerializer.Meta): + fields = RecipeSerializer.Meta.fields + ['description'] +``` + +### Views para APIs detalles receta + +Sobrescribiendo +[get_serializer_classself](https://www.django-rest-framework.org/api-guide/generic-views/#get_serializer_classself) +para usar `RecipeDetailSerializer`. Se añade la lógica para que al listar se +utilice `RecipeSerializer` + +[`recipe/views.py`](./app/recipe/views.py) + +```py +... +class RecipeViewSet(viewsets.ModelViewSet): + """View for manage recipe APIs.""" + serializer_class = serializers.RecipeDetailSerializer + ... + + def get_serializer_class(self): + """Return the serializer class for request.""" + if self.action == 'list': + return serializers.RecipeSerializer + return self.serializer_class +``` + +### Test creación de receta a travez de la API + +[`test_recipe_api.py`](./app/recipe/tests/test_recipe_api.py) + +```py +class PrivateRecipeApiTests(TestCase): + ... + + def test_create_recipe(self): + """Test creating a recipe.""" + payload = { + 'title': 'Titulo receta de ejemplo', + 'time_minutes': 16, + 'price': Decimal('5.99'), + } + res = self.client.post(RECIPES_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + recipe = Recipe.objects.get(id=res.data['id']) + for k, v in payload.items(): + self.assertEqual(getattr(recipe, k), v) + self.assertEqual(recipe.user, self.user) +``` + +Se hace la Comparación utilizando el método +[getattr](https://docs.python.org/3/library/functions.html#getattr) de python + +### Implementación de vista para creación de receta + +[`recipe/views.py`](./app/recipe/views.py) + +```py + ... + + def perform_create(self, serializer): + """Create a new recipe.""" + serializer.save(user=self.request.user) +``` + +## Tests adicionales + +### Refactorizando test_recipe_api + +[`test_recipe_api.py`](./app/recipe/tests/test_recipe_api.py) + +```diff +... + ++def create_user(**params): ++ """Create and create a new user.""" ++ return get_user_model().objects.create_user(**params) + +... + +class PrivateRecipeApiTests(TestCase): + """Test authenticated API requests.""" + + def setUp(self): + self.client = APIClient() +- self.user = get_user_model().objects.create_user( +- 'user@example.com', +- 'testpass123', +- ) ++ self.user = create_user(email='user@example.com', password='test123') + self.client.force_authenticate(self.user) + +... + + def test_recipe_list_limited_to_user(self): + """Test list of recipes is limited to authenticated user.""" +- other_user = get_user_model().objects.create_user( +- 'other@example.com', +- 'password123', +- ) ++ other_user = create_user(email='other@example.com', password='test123') + create_recipe(user=other_user) + create_recipe(user=self.user) + + ... +``` + +### Test actualización parcial de receta + +```py + ... + def test_partial_update(self): + """Test partial update of a recipe.""" + original_link = 'https://devfzn.kicnto.net/blog' + recipe = create_recipe( + user=self.user, + title='Titulo de la Receta de ejemplo', + link=original_link, + ) + + payload = {'title': 'Nuevo titulo de la receta de ejemplo'} + url = detail_url(recipe.id) + res = self.client.patch(url, payload) + + self.assertEqual(res.status_code, status.HTTP_200_OK) +``` + +### Test actualización completa de receta + +```py + def test_full_update(self): + """Test full update of recipe.""" + recipe = create_recipe( + user=self.user, + title='Titulo receta de ejemplo', + link='https://devfzn.kickto.net/blog', + description='Descripción receta de ejemplo', + ) + + payload = { + 'title': 'Titulo receta de ejemplo', + 'link': 'https://defzn.kickto.net/blog', + 'description': 'Descripción de ejemplo', + 'time_minutes': 10, + 'price': Decimal('3.65'), + } + url = detail_url(recipe.id) + res = self.client.put(url, payload) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + recipe.refresh_from_db() + for k, v in payload.items(): + self.assertEqual(getattr(recipe, k), v) + self.assertEqual(recipe.user, self.user) +``` + +### Test cambio de usuario dueño de receta + +```py + def test_update_user_returns_error(self): + """Test changing the recipe user results in an error.""" + new_user = create_user(email='user2@example.com', password='testpass123') + recipe = create_recipe(user=self.user) + + payload = {'user': new_user.id} + url = detail_url(recipe.id) + self.client.patch(url, payload) + + recipe.refresh_from_db() + self.assertEqual(recipe.user, self.user) +``` + +### Test eliminar receta + +```py + def test_delete_recipe(self): + """Test deleting a recipe sucessful.""" + recipe = create_recipe(user=self.user) + + url = detail_url(recipe.id) + res = self.client.delete(url) + + self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(Recipe.objects.filter(id=recipe.id).exists()) +``` + +### Test eliminar receta de otro usuario + +```py + def test_recipe_other_users_recipe_error(self): + """Test trying to delete another users recipe gives error.""" + new_user = create_user(email='user2@example.com', password='testpass123') + recipe = create_recipe(user=new_user) + + url = detail_url(recipe.id) + res = self.client.delete(url) + + self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) + self.assertTrue(Recipe.objects.filter(id=recipe.id).exists()) +``` + +## Probando API en el navegador + +URL `localhost:8000/api/docs/` + +![img](./imgs_readme/api_swagger_01.png) + +---- + +- Primera parte -> [Recepi API](./README.md) +- Tercera parte -> [Tags](./README3.md) diff --git a/README3.md b/README3.md new file mode 100644 index 0000000..e69de29 diff --git a/app/app/urls.py b/app/app/urls.py index dcd19b7..777efcb 100644 --- a/app/app/urls.py +++ b/app/app/urls.py @@ -26,5 +26,5 @@ urlpatterns = [ SpectacularSwaggerView.as_view(url_name='api-schema'), name='api-docs'), path('api/user/', include('user.urls')), - path('api/recipe', include('recipe.urls')), + path('api/recipe/', include('recipe.urls')), ] diff --git a/app/recipe/serializers.py b/app/recipe/serializers.py index b3bded3..7ce5914 100644 --- a/app/recipe/serializers.py +++ b/app/recipe/serializers.py @@ -13,3 +13,10 @@ class RecipeSerializer(serializers.ModelSerializer): 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'] diff --git a/app/recipe/tests/test_recipe_api.py b/app/recipe/tests/test_recipe_api.py index 67351cd..fba0344 100644 --- a/app/recipe/tests/test_recipe_api.py +++ b/app/recipe/tests/test_recipe_api.py @@ -1,9 +1,10 @@ """ -Test for reicpe APIs. +Test for recipe APIs. """ from decimal import Decimal +from operator import itemgetter -from django.contrib.auth import get_user_model +from django.contrib.auth import get_user, get_user_model from django.test import TestCase from django. urls import reverse @@ -12,19 +13,27 @@ from rest_framework.test import APIClient from core.models import Recipe -from recipe.serializers import RecipeSerializer +from recipe.serializers import ( + RecipeSerializer, + RecipeDetailSerializer, +) RECIPES_URL = reverse('recipe:recipe-list') +def detail_url(recipe_id): + """Create and return a recipe detail URL.""" + return reverse('recipe:recipe-detail', args=[recipe_id]) + + def create_recipe(user, **params): """Create and return a sample recipe.""" defaults = { - 'title': 'Titulo reseta de ejemplo', + 'title': 'Titulo receta de ejemplo', 'time_minutes': 31, 'price': Decimal('5.25'), - 'description': 'Descripción de ejmplo', + 'description': 'Descripción de ejemplo', 'link': 'https://defzn.kickto.net/blog', } defaults.update(params) @@ -33,6 +42,11 @@ def create_recipe(user, **params): return recipe +def create_user(**params): + """Create and create a new user.""" + return get_user_model().objects.create_user(**params) + + class PublicRecipeApiTests(TestCase): """Test unauthenticated API requests.""" @@ -51,10 +65,7 @@ class PrivateRecipeApiTests(TestCase): def setUp(self): self.client = APIClient() - self.user = get_user_model().objects.create_user( - 'user@example.com', - 'testpass123', - ) + self.user = create_user(email='user@example.com', password='testpass123') self.client.force_authenticate(self.user) def test_retrive_recipes(self): @@ -72,10 +83,7 @@ class PrivateRecipeApiTests(TestCase): def test_recipe_list_limited_to_user(self): """Test list of recipes is limited to authenticated user.""" - other_user = get_user_model().objects.create_user( - 'other@example.com', - 'password123', - ) + other_user = create_user(email='other@example.com', password='password123') create_recipe(user=other_user) create_recipe(user=self.user) @@ -85,3 +93,105 @@ class PrivateRecipeApiTests(TestCase): serializer = RecipeSerializer(recipes, many=True) self.assertEqual(res.status_code, status.HTTP_200_OK) self.assertEqual(res.data, serializer.data) + + def test_get_recipe_detail(self): + """Test get recipe detail.""" + recipe = create_recipe(user=self.user) + + url = detail_url(recipe.id) + res = self.client.get(url) + + serializer = RecipeDetailSerializer(recipe) + self.assertEqual(res.data, serializer.data) + + def test_create_recipe(self): + """Test creating a recipe.""" + payload = { + 'title': 'Titulo receta de ejemplo', + 'time_minutes': 16, + 'price': Decimal('5.99'), + } + res = self.client.post(RECIPES_URL, payload) + + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + recipe = Recipe.objects.get(id=res.data['id']) + for k, v in payload.items(): + self.assertEqual(getattr(recipe, k), v) + self.assertEqual(recipe.user, self.user) + + def test_partial_update(self): + """Test partial update of a recipe.""" + original_link = 'https://devfzn.kickto.net/acerca' + recipe = create_recipe( + user=self.user, + title='Titulo de la Receta de ejemplo', + link=original_link, + ) + + payload = {'title': 'Nuevo titulo de la receta de ejemplo'} + url = detail_url(recipe.id) + res = self.client.patch(url, payload) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + recipe.refresh_from_db() + self.assertEqual(recipe.title, payload['title']) + self.assertEqual(recipe.link, original_link) + self.assertEqual(recipe.user, self.user) + + def test_full_update(self): + """Test full update of recipe.""" + recipe = create_recipe( + user=self.user, + title='Titulo receta de ejemplo', + link='https://devfzn.kickto.net/blog', + description='Descripción receta de ejemplo', + ) + + payload = { + 'title': 'Titulo receta de ejemplo', + 'link': 'https://defzn.kickto.net/blog', + 'description': 'Descripción de ejemplo', + 'time_minutes': 10, + 'price': Decimal('3.65'), + } + url = detail_url(recipe.id) + res = self.client.put(url, payload) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + recipe.refresh_from_db() + for k, v in payload.items(): + self.assertEqual(getattr(recipe, k), v) + self.assertEqual(recipe.user, self.user) + + def test_update_user_returns_error(self): + """Test changing the recipe user results in an error.""" + new_user = create_user(email='user2@example.com', password='testpass123') + recipe = create_recipe(user=self.user) + + payload = {'user': new_user.id} + url = detail_url(recipe.id) + self.client.patch(url, payload) + + recipe.refresh_from_db() + self.assertEqual(recipe.user, self.user) + + def test_delete_recipe(self): + """Test deleting a recipe sucessful.""" + recipe = create_recipe(user=self.user) + + url = detail_url(recipe.id) + res = self.client.delete(url) + + self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(Recipe.objects.filter(id=recipe.id).exists()) + + def test_recipe_other_users_recipe_error(self): + """Test trying to delete another users recipe gives error.""" + new_user = create_user(email='user2@example.com', password='testpass123') + recipe = create_recipe(user=new_user) + + url = detail_url(recipe.id) + res = self.client.delete(url) + + self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND) + self.assertTrue(Recipe.objects.filter(id=recipe.id).exists()) diff --git a/app/recipe/views.py b/app/recipe/views.py index e47e50e..0f7050a 100644 --- a/app/recipe/views.py +++ b/app/recipe/views.py @@ -11,7 +11,7 @@ from recipe import serializers class RecipeViewSet(viewsets.ModelViewSet): """View for manage recipe APIs.""" - serializer_class = serializers.RecipeSerializer + serializer_class = serializers.RecipeDetailSerializer queryset = Recipe.objects.all() authentication_classes = [TokenAuthentication] permission_classes = [IsAuthenticated] @@ -19,3 +19,13 @@ class RecipeViewSet(viewsets.ModelViewSet): def get_queryset(self): """Retrieve recipes for authenticated user.""" return self.queryset.filter(user=self.request.user).order_by('-id') + + def get_serializer_class(self): + """Return the serializer class for request.""" + if self.action == 'list': + return serializers.RecipeSerializer + return self.serializer_class + + def perform_create(self, serializer): + """Create a new recipe.""" + serializer.save(user=self.request.user) diff --git a/imgs_readme/api_swagger_01.png b/imgs_readme/api_swagger_01.png new file mode 100644 index 0000000..c9c3666 Binary files /dev/null and b/imgs_readme/api_swagger_01.png differ