Merge branch 'develop' into develop

This commit is contained in:
vabene1111 2022-02-11 09:17:31 +01:00 committed by GitHub
commit b182a9962c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
391 changed files with 61562 additions and 56547 deletions

View File

@ -1,4 +1,4 @@
node_modules
**/node_modules
npm-debug.log
Dockerfile*
docker-compose*
@ -12,6 +12,21 @@ LICENSE
.env.template
.github
.idea
.prettierignore
LICENSE.md
docs
update.sh
update.sh
.pytest_cache
cookbook/tests
mediafiles
staticfiles
db.sqlite3
pytest.ini
vue/**/*.vue
vue/**/*.ts
**/.openapi-generator
mkdocs.yml
vue/babel.config*
vue/package.json
vue/tsconfig.json
vue/src/utils/openapi

View File

@ -1,6 +1,7 @@
# only set this to true when testing/debugging
# when unset: 1 (true) - dont unset this, just for development
DEBUG=0
SQL_DEBUG=0
# HTTP port to bind to
# TANDOOR_PORT=8080
@ -9,7 +10,9 @@ DEBUG=0
ALLOWED_HOSTS=*
# random secret key, use for example `base64 /dev/urandom | head -c50` to generate one
# ---------------------------- REQUIRED -------------------------
SECRET_KEY=
# ---------------------------------------------------------------
# your default timezone See https://timezonedb.com/time-zones for a list of timezones
TIMEZONE=Europe/Berlin
@ -20,7 +23,9 @@ DB_ENGINE=django.db.backends.postgresql
POSTGRES_HOST=db_recipes
POSTGRES_PORT=5432
POSTGRES_USER=djangouser
# ---------------------------- REQUIRED -------------------------
POSTGRES_PASSWORD=
# ---------------------------------------------------------------
POSTGRES_DB=djangodb
# database connection string, when used overrides other database settings.
@ -43,10 +48,18 @@ SHOPPING_MIN_AUTOSYNC_INTERVAL=5
# Default for user setting sticky navbar
# STICKY_NAV_PREF_DEFAULT=1
# If staticfiles are stored at a different location uncomment and change accordingly
# If base URL is something other than just / (you are serving a subfolder in your proxy for instance http://recipe_app/recipes/)
# Be sure to not have a trailing slash: e.g. '/recipes' instead of '/recipes/'
# SCRIPT_NAME=/recipes
# If staticfiles are stored at a different location uncomment and change accordingly, MUST END IN /
# this is not required if you are just using a subfolder
# This can either be a relative path from the applications base path or the url of an external host
# STATIC_URL=/static/
# If mediafiles are stored at a different location uncomment and change accordingly
# If mediafiles are stored at a different location uncomment and change accordingly, MUST END IN /
# this is not required if you are just using a subfolder
# This can either be a relative path from the applications base path or the url of an external host
# MEDIA_URL=/media/
# Serve mediafiles directly using gunicorn. Basically everyone recommends not doing this. Please use any of the examples
@ -81,8 +94,6 @@ GUNICORN_MEDIA=0
# when unset: 0 (false)
REVERSE_PROXY_AUTH=0
# If base URL is something other than just / (you are serving a subfolder in your proxy for instance http://recipe_app/recipes/)
# SCRIPT_NAME=/recipes
# Default settings for spaces, apply per space and can be changed in the admin view
# SPACE_DEFAULT_MAX_RECIPES=0 # 0=unlimited recipes
# SPACE_DEFAULT_MAX_USERS=0 # 0=unlimited users per space
@ -122,4 +133,28 @@ REVERSE_PROXY_AUTH=0
# Django session cookie settings. Can be changed to allow a single django application to authenticate several applications
# when running under the same database
# SESSION_COOKIE_DOMAIN=.example.com
# SESSION_COOKIE_NAME=sessionid # use this only to not interfere with non unified django applications under the same top level domain
# SESSION_COOKIE_NAME=sessionid # use this only to not interfere with non unified django applications under the same top level domain
# by default SORT_TREE_BY_NAME is disabled this will store all Keywords and Food in the order they are created
# enabling this setting makes saving new keywords and foods very slow, which doesn't matter in most usecases.
# however, when doing large imports of recipes that will create new objects, can increase total run time by 10-15x
# Keywords and Food can be manually sorted by name in Admin
# This value can also be temporarily changed in Admin, it will revert the next time the application is started
# This will be fixed/changed in the future by changing the implementation or finding a better workaround for sorting
# SORT_TREE_BY_NAME=0
# LDAP authentication
# default 0 (false), when 1 (true) list of allowed users will be fetched from LDAP server
#LDAP_AUTH=
#AUTH_LDAP_SERVER_URI=
#AUTH_LDAP_BIND_DN=
#AUTH_LDAP_BIND_PASSWORD=
#AUTH_LDAP_USER_SEARCH_BASE_DN=
#AUTH_LDAP_TLS_CACERTFILE=
# Enables exporting PDF (see export docs)
# Disabled by default, uncomment to enable
# ENABLE_PDF_EXPORT=1
# Recipe exports are cached for a certain time by default, adjust time if needed
# EXPORT_FILE_CACHE_DURATION=600

12
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,12 @@
# These are supported funding model platforms
github: [vabene1111]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@ -1,15 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
### Version
Please provide your current version (can be found on the system page since v0.8.4)
Version:
### Bug description
A clear and concise description of what the bug is.

View File

@ -0,0 +1,81 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
## Version
<!-- Please provide your current version (can be found on the system page since v0.8.4). -->
**Tandoor-Version:**
## Setup configuration
<!--Please tick all boxes which apply to your configuration. Feel free to provide additional information below.
To tick boxes here, simply put an X inside the brackets below -->
### Setup
- [ ] Docker / Docker-Compose
- [ ] Unraid
- [ ] Synology
- [ ] Kubernetes
- [ ] Manual setup
- [ ] Others (please state below)
### Reverse Proxy
- [ ] No reverse proxy
- [ ] jwilder's nginx proxy
- [ ] Nginx proxy manager (NPM)
- [ ] SWAG
- [ ] Caddy
- [ ] Traefik
- [ ] Others (please state below)
<!-- Please provide additional information if possible -->
**Additional information:**
## Bug description
A clear and concise description of what the bug is.
## Logs
<!-- *(Remove this section entirely if no logs are available or necessary for your issue)*
To get the most information about your issue, set DEBUG=1 (e.g. in your `.env` file if using docker-compose) and try to reproduce the issue afterwards.
Please put your logs into the expandable section below and use code quotation for all logs! Usage: Put three backticks in front and after the log, like this:
` ``` <Many lines of log messages ``` `
Feel free to remove parts if you don't fill them out.
-->
<details>
<summary>Web-Container-Logs</summary>
<!-- *Put your logs inside here (leave the code quotations in takt):* -->
```
Replace me with logs
```
</details>
<details>
<summary>DB-Container-Logs</summary>
<!-- *Put your logs inside here (leave the code quotations in takt):* -->
```
Replace me with logs
```
</details>
<details>
<summary>Nginx-Container-Logs <!-- if you use one --></summary>
<!-- *Put your logs inside here (leave the code quotations in takt):* -->
```
Replace me with logs
```
</details>

64
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@ -0,0 +1,64 @@
name: Bug Report
description: "Create a report to help us improve"
#title: ""
#labels: ["Bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: input
id: version
attributes:
label: Tandoor Version
description: "What version of Tandoor are you using? (can be found on the system page since v0.8.4)"
validations:
required: true
- type: dropdown
id: setup
attributes:
label: Setup
description: "How is your Tandoor instance set up?"
options:
- Docker / Docker-Compose
- Unraid
- Synology
- Kubernetes
- Manual Setup
- Others (please state below)
validations:
required: true
- type: dropdown
id: reverse-proxy
attributes:
label: "Reverse Proxy"
description: "What reverse proxy do you use with Tandoor?"
options:
- No reverse proxy
- jwilder's nginx proxy
- Nginx Proxy Manager (NPM)
- SWAG
- Caddy
- Traefik
- Apache2
- Others (please state below)
validations:
required: true
- type: input
id: other
attributes:
label: Other
description: "In case you chose 'Others' above, please provide more info here."
- type: textarea
id: bug-descr
attributes:
label: Bug description
description: "Please accurately describe the bug you encountered."
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant logs
description: Please copy and paste any relevant logs. This will be automatically formatted into code, so no need for backticks.
render: shell

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: FAQs
url: https://docs.tandoor.dev/faq/
about: Please take a look at the FAQs before creating a bug ticket.

40
.github/ISSUE_TEMPLATE/doc_issue.yml vendored Normal file
View File

@ -0,0 +1,40 @@
name: Documentation Issue
description: "Create a report to help us improve"
#title: ""
labels: ["documentation"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this documentation issue report!
- type: input
id: docs-link
attributes:
label: Documentation link
description: "Please provide a link to the corresponding documentation site on docs.tandoor.dev"
- type: dropdown
id: section
attributes:
label: Affected section
description: "What part of the documentation is the issue about?"
options:
- Installation
- Features
- System
- FAQ
- Does not exist yet
- Other (please state below)
validations:
required: true
- type: input
id: other
attributes:
label: Other
description: "In case you chose 'Other' above, please provide more info here."
- type: textarea
id: descr
attributes:
label: Issue description
description: "Please accurately describe the documentation issue you are seeing."
validations:
required: true

View File

@ -0,0 +1,39 @@
name: Feature Request
description: "Suggest an idea for this project"
#title: ""
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this feature request!
- type: textarea
id: problem
attributes:
label: "Is your feature request related to a problem? Please describe."
description: "A clear and concise description of what the problem is. Ex. I'm always frustrated when..."
- type: textarea
id: solution
attributes:
label: "Describe the solution you'd like"
description: "A clear and concise description of what you want to happen."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: "Describe alternatives you've considered"
description: "A clear and concise description of any alternative solutions or features you've considered."
- type: textarea
id: additional
attributes:
label: "Additional context"
description: "Add any other context or screenshots about the feature request here."
- type: checkboxes
attributes:
label: "Contribute"
description: "Are you willing and able to help develop this feature?"
options:
- label: "Yes"
- label: "Partly"
- label: "No"

82
.github/ISSUE_TEMPLATE/help_request.yml vendored Normal file
View File

@ -0,0 +1,82 @@
name: Help request
description: "If there is anything wrong with your setup"
#title: ""
labels: ["setup issue"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this help request!
- type: textarea
id: issue
attributes:
label: Issue
description: "Please describe your problem here."
validations:
required: true
- type: input
id: version
attributes:
label: Tandoor Version
description: "What version of Tandoor are you using? (can be found on the system page since v0.8.4)"
validations:
required: true
- type: input
id: os
attributes:
label: OS Version
description: "E.g. Ubuntu 20.02"
validations:
required: true
- type: dropdown
id: setup
attributes:
label: Setup
description: "How is your Tandoor instance set up?"
options:
- Docker / Docker-Compose
- Unraid
- Synology
- Kubernetes
- Manual Setup
- Others (please state below)
validations:
required: true
- type: dropdown
id: reverse-proxy
attributes:
label: "Reverse Proxy"
description: "What reverse proxy do you use with Tandoor?"
options:
- No reverse proxy
- jwilder's nginx proxy
- Nginx Proxy Manager (NPM)
- SWAG
- Caddy
- Traefik
- Others (please state below)
validations:
required: true
- type: input
id: other
attributes:
label: Other
description: "In case you chose 'Others' above or have more info, please provide additional details here."
- type: textarea
id: env
attributes:
label: Environment file
description: "Please include your `.env` config file (**make sure to remove/replace all secrets**)"
render: shell
- type: textarea
id: docker-compose
attributes:
label: Docker-Compose file
description: "When running with docker compose please provide your `docker-compose.yml`"
render: shell
- type: textarea
id: logs
attributes:
label: Relevant logs
description: "If you feel like there is anything interesting please post the output of `docker-compose logs` at container startup and when the issue happens."
render: shell

View File

@ -0,0 +1,36 @@
name: Website Import
description: "Anything related to website imports"
#title: ""
#labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this website import form!
- type: input
id: version
attributes:
label: Tandoor Version
description: "What version of Tandoor are you using? (can be found on the system page since v0.8.4)"
validations:
required: true
- type: input
id: url
attributes:
label: Import URL
description: "Exact URL you are trying to import from."
validations:
required: true
- type: textarea
id: bug-descr
attributes:
label: "When did the issue happen?"
description: "When pressing the search button with the url / when importing after the page has loaded / ..."
validations:
required: true
- type: textarea
id: logs
attributes:
label: Response / message shown
description: Please copy and paste any relevant logs or responses / messages which are shown in Tandoor. This will be automatically formatted into code, so no need for backticks.
render: shell

View File

@ -5,7 +5,12 @@
version: 2
updates:
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "daily"
interval: "monthly"
- package-ecosystem: "npm"
directory: "/vue/"
schedule:
interval: "monthly"

View File

@ -1,24 +1,35 @@
name: Continous Integration
name: Continuous Integration
on: [push]
jobs:
build:
if: github.repository_owner == 'TandoorRecipes'
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: [3.9]
python-version: ['3.10']
steps:
- uses: actions/checkout@v1
- name: Set up Python 3.9
- name: Set up Python 3.10
uses: actions/setup-python@v1
with:
python-version: 3.9
- name: Install dependencies
python-version: '3.10'
# Build Vue frontend
- uses: actions/setup-node@v2
with:
node-version: '14'
- name: Install Vue dependencies
working-directory: ./vue
run: yarn install
- name: Build Vue dependencies
working-directory: ./vue
run: yarn build
- name: Install Django dependencies
run: |
sudo apt-get install -y libsasl2-dev python-dev libldap2-dev libssl-dev
python -m pip install --upgrade pip
pip install -r requirements.txt
python3 manage.py collectstatic --noinput

View File

@ -8,9 +8,8 @@ on:
jobs:
CodeQL-Build:
if: github.repository_owner == 'TandoorRecipes'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2

View File

@ -5,9 +5,11 @@ on:
- 'beta'
jobs:
build:
if: github.repository_owner == 'TandoorRecipes'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
# Update Version number
- name: Update version file
uses: DamianReeves/write-file-action@v1.0
with:
@ -16,6 +18,17 @@ jobs:
VERSION_NUMBER = 'beta'
BUILD_REF = '${{ github.sha }}'
write-mode: overwrite
# Build Vue frontend
- uses: actions/setup-node@v2
with:
node-version: '14'
- name: Install dependencies
working-directory: ./vue
run: yarn install
- name: Build dependencies
working-directory: ./vue
run: yarn build
# Build container
- name: Build and publish image
uses: ilteoood/docker_buildx@master
with:
@ -23,4 +36,11 @@ jobs:
imageName: vabene1111/recipes
tag: beta
dockerHubUser: ${{ secrets.DOCKER_USERNAME }}
dockerHubPassword: ${{ secrets.DOCKER_PASSWORD }}
dockerHubPassword: ${{ secrets.DOCKER_PASSWORD }}
# Send discord notification
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_BETA_WEBHOOK }}
uses: Ilshidur/action-discord@0.3.2
with:
args: '🚀 The BETA Image has been updated! 🥳'

View File

@ -7,9 +7,11 @@ on:
- '!master'
jobs:
build:
if: github.repository_owner == 'TandoorRecipes'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
# Update Version number
- name: Update version file
uses: DamianReeves/write-file-action@v1.0
with:
@ -18,6 +20,20 @@ jobs:
VERSION_NUMBER = 'develop'
BUILD_REF = '${{ github.sha }}'
write-mode: overwrite
# Build Vue frontend
- uses: actions/setup-node@v2
with:
node-version: '14'
- name: Clear Cache
working-directory: ./vue
run: yarn cache clean --all
- name: Install dependencies
working-directory: ./vue
run: yarn install
- name: Build dependencies
working-directory: ./vue
run: yarn build
# Build container
- name: Publish to Registry
uses: elgohr/Publish-Docker-Github-Action@2.13
with:

View File

@ -6,12 +6,14 @@ on:
jobs:
build:
if: github.repository_owner == 'TandoorRecipes'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Get version number
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}
# Update Version number
- name: Update version file
uses: DamianReeves/write-file-action@v1.0
with:
@ -20,6 +22,17 @@ jobs:
VERSION_NUMBER = '${{ steps.get_version.outputs.VERSION }}'
BUILD_REF = '${{ github.sha }}'
write-mode: overwrite
# Build Vue frontend
- uses: actions/setup-node@v2
with:
node-version: '14'
- name: Install dependencies
working-directory: ./vue
run: yarn install
- name: Build dependencies
working-directory: ./vue
run: yarn build
# Build container
- name: Build and publish image
uses: ilteoood/docker_buildx@master
with:

View File

@ -1,12 +1,12 @@
name: publish tagged release docker
on:
push:
tags:
- '*'
release:
types: [published]
jobs:
build:
if: github.repository_owner == 'TandoorRecipes'
runs-on: ubuntu-latest
name: Build image job
steps:
@ -15,6 +15,7 @@ jobs:
- name: Get version number
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}
# Update Version number
- name: Update version file
uses: DamianReeves/write-file-action@v1.0
with:
@ -23,6 +24,17 @@ jobs:
VERSION_NUMBER = '${{ steps.get_version.outputs.VERSION }}'
BUILD_REF = '${{ github.sha }}'
write-mode: overwrite
# Build Vue frontend
- uses: actions/setup-node@v2
with:
node-version: '14'
- name: Install dependencies
working-directory: ./vue
run: yarn install
- name: Build dependencies
working-directory: ./vue
run: yarn build
# Build container
- name: Build and publish image
uses: ilteoood/docker_buildx@master
with:
@ -31,3 +43,10 @@ jobs:
tag: ${{ steps.get_version.outputs.VERSION }}
dockerHubUser: ${{ secrets.DOCKER_USERNAME }}
dockerHubPassword: ${{ secrets.DOCKER_PASSWORD }}
# Send discord notification
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
uses: Ilshidur/action-discord@0.3.2
with:
args: '🚀 Version {{ EVENT_PAYLOAD.release.tag_name }} of tandoor has been released 🥳 Check it out https://github.com/vabene1111/recipes/releases/tag/{{ EVENT_PAYLOAD.release.tag_name }}'

View File

@ -7,11 +7,12 @@ on:
jobs:
deploy:
if: github.repository_owner == 'TandoorRecipes'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: 3.x
- run: pip install mkdocs-material
- run: pip install mkdocs-material mkdocs-include-markdown-plugin
- run: mkdocs gh-deploy --force

5
.gitignore vendored
View File

@ -79,5 +79,8 @@ postgresql/
/docker-compose.override.yml
vue/node_modules
.vscode/
vue/yarn.lock
vetur.config.js
cookbook/static/vue
vue/webpack-stats.json
cookbook/templates/sw.js
.prettierignore

View File

@ -1,31 +0,0 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: local
hooks:
- id: pre-commit-yarn-build
name: Build javascript files
entry: yarn --cwd ./vue build
always_run: true
language: system
types: [ python ]
pass_filenames: false
#- id: pre-commit-django-migrations
# name: Check django migrations
# entry: bash -c './venv/bin/activate && ./manage.py makemigrations --check'
# language: system
# types: [ python ]
# pass_filenames: false
# - id: pre-commit-django-make-messages
# name: Make messages if necessary
# entry: ./manage.py makemessages -i venv -a
# language: system
# types: [ python ]
# pass_filenames: false
# - id: pre-commit-django-compile-messages
# name: Compile messages if necessary
# entry: ./manage.py compilemessages -i venv
# language: system
# types: [ python ]
# pass_filenames: false

View File

@ -1,7 +1,7 @@
FROM python:3.9-alpine3.12
FROM python:3.10-alpine3.15
#Install all dependencies.
RUN apk add --no-cache postgresql-libs gettext zlib libjpeg libxml2-dev libxslt-dev py-cryptography
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev py-cryptography
#Print all logs without buffering it.
ENV PYTHONUNBUFFERED 1
@ -15,7 +15,8 @@ WORKDIR /opt/recipes
COPY requirements.txt ./
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libressl-dev libffi-dev cargo && \
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev libressl-dev libffi-dev cargo openssl-dev openldap-dev && \
echo -n "INPUT ( libldap.so )" > /usr/lib/libldap_r.so && \
python -m venv venv && \
/opt/recipes/venv/bin/python -m pip install --upgrade pip && \
venv/bin/pip install wheel==0.36.2 && \
@ -25,4 +26,4 @@ RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-de
#Copy project and execute it.
COPY . ./
RUN chmod +x boot.sh
ENTRYPOINT ["/opt/recipes/boot.sh"]
ENTRYPOINT ["/opt/recipes/boot.sh"]

View File

@ -1,6 +1,6 @@
<h1 align="center">
<br>
<a href="https://app.tandoor.dev"><img src="https://github.com/vabene1111/recipes/raw/develop/docs/logo_color.svg" height="256px" width="256px"></a>
<a href="https://tandoor.dev"><img src="https://github.com/vabene1111/recipes/raw/develop/docs/logo_color.svg" height="256px" width="256px"></a>
<br>
Tandoor Recipes
<br>
@ -9,53 +9,83 @@
<h4 align="center">The recipe manager that allows you to manage your ever growing collection of digital recipes.</h4>
<p align="center">
<a href="https://github.com/vabene1111/recipes/actions" target="_blank" rel="noopener noreferrer"><img src="https://github.com/vabene1111/recipes/workflows/Continous%20Integration/badge.svg?branch=master" ></a>
<a href="https://github.com/vabene1111/recipes/actions" target="_blank" rel="noopener noreferrer"><img src="https://github.com/vabene1111/recipes/workflows/Continuous%20Integration/badge.svg?branch=master" ></a>
<a href="https://github.com/vabene1111/recipes/stargazers" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/stars/vabene1111/recipes" ></a>
<a href="https://github.com/vabene1111/recipes/network/members" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/forks/vabene1111/recipes" ></a>
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer"><img src="https://badgen.net/badge/icon/discord?icon=discord&label" ></a>
<a href="https://hub.docker.com/r/vabene1111/recipes" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/docker/pulls/vabene1111/recipes" ></a>
<a href="https://github.com/vabene1111/recipes/releases/latest" rel="noopener noreferrer"><img src="https://img.shields.io/github/v/release/vabene1111/recipes" ></a>
<a href="https://app.tandoor.dev/accounts/login/?demo" rel="noopener noreferrer"><img src="https://img.shields.io/badge/demo-available-success" ></a>
</p>
<p align="center">
<a href="https://docs.tandoor.dev/install/docker.html" target="_blank" rel="noopener noreferrer">Installation</a>
<a href="https://docs.tandoor.dev/" target="_blank" rel="noopener noreferrer">Documentation</a>
<a href="https://app.tandoor.dev/accounts/login/?demo" target="_blank" rel="noopener noreferrer">Demo</a>
<a href="https://tandoor.dev" target="_blank" rel="noopener noreferrer">Website</a>
<a href="https://docs.tandoor.dev/install/docker/" target="_blank" rel="noopener noreferrer">Installation</a>
<a href="https://docs.tandoor.dev/" target="_blank" rel="noopener noreferrer">Docs</a>
<a href="https://app.tandoor.dev/accounts/login/?demo" target="_blank" rel="noopener noreferrer">Demo</a>
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer">Discord</a>
</p>
![Preview](docs/preview.png)
# Your Feedback
## Core Features
- 🥗 **Manage your recipes** - Manage your ever growing recipe collection
- 📆 **Plan** - multiple meals for each day
- 🛒 **Shopping lists** - via the meal plan or straight from recipes
- 📚 **Cookbooks** - collect recipes into books
- 👪 **Share and collaborate** on recipes with friends and family
Share some information on how you use Tandoor to help me improve the application [Google Survey](https://forms.gle/qNfLK2tWTeWHe9Qd7)
## Made by and for power users
## Features
- 📦 **Sync** files with Dropbox and Nextcloud (more can easily be added)
- 🔍 Powerful **search** with Djangos [TrigramSimilarity](https://docs.djangoproject.com/en/3.0/ref/contrib/postgres/search/#trigram-similarity)
- 🔍 Powerful & customizable **search** with fulltext support and [TrigramSimilarity](https://docs.djangoproject.com/en/3.0/ref/contrib/postgres/search/#trigram-similarity)
- 🏷️ Create and search for **tags**, assign them in batch to all files matching certain filters
- 📄 **Create recipes** locally within a nice, standardized web interface
- ⬇️ **Import recipes** from thousands of websites supporting [ld+json or microdata](https://schema.org/Recipe)
- 📱 Optimized for use on **mobile** devices like phones and tablets
- 🛒 Generate **shopping** lists from recipes
- 📆 Create a **Plan** on what to eat when
- 👪 **Share** recipes with friends and comment on them to suggest or remember changes you made
- ➗ automatically convert decimal units to **fractions** for those who like this
- 🐳 Easy setup with **Docker** and included examples for Kubernetes, Unraid and Synology
- ↔️ Quickly merge and rename ingredients, tags and units
- 📥️ **Import recipes** from thousands of websites supporting [ld+json or microdata](https://schema.org/Recipe)
- ➗ Support for **fractions** or decimals
- 🐳 Easy setup with **Docker** and included examples for **Kubernetes**, **Unraid** and **Synology**
- 🎨 Customize your interface with **themes**
- ✉️ Export and import recipes from other users
- 📦 **Sync** files with Dropbox and Nextcloud
## All the must haves
- 📱Optimized for use on **mobile** devices
- 🌍 localized in many languages thanks to the awesome community
- Many more like recipe scaling, image compression, cookbooks, printing views, ...
- 📥️ **Import your collection** from many other [recipe managers](https://docs.tandoor.dev/features/import_export/)
- Many more like recipe scaling, image compression, printing views and supermarkets
This application is meant for people with a collection of recipes they want to share with family and friends or simply
store them in a nicely organized way. A basic permission system exists but this application is not meant to be run as
a public page.
## Docs
Documentation can be found [here](https://docs.tandoor.dev/).
While this application has been around for a while and is actively used by many (including myself), it is still considered
**beta** software that has a lot of rough edges and unpolished parts.
## Contributing
You can help out with the ongoing development by looking for potential bugs in our code base, or by contributing new features. We are always welcoming new pull requests containing bug fixes, refactors and new features. We have a list of tasks and bugs on our issue tracker on Github. Please comment on issues if you want to contribute with, to avoid duplicating effort.
## Your Feedback
Share some information on how you use Tandoor to help me improve the application [Google Survey](https://forms.gle/qNfLK2tWTeWHe9Qd7)
## Get in touch
<table>
<tr>
<td><a href="https://discord.gg/RhzBrfWgtp">Discord</a></td>
<td>We have a public Discord server that anyone can join. This is where all our developers and contributors hang out and where we make announcements</td>
</tr>
<tr>
<td><a href="https://twitter.com/TandoorRecipes">Twitter</a></td>
<td>You can follow our Twitter account to get updates on new features or releases</td>
</tr>
</table>
## License
Beginning with version 0.10.0 the code in this repository is licensed under the [GNU AGPL v3](https://www.gnu.org/licenses/agpl-3.0.de.html) license with an
Beginning with version 0.10.0 the code in this repository is licensed under the [GNU AGPL v3](https://www.gnu.org/licenses/agpl-3.0.de.html) license with a
[common clause](https://commonsclause.com/) selling exception. See [LICENSE.md](https://github.com/vabene1111/recipes/blob/develop/LICENSE.md) for details.
> NOTE: There appears to be a whole range of legal issues with licensing anything else then the standard completely open licenses.
@ -66,8 +96,8 @@ Beginning with version 0.10.0 the code in this repository is licensed under the
**This software and *all* its features are and will always be free for everyone to use and enjoy.**
The reason for the selling exception is that a significant amount of time was spend over multiple years to develop this software.
A payed hosted version which will be identical in features and code base to the software offered in this repository will
A paid hosted version which will be identical in features and code base to the software offered in this repository will
likely be released in the future (including all features needed to sell a hosted version as they might also be useful for personal use).
This will not only benefit me personally but also everyone who self-hosts this software as any profits made trough selling the hosted option
This will not only benefit me personally but also everyone who self-hosts this software as any profits made through selling the hosted option
allow me to spend more time developing and improving the software for everyone. Selling exceptions are [approved by Richard Stallman](http://www.gnu.org/philosophy/selling-exceptions.en.html) and the
common clause license is very permissive (see the [FAQ](https://commonsclause.com/)).

View File

@ -7,4 +7,4 @@ Since this software is still considered beta/WIP support is always only given fo
## Reporting a Vulnerability
Please open a normal public issue if you have any security related concerns. If you feel like the issue should not be discussed in
public just open a generic issue and we will discuss further communitcation there (since GitHub does not allow everyone to create a security advisory :/).
public just open a generic issue and we will discuss further communication there (since GitHub does not allow everyone to create a security advisory :/).

29
boot.sh
View File

@ -1,14 +1,39 @@
#!/bin/sh
source venv/bin/activate
TANDOOR_PORT="${TANDOOR_PORT:-8080}"
echo "Updating database"
echo "Waiting for database to be ready..."
attempt=0
max_attempts=20
while pg_isready --host=${POSTGRES_HOST} -q; status=$?; attempt=$((attempt+1)); [ $status -ne 0 ] && [ $attempt -le $max_attempts ]; do
sleep 5
done
if [ $attempt -gt $max_attempts ]; then
echo -e "\nDatabase not reachable. Maximum attempts exceeded."
echo "Please check logs above - misconfiguration is very likely."
echo "Make sure the DB container is up and POSTGRES_HOST is set properly."
echo "Shutting down container."
exit 1 # exit with error to make the container stop
fi
echo "Database is ready"
echo "Migrating database"
python manage.py migrate
echo "Generating static files"
python manage.py collectstatic_js_reverse
python manage.py collectstatic --noinput
echo "Done"
chmod -R 755 /opt/recipes/mediafiles
exec gunicorn -b :$TANDOOR_PORT --access-logfile - --error-logfile - --log-level INFO recipes.wsgi
exec gunicorn -b :$TANDOOR_PORT --access-logfile - --error-logfile - --log-level INFO recipes.wsgi

View File

@ -1,23 +1,22 @@
from django.conf import settings
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import Group, User
from django.contrib.postgres.search import SearchVector
from django.utils import translation
from django_scopes import scopes_disabled
from treebeard.admin import TreeAdmin
from treebeard.forms import movenodeform_factory
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import User, Group
from django_scopes import scopes_disabled
from django.utils import translation
from .models import (Comment, CookLog, Food, Ingredient, InviteLink, Keyword,
MealPlan, MealType, NutritionInformation, Recipe,
RecipeBook, RecipeBookEntry, RecipeImport, ShareLink,
ShoppingList, ShoppingListEntry, ShoppingListRecipe,
Space, Step, Storage, Sync, SyncLog, Unit, UserPreference,
ViewLog, Supermarket, SupermarketCategory, SupermarketCategoryRelation,
ImportLog, TelegramBot, BookmarkletImport, UserFile)
from cookbook.managers import DICTIONARY
from .models import (BookmarkletImport, Comment, CookLog, Food, FoodInheritField, ImportLog,
Ingredient, InviteLink, Keyword, MealPlan, MealType, NutritionInformation,
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink,
ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
TelegramBot, Unit, UserFile, UserPreference, ViewLog, Automation)
class CustomUserAdmin(UserAdmin):
def has_add_permission(self, request, obj=None):
@ -30,11 +29,52 @@ admin.site.register(User, CustomUserAdmin)
admin.site.unregister(Group)
@admin.action(description='Delete all data from a space')
def delete_space_action(modeladmin, request, queryset):
for space in queryset:
CookLog.objects.filter(space=space).delete()
ViewLog.objects.filter(space=space).delete()
ImportLog.objects.filter(space=space).delete()
BookmarkletImport.objects.filter(space=space).delete()
Comment.objects.filter(recipe__space=space).delete()
Keyword.objects.filter(space=space).delete()
Ingredient.objects.filter(space=space).delete()
Food.objects.filter(space=space).delete()
Unit.objects.filter(space=space).delete()
Step.objects.filter(space=space).delete()
NutritionInformation.objects.filter(space=space).delete()
RecipeBookEntry.objects.filter(book__space=space).delete()
RecipeBook.objects.filter(space=space).delete()
MealType.objects.filter(space=space).delete()
MealPlan.objects.filter(space=space).delete()
ShareLink.objects.filter(space=space).delete()
Recipe.objects.filter(space=space).delete()
RecipeImport.objects.filter(space=space).delete()
SyncLog.objects.filter(sync__space=space).delete()
Sync.objects.filter(space=space).delete()
Storage.objects.filter(space=space).delete()
ShoppingListEntry.objects.filter(shoppinglist__space=space).delete()
ShoppingListRecipe.objects.filter(shoppinglist__space=space).delete()
ShoppingList.objects.filter(space=space).delete()
SupermarketCategoryRelation.objects.filter(supermarket__space=space).delete()
SupermarketCategory.objects.filter(space=space).delete()
Supermarket.objects.filter(space=space).delete()
InviteLink.objects.filter(space=space).delete()
UserFile.objects.filter(space=space).delete()
Automation.objects.filter(space=space).delete()
class SpaceAdmin(admin.ModelAdmin):
list_display = ('name', 'created_by', 'max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
search_fields = ('name', 'created_by__username')
list_filter = ('max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
date_hierarchy = 'created_at'
actions = [delete_space_action]
admin.site.register(Space, SpaceAdmin)
@ -54,6 +94,19 @@ class UserPreferenceAdmin(admin.ModelAdmin):
admin.site.register(UserPreference, UserPreferenceAdmin)
class SearchPreferenceAdmin(admin.ModelAdmin):
list_display = ('name', 'search', 'trigram_threshold',)
search_fields = ('user__username',)
list_filter = ('search',)
@staticmethod
def name(obj):
return obj.user.get_user_name()
admin.site.register(SearchPreference, SearchPreferenceAdmin)
class StorageAdmin(admin.ModelAdmin):
list_display = ('name', 'method')
search_fields = ('name',)
@ -89,28 +142,43 @@ class SyncLogAdmin(admin.ModelAdmin):
admin.site.register(SyncLog, SyncLogAdmin)
@admin.action(description='Temporarily ENABLE sorting on Foods and Keywords.')
def enable_tree_sorting(modeladmin, request, queryset):
Food.node_order_by = ['name']
Keyword.node_order_by = ['name']
with scopes_disabled():
Food.fix_tree(fix_paths=True)
Keyword.fix_tree(fix_paths=True)
@admin.action(description='Temporarily DISABLE sorting on Foods and Keywords.')
def disable_tree_sorting(modeladmin, request, queryset):
Food.node_order_by = []
Keyword.node_order_by = []
@admin.action(description='Fix problems and sort tree by name')
def sort_tree(modeladmin, request, queryset):
orginal_value = modeladmin.model.node_order_by[:]
modeladmin.model.node_order_by = ['name']
with scopes_disabled():
modeladmin.model.fix_tree(fix_paths=True)
modeladmin.model.node_order_by = orginal_value
class KeywordAdmin(TreeAdmin):
form = movenodeform_factory(Keyword)
ordering = ('space', 'path',)
# removing ability to delete keywords from admin
# to avoid creating orphaned keywords
# def get_actions(self, request):
# actions = super().get_actions(request)
# if 'delete_selected' in actions:
# del actions['delete_selected']
# return actions
# def has_delete_permission(self, request, obj=None):
# return False
search_fields = ('name',)
actions = [sort_tree, enable_tree_sorting, disable_tree_sorting]
admin.site.register(Keyword, KeywordAdmin)
class StepAdmin(admin.ModelAdmin):
list_display = ('name', 'type', 'order')
search_fields = ('name', 'type')
list_display = ('name', 'order',)
search_fields = ('name',)
admin.site.register(Step, StepAdmin)
@ -144,7 +212,19 @@ class RecipeAdmin(admin.ModelAdmin):
admin.site.register(Recipe, RecipeAdmin)
admin.site.register(Unit)
admin.site.register(Food)
# admin.site.register(FoodInheritField)
class FoodAdmin(TreeAdmin):
form = movenodeform_factory(Keyword)
ordering = ('space', 'path',)
search_fields = ('name',)
actions = [sort_tree, enable_tree_sorting, disable_tree_sorting]
admin.site.register(Food, FoodAdmin)
class IngredientAdmin(admin.ModelAdmin):
@ -222,7 +302,7 @@ admin.site.register(ViewLog, ViewLogAdmin)
class InviteLinkAdmin(admin.ModelAdmin):
list_display = (
'group', 'valid_until',
'group', 'valid_until', 'space',
'created_by', 'created_at', 'used_by'
)
@ -245,7 +325,7 @@ admin.site.register(ShoppingListRecipe, ShoppingListRecipeAdmin)
class ShoppingListEntryAdmin(admin.ModelAdmin):
list_display = ('id', 'food', 'unit', 'list_recipe', 'checked')
list_display = ('id', 'food', 'unit', 'list_recipe', 'created_by', 'created_at', 'checked')
admin.site.register(ShoppingListEntry, ShoppingListEntryAdmin)

View File

@ -1,11 +1,37 @@
import traceback
from django.apps import AppConfig
from django.conf import settings
from django.db import OperationalError, ProgrammingError
from django_scopes import scopes_disabled
from recipes.settings import DEBUG
class CookbookConfig(AppConfig):
name = 'cookbook'
def ready(self):
# post_save signal is only necessary if using full-text search on postgres
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']:
import cookbook.signals # noqa
import cookbook.signals # noqa
# if not settings.DISABLE_TREE_FIX_STARTUP:
# # when starting up run fix_tree to:
# # a) make sure that nodes are sorted when switching between sort modes
# # b) fix problems, if any, with tree consistency
# with scopes_disabled():
# try:
# from cookbook.models import Food, Keyword
# Keyword.fix_tree(fix_paths=True)
# Food.fix_tree(fix_paths=True)
# except OperationalError:
# if DEBUG:
# traceback.print_exc()
# pass # if model does not exist there is no need to fix it
# except ProgrammingError:
# if DEBUG:
# traceback.print_exc()
# pass # if migration has not been run database cannot be fixed yet
# except Exception:
# if DEBUG:
# traceback.print_exc()
# pass # dont break startup just because fix could not run, need to investigate cases when this happens

View File

@ -1,5 +1,7 @@
import django_filters
from django.conf import settings
from django.contrib.postgres.search import TrigramSimilarity
from django.db.models import Q
from django.utils.translation import gettext as _
from django_scopes import scopes_disabled
@ -59,12 +61,12 @@ with scopes_disabled():
model = Recipe
fields = ['name', 'keywords', 'foods', 'internal']
class FoodFilter(django_filters.FilterSet):
name = django_filters.CharFilter(lookup_expr='icontains')
# class FoodFilter(django_filters.FilterSet):
# name = django_filters.CharFilter(lookup_expr='icontains')
class Meta:
model = Food
fields = ['name']
# class Meta:
# model = Food
# fields = ['name']
class ShoppingListFilter(django_filters.FilterSet):

View File

@ -1,18 +1,16 @@
from datetime import datetime
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.forms import widgets
from django.forms import NumberInput, widgets
from django.utils.translation import gettext_lazy as _
from django_scopes import scopes_disabled
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
from emoji_picker.widgets import EmojiPickerTextInput
from treebeard.forms import MoveNodeForm
from hcaptcha.fields import hCaptchaField
from .models import (Comment, Food, InviteLink, Keyword, MealPlan, Recipe,
RecipeBook, RecipeBookEntry, Storage, Sync, Unit, User,
UserPreference, SupermarketCategory, MealType, Space,
SearchPreference)
from .models import (Comment, Food, InviteLink, Keyword, MealPlan, MealType, Recipe, RecipeBook,
RecipeBookEntry, SearchPreference, Space, Storage, Sync, User, UserPreference)
class SelectWidget(widgets.Select):
@ -38,15 +36,38 @@ class DateWidget(forms.DateInput):
class UserPreferenceForm(forms.ModelForm):
prefix = 'preference'
def __init__(self, *args, **kwargs):
if x := kwargs.get('instance', None):
space = x.space
else:
space = kwargs.pop('space')
super().__init__(*args, **kwargs)
self.fields['plan_share'].queryset = User.objects.filter(userpreference__space=space).all()
class Meta:
model = UserPreference
fields = (
'default_unit', 'use_fractions', 'theme', 'nav_color',
'default_unit', 'use_fractions', 'use_kj', 'theme', 'nav_color',
'sticky_navbar', 'default_page', 'show_recent', 'search_style',
'plan_share', 'ingredient_decimals', 'shopping_auto_sync',
'comments'
'plan_share', 'ingredient_decimals', 'comments',
)
labels = {
'default_unit': _('Default unit'),
'use_fractions': _('Use fractions'),
'use_kj': _('Use KJ'),
'theme': _('Theme'),
'nav_color': _('Navbar color'),
'sticky_navbar': _('Sticky navbar'),
'default_page': _('Default page'),
'show_recent': _('Show recent recipes'),
'search_style': _('Search style'),
'plan_share': _('Plan sharing'),
'ingredient_decimals': _('Ingredient decimal places'),
'shopping_auto_sync': _('Shopping list auto sync period'),
'comments': _('Comments')
}
help_texts = {
'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!'),
# noqa: E501
@ -54,8 +75,9 @@ class UserPreferenceForm(forms.ModelForm):
'use_fractions': _(
'Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'),
# noqa: E501
'plan_share': _(
'Users with whom newly created meal plan/shopping list entries should be shared by default.'),
'use_kj': _('Display nutritional energy amounts in joules instead of calories'), # noqa: E501
'plan_share': _('Users with whom newly created meal plans should be shared by default.'),
'shopping_share': _('Users with whom to share shopping lists.'),
# noqa: E501
'show_recent': _('Show recently viewed recipes on search page.'), # noqa: E501
'ingredient_decimals': _('Number of decimals to round ingredients.'), # noqa: E501
@ -64,11 +86,14 @@ class UserPreferenceForm(forms.ModelForm):
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' # noqa: E501
'of mobile data. If lower than instance limit it is reset when saving.' # noqa: E501
),
'sticky_navbar': _('Makes the navbar stick to the top of the page.') # noqa: E501
'sticky_navbar': _('Makes the navbar stick to the top of the page.'), # noqa: E501
'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'),
'mealplan_autoexclude_onhand': _('Exclude ingredients that are on hand.'),
}
widgets = {
'plan_share': MultiSelectWidget
'plan_share': MultiSelectWidget,
'shopping_share': MultiSelectWidget,
}
@ -120,7 +145,7 @@ class ImportExportBase(forms.Form):
NEXTCLOUD = 'NEXTCLOUD'
MEALIE = 'MEALIE'
CHOWDOWN = 'CHOWDOWN'
SAFRON = 'SAFRON'
SAFFRON = 'SAFFRON'
CHEFTAP = 'CHEFTAP'
PEPPERPLATE = 'PEPPERPLATE'
RECIPEKEEPER = 'RECIPEKEEPER'
@ -130,13 +155,17 @@ class ImportExportBase(forms.Form):
MEALMASTER = 'MEALMASTER'
REZKONV = 'REZKONV'
OPENEATS = 'OPENEATS'
PLANTOEAT = 'PLANTOEAT'
COOKBOOKAPP = 'COOKBOOKAPP'
COPYMETHAT = 'COPYMETHAT'
PDF = 'PDF'
type = forms.ChoiceField(choices=(
(DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'),
(MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'), (SAFRON, 'Safron'), (CHEFTAP, 'ChefTap'),
(MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'), (SAFFRON, 'Saffron'), (CHEFTAP, 'ChefTap'),
(PEPPERPLATE, 'Pepperplate'), (RECETTETEK, 'RecetteTek'), (RECIPESAGE, 'Recipe Sage'), (DOMESTICA, 'Domestica'),
(MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'), (OPENEATS, 'Openeats'), (RECIPEKEEPER, 'Recipe Keeper'),
(PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'), (COPYMETHAT, 'CopyMeThat'), (PDF, 'PDF'),
))
@ -148,7 +177,7 @@ class ImportForm(ImportExportBase):
class ExportForm(ImportExportBase):
recipes = forms.ModelMultipleChoiceField(widget=MultiSelectWidget, queryset=Recipe.objects.none())
recipes = forms.ModelMultipleChoiceField(widget=MultiSelectWidget, queryset=Recipe.objects.none(), required=False)
all = forms.BooleanField(required=False)
def __init__(self, *args, **kwargs):
@ -157,52 +186,6 @@ class ExportForm(ImportExportBase):
self.fields['recipes'].queryset = Recipe.objects.filter(space=space).all()
class UnitMergeForm(forms.Form):
prefix = 'unit'
new_unit = SafeModelChoiceField(
queryset=Unit.objects.none(),
widget=SelectWidget,
label=_('New Unit'),
help_text=_('New unit that other gets replaced by.'),
)
old_unit = SafeModelChoiceField(
queryset=Unit.objects.none(),
widget=SelectWidget,
label=_('Old Unit'),
help_text=_('Unit that should be replaced.'),
)
def __init__(self, *args, **kwargs):
space = kwargs.pop('space')
super().__init__(*args, **kwargs)
self.fields['new_unit'].queryset = Unit.objects.filter(space=space).all()
self.fields['old_unit'].queryset = Unit.objects.filter(space=space).all()
class FoodMergeForm(forms.Form):
prefix = 'food'
new_food = SafeModelChoiceField(
queryset=Food.objects.none(),
widget=SelectWidget,
label=_('New Food'),
help_text=_('New food that other gets replaced by.'),
)
old_food = SafeModelChoiceField(
queryset=Food.objects.none(),
widget=SelectWidget,
label=_('Old Food'),
help_text=_('Food that should be replaced.'),
)
def __init__(self, *args, **kwargs):
space = kwargs.pop('space')
super().__init__(*args, **kwargs)
self.fields['new_food'].queryset = Food.objects.filter(space=space).all()
self.fields['old_food'].queryset = Food.objects.filter(space=space).all()
class CommentForm(forms.ModelForm):
prefix = 'comment'
@ -218,33 +201,6 @@ class CommentForm(forms.ModelForm):
}
class KeywordForm(MoveNodeForm):
class Meta:
model = Keyword
fields = ('name', 'icon', 'description')
exclude = ('sib_order', 'parent', 'path', 'depth', 'numchild')
widgets = {'icon': EmojiPickerTextInput}
class FoodForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
space = kwargs.pop('space')
super().__init__(*args, **kwargs)
self.fields['recipe'].queryset = Recipe.objects.filter(space=space).all()
self.fields['supermarket_category'].queryset = SupermarketCategory.objects.filter(space=space).all()
class Meta:
model = Food
fields = ('name', 'description', 'ignore_shopping', 'recipe', 'supermarket_category')
widgets = {'recipe': SelectWidget}
field_classes = {
'recipe': SafeModelChoiceField,
'supermarket_category': SafeModelChoiceField,
}
class StorageForm(forms.ModelForm):
username = forms.CharField(
widget=forms.TextInput(attrs={'autocomplete': 'new-password'}),
@ -273,6 +229,7 @@ class StorageForm(forms.ModelForm):
}
# TODO: Deprecate
class RecipeBookEntryForm(forms.ModelForm):
prefix = 'bookmark'
@ -305,7 +262,14 @@ class SyncForm(forms.ModelForm):
'storage': SafeModelChoiceField,
}
labels = {
'storage': _('Storage'),
'path': _('Path'),
'active': _('Active')
}
# TODO deprecate
class BatchEditForm(forms.Form):
search = forms.CharField(label=_('Search String'))
keywords = forms.ModelMultipleChoiceField(
@ -342,21 +306,7 @@ class ImportRecipeForm(forms.ModelForm):
}
class RecipeBookForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
space = kwargs.pop('space')
super().__init__(*args, **kwargs)
self.fields['shared'].queryset = User.objects.filter(userpreference__space=space).all()
class Meta:
model = RecipeBook
fields = ('name', 'icon', 'description', 'shared')
widgets = {'icon': EmojiPickerTextInput, 'shared': MultiSelectWidget}
field_classes = {
'shared': SafeModelMultipleChoiceField,
}
# TODO deprecate
class MealPlanForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
space = kwargs.pop('space')
@ -408,7 +358,8 @@ class InviteLinkForm(forms.ModelForm):
def clean(self):
space = self.cleaned_data['space']
if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() + InviteLink.objects.filter(space=space).count()) >= space.max_users:
if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() +
InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=space).count()) >= space.max_users:
raise ValidationError(_('Maximum number of users for this space reached.'))
def clean_email(self):
@ -423,7 +374,7 @@ class InviteLinkForm(forms.ModelForm):
model = InviteLink
fields = ('email', 'group', 'valid_until', 'space')
help_texts = {
'email': _('An email address is not required but if present the invite link will be send to the user.'),
'email': _('An email address is not required but if present the invite link will be sent to the user.'),
}
field_classes = {
'space': SafeModelChoiceField,
@ -478,19 +429,31 @@ class UserCreateForm(forms.Form):
class SearchPreferenceForm(forms.ModelForm):
prefix = 'search'
trigram_threshold = forms.DecimalField(min_value=0.01, max_value=1, decimal_places=2,
widget=NumberInput(attrs={'class': "form-control-range", 'type': 'range'}),
help_text=_(
'Determines how fuzzy a search is if it uses trigram similarity matching (e.g. low values mean more typos are ignored).'))
preset = forms.CharField(widget=forms.HiddenInput(), required=False)
class Meta:
model = SearchPreference
fields = ('search', 'lookup', 'unaccent', 'icontains', 'istartswith', 'trigram', 'fulltext')
fields = (
'search', 'lookup', 'unaccent', 'icontains', 'istartswith', 'trigram', 'fulltext', 'trigram_threshold')
help_texts = {
'search': _('Select type method of search. Click <a href="/docs/search/">here</a> for full desciption of choices.'),
'search': _(
'Select type method of search. Click <a href="/docs/search/">here</a> for full description of choices.'),
'lookup': _('Use fuzzy matching on units, keywords and ingredients when editing and importing recipes.'),
'unaccent': _('Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'),
'icontains': _("Fields to search for partial matches. (e.g. searching for 'Pie' will return 'pie' and 'piece' and 'soapie')"),
'istartswith': _("Fields to search for beginning of word matches. (e.g. searching for 'sa' will return 'salad' and 'sandwich')"),
'trigram': _("Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) Note: this option will conflict with 'web' and 'raw' methods of search."),
'fulltext': _("Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods only function with fulltext fields.")
'unaccent': _(
'Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'),
'icontains': _(
"Fields to search for partial matches. (e.g. searching for 'Pie' will return 'pie' and 'piece' and 'soapie')"),
'istartswith': _(
"Fields to search for beginning of word matches. (e.g. searching for 'sa' will return 'salad' and 'sandwich')"),
'trigram': _(
"Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) Note: this option will conflict with 'web' and 'raw' methods of search."),
'fulltext': _(
"Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods only function with fulltext fields."),
}
labels = {
@ -498,7 +461,7 @@ class SearchPreferenceForm(forms.ModelForm):
'lookup': _('Fuzzy Lookups'),
'unaccent': _('Ignore Accent'),
'icontains': _("Partial Match"),
'istartswith': _("Starts Wtih"),
'istartswith': _("Starts With"),
'trigram': _("Fuzzy Search"),
'fulltext': _("Full Text")
}
@ -511,3 +474,73 @@ class SearchPreferenceForm(forms.ModelForm):
'trigram': MultiSelectWidget,
'fulltext': MultiSelectWidget,
}
class ShoppingPreferenceForm(forms.ModelForm):
prefix = 'shopping'
class Meta:
model = UserPreference
fields = (
'shopping_share', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'mealplan_autoexclude_onhand',
'mealplan_autoinclude_related', 'shopping_add_onhand', 'default_delay', 'filter_to_supermarket', 'shopping_recent_days', 'csv_delim', 'csv_prefix'
)
help_texts = {
'shopping_share': _('Users will see all items you add to your shopping list. They must add you to see items on their list.'),
'shopping_auto_sync': _(
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' # noqa: E501
'of mobile data. If lower than instance limit it is reset when saving.' # noqa: E501
),
'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'),
'mealplan_autoinclude_related': _('When adding a meal plan to the shopping list (manually or automatically), include all related recipes.'),
'mealplan_autoexclude_onhand': _('When adding a meal plan to the shopping list (manually or automatically), exclude ingredients that are on hand.'),
'default_delay': _('Default number of hours to delay a shopping list entry.'),
'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'),
'shopping_recent_days': _('Days of recent shopping list entries to display.'),
'shopping_add_onhand': _("Mark food 'On Hand' when checked off shopping list."),
'csv_delim': _('Delimiter to use for CSV exports.'),
'csv_prefix': _('Prefix to add when copying list to the clipboard.'),
}
labels = {
'shopping_share': _('Share Shopping List'),
'shopping_auto_sync': _('Autosync'),
'mealplan_autoadd_shopping': _('Auto Add Meal Plan'),
'mealplan_autoexclude_onhand': _('Exclude On Hand'),
'mealplan_autoinclude_related': _('Include Related'),
'default_delay': _('Default Delay Hours'),
'filter_to_supermarket': _('Filter to Supermarket'),
'shopping_recent_days': _('Recent Days'),
'csv_delim': _('CSV Delimiter'),
"csv_prefix_label": _("List Prefix"),
'shopping_add_onhand': _("Auto On Hand"),
}
widgets = {
'shopping_share': MultiSelectWidget
}
class SpacePreferenceForm(forms.ModelForm):
prefix = 'space'
reset_food_inherit = forms.BooleanField(label=_("Reset Food Inheritance"), initial=False, required=False,
help_text=_("Reset all food to inherit the fields configured."))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) # populates the post
self.fields['food_inherit'].queryset = Food.inheritable_fields
class Meta:
model = Space
fields = ('food_inherit', 'reset_food_inherit', 'show_facet_count')
help_texts = {
'food_inherit': _('Fields on food that should be inherited by default.'),
'show_facet_count': _('Show recipe counts on search filters'), }
widgets = {
'food_inherit': MultiSelectWidget
}

View File

@ -0,0 +1,13 @@
from django.db.models import Func
class Round(Func):
function = 'ROUND'
template = '%(function)s(%(expressions)s, 0)'
def str2bool(v):
if type(v) == bool or v is None:
return v
else:
return v.lower() in ("yes", "true", "1")

View File

@ -5,7 +5,7 @@ from PIL import Image
from io import BytesIO
def rescale_image_jpeg(image_object, base_width=720):
def rescale_image_jpeg(image_object, base_width=1020):
img = Image.open(image_object)
icc_profile = img.info.get('icc_profile') # remember color profile to not mess up colors
width_percent = (base_width / float(img.size[0]))
@ -13,20 +13,20 @@ def rescale_image_jpeg(image_object, base_width=720):
img = img.resize((base_width, height), Image.ANTIALIAS)
img_bytes = BytesIO()
img.save(img_bytes, 'JPEG', quality=75, optimize=True, icc_profile=icc_profile)
img.save(img_bytes, 'JPEG', quality=90, optimize=True, icc_profile=icc_profile)
return img_bytes
def rescale_image_png(image_object, base_width=720):
basewidth = 720
wpercent = (basewidth / float(image_object.size[0]))
def rescale_image_png(image_object, base_width=1020):
image_object = Image.open(image_object)
wpercent = (base_width / float(image_object.size[0]))
hsize = int((float(image_object.size[1]) * float(wpercent)))
img = image_object.resize((basewidth, hsize), Image.ANTIALIAS)
img = image_object.resize((base_width, hsize), Image.ANTIALIAS)
im_io = BytesIO()
img.save(im_io, 'PNG', quality=70)
return img
img.save(im_io, 'PNG', quality=90)
return im_io
def get_filetype(name):
@ -36,9 +36,11 @@ def get_filetype(name):
return '.jpeg'
# TODO this whole file needs proper documentation, refactoring, and testing
# TODO also add env variable to define which images sizes should be compressed
def handle_image(request, image_object, filetype='.jpeg'):
if sys.getsizeof(image_object) / 8 > 500:
if filetype == '.jpeg':
if (image_object.size / 1000) > 500: # if larger than 500 kb compress
if filetype == '.jpeg' or filetype == '.jpg':
return rescale_image_jpeg(image_object), filetype
if filetype == '.png':
return rescale_image_png(image_object), filetype

View File

@ -2,193 +2,273 @@ import re
import string
import unicodedata
from cookbook.models import Unit, Food
from django.core.cache import caches
from cookbook.models import Unit, Food, Automation
def parse_fraction(x):
if len(x) == 1 and 'fraction' in unicodedata.decomposition(x):
frac_split = unicodedata.decomposition(x[-1:]).split()
return (float((frac_split[1]).replace('003', ''))
/ float((frac_split[3]).replace('003', '')))
else:
frac_split = x.split('/')
if not len(frac_split) == 2:
raise ValueError
try:
return int(frac_split[0]) / int(frac_split[1])
except ZeroDivisionError:
raise ValueError
class IngredientParser:
request = None
ignore_rules = False
food_aliases = {}
unit_aliases = {}
def parse_amount(x):
amount = 0
unit = ''
note = ''
did_check_frac = False
end = 0
while (end < len(x) and (x[end] in string.digits
or (
(x[end] == '.' or x[end] == ',' or x[end] == '/')
and end + 1 < len(x)
and x[end + 1] in string.digits
))):
end += 1
if end > 0:
if "/" in x[:end]:
amount = parse_fraction(x[:end])
else:
amount = float(x[:end].replace(',', '.'))
else:
amount = parse_fraction(x[0])
end += 1
did_check_frac = True
if end < len(x):
if did_check_frac:
unit = x[end:]
else:
try:
amount += parse_fraction(x[end])
unit = x[end + 1:]
except ValueError:
unit = x[end:]
if unit.startswith('(') or unit.startswith('-'): # i dont know any unit that starts with ( or - so its likely an alternative like 1L (500ml) Water or 2-3
unit = ''
note = x
return amount, unit, note
def parse_ingredient_with_comma(tokens):
ingredient = ''
note = ''
start = 0
# search for first occurrence of an argument ending in a comma
while start < len(tokens) and not tokens[start].endswith(','):
start += 1
if start == len(tokens):
# no token ending in a comma found -> use everything as ingredient
ingredient = ' '.join(tokens)
else:
ingredient = ' '.join(tokens[:start + 1])[:-1]
note = ' '.join(tokens[start + 1:])
return ingredient, note
def parse_ingredient(tokens):
ingredient = ''
note = ''
if tokens[-1].endswith(')'):
# Check if the matching opening bracket is in the same token
if (not tokens[-1].startswith('(')) and ('(' in tokens[-1]):
return parse_ingredient_with_comma(tokens)
# last argument ends with closing bracket -> look for opening bracket
start = len(tokens) - 1
while not tokens[start].startswith('(') and not start == 0:
start -= 1
if start == 0:
# the whole list is wrapped in brackets -> assume it is an error (e.g. assumed first argument was the unit) # noqa: E501
raise ValueError
elif start < 0:
# no opening bracket anywhere -> just ignore the last bracket
ingredient, note = parse_ingredient_with_comma(tokens)
else:
# opening bracket found -> split in ingredient and note, remove brackets from note # noqa: E501
note = ' '.join(tokens[start:])[1:-1]
ingredient = ' '.join(tokens[:start])
else:
ingredient, note = parse_ingredient_with_comma(tokens)
return ingredient, note
def parse(x):
# initialize default values
amount = 0
unit = ''
ingredient = ''
note = ''
unit_note = ''
# if the string contains parenthesis early on remove it and place it at the end
# because its likely some kind of note
if re.match('(.){1,6}\s\((.[^\(\)])+\)\s', x):
match = re.search('\((.[^\(])+\)', x)
x = x[:match.start()] + x[match.end():] + ' ' + x[match.start():match.end()]
tokens = x.split()
if len(tokens) == 1:
# there only is one argument, that must be the ingredient
ingredient = tokens[0]
else:
try:
# try to parse first argument as amount
amount, unit, unit_note = parse_amount(tokens[0])
# only try to parse second argument as amount if there are at least
# three arguments if it already has a unit there can't be
# a fraction for the amount
if len(tokens) > 2:
try:
if not unit == '':
# a unit is already found, no need to try the second argument for a fraction
# probably not the best method to do it, but I didn't want to make an if check and paste the exact same thing in the else as already is in the except # noqa: E501
raise ValueError
# try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½'
amount += parse_fraction(tokens[1])
# assume that units can't end with a comma
if len(tokens) > 3 and not tokens[2].endswith(','):
# try to use third argument as unit and everything else as ingredient, use everything as ingredient if it fails # noqa: E501
try:
ingredient, note = parse_ingredient(tokens[3:])
unit = tokens[2]
except ValueError:
ingredient, note = parse_ingredient(tokens[2:])
else:
ingredient, note = parse_ingredient(tokens[2:])
except ValueError:
# assume that units can't end with a comma
if not tokens[1].endswith(','):
# try to use second argument as unit and everything else as ingredient, use everything as ingredient if it fails # noqa: E501
try:
ingredient, note = parse_ingredient(tokens[2:])
if unit == '':
unit = tokens[1]
else:
note = tokens[1]
except ValueError:
ingredient, note = parse_ingredient(tokens[1:])
else:
ingredient, note = parse_ingredient(tokens[1:])
def __init__(self, request, cache_mode, ignore_automations=False):
"""
Initialize ingredient parser
:param request: request context (to control caching, rule ownership, etc.)
:param cache_mode: defines if all rules should be loaded on initialization (good when parser is used many times) or if they should be retrieved every time (good when parser is not used many times in a row)
:param ignore_automations: ignore automation rules, allows to use ingredient parser without database access/request (request can be None)
"""
self.request = request
self.ignore_rules = ignore_automations
if cache_mode:
FOOD_CACHE_KEY = f'automation_food_alias_{self.request.space.pk}'
if c := caches['default'].get(FOOD_CACHE_KEY, None):
self.food_aliases = c
caches['default'].touch(FOOD_CACHE_KEY, 30)
else:
# only two arguments, first one is the amount
# which means this is the ingredient
ingredient = tokens[1]
except ValueError:
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.FOOD_ALIAS).only('param_1', 'param_2').all():
self.food_aliases[a.param_1] = a.param_2
caches['default'].set(FOOD_CACHE_KEY, self.food_aliases, 30)
UNIT_CACHE_KEY = f'automation_unit_alias_{self.request.space.pk}'
if c := caches['default'].get(UNIT_CACHE_KEY, None):
self.unit_aliases = c
caches['default'].touch(UNIT_CACHE_KEY, 30)
else:
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.UNIT_ALIAS).only('param_1', 'param_2').all():
self.unit_aliases[a.param_1] = a.param_2
caches['default'].set(UNIT_CACHE_KEY, self.unit_aliases, 30)
else:
self.food_aliases = {}
self.unit_aliases = {}
def apply_food_automation(self, food):
"""
Apply food alias automations to passed foood
:param food: unit as string
:return: food as string (possibly changed by automation)
"""
if self.ignore_rules:
return food
else:
if self.food_aliases:
try:
return self.food_aliases[food]
except KeyError:
return food
else:
if automation := Automation.objects.filter(space=self.request.space, type=Automation.FOOD_ALIAS, param_1=food, disabled=False).first():
return automation.param_2
return food
def apply_unit_automation(self, unit):
"""
Apply unit alias automations to passed unit
:param unit: unit as string
:return: unit as string (possibly changed by automation)
"""
if self.ignore_rules:
return unit
else:
if self.unit_aliases:
try:
return self.unit_aliases[unit]
except KeyError:
return unit
else:
if automation := Automation.objects.filter(space=self.request.space, type=Automation.UNIT_ALIAS, param_1=unit, disabled=False).first():
return automation.param_2
return unit
def get_unit(self, unit):
"""
Get or create a unit for given space respecting possible automations
:param unit: string unit
:return: None if unit passed is invalid, Unit object otherwise
"""
if not unit:
return None
if len(unit) > 0:
u, created = Unit.objects.get_or_create(name=self.apply_unit_automation(unit), space=self.request.space)
return u
return None
def get_food(self, food):
"""
Get or create a food for given space respecting possible automations
:param food: string food
:return: None if food passed is invalid, Food object otherwise
"""
if not food:
return None
if len(food) > 0:
f, created = Food.objects.get_or_create(name=self.apply_food_automation(food), space=self.request.space)
return f
return None
def parse_fraction(self, x):
if len(x) == 1 and 'fraction' in unicodedata.decomposition(x):
frac_split = unicodedata.decomposition(x[-1:]).split()
return (float((frac_split[1]).replace('003', ''))
/ float((frac_split[3]).replace('003', '')))
else:
frac_split = x.split('/')
if not len(frac_split) == 2:
raise ValueError
try:
# can't parse first argument as amount
# -> no unit -> parse everything as ingredient
ingredient, note = parse_ingredient(tokens)
return int(frac_split[0]) / int(frac_split[1])
except ZeroDivisionError:
raise ValueError
def parse_amount(self, x):
amount = 0
unit = ''
note = ''
did_check_frac = False
end = 0
while (end < len(x) and (x[end] in string.digits
or (
(x[end] == '.' or x[end] == ',' or x[end] == '/')
and end + 1 < len(x)
and x[end + 1] in string.digits
))):
end += 1
if end > 0:
if "/" in x[:end]:
amount = self.parse_fraction(x[:end])
else:
amount = float(x[:end].replace(',', '.'))
else:
amount = self.parse_fraction(x[0])
end += 1
did_check_frac = True
if end < len(x):
if did_check_frac:
unit = x[end:]
else:
try:
amount += self.parse_fraction(x[end])
unit = x[end + 1:]
except ValueError:
unit = x[end:]
if unit.startswith('(') or unit.startswith('-'): # i dont know any unit that starts with ( or - so its likely an alternative like 1L (500ml) Water or 2-3
unit = ''
note = x
return amount, unit, note
def parse_ingredient_with_comma(self, tokens):
ingredient = ''
note = ''
start = 0
# search for first occurrence of an argument ending in a comma
while start < len(tokens) and not tokens[start].endswith(','):
start += 1
if start == len(tokens):
# no token ending in a comma found -> use everything as ingredient
ingredient = ' '.join(tokens)
else:
ingredient = ' '.join(tokens[:start + 1])[:-1]
note = ' '.join(tokens[start + 1:])
return ingredient, note
def parse_ingredient(self, tokens):
ingredient = ''
note = ''
if tokens[-1].endswith(')'):
# Check if the matching opening bracket is in the same token
if (not tokens[-1].startswith('(')) and ('(' in tokens[-1]):
return self.parse_ingredient_with_comma(tokens)
# last argument ends with closing bracket -> look for opening bracket
start = len(tokens) - 1
while not tokens[start].startswith('(') and not start == 0:
start -= 1
if start == 0:
# the whole list is wrapped in brackets -> assume it is an error (e.g. assumed first argument was the unit) # noqa: E501
raise ValueError
elif start < 0:
# no opening bracket anywhere -> just ignore the last bracket
ingredient, note = self.parse_ingredient_with_comma(tokens)
else:
# opening bracket found -> split in ingredient and note, remove brackets from note # noqa: E501
note = ' '.join(tokens[start:])[1:-1]
ingredient = ' '.join(tokens[:start])
else:
ingredient, note = self.parse_ingredient_with_comma(tokens)
return ingredient, note
def parse(self, x):
# initialize default values
amount = 0
unit = ''
ingredient = ''
note = ''
unit_note = ''
# if the string contains parenthesis early on remove it and place it at the end
# because its likely some kind of note
if re.match('(.){1,6}\s\((.[^\(\)])+\)\s', x):
match = re.search('\((.[^\(])+\)', x)
x = x[:match.start()] + x[match.end():] + ' ' + x[match.start():match.end()]
tokens = x.split()
if len(tokens) == 1:
# there only is one argument, that must be the ingredient
ingredient = tokens[0]
else:
try:
# try to parse first argument as amount
amount, unit, unit_note = self.parse_amount(tokens[0])
# only try to parse second argument as amount if there are at least
# three arguments if it already has a unit there can't be
# a fraction for the amount
if len(tokens) > 2:
try:
if not unit == '':
# a unit is already found, no need to try the second argument for a fraction
# probably not the best method to do it, but I didn't want to make an if check and paste the exact same thing in the else as already is in the except # noqa: E501
raise ValueError
# try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½'
amount += self.parse_fraction(tokens[1])
# assume that units can't end with a comma
if len(tokens) > 3 and not tokens[2].endswith(','):
# try to use third argument as unit and everything else as ingredient, use everything as ingredient if it fails # noqa: E501
try:
ingredient, note = self.parse_ingredient(tokens[3:])
unit = tokens[2]
except ValueError:
ingredient, note = self.parse_ingredient(tokens[2:])
else:
ingredient, note = self.parse_ingredient(tokens[2:])
except ValueError:
# assume that units can't end with a comma
if not tokens[1].endswith(','):
# try to use second argument as unit and everything else as ingredient, use everything as ingredient if it fails # noqa: E501
try:
ingredient, note = self.parse_ingredient(tokens[2:])
if unit == '':
unit = tokens[1]
else:
note = tokens[1]
except ValueError:
ingredient, note = self.parse_ingredient(tokens[1:])
else:
ingredient, note = self.parse_ingredient(tokens[1:])
else:
# only two arguments, first one is the amount
# which means this is the ingredient
ingredient = tokens[1]
except ValueError:
ingredient = ' '.join(tokens[1:])
try:
# can't parse first argument as amount
# -> no unit -> parse everything as ingredient
ingredient, note = self.parse_ingredient(tokens)
except ValueError:
ingredient = ' '.join(tokens[1:])
if unit_note not in note:
note += ' ' + unit_note
return amount, unit.strip(), ingredient.strip(), note.strip()
# small utility functions to prevent emtpy unit/food creation
def get_unit(unit, space):
if not unit:
return None
if len(unit) > 0:
u, created = Unit.objects.get_or_create(name=unit, space=space)
return u
return None
def get_food(food, space):
if not food:
return None
if len(food) > 0:
f, created = Food.objects.get_or_create(name=food, space=space)
return f
return None
if unit_note not in note:
note += ' ' + unit_note
return amount, self.apply_unit_automation(unit.strip()), self.apply_food_automation(ingredient.strip()), note.strip()

View File

@ -2,11 +2,9 @@
Source: https://djangosnippets.org/snippets/1703/
"""
from django.conf import settings
from django.core.cache import caches
from cookbook.models import ShareLink
from django.contrib import messages
from django.contrib.auth.decorators import user_passes_test
from django.core.cache import caches
from django.core.exceptions import ValidationError
from django.http import HttpResponseRedirect
from django.urls import reverse, reverse_lazy
@ -14,6 +12,8 @@ from django.utils.translation import gettext as _
from rest_framework import permissions
from rest_framework.permissions import SAFE_METHODS
from cookbook.models import ShareLink
def get_allowed_groups(groups_required):
"""
@ -34,7 +34,7 @@ def has_group_permission(user, groups):
"""
Tests if a given user is member of a certain group (or any higher group)
Superusers always bypass permission checks.
Unauthenticated users cant be member of any group thus always return false.
Unauthenticated users can't be member of any group thus always return false.
:param user: django auth user object
:param groups: list or tuple of groups the user should be checked for
:return: True if user is in allowed groups, false otherwise
@ -205,6 +205,9 @@ class CustomIsShared(permissions.BasePermission):
return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
# # temporary hack to make old shopping list work with new shopping list
# if obj.__class__.__name__ in ['ShoppingList', 'ShoppingListEntry']:
# return is_object_shared(request.user, obj) or obj.created_by in list(request.user.get_shopping_share())
return is_object_shared(request.user, obj)

View File

@ -1,16 +1,17 @@
import json
import re
from json import JSONDecodeError
from urllib.parse import unquote
from bs4 import BeautifulSoup
from bs4.element import Tag
from recipe_scrapers._utils import get_host_name, normalize_string
from cookbook.helper import recipe_url_import as helper
from cookbook.helper.scrapers.scrapers import text_scraper
from json import JSONDecodeError
from recipe_scrapers._utils import get_host_name, normalize_string
from urllib.parse import unquote
def get_recipe_from_source(text, url, space):
def get_recipe_from_source(text, url, request):
def build_node(k, v):
if isinstance(v, dict):
node = {
@ -58,7 +59,7 @@ def get_recipe_from_source(text, url, space):
return kid_list
recipe_json = {
'name': '',
'name': '',
'url': '',
'description': '',
'image': '',
@ -103,7 +104,7 @@ def get_recipe_from_source(text, url, space):
parse_list.append(el)
scrape = text_scraper(text, url=url)
recipe_json = helper.get_from_scraper(scrape, space)
recipe_json = helper.get_from_scraper(scrape, request)
for el in parse_list:
temp_tree = []
@ -188,6 +189,6 @@ def remove_graph(el):
for x in el['@graph']:
if '@type' in x and x['@type'] == 'Recipe':
el = x
except TypeError:
except (TypeError, JSONDecodeError):
pass
return el

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,18 @@
import random
import re
from html import unescape
from django.utils.dateparse import parse_duration
from isodate import parse_duration as iso_parse_duration
from isodate.isoerror import ISO8601Error
from cookbook.helper.ingredient_parser import parse as parse_single_ingredient
from cookbook.models import Keyword
from django.utils.dateparse import parse_duration
from html import unescape
from recipe_scrapers._utils import get_minutes
from cookbook.helper import recipe_url_import as helper
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.models import Keyword
def get_from_scraper(scrape, space):
def get_from_scraper(scrape, request):
# converting the scrape_me object to the existing json format based on ld+json
recipe_json = {}
try:
@ -91,15 +93,17 @@ def get_from_scraper(scrape, space):
except Exception:
pass
try:
recipe_json['keywords'] = parse_keywords(list(set(map(str.casefold, keywords))), space)
recipe_json['keywords'] = parse_keywords(list(set(map(str.casefold, keywords))), request.space)
except AttributeError:
recipe_json['keywords'] = keywords
ingredient_parser = IngredientParser(request, True)
ingredients = []
try:
ingredients = []
for x in scrape.ingredients():
try:
amount, unit, ingredient, note = parse_single_ingredient(x)
amount, unit, ingredient, note = ingredient_parser.parse(x)
ingredients.append(
{
'amount': amount,
@ -245,7 +249,10 @@ def parse_instructions(instructions):
instruction_text += str(i)
instructions = instruction_text
return normalize_string(instructions)
normalized_string = normalize_string(instructions)
normalized_string = normalized_string.replace('\n', ' \n')
normalized_string = normalized_string.replace(' \n \n', '\n\n')
return normalized_string
def parse_image(image):

View File

@ -1,7 +1,11 @@
from django.urls import reverse
from django_scopes import scope, scopes_disabled
from rest_framework.authentication import TokenAuthentication
from rest_framework.authtoken.models import Token
from rest_framework.exceptions import AuthenticationFailed
from cookbook.views import views
from recipes import settings
class ScopeMiddleware:
@ -9,16 +13,17 @@ class ScopeMiddleware:
self.get_response = get_response
def __call__(self, request):
prefix = settings.JS_REVERSE_SCRIPT_PREFIX or ''
if request.user.is_authenticated:
if request.path.startswith('/admin/'):
if request.path.startswith(prefix + '/admin/'):
with scopes_disabled():
return self.get_response(request)
if request.path.startswith('/signup/') or request.path.startswith('/invite/'):
if request.path.startswith(prefix + '/signup/') or request.path.startswith(prefix + '/invite/'):
return self.get_response(request)
if request.path.startswith('/accounts/'):
if request.path.startswith(prefix + '/accounts/'):
return self.get_response(request)
with scopes_disabled():
@ -33,6 +38,15 @@ class ScopeMiddleware:
with scope(space=request.space):
return self.get_response(request)
else:
if request.path.startswith(prefix + '/api/'):
try:
if auth := TokenAuthentication().authenticate(request):
request.space = auth[0].userpreference.space
with scope(space=request.space):
return self.get_response(request)
except AuthenticationFailed:
pass
with scopes_disabled():
request.space = None
return self.get_response(request)

View File

@ -0,0 +1,313 @@
from datetime import timedelta
from decimal import Decimal
from django.contrib.postgres.aggregates import ArrayAgg
from django.db.models import F, OuterRef, Q, Subquery, Value
from django.db.models.functions import Coalesce
from django.utils import timezone
from django.utils.translation import gettext as _
from cookbook.helper.HelperFunctions import Round, str2bool
from cookbook.models import (Ingredient, MealPlan, Recipe, ShoppingListEntry, ShoppingListRecipe,
SupermarketCategoryRelation)
from recipes import settings
def shopping_helper(qs, request):
supermarket = request.query_params.get('supermarket', None)
checked = request.query_params.get('checked', 'recent')
user = request.user
supermarket_order = [F('food__supermarket_category__name').asc(nulls_first=True), 'food__name']
# TODO created either scheduled task or startup task to delete very old shopping list entries
# TODO create user preference to define 'very old'
if supermarket:
supermarket_categories = SupermarketCategoryRelation.objects.filter(supermarket=supermarket, category=OuterRef('food__supermarket_category'))
qs = qs.annotate(supermarket_order=Coalesce(Subquery(supermarket_categories.values('order')), Value(9999)))
supermarket_order = ['supermarket_order'] + supermarket_order
if checked in ['false', 0, '0']:
qs = qs.filter(checked=False)
elif checked in ['true', 1, '1']:
qs = qs.filter(checked=True)
elif checked in ['recent']:
today_start = timezone.now().replace(hour=0, minute=0, second=0)
week_ago = today_start - timedelta(days=user.userpreference.shopping_recent_days)
qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago))
supermarket_order = ['checked'] + supermarket_order
return qs.order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe')
class RecipeShoppingEditor():
def __init__(self, user, space, **kwargs):
self.created_by = user
self.space = space
self._kwargs = {**kwargs}
self.mealplan = self._kwargs.get('mealplan', None)
if type(self.mealplan) in [int, float]:
self.mealplan = MealPlan.objects.filter(id=self.mealplan, space=self.space)
self.id = self._kwargs.get('id', None)
self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space)
if self._shopping_list_recipe:
# created_by needs to be sticky to original creator as it is 'their' shopping list
# changing shopping list created_by can shift some items to new owner which may not share in the other direction
self.created_by = getattr(self._shopping_list_recipe.entries.first(), 'created_by', self.created_by)
self.recipe = getattr(self._shopping_list_recipe, 'recipe', None) or self._kwargs.get('recipe', None) or getattr(self.mealplan, 'recipe', None)
if type(self.recipe) in [int, float]:
self.recipe = Recipe.objects.filter(id=self.recipe, space=self.space)
try:
self.servings = float(self._kwargs.get('servings', None))
except (ValueError, TypeError):
self.servings = getattr(self._shopping_list_recipe, 'servings', None) or getattr(self.mealplan, 'servings', None) or getattr(self.recipe, 'servings', None)
@property
def _recipe_servings(self):
return getattr(self.recipe, 'servings', None) or getattr(getattr(self.mealplan, 'recipe', None), 'servings', None) or getattr(getattr(self._shopping_list_recipe, 'recipe', None), 'servings', None)
@property
def _servings_factor(self):
return Decimal(self.servings)/Decimal(self._recipe_servings)
@property
def _shared_users(self):
return [*list(self.created_by.get_shopping_share()), self.created_by]
@staticmethod
def get_shopping_list_recipe(id, user, space):
return ShoppingListRecipe.objects.filter(id=id).filter(Q(shoppinglist__space=space) | Q(entries__space=space)).filter(
Q(shoppinglist__created_by=user)
| Q(shoppinglist__shared=user)
| Q(entries__created_by=user)
| Q(entries__created_by__in=list(user.get_shopping_share()))
).prefetch_related('entries').first()
def get_recipe_ingredients(self, id, exclude_onhand=False):
if exclude_onhand:
return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space).exclude(food__onhand_users__id__in=[x.id for x in self._shared_users])
else:
return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space)
@property
def _include_related(self):
return self.created_by.userpreference.mealplan_autoinclude_related
@property
def _exclude_onhand(self):
return self.created_by.userpreference.mealplan_autoexclude_onhand
def create(self, **kwargs):
ingredients = kwargs.get('ingredients', None)
exclude_onhand = not ingredients and self._exclude_onhand
if servings := kwargs.get('servings', None):
self.servings = float(servings)
if mealplan := kwargs.get('mealplan', None):
self.mealplan = mealplan
self.recipe = mealplan.recipe
elif recipe := kwargs.get('recipe', None):
self.recipe = recipe
if not self.servings:
self.servings = getattr(self.mealplan, 'servings', None) or getattr(self.recipe, 'servings', 1.0)
self._shopping_list_recipe = ShoppingListRecipe.objects.create(recipe=self.recipe, mealplan=self.mealplan, servings=self.servings)
if ingredients:
self._add_ingredients(ingredients=ingredients)
else:
if self._include_related:
related = self.recipe.get_related_recipes()
self._add_ingredients(self.get_recipe_ingredients(self.recipe.id, exclude_onhand=exclude_onhand).exclude(food__recipe__in=related))
for r in related:
self._add_ingredients(self.get_recipe_ingredients(r.id, exclude_onhand=exclude_onhand).exclude(food__recipe__in=related))
else:
self._add_ingredients(self.get_recipe_ingredients(self.recipe.id, exclude_onhand=exclude_onhand))
return True
def add(self, **kwargs):
return
def edit(self, servings=None, ingredients=None, **kwargs):
if servings:
self.servings = servings
self._delete_ingredients(ingredients=ingredients)
if self.servings != self._shopping_list_recipe.servings:
self.edit_servings()
self._add_ingredients(ingredients=ingredients)
return True
def edit_servings(self, servings=None, **kwargs):
if servings:
self.servings = servings
if id := kwargs.get('id', None):
self._shopping_list_recipe = self.get_shopping_list_recipe(id, self.created_by, self.space)
if not self.servings:
raise ValueError(_("You must supply a servings size"))
if self._shopping_list_recipe.servings == self.servings:
return True
for sle in ShoppingListEntry.objects.filter(list_recipe=self._shopping_list_recipe):
sle.amount = sle.ingredient.amount * Decimal(self._servings_factor)
sle.save()
self._shopping_list_recipe.servings = self.servings
self._shopping_list_recipe.save()
return True
def delete(self, **kwargs):
try:
self._shopping_list_recipe.delete()
return True
except:
return False
def _add_ingredients(self, ingredients=None):
if not ingredients:
return
elif type(ingredients) == list:
ingredients = Ingredient.objects.filter(id__in=ingredients)
existing = self._shopping_list_recipe.entries.filter(ingredient__in=ingredients).values_list('ingredient__pk', flat=True)
add_ingredients = ingredients.exclude(id__in=existing)
for i in [x for x in add_ingredients if x.food]:
ShoppingListEntry.objects.create(
list_recipe=self._shopping_list_recipe,
food=i.food,
unit=i.unit,
ingredient=i,
amount=i.amount * Decimal(self._servings_factor),
created_by=self.created_by,
space=self.space,
)
# deletes shopping list entries not in ingredients list
def _delete_ingredients(self, ingredients=None):
if not ingredients:
return
to_delete = self._shopping_list_recipe.entries.exclude(ingredient__in=ingredients)
ShoppingListEntry.objects.filter(id__in=to_delete).delete()
self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space)
# # TODO refactor as class
# def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False):
# """
# Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe
# :param list_recipe: Modify an existing ShoppingListRecipe
# :param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required
# :param mealplan: alternatively use a mealplan recipe as source of ingredients
# :param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted
# :param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used
# :param append: If False will remove any entries not included with ingredients, when True will append ingredients to the shopping list
# """
# r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None)
# if not r:
# raise ValueError(_("You must supply a recipe or mealplan"))
# created_by = created_by or getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', None)
# if not created_by:
# raise ValueError(_("You must supply a created_by"))
# try:
# servings = float(servings)
# except (ValueError, TypeError):
# servings = getattr(mealplan, 'servings', 1.0)
# servings_factor = servings / r.servings
# shared_users = list(created_by.get_shopping_share())
# shared_users.append(created_by)
# if list_recipe:
# created = False
# else:
# list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings)
# created = True
# related_step_ing = []
# if servings == 0 and not created:
# list_recipe.delete()
# return []
# elif ingredients:
# ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
# else:
# ingredients = Ingredient.objects.filter(step__recipe=r, food__ignore_shopping=False, space=space)
# if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand:
# ingredients = ingredients.exclude(food__onhand_users__id__in=[x.id for x in shared_users])
# if related := created_by.userpreference.mealplan_autoinclude_related:
# # TODO: add levels of related recipes (related recipes of related recipes) to use when auto-adding mealplans
# related_recipes = r.get_related_recipes()
# for x in related_recipes:
# # related recipe is a Step serving size is driven by recipe serving size
# # TODO once/if Steps can have a serving size this needs to be refactored
# if exclude_onhand:
# # if steps are used more than once in a recipe or subrecipe - I don' think this results in the desired behavior
# related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users]).values_list('id', flat=True)
# else:
# related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).values_list('id', flat=True)
# x_ing = []
# if ingredients.filter(food__recipe=x).exists():
# for ing in ingredients.filter(food__recipe=x):
# if exclude_onhand:
# x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users])
# else:
# x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__ignore_shopping=True)
# for i in [x for x in x_ing]:
# ShoppingListEntry.objects.create(
# list_recipe=list_recipe,
# food=i.food,
# unit=i.unit,
# ingredient=i,
# amount=i.amount * Decimal(servings_factor),
# created_by=created_by,
# space=space,
# )
# # dont' add food to the shopping list that are actually recipes that will be added as ingredients
# ingredients = ingredients.exclude(food__recipe=x)
# add_ingredients = list(ingredients.values_list('id', flat=True)) + related_step_ing
# if not append:
# existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe)
# # delete shopping list entries not included in ingredients
# existing_list.exclude(ingredient__in=ingredients).delete()
# # add shopping list entries that did not previously exist
# add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True))
# add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
# # if servings have changed, update the ShoppingListRecipe and existing Entries
# if servings <= 0:
# servings = 1
# if not created and list_recipe.servings != servings:
# update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True))
# list_recipe.servings = servings
# list_recipe.save()
# for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients):
# sle.amount = sle.ingredient.amount * Decimal(servings_factor)
# sle.save()
# # add any missing Entries
# for i in [x for x in add_ingredients if x.food]:
# ShoppingListEntry.objects.create(
# list_recipe=list_recipe,
# food=i.food,
# unit=i.unit,
# ingredient=i,
# amount=i.amount * Decimal(servings_factor),
# created_by=created_by,
# space=space,
# )
# # return all shopping list items
# return list_recipe

View File

@ -5,7 +5,7 @@ from cookbook.helper.mdx_attributes import MarkdownFormatExtension
from cookbook.helper.mdx_urlize import UrlizeExtension
from jinja2 import Template, TemplateSyntaxError, UndefinedError
from gettext import gettext as _
from markdown.extensions.tables import TableExtension
class IngredientObject(object):
amount = ""
@ -41,7 +41,7 @@ def render_instructions(step): # TODO deduplicate markdown cleanup code
parsed_md = md.markdown(
instructions,
extensions=[
'markdown.extensions.fenced_code', 'tables',
'markdown.extensions.fenced_code', TableExtension(),
UrlizeExtension(), MarkdownFormatExtension()
]
)

View File

@ -1,6 +1,6 @@
import re
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Ingredient
@ -42,11 +42,12 @@ class ChefTap(Integration):
step.instruction += '\n' + source_url
step.save()
ingredient_parser = IngredientParser(self.request, True)
for ingredient in ingredients:
if len(ingredient.strip()) > 0:
amount, unit, ingredient, note = parse(ingredient)
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
amount, unit, ingredient, note = ingredient_parser.parse(ingredient)
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))

View File

@ -3,7 +3,7 @@ from io import BytesIO
from zipfile import ZipFile
from cookbook.helper.image_processing import get_filetype
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Ingredient, Keyword
@ -58,10 +58,11 @@ class Chowdown(Integration):
instruction='\n'.join(directions) + '\n\n' + '\n'.join(descriptions), space=self.request.space,
)
ingredient_parser = IngredientParser(self.request, True)
for ingredient in ingredients:
amount, unit, ingredient, note = parse(ingredient)
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
amount, unit, ingredient, note = ingredient_parser.parse(ingredient)
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))

View File

@ -0,0 +1,68 @@
import base64
import gzip
import json
import re
from io import BytesIO
import requests
import yaml
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_html_import import get_recipe_from_source
from cookbook.helper.recipe_url_import import iso_duration_to_minutes
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Ingredient, Keyword
from gettext import gettext as _
class CookBookApp(Integration):
def import_file_name_filter(self, zip_info_object):
return zip_info_object.filename.endswith('.html')
def get_recipe_from_file(self, file):
recipe_html = file.getvalue().decode("utf-8")
recipe_json, recipe_tree, html_data, images = get_recipe_from_source(recipe_html, 'CookBookApp', self.request)
recipe = Recipe.objects.create(
name=recipe_json['name'].strip(),
created_by=self.request.user, internal=True,
space=self.request.space)
try:
recipe.servings = re.findall('([0-9])+', recipe_json['recipeYield'])[0]
except Exception as e:
pass
try:
recipe.working_time = iso_duration_to_minutes(recipe_json['prepTime'])
recipe.waiting_time = iso_duration_to_minutes(recipe_json['cookTime'])
except Exception:
pass
step = Step.objects.create(instruction=recipe_json['recipeInstructions'], space=self.request.space, )
if 'nutrition' in recipe_json:
step.instruction = step.instruction + '\n\n' + recipe_json['nutrition']
step.save()
recipe.steps.add(step)
ingredient_parser = IngredientParser(self.request, True)
for ingredient in recipe_json['recipeIngredient']:
f = ingredient_parser.get_food(ingredient['ingredient']['text'])
u = ingredient_parser.get_unit(ingredient['unit']['text'])
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=ingredient['amount'], note=ingredient['note'], space=self.request.space,
))
if len(images) > 0:
try:
response = requests.get(images[0])
self.import_recipe_image(recipe, BytesIO(response.content))
except Exception as e:
print('failed to import image ', str(e))
recipe.save()
return recipe

View File

@ -0,0 +1,84 @@
import re
from io import BytesIO
from zipfile import ZipFile
from bs4 import BeautifulSoup
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_html_import import get_recipe_from_source
from cookbook.helper.recipe_url_import import iso_duration_to_minutes, parse_servings
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Ingredient, Keyword
from recipes.settings import DEBUG
class CopyMeThat(Integration):
def import_file_name_filter(self, zip_info_object):
if DEBUG:
print("testing", zip_info_object.filename, zip_info_object.filename == 'recipes.html')
return zip_info_object.filename == 'recipes.html'
def get_recipe_from_file(self, file):
# 'file' comes is as a beautifulsoup object
recipe = Recipe.objects.create(name=file.find("div", {"id": "name"}).text.strip(), created_by=self.request.user, internal=True, space=self.request.space, )
for category in file.find_all("span", {"class": "recipeCategory"}):
keyword, created = Keyword.objects.get_or_create(name=category.text, space=self.request.space)
recipe.keywords.add(keyword)
try:
recipe.servings = parse_servings(file.find("a", {"id": "recipeYield"}).text.strip())
recipe.working_time = iso_duration_to_minutes(file.find("span", {"meta": "prepTime"}).text.strip())
recipe.waiting_time = iso_duration_to_minutes(file.find("span", {"meta": "cookTime"}).text.strip())
recipe.save()
except AttributeError:
pass
step = Step.objects.create(instruction='', space=self.request.space, )
ingredient_parser = IngredientParser(self.request, True)
for ingredient in file.find_all("li", {"class": "recipeIngredient"}):
if ingredient.text == "":
continue
amount, unit, ingredient, note = ingredient_parser.parse(ingredient.text.strip())
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
for s in file.find_all("li", {"class": "instruction"}):
if s.text == "":
continue
step.instruction += s.text.strip() + ' \n\n'
for s in file.find_all("li", {"class": "recipeNote"}):
if s.text == "":
continue
step.instruction += s.text.strip() + ' \n\n'
try:
if file.find("a", {"id": "original_link"}).text != '':
step.instruction += "\n\nImported from: " + file.find("a", {"id": "original_link"}).text
step.save()
except AttributeError:
pass
recipe.steps.add(step)
# import the Primary recipe image that is stored in the Zip
try:
for f in self.files:
if '.zip' in f['name']:
import_zip = ZipFile(f['file'])
self.import_recipe_image(recipe, BytesIO(import_zip.read(file.find("img", class_="recipeImage").get("src"))), filetype='.jpeg')
except Exception as e:
print(recipe.name, ': failed to import image ', str(e))
recipe.save()
return recipe
def split_recipe_file(self, file):
soup = BeautifulSoup(file, "html.parser")
return soup.find_all("div", {"class": "recipe"})

View File

@ -1,5 +1,5 @@
import json
from io import BytesIO
from io import BytesIO, StringIO
from re import match
from zipfile import ZipFile
@ -32,6 +32,39 @@ class Default(Integration):
return None
def get_file_from_recipe(self, recipe):
export = RecipeExportSerializer(recipe).data
return 'recipe.json', JSONRenderer().render(export).decode("utf-8")
def get_files_from_recipes(self, recipes, el, cookie):
export_zip_stream = BytesIO()
export_zip_obj = ZipFile(export_zip_stream, 'w')
for r in recipes:
if r.internal and r.space == self.request.space:
recipe_zip_stream = BytesIO()
recipe_zip_obj = ZipFile(recipe_zip_stream, 'w')
recipe_stream = StringIO()
filename, data = self.get_file_from_recipe(r)
recipe_stream.write(data)
recipe_zip_obj.writestr(filename, recipe_stream.getvalue())
recipe_stream.close()
try:
recipe_zip_obj.writestr(f'image{get_filetype(r.image.file.name)}', r.image.file.read())
except ValueError:
pass
recipe_zip_obj.close()
export_zip_obj.writestr(str(r.pk) + '.zip', recipe_zip_stream.getvalue())
el.exported_recipes += 1
el.msg += self.get_recipe_processed_msg(r)
el.save()
export_zip_obj.close()
return [[ self.get_export_file_name(), export_zip_stream.getvalue() ]]

View File

@ -2,7 +2,7 @@ import base64
import json
from io import BytesIO
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Ingredient
@ -34,11 +34,12 @@ class Domestica(Integration):
if file['source'] != '':
step.instruction += '\n' + file['source']
ingredient_parser = IngredientParser(self.request, True)
for ingredient in file['ingredients'].split('\n'):
if len(ingredient.strip()) > 0:
amount, unit, ingredient, note = parse(ingredient)
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
amount, unit, ingredient, note = ingredient_parser.parse(ingredient)
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))

View File

@ -1,20 +1,27 @@
import time
import datetime
import json
import traceback
import uuid
from io import BytesIO, StringIO
from zipfile import ZipFile, BadZipFile
from zipfile import BadZipFile, ZipFile
from django.core.cache import cache
import datetime
from bs4 import Tag
from django.core.exceptions import ObjectDoesNotExist
from django.core.files import File
from django.db import IntegrityError
from django.http import HttpResponse
from django.utils.formats import date_format
from django.utils.translation import gettext as _
from django_scopes import scope
from cookbook.forms import ImportExportBase
from cookbook.helper.image_processing import get_filetype
from cookbook.helper.image_processing import get_filetype, handle_image
from cookbook.models import Keyword, Recipe
from recipes.settings import DATABASES, DEBUG
from recipes.settings import DEBUG
from recipes.settings import EXPORT_FILE_CACHE_DURATION
class Integration:
@ -31,71 +38,70 @@ class Integration:
"""
self.request = request
self.export_type = export_type
self.ignored_recipes = []
description = f'Imported by {request.user.get_user_name()} at {date_format(datetime.datetime.now(), "DATETIME_FORMAT")}. Type: {export_type}'
icon = '📥'
count = Keyword.objects.filter(name__icontains='Import', space=request.space).count()
name = f'Import {count + 1}'
if DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']:
parent, created = Keyword.objects.get_or_create(name='Import', space=request.space)
try:
last_kw = Keyword.objects.filter(name__regex=r'^(Import [0-9]+)', space=request.space).latest('created_at')
name = f'Import {int(last_kw.name.replace("Import ", "")) + 1}'
except (ObjectDoesNotExist, ValueError):
name = 'Import 1'
parent, created = Keyword.objects.get_or_create(name='Import', space=request.space)
try:
self.keyword = parent.add_child(
name=name,
description=description,
icon=icon,
space=request.space
)
else:
self.keyword, created = Keyword.objects.get_or_create(
name=name,
except (IntegrityError, ValueError): # in case, for whatever reason, the name does exist append UUID to it. Not nice but works for now.
self.keyword = parent.add_child(
name=f'{name} {str(uuid.uuid4())[0:8]}',
description=description,
icon=icon,
space=request.space
)
def do_export(self, recipes):
"""
Perform the export based on a list of recipes
:param recipes: list of recipe objects
:return: HttpResponse with a ZIP file that is directly downloaded
"""
# TODO this is temporary, find a better solution for different export formats when doing other exporters
if self.export_type != ImportExportBase.RECIPESAGE:
export_zip_stream = BytesIO()
export_zip_obj = ZipFile(export_zip_stream, 'w')
for r in recipes:
if r.internal and r.space == self.request.space:
recipe_zip_stream = BytesIO()
recipe_zip_obj = ZipFile(recipe_zip_stream, 'w')
def do_export(self, recipes, el):
recipe_stream = StringIO()
filename, data = self.get_file_from_recipe(r)
recipe_stream.write(data)
recipe_zip_obj.writestr(filename, recipe_stream.getvalue())
recipe_stream.close()
try:
recipe_zip_obj.writestr(f'image{get_filetype(r.image.file.name)}', r.image.file.read())
except ValueError:
pass
with scope(space=self.request.space):
el.total_recipes = len(recipes)
el.cache_duration = EXPORT_FILE_CACHE_DURATION
el.save()
recipe_zip_obj.close()
export_zip_obj.writestr(str(r.pk) + '.zip', recipe_zip_stream.getvalue())
files = self.get_files_from_recipes(recipes, el, self.request.COOKIES)
export_zip_obj.close()
if len(files) == 1:
filename, file = files[0]
export_filename = filename
export_file = file
response = HttpResponse(export_zip_stream.getvalue(), content_type='application/force-download')
response['Content-Disposition'] = 'attachment; filename="export.zip"'
return response
else:
json_list = []
for r in recipes:
json_list.append(self.get_file_from_recipe(r))
else:
#zip the files if there is more then one file
export_filename = self.get_export_file_name()
export_stream = BytesIO()
export_obj = ZipFile(export_stream, 'w')
for filename, file in files:
export_obj.writestr(filename, file)
export_obj.close()
export_file = export_stream.getvalue()
cache.set('export_file_'+str(el.pk), {'filename': export_filename, 'file': export_file}, EXPORT_FILE_CACHE_DURATION)
el.running = False
el.save()
response = HttpResponse(export_file, content_type='application/force-download')
response['Content-Disposition'] = 'attachment; filename="' + export_filename + '"'
return response
response = HttpResponse(json.dumps(json_list), content_type='application/force-download')
response['Content-Disposition'] = 'attachment; filename="recipes.json"'
return response
def import_file_name_filter(self, zip_info_object):
"""
@ -116,8 +122,6 @@ class Integration:
:return: HttpResponseRedirect to the recipe search showing all imported recipes
"""
with scope(space=self.request.space):
self.keyword.name = _('Import') + ' ' + str(il.pk)
self.keyword.save()
try:
self.files = files
@ -135,7 +139,7 @@ class Integration:
for d in data_list:
recipe = self.get_recipe_from_file(d)
recipe.keywords.add(self.keyword)
il.msg += f'{recipe.pk} - {recipe.name} \n'
il.msg += self.get_recipe_processed_msg(recipe)
self.handle_duplicates(recipe, import_duplicates)
il.imported_recipes += 1
il.save()
@ -148,25 +152,34 @@ class Integration:
file_list.append(z)
il.total_recipes += len(file_list)
import cookbook
if isinstance(self, cookbook.integration.copymethat.CopyMeThat):
file_list = self.split_recipe_file(BytesIO(import_zip.read('recipes.html')))
il.total_recipes += len(file_list)
for z in file_list:
try:
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
if isinstance(z, Tag):
recipe = self.get_recipe_from_file(z)
else:
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
recipe.keywords.add(self.keyword)
il.msg += f'{recipe.pk} - {recipe.name} \n'
il.msg += self.get_recipe_processed_msg(recipe)
self.handle_duplicates(recipe, import_duplicates)
il.imported_recipes += 1
il.save()
except Exception as e:
traceback.print_exc()
self.handle_exception(e, log=il, message=f'-------------------- \nERROR \n{e}\n--------------------\n')
import_zip.close()
elif '.json' in f['name'] or '.txt' in f['name']:
elif '.json' in f['name'] or '.txt' in f['name'] or '.mmf' in f['name']:
data_list = self.split_recipe_file(f['file'])
il.total_recipes += len(data_list)
for d in data_list:
try:
recipe = self.get_recipe_from_file(d)
recipe.keywords.add(self.keyword)
il.msg += f'{recipe.pk} - {recipe.name} \n'
il.msg += self.get_recipe_processed_msg(recipe)
self.handle_duplicates(recipe, import_duplicates)
il.imported_recipes += 1
il.save()
@ -183,7 +196,7 @@ class Integration:
try:
recipe = self.get_recipe_from_file(d)
recipe.keywords.add(self.keyword)
il.msg += f'{recipe.pk} - {recipe.name} \n'
il.msg += self.get_recipe_processed_msg(recipe)
self.handle_duplicates(recipe, import_duplicates)
il.imported_recipes += 1
il.save()
@ -193,7 +206,7 @@ class Integration:
else:
recipe = self.get_recipe_from_file(f['file'])
recipe.keywords.add(self.keyword)
il.msg += f'{recipe.pk} - {recipe.name} \n'
il.msg += self.get_recipe_processed_msg(recipe)
self.handle_duplicates(recipe, import_duplicates)
except BadZipFile:
il.msg += 'ERROR ' + _(
@ -223,15 +236,14 @@ class Integration:
self.ignored_recipes.append(recipe.name)
recipe.delete()
@staticmethod
def import_recipe_image(recipe, image_file, filetype='.jpeg'):
def import_recipe_image(self, recipe, image_file, filetype='.jpeg'):
"""
Adds an image to a recipe naming it correctly
:param recipe: Recipe object
:param image_file: ByteIO stream containing the image
:param filetype: type of file to write bytes to, default to .jpeg if unknown
"""
recipe.image = File(image_file, name=f'{uuid.uuid4()}_{recipe.pk}{filetype}')
recipe.image = File(handle_image(self.request, File(image_file, name='image'), filetype=filetype)[0], name=f'{uuid.uuid4()}_{recipe.pk}{filetype}')
recipe.save()
def get_recipe_from_file(self, file):
@ -261,7 +273,18 @@ class Integration:
"""
raise NotImplementedError('Method not implemented in integration')
def handle_exception(self, exception, log=None, message=''):
def get_files_from_recipes(self, recipes, el, cookie):
"""
Takes a list of recipe object and converts it to a array containing each file.
Each file is represented as an array [filename, data] where data is a string of the content of the file.
:param recipe: Recipe object that should be converted
:returns:
[[filename, data], ...]
"""
raise NotImplementedError('Method not implemented in integration')
@staticmethod
def handle_exception(exception, log=None, message=''):
if log:
if message:
log.msg += message
@ -269,3 +292,10 @@ class Integration:
log.msg += exception.msg
if DEBUG:
traceback.print_exc()
def get_export_file_name(self, format='zip'):
return "export_{}.{}".format(datetime.datetime.now().strftime("%Y-%m-%d"), format)
def get_recipe_processed_msg(self, recipe):
return f'{recipe.pk} - {recipe.name} \n'

View File

@ -4,7 +4,7 @@ from io import BytesIO
from zipfile import ZipFile
from cookbook.helper.image_processing import get_filetype
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Ingredient
@ -23,7 +23,7 @@ class Mealie(Integration):
name=recipe_json['name'].strip(), description=description,
created_by=self.request.user, internal=True, space=self.request.space)
# TODO parse times (given in PT2H3M )
# TODO parse times (given in PT2H3M )
# @vabene check recipe_url_import.iso_duration_to_minutes I think it does what you are looking for
ingredients_added = False
@ -37,17 +37,18 @@ class Mealie(Integration):
if len(recipe_json['description'].strip()) > 500:
step.instruction = recipe_json['description'].strip() + '\n\n' + step.instruction
ingredient_parser = IngredientParser(self.request, True)
for ingredient in recipe_json['recipe_ingredient']:
try:
if ingredient['food']:
f = get_food(ingredient['food'], self.request.space)
u = get_unit(ingredient['unit'], self.request.space)
f = ingredient_parser.get_food(ingredient['food'])
u = ingredient_parser.get_unit(ingredient['unit'])
amount = ingredient['quantity']
note = ingredient['note']
else:
amount, unit, ingredient, note = parse(ingredient['note'])
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
amount, unit, ingredient, note = ingredient_parser.parse(ingredient['note'])
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))

View File

@ -1,6 +1,6 @@
import re
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Ingredient, Keyword
@ -8,12 +8,10 @@ from cookbook.models import Recipe, Step, Ingredient, Keyword
class MealMaster(Integration):
def get_recipe_from_file(self, file):
print('------------ getting recipe')
servings = 1
ingredients = []
directions = []
for line in file.replace('\r', '').split('\n'):
print('testing line')
if not line.startswith('MMMMM') and line.strip != '':
if 'Title:' in line:
title = line.replace('Title:', '').strip()
@ -44,11 +42,12 @@ class MealMaster(Integration):
instruction='\n'.join(directions) + '\n\n', space=self.request.space,
)
ingredient_parser = IngredientParser(self.request, True)
for ingredient in ingredients:
if len(ingredient.strip()) > 0:
amount, unit, ingredient, note = parse(ingredient)
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
amount, unit, ingredient, note = ingredient_parser.parse(ingredient)
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
@ -64,7 +63,7 @@ class MealMaster(Integration):
current_recipe = ''
for fl in file.readlines():
line = fl.decode("ANSI")
line = fl.decode("windows-1250")
if (line.startswith('MMMMM') or line.startswith('-----')) and 'meal-master' in line.lower():
if current_recipe != '':
recipe_list.append(current_recipe)

View File

@ -4,9 +4,10 @@ from io import BytesIO
from zipfile import ZipFile
from cookbook.helper.image_processing import get_filetype
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import iso_duration_to_minutes
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Ingredient
from cookbook.models import Recipe, Step, Ingredient, Keyword
class NextcloudCookbook(Integration):
@ -24,9 +25,24 @@ class NextcloudCookbook(Integration):
created_by=self.request.user, internal=True,
servings=recipe_json['recipeYield'], space=self.request.space)
# TODO parse times (given in PT2H3M )
# @vabene check recipe_url_import.iso_duration_to_minutes I think it does what you are looking for
# TODO parse keywords
try:
recipe.working_time = iso_duration_to_minutes(recipe_json['prepTime'])
recipe.waiting_time = iso_duration_to_minutes(recipe_json['cookTime'])
except Exception:
pass
if 'recipeCategory' in recipe_json:
try:
recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=recipe_json['recipeCategory'])[0])
except Exception:
pass
if 'keywords' in recipe_json:
try:
for x in recipe_json['keywords'].split(','):
recipe.keywords.add(Keyword.objects.get_or_create(space=self.request.space, name=x)[0])
except Exception:
pass
ingredients_added = False
for s in recipe_json['recipeInstructions']:
@ -39,20 +55,30 @@ class NextcloudCookbook(Integration):
ingredients_added = True
ingredient_parser = IngredientParser(self.request, True)
for ingredient in recipe_json['recipeIngredient']:
amount, unit, ingredient, note = parse(ingredient)
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
amount, unit, ingredient, note = ingredient_parser.parse(ingredient)
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
recipe.steps.add(step)
if 'nutrition' in recipe_json:
try:
recipe.nutrition.calories = recipe_json['nutrition']['calories'].replace(' kcal', '').replace(' ', '')
recipe.nutrition.proteins = recipe_json['nutrition']['calories'].replace(' g', '').replace(',', '.').replace(' ', '')
recipe.nutrition.fats = recipe_json['nutrition']['calories'].replace(' g', '').replace(',', '.').replace(' ', '')
recipe.nutrition.carbohydrates = recipe_json['nutrition']['calories'].replace(' g', '').replace(',', '.').replace(' ', '')
except Exception:
pass
for f in self.files:
if '.zip' in f['name']:
import_zip = ZipFile(f['file'])
for z in import_zip.filelist:
if re.match(f'^Recipes/{recipe.name}/full.jpg$', z.filename):
if re.match(f'^(.)+{recipe.name}/full.jpg$', z.filename):
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)), filetype=get_filetype(z.filename))
return recipe

View File

@ -1,6 +1,6 @@
import json
from cookbook.helper.ingredient_parser import get_food, get_unit
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Ingredient
@ -23,9 +23,10 @@ class OpenEats(Integration):
step = Step.objects.create(instruction=instructions, space=self.request.space,)
ingredient_parser = IngredientParser(self.request, True)
for ingredient in file['ingredients']:
f = get_food(ingredient['food'], self.request.space)
u = get_unit(ingredient['unit'], self.request.space)
f = ingredient_parser.get_food(ingredient['food'])
u = ingredient_parser.get_unit(ingredient['unit'])
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=ingredient['amount'], space=self.request.space,
))

View File

@ -4,7 +4,7 @@ import json
import re
from io import BytesIO
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Ingredient, Keyword
from gettext import gettext as _
@ -58,7 +58,7 @@ class Paprika(Integration):
instruction=instructions, space=self.request.space,
)
if len(recipe_json['description'].strip()) > 500:
if 'description' in recipe_json and len(recipe_json['description'].strip()) > 500:
step.instruction = recipe_json['description'].strip() + '\n\n' + step.instruction
if 'categories' in recipe_json:
@ -66,12 +66,13 @@ class Paprika(Integration):
keyword, created = Keyword.objects.get_or_create(name=c.strip(), space=self.request.space)
recipe.keywords.add(keyword)
ingredient_parser = IngredientParser(self.request, True)
try:
for ingredient in recipe_json['ingredients'].split('\n'):
if len(ingredient.strip()) > 0:
amount, unit, ingredient, note = parse(ingredient)
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
amount, unit, ingredient, note = ingredient_parser.parse(ingredient)
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))

View File

@ -0,0 +1,69 @@
import json
from io import BytesIO
from re import match
from zipfile import ZipFile
import asyncio
from pyppeteer import launch
from rest_framework.renderers import JSONRenderer
from cookbook.helper.image_processing import get_filetype
from cookbook.integration.integration import Integration
from cookbook.serializer import RecipeExportSerializer
from cookbook.models import ExportLog
from asgiref.sync import sync_to_async
import django.core.management.commands.runserver as runserver
import logging
class PDFexport(Integration):
def get_recipe_from_file(self, file):
raise NotImplementedError('Method not implemented in storage integration')
async def get_files_from_recipes_async(self, recipes, el, cookie):
cmd = runserver.Command()
browser = await launch(
handleSIGINT=False,
handleSIGTERM=False,
handleSIGHUP=False,
ignoreHTTPSErrors=True,
)
cookies = {'domain': cmd.default_addr, 'name': 'sessionid', 'value': cookie['sessionid'], }
options = {'format': 'letter',
'margin': {
'top': '0.75in',
'bottom': '0.75in',
'left': '0.75in',
'right': '0.75in',
}
}
files = []
for recipe in recipes:
page = await browser.newPage()
await page.emulateMedia('print')
await page.setCookie(cookies)
await page.goto('http://'+cmd.default_addr+':'+cmd.default_port+'/view/recipe/'+str(recipe.id), {'waitUntil': 'domcontentloaded'})
await page.waitForSelector('#printReady');
files.append([recipe.name + '.pdf', await page.pdf(options)])
await page.close();
el.exported_recipes += 1
el.msg += self.get_recipe_processed_msg(recipe)
await sync_to_async(el.save, thread_sensitive=True)()
await browser.close()
return files
def get_files_from_recipes(self, recipes, el, cookie):
return asyncio.run(self.get_files_from_recipes_async(recipes, el, cookie))

View File

@ -1,4 +1,4 @@
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Ingredient
@ -38,11 +38,12 @@ class Pepperplate(Integration):
instruction='\n'.join(directions) + '\n\n', space=self.request.space,
)
ingredient_parser = IngredientParser(self.request, True)
for ingredient in ingredients:
if len(ingredient.strip()) > 0:
amount, unit, ingredient, note = parse(ingredient)
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
amount, unit, ingredient, note = ingredient_parser.parse(ingredient)
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))

View File

@ -0,0 +1,94 @@
from io import BytesIO
import requests
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Ingredient, Keyword
class Plantoeat(Integration):
def get_recipe_from_file(self, file):
ingredient_mode = False
direction_mode = False
image_url = None
tags = None
ingredients = []
directions = []
description = ''
for line in file.replace('\r', '').split('\n'):
if line.strip() != '':
if 'Title:' in line:
title = line.replace('Title:', '').replace('"', '').strip()
if 'Description:' in line:
description = line.replace('Description:', '').strip()
if 'Source:' in line or 'Serves:' in line or 'Prep Time:' in line or 'Cook Time:' in line:
directions.append(line.strip() + '\n')
if 'Photo Url:' in line:
image_url = line.replace('Photo Url:', '').strip()
if 'Tags:' in line:
tags = line.replace('Tags:', '').strip()
if ingredient_mode:
if len(line) > 2 and 'Instructions:' not in line:
ingredients.append(line.strip())
if direction_mode:
if len(line) > 2:
directions.append(line.strip() + '\n')
if 'Ingredients:' in line:
ingredient_mode = True
if 'Directions:' in line:
ingredient_mode = False
direction_mode = True
recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space)
step = Step.objects.create(
instruction='\n'.join(directions) + '\n\n', space=self.request.space,
)
if tags:
for k in tags.split(','):
keyword, created = Keyword.objects.get_or_create(name=k.strip(), space=self.request.space)
recipe.keywords.add(keyword)
ingredient_parser = IngredientParser(self.request, True)
for ingredient in ingredients:
if len(ingredient.strip()) > 0:
amount, unit, ingredient, note = ingredient_parser.parse(ingredient)
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
recipe.steps.add(step)
if image_url:
try:
response = requests.get(image_url)
self.import_recipe_image(recipe, BytesIO(response.content))
except Exception as e:
print('failed to import image ', str(e))
return recipe
def split_recipe_file(self, file):
recipe_list = []
current_recipe = ''
for fl in file.readlines():
line = fl.decode("windows-1250")
if line.startswith('--------------'):
if current_recipe != '':
recipe_list.append(current_recipe)
current_recipe = ''
else:
current_recipe = ''
else:
current_recipe += line + '\n'
if current_recipe != '':
recipe_list.append(current_recipe)
return recipe_list

View File

@ -6,7 +6,7 @@ from zipfile import ZipFile
import imghdr
from cookbook.helper.image_processing import get_filetype
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Ingredient, Keyword
@ -27,10 +27,10 @@ class RecetteTek(Integration):
def get_recipe_from_file(self, file):
# Create initial recipe with just a title and a decription
# Create initial recipe with just a title and a description
recipe = Recipe.objects.create(name=file['title'], created_by=self.request.user, internal=True, space=self.request.space, )
# set the description as an empty string for later use for the source URL, incase there is no description text.
# set the description as an empty string for later use for the source URL, in case there is no description text.
recipe.description = ''
try:
@ -55,11 +55,12 @@ class RecetteTek(Integration):
try:
# Process the ingredients. Assumes 1 ingredient per line.
ingredient_parser = IngredientParser(self.request, True)
for ingredient in file['ingredients'].split('\n'):
if len(ingredient.strip()) > 0:
amount, unit, ingredient, note = parse(ingredient)
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
amount, unit, ingredient, note = ingredient_parser.parse(ingredient)
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))

View File

@ -3,7 +3,7 @@ from bs4 import BeautifulSoup
from io import BytesIO
from zipfile import ZipFile
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.helper.recipe_url_import import parse_servings, iso_duration_to_minutes
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Ingredient, Keyword
@ -41,12 +41,13 @@ class RecipeKeeper(Integration):
step = Step.objects.create(instruction='', space=self.request.space,)
ingredient_parser = IngredientParser(self.request, True)
for ingredient in file.find("div", {"itemprop": "recipeIngredients"}).findChildren("p"):
if ingredient.text == "":
continue
amount, unit, ingredient, note = parse(ingredient.text.strip())
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
amount, unit, ingredient, note = ingredient_parser.parse(ingredient.text.strip())
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))

View File

@ -3,7 +3,7 @@ from io import BytesIO
import requests
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Ingredient
@ -31,6 +31,7 @@ class RecipeSage(Integration):
except Exception as e:
print('failed to parse yield or time ', str(e))
ingredient_parser = IngredientParser(self.request,True)
ingredients_added = False
for s in file['recipeInstructions']:
step = Step.objects.create(
@ -40,9 +41,9 @@ class RecipeSage(Integration):
ingredients_added = True
for ingredient in file['recipeIngredient']:
amount, unit, ingredient, note = parse(ingredient)
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
amount, unit, ingredient, note = ingredient_parser.parse(ingredient)
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
@ -87,5 +88,16 @@ class RecipeSage(Integration):
return data
def get_files_from_recipes(self, recipes, el, cookie):
json_list = []
for r in recipes:
json_list.append(self.get_file_from_recipe(r))
el.exported_recipes += 1
el.msg += self.get_recipe_processed_msg(r)
el.save()
return [[self.get_export_file_name('json'), json.dumps(json_list)]]
def split_recipe_file(self, file):
return json.loads(file.read().decode("utf-8"))

View File

@ -1,4 +1,4 @@
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Ingredient, Keyword
@ -41,11 +41,12 @@ class RezKonv(Integration):
instruction='\n'.join(directions) + '\n\n', space=self.request.space,
)
ingredient_parser = IngredientParser(self.request, True)
for ingredient in ingredients:
if len(ingredient.strip()) > 0:
amount, unit, ingredient, note = parse(ingredient)
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
amount, unit, ingredient, note = ingredient_parser.parse(ingredient)
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
@ -61,7 +62,7 @@ class RezKonv(Integration):
current_recipe = ''
for fl in file.readlines():
line = fl.decode("ANSI")
line = fl.decode("windows-1250")
if line.startswith('=====') and 'rezkonv' in line.lower():
if current_recipe != '':
recipe_list.append(current_recipe)

View File

@ -1,11 +1,11 @@
from django.utils.translation import gettext as _
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
from cookbook.helper.ingredient_parser import IngredientParser
from cookbook.integration.integration import Integration
from cookbook.models import Recipe, Step, Ingredient
class Safron(Integration):
class Saffron(Integration):
def get_recipe_from_file(self, file):
ingredient_mode = False
@ -43,12 +43,13 @@ class Safron(Integration):
recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space, )
step = Step.objects.create(instruction='\n'.join(directions), space=self.request.space,)
step = Step.objects.create(instruction='\n'.join(directions), space=self.request.space, )
ingredient_parser = IngredientParser(self.request, True)
for ingredient in ingredients:
amount, unit, ingredient, note = parse(ingredient)
f = get_food(ingredient, self.request.space)
u = get_unit(unit, self.request.space)
amount, unit, ingredient, note = ingredient_parser.parse(ingredient)
f = ingredient_parser.get_food(ingredient)
u = ingredient_parser.get_unit(unit)
step.ingredients.add(Ingredient.objects.create(
food=f, unit=u, amount=amount, note=note, space=self.request.space,
))
@ -57,4 +58,43 @@ class Safron(Integration):
return recipe
def get_file_from_recipe(self, recipe):
raise NotImplementedError('Method not implemented in storage integration')
data = "Title: "+recipe.name if recipe.name else ""+"\n"
data += "Description: "+recipe.description if recipe.description else ""+"\n"
data += "Source: \n"
data += "Original URL: \n"
data += "Yield: "+str(recipe.servings)+"\n"
data += "Cookbook: \n"
data += "Section: \n"
data += "Image: \n"
recipeInstructions = []
recipeIngredient = []
for s in recipe.steps.all():
if s.type != Step.TIME:
recipeInstructions.append(s.instruction)
for i in s.ingredients.all():
recipeIngredient.append(f'{float(i.amount)} {i.unit} {i.food}')
data += "Ingredients: \n"
for ingredient in recipeIngredient:
data += ingredient+"\n"
data += "Instructions: \n"
for instruction in recipeInstructions:
data += instruction+"\n"
return recipe.name+'.txt', data
def get_files_from_recipes(self, recipes, el, cookie):
files = []
for r in recipes:
filename, data = self.get_file_from_recipe(r)
files.append([ filename, data ])
el.exported_recipes += 1
el.msg += self.get_recipe_processed_msg(r)
el.save()
return files

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-02-09 18:01+0100\n"
"PO-Revision-Date: 2021-04-12 20:22+0000\n"
"PO-Revision-Date: 2021-10-13 12:50+0000\n"
"Last-Translator: Hrachya Kocharyan <hkocharyan@ctemplar.com>\n"
"Language-Team: Armenian <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/hy/>\n"
@ -20,7 +20,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Generator: Weblate 4.5.3\n"
"X-Generator: Weblate 4.8\n"
#: .\cookbook\filters.py:22 .\cookbook\templates\base.html:87
#: .\cookbook\templates\forms\edit_internal_recipe.html:219
@ -79,7 +79,7 @@ msgid ""
"mobile data. If lower than instance limit it is reset when saving."
msgstr ""
"0-ն կանջատի ավտոմատ սինքրոնացումը։ Գնումների ցուցակը թարմացվում է "
"յուրաքանչյուր սահմանված վարկյանը մեկ, մեկ ուրիշի կատարած փոփոխությունները "
"յուրաքանչյուր սահմանված վարկյանը մեկ, ուրիշի կատարած փոփոխությունները "
"սինքրոնացնելու համար։ Հարմար է, երբ մեկից ավել մարդ է կատարում գնումները, "
"բայց կարող է օգտագործել բջջային ինտերնետ։"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@ class Command(BaseCommand):
def handle(self, *args, **options):
if settings.DATABASES['default']['ENGINE'] not in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']:
self.stdout.write(self.style.WARNING(_('Only Postgress databases use full text search, no index to rebuild')))
self.stdout.write(self.style.WARNING(_('Only Postgresql databases use full text search, no index to rebuild')))
try:
language = DICTIONARY.get(translation.get_language(), 'simple')

View File

@ -2,13 +2,15 @@
import annoying.fields
from django.conf import settings
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField, SearchVector
from django.contrib.postgres.search import SearchVector, SearchVectorField
from django.db import migrations, models
from django.db.models import deletion
from django_scopes import scopes_disabled
from django.utils import translation
from django_scopes import scopes_disabled
from cookbook.managers import DICTIONARY
from cookbook.models import Recipe, Step, Index, PermissionModelMixin, nameSearchField, allSearchFields
from cookbook.models import (Index, PermissionModelMixin, Recipe, Step, allSearchFields,
nameSearchField)
def set_default_search_vector(apps, schema_editor):
@ -16,8 +18,6 @@ def set_default_search_vector(apps, schema_editor):
return
language = DICTIONARY.get(translation.get_language(), 'simple')
with scopes_disabled():
# TODO this approach doesn't work terribly well if multiple languages are in use
# I'm also uncertain about forcing unaccent here
Recipe.objects.all().update(
name_search_vector=SearchVector('name__unaccent', weight='A', config=language),
desc_search_vector=SearchVector('description__unaccent', weight='B', config=language)

View File

@ -0,0 +1,57 @@
# Generated by Django 3.2.5 on 2021-08-14 15:40
from treebeard.mp_tree import MP_Node
from django.db import migrations, models
from django_scopes import scopes_disabled
# update if needed
steplen = MP_Node.steplen
alphabet = MP_Node.alphabet
node_order_by = ["name"]
def update_paths(apps, schema_editor):
with scopes_disabled():
Node = apps.get_model("cookbook", "Food")
nodes = Node.objects.all().order_by(*node_order_by)
for i, node in enumerate(nodes, 1):
# for default values, this resolves to: "{:04d}".format(i)
node.path = f"{{:{alphabet[0]}{steplen}d}}".format(i)
if nodes:
Node.objects.bulk_update(nodes, ["path"])
def backwards(apps, schema_editor):
"""nothing to do"""
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0149_fix_leading_trailing_spaces'),
]
operations = [
migrations.AddField(
model_name='food',
name='depth',
field=models.PositiveIntegerField(default=1),
preserve_default=False,
),
migrations.AddField(
model_name='food',
name='numchild',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='food',
name='path',
field=models.CharField(default=0, max_length=255, unique=False),
preserve_default=False,
),
migrations.RunPython(update_paths, backwards),
migrations.AlterField(
model_name="food",
name="path",
field=models.CharField(max_length=255, unique=True),
),
]

Some files were not shown because too many files have changed in this diff Show More