├── .dockerignore
├── .env.example
├── .github
└── workflows
│ └── run-tests.yml
├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── assets
├── avatar.png
└── preview.gif
├── compose.sh
├── docker-compose-postgres-ssl.yml
├── docker-compose-postgres.yml
├── docker-compose-sqlite-ssl.yml
├── docker-compose-sqlite.yml
├── fork_recipes
├── __init__.py
├── backend
│ ├── settings.py
│ ├── sql
│ │ └── README.md
│ ├── urls.py
│ └── wsgi.py
├── manage.py
├── recipes
│ ├── __init__.py
│ ├── apps.py
│ ├── middleware
│ │ ├── __init__.py
│ │ └── remote_auth_middleware.py
│ ├── models.py
│ ├── static
│ │ ├── images
│ │ │ ├── android-chrome-192x192.png
│ │ │ ├── android-chrome-512x512.png
│ │ │ ├── apple-touch-icon.png
│ │ │ ├── avatar.png
│ │ │ ├── favicon.ico
│ │ │ ├── logo.png
│ │ │ └── site.webmanifest
│ │ └── src
│ │ │ ├── input.css
│ │ │ └── output.css
│ ├── tailwind.config.js
│ ├── templates
│ │ ├── email
│ │ │ └── reset_password.html
│ │ ├── print
│ │ │ ├── compact_recipe_print.html
│ │ │ └── recipe_print.html
│ │ └── recipes
│ │ │ ├── 404.html
│ │ │ ├── 500.html
│ │ │ ├── base.html
│ │ │ ├── edit_recipe.html
│ │ │ ├── forgot_password.html
│ │ │ ├── forgot_password_send.html
│ │ │ ├── generate_recipe.html
│ │ │ ├── login.html
│ │ │ ├── new_recipe.html
│ │ │ ├── profile.html
│ │ │ ├── recipe_detail.html
│ │ │ ├── recipe_list.html
│ │ │ ├── reset_password.html
│ │ │ ├── saved_recipes.html
│ │ │ └── scrape_recipe.html
│ ├── urls.py
│ ├── utils
│ │ ├── __init__.py
│ │ ├── date_util.py
│ │ ├── email_util.py
│ │ └── general_util.py
│ └── views.py
├── schedule
│ ├── __init__.py
│ ├── apps.py
│ ├── models.py
│ ├── templates
│ │ └── schedule.html
│ ├── urls.py
│ └── views.py
├── settings
│ ├── __init__.py
│ ├── apps.py
│ ├── data
│ │ └── readme.md
│ ├── templates
│ │ ├── __init__.py
│ │ └── settings.html
│ ├── urls.py
│ └── views.py
├── shopping
│ ├── __init__.py
│ ├── apps.py
│ ├── templates
│ │ ├── shopping.html
│ │ └── shopping_list.html
│ ├── urls.py
│ └── views.py
├── uwsgi.ini
└── ws
│ ├── __init__.py
│ └── api_request.py
├── nginx
├── Dockerfile
├── DockerfileSSL
├── forkrecipes-ssl.nginx.conf
├── forkrecipes.nginx.conf
├── nginx.conf
└── ssl
│ └── README.md
├── pytest.ini
├── requirements.txt
├── scripts
└── migration.sh
└── tests
├── __init__.py
├── forkrecipes_tests.py
├── mock_util.py
├── models.py
├── payload.json
└── uploads
├── upload-image.png
└── upload-video.mp4
/.dockerignore:
--------------------------------------------------------------------------------
1 | .env
2 | .venv
3 | .github
4 | fork_recipes/static
5 | fork_recipes/backend/sql/db.sqlite3
6 | fork_recipes/recipes/migrations/*
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # Secrets more info in documentation
2 | DJANGO_SECRET=
3 | X_AUTH_HEADER=
4 |
5 | # URL for the BE API requests must start with protocol http:// or https://
6 | SERVICE_BASE_URL=
7 |
8 | # Smtp settings
9 | EMAIL_HOST=
10 | EMAIL_HOST_USER=
11 | EMAIL_HOST_PASSWORD=
12 | EMAIL_PORT=
13 | EMAIL_USE_TLS=
14 |
15 | # Database deployment type choice one of:
16 | # postgres, postgres-ssl, sqlite, sqlite-ssl
17 | DEPLOYMENT_TYPE=postgres
18 |
19 | # Seed admin user accounts and categories on initial deploy `true` or `false`
20 | SEED_DEFAULT_DATA=false
21 |
22 | # Connection string for postgres and postgres-ssl database setup
23 | DATABASE_URL=postgres://user:password@ip:port/forkrecipes
24 |
25 | # Pagination for the recipe search endpoints
26 | PAGINATION_PAGE_SIZE=
27 |
28 | # Host address for the frontend to access media with protocol eg. https:.. (minimum two) with separated by comma
29 | CORS_ALLOWED_HOSTS=https://localhost,http://localhost
30 |
31 | # Scrape functionality make sure to add '' for the API KEY
32 | OPENAI_API_KEY=
33 | OPENAI_MODEL=gpt-4o-mini
34 | # Voice is one of alloy, ash, ballad, coral, echo, fable, onyx, nova, sage, shimmer
35 | OPEN_AI_TTS_MODEL_VOICE=coral
36 |
--------------------------------------------------------------------------------
/.github/workflows/run-tests.yml:
--------------------------------------------------------------------------------
1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
3 |
4 | name: Python application
5 |
6 | on:
7 | push:
8 | branches: [ "master" ]
9 | pull_request:
10 | branches: [ "master" ]
11 |
12 | permissions:
13 | contents: read
14 |
15 | jobs:
16 | build:
17 |
18 | runs-on: ubuntu-latest
19 |
20 | steps:
21 | - uses: actions/checkout@v4
22 | - name: Set up Python 3.12
23 | uses: actions/setup-python@v3
24 | with:
25 | python-version: "3.12"
26 | - name: Install dependencies
27 | run: |
28 | python -m pip install --upgrade pip
29 | pip install -r requirements.txt
30 | pip install flake8 pytest tox
31 | - name: Lint with flake8
32 | run: |
33 | # stop the build if there are Python syntax errors or undefined names
34 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
35 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
36 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
37 | - name: Test with pytest
38 | id: test_run
39 | env:
40 | X_AUTH_HEADER: ${{ secrets.X_AUTH_HEADER }}
41 | DJANGO_SECRET: ${{ secrets.DJANGO_SECRET }}
42 | SERVICE_BASE_URL: ${{ secrets.SERVICE_BASE_URL }}
43 | EMAIL_HOST: ${{ secrets.EMAIL_HOST }}
44 | EMAIL_HOST_USER: ${{ secrets.EMAIL_HOST_USER }}
45 | EMAIL_HOST_PASSWORD: ${{ secrets.EMAIL_HOST_PASSWORD }}
46 | EMAIL_PORT: ${{ secrets.EMAIL_PORT }}
47 | EMAIL_USE_TLS: ${{ secrets.EMAIL_USE_TLS }}
48 | PAGINATION_PAGE_SIZE: 15
49 | run: |
50 | pytest -v
51 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | *.local
5 |
6 | # Editor directories and files
7 | .idea
8 | .DS_Store
9 |
10 |
11 | # created by virtualenv automatically
12 | .venv/*
13 | .idea/*
14 | .env
15 | media/*
16 | forkapi/sql/db.sqlite3
17 | forkapi/authentication/migrations/*
18 | forkapi/recipe/migrations/*
19 | __pycache__
20 | forkapi/static/*
21 | forkapi/media/*
22 | fullchain.pem
23 | privkey.pem
24 | .tox
25 | db.sqlite3
26 | fork_recipes/recipes/migrations/
27 | fork_recipes/static/
28 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use the official Python image from the Docker Hub
2 | FROM python:3.12-slim
3 |
4 | # Set environment variables
5 | ENV PYTHONDONTWRITEBYTECODE 1
6 | ENV PYTHONUNBUFFERED 1
7 |
8 | # Install build dependencies and curl
9 | RUN apt-get update && apt-get install -y --no-install-recommends \
10 | build-essential \
11 | libssl-dev \
12 | libpcre3-dev \
13 | && rm -rf /var/lib/apt/lists/*
14 |
15 | # Raspberry Pi packages 📦
16 | # RUN apt update && apt install -y \
17 | # libjpeg-dev \
18 | # zlib1g-dev \
19 | # libpng-dev \
20 | # libfreetype6-dev \
21 | # liblcms2-dev \
22 | # libopenjp2-7-dev \
23 | # libtiff5-dev \
24 | # libwebp-dev \
25 | # tcl8.6-dev \
26 | # tk8.6-dev \
27 | # python3-tk \
28 | # libharfbuzz-dev \
29 | # libfribidi-dev
30 |
31 | # Set the working directory
32 | WORKDIR /fork_recipes
33 |
34 | # Copy the project
35 | COPY /fork_recipes /fork_recipes
36 |
37 | COPY ./requirements.txt /fork_recipes/requirements.txt
38 |
39 | # Install dependencies
40 | RUN pip install --upgrade pip
41 | RUN pip install --no-cache-dir -r requirements.txt
42 |
43 |
44 | # Collect static files
45 | RUN python manage.py collectstatic --noinput
46 | RUN python manage.py makemigrations recipes
47 | RUN python manage.py migrate
48 |
49 | # Give permissions around and add www-data to staff
50 | RUN chmod 660 /fork_recipes/backend/sql/db.sqlite3
51 | RUN usermod -g staff www-data
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2025, mikebgrep
4 |
5 | Redistribution and use in source and binary forms, with or without
6 | modification, are permitted provided that the following conditions are met:
7 |
8 | 1. Redistributions of source code must retain the above copyright notice, this
9 | list of conditions and the following disclaimer.
10 |
11 | 2. Redistributions in binary form must reproduce the above copyright notice,
12 | this list of conditions and the following disclaimer in the documentation
13 | and/or other materials provided with the distribution.
14 |
15 | 3. Neither the name of the copyright holder nor the names of its
16 | contributors may be used to endorse or promote products derived from
17 | this software without specific prior written permission.
18 |
19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Fork recipes
2 |
3 |  [](https://opensource.org/license/bsd-3-clause)
4 |
5 |
6 |
7 |
8 | Simple, elegant frontend Python web application that work seamlessly with the [ForkApi](https://www.github.com/mikebgrep/forkapi) to provide management for food recipies collections.
9 |
10 | ## Features
11 | #### Core Stack
12 | - **Django 5.x** · Python-based backend 🐍
13 | - **Dockerized** for effortless deployment ⚓
14 | - **Postgres database** support 🐘
15 | - SSL/NoSSL configurations by default 🔒
16 |
17 | ---
18 |
19 | #### Recipe Management
20 | - Create **Video or Image** based recipes 👨🍳
21 | - Save your **Favorite Recipes** 🌟
22 | - Organize with **Categories** 📂
23 | - **Print** recipes in a clean layouts (compact and extended) 🖨
24 | - **Meal Planning Scheduler** built-in 🗓
25 | - **Backup Snapshot** import/export/apply database backup snapshots
26 | ---
27 |
28 | #### Smart Kitchen Tools
29 | - Add ingredients to a **Shopping List** 🛒
30 | - **Recipe Scraper** from almost any URL *(OpenAI token)* 🤖
31 | - **Generate Recipes by Ingredients** *(OpenAI token)* 🤖
32 | - **Translate Recipes** to various language *(OpenAI token)* 🤖
33 | - **Audio Narration** of recipes *(English only, OpenAI token)* 🤖
34 | - **Emojis** in the recipe description/name/ingredients when scraping them *(OpenAI token)* 🤖
35 |
36 | ## Installation
37 | Installation instructions are included in the ForkAPI documentation you can follow them up here ➡ [follow](https://mikebgrep.github.io/forkapi/latest/clients/)
38 |
39 | ## Application preview (from 1st realese)
40 | 
41 |
42 |
43 | ## License
44 | The application code is with [BSD-3-Clause license](https://opensource.org/license/bsd-3-clause)
45 |
--------------------------------------------------------------------------------
/assets/avatar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikebgrep/fork.recipes/669a53f14cc565fce41e93cb9ee69dc581adf97d/assets/avatar.png
--------------------------------------------------------------------------------
/assets/preview.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikebgrep/fork.recipes/669a53f14cc565fce41e93cb9ee69dc581adf97d/assets/preview.gif
--------------------------------------------------------------------------------
/compose.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 |
4 | ## Usage
5 | ## sudo ./compose.sh 'up' # To start the services
6 | ## sudo ./compose.sh 'down' # To remove the services
7 | ## sudo ./compose.sh 'down --volumes' # To remove the services volumes
8 | ## sudo ./compose.sh 'build' # To build the images
9 | ## sudo ./compose.sh 'build --no-cache' to build without cache
10 |
11 | (
12 | source .env
13 |
14 | command="docker compose"
15 | if [ "$($command version | grep 'version v2')" = "" ]; then
16 | command=docker-compose
17 | fi
18 |
19 | if [ "$DEPLOYMENT_TYPE" == 'postgres' ]; then
20 | $command -f docker-compose-postgres.yml $1
21 | elif [ "$DEPLOYMENT_TYPE" == 'postgres-ssl' ]; then
22 | $command -f docker-compose-postgres-ssl.yml $1
23 | elif [ "$DEPLOYMENT_TYPE" == 'sqlite' ]; then
24 | $command -f docker-compose-sqlite.yaml $1
25 | elif [ "$DEPLOYMENT_TYPE" == 'sqlite-ssl' ]; then
26 | $command -f docker-compose-sqlite-ssl.yaml $1
27 | else
28 | echo "Please set DEPLOYMENT_TYPE env variable to on of 'postgres', 'postgres-ssl', 'sqlite', 'sqlite-ssl'"
29 | fi
30 | )
31 |
32 |
--------------------------------------------------------------------------------
/docker-compose-postgres-ssl.yml:
--------------------------------------------------------------------------------
1 | services:
2 | nginx:
3 | build:
4 | context: .
5 | dockerfile: nginx/DockerfileSSL
6 | image: nginx:fork-recipes
7 | container_name: nginx
8 | ports:
9 | - "80:80"
10 | - "443:443"
11 | env_file:
12 | - .env
13 | command: /bin/sh -c "nginx -g 'daemon off;'"
14 | volumes:
15 | - uwsgi_data:/tmp/uwsgi/
16 | - web_static:/forkapi/static/:ro
17 | - web_media:/forkapi/media/
18 | - web_static_fe:/fork_recipes/static/:ro
19 | depends_on:
20 | - web
21 | - be
22 |
23 |
24 | be:
25 | image: mikebgrep/forkapi:latest # change with mikebgrep/forkapi:arm64 for RaspberryPI
26 | container_name: forkapi
27 | hostname: forkapi-host
28 | env_file:
29 | - .env
30 | restart: always
31 | network_mode: "host"
32 | command: >
33 | sh -c "chmod +x /scripts/migration.sh && /scripts/migration.sh ${SEED_DEFAULT_DATA} & uwsgi --ini uwsgi.ini --chown-socket=www-data:www-data"
34 | volumes:
35 | - ./scripts:/scripts
36 | - uwsgi_data:/tmp/uwsgi/
37 | - web_media:/forkapi/media/
38 | - web_static:/forkapi/static/
39 |
40 |
41 | web:
42 | build:
43 | context: .
44 | dockerfile: Dockerfile
45 | container_name: recipes
46 | image: forkrecipes:latest
47 | env_file:
48 | - .env
49 | restart: always
50 | network_mode: "host"
51 | command: >
52 | sh -c "uwsgi --ini uwsgi.ini --chown-socket=www-data:www-data"
53 | volumes:
54 | - data:/fork_recipes/sql/
55 | - uwsgi_data:/tmp/uwsgi/
56 | - web_static_fe:/fork_recipes/static/
57 | depends_on:
58 | - be
59 |
60 |
61 | volumes:
62 | data:
63 | uwsgi_data:
64 | web_static:
65 | web_media:
66 | web_static_fe:
67 |
--------------------------------------------------------------------------------
/docker-compose-postgres.yml:
--------------------------------------------------------------------------------
1 | services:
2 | nginx:
3 | build:
4 | context: .
5 | dockerfile: nginx/Dockerfile
6 | image: nginx:fork-recipes
7 | container_name: nginx
8 | ports:
9 | - "80:80"
10 | env_file:
11 | - .env
12 | command: /bin/sh -c "nginx -g 'daemon off;'"
13 | volumes:
14 | - uwsgi_data:/tmp/uwsgi/
15 | - web_static:/forkapi/static/:ro
16 | - web_media:/forkapi/media/
17 | - web_static_fe:/fork_recipes/static/:ro
18 | depends_on:
19 | - web
20 | - be
21 |
22 |
23 | be:
24 | image: mikebgrep/forkapi:latest # change with mikebgrep/forkapi:arm64 for RaspberryPI
25 | container_name: forkapi
26 | hostname: forkapi-host
27 | env_file:
28 | - .env
29 | restart: always
30 | network_mode: "host"
31 | command: >
32 | sh -c "chmod +x /scripts/migration.sh && /scripts/migration.sh ${SEED_DEFAULT_DATA} & uwsgi --ini uwsgi.ini --chown-socket=www-data:www-data"
33 | volumes:
34 | - ./scripts:/scripts
35 | - uwsgi_data:/tmp/uwsgi/
36 | - web_media:/forkapi/media/
37 | - web_static:/forkapi/static/
38 |
39 |
40 | web:
41 | build:
42 | context: .
43 | dockerfile: Dockerfile
44 | container_name: recipes
45 | image: forkrecipes:latest
46 | env_file:
47 | - .env
48 | restart: always
49 | network_mode: "host"
50 | command: >
51 | sh -c "uwsgi --ini uwsgi.ini --chown-socket=www-data:www-data"
52 | volumes:
53 | - data:/fork_recipes/sql/
54 | - uwsgi_data:/tmp/uwsgi/
55 | - web_static_fe:/fork_recipes/static/
56 | depends_on:
57 | - be
58 |
59 |
60 | volumes:
61 | data:
62 | uwsgi_data:
63 | web_static:
64 | web_media:
65 | web_static_fe:
66 |
--------------------------------------------------------------------------------
/docker-compose-sqlite-ssl.yml:
--------------------------------------------------------------------------------
1 | services:
2 | nginx:
3 | build:
4 | context: .
5 | dockerfile: nginx/DockerfileSSL
6 | image: nginx:fork-recipes
7 | container_name: nginx
8 | ports:
9 | - "80:80"
10 | - "443:443"
11 | env_file:
12 | - .env
13 | command: /bin/sh -c "nginx -g 'daemon off;'"
14 | volumes:
15 | - uwsgi_data:/tmp/uwsgi/
16 | - web_static:/forkapi/static/:ro
17 | - web_media:/forkapi/media/
18 | - web_static_fe:/fork_recipes/static/:ro
19 | depends_on:
20 | - web
21 | - be
22 |
23 |
24 | be:
25 | image: mikebgrep/forkapi:latest # change with mikebgrep/forkapi:arm64 for RaspberryPI
26 | container_name: forkapi
27 | hostname: forkapi-host
28 | env_file:
29 | - .env
30 | restart: always
31 | network_mode: "host"
32 | command: >
33 | sh -c "chmod +x /scripts/migration.sh && /scripts/migration.sh ${SEED_DEFAULT_DATA} & uwsgi --ini uwsgi.ini --chown-socket=www-data:www-data"
34 | volumes:
35 | - ./scripts:/scripts
36 | - data:/forkapi/sql/
37 | - uwsgi_data:/tmp/uwsgi/
38 | - web_media:/forkapi/media/
39 | - web_static:/forkapi/static/
40 |
41 |
42 | web:
43 | build:
44 | context: .
45 | dockerfile: Dockerfile
46 | container_name: recipes
47 | image: forkrecipes:latest
48 | env_file:
49 | - .env
50 | restart: always
51 | network_mode: "host"
52 | command: >
53 | sh -c "uwsgi --ini uwsgi.ini --chown-socket=www-data:www-data"
54 | volumes:
55 | - data:/fork_recipes/sql/
56 | - uwsgi_data:/tmp/uwsgi/
57 | - web_static_fe:/fork_recipes/static/
58 | depends_on:
59 | - be
60 |
61 |
62 | volumes:
63 | data:
64 | uwsgi_data:
65 | web_static:
66 | web_media:
67 | web_static_fe:
68 |
--------------------------------------------------------------------------------
/docker-compose-sqlite.yml:
--------------------------------------------------------------------------------
1 | services:
2 | nginx:
3 | build:
4 | context: .
5 | dockerfile: nginx/Dockerfile
6 | image: nginx:fork-recipes
7 | container_name: nginx
8 | ports:
9 | - "80:80"
10 | env_file:
11 | - .env
12 | command: /bin/sh -c "nginx -g 'daemon off;'"
13 | volumes:
14 | - uwsgi_data:/tmp/uwsgi/
15 | - web_static:/forkapi/static/:ro
16 | - web_media:/forkapi/media/
17 | - web_static_fe:/fork_recipes/static/:ro
18 | depends_on:
19 | - web
20 | - be
21 |
22 |
23 | be:
24 | image: mikebgrep/forkapi:latest # change with mikebgrep/forkapi:arm64 for RaspberryPI
25 | container_name: forkapi
26 | hostname: forkapi-host
27 | env_file:
28 | - .env
29 | restart: always
30 | network_mode: "host"
31 | command: >
32 | sh -c "chmod +x /scripts/migration.sh && /scripts/migration.sh ${SEED_DEFAULT_DATA} & uwsgi --ini uwsgi.ini --chown-socket=www-data:www-data"
33 | volumes:
34 | - ./scripts:/scripts
35 | - data:/forkapi/sql/
36 | - uwsgi_data:/tmp/uwsgi/
37 | - web_media:/forkapi/media/
38 | - web_static:/forkapi/static/
39 |
40 |
41 | web:
42 | build:
43 | context: .
44 | dockerfile: Dockerfile
45 | container_name: recipes
46 | image: forkrecipes:latest
47 | env_file:
48 | - .env
49 | restart: always
50 | network_mode: "host"
51 | command: >
52 | sh -c "uwsgi --ini uwsgi.ini --chown-socket=www-data:www-data"
53 | volumes:
54 | - data:/fork_recipes/sql/
55 | - uwsgi_data:/tmp/uwsgi/
56 | - web_static_fe:/fork_recipes/static/
57 | depends_on:
58 | - be
59 |
60 |
61 | volumes:
62 | data:
63 | uwsgi_data:
64 | web_static:
65 | web_media:
66 | web_static_fe:
67 |
--------------------------------------------------------------------------------
/fork_recipes/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikebgrep/fork.recipes/669a53f14cc565fce41e93cb9ee69dc581adf97d/fork_recipes/__init__.py
--------------------------------------------------------------------------------
/fork_recipes/backend/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for backend project.
3 | """
4 |
5 | import os
6 | from pathlib import Path
7 | from dotenv import load_dotenv
8 |
9 | load_dotenv()
10 | # Build paths inside the project like this: BASE_DIR / 'subdir'.
11 | BASE_DIR = Path(__file__).resolve().parent
12 |
13 | # SECURITY WARNING: keep the secret key used in production secret!
14 | SECRET_KEY = os.getenv('DJANGO_SECRET')
15 | SERVICE_BASE_URL = os.getenv('SERVICE_BASE_URL')
16 | # SECURITY WARNING: don't run with debug turned on in production!
17 | DEBUG = False
18 | X_FRAME_OPTIONS = 'SAMEORIGIN'
19 | LOGIN_URL = '/login/'
20 | ALLOWED_HOSTS = ['*']
21 |
22 | # Application definition
23 | INSTALLED_APPS = [
24 | 'django.contrib.admin',
25 | 'django.contrib.auth',
26 | 'django.contrib.contenttypes',
27 | 'django.contrib.sessions',
28 | 'django.contrib.messages',
29 | 'django.contrib.staticfiles',
30 | 'recipes.apps.RecipesConfig',
31 | 'schedule.apps.ScheduleConfig',
32 | 'shopping.apps.ShoppingConfig',
33 | 'settings.apps.SettingsConfig',
34 | ]
35 |
36 | MIDDLEWARE = [
37 |
38 | 'django.middleware.security.SecurityMiddleware',
39 | 'django.contrib.sessions.middleware.SessionMiddleware',
40 | 'django.middleware.common.CommonMiddleware',
41 | 'django.middleware.csrf.CsrfViewMiddleware',
42 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
43 | 'django.contrib.messages.middleware.MessageMiddleware',
44 | 'recipes.middleware.remote_auth_middleware.RemoteAuthenticationMiddleware',
45 | 'django.middleware.clickjacking.XFrameOptionsMiddleware',
46 | ]
47 |
48 | # Expiration set to 24 hrs
49 | SESSION_COOKIE_AGE = 60 * 60 * 24
50 |
51 | ROOT_URLCONF = 'backend.urls'
52 |
53 | TEMPLATES = [
54 | {
55 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
56 | 'DIRS': [],
57 | 'APP_DIRS': True,
58 | 'OPTIONS': {
59 | 'context_processors': [
60 | 'django.template.context_processors.debug',
61 | 'django.template.context_processors.request',
62 | 'django.contrib.auth.context_processors.auth',
63 | 'django.contrib.messages.context_processors.messages',
64 | ],
65 | },
66 | },
67 | ]
68 |
69 | WSGI_APPLICATION = 'backend.wsgi.application'
70 |
71 | # Database
72 | DATABASES = {
73 | 'default': {
74 | 'ENGINE': 'django.db.backends.sqlite3',
75 | 'NAME': BASE_DIR / 'sql/db.sqlite3',
76 | }
77 | }
78 |
79 | # Email Backend
80 | EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
81 | EMAIL_HOST = os.getenv('EMAIL_HOST')
82 | EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER')
83 | EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD')
84 | EMAIL_PORT = os.getenv('EMAIL_PORT')
85 | EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS')
86 |
87 |
88 | AUTH_USER_MODEL = 'recipes.User'
89 | AUTH_VERIFY_API_URL = ""
90 | # Internationalization
91 | LANGUAGE_CODE = 'en-us'
92 | TIME_ZONE = 'UTC'
93 | USE_I18N = True
94 | USE_TZ = True
95 |
96 | # Static files (CSS, JavaScript, Images)
97 | STATIC_ROOT = os.path.join(BASE_DIR.parent, 'static')
98 | STATIC_URL = 'static/'
99 |
100 | # Default primary key field type
101 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
--------------------------------------------------------------------------------
/fork_recipes/backend/sql/README.md:
--------------------------------------------------------------------------------
1 | ## Folder to store sqlite database file
2 | ### Created to skip docker permissions
3 |
--------------------------------------------------------------------------------
/fork_recipes/backend/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path, include
2 | from django.conf import settings
3 | from django.conf.urls.static import static
4 | from recipes import views
5 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns
6 |
7 | if settings.DEBUG:
8 | urlpatterns = [
9 | path('', include('recipes.urls', namespace="recipes")),
10 | path('schedule/', include('schedule.urls', namespace="schedule")),
11 | path('shopping/', include('shopping.urls', namespace="shopping")),
12 | path('settings/', include('settings.urls', namespace="settings")),
13 |
14 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + staticfiles_urlpatterns()
15 | else:
16 | urlpatterns = [
17 | path('', include('recipes.urls', namespace="recipes")),
18 | path('schedule/', include('schedule.urls', namespace="schedule")),
19 | path('shopping/', include('shopping.urls', namespace="shopping")),
20 | path('settings/', include('settings.urls', namespace="settings")),
21 | ]
22 |
23 |
24 | handler404 = views.handler404
25 | handler500 = views.handler500
--------------------------------------------------------------------------------
/fork_recipes/backend/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for forkapi 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/5.1/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', 'backend.settings')
15 |
16 | application = get_wsgi_application()
17 |
--------------------------------------------------------------------------------
/fork_recipes/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 | def main():
7 | """Run administrative tasks."""
8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
9 | try:
10 | from django.core.management import execute_from_command_line
11 | except ImportError as exc:
12 | raise ImportError(
13 | "Couldn't import Django. Are you sure it's installed?"
14 | ) from exc
15 | execute_from_command_line(sys.argv)
16 |
17 | if __name__ == '__main__':
18 | main()
--------------------------------------------------------------------------------
/fork_recipes/recipes/__init__.py:
--------------------------------------------------------------------------------
1 | import sys
2 | sys.path.append("..")
--------------------------------------------------------------------------------
/fork_recipes/recipes/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 | class RecipesConfig(AppConfig):
4 | default_auto_field = 'django.db.models.BigAutoField'
5 | name = 'recipes'
--------------------------------------------------------------------------------
/fork_recipes/recipes/middleware/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikebgrep/fork.recipes/669a53f14cc565fce41e93cb9ee69dc581adf97d/fork_recipes/recipes/middleware/__init__.py
--------------------------------------------------------------------------------
/fork_recipes/recipes/middleware/remote_auth_middleware.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth import get_user_model
2 | from django.contrib.auth.models import AnonymousUser
3 | from django.utils.deprecation import MiddlewareMixin
4 |
5 | User = get_user_model()
6 |
7 |
8 | class RemoteAuthenticationMiddleware(MiddlewareMixin):
9 |
10 | """
11 | Class for authentication of the user if the SESSION_COOKIE_AGE value from settings.py
12 | expire the user is logged out.
13 | """
14 | def process_request(self, request):
15 | if request.user.is_authenticated:
16 | return
17 |
18 | token = request.session.get('auth_token')
19 | if not token:
20 | request.user = AnonymousUser()
21 | return
22 |
--------------------------------------------------------------------------------
/fork_recipes/recipes/models.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import AbstractUser
2 | from django.db import models
3 |
4 |
5 | class User(AbstractUser):
6 | email = models.EmailField(max_length=150, unique=True)
7 | token = models.CharField(max_length=40)
8 |
9 | def __str__(self):
10 | return self.username
11 |
12 | DIFFICULTY_CHOICES = [
13 | ('Easy', 'Easy'),
14 | ('Intermediate', 'Intermediate'),
15 | ('Advanced', 'Advanced'),
16 | ('Expert', 'Expert'),
17 | ]
18 |
19 | LANGUAGES_CHOICES = [
20 | ('English', 'English'),
21 | ('Spanish', 'Español'),
22 | ('French', 'Français'),
23 | ('German', 'Deutsch'),
24 | ('Chinese', '中文'),
25 | ('Russian', 'Русский'),
26 | ('Italian', 'Italiano'),
27 | ('Japanese', '日本語'),
28 | ('Dutch', 'Nederlands'),
29 | ('Polish', 'Polski'),
30 | ('Greek', 'Ελληνικά'),
31 | ('Swedish', 'Svenska'),
32 | ('Czech', 'Čeština'),
33 | ('Bulgarian', 'Български'),
34 | ]
35 |
36 |
37 |
--------------------------------------------------------------------------------
/fork_recipes/recipes/static/images/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikebgrep/fork.recipes/669a53f14cc565fce41e93cb9ee69dc581adf97d/fork_recipes/recipes/static/images/android-chrome-192x192.png
--------------------------------------------------------------------------------
/fork_recipes/recipes/static/images/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikebgrep/fork.recipes/669a53f14cc565fce41e93cb9ee69dc581adf97d/fork_recipes/recipes/static/images/android-chrome-512x512.png
--------------------------------------------------------------------------------
/fork_recipes/recipes/static/images/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikebgrep/fork.recipes/669a53f14cc565fce41e93cb9ee69dc581adf97d/fork_recipes/recipes/static/images/apple-touch-icon.png
--------------------------------------------------------------------------------
/fork_recipes/recipes/static/images/avatar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikebgrep/fork.recipes/669a53f14cc565fce41e93cb9ee69dc581adf97d/fork_recipes/recipes/static/images/avatar.png
--------------------------------------------------------------------------------
/fork_recipes/recipes/static/images/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikebgrep/fork.recipes/669a53f14cc565fce41e93cb9ee69dc581adf97d/fork_recipes/recipes/static/images/favicon.ico
--------------------------------------------------------------------------------
/fork_recipes/recipes/static/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikebgrep/fork.recipes/669a53f14cc565fce41e93cb9ee69dc581adf97d/fork_recipes/recipes/static/images/logo.png
--------------------------------------------------------------------------------
/fork_recipes/recipes/static/images/site.webmanifest:
--------------------------------------------------------------------------------
1 | {"name":"","fork.recipes":"","icons":[{"src":"/static/images/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/static/images/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
--------------------------------------------------------------------------------
/fork_recipes/recipes/static/src/input.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/fork_recipes/recipes/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ['./templates/**/*.html', '../schedule/templates/**/*.html', "../shopping/templates/**/*.html", "../settings/templates/**/*.html"],
4 | darkMode: 'class',
5 | theme: {
6 | extend: {
7 | textDecoration: {
8 | 'line-through': 'line-through',
9 | },
10 | animation: {
11 | spin: 'spin 1s linear infinite',
12 | pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
13 | },
14 | keyframes: {
15 | spin: {
16 | '0%': { transform: 'rotate(0deg)' },
17 | '100%': { transform: 'rotate(360deg)' },
18 | },
19 | pulse: {
20 | '0%, 100%': { opacity: '1' },
21 | '50%': { opacity: '.5' },
22 | },
23 | },
24 | fontFamily: {
25 | fancy: ['"Young Serif"', 'sans-serif'],
26 | outfit: ['"Outfit"', 'serif'],
27 | body: [
28 | 'Inter',
29 | 'ui-sans-serif',
30 | 'system-ui',
31 | '-apple-system',
32 | 'system-ui',
33 | 'Segoe UI',
34 | 'Roboto',
35 | 'Helvetica Neue',
36 | 'Arial',
37 | 'Noto Sans',
38 | 'sans-serif',
39 | 'Apple Color Emoji',
40 | 'Segoe UI Emoji',
41 | 'Segoe UI Symbol',
42 | 'Noto Color Emoji'
43 | ],
44 | sans: [
45 | 'Inter',
46 | 'ui-sans-serif',
47 | 'system-ui',
48 | '-apple-system',
49 | 'system-ui',
50 | 'Segoe UI',
51 | 'Roboto',
52 | 'Helvetica Neue',
53 | 'Arial',
54 | 'Noto Sans',
55 | 'sans-serif',
56 | 'Apple Color Emoji',
57 | 'Segoe UI Emoji',
58 | 'Segoe UI Symbol',
59 | 'Noto Color Emoji'
60 | ],
61 | },
62 |
63 | colors: {
64 | 'nutmeg': '#854632',
65 | 'eggshell': '#f3e6d8',
66 | 'wenge-brown': '#5f574e',
67 | 'dark-charcoal': '#302d2c',
68 | mint: {
69 | 50: '#edfbf6',
70 | 100: '#d3f5e8',
71 | 200: '#56cc9d',
72 | 300: '#56cc9d',
73 | 400: '#56cc9d',
74 | 500: '#56cc9d',
75 | 600: '#4db88d',
76 | 700: '#44a47e',
77 | 800: '#3b906e',
78 | 900: '#327c5f',
79 | },
80 | primary: {
81 | 50: '#edfbf6',
82 | 100: '#d3f5e8',
83 | 200: '#56cc9d',
84 | 300: '#56cc9d',
85 | 400: '#56cc9d',
86 | 500: '#56cc9d',
87 | 600: '#4db88d',
88 | 700: '#44a47e',
89 | 800: '#3b906e',
90 | 900: '#327c5f',
91 | },
92 |
93 | }
94 | },
95 | },
96 | safelist: [
97 | {
98 | pattern: /^peer(-.*)?$/,
99 | },
100 | 'peer-checked:after:translate-x-full',
101 | 'peer-checked:bg-mint-600',
102 | 'after:absolute',
103 | 'after:top-[2px]',
104 | 'after:start-[2px]',
105 | 'after:h-5',
106 | 'after:w-5',
107 | 'after:rounded-full',
108 | 'after:border',
109 | 'after:border-gray-300',
110 | 'after:bg-white',
111 | 'after:transition-all',
112 | 'after:content-[\'\']',
113 | 'h-6',
114 | 'w-11',
115 | 'rounded-full',
116 | 'border',
117 | 'border-gray-300',
118 | 'bg-gray-200',
119 | 'transition-colors',
120 | 'focus:outline-none',
121 | 'focus:ring-2',
122 | 'focus:ring-mint-600',
123 | 'focus:ring-offset-2',
124 | ],
125 | plugins: [
126 | require('@tailwindcss/container-queries'),
127 | function({ addComponents }) {
128 | addComponents({
129 | '@media print': {
130 | '.print-img': {
131 | maxWidth: '100%',
132 | height: '400px', // Adjust this value for A4 paper
133 | objectFit: 'cover',
134 | display: 'block',
135 | margin: '0 auto',
136 | },
137 | },
138 | 'media-controller': {
139 | '--media-background-color': 'transparent',
140 | '--media-control-background': 'transparent',
141 | '--media-control-hover-background': 'transparent',
142 | display: 'block',
143 | opacity: '1 !important',
144 | '& button': {
145 | opacity: '1 !important',
146 | }
147 | },
148 | 'media-control-bar': {
149 | width: '100%',
150 | height: '5rem',
151 | '@screen md': {
152 | height: '4rem',
153 | borderRadius: '0.375rem',
154 | borderWidth: '1px',
155 | borderColor: 'rgb(226 232 240)', // slate-200 color
156 | borderStyle: 'solid',
157 | },
158 | padding: '0 1rem',
159 | backgroundColor: 'white',
160 | display: 'flex',
161 | alignItems: 'center',
162 | justifyContent: 'space-between',
163 | boxShadow: '0 1px 3px 0 rgb(0 0 0 / 0.05)',
164 | position: 'relative',
165 | overflow: 'hidden', // Add this to contain the progress bar
166 | },
167 | 'media-time-range': {
168 | '--media-range-track-background': 'transparent',
169 | '--media-time-range-buffered-color': 'rgb(0 0 0 / 0.02)',
170 | '--media-range-bar-color': 'rgb(77 184 141)',
171 | '--media-range-track-height': '0.5rem',
172 | '--media-range-thumb-background': 'rgb(77 184 141)',
173 | '--media-range-thumb-box-shadow': '0 0 0 2px rgb(255 255 255 / 0.9)',
174 | '--media-range-thumb-width': '0.25rem',
175 | '--media-range-thumb-height': '1rem',
176 | '--media-preview-time-text-shadow': 'transparent',
177 | '--media-range-track-border-radius': '0',
178 | display: 'block',
179 | width: '100%',
180 | height: '0.5rem',
181 | minHeight: '0',
182 | padding: '0',
183 | backgroundColor: 'rgb(248 250 252)',
184 | '&.block\\@md\\:hidden': {
185 | position: 'absolute',
186 | top: '0',
187 | left: '0',
188 | right: '0',
189 | margin: '0',
190 | borderRadius: '0',
191 | },
192 | '&.hidden\\@md\\:block': {
193 | flexGrow: '1',
194 | margin: '0 1rem',
195 | borderRadius: '0.375rem',
196 | },
197 | },
198 | 'media-play-button': {
199 | height: '2.5rem',
200 | width: '2.5rem',
201 | margin: '0 0.75rem',
202 | display: 'flex',
203 | alignItems: 'center',
204 | justifyContent: 'center',
205 | borderRadius: '9999px',
206 | backgroundColor: 'rgb(77 184 141)',
207 | color: 'white',
208 | transition: 'background-color 150ms',
209 | '&:hover': {
210 | backgroundColor: 'rgb(68 164 126)',
211 | },
212 | '& button': {
213 | width: '100%',
214 | height: '100%',
215 | display: 'flex',
216 | alignItems: 'center',
217 | justifyContent: 'center',
218 | color: 'white',
219 | }
220 | },
221 | 'media-time-display': {
222 | color: 'rgb(17 24 39)',
223 | fontSize: '0.875rem',
224 | '&.order-last': {
225 | '@screen md': {
226 | order: 'initial',
227 | }
228 | }
229 | },
230 | 'media-duration-display': {
231 | color: 'rgb(17 24 39)',
232 | fontSize: '0.875rem',
233 | '&.hidden': {
234 | '@screen md': {
235 | display: 'block',
236 | }
237 | }
238 | },
239 | 'media-mute-button': {
240 | color: 'rgb(77 184 141)',
241 | transition: 'color 150ms',
242 | '&.order-first': {
243 | '@screen md': {
244 | order: 'initial',
245 | }
246 | },
247 | '&:hover': {
248 | color: 'rgb(68 164 126)',
249 | },
250 | '& button': {
251 | color: 'currentColor',
252 | }
253 | },
254 | });
255 | }
256 | ],
257 | };
258 |
--------------------------------------------------------------------------------
/fork_recipes/recipes/templates/email/reset_password.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
431 |
438 |
439 |
440 |
441 |
442 |
443 |
444 |
445 |
446 |
447 |
448 | Fork Recipes
449 |
450 |
451 |
452 |
453 |
454 |
455 |
456 |
457 |
458 |
459 |
460 |
Hi {{ user_email }},
461 |
You recently requested to reset your password for your Fork Recipes account. Use the button below to reset it. This password reset is only valid for the next 24 hours.
462 |
463 |
464 |
465 |
466 |
467 |
474 |
475 |
476 |
477 |
If you did not request a password reset, please ignore this email or contact support if you have questions.
478 |
Thanks,
479 | The Fork Recipes team
480 |
481 |
482 |
483 |
484 | If you’re having trouble with the button above, copy and paste the URL below into your web browser.
485 | {{action_url}}
486 |
487 |
488 |
489 |
490 |
491 |
492 |
493 |
494 |
495 |
496 |
497 |
508 |
509 |
510 |
511 |
512 |
513 |
514 |
515 |
--------------------------------------------------------------------------------
/fork_recipes/recipes/templates/print/compact_recipe_print.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ recipe.name }}
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
19 |
20 |
21 |
22 |
23 |
{{ recipe.name }}
24 |
25 |
26 |
Total Time: {{ recipe.total_time }} hrs
27 |
Prep Time: {{ recipe.prep_time }} min
28 |
Cook Time: {{ recipe.cook_time }} min
29 |
Servings: {{ recipe.servings }}
30 | {% if recipe.chef %}
31 |
Chef: {{ recipe.chef }}
32 | {% endif %}
33 | {% if recipe.reference %}
34 |
Reference: {{ recipe.reference }}
35 | {% endif %}
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
Ingredients
45 |
46 | {% for ingredient in recipe.ingredients %}
47 |
48 | {{ ingredient.quantity }} {{ ingredient.metric }} {{ ingredient.name }}
49 |
50 | {% endfor %}
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
Instructions
59 |
60 | {% for step in recipe.steps %}
61 | {{ step.text }}
62 | {% endfor %}
63 |
64 |
65 |
66 |
67 |
68 |
69 |
--------------------------------------------------------------------------------
/fork_recipes/recipes/templates/print/recipe_print.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | {{ recipe.name }}
22 |
23 |
24 |
25 |
30 |
31 |
32 |
33 | {{ recipe.description }}
34 |
35 |
36 |
37 |
Chef: {{ recipe.chef }}
38 |
Prep Time: {{ recipe.prep_time }} min
39 |
Cook Time: {{ recipe.cook_time }} min
40 |
Total Time: {{ recipe.total_time }} hrs
41 |
42 |
43 |
44 |
Ingredients
45 |
46 | {% for ingredient in recipe.ingredients %}
47 | {{ ingredient.quantity }} {{ ingredient.metric }} {{ ingredient.name }}
48 | {% endfor %}
49 |
50 |
51 |
52 |
53 |
54 |
55 |
Instructions
56 |
57 | {% for step in recipe.steps %}
58 | {{ step.text }}
59 | {% endfor %}
60 |
61 |
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/fork_recipes/recipes/templates/recipes/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
15 |
Something's
16 | missing.
17 |
Sorry, we can't find that page. You'll
18 | find lots to explore on the home page.
19 |
Back
21 | to Homepage
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/fork_recipes/recipes/templates/recipes/500.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
Internal server error.
15 |
We are already working to solve the problem.
16 |
Back to Homepage
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/fork_recipes/recipes/templates/recipes/forgot_password.html:
--------------------------------------------------------------------------------
1 | {% extends 'recipes/base.html' %}
2 |
3 | {% block title %}Forgot Password - Fork Recipes{% endblock %}
4 |
5 | {% block header %}Reset Password{% endblock %}
6 |
7 | {% block content %}
8 |
9 |
10 |
11 |
16 |
17 |
18 | Enter your email address and we'll send you a link to reset your password.
19 |
20 |
21 |
43 |
44 |
45 | {% if messages %}
46 |
47 | {% for message in messages %}
48 |
51 | {% endfor %}
52 |
53 | {% endif %}
54 | {% endblock %}
--------------------------------------------------------------------------------
/fork_recipes/recipes/templates/recipes/forgot_password_send.html:
--------------------------------------------------------------------------------
1 | {% extends 'recipes/base.html' %}
2 |
3 | {% block title %}Forgot Password - Fork Recipes{% endblock %}
4 |
5 | {% block header %}Reset Password Email Send{% endblock %}
6 |
7 | {% block content %}
8 |
9 |
10 |
11 |
16 |
17 |
18 | Reset password link was send to the provided email.
19 |
20 |
21 |
26 |
27 |
28 | {% endblock %}
--------------------------------------------------------------------------------
/fork_recipes/recipes/templates/recipes/generate_recipe.html:
--------------------------------------------------------------------------------
1 | {% extends 'recipes/base.html' %}
2 |
3 | {% block title %}Get Recipe - Fork Recipes{% endblock %}
4 |
5 | {% block header %}Generate Recipes{% endblock %}
6 |
7 | {% block content %}
8 |
9 | {% if recipes %}
10 |
61 |
62 |
63 |
64 | {% else %}
65 |
66 |
67 |
Doing the magic.Your recipes are on it's way.Please
68 | wait....
69 |
82 |
95 |
108 |
109 |
110 |
111 |
Add your ingredients to generate recipes
112 |
151 |
152 |
153 | {% endif %}
154 |
155 |
156 | {% if messages %}
157 |
158 | {% for message in messages %}
159 |
162 | {% endfor %}
163 |
164 | {% endif %}
165 |
199 |
221 | {% endblock %}
222 |
--------------------------------------------------------------------------------
/fork_recipes/recipes/templates/recipes/login.html:
--------------------------------------------------------------------------------
1 | {% extends 'recipes/base.html' %}
2 |
3 | {% block title %}Login - Fork Recipes{% endblock %}
4 |
5 | {% block header %}Login{% endblock %}
6 |
7 | {% block content %}
8 |
65 | {% endblock %}
--------------------------------------------------------------------------------
/fork_recipes/recipes/templates/recipes/new_recipe.html:
--------------------------------------------------------------------------------
1 | {% extends 'recipes/base.html' %}
2 |
3 | {% block title %}Create Recipe - Fork Recipes{% endblock %}
4 |
5 | {% block header %}Create New Recipe{% endblock %}
6 |
7 | {% block content %}
8 |
182 |
183 |
275 | {% endblock %}
276 |
--------------------------------------------------------------------------------
/fork_recipes/recipes/templates/recipes/profile.html:
--------------------------------------------------------------------------------
1 | {% extends 'recipes/base.html' %}
2 |
3 | {% block title %}Profile - Fork Recipes{% endblock %}
4 | {% block header %}Profile Settings{% endblock %}
5 |
6 | {% block content %}
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
{{ user.username }}
17 |
Member since {{ user.date_joined }}
18 |
19 |
20 |
21 |
22 | {% csrf_token %}
23 |
24 |
25 | Username
26 |
28 |
29 |
30 |
31 |
32 | Email
33 |
35 |
36 |
37 |
38 |
39 |
41 | Save Changes
42 |
43 |
44 |
45 |
46 |
47 |
48 |
79 |
80 |
81 |
82 |
Delete Account
83 |
Once you delete your account, there is no going back. Please be
84 | certain.
85 |
87 | Delete Account
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
Confirm Account Deletion
96 |
Are you sure you want to delete your account? This action cannot be
97 | undone.
98 |
99 | {% csrf_token %}
100 |
102 | Yes, Delete Account
103 |
104 |
106 | Cancel
107 |
108 |
109 |
110 |
111 |
112 | {% if messages %}
113 |
114 | {% for message in messages %}
115 |
118 | {% endfor %}
119 |
120 | {% endif %}
121 | {% endblock %}
122 |
--------------------------------------------------------------------------------
/fork_recipes/recipes/templates/recipes/recipe_list.html:
--------------------------------------------------------------------------------
1 | {% extends 'recipes/base.html' %}
2 |
3 | {% block title %}Discover Recipes - Fork Recipes{% endblock %}
4 |
5 | {% block header %}Discover Recipes{% endblock %}
6 |
7 | {% block content %}
8 |
9 |
10 |
11 |
12 |
14 |
15 |
16 |
17 |
24 |
25 |
26 |
27 |
28 |
44 |
45 |
46 |
115 |
116 |
117 |
118 | {% if page_obj.has_previous %}
119 |
First
121 |
Previous
123 | {% endif %}
124 |
125 |
126 | Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
127 |
128 |
129 | {% if page_obj.has_next %}
130 |
Next
132 |
Last
134 | {% endif %}
135 |
136 |
137 | {% if messages %}
138 |
139 | {% for message in messages %}
140 |
143 | {% endfor %}
144 |
145 | {% endif %}
146 | {% endblock %}
147 |
--------------------------------------------------------------------------------
/fork_recipes/recipes/templates/recipes/reset_password.html:
--------------------------------------------------------------------------------
1 | {% extends 'recipes/base.html' %}
2 |
3 | {% block title %}Reset Password - Fork Recipes{% endblock %}
4 |
5 | {% block header %}Reset Password{% endblock %}
6 |
7 | {% block content %}
8 |
9 |
10 |
19 |
20 | {% csrf_token %}
21 |
33 | {% if message %}
34 | {{ message }}
35 | {% endif %}
36 |
37 |
38 |
40 | Confirm
41 |
42 |
43 |
44 |
45 |
46 | {% if messages %}
47 |
48 | {% for message in messages %}
49 |
52 | {% endfor %}
53 |
54 | {% endif %}
55 | {% endblock %}
--------------------------------------------------------------------------------
/fork_recipes/recipes/templates/recipes/saved_recipes.html:
--------------------------------------------------------------------------------
1 | {% extends 'recipes/base.html' %}
2 |
3 | {% block title %}Saved Recipes - Fork Recipes{% endblock %}
4 |
5 | {% block header %}Saved Recipes{% endblock %}
6 |
7 | {% block content %}
8 |
70 |
71 |
72 |
73 | {% if page_obj.has_previous %}
74 |
First
75 |
Previous
76 | {% endif %}
77 |
78 |
79 | Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
80 |
81 |
82 | {% if page_obj.has_next %}
83 |
Next
84 |
Last
85 | {% endif %}
86 |
87 | {% endblock %}
88 |
--------------------------------------------------------------------------------
/fork_recipes/recipes/templates/recipes/scrape_recipe.html:
--------------------------------------------------------------------------------
1 | {% extends 'recipes/base.html' %}
2 |
3 | {% block title %}Get Recipe - Fork Recipes{% endblock %}
4 |
5 | {% block header %}Get a Recipe{% endblock %}
6 |
7 | {% block content %}
8 |
9 |
Enter a URL to Get the Recipe
10 |
11 |
12 | {% csrf_token %}
13 |
14 | Recipe URL
15 |
22 |
23 |
24 |
28 | Get the Recipe
29 |
30 |
35 |
36 |
37 |
38 |
39 | Downloading..
40 |
41 |
42 | Back to Home
43 |
44 |
45 |
46 |
47 |
48 |
49 | {% if messages %}
50 |
51 | {% for message in messages %}
52 |
55 | {% endfor %}
56 |
57 | {% endif %}
58 |
74 | {% endblock %}
75 |
--------------------------------------------------------------------------------
/fork_recipes/recipes/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path
2 | from . import views
3 |
4 | app_name = 'recipes'
5 |
6 | urlpatterns = [
7 | path('', views.recipe_list, name='recipe_list'),
8 | path('random/', views.random_recipe, name='random_recipe'),
9 | path('recipe//', views.recipe_detail, name='recipe_detail'),
10 | path('login/', views.login_view, name='login'),
11 | path('logout/', views.log_out_view, name='logout'),
12 | path('forgot-password/', views.forgot_password, name='forgot_password'),
13 | path('forgot-password/reset', views.change_password_after_reset, name='change_password_after_reset'),
14 |
15 | path('saved-recipes/', views.saved_recipes, name='saved_recipes'),
16 | path('profile/', views.profile_view, name='profile'),
17 | path('toggle-favorite//', views.toggle_favorite, name='toggle_favorite'),
18 | path('new-recipe/', views.new_recipe, name='new_recipe'),
19 | path('recipe//edit/', views.edit_recipe, name='edit_recipe'),
20 | path('scrape-recipe/', views.scrape_recipe, name='scrape_recipe'),
21 | path('generate-recipes/', views.generate_recipe, name="generate_recipes"),
22 | path('recipe//translate/', views.translate_recipe_view, name="translate_recipe"),
23 | path('recipe//audio/', views.generate_audio_for_recipe, name="audio_recipe"),
24 | path('recipe//delete', views.delete_recipe, name="delete_recipe"),
25 | path('settings/change-password/', views.change_password, name='change_password'),
26 | path('settings/delete-account/', views.delete_account, name='delete_account'),
27 | path("recipe//print/", views.print_recipe, name="print_recipe")
28 | ]
29 |
--------------------------------------------------------------------------------
/fork_recipes/recipes/utils/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikebgrep/fork.recipes/669a53f14cc565fce41e93cb9ee69dc581adf97d/fork_recipes/recipes/utils/__init__.py
--------------------------------------------------------------------------------
/fork_recipes/recipes/utils/date_util.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 |
4 | def format_date_joined(date_joined):
5 | date_obj = datetime.datetime.strptime(date_joined, "%Y-%m-%dT%H:%M:%S.%fZ")
6 | formatted_date = date_obj.strftime("%B %Y")
7 |
8 | return formatted_date
--------------------------------------------------------------------------------
/fork_recipes/recipes/utils/email_util.py:
--------------------------------------------------------------------------------
1 | from django.core.mail import EmailMultiAlternatives
2 | from django.template.loader import render_to_string
3 |
4 | from fork_recipes.backend import settings
5 |
6 |
7 | def send_reset_password_link(host, user_email, reset_password_token):
8 | context = {
9 | 'user_email': user_email,
10 | 'base_url': settings.SERVICE_BASE_URL,
11 | 'support_url': "support@forkrecipes.com",
12 | 'action_url': "{}reset?token={}".format(
13 | host,
14 | reset_password_token)
15 | }
16 |
17 | # render email text
18 | email_html_message = render_to_string('email/reset_password.html', context)
19 |
20 | msg = EmailMultiAlternatives(
21 | # title:
22 | "Reset password request for Fork Recipes account.",
23 | # message:
24 | "Password reset token",
25 | # from
26 | "noreplay@forkrecipes.com",
27 | # to:
28 | [user_email],
29 | )
30 | msg.attach_alternative(email_html_message, "text/html")
31 | msg.send()
32 |
33 |
34 | def check_smtp_configuration():
35 | required_settings = [
36 | "EMAIL_HOST",
37 | "EMAIL_PORT",
38 | "EMAIL_USE_TLS",
39 | "EMAIL_HOST_USER",
40 | "EMAIL_HOST_PASSWORD",
41 | ]
42 |
43 | missing_settings = [
44 | setting for setting in required_settings if not getattr(settings, setting, None)
45 | ]
46 |
47 | if missing_settings:
48 | return False, f"Missing SMTP configuration settings: {', '.join(missing_settings)} in environment variables!"
49 |
50 | return True, "SMTP is properly configured."
51 |
--------------------------------------------------------------------------------
/fork_recipes/recipes/utils/general_util.py:
--------------------------------------------------------------------------------
1 | from string import punctuation, ascii_lowercase, whitespace
2 | from unicodedata import category
3 |
4 | def is_recipe_english(text):
5 | """
6 | Function that checks if text contains give percentage of an english alphabet chars (ascii)
7 | """
8 | symbols_cleaned_text = ''.join(c for c in text if category(c) not in ['P', 'S', 'C'])
9 | only_chars = [
10 | x for x in symbols_cleaned_text
11 | if x not in punctuation
12 | and x not in whitespace
13 | and not x.isdigit()
14 | ]
15 |
16 | if len(only_chars) == 0:
17 | return False
18 |
19 | english_chars = sum(1 for x in only_chars if x.lower() in ascii_lowercase)
20 | english_percentage = english_chars / len(only_chars) * 100
21 |
22 | #TODO: Adjust if needed this is fault tolerant for a couple of lines in non english alphabet (ascii)
23 | return english_percentage > 97
--------------------------------------------------------------------------------
/fork_recipes/schedule/__init__.py:
--------------------------------------------------------------------------------
1 | import sys
2 | sys.path.append("..")
--------------------------------------------------------------------------------
/fork_recipes/schedule/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 | class ScheduleConfig(AppConfig):
4 | default_auto_field = 'django.db.models.BigAutoField'
5 | name = 'schedule'
--------------------------------------------------------------------------------
/fork_recipes/schedule/models.py:
--------------------------------------------------------------------------------
1 |
2 | TIMING_CHOICES = [
3 | ('Breakfast', 'Breakfast'),
4 | ('Lunch', 'Lunch'),
5 | ('Dinner', 'Dinner'),
6 | ('Side', 'Side'),
7 | ]
8 |
9 |
--------------------------------------------------------------------------------
/fork_recipes/schedule/templates/schedule.html:
--------------------------------------------------------------------------------
1 | {% extends 'recipes/base.html' %}
2 |
3 | {% block title %}Meal Planner - Fork Recipes{% endblock %}
4 |
5 | {% block header %}Meal Planner{% endblock %}
6 |
7 | {% block content %}
8 |
9 |
Select date
10 |
11 |
12 |
13 | {% csrf_token %}
14 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
27 |
28 |
29 |
30 | {% if is_plan_empty %}
31 |
32 |
404
33 |
No plan
34 | for this date
35 |
36 | {% else %}
37 |
38 |
39 | {% if breakfast %}
40 |
41 |
42 | Breakfast
43 |
44 |
45 |
46 |
48 |
49 |
50 |
{{ breakfast.recipe.name }}
51 |
52 |
53 |
54 |
55 |
57 | Prepare Recipe
58 |
59 |
60 |
61 | {% else %}
62 | {% endif %}
63 | {% if lunch %}
64 |
65 |
66 | Lunch
67 |
68 |
69 |
70 |
72 |
73 |
74 |
{{ lunch.recipe.name }}
75 |
76 |
77 |
78 |
79 |
81 | Prepare Recipe
82 |
83 |
84 |
85 |
86 | {% else %}
87 | {% endif %}
88 | {% if dinner %}
89 |
90 |
91 | Dinner
92 |
93 |
94 |
95 |
97 |
98 |
99 |
{{ dinner.recipe.name }}
100 |
101 |
102 |
103 |
104 |
106 | Prepare Recipe
107 |
108 |
109 |
110 | {% else %}
111 | {% endif %}
112 | {% if side %}
113 |
114 |
115 | Side
116 |
117 |
118 |
119 |
121 |
122 |
123 |
{{ side.recipe.name }}
124 |
125 |
126 |
127 |
128 |
130 | Prepare Recipe
131 |
132 |
133 |
134 |
135 | {% else %}
136 | {% endif %}
137 |
138 |
139 | {% endif %}
140 |
141 |
142 |
143 |
144 | {% csrf_token %}
145 |
146 |
147 |
148 |
149 | {% if messages %}
150 |
151 | {% for message in messages %}
152 |
155 | {% endfor %}
156 |
157 | {% endif %}
158 |
168 |
179 | {% endblock %}
180 |
--------------------------------------------------------------------------------
/fork_recipes/schedule/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path
2 | from . import views
3 |
4 | app_name = 'schedule'
5 |
6 |
7 |
8 | urlpatterns = [
9 | path('', views.schedule_list, name='schedule_list'),
10 | path('create/', views.create_schedule_meal_type, name='create_schedule'),
11 |
12 | ]
--------------------------------------------------------------------------------
/fork_recipes/schedule/views.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from django.contrib import messages
4 | from django.contrib.auth.decorators import login_required
5 | from django.shortcuts import render, redirect
6 |
7 | from fork_recipes.ws import api_request
8 |
9 |
10 | @login_required
11 | def schedule_list(request):
12 | date = str(datetime.datetime.now().date())
13 |
14 | if request.POST:
15 | date = request.POST.get('selected_date')
16 | if not date:
17 | date = str(datetime.datetime.now().date())
18 |
19 | response = api_request.request_get_schedule_of_a_day(date)
20 |
21 | breakfast = [x for x in response if x.timing == 'Breakfast']
22 | lunch = [x for x in response if x.timing == 'Lunch']
23 | dinner = [x for x in response if x.timing == 'Dinner']
24 | side = [x for x in response if x.timing == 'Side']
25 |
26 | breakfast = breakfast[0] if len(breakfast) > 0 else None
27 | lunch = lunch[0] if len(lunch) > 0 else None
28 | dinner = dinner[0] if len(dinner) > 0 else None
29 | side = side[0] if len(side) > 0 else None
30 |
31 | is_plan_empty = (breakfast, lunch, dinner, side) == (None, None, None, None)
32 |
33 | return render(request, 'schedule.html',
34 | context={"breakfast": breakfast, "lunch": lunch, "dinner": dinner, "side": side, "current_selected_date": date, "is_plan_empty": is_plan_empty})
35 |
36 |
37 | @login_required
38 | def create_schedule_meal_type(request):
39 | if request.POST:
40 | meal_type = request.POST.get('meal_type')
41 | date = request.POST.get('date')
42 | recipe_id = request.POST.get('recipe_id')
43 | token = request.session.get("auth_token")
44 |
45 | response = api_request.request_post_schedule(date, recipe_id, meal_type, token)
46 | if response:
47 | messages.success(request, f'Recipe was added to schedule for date {date}')
48 | else:
49 | messages.error(request, f'There was an error processing the request.Please try again.')
50 |
51 | return redirect("recipes:recipe_detail", recipe_pk=recipe_id)
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/fork_recipes/settings/__init__.py:
--------------------------------------------------------------------------------
1 | import sys
2 | sys.path.append("..")
--------------------------------------------------------------------------------
/fork_recipes/settings/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 | class SettingsConfig(AppConfig):
4 | default_auto_field = 'django.db.models.BigAutoField'
5 | name = 'settings'
--------------------------------------------------------------------------------
/fork_recipes/settings/data/readme.md:
--------------------------------------------------------------------------------
1 | # Folder to downlaod backups
--------------------------------------------------------------------------------
/fork_recipes/settings/templates/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikebgrep/fork.recipes/669a53f14cc565fce41e93cb9ee69dc581adf97d/fork_recipes/settings/templates/__init__.py
--------------------------------------------------------------------------------
/fork_recipes/settings/templates/settings.html:
--------------------------------------------------------------------------------
1 | {% extends 'recipes/base.html' %}
2 |
3 | {% block title %}Settings - Fork Recipes{% endblock %}
4 |
5 | {% block header %}Settings{% endblock %}
6 | {% block content %}
7 |
8 |
9 |
10 |
11 |
12 |
13 |
Change Translation Language
14 |
15 |
16 | {% csrf_token %}
17 |
18 |
Select language
19 |
20 |
22 | {% if selected_language %}
23 | {{ selected_language }}
24 | {% endif %}
25 | {% for option in languages %}
26 | {{ option }}
27 | {% endfor %}
28 |
29 |
30 |
31 |
32 |
34 | Save Language
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
Additional Options
44 |
45 |
46 |
47 |
48 |
Emoji
49 |
Enable emoji in ingredients on scraped recipes.
50 |
51 |
{% if user_settings.emoji_recipes %} Enabled {% else %} Disabled {% endif %}
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
PDF
63 |
Enable compact PDF mode for recipe exports.
64 |
65 |
{% if user_settings.compact_pdf %} Enabled {% else %} Disabled {% endif %}
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
Manage Backups
81 |
82 |
83 |
84 |
85 |
86 |
87 |
105 |
106 |
107 |
108 |
127 |
128 |
129 |
130 |
131 |
132 | Available Backups
133 |
136 | {% for backup in backups %}
137 | {{ backup.file }}
138 | {% endfor %}
139 |
140 |
141 |
142 |
143 |
144 |
146 | Delete Backup
147 |
148 |
150 | Apply Backup
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
Confirm Backup Deletion
164 |
Are you sure you want to delete this backup file? This action cannot be
165 | undone.
166 |
168 | Delete Backup
169 |
170 |
172 | Cancel
173 |
174 |
175 |
176 |
177 |
178 |
179 |
Confirm Apply Backup
180 |
Are you sure you want to apply this backup file? This action cannot be
181 | undone and all data before the backup will be deleted.
182 |
184 | Apply Backup
185 |
186 |
188 | Cancel
189 |
190 |
191 |
192 |
193 |
194 | {% if messages %}
195 |
196 | {% for message in messages %}
197 |
200 | {% endfor %}
201 |
202 | {% endif %}
203 |
212 |
224 |
236 |
251 |
308 |
309 | {% endblock %}
--------------------------------------------------------------------------------
/fork_recipes/settings/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path
2 | from . import views
3 |
4 | app_name = 'settings'
5 |
6 | urlpatterns = [
7 | path('', views.settings_view, name='settings_page'),
8 | path('create', views.create_backup_view, name="create_backup"),
9 | path('/delete', views.delete_backup_view, name="delete_backup"),
10 | path('/apply', views.apply_backup_view, name="apply_backup"),
11 | path('import', views.import_backup_file_view, name="import_backup"),
12 | path('/export', views.export_backup_file_view, name="export_backup"),
13 | path('change-translation-language/', views.change_translation_language, name="change_translation_language"),
14 | path('change-pdf-option/', views.enable_compact_pdf, name="compact_pdf"),
15 | path('emoji-recipes-option/', views.enable_emoji_recipes, name="emoji_recipes")
16 |
17 | ]
--------------------------------------------------------------------------------
/fork_recipes/settings/views.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 |
4 | from django.contrib.auth.decorators import login_required
5 | from django.http import FileResponse, JsonResponse
6 | from django.shortcuts import render, redirect
7 | from django.contrib import messages
8 | from django.contrib.auth import logout
9 | from fork_recipes.ws import api_request
10 | from recipes.models import LANGUAGES_CHOICES
11 |
12 |
13 | @login_required
14 | def settings_view(request):
15 | token = request.session.get("auth_token")
16 | user_settings = api_request.request_get_user_settings(token=token)
17 | languages = [choice[0] for choice in LANGUAGES_CHOICES if
18 | choice[0] != user_settings.preferred_translate_language]
19 |
20 | backups = api_request.request_get_backups(token)
21 |
22 | processed_backups = []
23 | if backups:
24 | processed_backups = [{"file": backup.file.split('/')[-1], "pk": backup.pk} for backup in backups]
25 |
26 | context = {
27 | 'languages': languages,
28 | 'selected_language': user_settings.preferred_translate_language,
29 | 'backups': processed_backups,
30 | 'user_settings': user_settings,
31 | }
32 |
33 | return render(request, 'settings.html', context=context)
34 |
35 |
36 | @login_required
37 | def change_translation_language(request):
38 | if request.method == 'POST':
39 | language_choice = request.POST.get("language_choice")
40 | token = request.session.get("auth_token")
41 | response = api_request.request_change_user_settings(token, language_choice)
42 | if response:
43 | messages.success(request, 'Your translation language was successfully updated!')
44 | return redirect('settings:settings_page')
45 |
46 |
47 | @login_required
48 | def create_backup_view(request):
49 | token = request.session.get("auth_token")
50 |
51 | is_created = api_request.request_create_backup(token)
52 | if is_created:
53 | messages.success(request, f'Backup was successfully created.')
54 | else:
55 | messages.error(request, f'There was an error processing the backup request.Please try again.')
56 |
57 | return redirect("settings:settings_page")
58 |
59 |
60 | @login_required
61 | def delete_backup_view(request, backup_pk):
62 | token = request.session.get("auth_token")
63 | is_deleted = api_request.reqeust_delete_backup(backup_pk, token)
64 | if is_deleted:
65 | messages.success(request, f'Backup was successfully deleted.')
66 | else:
67 | messages.error(request, f'There was an error processing the backup request.Please try again.')
68 |
69 | return redirect("settings:settings_page")
70 |
71 |
72 | @login_required
73 | def apply_backup_view(request, backup_pk):
74 | token = request.session.get("auth_token")
75 |
76 | is_applied = api_request.request_apply_backup(backup_pk, token)
77 | if is_applied:
78 | messages.success(request, f'Backup was successfully applied.')
79 | user = request.user
80 | logout(request)
81 | user.delete()
82 | else:
83 | messages.error(request, f'There was an error processing the backup request.Please try again.')
84 | logout(request)
85 | return redirect("recipes:login")
86 |
87 |
88 | @login_required
89 | def import_backup_file_view(request):
90 | token = request.session.get("auth_token")
91 |
92 | if request.method == "POST":
93 | backup_file = request.FILES.get('backup_file')
94 | backup_file = [("file", backup_file)]
95 | is_uploaded = api_request.reqeust_import_backup(backup_file, token)
96 |
97 | if is_uploaded:
98 | messages.success(request, f'Backup was successfully imported.')
99 | else:
100 | messages.error(request, f'There was an error processing the backup request.Please try again.')
101 |
102 |
103 | return redirect("settings:settings_page")
104 |
105 | @login_required
106 | def export_backup_file_view(request, backup_pk):
107 | token = request.session.get("auth_token")
108 | backup = api_request.request_get_backup(backup_pk, token)
109 |
110 | if backup.file:
111 | file_path = f"settings/data/{backup.file.split('/')[-1]}"
112 | import urllib.request
113 | urllib.request.urlretrieve(backup.file, file_path)
114 | try:
115 | return FileResponse(open(file_path, 'rb'), as_attachment=True)
116 | finally:
117 | os.remove(file_path)
118 |
119 | messages.error(request, f'There was an error processing the backup download request.Please try again.')
120 | return redirect("settings:settings_page")
121 |
122 |
123 | @login_required
124 | def enable_emojy_in_ingredients_on_scrape(request):
125 | pass
126 |
127 |
128 | @login_required
129 | def enable_compact_pdf(request):
130 | if request.method == "POST":
131 | token = request.session.get("auth_token")
132 | data = json.loads(request.body)
133 | enabled = data.get('enabled')
134 | is_success = api_request.request_change_user_settings(token=token, compact_pdf=enabled)
135 |
136 | return JsonResponse({'status': 'success' if is_success else "failure"})
137 |
138 |
139 | @login_required
140 | def enable_emoji_recipes(request):
141 | if request.method == "POST":
142 | token = request.session.get("auth_token")
143 | data = json.loads(request.body)
144 | enabled = data.get('enabled')
145 |
146 | is_success = api_request.request_change_user_settings(token=token, emoji_recipes=enabled)
147 | return JsonResponse({'status': 'success' if is_success else "failure"})
148 |
--------------------------------------------------------------------------------
/fork_recipes/shopping/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikebgrep/fork.recipes/669a53f14cc565fce41e93cb9ee69dc581adf97d/fork_recipes/shopping/__init__.py
--------------------------------------------------------------------------------
/fork_recipes/shopping/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 | class ShoppingConfig(AppConfig):
4 | default_auto_field = 'django.db.models.BigAutoField'
5 | name = 'shopping'
--------------------------------------------------------------------------------
/fork_recipes/shopping/templates/shopping.html:
--------------------------------------------------------------------------------
1 | {% extends 'recipes/base.html' %}
2 |
3 | {% block title %} Shopping Lists - Fork Recipes{% endblock %}
4 |
5 | {% block header %}Shopping Lists{% endblock %}
6 |
7 | {% block content %}
8 |
9 |
10 |
11 |
12 | {% if shopping_lists|length == 0 %}
13 |
14 |
404
15 |
16 | No shopping lists are found
17 |
18 |
19 | {% endif %}
20 | {% for shopping in shopping_lists %}
21 |
22 |
23 |
24 |
25 | Shopping List
26 |
27 |
28 |
29 |
30 |
31 |
39 |
40 |
41 |
42 |
{{ shopping.name }}
43 |
44 |
45 |
46 |
47 |
57 |
58 | {% endfor %}
59 |
60 |
61 |
62 |
63 |
65 | Add New List
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
Confirm Shopping List Deletion
74 |
75 | Are you sure you want to delete this shopping list? This action cannot be undone.
76 |
77 |
78 | {% csrf_token %}
79 |
81 | Yes, Delete Recipe
82 |
83 |
85 | Cancel
86 |
87 |
88 |
89 |
90 |
92 |
93 |
94 | {% csrf_token %}
95 |
96 |
97 |
104 |
105 |
Create New Shopping
106 | List
107 |
Enter a name for your new shopping list.
108 |
109 |
110 |
111 |
112 | List Name
113 |
116 |
117 |
118 |
119 |
120 |
122 | Create List
123 |
124 |
126 | Cancel
127 |
128 |
129 |
130 |
131 |
132 |
133 | {% if messages %}
134 |
135 | {% for message in messages %}
136 |
139 | {% endfor %}
140 |
141 | {% endif %}
142 |
154 |
163 | {% endblock %}
--------------------------------------------------------------------------------
/fork_recipes/shopping/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path
2 | from . import views
3 |
4 | app_name = 'shopping'
5 |
6 |
7 | urlpatterns = [
8 | path('', views.shopping_list, name="shopping_list"),
9 | path('delete//', views.delete_list, name="delete_list"),
10 | path('create/', views.create_list, name="create_list"),
11 | path('/', views.get_shopping_list, name="single_shopping_list"),
12 | path('item//', views.update_shopping_item, name="update_item"),
13 | path('/add-ingredient', views.add_shopping_list_item, name="add_item"),
14 | path('/delete//', views.delete_shopping_list_item, name="remove_item"),
15 | path('complete//', views.complete_single_shopping_list_item, name="complete_item"),
16 | path('/complete/', views.complete_shopping_list, name="complete_list"),
17 | path('add//', views.add_shopping_list_recipe_items, name="add_recipe_to_list")
18 | ]
--------------------------------------------------------------------------------
/fork_recipes/shopping/views.py:
--------------------------------------------------------------------------------
1 | from django.contrib import messages
2 | from django.contrib.auth.decorators import login_required
3 | from django.http import JsonResponse
4 | from django.shortcuts import render, redirect
5 |
6 | from fork_recipes.ws import api_request
7 |
8 |
9 | @login_required
10 | def shopping_list(request):
11 | token = request.session.get("auth_token")
12 | shopping_lists = api_request.request_get_shopping_lists(token)
13 |
14 | return render(request, "shopping.html", context={"shopping_lists": shopping_lists})
15 |
16 |
17 | @login_required
18 | def get_shopping_list(request, list_pk):
19 | token = request.session.get("auth_token")
20 | single_shopping_list = api_request.request_get_shopping_list(list_pk, token)
21 | print(single_shopping_list)
22 | recipes = None
23 | try:
24 | recipes = [api_request.get_recipe_by_pk(x) for x in single_shopping_list.recipes]
25 | except AttributeError as ex:
26 | print(ex)
27 |
28 | return render(request, "shopping_list.html", context={"shopping_list": single_shopping_list, "recipes": recipes})
29 |
30 |
31 | @login_required
32 | def delete_list(request, list_pk):
33 | token = request.session.get("auth_token")
34 | response = api_request.request_delete_shopping_list(list_pk, token)
35 | if response:
36 | return redirect("shopping:shopping_list")
37 |
38 | messages.error(request, "There an error.Please try again later or contact support")
39 | return redirect("shopping:shopping_list")
40 |
41 |
42 | @login_required
43 | def create_list(request):
44 | if request.method == "POST":
45 | name = request.POST.get('name')
46 | token = request.session.get("auth_token")
47 | created_shopping_list = api_request.request_create_shopping_list(name, token)
48 |
49 | if created_shopping_list:
50 | return redirect("shopping:shopping_list")
51 |
52 | messages.error(request, "There an error.Please try again later or contact support")
53 |
54 | return redirect("shopping:shopping_list")
55 |
56 |
57 | @login_required
58 | def update_shopping_item(request, item_pk):
59 | if request.method == "POST":
60 | name = request.POST.get('name')
61 | quantity = request.POST.get('quantity')
62 | metric = request.POST.get('metric')
63 | times = request.POST.get('times')
64 | list_pk = request.POST.get('list_pk')
65 | token = request.session.get("auth_token")
66 |
67 | data = {
68 | "name": name,
69 | "quantity": quantity,
70 | "metric": metric,
71 | "times": times if times else 1
72 | }
73 |
74 | item = api_request.request_update_shopping_list_item(item_pk, data, token)
75 | if item:
76 | return redirect("shopping:single_shopping_list", list_pk=list_pk)
77 |
78 | messages.error(request, "There an error.Please try again later or contact support")
79 | return redirect("shopping:single_shopping_list", list_pk=list_pk)
80 |
81 |
82 | @login_required
83 | def add_shopping_list_item(request, list_pk):
84 | if request.method == "POST":
85 | name = request.POST.get('name')
86 | quantity = request.POST.get('quantity')
87 | metric = request.POST.get('metric')
88 | token = request.session.get("auth_token")
89 |
90 | data = {
91 | "name": name,
92 | "quantity": quantity,
93 | "metric": metric,
94 | }
95 | ingredient = api_request.request_add_ingredient_to_shopping_list(list_pk, data, token)
96 |
97 | if ingredient:
98 | return redirect("shopping:single_shopping_list", list_pk=list_pk)
99 |
100 | messages.error(request, "There an error.Please try again later or contact support")
101 | return redirect("shopping:single_shopping_list", list_pk=list_pk)
102 |
103 | @login_required
104 | def add_shopping_list_recipe_items(request, recipe_pk):
105 | if request.method == "POST":
106 | list_pk = request.POST.get('list_pk')
107 | token = request.session.get("auth_token")
108 | list_from_recipe = api_request.request_add_recipe_to_shopping_list(list_pk, recipe_pk, token)
109 | if list_from_recipe:
110 | messages.success(request, message=f"Recipe ingredients added to list {list_from_recipe.name}")
111 | else:
112 | messages.error(request, message="Recipe ingredients are not added to list. Please try again!")
113 |
114 | return redirect('recipes:recipe_detail', recipe_pk=recipe_pk)
115 |
116 |
117 | @login_required
118 | def delete_shopping_list_item(request, list_pk, item_pk):
119 | token = request.session.get("auth_token")
120 | response = api_request.request_delete_ingredient_from_shopping_list(item_pk, token)
121 | if response:
122 | return redirect("shopping:single_shopping_list", list_pk=list_pk)
123 |
124 | messages.error(request, "There an error.Please try again later or contact support")
125 | return redirect("shopping:single_shopping_list", list_pk=list_pk)
126 |
127 | @login_required
128 | def complete_shopping_list(request, list_pk):
129 | token = request.session.get("auth_token")
130 | is_completed = api_request.request_complete_shopping_list(list_pk, token)
131 |
132 | if is_completed:
133 | return redirect("shopping:single_shopping_list", list_pk=list_pk)
134 |
135 | messages.error(request, "There an error.Please try again later or contact support")
136 | return redirect("shopping:single_shopping_list", list_pk=list_pk)
137 |
138 |
139 | @login_required
140 | def complete_single_shopping_list_item(request, item_pk):
141 | token = request.session.get("auth_token")
142 | is_completed = api_request.request_complete_single_ingredient(item_pk, token)
143 | if is_completed:
144 | return JsonResponse({
145 | "status": "success",
146 | "message": "Item marked as complete!"
147 | })
148 |
149 | return JsonResponse({
150 | "status": "Bad request",
151 | "message": "Item is not marked as complete!"
152 | })
153 |
--------------------------------------------------------------------------------
/fork_recipes/uwsgi.ini:
--------------------------------------------------------------------------------
1 | [uwsgi]
2 | chdir=.
3 | module=backend.wsgi:application
4 | socket=/tmp/uwsgi/uwsgi_recipes.sock
5 | chmod-socket=660
6 | vacuum=true
7 | die-on-term=true
8 |
--------------------------------------------------------------------------------
/fork_recipes/ws/__init__.py:
--------------------------------------------------------------------------------
1 | import sys
2 | sys.path.append("..")
--------------------------------------------------------------------------------
/nginx/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM nginx:latest
2 |
3 | COPY ./nginx/nginx.conf /etc/nginx/nginx.conf
4 | COPY ./nginx/forkrecipes.nginx.conf /etc/nginx/sites-available/
5 | RUN mkdir /etc/nginx/sites-enabled
6 | RUN ln -s /etc/nginx/sites-available/forkrecipes.nginx.conf /etc/nginx/sites-enabled/
7 |
8 | CMD ["nginx", "-g", "daemon off;"]
9 |
--------------------------------------------------------------------------------
/nginx/DockerfileSSL:
--------------------------------------------------------------------------------
1 | FROM nginx:latest
2 |
3 | COPY ./nginx/nginx.conf /etc/nginx/nginx.conf
4 | COPY ./nginx/forkrecipes-ssl.nginx.conf /etc/nginx/sites-available/
5 | COPY ./nginx/ssl /etc/nginx/ssl
6 |
7 | RUN mkdir /etc/nginx/sites-enabled
8 | RUN ln -s /etc/nginx/sites-available/forkrecipes-ssl.nginx.conf /etc/nginx/sites-enabled/
9 |
10 | CMD ["nginx", "-g", "daemon off;"]
11 |
--------------------------------------------------------------------------------
/nginx/forkrecipes-ssl.nginx.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | server_name ${DOMAIN_NAME_NGINX};
4 | client_max_body_size 100M;
5 |
6 | return 301 https://$host$request_uri;
7 | }
8 |
9 | server {
10 | listen 80;
11 | server_name ${DOMAIN_NAME_NGINX_API};
12 | client_max_body_size 100M;
13 |
14 | return 301 https://$host$request_uri;
15 | }
16 |
17 |
18 | server {
19 | listen 443 ssl;
20 | server_name ${DOMAIN_NAME_NGINX};
21 | charset utf-8;
22 | client_max_body_size 100M;
23 |
24 | ssl_certificate /etc/nginx/ssl/fullchain.pem;
25 | ssl_certificate_key /etc/nginx/ssl/privkey.pem;
26 |
27 | location / {
28 | include /etc/nginx/uwsgi_params;
29 | uwsgi_pass unix:/tmp/uwsgi/uwsgi_recipes.sock;
30 |
31 | # This timeouts are needed regarding the amount of wait when using openai model
32 | proxy_connect_timeout 760s; # Timeout for connection to upstream
33 | proxy_send_timeout 760s; # Timeout for sending data to upstream
34 | proxy_read_timeout 760s; # Timeout for reading data from upstream
35 | send_timeout 760s;
36 | uwsgi_read_timeout 760s;
37 | }
38 |
39 | location /static {
40 | autoindex on;
41 | alias /fork_recipes/static;
42 | }
43 |
44 | error_log /var/log/nginx/error.log;
45 | access_log /var/log/nginx/access.log;
46 | }
47 |
48 |
49 | server {
50 | listen 443 ssl;
51 | server_name ${DOMAIN_NAME_NGINX_API};
52 | charset utf-8;
53 | client_max_body_size 100M;
54 |
55 | ssl_certificate /etc/nginx/ssl/fullchain.pem;
56 | ssl_certificate_key /etc/nginx/ssl/privkey.pem;
57 |
58 | location /static {
59 | autoindex on;
60 | alias /forkapi/static;
61 | }
62 |
63 | location /media {
64 | autoindex on;
65 | alias /forkapi/media;
66 | }
67 |
68 | location / {
69 | include /etc/nginx/uwsgi_params;
70 | uwsgi_pass unix:/tmp/uwsgi/uwsgi.sock;
71 | }
72 |
73 | error_log /var/log/nginx/error.log;
74 | access_log /var/log/nginx/access.log;
75 | }
--------------------------------------------------------------------------------
/nginx/forkrecipes.nginx.conf:
--------------------------------------------------------------------------------
1 | server {
2 | listen 80;
3 | server_name localhost;
4 | client_max_body_size 100M;
5 |
6 | location / {
7 | include /etc/nginx/uwsgi_params;
8 | uwsgi_pass unix:/tmp/uwsgi/uwsgi_recipes.sock;
9 | # This timeouts are needed regarding the amount of wait when using openai model
10 | proxy_connect_timeout 760s; # Timeout for connection to upstream
11 | proxy_send_timeout 760s; # Timeout for sending data to upstream
12 | proxy_read_timeout 760s; # Timeout for reading data from upstream
13 | send_timeout 760s;
14 | uwsgi_read_timeout 760s;
15 | }
16 |
17 | location /static {
18 | autoindex on;
19 | alias /fork_recipes/static;
20 | }
21 |
22 | error_log /var/log/nginx/error.log;
23 | access_log /var/log/nginx/access.log;
24 | }
25 |
26 | server {
27 | listen 80;
28 | server_name localhost;
29 | client_max_body_size 100M;
30 |
31 | location / {
32 | include /etc/nginx/uwsgi_params;
33 | uwsgi_pass unix:/tmp/uwsgi/uwsgi.sock;
34 | uwsgi_read_timeout 760s;
35 | }
36 |
37 | location /static {
38 | autoindex on;
39 | alias /forkapi/static;
40 | }
41 |
42 | location /media {
43 | autoindex on;
44 | alias /forkapi/media;
45 | }
46 |
47 | error_log /var/log/nginx/error.log;
48 | access_log /var/log/nginx/access.log;
49 | }
50 |
--------------------------------------------------------------------------------
/nginx/nginx.conf:
--------------------------------------------------------------------------------
1 | user www-data;
2 | worker_processes auto;error_log /var/log/nginx/error.log notice;
3 | pid /var/run/nginx.pid;events {
4 | worker_connections 1024;
5 | }http {
6 | include /etc/nginx/mime.types;
7 | default_type application/octet-stream;log_format main '$remote_addr - $remote_user [$time_local] "$request" '
8 | '$status $body_bytes_sent "$http_referer" '
9 | '"$http_user_agent" "$http_x_forwarded_for"';access_log /var/log/nginx/access.log main;sendfile on;
10 | #tcp_nopush on;keepalive_timeout 65;#gzip on;#include /etc/nginx/conf.d/*.conf;
11 | keepalive_timeout 175;
12 | include /etc/nginx/sites-enabled/*;
13 | }
--------------------------------------------------------------------------------
/nginx/ssl/README.md:
--------------------------------------------------------------------------------
1 | ## Folder to store letsencrypt certificates files
2 | Files names: ``fullchain.pem`` and ``privkey.pem``
3 |
4 | Files location: ```/etc/letsencrypt/live/{your-domain-name}/```
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | pythonpath = . fork_recipes
3 | DJANGO_SETTINGS_MODULE = fork_recipes.backend.settings
4 | python_files = *_tests.py
5 | python_functions = test_*
6 | python_classes = Test*
7 | addopts = --reuse-db --nomigrations
8 | testpaths = tests
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | asgiref==3.8.1
2 | certifi==2024.8.30
3 | charset-normalizer==3.4.0
4 | Django==5.1.8
5 | django-cors-headers==4.3.1
6 | djangorestframework==3.15.2
7 | idna==3.10
8 | iniconfig==2.0.0
9 | packaging==24.2
10 | pluggy==1.5.0
11 | pytest==8.3.3
12 | pytest-django==4.9.0
13 | python-dotenv==1.0.1
14 | PyYAML==6.0.2
15 | requests==2.32.3
16 | responses==0.25.3
17 | sqlparse==0.5.1
18 | urllib3==2.2.3
19 | uWSGI==2.0.28
20 |
--------------------------------------------------------------------------------
/scripts/migration.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | python manage.py makemigrations authentication
4 | python manage.py makemigrations recipe
5 | python manage.py makemigrations schedule
6 | python manage.py makemigrations shopping
7 | python manage.py makemigrations backupper
8 | python manage.py migrate
9 | python manage.py collectstatic --noinput
10 |
11 |
12 | if [ "$1" == 'true' ]; then
13 | python manage.py seed_admin_user
14 | python manage.py seed_categories
15 | fi
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 | import sys
2 | sys.path.append("..")
--------------------------------------------------------------------------------
/tests/mock_util.py:
--------------------------------------------------------------------------------
1 | import os, json
2 | import random
3 | import uuid
4 | from http import HTTPMethod
5 |
6 | import responses
7 | from . import models
8 | from fork_recipes.ws import api_request
9 | from recipes.models import DIFFICULTY_CHOICES
10 |
11 | DIFFICULTY_CHOICES_CHOICE = [choice[0] for choice in DIFFICULTY_CHOICES]
12 |
13 | with open('tests/payload.json', 'r') as file:
14 | json_data_responses = json.load(file)
15 |
16 |
17 | def get_new_password_data_and_token():
18 | new_password = f"password-{uuid.uuid4()}"
19 | request_password_data = {
20 | "password": new_password,
21 | "confirm_password": new_password
22 | }
23 | token = uuid.uuid4()
24 |
25 | return request_password_data, token
26 |
27 |
28 | def responses_register_mock(method: responses, path: str, status_code: int, json_data=None):
29 | responses.add(
30 | method,
31 | f"{os.getenv('SERVICE_BASE_URL')}/{path}",
32 | json=json_data,
33 | status=status_code,
34 | )
35 |
36 |
37 | def mock_token_user():
38 | json_response = json_data_responses['responses']['authentication']['user_login_success']
39 | responses_register_mock(responses.POST, path="api/auth/token", json_data=json_response, status_code=200)
40 |
41 | return json_response
42 |
43 |
44 | def mock_login_user_not_found():
45 | json_response = json_data_responses['responses']['authentication']['user_not_found']
46 | responses_register_mock(responses.POST, path="api/auth/token",
47 | json_data=json_response, status_code=404)
48 |
49 | api_request.get_user_token("non_existing@test.com", "non_existing")
50 |
51 |
52 | def mock_forgot_password_success():
53 | json_response = json_data_responses['responses']['authentication']['password_reset_success']
54 | responses_register_mock(method=responses.POST, path="api/auth/password_reset", json_data=json_response,
55 | status_code=201)
56 |
57 |
58 | def mock_forgot_password_forbidden():
59 | json_response = json_data_responses['responses']['authentication']['password_reset_forbidden']
60 | responses_register_mock(method=responses.POST, path="api/auth/password_reset",
61 | json_data=json_response, status_code=403)
62 |
63 |
64 | def mock_change_password_after_reset():
65 | request_password_data, token = get_new_password_data_and_token()
66 | responses_register_mock(method=responses.POST, path=f"api/auth/password_reset/reset?token={token}",
67 | status_code=204)
68 |
69 | return token, request_password_data
70 |
71 |
72 | def mock_change_password_after_reset_token_does_not_match():
73 | json_response = json_data_responses['responses']['authentication']['password_reset_token_does_not_match']
74 | request_password_data, token = get_new_password_data_and_token()
75 |
76 | responses_register_mock(method=responses.POST, path=f"api/auth/password_reset/reset?token={token}",
77 | json_data=json_response,
78 | status_code=404)
79 |
80 | return request_password_data, token
81 |
82 |
83 | def mock_get_recipe_by_pk():
84 | json_response, categories_response = mock_categories_and_get_recipes_response(
85 | models.RecipeResponseType.RECIPE_DETAILS)
86 | recipe_pk = json_response['pk']
87 | responses_register_mock(method=responses.GET, path=f"api/recipe/{recipe_pk}/", json_data=json_response,
88 | status_code=200)
89 |
90 | return json_response, recipe_pk, categories_response
91 |
92 | def mock_patch_recipe_category():
93 | responses_register_mock(method=responses.PATCH, path=f"api/recipe/1/category",
94 | status_code=204)
95 |
96 | def mock_post_recipe_ingredients(recipe_pk: int):
97 | response_data = json_data_responses['responses']['ingredients']
98 | responses_register_mock(method=responses.POST, path=f"api/recipe/{recipe_pk}/ingredients", status_code=201,
99 | json_data=response_data)
100 |
101 | return response_data
102 |
103 |
104 | def mock_post_recipe_instructions(recipe_pk: int):
105 | response_data = json_data_responses['responses']['steps']
106 | responses_register_mock(method=responses.POST, path=f"api/recipe/{recipe_pk}/steps", status_code=201,
107 | json_data=response_data)
108 |
109 | return response_data
110 |
111 |
112 | def mock_create_update_recipe_full_info(method: HTTPMethod, status_code: int):
113 | response_recipe = json_data_responses['responses']['recipes']['recipe_details']
114 | response_data = response_recipe if status_code != 400 else {
115 | "category": ["Incorrect type. Expected pk value, received str."]}
116 |
117 | match method:
118 | case HTTPMethod.POST:
119 | responses_register_mock(method=responses.POST, path=f"api/recipe/", status_code=status_code,
120 | json_data=response_data)
121 | case HTTPMethod.PUT:
122 | responses_register_mock(method=responses.PUT, path=f"api/recipe/{response_recipe['pk']}",
123 | status_code=status_code,
124 | json_data=response_data)
125 |
126 | ingredients_response_data = mock_post_recipe_ingredients(response_recipe['pk'])
127 | instructions_response_data = mock_post_recipe_instructions(response_recipe['pk'])
128 |
129 | return response_data, ingredients_response_data, instructions_response_data
130 |
131 |
132 | def mock_data_recipe_on_update_or_create(method: HTTPMethod, recipe_pk, categories_response, status_code: int):
133 | post_data = {
134 | "name": f"name-{uuid.uuid4()}",
135 | "category": categories_response[0]['pk'],
136 | "difficulty": random.choice(DIFFICULTY_CHOICES_CHOICE),
137 | "prep_time": int(random.uniform(20, 60)),
138 | "cook_time": int(random.uniform(10, 60)),
139 | "servings": int(random.uniform(1, 10)),
140 | "description": f"Description-{uuid.uuid4()}",
141 | "chef": f"Chef-{uuid.uuid4()}",
142 | "ingredient_name[]": ['ingredient-1', 'ingredient-4', 'ingredient-3'],
143 | "ingredient_quantity[]": ['10', '20', '30'],
144 | "ingredient_metric[]": ["pcs", "tbs", "tps"],
145 | "instructions[]": [f"Instruction-{uuid.uuid4()}", f"Instruction-{uuid.uuid4()}"],
146 | "clear_video": False
147 | }
148 |
149 | recipe_main_info_data = {
150 | "name": post_data['name'],
151 | "category": post_data['category'],
152 | "difficulty": post_data['difficulty'],
153 | "prep_time": post_data['prep_time'],
154 | "cook_time": post_data['cook_time'],
155 | "servings": post_data['servings'],
156 | "description": post_data['description'],
157 | "chef": post_data['chef'],
158 | "is_favorite": True
159 | }
160 |
161 | recipe_files = [
162 | ("image", open("tests/uploads/upload-image.png", 'rb')),
163 | ("video", open("tests/uploads/upload-video.mp4", 'rb'))
164 | ]
165 |
166 | ingredients_data = []
167 | for name, quantity, metric in zip(post_data["ingredient_name[]"], post_data['ingredient_quantity[]'],
168 | post_data['ingredient_metric[]']):
169 | ingredients_data.append({"name": name, "quantity": quantity, "metric": metric})
170 |
171 | instructions_data = []
172 | for instruction in post_data['instructions[]']:
173 | instructions_data.append({"text": instruction})
174 |
175 | mock_create_update_recipe_full_info(method, status_code)
176 | generate_token = uuid.uuid4()
177 |
178 | match method:
179 | case HTTPMethod.PUT:
180 | api_request.update_recipe_main_info(recipe_pk, multipart_form_data=recipe_main_info_data,
181 | files=recipe_files, token=generate_token)
182 | case HTTPMethod.POST:
183 | api_request.post_new_recipe_main_info(multipart_form_data=recipe_main_info_data, files=recipe_files,
184 | token=generate_token)
185 |
186 | api_request.post_ingredients_for_recipe(recipe_pk, token=generate_token, data=ingredients_data)
187 | api_request.post_instructions_for_recipe(recipe_pk, token=generate_token, data=instructions_data)
188 |
189 | return post_data
190 |
191 |
192 | def mock_categories_and_get_recipes_response(recipe_response_type: models.RecipeResponseType):
193 | categories_response = json_data_responses['responses']['categories']
194 |
195 | responses_register_mock(method=responses.GET, path=f"api/recipe/category",
196 | json_data=categories_response,
197 | status_code=200)
198 |
199 | api_request.get_categories()
200 | recipe_response = {}
201 |
202 | match recipe_response_type:
203 | case models.RecipeResponseType.HOME_PREVIEW_PAGINATE:
204 | recipe_response = json_data_responses['responses']['recipes']['search_recipes']['home_paginate']
205 | responses_register_mock(method=responses.GET, path=f"api/recipe/home/preview/?page=1",
206 | json_data=recipe_response, status_code=200)
207 |
208 | case models.RecipeResponseType.HOME_PREVIEW_BY_CATEGORY:
209 | recipe_response = json_data_responses['responses']['recipes']['search_recipes']['home_by_category']
210 | category_pk = categories_response[0]['pk']
211 | responses_register_mock(method=responses.GET,
212 | path=f"api/recipe/category/{category_pk}/recipes",
213 | json_data=recipe_response, status_code=200)
214 | case models.RecipeResponseType.RECIPE_DETAILS:
215 | recipe_response = json_data_responses['responses']['recipes']['recipe_details']
216 |
217 | return recipe_response, categories_response
218 |
219 |
220 | def mock_get_user_profile_request():
221 | profile_response = json_data_responses['responses']['profile_info']['success']
222 | responses_register_mock(method=responses.GET, path="api/auth/user/info", json_data=profile_response,
223 | status_code=200)
224 |
225 | return profile_response
226 |
227 |
228 | def mock_change_user_profile_info_success():
229 | profile_response = mock_get_user_profile_request()
230 |
231 | request_data = {
232 | "username": profile_response['username'],
233 | "email": profile_response['email'],
234 | }
235 |
236 | responses_register_mock(method=responses.PATCH, path="api/auth/user", json_data=profile_response, status_code=200)
237 |
238 | return profile_response, request_data
239 |
240 |
241 | def mock_change_user_profile_info_bad_request():
242 | profile_response = json_data_responses['responses']['profile_info']['bad_request']
243 | responses_register_mock(method=responses.PATCH, path="api/auth/user", json_data=profile_response, status_code=400)
244 | get_profile_response = mock_get_user_profile_request()
245 |
246 | return profile_response, get_profile_response
247 |
248 |
249 | def mock_get_favorite_recipes(page_number: int):
250 | json_response = json_data_responses['responses']['recipes']['favorite_recipes']
251 | responses_register_mock(responses.GET, path=f"api/recipe/home/favorites/?page={page_number}",
252 | json_data=json_response, status_code=200)
253 |
254 | return json_response
255 |
256 |
257 | def mock_favorite_action_recipe(recipe_pk, status_code: int):
258 | responses_register_mock(method=responses.PATCH, path=f"api/recipe/{recipe_pk}/favorite", status_code=status_code)
259 |
260 |
261 | def mock_change_password_from_profile(status_code: int):
262 | json_request = json_data_responses['requests']['authentication']['change_password_logged_user_success']
263 | data = json_data_responses['requests']['authentication']['change_password_success']
264 |
265 | responses_register_mock(responses.PUT, path="api/auth/user", status_code=status_code)
266 |
267 | return data, json_request
268 |
269 |
270 | def mock_delete_account(status_code: int):
271 | responses_register_mock(responses.DELETE, path="api/auth/delete-account", status_code=status_code)
272 |
273 | def mock_get_user_settings():
274 | json_response = json_data_responses['responses']['user_profile']['user_settings']
275 | responses_register_mock(method=responses.GET, path="api/auth/settings", json_data=json_response, status_code=200)
276 |
277 |
278 | def mock_get_shopping_list():
279 | json_response = json_data_responses['responses']['shopping']['shopping_lists']
280 | responses_register_mock(method=responses.GET, path="api/shopping/", json_data=json_response, status_code=200)
--------------------------------------------------------------------------------
/tests/models.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 |
4 | class RecipeResponseType(Enum):
5 | HOME_PREVIEW_PAGINATE = 0,
6 | HOME_PREVIEW_BY_CATEGORY = 1,
7 | RECIPE_DETAILS = 2
8 |
--------------------------------------------------------------------------------
/tests/payload.json:
--------------------------------------------------------------------------------
1 | {
2 | "responses": {
3 | "categories": [
4 | {
5 | "pk": 1,
6 | "name": "Breakfast"
7 | },
8 | {
9 | "pk": 2,
10 | "name": "Lunch"
11 | },
12 | {
13 | "pk": 3,
14 | "name": "Sunday"
15 | },
16 | {
17 | "pk": 4,
18 | "name": "Pizza"
19 | },
20 | {
21 | "pk": 5,
22 | "name": "Brunch"
23 | }
24 | ],
25 | "steps": [
26 | {
27 | "text": "Heat the oven"
28 | },
29 | {
30 | "text": "Take a break"
31 | },
32 | {
33 | "text": "Prepare the mix"
34 | },
35 | {
36 | "text": "Enjoy"
37 | }
38 | ],
39 | "ingredients": [
40 | {
41 | "name": "Kasher salt",
42 | "quantity": "1/5",
43 | "metric": "tbsp"
44 | },
45 | {
46 | "name": "Pudding mix",
47 | "quantity": "1",
48 | "metric": "pcs"
49 | }
50 | ],
51 | "recipes": {
52 | "search_recipes": {
53 | "home_paginate": {
54 | "count": 1,
55 | "next": null,
56 | "previous": null,
57 | "results": [
58 | {
59 | "pk": 46,
60 | "image": "http://localhost:8000/media/images/8ddef70f-dd9d-44b1-847f-834cdf97e7bd_d5122d74-1c5e-40d3-a505-f9d09383d1ba_delicio_X3L8djK.jpg",
61 | "name": "Creamy Mushroom Risotto",
62 | "chef": "Julia Child",
63 | "servings": 1,
64 | "total_time": 1.62,
65 | "difficulty": "Intermediate",
66 | "is_favorite": false
67 | }
68 | ]
69 | },
70 | "home_by_category": [
71 | {
72 | "pk": 46,
73 | "image": "http://localhost:8000/media/images/8ddef70f-dd9d-44b1-847f-834cdf97e7bd_d5122d74-1c5e-40d3-a505-f9d09383d1ba_delicio_X3L8djK.jpg",
74 | "name": "Creamy Mushroom Risotto",
75 | "chef": "Julia Child",
76 | "servings": 1,
77 | "total_time": 1.62,
78 | "difficulty": "Intermediate",
79 | "is_favorite": false
80 | }
81 | ]
82 | },
83 | "recipe_details": {
84 | "pk": 1,
85 | "image": "http://localhost:8000/media/images/6b732e73-73b9-40e4-b8c9-1d5c65361d0e_1667446a-2636-41d1-9e03-bd43f1873f79_top-vie_eSzouHS.jpg",
86 | "name": "Tuscan White Bean Soup",
87 | "servings": 5,
88 | "chef": "Thomas Keller",
89 | "video": "http://localhost:8000/media/videos/58457d14-3f2f-413a-82d4-4a67fbb6a6b8_7451c646-5600-4ab3-801d-c861a2585d00_4935156_Jj5bDqV.mp4",
90 | "description": "A savory and hearty chicken stew packed with tender vegetables, slow-cooked in a rich tomato-based broth, perfect for a cozy dinner.",
91 | "category": {
92 | "pk": 1,
93 | "name": "test1"
94 | },
95 | "tag": null,
96 | "prep_time": 38,
97 | "cook_time": 13,
98 | "total_time": 0.85,
99 | "difficulty": "Intermediate",
100 | "is_favorite": false,
101 | "language": "English",
102 | "is_original": true,
103 | "is_translated": false,
104 | "ingredients": [
105 | {
106 | "name": "chicken breast",
107 | "quantity": "2",
108 | "metric": "lbs"
109 | },
110 | {
111 | "name": "all-purpose flour",
112 | "quantity": "1.5",
113 | "metric": "cups"
114 | }
115 | ],
116 | "steps": [
117 | {
118 | "text": "Preheat the oven to 375°F."
119 | },
120 | {
121 | "text": "Chop the carrots into small cubes."
122 | }
123 | ]
124 | },
125 | "favorite_recipes": {
126 | "count": 2,
127 | "next": null,
128 | "previous": null,
129 | "results": [
130 | {
131 | "pk": 16,
132 | "image": "http://localhost:8000/media/images/6f827d64-9390-441b-a8ad-38d9b17472a7_f02b7568-5cfa-48bd-b68a-911f0426d61c_pancake_hHpm3G5.jpg",
133 | "name": "Vegetarian Enchilada Casserole",
134 | "servings": 2,
135 | "chef": "Gordon Ramsay",
136 | "video": null,
137 | "description": "A refreshing Mediterranean salad featuring crisp cucumbers, juicy tomatoes, Kalamata olives, and a zesty lemon-oregano dressing.",
138 | "category": 4,
139 | "tag": null,
140 | "prep_time": 31,
141 | "cook_time": 20,
142 | "total_time": 0.85,
143 | "difficulty": "Easy",
144 | "is_favorite": true,
145 | "ingredients": [
146 | {
147 | "name": "chicken breast",
148 | "quantity": "2",
149 | "metric": "lbs"
150 | },
151 | {
152 | "name": "all-purpose flour",
153 | "quantity": "1.5",
154 | "metric": "cups"
155 | }
156 | ],
157 | "steps": [
158 | {
159 | "text": "Preheat the oven to 375\u00b0F."
160 | },
161 | {
162 | "text": "Chop the carrots into small cubes."
163 | }
164 | ]
165 | },
166 | {
167 | "pk": 15,
168 | "image": "http://localhost:8000/media/images/de251b0c-bf07-411b-aab8-9b2bf810d584_0512c997-a679-4fa7-8477-a4d1723dde76_gourme.png",
169 | "name": "Slow-Cooked Beef Bourguignon",
170 | "servings": 3,
171 | "chef": "Nigella Lawson",
172 | "video": null,
173 | "description": "A refreshing Mediterranean salad featuring crisp cucumbers, juicy tomatoes, Kalamata olives, and a zesty lemon-oregano dressing.",
174 | "category": 4,
175 | "tag": null,
176 | "prep_time": 34,
177 | "cook_time": 19,
178 | "total_time": 0.88,
179 | "difficulty": "Expert",
180 | "is_favorite": true,
181 | "ingredients": [
182 | {
183 | "name": "chicken breast",
184 | "quantity": "2",
185 | "metric": "lbs"
186 | },
187 | {
188 | "name": "all-purpose flour",
189 | "quantity": "1.5",
190 | "metric": "cups"
191 | }
192 | ],
193 | "steps": [
194 | {
195 | "text": "Preheat the oven to 375\u00b0F."
196 | },
197 | {
198 | "text": "Chop the carrots into small cubes."
199 | }
200 | ]
201 | }
202 | ]
203 | }
204 | },
205 | "profile_info": {
206 | "success": {
207 | "username": "test_user",
208 | "email": "test_email@mail.com",
209 | "date_joined": "2024-11-23T12:07:18.951676Z"
210 | },
211 | "bad_request": {
212 | "errors": [
213 | "This email address already exists or is invalid.Please choice another."
214 | ]
215 | }
216 | },
217 | "authentication": {
218 | "user_login_success": {
219 | "token": "bba74ec6c4e78d9226bf980cc98bc7177509e279",
220 | "user": {
221 | "username": "user_123",
222 | "email": "email@test.com",
223 | "is_superuser": true
224 | }
225 | },
226 | "user_not_found": {
227 | "detail": "No User matches the given query."
228 | },
229 | "password_reset_success": {
230 | "token": "bba74ec6c4e78d9226bf980cc98bc7177509e279"
231 | },
232 | "password_reset_forbidden": {
233 | "detail": "You must use authentication header"
234 | },
235 | "password_reset_token_does_not_match": {
236 | "detail": "No PasswordResetToken matches the given query."
237 | }
238 | },
239 | "user_profile": {
240 | "user_settings": {
241 | "preferred_translate_language": "English"
242 | }
243 | },
244 | "shopping": {
245 | "shopping_lists": [
246 | {
247 | "pk": 17,
248 | "items": [
249 | {
250 | "pk": 132,
251 | "name": "unsalted butter",
252 | "quantity": "4",
253 | "metric": "sticks",
254 | "times": 1,
255 | "is_completed": false
256 | },
257 | {
258 | "pk": 133,
259 | "name": "olive oil",
260 | "quantity": "0.25",
261 | "metric": "cups",
262 | "times": 1,
263 | "is_completed": false
264 | },
265 | {
266 | "pk": 134,
267 | "name": "Test ingredient11",
268 | "quantity": "1231",
269 | "metric": "gr",
270 | "times": 1,
271 | "is_completed": false
272 | }
273 | ],
274 | "name": "112",
275 | "is_completed": false,
276 | "recipes": [
277 | 10
278 | ]
279 | }
280 | ]
281 | }
282 | },
283 | "requests": {
284 | "authentication": {
285 | "password_reset_json": {
286 | "email": "testemail@email.com"
287 | },
288 | "change_password_success": {
289 | "old_password": "admin123??",
290 | "new_password": "new_password123??"
291 | },
292 | "change_password_logged_user_success": {
293 | "current_password": "admin",
294 | "new_password": "new_password123??",
295 | "confirm_password": "new_password123??"
296 | },
297 | "change_password_logged_user_passwords_does_not_match": {
298 | "current_password": "admin",
299 | "new_password": "new_password123??",
300 | "confirm_password": "new_password123??4"
301 | }
302 | },
303 | "user_settings": {
304 | "language": " English"
305 | }
306 | }
307 | }
--------------------------------------------------------------------------------
/tests/uploads/upload-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikebgrep/fork.recipes/669a53f14cc565fce41e93cb9ee69dc581adf97d/tests/uploads/upload-image.png
--------------------------------------------------------------------------------
/tests/uploads/upload-video.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mikebgrep/fork.recipes/669a53f14cc565fce41e93cb9ee69dc581adf97d/tests/uploads/upload-video.mp4
--------------------------------------------------------------------------------