diff --git a/Dockerfile b/Dockerfile index 39f2150..7abb675 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,9 +12,9 @@ EXPOSE 8000 ARG DEV=false RUN python -m venv /py && \ /py/bin/pip install --upgrade pip && \ - apk add --update --no-cache postgresql-client && \ + apk add --update --no-cache postgresql-client jpeg-dev && \ apk add --update --no-cache --virtual .tmp-build-deps \ - build-base postgresql-dev musl-dev && \ + build-base postgresql-dev musl-dev zlib zlib-dev && \ /py/bin/pip install -r /tmp/requirements.txt && \ if [ $DEV = "true" ]; \ then /py/bin/pip install -r /tmp/requirements.dev.txt ; \ @@ -24,7 +24,11 @@ RUN python -m venv /py && \ adduser \ --disabled-password \ --no-create-home \ - django-user + django-user && \ + mkdir -p /vol/web/media && \ + mkdir -p /vol/web/static && \ + chown -R django-user:django-user /vol && \ + chmod -R 755 /vol ENV PATH="/py/bin:$PATH" diff --git a/app/app/settings.py b/app/app/settings.py index 89785a6..898d571 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -123,14 +123,11 @@ USE_TZ = True +STATIC_URL = '/static/static/' +MEDIA_URL = '/static/media/' -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/3.2/howto/static-files/ - -STATIC_URL = '/static/' - -# Default primary key field type -# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field +MEDIA_ROOT = '/vol/web/media' +STATIC_ROOT = '/vol/web/static' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' @@ -139,3 +136,7 @@ REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', } + +SPECTACULAR_SETTINGS = { + 'COMPONENT_SPLIT_REQUEST': True, +} diff --git a/app/app/urls.py b/app/app/urls.py index d84b2d5..2e129e0 100644 --- a/app/app/urls.py +++ b/app/app/urls.py @@ -19,6 +19,8 @@ ) from django.contrib import admin from django.urls import path, include +from django.conf.urls.static import static +from django.conf import settings urlpatterns = [ path('admin/', admin.site.urls), @@ -31,3 +33,13 @@ path('api/user/', include('user.urls')), path('api/recipe/', include('recipe.urls')) ] + +if settings.DEBUG: + urlpatterns += static( + settings.MEDIA_URL, + document_root=settings.MEDIA_ROOT + ) + urlpatterns += static( + settings.STATIC_URL, + document_root=settings.STATIC_ROOT + ) diff --git a/app/core/migrations/0007_recipe_image.py b/app/core/migrations/0007_recipe_image.py new file mode 100644 index 0000000..57ce0f7 --- /dev/null +++ b/app/core/migrations/0007_recipe_image.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.25 on 2024-09-06 00:30 + +import core.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0006_ingredient_name'), + ] + + operations = [ + migrations.AddField( + model_name='recipe', + name='image', + field=models.ImageField(null=True, upload_to=core.models.recipe_image_file_path), + ), + ] diff --git a/app/core/models.py b/app/core/models.py index 20db07d..9d3047b 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -1,11 +1,22 @@ """ Database models. """ +import uuid +import os + from django.conf import settings from django.db import models from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin +def recipe_image_file_path(instance, filename): + """Geerate file path for new recipe image.""" + ext = os.path.splitext(filename)[1] + filename = f'{uuid.uuid4()}{ext}' + + return os.path.join('uploads', 'recipe', filename) + + class UserManager(BaseUserManager): """Manager for users.""" @@ -53,6 +64,7 @@ class Recipe(models.Model): link = models.CharField(max_length=255, blank=True) tags = models.ManyToManyField('Tag') ingredients = models.ManyToManyField('Ingredient') + image = models.ImageField(null=True, upload_to=recipe_image_file_path) def __str__(self): return self.title diff --git a/app/core/tests/test_models.py b/app/core/tests/test_models.py index 6740cc5..46fc048 100644 --- a/app/core/tests/test_models.py +++ b/app/core/tests/test_models.py @@ -1,6 +1,7 @@ """ Tests for models """ +from unittest.mock import patch from decimal import Decimal from django.test import TestCase @@ -82,4 +83,12 @@ def test_create_ingredient(self): self.assertEqual(str(ingredient), ingredient.name) + @patch('core.models.uuid.uuid4') + def test_recipe_file_name_uuid(self, mock_uuid): + """Test generating image path.""" + uuid = 'test-uuid' + mock_uuid.return_value = uuid + file_path = models.recipe_image_file_path(None, 'example.jpg') + + self.assertEqual(file_path, f'uploads/recipe/{uuid}.jpg') diff --git a/app/recipe/serializers.py b/app/recipe/serializers.py index b480d8f..52d52c9 100644 --- a/app/recipe/serializers.py +++ b/app/recipe/serializers.py @@ -8,20 +8,21 @@ from core.models import Recipe, Tag, Ingredient +class BaseIdNameSerializerMeta: + fields = ['id', 'name'] + read_only_fields = ['id'] + + class TagSerializer(serializers.ModelSerializer): - class Meta: + class Meta(BaseIdNameSerializerMeta): model = Tag - fields = ['id', 'name'] - read_only_fields = ['id'] class IngredientSerializer(serializers.ModelSerializer): - class Meta: + class Meta(BaseIdNameSerializerMeta): model = Ingredient - fields = ['id', 'name'] - read_only_fields = ['id'] class RecipeSerializer(serializers.ModelSerializer): @@ -82,5 +83,13 @@ def update(self, instance, validated_data): class RecipeDetailSerializer(RecipeSerializer): class Meta(RecipeSerializer.Meta): - fields = RecipeSerializer.Meta.fields + ['description'] + fields = RecipeSerializer.Meta.fields + ['description', 'image'] + +class RecipeImageSerializer(serializers.ModelSerializer): + + class Meta: + model = Recipe + fields = ['id', 'image'] + read_only_fields = ['id'] + extra_kwargs = {'image': {'required': 'True'}} diff --git a/app/recipe/tests/base_test_name_user_model_api.py b/app/recipe/tests/base_test_name_user_model_api.py index 99ca197..8d580be 100644 --- a/app/recipe/tests/base_test_name_user_model_api.py +++ b/app/recipe/tests/base_test_name_user_model_api.py @@ -1,5 +1,7 @@ from abc import ABC +from decimal import Decimal + from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse @@ -7,6 +9,14 @@ from rest_framework import status from rest_framework.test import APIClient +from core.models import Recipe, Ingredient, Tag + + +RELATED_FIELD_MAP = { + Ingredient: 'ingredients', + Tag: 'tags', +} + def create_user(email='user@example.com', password='testpass123'): return get_user_model().objects.create(email=email, password=password) @@ -54,14 +64,14 @@ def test_retrieve_model(self): def test_model_limited_to_user(self): user2 = create_user(email='user2@example.com') self.model.objects.create(user=user2, name="Meat") - tag = self.model.objects.create(user=self.user, name="Fruity") + item = self.model.objects.create(user=self.user, name="Fruity") res = self.client.get(self.list_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) + self.assertEqual(res.data[0]['name'], item.name) + self.assertEqual(res.data[0]['id'], item.id) def test_update_model(self): model= self.model.objects.create(user=self.user, name="Name") @@ -85,3 +95,45 @@ def test_delete_model(self): self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT) models = self.model.objects.filter(user=self.user) self.assertFalse(models.exists()) + + def test_filter_model_assigned_to_recipes(self): + obj1 = self.model.objects.create(user=self.user, name="Object 1") + obj2 = self.model.objects.create(user=self.user, name="Object 2") + recipe = Recipe.objects.create( + title='Apple Crumble', + time_minutes=5, + price=Decimal('4.50'), + user=self.user, + ) + getattr(recipe, RELATED_FIELD_MAP.get(self.model)).add(obj1) + + res = self.client.get(self.list_url, {'assigned_only': 1}) + + s1 = self.serializer(obj1) + s2 = self.serializer(obj2) + self.assertIn(s1.data, res.data) + self.assertNotIn(s2.data, res.data) + + def test_filtered_objects_unique(self): + obj = self.model.objects.create(user=self.user, name="Object") + self.model.objects.create(user=self.user, name="Different Object") + recipe1 = Recipe.objects.create( + title='Eggs Benedict', + time_minutes=60, + price=Decimal('7.00'), + user=self.user + ) + recipe2 = Recipe.objects.create( + title='Herb Eggs', + time_minutes=20, + price=Decimal('4.00'), + user=self.user + ) + + getattr(recipe1, RELATED_FIELD_MAP.get(self.model)).add(obj) + getattr(recipe2, RELATED_FIELD_MAP.get(self.model)).add(obj) + + res = self.client.get(self.list_url, {'assigned_only': 1}) + + self.assertEqual(len(res.data), 1) + diff --git a/app/recipe/tests/test_ingredients_apy.py b/app/recipe/tests/test_ingredients_api.py similarity index 100% rename from app/recipe/tests/test_ingredients_apy.py rename to app/recipe/tests/test_ingredients_api.py diff --git a/app/recipe/tests/test_recipe_api.py b/app/recipe/tests/test_recipe_api.py index 0841bc5..a9ec00a 100644 --- a/app/recipe/tests/test_recipe_api.py +++ b/app/recipe/tests/test_recipe_api.py @@ -2,6 +2,10 @@ Tests for recipe APIs. """ from decimal import Decimal +import tempfile +import os + +from PIL import Image from django.contrib.auth import get_user_model from django.test import TestCase @@ -30,6 +34,11 @@ def detail_url(recipe_id): return reverse('recipe:recipe-detail', args=[recipe_id]) +def image_upload_url(recipe_id): + """Create and return an image upload URL""" + return reverse('recipe:recipe-upload-image', args=[recipe_id]) + + def create_recipe(user, **params): """Create and return a same recipe""" defaults = { @@ -373,3 +382,83 @@ def test_clear_recipe_ingredients(self): self.assertEqual(res.status_code, status.HTTP_200_OK) self.assertEqual(recipe.ingredients.count(), 0) + + def test_filter_by_tags(self): + r1 = create_recipe(user=self.user, title = 'Thai vegetable curry') + r2 = create_recipe(user=self.user, title='Aubergine with Tahini') + tag1 = Tag.objects.create(user=self.user, name='Vegan') + tag2 = Tag.objects.create(user=self.user, name='Vegeterian') + r1.tags.add(tag1) + r2.tags.add(tag2) + r3 = create_recipe(user=self.user, title='Fish and Chips') + + params = {'tags': f'{tag1.id},{tag2.id}'} + res = self.client.get(RECIPES_URL, params=params) + + s1 = RecipeSerializer(r1) + s2 = RecipeSerializer(r2) + s3 = RecipeSerializer(r3) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertIn(s1.data, res.data) + self.assertIn(s2.data, res.data) + self.assertNotIn(s3.data, res.data) + + def test_filter_by_ingredients(self): + r1 = create_recipe(user=self.user, title = 'Thai vegetable curry') + r2 = create_recipe(user=self.user, title='Aubergine with Tahini') + i1 = Ingredient.objects.create(user=self.user, name='Potato') + i2 = Ingredient.objects.create(user=self.user, name='Coconut Milk') + r1.ingredients.add(i1) + r2.ingredients.add(i2) + r3 = create_recipe(user=self.user, title='Fish and Chips') + + params = {'ingredients': f'{i1.id},{i2.id}'} + res = self.client.get(RECIPES_URL, params) + + s1 = RecipeSerializer(r1) + s2 = RecipeSerializer(r2) + s3 = RecipeSerializer(r3) + + self.assertIn(s1.data, res.data) + self.assertIn(s2.data, res.data) + self.assertNotIn(s3.data, res.data) + + +class ImageUploadTests(TestCase): + """Tests for the image upload API.""" + + def setUp(self): + self.client = APIClient() + self.user = get_user_model().objects.create_user( + 'user@example.com', + 'password123', + ) + self.client.force_authenticate(self.user) + self.recipe = create_recipe(user=self.user) + + def tearDown(self): + self.recipe.image.delete() + + def test_upload_image(self): + """Test uploading an image to a recipe.""" + url = image_upload_url(self.recipe.id) + with tempfile.NamedTemporaryFile(suffix='.jpg') as image_file: + img = Image.new('RGB', (10, 10)) + img.save(image_file, format='JPEG') + image_file.seek(0) + payload = {'image': image_file} + res = self.client.post(url, payload, format='multipart') + + self.recipe.refresh_from_db() + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertIn('image', res.data) + self.assertTrue(os.path.exists(self.recipe.image.path)) + + def test_upload_image_bad_request(self): + url = image_upload_url(self.recipe.id) + payload = {'image': 'bad'} + + res = self.client.post(url, payload, format='multipart') + + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/app/recipe/tests/test_tags_apy.py b/app/recipe/tests/test_tags_api.py similarity index 100% rename from app/recipe/tests/test_tags_apy.py rename to app/recipe/tests/test_tags_api.py diff --git a/app/recipe/views.py b/app/recipe/views.py index 013358c..ecf4659 100644 --- a/app/recipe/views.py +++ b/app/recipe/views.py @@ -1,25 +1,67 @@ """ Views for the recipe api """ -from rest_framework import viewsets, authentication, permissions, mixins +from drf_spectacular.utils import ( + extend_schema_view, + extend_schema, + OpenApiParameter, + OpenApiTypes +) +from rest_framework import viewsets, authentication, permissions, mixins, status +from rest_framework.decorators import action +from rest_framework.response import Response -from recipe import serializers from core.models import Recipe, Tag, Ingredient +from recipe import serializers +@extend_schema_view( + list=extend_schema( + parameters=[ + OpenApiParameter( + 'tags', + OpenApiTypes.STR, + description='Comma separated list of tag IDs to filter' + ), + OpenApiParameter( + 'ingredients', + OpenApiTypes.STR, + description='Comma separated list of ingredient IDs to filter', + ) + ] + ) +) class RecipeViewSet(viewsets.ModelViewSet): serializer_class = serializers.RecipeDetailSerializer queryset = Recipe.objects.all() authentication_classes = [authentication.TokenAuthentication] permission_classes = [permissions.IsAuthenticated] + def __params_to_ints(self, qs): + """Convert a list of strings to integers.""" + return [int(str_id) for str_id in qs.split(',')] + def get_queryset(self): - return Recipe.objects.filter(user=self.request.user).order_by('-id') + tags = self.request.query_params.get('tags') + ingredients = self.request.query_params.get('ingredients') + queryset = self.queryset + if tags: + tag_ids = self._params_to_ints(tags) + queryset = queryset.filter(tags__id__in=tag_ids) + if ingredients: + ingredient_ids = self.__params_to_ints(ingredients) + queryset = queryset.filter(ingredients__id__in=ingredient_ids) + + return queryset.filter( + user=self.request.user + ).order_by('-id').distinct() def get_serializer_class(self): """Return the serializer class for request.""" if self.action == 'list': return serializers.RecipeSerializer + elif self.action == 'upload_image': + return serializers.RecipeImageSerializer return self.serializer_class @@ -27,7 +69,28 @@ def perform_create(self, serializer): """Create a new Recipe""" serializer.save(user=self.request.user) + @action(methods=['POST'], detail=True, url_path='upload-image') + def upload_image(self, request, pk=None): + """Upload an image to a recipe""" + recipe = self.get_object() + serializer = self.get_serializer(recipe, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +@extend_schema_view( + list=extend_schema( + OpenApiParameter( + 'assigned_only', + OpenApiTypes.INT, enum=[0, 1], + description='Filter by items assigned to recipes.' + ) + ) +) class BaseRecipeAttrViewSet(mixins.ListModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin, @@ -36,7 +99,16 @@ class BaseRecipeAttrViewSet(mixins.ListModelMixin, permission_classes = [permissions.IsAuthenticated] def get_queryset(self): - return self.queryset.filter(user=self.request.user).order_by('-name') + assigned_only = bool( + int(self.request.query_params.get('assigned_only', 0)) + ) + queryset = self.queryset + if assigned_only: + queryset = queryset.filter(recipe__isnull=False) + + return queryset.filter( + user=self.request.user + ).order_by('-name').distinct() class TagViewSet(BaseRecipeAttrViewSet): diff --git a/docker-compose.yml b/docker-compose.yml index e268b86..1e0f7be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: - "8000:8000" volumes: - ./app:/app + - dev-static-data:/vol/web command: > sh -c "python manage.py wait_for_db && python manage.py migrate && @@ -33,4 +34,5 @@ services: volumes: dev-db-data: + dev-static-data: diff --git a/requirements.txt b/requirements.txt index 8e1d79e..35aade3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ Django>=3.2.4,<3.3 djangorestframework>=3.12.4,<3.13 psycopg2>=2.8.6,<2.11 drf-spectacular>=0.15.1,<0.16 +Pillow>=8.2.0,<8.3.0 \ No newline at end of file