├── .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 | ![version](https://img.shields.io/badge/version-4.1.1-green) [![License: BSD-3-Clause](https://img.shields.io/badge/License-BSD_3_Clause-red.svg)](https://opensource.org/license/bsd-3-clause) 4 | 5 | 6 | Logo 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 | ![preview](assets/preview.gif) 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 | Use this link to reset your password. The link is only valid for 24 hours. 441 | 442 | 443 | 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 | Recipe image 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 |
  1. {{ step.text }}
  2. 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 | Recipe image 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 | 50 |
51 | 52 |
53 | 54 |
55 |

Instructions

56 |
    57 | {% for step in recipe.steps %} 58 |
  1. {{ step.text }}
  2. 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 |

14 | 404

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 |

500

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 |
12 | 13 | 14 | 15 |
16 |

Reset your password

17 |

18 | Enter your email address and we'll send you a link to reset your password. 19 |

20 |
21 |
22 | {% csrf_token %} 23 |
24 | 25 | 28 |
29 | 30 |
31 | 35 |
36 | 37 | 42 |
43 |
44 |
45 | {% if messages %} 46 |
47 | {% for message in messages %} 48 |
49 |

{{ message }}

50 |
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 |
12 | 13 | 14 | 15 |
16 |

Reset your password

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 |
11 |

Results

12 | 60 |
61 | 62 | 63 | 64 | {% else %} 65 | 66 | 109 | 110 |
111 |

Add your ingredients to generate recipes

112 |
113 | {% csrf_token %} 114 |
115 | 116 | 117 |
118 | 119 |
120 | 121 |
122 | 1 123 | 125 | 132 |
133 |
134 |
135 | 136 | 140 |
141 |
142 | 146 | 147 | Back to Home 148 | 149 |
150 |
151 |
152 | 153 | {% endif %} 154 | 155 | 156 | {% if messages %} 157 |
158 | {% for message in messages %} 159 |
160 |

{{ message }}

161 |
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 |
9 |
10 |
11 |
12 | 13 | 15 | 16 |
17 |

Sign in to your account

18 |
19 |
20 | {% csrf_token %} 21 |
22 |
23 | 26 |
27 |
28 | 31 |
32 |
33 | {% if message %} 34 |

{{ message }}

35 | {% endif %} 36 |
37 |
38 | 40 | 41 |
42 | 43 | 46 |
47 | 48 |
49 | 61 |
62 |
63 |
64 |
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 |
9 |
10 | {% csrf_token %} 11 |
12 | 13 | {% if message %} 14 |

{{ message }}

15 | {% endif %} 16 |
17 | 18 | 21 |
22 | 23 | 24 |
25 |
26 | 27 | 34 | 37 |
38 |
39 |
40 | 41 |
42 | 43 | 50 |
51 | 52 |
53 | 54 | 56 |
57 |
58 | 59 | 61 |
62 | 63 |
64 | 65 | 67 |
68 |
69 | 70 | 73 |
74 |
75 | 76 | 77 |
78 | 79 | 82 |
83 | 84 | 85 |
86 |
87 | 88 |
90 | 92 | 101 |
102 |
103 | 104 |
105 | 106 |
108 | 109 | 118 |
119 |
120 |
121 | 122 | 123 |
124 |
125 | 126 | 130 |
131 |
132 |
133 | 135 | 137 | 139 | 143 |
144 |
145 |
146 | 147 | 148 | 149 |
150 |
151 | 152 | 156 |
157 |
158 |
159 | 1 161 | 163 | 167 |
168 |
169 |
170 |
171 | 172 | 173 |
174 | Cancel 176 | 179 |
180 |
181 |
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 | 26 | 28 |
29 | 30 | 31 |
32 | 33 | 35 |
36 | 37 | 38 |
39 | 43 |
44 |
45 |
46 | 47 | 48 |
49 |

Change Password

50 |
51 | {% csrf_token %} 52 |
53 | 54 | 56 |
57 |
58 | 59 | 61 |
62 |
63 | 65 | 67 |
68 | {% if error %} 69 |

{{ error }}

70 | {% endif %} 71 |
72 | 76 |
77 |
78 |
79 | 80 | 81 |
82 |

Delete Account

83 |

Once you delete your account, there is no going back. Please be 84 | certain.

85 | 89 |
90 |
91 | 92 | 93 | 111 | 112 | {% if messages %} 113 |
114 | {% for message in messages %} 115 |
116 |

{{ message }}

117 |
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 |
29 | 31 | All Recipes 32 | 33 | 35 | 🎲 Random Recipe 36 | 37 | {% for category in categories %} 38 | 40 | {{ category.name }} 41 | 42 | {% endfor %} 43 |
44 | 45 | 46 |
47 | {% for recipe in page_obj %} 48 | 50 |
51 | {{ recipe.title }} 52 |
53 |
54 | {% if recipe.difficulty %} 55 | 56 | {{ recipe.difficulty }} 57 | 58 | {% endif %} 59 |
60 |
61 |
62 |

{{ recipe.name }}

63 |
64 | {% if recipe.total_time %} 65 |
66 | 68 | 69 | 70 | 71 | {{ recipe.total_time }} hrs 72 |
73 | {% endif %} 74 |
75 | 77 | 78 | 79 | 80 | 81 | 82 | {{ recipe.servings }} servings 83 |
84 |
85 | {% if recipe.chef %} 86 |
87 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | By {{ recipe.chef }} 96 |
97 | {% endif %} 98 |
99 |
100 | {% empty %} 101 |
102 | 104 | 106 | 107 |

No recipes found

108 | 110 | Browse All Recipes 111 | 112 |
113 | {% endfor %} 114 |
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 |
141 |

{{ message }}

142 |
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 |
11 |
12 | 13 | 15 | 16 |
17 |

Enter your new password

18 |
19 |
20 | {% csrf_token %} 21 |
22 |
23 | 26 |
27 |
28 | 31 |
32 |
33 | {% if message %} 34 |

{{ message }}

35 | {% endif %} 36 | 37 |
38 | 42 |
43 |
44 |
45 |
46 | {% if messages %} 47 |
48 | {% for message in messages %} 49 |
50 |

{{ message }}

51 |
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 |
9 | {% for recipe in page_obj %} 10 | 11 |
12 | {{ recipe.title }} 13 |
14 |
15 | 16 | {{ recipe.difficulty }} 17 | 18 |
19 |
20 |
21 |

{{ recipe.name }}

22 |
23 | {% if recipe.total_time %} 24 |
25 | 26 | 27 | 28 | 29 | {{ recipe.total_time }} hrs 30 |
31 | {% endif %} 32 |
33 | 34 | 35 | 36 | 37 | 38 | 39 | {{ recipe.servings }} servings 40 |
41 |
42 | {% if recipe.chef %} 43 |
44 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | By {{ recipe.chef }} 53 |
54 | {% endif %} 55 |
56 | 57 |
58 | {% empty %} 59 |
60 | 61 | 62 | 63 |

No saved recipes yet

64 | 65 | Discover Recipes 66 | 67 |
68 | {% endfor %} 69 |
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 | 15 | 22 |
23 |
24 | 30 | 41 | 42 | Back to Home 43 | 44 |
45 |
46 |
47 | 48 | 49 | {% if messages %} 50 |
51 | {% for message in messages %} 52 |
53 |

{{ message }}

54 |
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 |

25 | Schedule 26 |

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 | Recipe Image 48 | 49 |
50 |

{{ breakfast.recipe.name }}

51 |
52 |
53 | 54 |
55 | 59 |
60 |
61 | {% else %} 62 | {% endif %} 63 | {% if lunch %} 64 |
65 |

66 | Lunch 67 |

68 |
69 | 70 | Recipe Image 72 | 73 |
74 |

{{ lunch.recipe.name }}

75 |
76 |
77 | 78 |
79 | 83 |
84 | 85 |
86 | {% else %} 87 | {% endif %} 88 | {% if dinner %} 89 |
90 |

91 | Dinner 92 |

93 |
94 | 95 | Recipe Image 97 | 98 |
99 |

{{ dinner.recipe.name }}

100 |
101 |
102 | 103 |
104 | 108 |
109 |
110 | {% else %} 111 | {% endif %} 112 | {% if side %} 113 |
114 |

115 | Side 116 |

117 |
118 | 119 | Recipe Image 121 | 122 |
123 |

{{ side.recipe.name }}

124 |
125 |
126 | 127 |
128 | 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 |
153 |

{{ message }}

154 |
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 | 19 |
20 | 29 |
30 |
31 |
32 | 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 | 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 | 71 |
72 |
73 |
74 |
75 | 76 | 77 | 78 |
79 |
80 |

Manage Backups

81 |
82 |
83 | 84 | 85 |
86 | 87 |
88 | 92 | Create Backup 93 | 94 | 104 |
105 | 106 | 107 | 108 |
109 | 110 |
111 | {% csrf_token %} 112 | 117 |
118 | 119 | 120 | 124 | Download Backup 125 | 126 |
127 | 128 |
129 | 130 | 131 |
132 | 133 | 140 |
141 | 142 | 143 |
144 | 148 | 152 |
153 | 154 |
155 | 156 |
157 |
158 |
159 | 160 | 161 | 176 | 177 | 192 | 193 | 194 | {% if messages %} 195 |
196 | {% for message in messages %} 197 |
198 |

{{ message }}

199 |
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 |
32 | 34 | 36 | 37 | 38 |
39 | 40 | 41 |
42 |

{{ shopping.name }}

43 |
44 |
45 | 46 | 47 |
48 | 52 | 54 | Delete 55 | 56 |
57 |
58 | {% endfor %} 59 |
60 | 61 | 62 |
63 | 67 |
68 |
69 |
70 | 71 | 90 | 132 | 133 | {% if messages %} 134 |
135 | {% for message in messages %} 136 |
137 |

{{ message }}

138 |
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 --------------------------------------------------------------------------------