├── .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 |
2 | 3 | Banner image 4 | 5 |
6 | 7 |
8 |

Full-Stack Consulting and Courses.

9 | Website | 10 | Courses | 11 | Tutorials | 12 | Consulting 13 |
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 | --------------------------------------------------------------------------------