diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..8a7536e --- /dev/null +++ b/.env.sample @@ -0,0 +1,5 @@ +DB_NAME = db_name +DB_USER = db_user +DB_PASS = change_me +DJANGO_SECRET_KEY = change_me +DJANGO_ALLOWED_HOSTS = localhost \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e92a1a8..03038f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,7 @@ ENV PYTHONUNBUFFERED 1 # Copy the requirements files and the app code into the container COPY ./requirements.txt /tmp/requirements.txt COPY ./requirements.dev.txt /tmp/requirements.dev.txt +COPY ./scripts /scripts COPY ./app /app # Set the working directory to /app @@ -28,7 +29,7 @@ RUN python -m venv /py && \ /py/bin/pip install --upgrade pip && \ apk add --update --no-cache postgresql-client jpeg-dev && \ apk add --update --no-cache --virtual .tmp-build-deps \ - build-base postgresql-dev musl-dev zlib zlib-dev &&\ + build-base postgresql-dev musl-dev zlib zlib-dev linux-headers &&\ /py/bin/pip install -r /tmp/requirements.txt && \ if [ $DEV = "true" ]; \ then /py/bin/pip install -r /tmp/requirements.dev.txt; \ @@ -42,10 +43,13 @@ RUN python -m venv /py && \ mkdir -p /vol/web/media && \ mkdir -p /vol/web/static && \ chown -R django-user:django-user /vol && \ - chmod -R 755 /vol + chmod -R 755 /vol && \ + chmod -R +x /scripts # Add the Python binary path to the container's PATH environment variable -ENV PATH="/py/bin:$PATH" +ENV PATH="/scripts:/py/bin:$PATH" # Set the user to run the container as -USER django-user \ No newline at end of file +USER django-user + +CMD ["run.sh"] \ No newline at end of file diff --git a/app/app/calc.py b/app/app/calc.py deleted file mode 100644 index 10e90b2..0000000 --- a/app/app/calc.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Calculator functions""" - - -def add(x, y): - """Add two numbers""" - return x + y - - -def subtract(x, y): - return x - y diff --git a/app/app/settings.py b/app/app/settings.py index c84fc72..65756f0 100644 --- a/app/app/settings.py +++ b/app/app/settings.py @@ -22,18 +22,22 @@ # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-^&1#np6re4&e%g^sbe_$woefi7*#wa_+%ta*kc)$)31y#fv97v' +SECRET_KEY = os.environ.get('SECRET_KEY', 'change_me') # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = ['0.0.0.0'] +DEBUG = bool(int(os.environ.get('DEBUG', 0))) +ALLOWED_HOSTS = [] +ALLOWED_HOSTS.extend( + filter( + None, + os.environ.get('ALLOWED_HOSTS', '').split(','), + ) + ) # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -146,4 +150,8 @@ REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} + +SPECTACULAR_SETTINGS = { + 'COMPONENT_SPLIT_REQUEST': True, } \ No newline at end of file diff --git a/app/app/tests.py b/app/app/tests.py deleted file mode 100644 index 2d37bae..0000000 --- a/app/app/tests.py +++ /dev/null @@ -1,24 +0,0 @@ -"""This file contains the tests for the app.""" - -from django.test import SimpleTestCase -from app import calc - - -class CalcTest(SimpleTestCase): - """Test the calc module""" - - def test_add(self): - """Test the add function """ - self.assertEqual(calc.add(3, 8), 11) - self.assertEqual(calc.add(-3, 8), 5) - self.assertEqual(calc.add(-3, -8), -11) - self.assertEqual(calc.add(3, -8), -5) - - -class SubstractNumbers(SimpleTestCase): - """ Test the calc module """ - - def test_substract(self): - """ Test the substract function """ - res = calc.subtract(5, 11) - self.assertEqual(res, -6) \ No newline at end of file diff --git a/app/core/admin.py b/app/core/admin.py index 1680035..e4aa186 100644 --- a/app/core/admin.py +++ b/app/core/admin.py @@ -31,4 +31,4 @@ class UserAdmin(BaseUserAdmin): admin.site.register(models.User, UserAdmin) admin.site.register(models.Recipe) admin.site.register(models.Tag) -admin.site.register(models.Ingredient) \ No newline at end of file +admin.site.register(models.Ingredient) diff --git a/app/recipe/serializers.py b/app/recipe/serializers.py index 53d3eed..3bb710b 100644 --- a/app/recipe/serializers.py +++ b/app/recipe/serializers.py @@ -84,7 +84,7 @@ class RecipeDetailSerializer(RecipeSerializer): '''Serializer for recipe detail objects''' class Meta(RecipeSerializer.Meta): - fields = RecipeSerializer.Meta.fields + ['description'] + fields = RecipeSerializer.Meta.fields + ['description', 'image'] read_only_fields = ['id'] class RecipeImageSerializer(serializers.ModelSerializer): diff --git a/app/recipe/tests/test_ingredients_api.py b/app/recipe/tests/test_ingredients_api.py index dabddb6..db1bf1d 100644 --- a/app/recipe/tests/test_ingredients_api.py +++ b/app/recipe/tests/test_ingredients_api.py @@ -1,4 +1,6 @@ """Test for the ingredients API""" +from decimal import Decimal + from django.contrib.auth import get_user_model from django.urls import reverse from django.test import TestCase @@ -6,7 +8,7 @@ from rest_framework import status from rest_framework.test import APIClient -from core.models import Ingredient +from core.models import Ingredient, Recipe from recipe.serializers import IngredientSerializer @@ -88,4 +90,54 @@ def test_delete_ingredient_successful(self): res = self.client.get(INGREDIENTS_URL) self.assertEqual(res.status_code, status.HTTP_200_OK) - self.assertEqual(len(res.data), 0) \ No newline at end of file + self.assertEqual(len(res.data), 0) + + def test_filter_ingredients_assigned_to_recipe(self): + """Test returning ingredients by those assigned to recipes""" + ingredient1 = Ingredient.objects.create(user=self.user, name='Apples') + ingredient2 = Ingredient.objects.create(user=self.user, name='Turkey') + recipe = Recipe.objects.create( + title='Apple crumble', + time_minutes=5, + price=10.00, + user=self.user, + ) + recipe.ingredients.add(ingredient1) + + res = self.client.get( + INGREDIENTS_URL, + {'assigned_only': 1} + ) + + serializer1 = IngredientSerializer(ingredient1) + serializer2 = IngredientSerializer(ingredient2) + + self.assertIn(serializer1.data, res.data) + self.assertNotIn(serializer2.data, res.data) + + def test_filter_ingredients_assigned_unique(self): + """Test filtering tags by assigned returns unique items""" + ingredient = Ingredient.objects.create(user=self.user, name='Eggs') + Ingredient.objects.create(user=self.user, name='Cheese') + recipe1 = Recipe.objects.create( + title='Egg benedict', + time_minutes=30, + price=12.00, + user=self.user, + ) + recipe1.ingredients.add(ingredient) + recipe2 = Recipe.objects.create( + title='Coriander eggs on toast', + time_minutes=20, + price=5.00, + user=self.user, + ) + recipe2.ingredients.add(ingredient) + + res = self.client.get( + INGREDIENTS_URL, + {'assigned_only': 1} + ) + + self.assertEqual(len(res.data), 1) + self.assertEqual(res.data[0]['name'], ingredient.name) \ No newline at end of file diff --git a/app/recipe/tests/test_recipe_api.py b/app/recipe/tests/test_recipe_api.py index 74b4eec..610262c 100644 --- a/app/recipe/tests/test_recipe_api.py +++ b/app/recipe/tests/test_recipe_api.py @@ -362,6 +362,50 @@ 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): + """Test filtering recipe by tags.""" + r1 = create_recipe(user= self.user, title="Thai Red Curry") + r2 = create_recipe(user= self.user, title="Spaghetti Aubergine Carbonara") + tags1 = Tag.objects.create(user=self.user, name='Vegan') + tags2 = Tag.objects.create(user=self.user, name='Vegetarian') + r1.tags.add(tags1) + r2.tags.add(tags2) + r3 = create_recipe(user= self.user, title="Fish and Chips") + + params = {'tags': f'{tags1.id},{tags2.id}'} + rs = self.client.get(RECIPE_URL, params) + + serializer1 = RecipeSerializer(r1) + serializer2 = RecipeSerializer(r2) + serializer3 = RecipeSerializer(r3) + + self.assertEqual(rs.status_code, status.HTTP_200_OK) + self.assertIn(serializer1.data, rs.data) + self.assertIn(serializer2.data, rs.data) + self.assertNotIn(serializer3.data, rs.data) + + def test_filter_by_ingredients(self): + """Test filtering recipe by ingredients.""" + r1 = create_recipe(user= self.user, title="Thai Red Curry") + r2 = create_recipe(user= self.user, title="Spaghetti Aubergine Carbonara") + ingredient1 = Ingredient.objects.create(user=self.user, name='Coconut Milk') + ingredient2 = Ingredient.objects.create(user=self.user, name='Aubergine') + r1.ingredients.add(ingredient1) + r2.ingredients.add(ingredient2) + r3 = create_recipe(user= self.user, title="Fish and Chips") + + params = {'ingredients': f'{ingredient1.id},{ingredient2.id}'} + rs = self.client.get(RECIPE_URL, params) + + serializer1 = RecipeSerializer(r1) + serializer2 = RecipeSerializer(r2) + serializer3 = RecipeSerializer(r3) + + self.assertEqual(rs.status_code, status.HTTP_200_OK) + self.assertIn(serializer1.data, rs.data) + self.assertIn(serializer2.data, rs.data) + self.assertNotIn(serializer3.data, rs.data) + class RecipeImageUploadTests(TestCase): def setUp(self): self.client = APIClient() diff --git a/app/recipe/tests/test_tags_api.py b/app/recipe/tests/test_tags_api.py index 505a399..983323c 100644 --- a/app/recipe/tests/test_tags_api.py +++ b/app/recipe/tests/test_tags_api.py @@ -1,4 +1,5 @@ """Test for the tags API""" +from decimal import Decimal from django.contrib.auth import get_user_model from django.urls import reverse from django.test import TestCase @@ -6,7 +7,7 @@ from rest_framework import status from rest_framework.test import APIClient -from core.models import Tag +from core.models import Tag, Recipe from recipe.serializers import TagSerializer @@ -86,4 +87,53 @@ def test_delete_tag(self): res = self.client.delete(url) self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT) - self.assertEqual(Tag.objects.count(), 0) \ No newline at end of file + self.assertEqual(Tag.objects.count(), 0) + + def test_filter_tags_assigned_to_recipe(self): + """ Test filtering tags by those assigned to recipes """ + tag1 = Tag.objects.create(user=self.user, name='Breakfast') + tag2 = Tag.objects.create(user=self.user, name='Lunch') + recipe = Recipe.objects.create( + title='Coriander eggs on toast', + time_minutes=10, + price=Decimal('5.00'), + user=self.user + ) + recipe.tags.add(tag1) + + res = self.client.get( + TAG_URL, + {'assigned_only': 1} + ) + + serializer1 = TagSerializer(tag1) + serializer2 = TagSerializer(tag2) + + self.assertIn(serializer1.data, res.data) + self.assertNotIn(serializer2.data, res.data) + + def test_filtered_tags_unique(self): + """ Test that filtered tags are unique """ + tag = Tag.objects.create(user=self.user, name='Breakfast') + Tag.objects.create(user=self.user, name='Lunch') + recipe1 = Recipe.objects.create( + title='Pancakes', + time_minutes=5, + price=3.00, + user=self.user + ) + recipe1.tags.add(tag) + recipe2 = Recipe.objects.create( + title='Porridge', + time_minutes=3, + price=2.00, + user=self.user + ) + recipe2.tags.add(tag) + + res = self.client.get( + TAG_URL, + {'assigned_only': 1} + ) + + self.assertEqual(len(res.data), 1) \ No newline at end of file diff --git a/app/recipe/views.py b/app/recipe/views.py index 57ac0e5..adafa9b 100644 --- a/app/recipe/views.py +++ b/app/recipe/views.py @@ -1,6 +1,12 @@ """ views for recipe app """ +from drf_spectacular.utils import ( + extend_schema, + extend_schema_view, + OpenApiParameter, + OpenApiTypes,) + from rest_framework import viewsets, mixins, status # mixin is used to add list, create, update, delete functionalities from rest_framework.authentication import TokenAuthentication from rest_framework.permissions import IsAuthenticated @@ -10,6 +16,45 @@ from core.models import Recipe, Tag, Ingredient from recipe import serializers +@extend_schema_view( + list=extend_schema( + description='List all recipes', + parameters=[ + OpenApiParameter( + name='tags', + type=OpenApiTypes.STR, + location + ='query', + description='Filter by tags', + ), + OpenApiParameter( + name='ingredients', + type=OpenApiTypes.STR, + location='query', + description='Filter by ingredients', + ), + ], + ), + create=extend_schema( + description='Create a new recipe', + ), + retrieve=extend_schema( + description='Retrieve a recipe', + ), + update=extend_schema( + description='Update a recipe', + ), + partial_update=extend_schema( + description='Partial update a recipe', + ), + destroy=extend_schema( + description='Delete a recipe', + ), + upload_image=extend_schema( + description='Upload an image to a recipe', + ), +) + class RecipeViewSet(viewsets.ModelViewSet): """Manage recipes in the database""" serializer_class = serializers.RecipeSerializer @@ -17,9 +62,23 @@ class RecipeViewSet(viewsets.ModelViewSet): authentication_classes = [TokenAuthentication] permission_classes = [IsAuthenticated] + def _params_to_ints(self, qs): + """Convert a list of string IDs to a list of integers""" + return [int(str_id) for str_id in qs.split(',')] + def get_queryset(self): """Return objects for the current authenticated user only""" - return self.queryset.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""" @@ -52,6 +111,21 @@ def upload_image(self, request, pk=None): status=status.HTTP_400_BAD_REQUEST, ) +@extend_schema_view( + list=extend_schema( + parameters=[ + OpenApiParameter( + name='assigned_only', + type=OpenApiTypes.BOOL, + enum=[0, 1], + description='Filter by items assigned to recipes only', + ), + ], + ), + create=extend_schema( + description='Create a new tag', + ), +) class BaseRecipeAttrViewSet(mixins.UpdateModelMixin, mixins.ListModelMixin, mixins.DestroyModelMixin, @@ -62,7 +136,16 @@ class BaseRecipeAttrViewSet(mixins.UpdateModelMixin, def get_queryset(self): """Return objects for the current authenticated user only""" - 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): """Manage tags in the database""" diff --git a/docker-compose-deploy.yml b/docker-compose-deploy.yml new file mode 100644 index 0000000..6cddbe9 --- /dev/null +++ b/docker-compose-deploy.yml @@ -0,0 +1,41 @@ +services: + app: + build: + context: . + restart: always + volumes: + - static-data:/vol/web + environment: + - DB_HOST=db + - DB_NAME=${DB_NAME} + - DB_USER=db_user + - DB_PASS=${DB_PASS} + - SECRET_KEY=${DJANGO_SECRET_KEY} + - ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS} + depends_on: + - db + + db: + image: postgres:13-alpine + restart: always + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + - POSTGRES_DB=${DB_NAME} + - POSTGRES_USER=db_user + - POSTGRES_PASSWORD=${DB_PASS} + + proxy: + build: + context: ./proxy + restart: always + depends_on: + - app + ports: + - 8000:8000 + volumes: + - static-data:/vol/static + +volumes: + postgres-data: + static-data: \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 22b6224..16121a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,7 @@ services: - DB_NAME=devdb - DB_USER=devuser - DB_PASS=changeme + - DEBUG=1 depends_on: - db diff --git a/proxy/Dockerfile b/proxy/Dockerfile new file mode 100644 index 0000000..b99d126 --- /dev/null +++ b/proxy/Dockerfile @@ -0,0 +1,24 @@ +FROM nginxinc/nginx-unprivileged:1-alpine +LABEL maintainer="bruno" + +COPY ./default.conf.tpl /etc/nginx/default.conf.tpl +COPY ./uwsgi_params /etc/nginx/uswgi_params +COPY ./run.sh /run.sh + +ENV LISTEN_PORT=8000 +ENV APP_HOST=app +ENV APP_PORT=9000 + +USER root + +RUN mkdir -p /vol/static && \ + chmod 755 /vol/static && \ + touch /etc/nginx/conf.d/default.conf && \ + chown nginx:nginx /etc/nginx/conf.d/default.conf && \ + chmod +x /run.sh + +VOLUME /vol/static + +USER nginx + +CMD ["/run.sh"] \ No newline at end of file diff --git a/proxy/default.conf.tpl b/proxy/default.conf.tpl new file mode 100644 index 0000000..36cdedf --- /dev/null +++ b/proxy/default.conf.tpl @@ -0,0 +1,13 @@ +server { + listen ${LISTEN_PORT}; + + location /static { + alias /vol/static; + } + + location / { + uwsgi_pass ${APP_HOST}:${APP_PORT}; + include /etc/nginx/uwsgi_params; + class_max_body_size 10M; + } +} \ No newline at end of file diff --git a/proxy/run.sh b/proxy/run.sh new file mode 100644 index 0000000..8c7e56f --- /dev/null +++ b/proxy/run.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +set -e + +envsubst < /etc/nginx/default.conf.tpl > /etc/nginx.conf.d/default.conf +nginx -g 'daemon off;' \ No newline at end of file diff --git a/proxy/uwsgi_params b/proxy/uwsgi_params new file mode 100644 index 0000000..4f3b520 --- /dev/null +++ b/proxy/uwsgi_params @@ -0,0 +1,13 @@ +uwsgi_param QUERY_STRING $query_string; +uwsgi_param REQUEST_METHOD $request_method; +uwsgi_param CONTENT_TYPE $content_type; +uwsgi_param CONTENT_LENGTH $content_length; +uwsgi_param REQUEST_URI $request_uri; +uwsgi_param PATH_INFO $document_uri; +uwsgi_param DOCUMENT_ROOT $document_root; +uwsgi_param SERVER_PROTOCOL $server_protocol; +uwsgi_param REMOTE_ADDR $remote_addr; +uwsgi_param REMOTE_PORT $remote_port; +uwsgi_param SERVER_ADDR $server_addr; +uwsgi_param SERVER_PORT $server_port; +uwsgi_param SERVER_NAME $server_name; \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9379655..b7d9724 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ Django >=3.2.4, <3.3 djangorestframework >=3.12.4, <3.13 psycopg2 >=2.8.6, <2.9 drf-spectacular == 0.15.1 -pillow >=8.2.0, <8.3 \ No newline at end of file +pillow >=8.2.0, <8.3 +uwsgi >=2.0.19<2.1 \ No newline at end of file diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100644 index 0000000..1d514cc --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +set -e + +python manage.py wait_for_db +python manage.py collectstatic --noinput +python manage.py migrate + +uswgi --socket :9000 --workers 4 --master --enable-threads --module app.wsgi \ No newline at end of file