├── .dockerignore
├── .env.sample
├── .github
└── workflows
│ └── checks.yml
├── .gitignore
├── Dockerfile
├── README.md
├── app
├── .flake8
├── app
│ ├── __init__.py
│ ├── asgi.py
│ ├── calc.py
│ ├── settings.py
│ ├── tests.py
│ ├── urls.py
│ └── wsgi.py
├── core
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── management
│ │ ├── __init__.py
│ │ └── commands
│ │ │ ├── __init__.py
│ │ │ └── wait_for_db.py
│ ├── migrations
│ │ ├── 0001_initial.py
│ │ ├── 0002_recipe.py
│ │ ├── 0003_auto_20210506_1504.py
│ │ ├── 0004_auto_20210512_1535.py
│ │ ├── 0005_recipe_image.py
│ │ └── __init__.py
│ ├── models.py
│ ├── tests
│ │ ├── __init__.py
│ │ ├── test_admin.py
│ │ ├── test_commands.py
│ │ ├── test_health_check.py
│ │ └── test_models.py
│ └── views.py
├── manage.py
├── recipe
│ ├── __init__.py
│ ├── apps.py
│ ├── serializers.py
│ ├── tests
│ │ ├── __init__.py
│ │ ├── test_ingredients_api.py
│ │ ├── test_recipe_api.py
│ │ └── test_tags_api.py
│ ├── urls.py
│ └── views.py
└── user
│ ├── __init__.py
│ ├── apps.py
│ ├── serializers.py
│ ├── tests
│ ├── __init__.py
│ └── test_user_api.py
│ ├── urls.py
│ └── views.py
├── docker-compose-deploy.yml
├── docker-compose.yml
├── proxy
├── Dockerfile
├── default.conf.tpl
├── run.sh
└── uwsgi_params
├── requirements.dev.txt
├── requirements.txt
└── scripts
└── run.sh
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Git
2 | .git
3 | .gitignore
4 |
5 | # Docker
6 | .docker
7 |
8 | # Python
9 | app/__pycache__/
10 | app/*/__pycache__/
11 | app/*/*/__pycache__/
12 | app/*/*/*/__pycache__/
13 | .env/
14 | .venv/
15 | venv/
16 |
--------------------------------------------------------------------------------
/.env.sample:
--------------------------------------------------------------------------------
1 | DB_NAME=dbname
2 | DB_USER=rootuser
3 | DB_PASS=changeme
4 | DJANGO_SECRET_KEY=changeme
5 | DJANGO_ALLOWED_HOSTS=127.0.0.1
6 |
--------------------------------------------------------------------------------
/.github/workflows/checks.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Checks
3 |
4 | on: [push]
5 |
6 | jobs:
7 | test-lint:
8 | name: Test and Lint
9 | runs-on: ubuntu-24.04
10 | steps:
11 | - name: Login to Docker Hub
12 | uses: docker/login-action@v3
13 | with:
14 | username: ${{ secrets.DOCKERHUB_USER }}
15 | password: ${{ secrets.DOCKERHUB_TOKEN }}
16 | - name: Checkout
17 | uses: actions/checkout@v4
18 | - name: Test
19 | run: docker compose run --rm app sh -c "python manage.py wait_for_db && python manage.py test"
20 | - name: Lint
21 | run: docker compose run --rm app sh -c "flake8"
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | pip-wheel-metadata/
24 | share/python-wheels/
25 | *.egg-info/
26 | .installed.cfg
27 | *.egg
28 | MANIFEST
29 |
30 | # PyInstaller
31 | # Usually these files are written by a python script from a template
32 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
33 | *.manifest
34 | *.spec
35 |
36 | # Installer logs
37 | pip-log.txt
38 | pip-delete-this-directory.txt
39 |
40 | # Unit test / coverage reports
41 | htmlcov/
42 | .tox/
43 | .nox/
44 | .coverage
45 | .coverage.*
46 | .cache
47 | nosetests.xml
48 | coverage.xml
49 | *.cover
50 | *.py,cover
51 | .hypothesis/
52 | .pytest_cache/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | target/
76 |
77 | # Jupyter Notebook
78 | .ipynb_checkpoints
79 |
80 | # IPython
81 | profile_default/
82 | ipython_config.py
83 |
84 | # pyenv
85 | .python-version
86 |
87 | # pipenv
88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
91 | # install all needed dependencies.
92 | #Pipfile.lock
93 |
94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
95 | __pypackages__/
96 |
97 | # Celery stuff
98 | celerybeat-schedule
99 | celerybeat.pid
100 |
101 | # SageMath parsed files
102 | *.sage.py
103 |
104 | # Environments
105 | .env
106 | .venv
107 | env/
108 | venv/
109 | ENV/
110 | env.bak/
111 | venv.bak/
112 |
113 | # Spyder project settings
114 | .spyderproject
115 | .spyproject
116 |
117 | # Rope project settings
118 | .ropeproject
119 |
120 | # mkdocs documentation
121 | /site
122 |
123 | # mypy
124 | .mypy_cache/
125 | .dmypy.json
126 | dmypy.json
127 |
128 | # Pyre type checker
129 | .pyre/
130 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.9-alpine3.13
2 | LABEL maintainer="londonappdeveloper.com"
3 |
4 | ENV PYTHONUNBUFFERED 1
5 |
6 | COPY ./requirements.txt /tmp/requirements.txt
7 | COPY ./requirements.dev.txt /tmp/requirements.dev.txt
8 | COPY ./scripts /scripts
9 | COPY ./app /app
10 | WORKDIR /app
11 | EXPOSE 8000
12 |
13 | ARG DEV=false
14 | RUN python -m venv /py && \
15 | /py/bin/pip install --upgrade pip && \
16 | apk add --update --no-cache postgresql-client jpeg-dev && \
17 | apk add --update --no-cache --virtual .tmp-build-deps \
18 | build-base postgresql-dev musl-dev zlib zlib-dev linux-headers && \
19 | /py/bin/pip install -r /tmp/requirements.txt && \
20 | if [ $DEV = "true" ]; \
21 | then /py/bin/pip install -r /tmp/requirements.dev.txt ; \
22 | fi && \
23 | rm -rf /tmp && \
24 | apk del .tmp-build-deps && \
25 | adduser \
26 | --disabled-password \
27 | --no-create-home \
28 | django-user && \
29 | mkdir -p /vol/web/media && \
30 | mkdir -p /vol/web/static && \
31 | chown -R django-user:django-user /vol && \
32 | chmod -R 755 /vol && \
33 | chmod -R +x /scripts
34 |
35 | ENV PATH="/scripts:/py/bin:$PATH"
36 |
37 | USER django-user
38 |
39 | CMD ["run.sh"]
40 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
6 |
7 |
14 |
15 |
16 |
17 | # Recipe App API (v2) Source Code
18 |
19 | This is the code for the second edition of the course that was released in 2022.
20 |
21 | Course code for: [Build a Backend REST API with Python & Django - Advanced](https://londonapp.dev/c2)
22 |
23 |
--------------------------------------------------------------------------------
/app/.flake8:
--------------------------------------------------------------------------------
1 |
2 | [flake8]
3 | exclude =
4 | migrations,
5 | __pycache__,
6 | manage.py,
7 | settings.py
8 |
--------------------------------------------------------------------------------
/app/app/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LondonAppDeveloper/c2-recipe-app-api-2/0b193f0681c6eda2cc76e13bbd495861f9d54193/app/app/__init__.py
--------------------------------------------------------------------------------
/app/app/asgi.py:
--------------------------------------------------------------------------------
1 | """
2 | ASGI config for app project.
3 |
4 | It exposes the ASGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.asgi import get_asgi_application
13 |
14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings')
15 |
16 | application = get_asgi_application()
17 |
--------------------------------------------------------------------------------
/app/app/calc.py:
--------------------------------------------------------------------------------
1 | """
2 | Calculator functions
3 | """
4 |
5 |
6 | def add(x, y):
7 | """Add x and y and return result."""
8 | return x + y
9 |
10 |
11 | def subtract(x, y):
12 | """Subtract x from y and return result."""
13 | return y - x
14 |
--------------------------------------------------------------------------------
/app/app/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for app project.
3 |
4 | Generated by 'django-admin startproject' using Django 3.2.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.2/topics/settings/
8 |
9 | For the full list of settings and their values, see
10 | https://docs.djangoproject.com/en/3.2/ref/settings/
11 | """
12 |
13 | import os
14 | from pathlib import Path
15 |
16 | # Build paths inside the project like this: BASE_DIR / 'subdir'.
17 | BASE_DIR = Path(__file__).resolve().parent.parent
18 |
19 |
20 | # Quick-start development settings - unsuitable for production
21 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
22 |
23 | # SECURITY WARNING: keep the secret key used in production secret!
24 | SECRET_KEY = os.environ.get('SECRET_KEY', 'changeme')
25 |
26 | # SECURITY WARNING: don't run with debug turned on in production!
27 | DEBUG = bool(int(os.environ.get('DEBUG', 0)))
28 |
29 | ALLOWED_HOSTS = []
30 | ALLOWED_HOSTS.extend(
31 | filter(
32 | None,
33 | os.environ.get('ALLOWED_HOSTS', '').split(','),
34 | )
35 | )
36 |
37 | # Application definition
38 |
39 | INSTALLED_APPS = [
40 | 'django.contrib.admin',
41 | 'django.contrib.auth',
42 | 'django.contrib.contenttypes',
43 | 'django.contrib.sessions',
44 | 'django.contrib.messages',
45 | 'django.contrib.staticfiles',
46 | 'rest_framework',
47 | 'rest_framework.authtoken',
48 | 'drf_spectacular',
49 | 'core',
50 | 'user',
51 | 'recipe',
52 | ]
53 |
54 | MIDDLEWARE = [
55 | 'django.middleware.security.SecurityMiddleware',
56 | 'django.contrib.sessions.middleware.SessionMiddleware',
57 | 'django.middleware.common.CommonMiddleware',
58 | 'django.middleware.csrf.CsrfViewMiddleware',
59 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
60 | 'django.contrib.messages.middleware.MessageMiddleware',
61 | 'django.middleware.clickjacking.XFrameOptionsMiddleware',
62 | ]
63 |
64 | ROOT_URLCONF = 'app.urls'
65 |
66 | TEMPLATES = [
67 | {
68 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
69 | 'DIRS': [],
70 | 'APP_DIRS': True,
71 | 'OPTIONS': {
72 | 'context_processors': [
73 | 'django.template.context_processors.debug',
74 | 'django.template.context_processors.request',
75 | 'django.contrib.auth.context_processors.auth',
76 | 'django.contrib.messages.context_processors.messages',
77 | ],
78 | },
79 | },
80 | ]
81 |
82 | WSGI_APPLICATION = 'app.wsgi.application'
83 |
84 |
85 | # Database
86 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases
87 |
88 | DATABASES = {
89 | 'default': {
90 | 'ENGINE': 'django.db.backends.postgresql',
91 | 'HOST': os.environ.get('DB_HOST'),
92 | 'NAME': os.environ.get('DB_NAME'),
93 | 'USER': os.environ.get('DB_USER'),
94 | 'PASSWORD': os.environ.get('DB_PASS'),
95 | }
96 | }
97 |
98 |
99 | # Password validation
100 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
101 |
102 | AUTH_PASSWORD_VALIDATORS = [
103 | {
104 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
105 | },
106 | {
107 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
108 | },
109 | {
110 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
111 | },
112 | {
113 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
114 | },
115 | ]
116 |
117 |
118 | # Internationalization
119 | # https://docs.djangoproject.com/en/3.2/topics/i18n/
120 |
121 | LANGUAGE_CODE = 'en-us'
122 |
123 | TIME_ZONE = 'UTC'
124 |
125 | USE_I18N = True
126 |
127 | USE_L10N = True
128 |
129 | USE_TZ = True
130 |
131 |
132 | # Static files (CSS, JavaScript, Images)
133 | # https://docs.djangoproject.com/en/3.2/howto/static-files/
134 |
135 | STATIC_URL = '/static/static/'
136 | MEDIA_URL = '/static/media/'
137 |
138 | MEDIA_ROOT = '/vol/web/media'
139 | STATIC_ROOT = '/vol/web/static'
140 |
141 | # Default primary key field type
142 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
143 |
144 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
145 |
146 | AUTH_USER_MODEL = 'core.User'
147 |
148 | REST_FRAMEWORK = {
149 | 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
150 | }
151 |
152 | SPECTACULAR_SETTINGS = {
153 | 'COMPONENT_SPLIT_REQUEST': True,
154 | }
155 |
--------------------------------------------------------------------------------
/app/app/tests.py:
--------------------------------------------------------------------------------
1 | """
2 | Sample tests
3 | """
4 | from django.test import SimpleTestCase
5 |
6 | from app import calc
7 |
8 |
9 | class CalcTests(SimpleTestCase):
10 | """Test the calc module."""
11 |
12 | def test_add_numbers(self):
13 | """Test adding numbers together."""
14 | res = calc.add(5, 6)
15 |
16 | self.assertEqual(res, 11)
17 |
18 | def test_subtract_numbers(self):
19 | """Test subtracting numbers."""
20 | res = calc.subtract(10, 15)
21 |
22 | self.assertEqual(res, 5)
23 |
--------------------------------------------------------------------------------
/app/app/urls.py:
--------------------------------------------------------------------------------
1 | """app URL Configuration
2 |
3 | The `urlpatterns` list routes URLs to views. For more information please see:
4 | https://docs.djangoproject.com/en/3.2/topics/http/urls/
5 | Examples:
6 | Function views
7 | 1. Add an import: from my_app import views
8 | 2. Add a URL to urlpatterns: path('', views.home, name='home')
9 | Class-based views
10 | 1. Add an import: from other_app.views import Home
11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
12 | Including another URLconf
13 | 1. Import the include() function: from django.urls import include, path
14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
15 | """
16 | from drf_spectacular.views import (
17 | SpectacularAPIView,
18 | SpectacularSwaggerView,
19 | )
20 |
21 | from django.contrib import admin
22 | from django.urls import path, include
23 | from django.conf.urls.static import static
24 | from django.conf import settings
25 |
26 | from core import views as core_views
27 |
28 |
29 | urlpatterns = [
30 | path('admin/', admin.site.urls),
31 | path('api/health-check/', core_views.health_check, name='health-check'),
32 | path('api/schema/', SpectacularAPIView.as_view(), name='api-schema'),
33 | path(
34 | 'api/docs/',
35 | SpectacularSwaggerView.as_view(url_name='api-schema'),
36 | name='api-docs',
37 | ),
38 | path('api/user/', include('user.urls')),
39 | path('api/recipe/', include('recipe.urls')),
40 | ]
41 |
42 | if settings.DEBUG:
43 | urlpatterns += static(
44 | settings.MEDIA_URL,
45 | document_root=settings.MEDIA_ROOT,
46 | )
47 |
--------------------------------------------------------------------------------
/app/app/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for app project.
3 |
4 | It exposes the WSGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.wsgi import get_wsgi_application
13 |
14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings')
15 |
16 | application = get_wsgi_application()
17 |
--------------------------------------------------------------------------------
/app/core/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LondonAppDeveloper/c2-recipe-app-api-2/0b193f0681c6eda2cc76e13bbd495861f9d54193/app/core/__init__.py
--------------------------------------------------------------------------------
/app/core/admin.py:
--------------------------------------------------------------------------------
1 | """
2 | Django admin customization.
3 | """
4 | from django.contrib import admin
5 | from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
6 | from django.utils.translation import gettext_lazy as _
7 |
8 | from core import models
9 |
10 |
11 | class UserAdmin(BaseUserAdmin):
12 | """Define the admin pages for users."""
13 | ordering = ['id']
14 | list_display = ['email', 'name']
15 | fieldsets = (
16 | (None, {'fields': ('email', 'password')}),
17 | (_('Personal Info'), {'fields': ('name',)}),
18 | (
19 | _('Permissions'),
20 | {
21 | 'fields': (
22 | 'is_active',
23 | 'is_staff',
24 | 'is_superuser',
25 | )
26 | }
27 | ),
28 | (_('Important dates'), {'fields': ('last_login',)}),
29 | )
30 | readonly_fields = ['last_login']
31 | add_fieldsets = (
32 | (None, {
33 | 'classes': ('wide',),
34 | 'fields': (
35 | 'email',
36 | 'password1',
37 | 'password2',
38 | 'name',
39 | 'is_active',
40 | 'is_staff',
41 | 'is_superuser',
42 | ),
43 | }),
44 | )
45 |
46 |
47 | admin.site.register(models.User, UserAdmin)
48 | admin.site.register(models.Recipe)
49 | admin.site.register(models.Tag)
50 | admin.site.register(models.Ingredient)
51 |
--------------------------------------------------------------------------------
/app/core/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class CoreConfig(AppConfig):
5 | default_auto_field = 'django.db.models.BigAutoField'
6 | name = 'core'
7 |
--------------------------------------------------------------------------------
/app/core/management/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LondonAppDeveloper/c2-recipe-app-api-2/0b193f0681c6eda2cc76e13bbd495861f9d54193/app/core/management/__init__.py
--------------------------------------------------------------------------------
/app/core/management/commands/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LondonAppDeveloper/c2-recipe-app-api-2/0b193f0681c6eda2cc76e13bbd495861f9d54193/app/core/management/commands/__init__.py
--------------------------------------------------------------------------------
/app/core/management/commands/wait_for_db.py:
--------------------------------------------------------------------------------
1 | """
2 | Django command to wait for the database to be available.
3 | """
4 | import time
5 |
6 | from psycopg2 import OperationalError as Psycopg2OpError
7 |
8 | from django.db.utils import OperationalError
9 | from django.core.management.base import BaseCommand
10 |
11 |
12 | class Command(BaseCommand):
13 | """Django command to wait for database."""
14 |
15 | def handle(self, *args, **options):
16 | """Entrypoint for command."""
17 | self.stdout.write('Waiting for database...')
18 | db_up = False
19 | while db_up is False:
20 | try:
21 | self.check(databases=['default'])
22 | db_up = True
23 | except (Psycopg2OpError, OperationalError):
24 | self.stdout.write('Database unavailable, waiting 1 second...')
25 | time.sleep(1)
26 |
27 | self.stdout.write(self.style.SUCCESS('Database available!'))
28 |
--------------------------------------------------------------------------------
/app/core/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2 on 2021-04-20 15:09
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | initial = True
9 |
10 | dependencies = [
11 | ('auth', '0012_alter_user_first_name_max_length'),
12 | ]
13 |
14 | operations = [
15 | migrations.CreateModel(
16 | name='User',
17 | fields=[
18 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19 | ('password', models.CharField(max_length=128, verbose_name='password')),
20 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
21 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
22 | ('email', models.EmailField(max_length=255, unique=True)),
23 | ('name', models.CharField(max_length=255)),
24 | ('is_active', models.BooleanField(default=True)),
25 | ('is_staff', models.BooleanField(default=False)),
26 | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
27 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
28 | ],
29 | options={
30 | 'abstract': False,
31 | },
32 | ),
33 | ]
34 |
--------------------------------------------------------------------------------
/app/core/migrations/0002_recipe.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2 on 2021-05-04 13:37
2 |
3 | from django.conf import settings
4 | from django.db import migrations, models
5 | import django.db.models.deletion
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | dependencies = [
11 | ('core', '0001_initial'),
12 | ]
13 |
14 | operations = [
15 | migrations.CreateModel(
16 | name='Recipe',
17 | fields=[
18 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19 | ('title', models.CharField(max_length=255)),
20 | ('description', models.TextField(blank=True)),
21 | ('time_minutes', models.IntegerField()),
22 | ('price', models.DecimalField(decimal_places=2, max_digits=5)),
23 | ('link', models.CharField(blank=True, max_length=255)),
24 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
25 | ],
26 | ),
27 | ]
28 |
--------------------------------------------------------------------------------
/app/core/migrations/0003_auto_20210506_1504.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2 on 2021-05-06 15:04
2 |
3 | from django.conf import settings
4 | from django.db import migrations, models
5 | import django.db.models.deletion
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | dependencies = [
11 | ('core', '0002_recipe'),
12 | ]
13 |
14 | operations = [
15 | migrations.CreateModel(
16 | name='Tag',
17 | fields=[
18 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19 | ('name', models.CharField(max_length=255)),
20 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
21 | ],
22 | ),
23 | migrations.AddField(
24 | model_name='recipe',
25 | name='tags',
26 | field=models.ManyToManyField(to='core.Tag'),
27 | ),
28 | ]
29 |
--------------------------------------------------------------------------------
/app/core/migrations/0004_auto_20210512_1535.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.2 on 2021-05-12 15:35
2 |
3 | from django.conf import settings
4 | from django.db import migrations, models
5 | import django.db.models.deletion
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | dependencies = [
11 | ('core', '0003_auto_20210506_1504'),
12 | ]
13 |
14 | operations = [
15 | migrations.CreateModel(
16 | name='Ingredient',
17 | fields=[
18 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19 | ('name', models.CharField(max_length=255)),
20 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
21 | ],
22 | ),
23 | migrations.AddField(
24 | model_name='recipe',
25 | name='ingredients',
26 | field=models.ManyToManyField(to='core.Ingredient'),
27 | ),
28 | ]
29 |
--------------------------------------------------------------------------------
/app/core/migrations/0005_recipe_image.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.3 on 2021-05-13 11:19
2 |
3 | import core.models
4 | from django.db import migrations, models
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = [
10 | ('core', '0004_auto_20210512_1535'),
11 | ]
12 |
13 | operations = [
14 | migrations.AddField(
15 | model_name='recipe',
16 | name='image',
17 | field=models.ImageField(null=True, upload_to=core.models.recipe_image_file_path),
18 | ),
19 | ]
20 |
--------------------------------------------------------------------------------
/app/core/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LondonAppDeveloper/c2-recipe-app-api-2/0b193f0681c6eda2cc76e13bbd495861f9d54193/app/core/migrations/__init__.py
--------------------------------------------------------------------------------
/app/core/models.py:
--------------------------------------------------------------------------------
1 | """
2 | Database models.
3 | """
4 | import uuid
5 | import os
6 |
7 | from django.conf import settings
8 | from django.db import models
9 | from django.contrib.auth.models import (
10 | AbstractBaseUser,
11 | BaseUserManager,
12 | PermissionsMixin,
13 | )
14 |
15 |
16 | def recipe_image_file_path(instance, filename):
17 | """Generate file path for new recipe image."""
18 | ext = os.path.splitext(filename)[1]
19 | filename = f'{uuid.uuid4()}{ext}'
20 |
21 | return os.path.join('uploads', 'recipe', filename)
22 |
23 |
24 | class UserManager(BaseUserManager):
25 | """Manager for users."""
26 |
27 | def create_user(self, email, password=None, **extra_fields):
28 | """Create, save and return a new user."""
29 | if not email:
30 | raise ValueError('User must have an email address.')
31 | user = self.model(email=self.normalize_email(email), **extra_fields)
32 | user.set_password(password)
33 | user.save(using=self._db)
34 |
35 | return user
36 |
37 | def create_superuser(self, email, password):
38 | """Create and return a new superuser."""
39 | user = self.create_user(email, password)
40 | user.is_staff = True
41 | user.is_superuser = True
42 | user.save(using=self._db)
43 |
44 | return user
45 |
46 |
47 | class User(AbstractBaseUser, PermissionsMixin):
48 | """User in the system."""
49 | email = models.EmailField(max_length=255, unique=True)
50 | name = models.CharField(max_length=255)
51 | is_active = models.BooleanField(default=True)
52 | is_staff = models.BooleanField(default=False)
53 |
54 | objects = UserManager()
55 |
56 | USERNAME_FIELD = 'email'
57 |
58 |
59 | class Recipe(models.Model):
60 | """Recipe object."""
61 | user = models.ForeignKey(
62 | settings.AUTH_USER_MODEL,
63 | on_delete=models.CASCADE,
64 | )
65 | title = models.CharField(max_length=255)
66 | description = models.TextField(blank=True)
67 | time_minutes = models.IntegerField()
68 | price = models.DecimalField(max_digits=5, decimal_places=2)
69 | link = models.CharField(max_length=255, blank=True)
70 | tags = models.ManyToManyField('Tag')
71 | ingredients = models.ManyToManyField('Ingredient')
72 | image = models.ImageField(null=True, upload_to=recipe_image_file_path)
73 |
74 | def __str__(self):
75 | return self.title
76 |
77 |
78 | class Tag(models.Model):
79 | """Tag for filtering recipes."""
80 | name = models.CharField(max_length=255)
81 | user = models.ForeignKey(
82 | settings.AUTH_USER_MODEL,
83 | on_delete=models.CASCADE,
84 | )
85 |
86 | def __str__(self):
87 | return self.name
88 |
89 |
90 | class Ingredient(models.Model):
91 | """Ingredient for recipes."""
92 | name = models.CharField(max_length=255)
93 | user = models.ForeignKey(
94 | settings.AUTH_USER_MODEL,
95 | on_delete=models.CASCADE,
96 | )
97 |
98 | def __str__(self):
99 | return self.name
100 |
--------------------------------------------------------------------------------
/app/core/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LondonAppDeveloper/c2-recipe-app-api-2/0b193f0681c6eda2cc76e13bbd495861f9d54193/app/core/tests/__init__.py
--------------------------------------------------------------------------------
/app/core/tests/test_admin.py:
--------------------------------------------------------------------------------
1 | """
2 | Tests for the Django admin modifications.
3 | """
4 | from django.test import TestCase
5 | from django.contrib.auth import get_user_model
6 | from django.urls import reverse
7 | from django.test import Client
8 |
9 |
10 | class AdminSiteTests(TestCase):
11 | """Tests for Django admin."""
12 |
13 | def setUp(self):
14 | """Create user and client."""
15 | self.client = Client()
16 | self.admin_user = get_user_model().objects.create_superuser(
17 | email='admin@example.com',
18 | password='testpass123',
19 | )
20 | self.client.force_login(self.admin_user)
21 | self.user = get_user_model().objects.create_user(
22 | email='user@example.com',
23 | password='testpass123',
24 | name='Test User'
25 | )
26 |
27 | def test_users_lists(self):
28 | """Test that users are listed on page."""
29 | url = reverse('admin:core_user_changelist')
30 | res = self.client.get(url)
31 |
32 | self.assertContains(res, self.user.name)
33 | self.assertContains(res, self.user.email)
34 |
35 | def test_edit_user_page(self):
36 | """Test the edit user page works."""
37 | url = reverse('admin:core_user_change', args=[self.user.id])
38 | res = self.client.get(url)
39 |
40 | self.assertEqual(res.status_code, 200)
41 |
42 | def test_create_user_page(self):
43 | """Test the create user page works."""
44 | url = reverse('admin:core_user_add')
45 | res = self.client.get(url)
46 |
47 | self.assertEqual(res.status_code, 200)
48 |
--------------------------------------------------------------------------------
/app/core/tests/test_commands.py:
--------------------------------------------------------------------------------
1 | """
2 | Test custom Django management commands.
3 | """
4 | from unittest.mock import patch
5 |
6 | from psycopg2 import OperationalError as Psycopg2OpError
7 |
8 | from django.core.management import call_command
9 | from django.db.utils import OperationalError
10 | from django.test import SimpleTestCase
11 |
12 |
13 | @patch('core.management.commands.wait_for_db.Command.check')
14 | class CommandTests(SimpleTestCase):
15 | """Test commands."""
16 |
17 | def test_wait_for_db_ready(self, patched_check):
18 | """Test waiting for database if database ready."""
19 | patched_check.return_value = True
20 |
21 | call_command('wait_for_db')
22 |
23 | patched_check.assert_called_once_with(databases=['default'])
24 |
25 | @patch('time.sleep')
26 | def test_wait_for_db_delay(self, patched_sleep, patched_check):
27 | """Test waiting for database when getting OperationalError."""
28 | patched_check.side_effect = [Psycopg2OpError] * 2 + \
29 | [OperationalError] * 3 + [True]
30 |
31 | call_command('wait_for_db')
32 |
33 | self.assertEqual(patched_check.call_count, 6)
34 | patched_check.assert_called_with(databases=['default'])
35 |
--------------------------------------------------------------------------------
/app/core/tests/test_health_check.py:
--------------------------------------------------------------------------------
1 | """
2 | Tests for the health check API.
3 | """
4 | from django.test import TestCase
5 | from django.urls import reverse
6 |
7 | from rest_framework import status
8 | from rest_framework.test import APIClient
9 |
10 |
11 | class HealthCheckTests(TestCase):
12 | """Test the health check API."""
13 |
14 | def test_health_check(self):
15 | """Test health check API."""
16 | client = APIClient()
17 | url = reverse('health-check')
18 | res = client.get(url)
19 |
20 | self.assertEqual(res.status_code, status.HTTP_200_OK)
21 |
--------------------------------------------------------------------------------
/app/core/tests/test_models.py:
--------------------------------------------------------------------------------
1 | """
2 | Tests for models.
3 | """
4 | from unittest.mock import patch
5 | from decimal import Decimal
6 |
7 | from django.test import TestCase
8 | from django.contrib.auth import get_user_model
9 |
10 | from core import models
11 |
12 |
13 | def create_user(email='user@example.com', password='testpass123'):
14 | """Create a return a new user."""
15 | return get_user_model().objects.create_user(email, password)
16 |
17 |
18 | class ModelTests(TestCase):
19 | """Test models."""
20 |
21 | def test_create_user_with_email_successful(self):
22 | """Test creating a user with an email is successful."""
23 | email = 'test@example.com'
24 | password = 'testpass123'
25 | user = get_user_model().objects.create_user(
26 | email=email,
27 | password=password,
28 | )
29 |
30 | self.assertEqual(user.email, email)
31 | self.assertTrue(user.check_password(password))
32 |
33 | def test_new_user_email_normalized(self):
34 | """Test email is normalized for new users."""
35 | sample_emails = [
36 | ['test1@EXAMPLE.com', 'test1@example.com'],
37 | ['Test2@Example.com', 'Test2@example.com'],
38 | ['TEST3@EXAMPLE.com', 'TEST3@example.com'],
39 | ['test4@example.COM', 'test4@example.com'],
40 | ]
41 | for email, expected in sample_emails:
42 | user = get_user_model().objects.create_user(email, 'sample123')
43 | self.assertEqual(user.email, expected)
44 |
45 | def test_new_user_without_email_raises_error(self):
46 | """Test that creating a user without an email raises a ValueError."""
47 | with self.assertRaises(ValueError):
48 | get_user_model().objects.create_user('', 'test123')
49 |
50 | def test_create_superuser(self):
51 | """Test creating a superuser."""
52 | user = get_user_model().objects.create_superuser(
53 | 'test@example.com',
54 | 'test123',
55 | )
56 |
57 | self.assertTrue(user.is_superuser)
58 | self.assertTrue(user.is_staff)
59 |
60 | def test_create_recipe(self):
61 | """Test creating a recipe is successful."""
62 | user = get_user_model().objects.create_user(
63 | 'test@example.com',
64 | 'testpass123',
65 | )
66 | recipe = models.Recipe.objects.create(
67 | user=user,
68 | title='Sample recipe name',
69 | time_minutes=5,
70 | price=Decimal('5.50'),
71 | description='Sample receipe description.',
72 | )
73 |
74 | self.assertEqual(str(recipe), recipe.title)
75 |
76 | def test_create_tag(self):
77 | """Test creating a tag is successful."""
78 | user = create_user()
79 | tag = models.Tag.objects.create(user=user, name='Tag1')
80 |
81 | self.assertEqual(str(tag), tag.name)
82 |
83 | def test_create_ingredient(self):
84 | """Test creating an ingredient is successful."""
85 | user = create_user()
86 | ingredient = models.Ingredient.objects.create(
87 | user=user,
88 | name='Ingredient1'
89 | )
90 |
91 | self.assertEqual(str(ingredient), ingredient.name)
92 |
93 | @patch('core.models.uuid.uuid4')
94 | def test_recipe_file_name_uuid(self, mock_uuid):
95 | """Test generating image path."""
96 | uuid = 'test-uuid'
97 | mock_uuid.return_value = uuid
98 | file_path = models.recipe_image_file_path(None, 'example.jpg')
99 |
100 | self.assertEqual(file_path, f'uploads/recipe/{uuid}.jpg')
101 |
--------------------------------------------------------------------------------
/app/core/views.py:
--------------------------------------------------------------------------------
1 | """
2 | Core views for app.
3 | """
4 | from rest_framework.decorators import api_view
5 | from rest_framework.response import Response
6 |
7 |
8 | @api_view(['GET'])
9 | def health_check(request):
10 | """Returns successful response."""
11 | return Response({'healthy': True})
12 |
--------------------------------------------------------------------------------
/app/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 |
7 | def main():
8 | """Run administrative tasks."""
9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings')
10 | try:
11 | from django.core.management import execute_from_command_line
12 | except ImportError as exc:
13 | raise ImportError(
14 | "Couldn't import Django. Are you sure it's installed and "
15 | "available on your PYTHONPATH environment variable? Did you "
16 | "forget to activate a virtual environment?"
17 | ) from exc
18 | execute_from_command_line(sys.argv)
19 |
20 |
21 | if __name__ == '__main__':
22 | main()
23 |
--------------------------------------------------------------------------------
/app/recipe/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LondonAppDeveloper/c2-recipe-app-api-2/0b193f0681c6eda2cc76e13bbd495861f9d54193/app/recipe/__init__.py
--------------------------------------------------------------------------------
/app/recipe/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class RecipeConfig(AppConfig):
5 | default_auto_field = 'django.db.models.BigAutoField'
6 | name = 'recipe'
7 |
--------------------------------------------------------------------------------
/app/recipe/serializers.py:
--------------------------------------------------------------------------------
1 | """
2 | Serializers for recipe APIs
3 | """
4 | from rest_framework import serializers
5 |
6 | from core.models import (
7 | Recipe,
8 | Tag,
9 | Ingredient,
10 | )
11 |
12 |
13 | class IngredientSerializer(serializers.ModelSerializer):
14 | """Serializer for ingredients."""
15 |
16 | class Meta:
17 | model = Ingredient
18 | fields = ['id', 'name']
19 | read_only_fields = ['id']
20 |
21 |
22 | class TagSerializer(serializers.ModelSerializer):
23 | """Serializer for tags."""
24 |
25 | class Meta:
26 | model = Tag
27 | fields = ['id', 'name']
28 | read_only_fields = ['id']
29 |
30 |
31 | class RecipeSerializer(serializers.ModelSerializer):
32 | """Serializer for recipes."""
33 | tags = TagSerializer(many=True, required=False)
34 | ingredients = IngredientSerializer(many=True, required=False)
35 |
36 | class Meta:
37 | model = Recipe
38 | fields = [
39 | 'id', 'title', 'time_minutes', 'price', 'link', 'tags',
40 | 'ingredients',
41 | ]
42 | read_only_fields = ['id']
43 |
44 | def _get_or_create_tags(self, tags, recipe):
45 | """Handle getting or creating tags as needed."""
46 | auth_user = self.context['request'].user
47 | for tag in tags:
48 | tag_obj, created = Tag.objects.get_or_create(
49 | user=auth_user,
50 | **tag,
51 | )
52 | recipe.tags.add(tag_obj)
53 |
54 | def _get_or_create_ingredients(self, ingredients, recipe):
55 | """Handle getting or creating ingredients as needed."""
56 | auth_user = self.context['request'].user
57 | for ingredient in ingredients:
58 | ingredient_obj, created = Ingredient.objects.get_or_create(
59 | user=auth_user,
60 | **ingredient,
61 | )
62 | recipe.ingredients.add(ingredient_obj)
63 |
64 | def create(self, validated_data):
65 | """Create a recipe."""
66 | tags = validated_data.pop('tags', [])
67 | ingredients = validated_data.pop('ingredients', [])
68 | recipe = Recipe.objects.create(**validated_data)
69 | self._get_or_create_tags(tags, recipe)
70 | self._get_or_create_ingredients(ingredients, recipe)
71 |
72 | return recipe
73 |
74 | def update(self, instance, validated_data):
75 | """Update recipe."""
76 | tags = validated_data.pop('tags', None)
77 | ingredients = validated_data.pop('ingredients', None)
78 | if tags is not None:
79 | instance.tags.clear()
80 | self._get_or_create_tags(tags, instance)
81 | if ingredients is not None:
82 | instance.ingredients.clear()
83 | self._get_or_create_ingredients(ingredients, instance)
84 |
85 | for attr, value in validated_data.items():
86 | setattr(instance, attr, value)
87 |
88 | instance.save()
89 | return instance
90 |
91 |
92 | class RecipeDetailSerializer(RecipeSerializer):
93 | """Serializer for recipe detail view."""
94 |
95 | class Meta(RecipeSerializer.Meta):
96 | fields = RecipeSerializer.Meta.fields + ['description']
97 |
98 |
99 | class RecipeImageSerializer(serializers.ModelSerializer):
100 | """Serializer for uploading images to recipes."""
101 |
102 | class Meta:
103 | model = Recipe
104 | fields = ['id', 'image']
105 | read_only_fields = ['id']
106 | extra_kwargs = {'image': {'required': 'True'}}
107 |
--------------------------------------------------------------------------------
/app/recipe/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LondonAppDeveloper/c2-recipe-app-api-2/0b193f0681c6eda2cc76e13bbd495861f9d54193/app/recipe/tests/__init__.py
--------------------------------------------------------------------------------
/app/recipe/tests/test_ingredients_api.py:
--------------------------------------------------------------------------------
1 | """
2 | Tests for the ingredients API.
3 | """
4 | from decimal import Decimal
5 |
6 | from django.contrib.auth import get_user_model
7 | from django.urls import reverse
8 | from django.test import TestCase
9 |
10 | from rest_framework import status
11 | from rest_framework.test import APIClient
12 |
13 | from core.models import (
14 | Ingredient,
15 | Recipe,
16 | )
17 |
18 | from recipe.serializers import IngredientSerializer
19 |
20 |
21 | INGREDIENTS_URL = reverse('recipe:ingredient-list')
22 |
23 |
24 | def detail_url(ingredient_id):
25 | """Create and return an ingredient detail URL."""
26 | return reverse('recipe:ingredient-detail', args=[ingredient_id])
27 |
28 |
29 | def create_user(email='user@example.com', password='testpass123'):
30 | """Create and return user."""
31 | return get_user_model().objects.create_user(email=email, password=password)
32 |
33 |
34 | class PublicIngredientsApiTests(TestCase):
35 | """Test unauthenticated API requests."""
36 |
37 | def setUp(self):
38 | self.client = APIClient()
39 |
40 | def test_auth_required(self):
41 | """Test auth is required for retrieving ingredients."""
42 | res = self.client.get(INGREDIENTS_URL)
43 |
44 | self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED)
45 |
46 |
47 | class PrivateIngredientsApiTests(TestCase):
48 | """Test authenticated API requests."""
49 |
50 | def setUp(self):
51 | self.user = create_user()
52 | self.client = APIClient()
53 | self.client.force_authenticate(self.user)
54 |
55 | def test_retrieve_ingredients(self):
56 | """Test retrieving a list of ingredients."""
57 | Ingredient.objects.create(user=self.user, name='Kale')
58 | Ingredient.objects.create(user=self.user, name='Vanilla')
59 |
60 | res = self.client.get(INGREDIENTS_URL)
61 |
62 | ingredients = Ingredient.objects.all().order_by('-name')
63 | serializer = IngredientSerializer(ingredients, many=True)
64 | self.assertEqual(res.status_code, status.HTTP_200_OK)
65 | self.assertEqual(res.data, serializer.data)
66 |
67 | def test_ingredients_limited_to_user(self):
68 | """Test list of ingredients is limited to authenticated user."""
69 | user2 = create_user(email='user2@example.com')
70 | Ingredient.objects.create(user=user2, name='Salt')
71 | ingredient = Ingredient.objects.create(user=self.user, name='Pepper')
72 |
73 | res = self.client.get(INGREDIENTS_URL)
74 |
75 | self.assertEqual(res.status_code, status.HTTP_200_OK)
76 | self.assertEqual(len(res.data), 1)
77 | self.assertEqual(res.data[0]['name'], ingredient.name)
78 | self.assertEqual(res.data[0]['id'], ingredient.id)
79 |
80 | def test_update_ingredient(self):
81 | """Test updating an ingredient."""
82 | ingredient = Ingredient.objects.create(user=self.user, name='Cilantro')
83 |
84 | payload = {'name': 'Coriander'}
85 | url = detail_url(ingredient.id)
86 | res = self.client.patch(url, payload)
87 |
88 | self.assertEqual(res.status_code, status.HTTP_200_OK)
89 | ingredient.refresh_from_db()
90 | self.assertEqual(ingredient.name, payload['name'])
91 |
92 | def test_delete_ingredient(self):
93 | """Test deleting an ingredient."""
94 | ingredient = Ingredient.objects.create(user=self.user, name='Lettuce')
95 |
96 | url = detail_url(ingredient.id)
97 | res = self.client.delete(url)
98 |
99 | self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT)
100 | ingredients = Ingredient.objects.filter(user=self.user)
101 | self.assertFalse(ingredients.exists())
102 |
103 | def test_filter_ingredients_assigned_to_recipes(self):
104 | """Test listing ingedients to those assigned to recipes."""
105 | in1 = Ingredient.objects.create(user=self.user, name='Apples')
106 | in2 = Ingredient.objects.create(user=self.user, name='Turkey')
107 | recipe = Recipe.objects.create(
108 | title='Apple Crumble',
109 | time_minutes=5,
110 | price=Decimal('4.50'),
111 | user=self.user,
112 | )
113 | recipe.ingredients.add(in1)
114 |
115 | res = self.client.get(INGREDIENTS_URL, {'assigned_only': 1})
116 |
117 | s1 = IngredientSerializer(in1)
118 | s2 = IngredientSerializer(in2)
119 | self.assertIn(s1.data, res.data)
120 | self.assertNotIn(s2.data, res.data)
121 |
122 | def test_filtered_ingredients_unique(self):
123 | """Test filtered ingredients returns a unique list."""
124 | ing = Ingredient.objects.create(user=self.user, name='Eggs')
125 | Ingredient.objects.create(user=self.user, name='Lentils')
126 | recipe1 = Recipe.objects.create(
127 | title='Eggs Benedict',
128 | time_minutes=60,
129 | price=Decimal('7.00'),
130 | user=self.user,
131 | )
132 | recipe2 = Recipe.objects.create(
133 | title='Herb Eggs',
134 | time_minutes=20,
135 | price=Decimal('4.00'),
136 | user=self.user,
137 | )
138 | recipe1.ingredients.add(ing)
139 | recipe2.ingredients.add(ing)
140 |
141 | res = self.client.get(INGREDIENTS_URL, {'assigned_only': 1})
142 |
143 | self.assertEqual(len(res.data), 1)
144 |
--------------------------------------------------------------------------------
/app/recipe/tests/test_recipe_api.py:
--------------------------------------------------------------------------------
1 | """
2 | Tests for recipe APIs.
3 | """
4 | from decimal import Decimal
5 | import tempfile
6 | import os
7 |
8 | from PIL import Image
9 |
10 | from django.contrib.auth import get_user_model
11 | from django.test import TestCase
12 | from django.urls import reverse
13 |
14 | from rest_framework import status
15 | from rest_framework.test import APIClient
16 |
17 | from core.models import (
18 | Recipe,
19 | Tag,
20 | Ingredient,
21 | )
22 |
23 | from recipe.serializers import (
24 | RecipeSerializer,
25 | RecipeDetailSerializer,
26 | )
27 |
28 |
29 | RECIPES_URL = reverse('recipe:recipe-list')
30 |
31 |
32 | def detail_url(recipe_id):
33 | """Create and return a recipe detail URL."""
34 | return reverse('recipe:recipe-detail', args=[recipe_id])
35 |
36 |
37 | def image_upload_url(recipe_id):
38 | """Create and return an image upload URL."""
39 | return reverse('recipe:recipe-upload-image', args=[recipe_id])
40 |
41 |
42 | def create_recipe(user, **params):
43 | """Create and return a sample recipe."""
44 | defaults = {
45 | 'title': 'Sample recipe title',
46 | 'time_minutes': 22,
47 | 'price': Decimal('5.25'),
48 | 'description': 'Sample description',
49 | 'link': 'http://example.com/recipe.pdf',
50 | }
51 | defaults.update(params)
52 |
53 | recipe = Recipe.objects.create(user=user, **defaults)
54 | return recipe
55 |
56 |
57 | def create_user(**params):
58 | """Create and return a new user."""
59 | return get_user_model().objects.create_user(**params)
60 |
61 |
62 | class PublicRecipeAPITests(TestCase):
63 | """Test unauthenticated API requests."""
64 |
65 | def setUp(self):
66 | self.client = APIClient()
67 |
68 | def test_auth_required(self):
69 | """Test auth is required to call API."""
70 | res = self.client.get(RECIPES_URL)
71 |
72 | self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED)
73 |
74 |
75 | class PrivateRecipeApiTests(TestCase):
76 | """Test authenticated API requests."""
77 |
78 | def setUp(self):
79 | self.client = APIClient()
80 | self.user = create_user(email='user@example.com', password='test123')
81 | self.client.force_authenticate(self.user)
82 |
83 | def test_retrieve_recipes(self):
84 | """Test retrieving a list of recipes."""
85 | create_recipe(user=self.user)
86 | create_recipe(user=self.user)
87 |
88 | res = self.client.get(RECIPES_URL)
89 |
90 | recipes = Recipe.objects.all().order_by('-id')
91 | serializer = RecipeSerializer(recipes, many=True)
92 | self.assertEqual(res.status_code, status.HTTP_200_OK)
93 | self.assertEqual(res.data, serializer.data)
94 |
95 | def test_recipe_list_limited_to_user(self):
96 | """Test list of recipes is limited to authenticated user."""
97 | other_user = create_user(email='other@example.com', password='test123')
98 | create_recipe(user=other_user)
99 | create_recipe(user=self.user)
100 |
101 | res = self.client.get(RECIPES_URL)
102 |
103 | recipes = Recipe.objects.filter(user=self.user)
104 | serializer = RecipeSerializer(recipes, many=True)
105 | self.assertEqual(res.status_code, status.HTTP_200_OK)
106 | self.assertEqual(res.data, serializer.data)
107 |
108 | def test_get_recipe_detail(self):
109 | """Test get recipe detail."""
110 | recipe = create_recipe(user=self.user)
111 |
112 | url = detail_url(recipe.id)
113 | res = self.client.get(url)
114 |
115 | serializer = RecipeDetailSerializer(recipe)
116 | self.assertEqual(res.data, serializer.data)
117 |
118 | def test_create_recipe(self):
119 | """Test creating a recipe."""
120 | payload = {
121 | 'title': 'Sample recipe',
122 | 'time_minutes': 30,
123 | 'price': Decimal('5.99'),
124 | }
125 | res = self.client.post(RECIPES_URL, payload)
126 |
127 | self.assertEqual(res.status_code, status.HTTP_201_CREATED)
128 | recipe = Recipe.objects.get(id=res.data['id'])
129 | for k, v in payload.items():
130 | self.assertEqual(getattr(recipe, k), v)
131 | self.assertEqual(recipe.user, self.user)
132 |
133 | def test_partial_update(self):
134 | """Test partial update of a recipe."""
135 | original_link = 'https://example.com/recipe.pdf'
136 | recipe = create_recipe(
137 | user=self.user,
138 | title='Sample recipe title',
139 | link=original_link,
140 | )
141 |
142 | payload = {'title': 'New recipe title'}
143 | url = detail_url(recipe.id)
144 | res = self.client.patch(url, payload)
145 |
146 | self.assertEqual(res.status_code, status.HTTP_200_OK)
147 | recipe.refresh_from_db()
148 | self.assertEqual(recipe.title, payload['title'])
149 | self.assertEqual(recipe.link, original_link)
150 | self.assertEqual(recipe.user, self.user)
151 |
152 | def test_full_update(self):
153 | """Test full update of recipe."""
154 | recipe = create_recipe(
155 | user=self.user,
156 | title='Sample recipe title',
157 | link='https://exmaple.com/recipe.pdf',
158 | description='Sample recipe description.',
159 | )
160 |
161 | payload = {
162 | 'title': 'New recipe title',
163 | 'link': 'https://example.com/new-recipe.pdf',
164 | 'description': 'New recipe description',
165 | 'time_minutes': 10,
166 | 'price': Decimal('2.50'),
167 | }
168 | url = detail_url(recipe.id)
169 | res = self.client.put(url, payload)
170 |
171 | self.assertEqual(res.status_code, status.HTTP_200_OK)
172 | recipe.refresh_from_db()
173 | for k, v in payload.items():
174 | self.assertEqual(getattr(recipe, k), v)
175 | self.assertEqual(recipe.user, self.user)
176 |
177 | def test_update_user_returns_error(self):
178 | """Test changing the recipe user results in an error."""
179 | new_user = create_user(email='user2@example.com', password='test123')
180 | recipe = create_recipe(user=self.user)
181 |
182 | payload = {'user': new_user.id}
183 | url = detail_url(recipe.id)
184 | self.client.patch(url, payload)
185 |
186 | recipe.refresh_from_db()
187 | self.assertEqual(recipe.user, self.user)
188 |
189 | def test_delete_recipe(self):
190 | """Test deleting a recipe successful."""
191 | recipe = create_recipe(user=self.user)
192 |
193 | url = detail_url(recipe.id)
194 | res = self.client.delete(url)
195 |
196 | self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT)
197 | self.assertFalse(Recipe.objects.filter(id=recipe.id).exists())
198 |
199 | def test_recipe_other_users_recipe_error(self):
200 | """Test trying to delete another users recipe gives error."""
201 | new_user = create_user(email='user2@example.com', password='test123')
202 | recipe = create_recipe(user=new_user)
203 |
204 | url = detail_url(recipe.id)
205 | res = self.client.delete(url)
206 |
207 | self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND)
208 | self.assertTrue(Recipe.objects.filter(id=recipe.id).exists())
209 |
210 | def test_create_recipe_with_new_tags(self):
211 | """Test creating a recipe with new tags."""
212 | payload = {
213 | 'title': 'Thai Prawn Curry',
214 | 'time_minutes': 30,
215 | 'price': Decimal('2.50'),
216 | 'tags': [{'name': 'Thai'}, {'name': 'Dinner'}],
217 | }
218 | res = self.client.post(RECIPES_URL, payload, format='json')
219 |
220 | self.assertEqual(res.status_code, status.HTTP_201_CREATED)
221 | recipes = Recipe.objects.filter(user=self.user)
222 | self.assertEqual(recipes.count(), 1)
223 | recipe = recipes[0]
224 | self.assertEqual(recipe.tags.count(), 2)
225 | for tag in payload['tags']:
226 | exists = recipe.tags.filter(
227 | name=tag['name'],
228 | user=self.user,
229 | ).exists()
230 | self.assertTrue(exists)
231 |
232 | def test_create_recipe_with_existing_tags(self):
233 | """Test creating a recipe with existing tag."""
234 | tag_indian = Tag.objects.create(user=self.user, name='Indian')
235 | payload = {
236 | 'title': 'Pongal',
237 | 'time_minutes': 60,
238 | 'price': Decimal('4.50'),
239 | 'tags': [{'name': 'Indian'}, {'name': 'Breakfast'}],
240 | }
241 | res = self.client.post(RECIPES_URL, payload, format='json')
242 |
243 | self.assertEqual(res.status_code, status.HTTP_201_CREATED)
244 | recipes = Recipe.objects.filter(user=self.user)
245 | self.assertEqual(recipes.count(), 1)
246 | recipe = recipes[0]
247 | self.assertEqual(recipe.tags.count(), 2)
248 | self.assertIn(tag_indian, recipe.tags.all())
249 | for tag in payload['tags']:
250 | exists = recipe.tags.filter(
251 | name=tag['name'],
252 | user=self.user,
253 | ).exists()
254 | self.assertTrue(exists)
255 |
256 | def test_create_tag_on_update(self):
257 | """Test create tag when updating a recipe."""
258 | recipe = create_recipe(user=self.user)
259 |
260 | payload = {'tags': [{'name': 'Lunch'}]}
261 | url = detail_url(recipe.id)
262 | res = self.client.patch(url, payload, format='json')
263 |
264 | self.assertEqual(res.status_code, status.HTTP_200_OK)
265 | new_tag = Tag.objects.get(user=self.user, name='Lunch')
266 | self.assertIn(new_tag, recipe.tags.all())
267 |
268 | def test_update_recipe_assign_tag(self):
269 | """Test assigning an existing tag when updating a recipe."""
270 | tag_breakfast = Tag.objects.create(user=self.user, name='Breakfast')
271 | recipe = create_recipe(user=self.user)
272 | recipe.tags.add(tag_breakfast)
273 |
274 | tag_lunch = Tag.objects.create(user=self.user, name='Lunch')
275 | payload = {'tags': [{'name': 'Lunch'}]}
276 | url = detail_url(recipe.id)
277 | res = self.client.patch(url, payload, format='json')
278 |
279 | self.assertEqual(res.status_code, status.HTTP_200_OK)
280 | self.assertIn(tag_lunch, recipe.tags.all())
281 | self.assertNotIn(tag_breakfast, recipe.tags.all())
282 |
283 | def test_clear_recipe_tags(self):
284 | """Test clearing a recipes tags."""
285 | tag = Tag.objects.create(user=self.user, name='Dessert')
286 | recipe = create_recipe(user=self.user)
287 | recipe.tags.add(tag)
288 |
289 | payload = {'tags': []}
290 | url = detail_url(recipe.id)
291 | res = self.client.patch(url, payload, format='json')
292 |
293 | self.assertEqual(res.status_code, status.HTTP_200_OK)
294 | self.assertEqual(recipe.tags.count(), 0)
295 |
296 | def test_create_recipe_with_new_ingredients(self):
297 | """Test creating a recipe with new ingredients."""
298 | payload = {
299 | 'title': 'Cauliflower Tacos',
300 | 'time_minutes': 60,
301 | 'price': Decimal('4.30'),
302 | 'ingredients': [{'name': 'Cauliflower'}, {'name': 'Salt'}],
303 | }
304 | res = self.client.post(RECIPES_URL, payload, format='json')
305 |
306 | self.assertEqual(res.status_code, status.HTTP_201_CREATED)
307 | recipes = Recipe.objects.filter(user=self.user)
308 | self.assertEqual(recipes.count(), 1)
309 | recipe = recipes[0]
310 | self.assertEqual(recipe.ingredients.count(), 2)
311 | for ingredient in payload['ingredients']:
312 | exists = recipe.ingredients.filter(
313 | name=ingredient['name'],
314 | user=self.user,
315 | ).exists()
316 | self.assertTrue(exists)
317 |
318 | def test_create_recipe_with_existing_ingredient(self):
319 | """Test creating a new recipe with existing ingredient."""
320 | ingredient = Ingredient.objects.create(user=self.user, name='Lemon')
321 | payload = {
322 | 'title': 'Vietnamese Soup',
323 | 'time_minutes': 25,
324 | 'price': '2.55',
325 | 'ingredients': [{'name': 'Lemon'}, {'name': 'Fish Sauce'}],
326 | }
327 | res = self.client.post(RECIPES_URL, payload, format='json')
328 |
329 | self.assertEqual(res.status_code, status.HTTP_201_CREATED)
330 | recipes = Recipe.objects.filter(user=self.user)
331 | self.assertEqual(recipes.count(), 1)
332 | recipe = recipes[0]
333 | self.assertEqual(recipe.ingredients.count(), 2)
334 | self.assertIn(ingredient, recipe.ingredients.all())
335 | for ingredient in payload['ingredients']:
336 | exists = recipe.ingredients.filter(
337 | name=ingredient['name'],
338 | user=self.user,
339 | ).exists()
340 | self.assertTrue(exists)
341 |
342 | def test_create_ingredient_on_update(self):
343 | """Test creating an ingredient when updating a recipe."""
344 | recipe = create_recipe(user=self.user)
345 |
346 | payload = {'ingredients': [{'name': 'Limes'}]}
347 | url = detail_url(recipe.id)
348 | res = self.client.patch(url, payload, format='json')
349 |
350 | self.assertEqual(res.status_code, status.HTTP_200_OK)
351 | new_ingredient = Ingredient.objects.get(user=self.user, name='Limes')
352 | self.assertIn(new_ingredient, recipe.ingredients.all())
353 |
354 | def test_update_recipe_assign_ingredient(self):
355 | """Test assigning an existing ingredient when updating a recipe."""
356 | ingredient1 = Ingredient.objects.create(user=self.user, name='Pepper')
357 | recipe = create_recipe(user=self.user)
358 | recipe.ingredients.add(ingredient1)
359 |
360 | ingredient2 = Ingredient.objects.create(user=self.user, name='Chili')
361 | payload = {'ingredients': [{'name': 'Chili'}]}
362 | url = detail_url(recipe.id)
363 | res = self.client.patch(url, payload, format='json')
364 |
365 | self.assertEqual(res.status_code, status.HTTP_200_OK)
366 | self.assertIn(ingredient2, recipe.ingredients.all())
367 | self.assertNotIn(ingredient1, recipe.ingredients.all())
368 |
369 | def test_clear_recipe_ingredients(self):
370 | """Test clearing a recipes ingredients."""
371 | ingredient = Ingredient.objects.create(user=self.user, name='Garlic')
372 | recipe = create_recipe(user=self.user)
373 | recipe.ingredients.add(ingredient)
374 |
375 | payload = {'ingredients': []}
376 | url = detail_url(recipe.id)
377 | res = self.client.patch(url, payload, format='json')
378 |
379 | self.assertEqual(res.status_code, status.HTTP_200_OK)
380 | self.assertEqual(recipe.ingredients.count(), 0)
381 |
382 | def test_filter_by_tags(self):
383 | """Test filtering recipes by tags."""
384 | r1 = create_recipe(user=self.user, title='Thai Vegetable Curry')
385 | r2 = create_recipe(user=self.user, title='Aubergine with Tahini')
386 | tag1 = Tag.objects.create(user=self.user, name='Vegan')
387 | tag2 = Tag.objects.create(user=self.user, name='Vegetarian')
388 | r1.tags.add(tag1)
389 | r2.tags.add(tag2)
390 | r3 = create_recipe(user=self.user, title='Fish and chips')
391 |
392 | params = {'tags': f'{tag1.id},{tag2.id}'}
393 | res = self.client.get(RECIPES_URL, params)
394 |
395 | s1 = RecipeSerializer(r1)
396 | s2 = RecipeSerializer(r2)
397 | s3 = RecipeSerializer(r3)
398 | self.assertIn(s1.data, res.data)
399 | self.assertIn(s2.data, res.data)
400 | self.assertNotIn(s3.data, res.data)
401 |
402 | def test_filter_by_ingredients(self):
403 | """Test filtering recipes by ingredients."""
404 | r1 = create_recipe(user=self.user, title='Posh Beans on Toast')
405 | r2 = create_recipe(user=self.user, title='Chicken Cacciatore')
406 | in1 = Ingredient.objects.create(user=self.user, name='Feta Cheese')
407 | in2 = Ingredient.objects.create(user=self.user, name='Chicken')
408 | r1.ingredients.add(in1)
409 | r2.ingredients.add(in2)
410 | r3 = create_recipe(user=self.user, title='Red Lentil Daal')
411 |
412 | params = {'ingredients': f'{in1.id},{in2.id}'}
413 | res = self.client.get(RECIPES_URL, params)
414 |
415 | s1 = RecipeSerializer(r1)
416 | s2 = RecipeSerializer(r2)
417 | s3 = RecipeSerializer(r3)
418 | self.assertIn(s1.data, res.data)
419 | self.assertIn(s2.data, res.data)
420 | self.assertNotIn(s3.data, res.data)
421 |
422 |
423 | class ImageUploadTests(TestCase):
424 | """Tests for the image upload API."""
425 |
426 | def setUp(self):
427 | self.client = APIClient()
428 | self.user = get_user_model().objects.create_user(
429 | 'user@example.com',
430 | 'password123',
431 | )
432 | self.client.force_authenticate(self.user)
433 | self.recipe = create_recipe(user=self.user)
434 |
435 | def tearDown(self):
436 | self.recipe.image.delete()
437 |
438 | def test_upload_image(self):
439 | """Test uploading an image to a recipe."""
440 | url = image_upload_url(self.recipe.id)
441 | with tempfile.NamedTemporaryFile(suffix='.jpg') as image_file:
442 | img = Image.new('RGB', (10, 10))
443 | img.save(image_file, format='JPEG')
444 | image_file.seek(0)
445 | payload = {'image': image_file}
446 | res = self.client.post(url, payload, format='multipart')
447 |
448 | self.recipe.refresh_from_db()
449 | self.assertEqual(res.status_code, status.HTTP_200_OK)
450 | self.assertIn('image', res.data)
451 | self.assertTrue(os.path.exists(self.recipe.image.path))
452 |
453 | def test_upload_image_bad_request(self):
454 | """Test uploading an invalid image."""
455 | url = image_upload_url(self.recipe.id)
456 | payload = {'image': 'notanimage'}
457 | res = self.client.post(url, payload, format='multipart')
458 |
459 | self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
460 |
--------------------------------------------------------------------------------
/app/recipe/tests/test_tags_api.py:
--------------------------------------------------------------------------------
1 | """
2 | Tests for the tags API.
3 | """
4 | from decimal import Decimal
5 |
6 | from django.contrib.auth import get_user_model
7 | from django.urls import reverse
8 | from django.test import TestCase
9 |
10 | from rest_framework import status
11 | from rest_framework.test import APIClient
12 |
13 | from core.models import (
14 | Tag,
15 | Recipe,
16 | )
17 |
18 | from recipe.serializers import TagSerializer
19 |
20 |
21 | TAGS_URL = reverse('recipe:tag-list')
22 |
23 |
24 | def detail_url(tag_id):
25 | """Create and return a tag detail url."""
26 | return reverse('recipe:tag-detail', args=[tag_id])
27 |
28 |
29 | def create_user(email='user@example.com', password='testpass123'):
30 | """Create and return a user."""
31 | return get_user_model().objects.create_user(email=email, password=password)
32 |
33 |
34 | class PublicTagsApiTests(TestCase):
35 | """Test unauthenticated API requests."""
36 |
37 | def setUp(self):
38 | self.client = APIClient()
39 |
40 | def test_auth_required(self):
41 | """Test auth is required for retrieving tags."""
42 | res = self.client.get(TAGS_URL)
43 |
44 | self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED)
45 |
46 |
47 | class PrivateTagsApiTests(TestCase):
48 | """Test authenticated API requests."""
49 |
50 | def setUp(self):
51 | self.user = create_user()
52 | self.client = APIClient()
53 | self.client.force_authenticate(self.user)
54 |
55 | def test_retrieve_tags(self):
56 | """Test retrieving a list of tags."""
57 | Tag.objects.create(user=self.user, name='Vegan')
58 | Tag.objects.create(user=self.user, name='Dessert')
59 |
60 | res = self.client.get(TAGS_URL)
61 |
62 | tags = Tag.objects.all().order_by('-name')
63 | serializer = TagSerializer(tags, many=True)
64 | self.assertEqual(res.status_code, status.HTTP_200_OK)
65 | self.assertEqual(res.data, serializer.data)
66 |
67 | def test_tags_limited_to_user(self):
68 | """Test list of tags is limited to authenticated user."""
69 | user2 = create_user(email='user2@example.com')
70 | Tag.objects.create(user=user2, name='Fruity')
71 | tag = Tag.objects.create(user=self.user, name='Comfort Food')
72 |
73 | res = self.client.get(TAGS_URL)
74 |
75 | self.assertEqual(res.status_code, status.HTTP_200_OK)
76 | self.assertEqual(len(res.data), 1)
77 | self.assertEqual(res.data[0]['name'], tag.name)
78 | self.assertEqual(res.data[0]['id'], tag.id)
79 |
80 | def test_update_tag(self):
81 | """Test updating a tag."""
82 | tag = Tag.objects.create(user=self.user, name='After Dinner')
83 |
84 | payload = {'name': 'Dessert'}
85 | url = detail_url(tag.id)
86 | res = self.client.patch(url, payload)
87 |
88 | self.assertEqual(res.status_code, status.HTTP_200_OK)
89 | tag.refresh_from_db()
90 | self.assertEqual(tag.name, payload['name'])
91 |
92 | def test_delete_tag(self):
93 | """Test deleting a tag."""
94 | tag = Tag.objects.create(user=self.user, name='Breakfast')
95 |
96 | url = detail_url(tag.id)
97 | res = self.client.delete(url)
98 |
99 | self.assertEqual(res.status_code, status.HTTP_204_NO_CONTENT)
100 | tags = Tag.objects.filter(user=self.user)
101 | self.assertFalse(tags.exists())
102 |
103 | def test_filter_tags_assigned_to_recipes(self):
104 | """Test listing tags to those assigned to recipes."""
105 | tag1 = Tag.objects.create(user=self.user, name='Breakfast')
106 | tag2 = Tag.objects.create(user=self.user, name='Lunch')
107 | recipe = Recipe.objects.create(
108 | title='Green Eggs on Toast',
109 | time_minutes=10,
110 | price=Decimal('2.50'),
111 | user=self.user,
112 | )
113 | recipe.tags.add(tag1)
114 |
115 | res = self.client.get(TAGS_URL, {'assigned_only': 1})
116 |
117 | s1 = TagSerializer(tag1)
118 | s2 = TagSerializer(tag2)
119 | self.assertIn(s1.data, res.data)
120 | self.assertNotIn(s2.data, res.data)
121 |
122 | def test_filtered_tags_unique(self):
123 | """Test filtered tags returns a unique list."""
124 | tag = Tag.objects.create(user=self.user, name='Breakfast')
125 | Tag.objects.create(user=self.user, name='Dinner')
126 | recipe1 = Recipe.objects.create(
127 | title='Pancakes',
128 | time_minutes=5,
129 | price=Decimal('5.00'),
130 | user=self.user,
131 | )
132 | recipe2 = Recipe.objects.create(
133 | title='Porridge',
134 | time_minutes=3,
135 | price=Decimal('2.00'),
136 | user=self.user,
137 | )
138 | recipe1.tags.add(tag)
139 | recipe2.tags.add(tag)
140 |
141 | res = self.client.get(TAGS_URL, {'assigned_only': 1})
142 |
143 | self.assertEqual(len(res.data), 1)
144 |
--------------------------------------------------------------------------------
/app/recipe/urls.py:
--------------------------------------------------------------------------------
1 | """
2 | URL mappings for the recipe app.
3 | """
4 | from django.urls import (
5 | path,
6 | include,
7 | )
8 |
9 | from rest_framework.routers import DefaultRouter
10 |
11 | from recipe import views
12 |
13 |
14 | router = DefaultRouter()
15 | router.register('recipes', views.RecipeViewSet)
16 | router.register('tags', views.TagViewSet)
17 | router.register('ingredients', views.IngredientViewSet)
18 |
19 | app_name = 'recipe'
20 |
21 | urlpatterns = [
22 | path('', include(router.urls)),
23 | ]
24 |
--------------------------------------------------------------------------------
/app/recipe/views.py:
--------------------------------------------------------------------------------
1 | """
2 | Views for the recipe APIs
3 | """
4 | from drf_spectacular.utils import (
5 | extend_schema_view,
6 | extend_schema,
7 | OpenApiParameter,
8 | OpenApiTypes,
9 | )
10 |
11 | from rest_framework import (
12 | viewsets,
13 | mixins,
14 | status,
15 | )
16 | from rest_framework.decorators import action
17 | from rest_framework.response import Response
18 | from rest_framework.authentication import TokenAuthentication
19 | from rest_framework.permissions import IsAuthenticated
20 |
21 | from core.models import (
22 | Recipe,
23 | Tag,
24 | Ingredient,
25 | )
26 | from recipe import serializers
27 |
28 |
29 | @extend_schema_view(
30 | list=extend_schema(
31 | parameters=[
32 | OpenApiParameter(
33 | 'tags',
34 | OpenApiTypes.STR,
35 | description='Comma separated list of tag IDs to filter',
36 | ),
37 | OpenApiParameter(
38 | 'ingredients',
39 | OpenApiTypes.STR,
40 | description='Comma separated list of ingredient IDs to filter',
41 | ),
42 | ]
43 | )
44 | )
45 | class RecipeViewSet(viewsets.ModelViewSet):
46 | """View for manage recipe APIs."""
47 | serializer_class = serializers.RecipeDetailSerializer
48 | queryset = Recipe.objects.all()
49 | authentication_classes = [TokenAuthentication]
50 | permission_classes = [IsAuthenticated]
51 |
52 | def _params_to_ints(self, qs):
53 | """Convert a list of strings to integers."""
54 | return [int(str_id) for str_id in qs.split(',')]
55 |
56 | def get_queryset(self):
57 | """Retrieve recipes for authenticated user."""
58 | tags = self.request.query_params.get('tags')
59 | ingredients = self.request.query_params.get('ingredients')
60 | queryset = self.queryset
61 | if tags:
62 | tag_ids = self._params_to_ints(tags)
63 | queryset = queryset.filter(tags__id__in=tag_ids)
64 | if ingredients:
65 | ingredient_ids = self._params_to_ints(ingredients)
66 | queryset = queryset.filter(ingredients__id__in=ingredient_ids)
67 |
68 | return queryset.filter(
69 | user=self.request.user
70 | ).order_by('-id').distinct()
71 |
72 | def get_serializer_class(self):
73 | """Return the serializer class for request."""
74 | if self.action == 'list':
75 | return serializers.RecipeSerializer
76 | elif self.action == 'upload_image':
77 | return serializers.RecipeImageSerializer
78 |
79 | return self.serializer_class
80 |
81 | def perform_create(self, serializer):
82 | """Create a new recipe."""
83 | serializer.save(user=self.request.user)
84 |
85 | @action(methods=['POST'], detail=True, url_path='upload-image')
86 | def upload_image(self, request, pk=None):
87 | """Upload an image to recipe."""
88 | recipe = self.get_object()
89 | serializer = self.get_serializer(recipe, data=request.data)
90 |
91 | if serializer.is_valid():
92 | serializer.save()
93 | return Response(serializer.data, status=status.HTTP_200_OK)
94 |
95 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
96 |
97 |
98 | @extend_schema_view(
99 | list=extend_schema(
100 | parameters=[
101 | OpenApiParameter(
102 | 'assigned_only',
103 | OpenApiTypes.INT, enum=[0, 1],
104 | description='Filter by items assigned to recipes.',
105 | ),
106 | ]
107 | )
108 | )
109 | class BaseRecipeAttrViewSet(mixins.DestroyModelMixin,
110 | mixins.UpdateModelMixin,
111 | mixins.ListModelMixin,
112 | viewsets.GenericViewSet):
113 | """Base viewset for recipe attributes."""
114 | authentication_classes = [TokenAuthentication]
115 | permission_classes = [IsAuthenticated]
116 |
117 | def get_queryset(self):
118 | """Filter queryset to authenticated user."""
119 | assigned_only = bool(
120 | int(self.request.query_params.get('assigned_only', 0))
121 | )
122 | queryset = self.queryset
123 | if assigned_only:
124 | queryset = queryset.filter(recipe__isnull=False)
125 |
126 | return queryset.filter(
127 | user=self.request.user
128 | ).order_by('-name').distinct()
129 |
130 |
131 | class TagViewSet(BaseRecipeAttrViewSet):
132 | """Manage tags in the database."""
133 | serializer_class = serializers.TagSerializer
134 | queryset = Tag.objects.all()
135 |
136 |
137 | class IngredientViewSet(BaseRecipeAttrViewSet):
138 | """Manage ingredients in the database."""
139 | serializer_class = serializers.IngredientSerializer
140 | queryset = Ingredient.objects.all()
141 |
--------------------------------------------------------------------------------
/app/user/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LondonAppDeveloper/c2-recipe-app-api-2/0b193f0681c6eda2cc76e13bbd495861f9d54193/app/user/__init__.py
--------------------------------------------------------------------------------
/app/user/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class UserConfig(AppConfig):
5 | default_auto_field = 'django.db.models.BigAutoField'
6 | name = 'user'
7 |
--------------------------------------------------------------------------------
/app/user/serializers.py:
--------------------------------------------------------------------------------
1 | """
2 | Serializers for the user API View.
3 | """
4 | from django.contrib.auth import (
5 | get_user_model,
6 | authenticate,
7 | )
8 | from django.utils.translation import gettext as _
9 |
10 | from rest_framework import serializers
11 |
12 |
13 | class UserSerializer(serializers.ModelSerializer):
14 | """Serializer for the user object."""
15 |
16 | class Meta:
17 | model = get_user_model()
18 | fields = ['email', 'password', 'name']
19 | extra_kwargs = {'password': {'write_only': True, 'min_length': 5}}
20 |
21 | def create(self, validated_data):
22 | """Create and return a user with encrypted password."""
23 | return get_user_model().objects.create_user(**validated_data)
24 |
25 | def update(self, instance, validated_data):
26 | """Update and return user."""
27 | password = validated_data.pop('password', None)
28 | user = super().update(instance, validated_data)
29 |
30 | if password:
31 | user.set_password(password)
32 | user.save()
33 |
34 | return user
35 |
36 |
37 | class AuthTokenSerializer(serializers.Serializer):
38 | """Serializer for the user auth token."""
39 | email = serializers.EmailField()
40 | password = serializers.CharField(
41 | style={'input_type': 'password'},
42 | trim_whitespace=False,
43 | )
44 |
45 | def validate(self, attrs):
46 | """Validate and authenticate the user."""
47 | email = attrs.get('email')
48 | password = attrs.get('password')
49 | user = authenticate(
50 | request=self.context.get('request'),
51 | username=email,
52 | password=password,
53 | )
54 | if not user:
55 | msg = _('Unable to authenticate with provided credentials.')
56 | raise serializers.ValidationError(msg, code='authorization')
57 |
58 | attrs['user'] = user
59 | return attrs
60 |
--------------------------------------------------------------------------------
/app/user/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/LondonAppDeveloper/c2-recipe-app-api-2/0b193f0681c6eda2cc76e13bbd495861f9d54193/app/user/tests/__init__.py
--------------------------------------------------------------------------------
/app/user/tests/test_user_api.py:
--------------------------------------------------------------------------------
1 | """
2 | Tests for the user API.
3 | """
4 | from django.test import TestCase
5 | from django.contrib.auth import get_user_model
6 | from django.urls import reverse
7 |
8 | from rest_framework.test import APIClient
9 | from rest_framework import status
10 |
11 |
12 | CREATE_USER_URL = reverse('user:create')
13 | TOKEN_URL = reverse('user:token')
14 | ME_URL = reverse('user:me')
15 |
16 |
17 | def create_user(**params):
18 | """Create and return a new user."""
19 | return get_user_model().objects.create_user(**params)
20 |
21 |
22 | class PublicUserApiTests(TestCase):
23 | """Test the public features of the user API."""
24 |
25 | def setUp(self):
26 | self.client = APIClient()
27 |
28 | def test_create_user_success(self):
29 | """Test creating a user is successful."""
30 | payload = {
31 | 'email': 'test@example.com',
32 | 'password': 'testpass123',
33 | 'name': 'Test Name',
34 | }
35 | res = self.client.post(CREATE_USER_URL, payload)
36 |
37 | self.assertEqual(res.status_code, status.HTTP_201_CREATED)
38 | user = get_user_model().objects.get(email=payload['email'])
39 | self.assertTrue(user.check_password(payload['password']))
40 | self.assertNotIn('password', res.data)
41 |
42 | def test_user_with_email_exists_error(self):
43 | """Test error returned if user with email exists."""
44 | payload = {
45 | 'email': 'test@example.com',
46 | 'password': 'testpass123',
47 | 'name': 'Test Name',
48 | }
49 | create_user(**payload)
50 | res = self.client.post(CREATE_USER_URL, payload)
51 |
52 | self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
53 |
54 | def test_password_too_short_error(self):
55 | """Test an error is returned if password less than 5 chars."""
56 | payload = {
57 | 'email': 'test@example.com',
58 | 'password': 'pw',
59 | 'name': 'Test name',
60 | }
61 | res = self.client.post(CREATE_USER_URL, payload)
62 |
63 | self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
64 | user_exists = get_user_model().objects.filter(
65 | email=payload['email']
66 | ).exists()
67 | self.assertFalse(user_exists)
68 |
69 | def test_create_token_for_user(self):
70 | """Test generates token for valid credentials."""
71 | user_details = {
72 | 'name': 'Test Name',
73 | 'email': 'test@example.com',
74 | 'password': 'test-user-password123',
75 | }
76 | create_user(**user_details)
77 |
78 | payload = {
79 | 'email': user_details['email'],
80 | 'password': user_details['password'],
81 | }
82 | res = self.client.post(TOKEN_URL, payload)
83 |
84 | self.assertIn('token', res.data)
85 | self.assertEqual(res.status_code, status.HTTP_200_OK)
86 |
87 | def test_create_token_bad_credentials(self):
88 | """Test returns error if credentials invalid."""
89 | create_user(email='test@example.com', password='goodpass')
90 |
91 | payload = {'email': 'test@example.com', 'password': 'badpass'}
92 | res = self.client.post(TOKEN_URL, payload)
93 |
94 | self.assertNotIn('token', res.data)
95 | self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
96 |
97 | def test_create_token_email_not_found(self):
98 | """Test error returned if user not found for given email."""
99 | payload = {'email': 'test@example.com', 'password': 'pass123'}
100 | res = self.client.post(TOKEN_URL, payload)
101 |
102 | self.assertNotIn('token', res.data)
103 | self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
104 |
105 | def test_create_token_blank_password(self):
106 | """Test posting a blank password returns an error."""
107 | payload = {'email': 'test@example.com', 'password': ''}
108 | res = self.client.post(TOKEN_URL, payload)
109 |
110 | self.assertNotIn('token', res.data)
111 | self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
112 |
113 | def test_retrieve_user_unauthorized(self):
114 | """Test authentication is required for users."""
115 | res = self.client.get(ME_URL)
116 |
117 | self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED)
118 |
119 |
120 | class PrivateUserApiTests(TestCase):
121 | """Test API requests that require authentication."""
122 |
123 | def setUp(self):
124 | self.user = create_user(
125 | email='test@example.com',
126 | password='testpass123',
127 | name='Test Name',
128 | )
129 | self.client = APIClient()
130 | self.client.force_authenticate(user=self.user)
131 |
132 | def test_retrieve_profile_success(self):
133 | """Test retrieving profile for logged in user."""
134 | res = self.client.get(ME_URL)
135 |
136 | self.assertEqual(res.status_code, status.HTTP_200_OK)
137 | self.assertEqual(res.data, {
138 | 'name': self.user.name,
139 | 'email': self.user.email,
140 | })
141 |
142 | def test_post_me_not_allowed(self):
143 | """Test POST is not allowed for the me endpoint."""
144 | res = self.client.post(ME_URL, {})
145 |
146 | self.assertEqual(res.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
147 |
148 | def test_update_user_profile(self):
149 | """Test updating the user profile for the authenticated user."""
150 | payload = {'name': 'Updated name', 'password': 'newpassword123'}
151 |
152 | res = self.client.patch(ME_URL, payload)
153 |
154 | self.user.refresh_from_db()
155 | self.assertEqual(self.user.name, payload['name'])
156 | self.assertTrue(self.user.check_password(payload['password']))
157 | self.assertEqual(res.status_code, status.HTTP_200_OK)
158 |
--------------------------------------------------------------------------------
/app/user/urls.py:
--------------------------------------------------------------------------------
1 | """
2 | URL mappings for the user API.
3 | """
4 | from django.urls import path
5 |
6 | from user import views
7 |
8 |
9 | app_name = 'user'
10 |
11 | urlpatterns = [
12 | path('create/', views.CreateUserView.as_view(), name='create'),
13 | path('token/', views.CreateTokenView.as_view(), name='token'),
14 | path('me/', views.ManageUserView.as_view(), name='me'),
15 | ]
16 |
--------------------------------------------------------------------------------
/app/user/views.py:
--------------------------------------------------------------------------------
1 | """
2 | Views for the user API.
3 | """
4 | from rest_framework import generics, authentication, permissions
5 | from rest_framework.authtoken.views import ObtainAuthToken
6 | from rest_framework.settings import api_settings
7 |
8 | from user.serializers import (
9 | UserSerializer,
10 | AuthTokenSerializer,
11 | )
12 |
13 |
14 | class CreateUserView(generics.CreateAPIView):
15 | """Create a new user in the system."""
16 | serializer_class = UserSerializer
17 |
18 |
19 | class CreateTokenView(ObtainAuthToken):
20 | """Create a new auth token for user."""
21 | serializer_class = AuthTokenSerializer
22 | renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES
23 |
24 |
25 | class ManageUserView(generics.RetrieveUpdateAPIView):
26 | """Manage the authenticated user."""
27 | serializer_class = UserSerializer
28 | authentication_classes = [authentication.TokenAuthentication]
29 | permission_classes = [permissions.IsAuthenticated]
30 |
31 | def get_object(self):
32 | """Retrieve and return the authenticated user."""
33 | return self.request.user
34 |
--------------------------------------------------------------------------------
/docker-compose-deploy.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 |
3 | services:
4 | app:
5 | build:
6 | context: .
7 | restart: always
8 | volumes:
9 | - static-data:/vol/web
10 | environment:
11 | - DB_HOST=db
12 | - DB_NAME=${DB_NAME}
13 | - DB_USER=${DB_USER}
14 | - DB_PASS=${DB_PASS}
15 | - SECRET_KEY=${DJANGO_SECRET_KEY}
16 | - ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS}
17 | depends_on:
18 | - db
19 |
20 | db:
21 | image: postgres:13-alpine
22 | restart: always
23 | volumes:
24 | - postgres-data:/var/lib/postgresql/data
25 | environment:
26 | - POSTGRES_DB=${DB_NAME}
27 | - POSTGRES_USER=${DB_USER}
28 | - POSTGRES_PASSWORD=${DB_PASS}
29 |
30 | proxy:
31 | build:
32 | context: ./proxy
33 | restart: always
34 | depends_on:
35 | - app
36 | ports:
37 | - 80:8000
38 | volumes:
39 | - static-data:/vol/static
40 |
41 | volumes:
42 | postgres-data:
43 | static-data:
44 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 |
3 | services:
4 | app:
5 | build:
6 | context: .
7 | args:
8 | - DEV=true
9 | ports:
10 | - "8000:8000"
11 | volumes:
12 | - ./app:/app
13 | - dev-static-data:/vol/web
14 | command: >
15 | sh -c "python manage.py wait_for_db &&
16 | python manage.py migrate &&
17 | python manage.py runserver 0.0.0.0:8000"
18 | environment:
19 | - DB_HOST=db
20 | - DB_NAME=devdb
21 | - DB_USER=devuser
22 | - DB_PASS=changeme
23 | - DEBUG=1
24 | depends_on:
25 | db:
26 | condition: service_healthy
27 |
28 | db:
29 | image: postgres:13-alpine
30 | volumes:
31 | - dev-db-data:/var/lib/postgresql/data
32 | environment:
33 | - POSTGRES_DB=devdb
34 | - POSTGRES_USER=devuser
35 | - POSTGRES_PASSWORD=changeme
36 | healthcheck:
37 | test: ["CMD", "pg_isready", "-q", "-d", "devdb", "-U", "devuser"]
38 |
39 | volumes:
40 | dev-db-data:
41 | dev-static-data:
42 |
--------------------------------------------------------------------------------
/proxy/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM nginxinc/nginx-unprivileged:1-alpine
2 | LABEL maintainer="londonappdeveloper.com"
3 |
4 | COPY ./default.conf.tpl /etc/nginx/default.conf.tpl
5 | COPY ./uwsgi_params /etc/nginx/uwsgi_params
6 | COPY ./run.sh /run.sh
7 |
8 | ENV LISTEN_PORT=8000
9 | ENV APP_HOST=app
10 | ENV APP_PORT=9000
11 |
12 | USER root
13 |
14 | RUN mkdir -p /vol/static && \
15 | chmod 755 /vol/static && \
16 | touch /etc/nginx/conf.d/default.conf && \
17 | chown nginx:nginx /etc/nginx/conf.d/default.conf && \
18 | chmod +x /run.sh
19 |
20 | VOLUME /vol/static
21 |
22 | USER nginx
23 |
24 | CMD ["/run.sh"]
25 |
--------------------------------------------------------------------------------
/proxy/default.conf.tpl:
--------------------------------------------------------------------------------
1 | server {
2 | listen ${LISTEN_PORT};
3 |
4 | location /static {
5 | alias /vol/static;
6 | }
7 |
8 | location / {
9 | uwsgi_pass ${APP_HOST}:${APP_PORT};
10 | include /etc/nginx/uwsgi_params;
11 | client_max_body_size 10M;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/proxy/run.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -e
4 |
5 | envsubst < /etc/nginx/default.conf.tpl > /etc/nginx/conf.d/default.conf
6 | nginx -g 'daemon off;'
7 |
--------------------------------------------------------------------------------
/proxy/uwsgi_params:
--------------------------------------------------------------------------------
1 | uwsgi_param QUERY_STRING $query_string;
2 | uwsgi_param REQUEST_METHOD $request_method;
3 | uwsgi_param CONTENT_TYPE $content_type;
4 | uwsgi_param CONTENT_LENGTH $content_length;
5 | uwsgi_param REQUEST_URI $request_uri;
6 | uwsgi_param PATH_INFO $document_uri;
7 | uwsgi_param DOCUMENT_ROOT $document_root;
8 | uwsgi_param SERVER_PROTOCOL $server_protocol;
9 | uwsgi_param REMOTE_ADDR $remote_addr;
10 | uwsgi_param REMOTE_PORT $remote_port;
11 | uwsgi_param SERVER_ADDR $server_addr;
12 | uwsgi_param SERVER_PORT $server_port;
13 | uwsgi_param SERVER_NAME $server_name;
14 |
--------------------------------------------------------------------------------
/requirements.dev.txt:
--------------------------------------------------------------------------------
1 | flake8>=4.0.1,<4.1
2 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Django>=4.1.5,<4.2
2 | djangorestframework>=3.13.1,<3.14
3 | psycopg2>=2.9.3,<2.10
4 | drf-spectacular>=0.22.1,<0.23
5 | Pillow>=9.1.0,<9.2
6 | uwsgi>=2.0.20,<2.1
7 |
--------------------------------------------------------------------------------
/scripts/run.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -e
4 |
5 | python manage.py wait_for_db
6 | python manage.py collectstatic --noinput
7 | python manage.py migrate
8 |
9 | uwsgi --socket :9000 --workers 4 --master --enable-threads --module app.wsgi
10 |
--------------------------------------------------------------------------------