Compare commits

...

124 Commits
2.2.1 ... 2.3.2

Author SHA1 Message Date
vabene1111
602f0a8bf0 Merge branch 'master' of https://github.com/TandoorRecipes/recipes 2025-10-11 11:59:11 +02:00
vabene1111
673d12d233 increased default max body size for file uploads to 512 MB 2025-10-11 11:57:32 +02:00
vabene1111
6359245925 Merge branch 'develop' of https://github.com/TandoorRecipes/recipes into develop 2025-10-11 11:56:51 +02:00
vabene1111
a7c4822322 fixed cannoit create steps if none are present 2025-10-11 11:56:47 +02:00
vabene1111
e94419f320 Merge pull request #4138 from KoMa1012/KoMa1012-gunicorn-timeoutconfig
Update boot.sh
2025-10-11 11:55:48 +02:00
vabene1111
01f46483ff clearer batch delete warning 2025-10-11 11:52:52 +02:00
vabene1111
d6da5688af fixed 0 servings 2025-10-11 11:47:10 +02:00
vabene1111
680ae39201 fixed import button not switching to loading in app import 2025-10-11 11:38:58 +02:00
vabene1111
2472ee9c26 fixed api setting example 2025-10-11 11:05:20 +02:00
vabene1111
4428b06d4a fixed properties edge case with missing conversions 2025-10-11 11:04:00 +02:00
vabene1111
e9c38d7d5e added bottom margin to properties editor 2025-10-11 09:22:43 +02:00
vabene1111
6f28d58807 fixed and imporved food properties 2025-10-11 09:20:58 +02:00
vabene1111
88db611f0a Merge branch 'develop' of https://github.com/TandoorRecipes/recipes into develop 2025-10-11 09:02:14 +02:00
vabene1111
f3302b4014 fixed start page and ingredient templater 2025-10-11 09:02:10 +02:00
vabene1111
d4bb161275 Merge pull request #4147 from TandoorRecipes/dependabot/pip/python-ldap-3.4.5
Bump python-ldap from 3.4.4 to 3.4.5
2025-10-11 08:36:03 +02:00
dependabot[bot]
32f1538938 Bump python-ldap from 3.4.4 to 3.4.5
Bumps [python-ldap](https://github.com/python-ldap/python-ldap) from 3.4.4 to 3.4.5.
- [Release notes](https://github.com/python-ldap/python-ldap/releases)
- [Changelog](https://github.com/python-ldap/python-ldap/blob/python-ldap-3.4.5/CHANGES)
- [Commits](https://github.com/python-ldap/python-ldap/compare/python-ldap-3.4.4...python-ldap-3.4.5)

---
updated-dependencies:
- dependency-name: python-ldap
  dependency-version: 3.4.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-10 22:55:18 +00:00
KoMa1012
029baea4c7 Update configuration.md
added explanation for GUNICORN_TIMEOUT environmental variable
2025-10-09 12:51:25 +02:00
KoMa1012
38d1b7cef5 Update boot.sh
made gunicorn timout configurable via environmental variable GUNICORN_TIMEOUT this can help in scenarios where e.g. the LLM takes too much time to answer when using a local LLM
2025-10-08 20:09:51 +02:00
vabene1111
856f417d1b Merge branch 'develop' 2025-10-08 07:57:59 +02:00
vabene1111
85821bcc94 nginx config update 2025-10-08 07:42:09 +02:00
vabene1111
2345af8fd6 updated django 2025-10-05 13:11:34 +02:00
vabene1111
51107c64ee fixed unit merge with duplicate conversion 2025-10-05 13:10:38 +02:00
vabene1111
81983c5ae2 fixed default unit for first ingredient 2025-10-05 13:06:57 +02:00
vabene1111
f7713a43a7 fxied recipe properties editor and added recipe AI properties 2025-10-05 12:55:44 +02:00
vabene1111
ffd951a7f4 Merge branch 'develop' of https://github.com/TandoorRecipes/recipes into develop 2025-10-05 09:39:58 +02:00
vabene1111
319ac8e191 fixed recipe properties editor 2025-10-05 09:39:54 +02:00
vabene1111
aea247b4a3 Merge pull request #4091 from c0mputerguru/ical-test-fix
Requests using date should be in local timezone, not UTC as tandoor is timezone aware
2025-09-30 21:45:11 +02:00
vabene1111
e2843bb02f if at least one reciep 2025-09-30 21:41:20 +02:00
vabene1111
e3aa3e1137 always show at least one random recipe slider 2025-09-30 21:41:04 +02:00
vabene1111
da1187b03a fixed date editor missing from cook log editor 2025-09-30 21:39:35 +02:00
vabene1111
f9ed79978c improved mealie 1 importer 2025-09-30 21:36:37 +02:00
vabene1111
920a3ed4a3 fixed times cooked filter 2025-09-30 21:07:01 +02:00
vabene1111
2077eae142 fixed step sorter for import page 2025-09-30 20:55:12 +02:00
vabene1111
b1ef35e415 added default ordering for most models 2025-09-30 20:47:44 +02:00
vabene1111
0a687d840c fixed ingredients missing in sub recipe steps 2025-09-30 20:25:09 +02:00
vabene1111
6a3034b966 fixed merging in ingredient editor 2025-09-30 20:17:43 +02:00
Anand Patel
3d7afbfe4f Requests using date should be in local timezone, not UTC as tandoor is timezone aware. 2025-09-30 16:44:05 +00:00
vabene1111
02e43730bd fixed unit conversion division by 0 2025-09-29 22:14:19 +02:00
vabene1111
6adf077ee5 removed outside guincorn binding 2025-09-29 21:37:41 +02:00
vabene1111
d73ffa46ff added auto demo login link to docs index page 2025-09-29 21:21:13 +02:00
vabene1111
8572f338ad fixed ingredient insert focus error 2025-09-25 21:03:35 +02:00
vabene1111
920ec8e74b fixed missing pg extensions 2025-09-25 20:56:53 +02:00
vabene1111
2328bf2342 fixed mealie edgecases 2025-09-25 20:48:20 +02:00
vabene1111
85620a1431 Merge branch 'master' into develop 2025-09-25 12:33:43 +02:00
vabene1111
0037858885 fixed step editor layout 2025-09-25 12:33:39 +02:00
vabene1111
9df3ff0028 Merge branch 'develop' 2025-09-25 07:39:38 +02:00
vabene1111
0a43272126 fixed property editor page updateing with 0 values 2025-09-25 07:39:30 +02:00
vabene1111
ff96eb194f auto grow comment textarea 2025-09-25 07:37:40 +02:00
vabene1111
6b69c4184b fixed ingredients wihtout unit in steps overview 2025-09-25 07:31:45 +02:00
vabene1111
e90e21181c fixed sizing of ingredient input in recipe editor 2025-09-25 07:28:01 +02:00
vabene1111
5237228673 added patreon link 2025-09-24 19:54:36 +02:00
vabene1111
ecb3172085 increased storage token length 2025-09-24 19:24:33 +02:00
vabene1111
b4f4e9fd2b Merge branch 'develop' 2025-09-24 19:11:01 +02:00
vabene1111
6d0f3b99c8 deactivate for now 2025-09-24 18:56:45 +02:00
vabene1111
cdb94ae628 more nginx testing 2025-09-24 18:49:29 +02:00
vabene1111
0d589444fd fixed order 2025-09-24 18:34:53 +02:00
vabene1111
95fa420c3a updated nginx confi 2025-09-24 18:31:02 +02:00
vabene1111
dd4dc1083f add duplicate to recipe 2025-09-24 18:24:58 +02:00
vabene1111
04f889b742 fixed search updated at filter 2025-09-24 17:36:37 +02:00
vabene1111
67d374c071 always clear staticfiles directory on collect 2025-09-24 17:33:49 +02:00
vabene1111
8d749e351d django vite manifest path 2025-09-24 15:22:01 +02:00
vabene1111
417ffcab5d improved recipe editor layout 2025-09-24 07:46:15 +02:00
vabene1111
0c0012aab8 WIP recipe editor improvements 2025-09-23 07:58:38 +02:00
vabene1111
e562883da3 return steps in importer 2025-09-23 07:41:11 +02:00
vabene1111
a81bc335cc added AI properties import 2025-09-22 21:59:34 +02:00
vabene1111
ebee1ccd4b Merge branch 'develop' of https://github.com/TandoorRecipes/recipes into develop 2025-09-22 21:08:59 +02:00
vabene1111
b1104b4581 import button in shared recipes 2025-09-22 21:08:55 +02:00
vabene1111
f5e952d88c Merge pull request #4060 from Nailik/patch-1
Synology documentation update Tandoor 2
2025-09-22 20:29:54 +02:00
vabene1111
968fcc3936 fixed docs 2025-09-22 20:28:36 +02:00
vabene1111
73d3d87217 fixed open data import 2025-09-22 20:21:59 +02:00
vabene1111
9050f648f9 added special symbols and updated translations 2025-09-22 20:17:55 +02:00
vabene1111
a4a9e104b5 invite link button on UserSpace model Liste page 2025-09-22 20:02:56 +02:00
Nailik
3ed85ea0c4 Update synology.md 2025-09-22 14:16:53 +02:00
Nailik
da8ceb7abe Update synology.md 2025-09-22 14:06:25 +02:00
Nailik
5ff3a6bb2e Update synology.md 2025-09-22 14:02:42 +02:00
Vincenzo Reale
3ed750b330 Translated using Weblate (Italian)
Currently translated at 100.0% (488 of 488 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/it/
2025-09-22 10:09:35 +00:00
TC Kuo
0315911802 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 92.0% (795 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/zh_Hant/
2025-09-22 10:02:22 +00:00
TC Kuo
1fe96f2b3d Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 65.7% (568 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/zh_Hans/
2025-09-22 10:02:22 +00:00
TC Kuo
11761c0b15 Translated using Weblate (Ukrainian)
Currently translated at 35.9% (311 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/uk/
2025-09-22 10:02:22 +00:00
TC Kuo
a7b0a1ab30 Translated using Weblate (Turkish)
Currently translated at 65.7% (568 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/tr/
2025-09-22 10:02:22 +00:00
TC Kuo
e4fcae3b00 Translated using Weblate (Swedish)
Currently translated at 70.0% (605 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/sv/
2025-09-22 10:02:22 +00:00
TC Kuo
b0401639f1 Translated using Weblate (Slovenian)
Currently translated at 92.0% (795 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/sl/
2025-09-22 10:02:22 +00:00
TC Kuo
5f8770f502 Translated using Weblate (Russian)
Currently translated at 91.6% (792 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/ru/
2025-09-22 10:02:22 +00:00
TC Kuo
aaa627d3b6 Translated using Weblate (Romanian)
Currently translated at 55.3% (478 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/ro/
2025-09-22 10:02:22 +00:00
TC Kuo
e77734f696 Translated using Weblate (Portuguese (Brazil))
Currently translated at 75.9% (656 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/pt_BR/
2025-09-22 10:02:22 +00:00
TC Kuo
73df6bb961 Translated using Weblate (Polish)
Currently translated at 68.7% (594 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/pl/
2025-09-22 10:02:22 +00:00
Justin Straver
7d187b638e Translated using Weblate (Dutch)
Currently translated at 92.2% (797 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/nl/
2025-09-22 10:02:22 +00:00
TC Kuo
68bb750f8c Translated using Weblate (Dutch)
Currently translated at 92.2% (797 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/nl/
2025-09-22 10:02:22 +00:00
TC Kuo
1e35035540 Translated using Weblate (Norwegian Bokmål)
Currently translated at 46.4% (401 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/nb_NO/
2025-09-22 10:02:22 +00:00
Vincenzo Reale
561ba2f1da Translated using Weblate (Italian)
Currently translated at 100.0% (864 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/it/
2025-09-22 10:02:22 +00:00
TC Kuo
bd600301f9 Translated using Weblate (Italian)
Currently translated at 100.0% (864 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/it/
2025-09-22 10:02:22 +00:00
TC Kuo
cf5483a4d9 Translated using Weblate (Indonesian)
Currently translated at 16.5% (143 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/id/
2025-09-22 10:02:22 +00:00
TC Kuo
ba417c49dd Translated using Weblate (Hungarian)
Currently translated at 53.7% (464 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/hu/
2025-09-22 10:02:22 +00:00
TC Kuo
0259b1dc08 Translated using Weblate (Croatian)
Currently translated at 65.7% (568 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/hr/
2025-09-22 10:02:22 +00:00
TC Kuo
34553dadd7 Translated using Weblate (Hebrew)
Currently translated at 65.7% (568 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/he/
2025-09-22 10:02:22 +00:00
TC Kuo
535b88c8db Translated using Weblate (French)
Currently translated at 89.2% (771 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/fr/
2025-09-22 10:02:22 +00:00
TC Kuo
71eb8818b5 Translated using Weblate (Finnish)
Currently translated at 59.6% (515 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/fi/
2025-09-22 10:02:21 +00:00
TC Kuo
0dc94a817f Translated using Weblate (Spanish)
Currently translated at 88.0% (761 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/es/
2025-09-22 10:02:21 +00:00
TC Kuo
4df862c7f3 Translated using Weblate (Greek)
Currently translated at 65.7% (568 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/el/
2025-09-22 10:02:21 +00:00
TC Kuo
69f013c980 Translated using Weblate (Danish)
Currently translated at 65.7% (568 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/da/
2025-09-22 10:02:21 +00:00
TC Kuo
23c420dda8 Translated using Weblate (Czech)
Currently translated at 64.6% (559 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/cs/
2025-09-22 10:02:21 +00:00
TC Kuo
63daf1e958 Translated using Weblate (Catalan)
Currently translated at 65.7% (568 of 864 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/ca/
2025-09-22 10:02:21 +00:00
Vincenzo Reale
52b44eacdd Translated using Weblate (Italian)
Currently translated at 100.0% (488 of 488 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/it/
2025-09-22 10:02:21 +00:00
vabene1111
fa7cc12b99 added AI step sorter 2025-09-22 08:21:26 +02:00
vabene1111
64d2108ef6 locales 2025-09-21 13:16:16 +02:00
vabene1111
dccfdcc11c Merge branch 'develop' of http://translate.tandoor.dev/git/tandoor/recipes-backend into develop
# Conflicts:
#	vue3/src/locales/pt.json
2025-09-21 12:47:20 +02:00
vabene1111
974e72631d improved modeldeletepage, hide when not needed 2025-09-21 10:23:04 +02:00
vabene1111
70f31b8553 fxied api food test: foods can be deleted even when part of a recipe 2025-09-21 10:22:54 +02:00
vabene1111
3cb980c0e7 model delete page done 2025-09-21 09:01:07 +02:00
vabene1111
b8a403b7c1 playing with delete confirm view 2025-09-20 19:04:51 +02:00
vabene1111
b037d90220 proper function for delete relation views 2025-09-20 12:24:25 +02:00
vabene1111
ad32e457fa basics of delete collector logic 2025-09-20 12:05:29 +02:00
vabene1111
8e2726caeb improved step sorting 2025-09-20 10:53:44 +02:00
vabene1111
e693737c57 fixed AI ordering and VNumberInput decimal seperator 2025-09-20 10:42:39 +02:00
vabene1111
9f239c06d3 fixed unglobal of AI provider 2025-09-20 10:12:31 +02:00
vabene1111
0f551c5f88 improved german translation for keyword 2025-09-20 10:03:29 +02:00
vabene1111
eb224a769d Merge branch 'develop' 2025-09-19 17:02:10 +02:00
vabene1111
4515eba9d7 fixed ai provider admin and prevent accidental update 2025-09-19 17:02:01 +02:00
vabene1111
30b37bf0b6 fixed ai credit system 2025-09-19 16:49:30 +02:00
vabene1111
f17207e56e protect endpoint WIP 2025-09-19 16:22:20 +02:00
vabene1111
2cba0e18af Merge branch 'develop' 2025-09-19 16:20:56 +02:00
vabene1111
ec6e81316a fixed unwanted redirect to start page 2025-09-19 16:20:48 +02:00
Whalysonramos
a35c92439c Translated using Weblate (Portuguese)
Currently translated at 43.9% (350 of 797 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/pt/
2025-08-20 19:54:18 +00:00
Whalysonramos
eed09a7891 Translated using Weblate (Portuguese)
Currently translated at 43.7% (349 of 797 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/pt/
2025-08-20 19:54:17 +00:00
223 changed files with 39159 additions and 38292 deletions

View File

@@ -30,9 +30,11 @@
![Preview](docs/preview.png)
## 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
- 🪄 **use AI** to recognize images, sort recipe steps, find nutrition facts and more
- 📚 **Cookbooks** - collect recipes into books
- 👪 **Share and collaborate** on recipes with friends and family
@@ -62,12 +64,13 @@ a public page.
Documentation can be found [here](https://docs.tandoor.dev/).
## Support our work
## ❤️ Support our work ❤️
Tandoor is developed by volunteers in their free time just because its fun. That said earning
some money with the project allows us to spend more time on it and thus make improvements we otherwise couldn't.
Because of that there are several ways you can support us
- **GitHub Sponsors** You can sponsor contributors of this project on GitHub: [vabene1111](https://github.com/sponsors/vabene1111)
- **Patron** You can sponsor contributors of this project on Patron: [vabene111](https://www.patreon.com/cw/vabene1111)
- **Host at Hetzner** We have been very happy customers of Hetzner for multiple years for all of our projects. If you want to get into self-hosting or are tired of the expensive big providers, their cloud servers are a great place to get started. When you sign up via our [referral link](https://hetzner.cloud/?ref=ISdlrLmr9kGj) you will get 20€ worth of cloud credits and we get a small kickback too.
- **Let us host for you** We are offering a [hosted version](https://app.tandoor.dev) where all profits support us and the development of tandoor (currently only available in germany).

31
boot.sh
View File

@@ -2,7 +2,7 @@
source venv/bin/activate
# these are envsubst in the nginx config, make sure they default to something sensible when unset
export TANDOOR_PORT="${TANDOOR_PORT:-8080}"
export TANDOOR_PORT="${TANDOOR_PORT:-80}"
export MEDIA_ROOT=${MEDIA_ROOT:-/opt/recipes/mediafiles};
export STATIC_ROOT=${STATIC_ROOT:-/opt/recipes/staticfiles};
@@ -12,16 +12,18 @@ GUNICORN_LOG_LEVEL="${GUNICORN_LOG_LEVEL:-'info'}"
PLUGINS_BUILD="${PLUGINS_BUILD:-0}"
if [ "${TANDOOR_PORT}" -eq 80 ]; then
echo "TANDOOR_PORT set to 8080 because 80 is now taken by the integrated nginx"
TANDOOR_PORT=8080
fi
display_warning() {
echo "[WARNING]"
echo -e "$1"
}
# prepare nginx config
envsubst '$MEDIA_ROOT $STATIC_ROOT $TANDOOR_PORT' < /opt/recipes/http.d/Recipes.conf.template > /opt/recipes/http.d/Recipes.conf
# start nginx early to display error pages
echo "Starting nginx"
nginx
echo "Checking configuration..."
# SECRET_KEY (or a valid file at SECRET_KEY_FILE) must be set in .env file
@@ -93,7 +95,7 @@ fi
echo "Collecting static files, this may take a while..."
python manage.py collectstatic --noinput
python manage.py collectstatic --noinput --clear
echo "Done"
@@ -101,17 +103,6 @@ chmod -R 755 ${MEDIA_ROOT:-/opt/recipes/mediafiles}
ipv6_disable=$(cat /sys/module/ipv6/parameters/disable)
# prepare nginx config
envsubst '$MEDIA_ROOT $STATIC_ROOT $TANDOOR_PORT' < /opt/recipes/http.d/Recipes.conf.template > /opt/recipes/http.d/Recipes.conf
# start nginx
echo "Starting nginx"
nginx
echo "Starting gunicorn"
# Check if IPv6 is enabled, only then run gunicorn with ipv6 support
if [ "$ipv6_disable" -eq 0 ]; then
exec gunicorn -b "[::]:$TANDOOR_PORT" --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
else
exec gunicorn -b ":$TANDOOR_PORT" --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
fi
exec gunicorn --bind unix:/run/tandoor.sock --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --timeout ${GUNICORN_TIMEOUT:-30} --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi

View File

@@ -91,8 +91,8 @@ admin.site.register(SearchPreference, SearchPreferenceAdmin)
class AiProviderAdmin(admin.ModelAdmin):
list_display = ('name', 'space', 'model',)
search_fields = ('name', 'space', 'model',)
list_display = ('name', 'space', 'model_name',)
search_fields = ('name', 'space', 'model_name',)
admin.site.register(AiProvider, AiProviderAdmin)

View File

@@ -33,12 +33,14 @@ class AiCallbackHandler(CustomLogger):
space = None
user = None
ai_provider = None
function = None
def __init__(self, space, user, ai_provider):
def __init__(self, space, user, ai_provider, function):
super().__init__()
self.space = space
self.user = user
self.ai_provider = ai_provider
self.function = function
def log_pre_api_call(self, model, messages, kwargs):
pass
@@ -62,7 +64,7 @@ class AiCallbackHandler(CustomLogger):
remaining_balance = self.space.ai_credits_balance - Decimal(str(credit_cost))
if remaining_balance < 0:
remaining_balance = 0
if settings.HOSTED:
if settings.HOSTED and self.space.ai_credits_monthly == 0:
self.space.ai_enabled = False
self.space.ai_credits_balance = remaining_balance
@@ -77,7 +79,7 @@ class AiCallbackHandler(CustomLogger):
end_time=end_time,
input_tokens=response_obj['usage']['prompt_tokens'],
output_tokens=response_obj['usage']['completion_tokens'],
function=AiLog.F_FILE_IMPORT,
function=self.function,
credit_cost=credit_cost,
credits_from_balance=credits_from_balance,
)

View File

@@ -51,10 +51,10 @@ class OpenDataImporter:
for field in field_list:
if isinstance(getattr(obj, field), float) or isinstance(getattr(obj, field), Decimal):
if abs(float(getattr(obj, field)) - float(existing_obj[field])) > 0.001: # convert both to float and check if basically equal
print(f'comparing FLOAT {obj} failed because field {field} is not equal ({getattr(obj, field)} != {existing_obj[field]})')
#print(f'comparing FLOAT {obj} failed because field {field} is not equal ({getattr(obj, field)} != {existing_obj[field]})')
return False
elif getattr(obj, field) != existing_obj[field]:
print(f'comparing {obj} failed because field {field} is not equal ({getattr(obj, field)} != {existing_obj[field]})')
#print(f'comparing {obj} failed because field {field} is not equal ({getattr(obj, field)} != {existing_obj[field]})')
return False
return True
@@ -342,7 +342,7 @@ class OpenDataImporter:
'name': self.data[datatype][k]['name'],
'plural_name': self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None,
'supermarket_category_id': self.slug_id_cache['category'][self.data[datatype][k]['store_category']] if self.data[datatype][k]['store_category'] in self.slug_id_cache['category'] else None,
'fdc_id': re.sub(r'\D', '', self.data[datatype][k]['fdc_id']) if self.data[datatype][k]['fdc_id'] != '' else None,
'fdc_id': re.sub(r'\D', '', str(self.data[datatype][k]['fdc_id'])) if self.data[datatype][k]['fdc_id'] != '' else None,
'open_data_slug': k,
'properties_food_unit_id': None,
'space_id': self.request.space.id,

View File

@@ -48,7 +48,7 @@ class FoodPropertyHelper:
found_property = False
# if food has a value for the given property type (no matter if conversion is possible)
has_property_value = False
if i.food.properties_food_amount == 0 or i.food.properties_food_unit is None and not (i.amount == 0 or i.no_amount): # if food is configured incorrectly
if (i.food.properties_food_amount == 0 or i.food.properties_food_unit is None) and not (i.amount == 0 or i.no_amount): # if food is configured incorrectly
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': {'id': i.food.id, 'name': i.food.name}, 'value': None}
computed_properties[pt.id]['missing_value'] = True
else:
@@ -63,8 +63,9 @@ class FoodPropertyHelper:
computed_properties[p.property_type.id]['food_values'], c.food.id, (c.amount / i.food.properties_food_amount) * p.property_amount, c.food)
if not found_property:
# if no amount and food does not exist yet add it but don't count as missing
if i.amount == 0 or i.no_amount and i.food.id not in computed_properties[pt.id]['food_values']:
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': {'id': i.food.id, 'name': i.food.name}, 'value': 0}
if i.amount == 0 or i.no_amount:
if i.food.id not in computed_properties[pt.id]['food_values']:
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': {'id': i.food.id, 'name': i.food.name}, 'value': 0}
# if amount is present but unit is missing indicate it in the result
elif i.unit is None:
if i.food.id not in computed_properties[pt.id]['food_values']:
@@ -72,7 +73,8 @@ class FoodPropertyHelper:
computed_properties[pt.id]['food_values'][i.food.id]['missing_unit'] = True
else:
computed_properties[pt.id]['missing_value'] = True
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': {'id': i.food.id, 'name': i.food.name}, 'value': None}
if i.food.id not in computed_properties[pt.id]['food_values']:
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': {'id': i.food.id, 'name': i.food.name}, 'value': None}
if has_property_value and i.unit is not None:
computed_properties[pt.id]['food_values'][i.food.id]['missing_conversion'] = {'base_unit': {'id': i.unit.id, 'name': i.unit.name}, 'converted_unit': {'id': i.food.properties_food_unit.id, 'name': i.food.properties_food_unit.name}}
@@ -82,8 +84,12 @@ class FoodPropertyHelper:
# TODO move to central helper ? --> use defaultdict
@staticmethod
def add_or_create(d, key, value, food):
if key in d and d[key]['value']:
d[key]['value'] += value
if key in d:
# value can be None if a previous instance of the same food was missing a conversion
if d[key]['value']:
d[key]['value'] += value
else:
d[key]['value'] = value
else:
d[key] = {'id': food.id, 'food': {'id': food.id, 'name': food.name}, 'value': value}
return d

View File

@@ -288,7 +288,7 @@ class RecipeSearch():
def _updated_on_filter(self):
if self._updatedon:
self._queryset = self._queryset.filter(updated_at__date__date=self._updatedon)
self._queryset = self._queryset.filter(updated_at__date=self._updatedon)
elif self._updatedon_lte:
self._queryset = self._queryset.filter(updated_at__date__lte=self._updatedon_lte)
elif self._updatedon_gte:
@@ -326,7 +326,7 @@ class RecipeSearch():
def _favorite_recipes(self):
if self._sort_includes('favorite') or self._timescooked or self._timescooked_gte or self._timescooked_lte:
less_than = self._timescooked_lte and not self._sort_includes('-favorite')
if less_than:
if less_than or self._timescooked == 0:
default = 1000
else:
default = 0
@@ -339,7 +339,7 @@ class RecipeSearch():
self._queryset = self._queryset.annotate(favorite=Coalesce(Subquery(favorite_recipes), default))
if self._timescooked:
self._queryset = self._queryset.filter(favorite=0)
self._queryset = self._queryset.filter(favorite=self._timescooked)
elif self._timescooked_lte:
self._queryset = self._queryset.filter(favorite__lte=int(self._timescooked_lte)).exclude(favorite=0)
elif self._timescooked_gte:

View File

@@ -72,7 +72,8 @@ def get_from_scraper(scrape, request):
# assign servings attributes
try:
# dont use scrape.yields() as this will always return "x servings" or "x items", should be improved in scrapers directly
servings = scrape.schema.data.get('recipeYield') or 1
# max(x,1) to prevent 0 servings which breaks scaling
servings = max(scrape.schema.data.get('recipeYield') or 1, 1)
except Exception:
servings = 1
@@ -155,7 +156,7 @@ def get_from_scraper(scrape, request):
# assign steps
try:
for i in parse_instructions(scrape.instructions()):
for i in parse_instructions(scrape.instructions_list()):
recipe_json['steps'].append({
'instruction': i,
'ingredients': [],
@@ -177,11 +178,11 @@ def get_from_scraper(scrape, request):
for x in scrape.ingredients():
if x.strip() != '':
try:
amount, unit, ingredient, note = ingredient_parser.parse(x)
amount, unit, food, note = ingredient_parser.parse(x)
ingredient = {
'amount': amount,
'food': {
'name': ingredient,
'name': food,
},
'unit': None,
'note': note,
@@ -315,14 +316,29 @@ def clean_instruction_string(instruction):
# handle unsupported, special UTF8 character in Thermomix-specific instructions,
# that happen in nearly every recipe on Cookidoo, Zaubertopf Club, Rezeptwelt
# and in Thermomix-specific recipes on many other sites
return normalized_string \
.replace("", _('reverse rotation')) \
.replace("", _('careful rotation')) \
.replace("", _('knead')) \
.replace("", _('thicken')) \
.replace("", _('warm up')) \
.replace("", _('ferment')) \
.replace("", _("sous-vide"))
normalized_string = normalized_string \
.replace(u"\uE003", _('reverse rotation')) \
.replace(u"\uE002", _('careful rotation')) \
.replace(u"\uE001", _('knead')) \
.replace(u"\uE031", _('thicken')) \
.replace(u"\uE019", _('warm up')) \
.replace(u"\uE02E", _('ferment')) \
.replace(u"\uE018", _('slow cook')) \
.replace(u"\uE033", _('egg boiler')) \
.replace(u"\uE016", _('kettle')) \
.replace(u"\uE01E", _('blend')) \
.replace(u"\uE011", _('pre-clean')) \
.replace(u"\uE026", _('high temperature')) \
.replace(u"\uE00D", _('rice cooker')) \
.replace(u"\uE00C", _('caramelize')) \
.replace(u"\uE038", _('peeler')) \
.replace(u"\uE037", _('slicer')) \
.replace(u"\uE036", _('grater')) \
.replace(u"\uE04C", _('spiralizer')) \
.replace(u"\uE02D", _("sous-vide"))
return normalized_string
def parse_instructions(instructions):

View File

@@ -135,8 +135,9 @@ class UnitConversionHelper:
:param food: base food
:return: converted ingredient object from base amount/unit/food
"""
if uc.food is None or uc.food == food:
if (uc.food is None or uc.food == food) and uc.converted_amount > 0 and uc.base_amount > 0:
if unit == uc.base_unit:
return Ingredient(amount=amount * (uc.converted_amount / uc.base_amount), unit=uc.converted_unit, food=food, space=self.space)
else:
return Ingredient(amount=amount * (uc.base_amount / uc.converted_amount), unit=uc.base_unit, food=food, space=self.space)
return None

View File

@@ -128,6 +128,7 @@ class Mealie1(Integration):
steps_relation = []
first_step_of_recipe_dict = {}
step_id_dict = {}
for s in mealie_database['recipe_instructions']:
if s['recipe_id'] in recipes_dict:
step = Step.objects.create(instruction=(s['text'] if s['text'] else "") + (f" \n {s['summary']}" if s['summary'] else ""),
@@ -135,9 +136,20 @@ class Mealie1(Integration):
name=s['title'],
space=self.request.space)
steps_relation.append(Recipe.steps.through(recipe_id=recipes_dict[s['recipe_id']], step_id=step.pk))
step_id_dict[s["id"]] = step.pk
if s['recipe_id'] not in first_step_of_recipe_dict:
first_step_of_recipe_dict[s['recipe_id']] = step.pk
# it is possible for a recipe to not have steps but have ingredients, in that case create an empty step to add them to later
for r in recipes_dict.keys():
if r not in first_step_of_recipe_dict:
step = Step.objects.create(instruction='',
order=0,
name='',
space=self.request.space)
steps_relation.append(Recipe.steps.through(recipe_id=recipes_dict[r], step_id=step.pk))
first_step_of_recipe_dict[r] = step.pk
for n in mealie_database['notes']:
if n['recipe_id'] in recipes_dict:
step = Step.objects.create(instruction=n['text'],
@@ -153,6 +165,11 @@ class Mealie1(Integration):
self.import_log.msg += f"Importing {len(mealie_database["recipes_ingredients"])} ingredients...\n"
self.import_log.save()
# mealie stores the reference to a step (instruction) from an ingredient (reference) in the recipe_ingredient_ref_link table
recipe_ingredient_ref_link_dict = {}
for ref in mealie_database['recipe_ingredient_ref_link']:
recipe_ingredient_ref_link_dict[ref["reference_id"]] = ref["instruction_id"]
ingredients_relation = []
for i in mealie_database['recipes_ingredients']:
if i['recipe_id'] in recipes_dict:
@@ -162,18 +179,18 @@ class Mealie1(Integration):
is_header=True,
space=self.request.space,
)
ingredients_relation.append(Step.ingredients.through(step_id=first_step_of_recipe_dict[i['recipe_id']], ingredient_id=title_ingredient.pk))
ingredients_relation.append(Step.ingredients.through(step_id=get_step_id(i, first_step_of_recipe_dict, step_id_dict,recipe_ingredient_ref_link_dict), ingredient_id=title_ingredient.pk))
if i['food_id']:
ingredient = Ingredient.objects.create(
food_id=foods_dict[i['food_id']] if i['food_id'] in foods_dict else None,
unit_id=units_dict[i['unit_id']] if i['unit_id'] in units_dict else None,
original_text=i['original_text'],
order=i['position'],
amount=i['quantity'],
amount=i['quantity'] if i['quantity'] else 0,
note=i['note'],
space=self.request.space,
)
ingredients_relation.append(Step.ingredients.through(step_id=first_step_of_recipe_dict[i['recipe_id']], ingredient_id=ingredient.pk))
ingredients_relation.append(Step.ingredients.through(step_id=get_step_id(i, first_step_of_recipe_dict, step_id_dict,recipe_ingredient_ref_link_dict), ingredient_id=ingredient.pk))
elif i['note'].strip():
amount, unit, food, note = ingredient_parser.parse(i['note'].strip())
f = ingredient_parser.get_food(food)
@@ -186,7 +203,7 @@ class Mealie1(Integration):
original_text=i['original_text'],
space=self.request.space,
)
ingredients_relation.append(Step.ingredients.through(step_id=first_step_of_recipe_dict[i['recipe_id']], ingredient_id=ingredient.pk))
ingredients_relation.append(Step.ingredients.through(step_id=get_step_id(i, first_step_of_recipe_dict, step_id_dict,recipe_ingredient_ref_link_dict), ingredient_id=ingredient.pk))
Step.ingredients.through.objects.bulk_create(ingredients_relation)
self.import_log.msg += f"Importing {len(mealie_database["recipes_to_categories"]) + len(mealie_database["recipes_to_tags"])} category and keyword relations...\n"
@@ -340,3 +357,10 @@ class Mealie1(Integration):
def get_file_from_recipe(self, recipe):
raise NotImplementedError('Method not implemented in storage integration')
def get_step_id(i, first_step_of_recipe_dict, step_id_dict, recipe_ingredient_ref_link_dict):
try:
return step_id_dict[recipe_ingredient_ref_link_dict[i['reference_id']]]
except KeyError:
return first_step_of_recipe_dict[i['recipe_id']]

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

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

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

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,7 @@ import uuid
from django.conf import settings
from django.db import migrations, models
from cookbook.models import SearchFields
from django.contrib.postgres.operations import TrigramExtension, UnaccentExtension
def allSearchFields():
return list(SearchFields.objects.values_list('id', flat=True))
@@ -141,6 +141,8 @@ class Migration(migrations.Migration):
]
operations = [
TrigramExtension(),
UnaccentExtension(),
migrations.RunPython(create_default_groups),
migrations.CreateModel(
name='AiProvider',

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.2.6 on 2025-09-24 17:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0228_space_space_setup_completed'),
]
operations = [
migrations.AlterModelOptions(
name='ailog',
options={'ordering': ('-created_at',)},
),
migrations.AlterModelOptions(
name='aiprovider',
options={'ordering': ('id',)},
),
migrations.AlterField(
model_name='storage',
name='token',
field=models.CharField(blank=True, max_length=4098, null=True),
),
]

View File

@@ -0,0 +1,15 @@
# Generated by Django 5.2.6 on 2025-09-25 18:56
from django.db import migrations
from django.contrib.postgres.operations import TrigramExtension, UnaccentExtension
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0229_alter_ailog_options_alter_aiprovider_options_and_more'),
]
operations = [
TrigramExtension(),
UnaccentExtension(),
]

View File

@@ -0,0 +1,141 @@
# Generated by Django 5.2.6 on 2025-09-30 18:47
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('cookbook', '0230_auto_20250925_2056'),
]
operations = [
migrations.AlterModelOptions(
name='aiprovider',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='automation',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='bookmarkletimport',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='comment',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='connectorconfig',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='cooklog',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='customfilter',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='exportlog',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='food',
options={'ordering': ('name',)},
),
migrations.AlterModelOptions(
name='importlog',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='invitelink',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='keyword',
options={'ordering': ('name',)},
),
migrations.AlterModelOptions(
name='mealplan',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='mealtype',
options={'ordering': ('name',)},
),
migrations.AlterModelOptions(
name='recipe',
options={'ordering': ('name',)},
),
migrations.AlterModelOptions(
name='recipebook',
options={'ordering': ('name',)},
),
migrations.AlterModelOptions(
name='recipeimport',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='sharelink',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='shoppinglistentry',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='shoppinglistrecipe',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='space',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='storage',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='supermarket',
options={'ordering': ('name',)},
),
migrations.AlterModelOptions(
name='supermarketcategory',
options={'ordering': ('name',)},
),
migrations.AlterModelOptions(
name='sync',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='synclog',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='telegrambot',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='unit',
options={'ordering': ('name',)},
),
migrations.AlterModelOptions(
name='unitconversion',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='userfile',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='userspace',
options={'ordering': ('pk',)},
),
migrations.AlterModelOptions(
name='viewlog',
options={'ordering': ('pk',)},
),
]

View File

@@ -402,6 +402,9 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
def __str__(self):
return self.name
class Meta:
ordering = ('pk',)
class AiProvider(models.Model):
name = models.CharField(max_length=128)
@@ -417,9 +420,18 @@ class AiProvider(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class Meta:
ordering = ('pk',)
class AiLog(models.Model, PermissionModelMixin):
F_FILE_IMPORT = 'FILE_IMPORT'
F_STEP_SORT = 'STEP_SORT'
F_FOOD_PROPERTIES = 'FOOD_PROPERTIES'
F_RECIPE_PROPERTIES = 'RECIPE_PROPERTIES'
ai_provider = models.ForeignKey(AiProvider, on_delete=models.SET_NULL, null=True)
function = models.CharField(max_length=64)
@@ -437,6 +449,12 @@ class AiLog(models.Model, PermissionModelMixin):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.function} {self.ai_provider.name} {self.created_at}"
class Meta:
ordering = ('-created_at',)
class ConnectorConfig(models.Model, PermissionModelMixin):
HOMEASSISTANT = 'HomeAssistant'
@@ -462,6 +480,9 @@ class ConnectorConfig(models.Model, PermissionModelMixin):
space = models.ForeignKey(Space, on_delete=models.CASCADE)
objects = ScopedManager(space='space')
class Meta:
ordering = ('pk',)
class UserPreference(models.Model, PermissionModelMixin):
# Themes
@@ -565,6 +586,9 @@ class UserSpace(models.Model, PermissionModelMixin):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ('pk',)
class Storage(models.Model, PermissionModelMixin):
DROPBOX = 'DB'
@@ -578,7 +602,7 @@ class Storage(models.Model, PermissionModelMixin):
)
username = models.CharField(max_length=128, blank=True, null=True)
password = models.CharField(max_length=128, blank=True, null=True)
token = models.CharField(max_length=512, blank=True, null=True)
token = models.CharField(max_length=4098, blank=True, null=True)
url = models.URLField(blank=True, null=True)
path = models.CharField(blank=True, default='', max_length=256)
created_by = models.ForeignKey(User, on_delete=models.PROTECT)
@@ -589,6 +613,9 @@ class Storage(models.Model, PermissionModelMixin):
def __str__(self):
return self.name
class Meta:
ordering = ('pk',)
class Sync(models.Model, PermissionModelMixin):
storage = models.ForeignKey(Storage, on_delete=models.PROTECT)
@@ -604,6 +631,9 @@ class Sync(models.Model, PermissionModelMixin):
def __str__(self):
return self.path
class Meta:
ordering = ('pk',)
class SupermarketCategory(models.Model, PermissionModelMixin, MergeModelMixin):
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
@@ -629,6 +659,7 @@ class SupermarketCategory(models.Model, PermissionModelMixin, MergeModelMixin):
models.UniqueConstraint(fields=['space', 'name'], name='smc_unique_name_per_space'),
models.UniqueConstraint(fields=['space', 'open_data_slug'], name='supermarket_category_unique_open_data_slug_per_space')
]
ordering = ('name',)
class Supermarket(models.Model, PermissionModelMixin):
@@ -648,6 +679,7 @@ class Supermarket(models.Model, PermissionModelMixin):
models.UniqueConstraint(fields=['space', 'name'], name='sm_unique_name_per_space'),
models.UniqueConstraint(fields=['space', 'open_data_slug'], name='supermarket_unique_open_data_slug_per_space')
]
ordering = ('name',)
class SupermarketCategoryRelation(models.Model, PermissionModelMixin):
@@ -679,6 +711,9 @@ class SyncLog(models.Model, PermissionModelMixin):
def __str__(self):
return f"{self.created_at}:{self.sync} - {self.status}"
class Meta:
ordering = ('pk',)
class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelMixin):
if SORT_TREE_BY_NAME:
@@ -696,6 +731,7 @@ class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelM
models.UniqueConstraint(fields=['space', 'name'], name='kw_unique_name_per_space')
]
indexes = (Index(fields=['id', 'name']),)
ordering = ('name',)
class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixin, MergeModelMixin):
@@ -727,6 +763,7 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
models.UniqueConstraint(fields=['space', 'name'], name='u_unique_name_per_space'),
models.UniqueConstraint(fields=['space', 'open_data_slug'], name='unit_unique_open_data_slug_per_space')
]
ordering = ('name',)
class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
@@ -793,14 +830,7 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
self.delete()
return target
def delete(self):
if self.ingredient_set.all().exclude(step=None).count() > 0:
raise ProtectedError(self.name + _(" is part of a recipe step and cannot be deleted"), self.ingredient_set.all().exclude(step=None))
else:
return super().delete()
# MP_Tree move uses raw SQL to execute move, override behavior to force a save triggering post_save signal
def move(self, *args, **kwargs):
super().move(*args, **kwargs)
# treebeard bypasses ORM, need to explicity save to trigger post save signals retrieve the object again to avoid writing previous state back to disk
@@ -867,6 +897,7 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
Index(fields=['id']),
Index(fields=['name']),
)
ordering = ('name',)
class UnitConversion(ExportModelOperationsMixin('unit_conversion'), models.Model, PermissionModelMixin):
@@ -893,6 +924,7 @@ class UnitConversion(ExportModelOperationsMixin('unit_conversion'), models.Model
models.UniqueConstraint(fields=['space', 'base_unit', 'converted_unit', 'food'], name='f_unique_conversion_per_space'),
models.UniqueConstraint(fields=['space', 'open_data_slug'], name='unit_conversion_unique_open_data_slug_per_space')
]
ordering = ('pk',)
class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, PermissionModelMixin):
@@ -1097,13 +1129,14 @@ class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModel
sub_food_recipes = Q(id__in=Food.objects.filter(ingredient__step__recipe__in=related_recipes).exclude(recipe=None).values_list('recipe'))
return Recipe.objects.filter(Q(id__in=related_recipes.values_list('id')) | sub_step_recipes | sub_food_recipes)
class Meta():
class Meta:
indexes = (
GinIndex(fields=["name_search_vector"]),
GinIndex(fields=["desc_search_vector"]),
Index(fields=['id']),
Index(fields=['name']),
)
ordering = ('name',)
class Comment(ExportModelOperationsMixin('comment'), models.Model, PermissionModelMixin):
@@ -1124,6 +1157,9 @@ class Comment(ExportModelOperationsMixin('comment'), models.Model, PermissionMod
def __str__(self):
return self.text
class Meta:
ordering = ('pk',)
class RecipeImport(models.Model, PermissionModelMixin):
@@ -1152,6 +1188,9 @@ class RecipeImport(models.Model, PermissionModelMixin):
self.delete()
return recipe
class Meta:
ordering = ('pk',)
class RecipeBook(ExportModelOperationsMixin('book'), models.Model, PermissionModelMixin):
name = models.CharField(max_length=128)
@@ -1169,6 +1208,7 @@ class RecipeBook(ExportModelOperationsMixin('book'), models.Model, PermissionMod
class Meta():
indexes = (Index(fields=['name']),)
ordering = ('name',)
class RecipeBookEntry(ExportModelOperationsMixin('book_entry'), models.Model, PermissionModelMixin):
@@ -1214,6 +1254,7 @@ class MealType(models.Model, PermissionModelMixin):
constraints = [
models.UniqueConstraint(fields=['space', 'name', 'created_by'], name='mt_unique_name_per_space'),
]
ordering = ('name',)
class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, PermissionModelMixin):
@@ -1241,6 +1282,9 @@ class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, Permission
def __str__(self):
return f'{self.get_label()} - {self.from_date} - {self.meal_type.name}'
class Meta:
ordering = ('pk',)
class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), models.Model, PermissionModelMixin):
name = models.CharField(max_length=32, blank=True, default='')
@@ -1256,6 +1300,9 @@ class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), mod
def __str__(self):
return f'Shopping list recipe {self.id} - {self.recipe}'
class Meta:
ordering = ('pk',)
class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), models.Model, PermissionModelMixin):
list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True, related_name='entries')
@@ -1287,6 +1334,9 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model
except AttributeError:
return None
class Meta:
ordering = ('pk',)
class ShareLink(ExportModelOperationsMixin('share_link'), models.Model, PermissionModelMixin):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
@@ -1302,6 +1352,9 @@ class ShareLink(ExportModelOperationsMixin('share_link'), models.Model, Permissi
def __str__(self):
return f'{self.recipe} - {self.uuid}'
class Meta:
ordering = ('pk',)
def default_valid_until():
return date.today() + timedelta(days=14)
@@ -1325,6 +1378,9 @@ class InviteLink(ExportModelOperationsMixin('invite_link'), models.Model, Permis
def __str__(self):
return f'{self.uuid}'
class Meta:
ordering = ('pk',)
class TelegramBot(models.Model, PermissionModelMixin):
token = models.CharField(max_length=256)
@@ -1339,6 +1395,9 @@ class TelegramBot(models.Model, PermissionModelMixin):
def __str__(self):
return f"{self.name}"
class Meta:
ordering = ('pk',)
class CookLog(ExportModelOperationsMixin('cook_log'), models.Model, PermissionModelMixin):
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
@@ -1356,7 +1415,7 @@ class CookLog(ExportModelOperationsMixin('cook_log'), models.Model, PermissionMo
def __str__(self):
return self.recipe.name
class Meta():
class Meta:
indexes = (
Index(fields=['id']),
Index(fields=['recipe']),
@@ -1365,6 +1424,7 @@ class CookLog(ExportModelOperationsMixin('cook_log'), models.Model, PermissionMo
Index(fields=['created_by']),
Index(fields=['created_by', 'rating']),
)
ordering = ('pk',)
class ViewLog(ExportModelOperationsMixin('view_log'), models.Model, PermissionModelMixin):
@@ -1378,13 +1438,14 @@ class ViewLog(ExportModelOperationsMixin('view_log'), models.Model, PermissionMo
def __str__(self):
return self.recipe.name
class Meta():
class Meta:
indexes = (
Index(fields=['recipe']),
Index(fields=['-created_at']),
Index(fields=['created_by']),
Index(fields=['recipe', '-created_at', 'created_by']),
)
ordering = ('pk',)
class ImportLog(models.Model, PermissionModelMixin):
@@ -1405,6 +1466,9 @@ class ImportLog(models.Model, PermissionModelMixin):
def __str__(self):
return f"{self.created_at}:{self.type}"
class Meta:
ordering = ('pk',)
class ExportLog(models.Model, PermissionModelMixin):
type = models.CharField(max_length=32)
@@ -1425,6 +1489,9 @@ class ExportLog(models.Model, PermissionModelMixin):
def __str__(self):
return f"{self.created_at}:{self.type}"
class Meta:
ordering = ('pk',)
class BookmarkletImport(ExportModelOperationsMixin('bookmarklet_import'), models.Model, PermissionModelMixin):
html = models.TextField()
@@ -1435,6 +1502,9 @@ class BookmarkletImport(ExportModelOperationsMixin('bookmarklet_import'), models
objects = ScopedManager(space='space')
space = models.ForeignKey(Space, on_delete=models.CASCADE)
class Meta:
ordering = ('pk',)
# field names used to configure search behavior - all data populated during data migration
# other option is to use a MultiSelectField from https://github.com/goinnn/django-multiselectfield
@@ -1502,6 +1572,9 @@ class UserFile(ExportModelOperationsMixin('user_files'), models.Model, Permissio
def __str__(self):
return f'{self.name} (#{self.id})'
class Meta:
ordering = ('pk',)
class Automation(ExportModelOperationsMixin('automations'), models.Model, PermissionModelMixin):
FOOD_ALIAS = 'FOOD_ALIAS'
@@ -1548,6 +1621,9 @@ class Automation(ExportModelOperationsMixin('automations'), models.Model, Permis
objects = ScopedManager(space='space')
space = models.ForeignKey(Space, on_delete=models.CASCADE)
class Meta:
ordering = ('pk',)
class CustomFilter(models.Model, PermissionModelMixin):
RECIPE = 'RECIPE'
@@ -1578,3 +1654,4 @@ class CustomFilter(models.Model, PermissionModelMixin):
constraints = [
models.UniqueConstraint(fields=['space', 'name'], name='cf_unique_name_per_space')
]
ordering = ('pk',)

View File

@@ -151,19 +151,22 @@ class CustomOnHandField(serializers.Field):
return instance
def to_representation(self, obj):
if not self.context["request"].user.is_authenticated:
try:
if not self.context["request"].user.is_authenticated:
return []
shared_users = []
if c := caches['default'].get(f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None):
shared_users = c
else:
try:
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id]
caches['default'].set(f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', shared_users, timeout=5 * 60)
# TODO ugly hack that improves API performance significantly, should be done properly
except AttributeError: # Anonymous users (using share links) don't have shared users
pass
return obj.onhand_users.filter(id__in=shared_users).exists()
except AttributeError:
return []
shared_users = []
if c := caches['default'].get(f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None):
shared_users = c
else:
try:
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id]
caches['default'].set(f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', shared_users, timeout=5 * 60)
# TODO ugly hack that improves API performance significantly, should be done properly
except AttributeError: # Anonymous users (using share links) don't have shared users
pass
return obj.onhand_users.filter(id__in=shared_users).exists()
def to_internal_value(self, data):
return data
@@ -335,17 +338,26 @@ class AiProviderSerializer(serializers.ModelSerializer):
return super().create(validated_data)
def update(self, instance, validated_data):
validated_data = self.handle_global_space_logic(validated_data)
validated_data = self.handle_global_space_logic(validated_data, instance=instance)
return super().update(instance, validated_data)
def handle_global_space_logic(self, validated_data):
def handle_global_space_logic(self, validated_data, instance=None):
"""
allow superusers to create AI providers without a space but make sure everyone else only uses their own space
"""
if ('space' not in validated_data or not validated_data['space']) and self.context['request'].user.is_superuser:
validated_data['space'] = None
if self.context['request'].user.is_superuser:
if ('space' not in validated_data or not validated_data['space']):
validated_data['space'] = None
else:
validated_data['space'] = self.context['request'].space
else:
validated_data['space'] = self.context['request'].space
if instance:
validated_data['space'] = instance.space
else:
validated_data['space'] = self.context['request'].space
if 'log_credit_cost' in validated_data and not self.context['request'].user.is_superuser:
del validated_data['log_credit_cost']
return validated_data
@@ -834,28 +846,31 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
@extend_schema_field(bool)
def get_substitute_onhand(self, obj):
if not self.context["request"].user.is_authenticated:
try:
if not self.context["request"].user.is_authenticated:
return []
shared_users = []
if c := caches['default'].get(
f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None):
shared_users = c
else:
try:
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [
self.context['request'].user.id]
caches['default'].set(
f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}',
shared_users, timeout=5 * 60)
# TODO ugly hack that improves API performance significantly, should be done properly
except AttributeError: # Anonymous users (using share links) don't have shared users
pass
filter = Q(id__in=obj.substitute.all())
if obj.substitute_siblings:
filter |= Q(path__startswith=obj.path[:Food.steplen * (obj.depth - 1)], depth=obj.depth)
if obj.substitute_children:
filter |= Q(path__startswith=obj.path, depth__gt=obj.depth)
return Food.objects.filter(filter).filter(onhand_users__id__in=shared_users).exists()
except AttributeError:
return []
shared_users = []
if c := caches['default'].get(
f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None):
shared_users = c
else:
try:
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [
self.context['request'].user.id]
caches['default'].set(
f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}',
shared_users, timeout=5 * 60)
# TODO ugly hack that improves API performance significantly, should be done properly
except AttributeError: # Anonymous users (using share links) don't have shared users
pass
filter = Q(id__in=obj.substitute.all())
if obj.substitute_siblings:
filter |= Q(path__startswith=obj.path[:Food.steplen * (obj.depth - 1)], depth=obj.depth)
if obj.substitute_children:
filter |= Q(path__startswith=obj.path, depth__gt=obj.depth)
return Food.objects.filter(filter).filter(onhand_users__id__in=shared_users).exists()
def create(self, validated_data):
name = validated_data['name'].strip()
@@ -1709,6 +1724,11 @@ class FdcQuerySerializer(serializers.Serializer):
foods = FdcQueryFoodsSerializer(many=True)
class GenericModelReferenceSerializer(serializers.Serializer):
id = serializers.IntegerField()
model = serializers.CharField()
name = serializers.CharField()
# Export/Import Serializers
class KeywordExportSerializer(KeywordSerializer):

View File

@@ -236,42 +236,6 @@ def test_delete(u1_s1, u1_s2, obj_1, obj_tree_1):
assert Food.find_problems() == ([], [], [], [], [])
def test_integrity(u1_s1, recipe_1_s1):
with scopes_disabled():
assert Food.objects.count() == 10
assert Ingredient.objects.count() == 10
f_1 = Food.objects.first()
# deleting food will fail because food is part of recipe
r = u1_s1.delete(
reverse(
DETAIL_URL,
args={f_1.id}
)
)
assert r.status_code == 403
with scopes_disabled():
i_1 = f_1.ingredient_set.first()
# remove Ingredient that references Food from recipe step
i_1.step_set.first().ingredients.remove(i_1)
assert Food.objects.count() == 10
assert Ingredient.objects.count() == 10
# deleting food will succeed because its not part of recipe and delete will cascade to Ingredient
r = u1_s1.delete(
reverse(
DETAIL_URL,
args={f_1.id}
)
)
assert r.status_code == 204
with scopes_disabled():
assert Food.objects.count() == 9
assert Ingredient.objects.count() == 9
def test_move(u1_s1, obj_tree_1, obj_2, obj_3, space_1):
with scope(space=space_1):
# for some reason the 'path' attribute changes between the factory and the test when using both obj_tree and obj

View File

@@ -99,19 +99,19 @@ def test_list_filter(obj_1, u1_s1):
response = json.loads(
u1_s1.get(
f'{reverse(LIST_URL)}?from_date={(timezone.now() + timedelta(days=2)).strftime("%Y-%m-%d")}'
f'{reverse(LIST_URL)}?from_date={(timezone.localtime(timezone.now()) + timedelta(days=1)).strftime("%Y-%m-%d")}'
).content)['results']
assert len(response) == 0
response = json.loads(
u1_s1.get(
f'{reverse(LIST_URL)}?to_date={(timezone.now() - timedelta(days=2)).strftime("%Y-%m-%d")}'
f'{reverse(LIST_URL)}?to_date={(timezone.localtime(timezone.now()) - timedelta(days=1)).strftime("%Y-%m-%d")}'
).content)['results']
assert len(response) == 0
response = json.loads(
u1_s1.get(
f'{reverse(LIST_URL)}?from_date={(timezone.now() - timedelta(days=2)).strftime("%Y-%m-%d")}&to_date={(timezone.now() + timedelta(days=2)).strftime("%Y-%m-%d")}'
f'{reverse(LIST_URL)}?from_date={(timezone.localtime(timezone.now()) - timedelta(days=1)).strftime("%Y-%m-%d")}&to_date={(timezone.localtime(timezone.now()) + timedelta(days=1)).strftime("%Y-%m-%d")}'
).content)['results']
assert len(response) == 1
@@ -153,8 +153,8 @@ def test_add(arg, request, u1_s2, recipe_1_s1, meal_type):
'id': meal_type.id,
'name': meal_type.name
},
'from_date': (timezone.now()).strftime("%Y-%m-%d"),
'to_date': (timezone.now()).strftime("%Y-%m-%d"),
'from_date': (timezone.localtime(timezone.now())).strftime("%Y-%m-%d"),
'to_date': (timezone.localtime(timezone.now())).strftime("%Y-%m-%d"),
'servings': 1,
'title': 'test',
'shared': []
@@ -196,8 +196,8 @@ def test_add_with_shopping(u1_s1, meal_type):
'id': meal_type.id,
'name': meal_type.name
},
'from_date': (timezone.now()).strftime("%Y-%m-%d"),
'to_date': (timezone.now()).strftime("%Y-%m-%d"),
'from_date': (timezone.localtime(timezone.now())).strftime("%Y-%m-%d"),
'to_date': (timezone.localtime(timezone.now())).strftime("%Y-%m-%d"),
'servings': 1,
'title': 'test',
'shared': [],
@@ -212,13 +212,13 @@ def test_add_with_shopping(u1_s1, meal_type):
@pytest.mark.parametrize("arg", [
['', 2],
[f'?from_date={timezone.now().strftime("%Y-%m-%d")}', 1],
[f'?from_date={timezone.localtime(timezone.now()).strftime("%Y-%m-%d")}', 1],
[
f'?to_date={(timezone.now() - timedelta(days=1)).strftime("%Y-%m-%d")}',
f'?to_date={(timezone.localtime(timezone.now()) - timedelta(days=1)).strftime("%Y-%m-%d")}',
1
],
[
f'?from_date={(timezone.now() + timedelta(days=2)).strftime("%Y-%m-%d")}&to_date={(timezone.now() + timedelta(days=2)).strftime("%Y-%m-%d")}',
f'?from_date={(timezone.localtime(timezone.now()) + timedelta(days=1)).strftime("%Y-%m-%d")}&to_date={(timezone.localtime(timezone.now()) + timedelta(days=1)).strftime("%Y-%m-%d")}',
0
],
])

View File

@@ -185,3 +185,32 @@ def test_unit_conversions(space_1, space_2, u1_s1):
assert next(x for x in conversions if x.unit == unit_kg_space_2) is not None
assert abs(next(x for x in conversions if x.unit == unit_kg_space_2).amount - Decimal(0.1)) < 0.0001
print(conversions)
def test_conversion_with_zero(space_1, space_2, u1_s1):
with scopes_disabled():
uch = UnitConversionHelper(space_1)
unit_gram = Unit.objects.create(name='gram', base_unit='g', space=space_1)
unit_fantasy = Unit.objects.create(name='Fantasy Unit', base_unit=None, space=space_1)
food_1 = Food.objects.create(name='Test Food 1', space=space_1)
ingredient_food_1_gram = Ingredient.objects.create(
food=food_1,
unit=unit_gram,
amount=100,
space=space_1,
)
print('\n----------- TEST BASE CUSTOM CONVERSION - TO CUSTOM CONVERSION ---------------')
UnitConversion.objects.create(
base_amount=0,
base_unit=unit_gram,
converted_amount=0,
converted_unit=unit_fantasy,
space=space_1,
created_by=auth.get_user(u1_s1),
)
conversions = uch.get_conversions(ingredient_food_1_gram)
assert len(conversions) == 1 # conversion always includes the ingredient, if count is 1 no other conversion was found

View File

@@ -104,6 +104,7 @@ urlpatterns = [
path('api/sync_all/', api.sync_all, name='api_sync'),
path('api/recipe-from-source/', api.RecipeUrlImportView.as_view(), name='api_recipe_from_source'),
path('api/ai-import/', api.AiImportView.as_view(), name='api_ai_import'),
path('api/ai-step-sort/', api.AiStepSortView.as_view(), name='api_ai_step_sort'),
path('api/import-open-data/', api.ImportOpenData.as_view(), name='api_import_open_data'),
path('api/ingredient-from-string/', api.ingredient_from_string, name='api_ingredient_from_string'),
path('api/fdc-search/', api.FdcSearchView.as_view(), name='api_fdc_search'),

View File

@@ -9,6 +9,7 @@ import threading
import traceback
import uuid
from collections import OrderedDict
from functools import wraps
from json import JSONDecodeError
from urllib.parse import unquote
from zipfile import ZipFile
@@ -19,12 +20,15 @@ import redis
import requests
from PIL import UnidentifiedImageError
from django.contrib import messages
from django.contrib.admin.utils import get_deleted_objects, NestedObjects
from django.contrib.auth.models import Group, User
from django.contrib.postgres.search import TrigramSimilarity
from django.core.cache import caches
from django.core.exceptions import FieldError, ValidationError
from django.core.files import File
from django.db.models import Case, Count, Exists, OuterRef, ProtectedError, Q, Subquery, Value, When
from django.db import DEFAULT_DB_ALIAS
from django.db.models import Case, Count, Exists, OuterRef, ProtectedError, Q, Subquery, Value, When, QuerySet
from django.db.models.deletion import Collector
from django.db.models.fields.related import ForeignObjectRel
from django.db.models.functions import Coalesce, Lower
from django.db.models.signals import post_save
@@ -110,7 +114,7 @@ from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer, Au
LocalizationSerializer, ServerSettingsSerializer, RecipeFromSourceResponseSerializer, ShoppingListEntryBulkCreateSerializer, FdcQuerySerializer,
AiImportSerializer, ImportOpenDataSerializer, ImportOpenDataMetaDataSerializer, ImportOpenDataResponseSerializer, ExportRequestSerializer,
RecipeImportSerializer, ConnectorConfigSerializer, SearchPreferenceSerializer, SearchFieldsSerializer, RecipeBatchUpdateSerializer,
AiProviderSerializer, AiLogSerializer, FoodBatchUpdateSerializer
AiProviderSerializer, AiLogSerializer, FoodBatchUpdateSerializer, GenericModelReferenceSerializer
)
from cookbook.version_info import TANDOOR_VERSION
from cookbook.views.import_export import get_integration
@@ -368,11 +372,16 @@ class MergeMixin(ViewSetMixin):
isTree = False
try:
# TODO these checks could be improved to merge existing properties and conversion in a smart way. For now it will just loose them to prevent duplicates
if isinstance(source, Food):
source.properties.all().delete()
source.properties.clear()
UnitConversion.objects.filter(food=source).delete()
if isinstance(source, Unit):
UnitConversion.objects.filter(base_unit=source).delete()
UnitConversion.objects.filter(converted_unit=source).delete()
for link in [field for field in source._meta.get_fields() if issubclass(type(field), ForeignObjectRel)]:
linkManager = getattr(source, link.get_accessor_name())
related = linkManager.all()
@@ -511,6 +520,143 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
return Response(content, status=status.HTTP_400_BAD_REQUEST)
def paginate(func):
"""
pagination decorator for custom ViewSet actions
"""
@wraps(func)
def inner(self, *args, **kwargs):
queryset = func(self, *args, **kwargs)
assert isinstance(queryset, (list, QuerySet))
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
return inner
class DeleteRelationMixing:
"""
mixin to add custom API function for model delete dependency checking
"""
@staticmethod
def collect(obj):
# collector.nested() nested seems to not include protecting but does include cascading
# collector.protected: objects that raise Protected or Restricted error when deleting unit
# collector.field_updates: fields that get updated when deleting the unit
# collector.model_objs: collects the objects that should be deleted together with the selected unit
collector = NestedObjects(using=DEFAULT_DB_ALIAS)
collector.collect([obj])
return collector
@extend_schema(responses=GenericModelReferenceSerializer(many=True), parameters=[
OpenApiParameter(name='cache', description='If results can be cached or not', type=bool, default=True),
])
@decorators.action(detail=True, methods=['GET'], serializer_class=GenericModelReferenceSerializer)
@paginate
def protecting(self, request, pk):
"""
get a paginated list of objects that are protecting the selected object form being deleted
"""
obj = self.queryset.filter(pk=pk, space=request.space).first()
if obj:
CACHE_KEY = f'DELETE_COLLECTOR_{request.space.pk}_PROTECTING_{obj.__class__.__name__}_{obj.pk}'
cache = self.request.query_params.get('cache', "true") == "true"
if caches['default'].has_key(CACHE_KEY) and cache:
return caches['default'].get(CACHE_KEY)
collector = self.collect(obj)
protected_objects = []
for o in collector.protected:
protected_objects.append({
'id': o.pk,
'model': o.__class__.__name__,
'name': str(o),
})
caches['default'].set(CACHE_KEY, protected_objects, 60)
return protected_objects
else:
return []
@extend_schema(responses=GenericModelReferenceSerializer(many=True), parameters=[
OpenApiParameter(name='cache', description='If results can be cached or not', type=bool, default=True),
])
@decorators.action(detail=True, methods=['GET'], serializer_class=GenericModelReferenceSerializer)
@paginate
def cascading(self, request, pk):
"""
get a paginated list of objects that will be cascaded (deleted) when deleting the selected object
"""
obj = self.queryset.filter(pk=pk, space=request.space).first()
if obj:
CACHE_KEY = f'DELETE_COLLECTOR_{request.space.pk}_CASCADING_{obj.__class__.__name__}_{obj.pk}'
cache = self.request.query_params.get('cache', "true") == "true"
if caches['default'].has_key(CACHE_KEY) and cache:
return caches['default'].get(CACHE_KEY)
collector = self.collect(obj)
cascading_objects = []
for model, objs in collector.model_objs.items():
for o in objs:
if o.pk != pk and o.__class__.__name__ != obj.__class__.__name__:
cascading_objects.append({
'id': o.pk,
'model': o.__class__.__name__,
'name': str(o),
})
caches['default'].set(CACHE_KEY, cascading_objects, 60)
return cascading_objects
else:
return []
@extend_schema(responses=GenericModelReferenceSerializer(many=True), parameters=[
OpenApiParameter(name='cache', description='If results can be cached or not', type=bool, default=True),
])
@decorators.action(detail=True, methods=['GET'], serializer_class=GenericModelReferenceSerializer)
@paginate
def nulling(self, request, pk):
"""
get a paginated list of objects where the selected object will be removed whe its deleted
"""
obj = self.queryset.filter(pk=pk, space=request.space).first()
if obj:
CACHE_KEY = f'DELETE_COLLECTOR_{request.space.pk}_NULLING_{obj.__class__.__name__}_{obj.pk}'
cache = self.request.query_params.get('cache', "true") == "true"
if caches['default'].has_key(CACHE_KEY) and cache:
return caches['default'].get(CACHE_KEY)
collector = self.collect(obj)
nulling_objects = []
# field_updates is a dict of relations that will be updated and querysets of items affected
for key, value in collector.field_updates.items():
# iterate over each queryset for relation
for qs in value:
# itereate over each object in queryset of relation
for o in qs:
nulling_objects.append(
{
'id': o.pk,
'model': o.__class__.__name__,
'name': str(o),
}
)
caches['default'].set(CACHE_KEY, nulling_objects, 60)
return nulling_objects
else:
return []
@extend_schema_view(list=extend_schema(parameters=[
OpenApiParameter(name='filter_list', description='User IDs, repeat for multiple', type=str, many=True),
]))
@@ -633,7 +779,7 @@ class SearchPreferenceViewSet(LoggingMixin, viewsets.ModelViewSet):
return self.queryset.filter(user=self.request.user)
class AiProviderViewSet(LoggingMixin, viewsets.ModelViewSet):
class AiProviderViewSet(LoggingMixin, viewsets.ModelViewSet, DeleteRelationMixing):
queryset = AiProvider.objects
serializer_class = AiProviderSerializer
permission_classes = [CustomAiProviderPermission & CustomTokenHasReadWriteScope]
@@ -656,7 +802,7 @@ class AiLogViewSet(LoggingMixin, viewsets.ModelViewSet):
return self.queryset.filter(space=self.request.space)
class StorageViewSet(LoggingMixin, viewsets.ModelViewSet):
class StorageViewSet(LoggingMixin, viewsets.ModelViewSet, DeleteRelationMixing):
# TODO handle delete protect error and adjust test
queryset = Storage.objects
serializer_class = StorageSerializer
@@ -667,7 +813,7 @@ class StorageViewSet(LoggingMixin, viewsets.ModelViewSet):
return self.queryset.filter(space=self.request.space)
class SyncViewSet(LoggingMixin, viewsets.ModelViewSet):
class SyncViewSet(LoggingMixin, viewsets.ModelViewSet, DeleteRelationMixing):
queryset = Sync.objects
serializer_class = SyncSerializer
permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope]
@@ -728,7 +874,7 @@ class RecipeImportViewSet(LoggingMixin, viewsets.ModelViewSet):
return Response({'msg': 'ok'}, status=status.HTTP_200_OK)
class ConnectorConfigViewSet(LoggingMixin, viewsets.ModelViewSet):
class ConnectorConfigViewSet(LoggingMixin, viewsets.ModelViewSet, DeleteRelationMixing):
queryset = ConnectorConfig.objects
serializer_class = ConnectorConfigSerializer
permission_classes = [CustomIsAdmin & CustomTokenHasReadWriteScope]
@@ -738,7 +884,7 @@ class ConnectorConfigViewSet(LoggingMixin, viewsets.ModelViewSet):
return self.queryset.filter(space=self.request.space)
class SupermarketViewSet(LoggingMixin, StandardFilterModelViewSet):
class SupermarketViewSet(LoggingMixin, StandardFilterModelViewSet, DeleteRelationMixing):
queryset = Supermarket.objects
serializer_class = SupermarketSerializer
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
@@ -750,7 +896,7 @@ class SupermarketViewSet(LoggingMixin, StandardFilterModelViewSet):
# TODO does supermarket category have settings to support fuzzy filtering and/or merge?
class SupermarketCategoryViewSet(LoggingMixin, FuzzyFilterMixin, MergeMixin):
class SupermarketCategoryViewSet(LoggingMixin, FuzzyFilterMixin, MergeMixin, DeleteRelationMixing):
queryset = SupermarketCategory.objects
model = SupermarketCategory
serializer_class = SupermarketCategorySerializer
@@ -773,7 +919,7 @@ class SupermarketCategoryRelationViewSet(LoggingMixin, StandardFilterModelViewSe
return super().get_queryset()
class KeywordViewSet(LoggingMixin, TreeMixin):
class KeywordViewSet(LoggingMixin, TreeMixin, DeleteRelationMixing):
queryset = Keyword.objects
model = Keyword
serializer_class = KeywordSerializer
@@ -781,7 +927,7 @@ class KeywordViewSet(LoggingMixin, TreeMixin):
pagination_class = DefaultPagination
class UnitViewSet(LoggingMixin, MergeMixin, FuzzyFilterMixin):
class UnitViewSet(LoggingMixin, MergeMixin, FuzzyFilterMixin, DeleteRelationMixing):
queryset = Unit.objects
model = Unit
serializer_class = UnitSerializer
@@ -801,7 +947,7 @@ class FoodInheritFieldViewSet(LoggingMixin, viewsets.ReadOnlyModelViewSet):
return super().get_queryset()
class FoodViewSet(LoggingMixin, TreeMixin):
class FoodViewSet(LoggingMixin, TreeMixin, DeleteRelationMixing):
queryset = Food.objects
model = Food
serializer_class = FoodSerializer
@@ -947,6 +1093,82 @@ class FoodViewSet(LoggingMixin, TreeMixin):
return JsonResponse({'msg': 'there was an error parsing the FDC data, please check the server logs'},
status=500, json_dumps_params={'indent': 4})
@extend_schema(
parameters=[
OpenApiParameter(name='provider', description='ID of the AI provider that should be used for this AI request', type=int),
]
)
@decorators.action(detail=True, methods=['POST'], )
def aiproperties(self, request, pk):
serializer = RecipeSerializer(data=request.data, partial=True, context={'request': request})
if serializer.is_valid():
if not request.query_params.get('provider', None) or not re.match(r'^(\d)+$', request.query_params.get('provider', None)):
response = {
'error': True,
'msg': _('You must select an AI provider to perform your request.'),
}
return Response(response, status=status.HTTP_400_BAD_REQUEST)
if not can_perform_ai_request(request.space):
response = {
'error': True,
'msg': _("You don't have any credits remaining to use AI or AI features are not enabled for your space."),
}
return Response(response, status=status.HTTP_400_BAD_REQUEST)
ai_provider = AiProvider.objects.filter(pk=request.query_params.get('provider')).filter(Q(space=request.space) | Q(space__isnull=True)).first()
litellm.callbacks = [AiCallbackHandler(request.space, request.user, ai_provider, AiLog.F_FOOD_PROPERTIES)]
property_type_list = list(PropertyType.objects.filter(space=request.space).values('id', 'name', 'description', 'unit', 'category', 'fdc_id'))
messages = [
{
"role": "user",
"content": [
{
"type": "text",
"text": "Given the following food and the following different types of properties please update the food so that the properties attribute contains a list with all property types in the following format [{property_amount: <the property value>, property_type: {id: <the ID of the property type>, name: <the name of the property type>}}]."
"The property values should be in the unit given in the property type and for the amount specified in the properties_food_amount attribute of the food, which is given in the properties_food_unit."
"property_amount is a decimal number. Please try to keep a precision of two decimal places if given in your source data."
"Do not make up any data. If there is no data available for the given property type that is ok, just return null as a property_amount for that property type. Do not change anything else!"
"Most property types are likely going to be nutritional values. Please do not make up any values, only return values you can find in the sources available to you."
"Only return values if you are sure they are meant for the food given. Under no circumstance are you allowed to change any other value of the given food or change the structure in any way or form."
},
{
"type": "text",
"text": json.dumps(request.data)
},
{
"type": "text",
"text": json.dumps(property_type_list)
},
]
},
]
try:
ai_request = {
'api_key': ai_provider.api_key,
'model': ai_provider.model_name,
'response_format': {"type": "json_object"},
'messages': messages,
}
if ai_provider.url:
ai_request['api_base'] = ai_provider.url
ai_response = completion(**ai_request)
response_text = ai_response.choices[0].message.content
return Response(json.loads(response_text), status=status.HTTP_200_OK)
except BadRequestError as err:
pass
response = {
'error': True,
'msg': 'The AI could not process your request. \n\n' + err.message,
}
return Response(response, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, *args, **kwargs):
try:
return (super().destroy(self, *args, **kwargs))
@@ -1049,7 +1271,7 @@ class FoodViewSet(LoggingMixin, TreeMixin):
OpenApiParameter(name='order_direction', description='Order ascending or descending', type=str,
enum=['asc', 'desc']),
]))
class RecipeBookViewSet(LoggingMixin, StandardFilterModelViewSet):
class RecipeBookViewSet(LoggingMixin, StandardFilterModelViewSet, DeleteRelationMixing):
queryset = RecipeBook.objects
serializer_class = RecipeBookSerializer
permission_classes = [(CustomIsOwner | CustomIsShared) & CustomTokenHasReadWriteScope]
@@ -1203,7 +1425,7 @@ class AutoPlanViewSet(LoggingMixin, mixins.CreateModelMixin, viewsets.GenericVie
return Response(serializer.errors, 400)
class MealTypeViewSet(LoggingMixin, viewsets.ModelViewSet):
class MealTypeViewSet(LoggingMixin, viewsets.ModelViewSet, DeleteRelationMixing):
"""
returns list of meal types created by the
requesting user ordered by the order field.
@@ -1353,7 +1575,7 @@ class RecipePagination(PageNumberPagination):
OpenApiParameter(name='filter', description=_('ID of a custom filter. Returns all recipes matched by that filter.'), type=int),
OpenApiParameter(name='makenow', description=_('Filter recipes that can be made with OnHand food. [''true''/''<b>false</b>'']'), type=bool),
]))
class RecipeViewSet(LoggingMixin, viewsets.ModelViewSet):
class RecipeViewSet(LoggingMixin, viewsets.ModelViewSet, DeleteRelationMixing):
queryset = Recipe.objects
serializer_class = RecipeSerializer
# TODO split read and write permission for meal plan guest
@@ -1588,6 +1810,82 @@ class RecipeViewSet(LoggingMixin, viewsets.ModelViewSet):
return Response(serializer.errors, 400)
@extend_schema(
parameters=[
OpenApiParameter(name='provider', description='ID of the AI provider that should be used for this AI request', type=int),
]
)
@decorators.action(detail=True, methods=['POST'], )
def aiproperties(self, request, pk):
serializer = RecipeSerializer(data=request.data, partial=True, context={'request': request})
if serializer.is_valid():
if not request.query_params.get('provider', None) or not re.match(r'^(\d)+$', request.query_params.get('provider', None)):
response = {
'error': True,
'msg': _('You must select an AI provider to perform your request.'),
}
return Response(response, status=status.HTTP_400_BAD_REQUEST)
if not can_perform_ai_request(request.space):
response = {
'error': True,
'msg': _("You don't have any credits remaining to use AI or AI features are not enabled for your space."),
}
return Response(response, status=status.HTTP_400_BAD_REQUEST)
ai_provider = AiProvider.objects.filter(pk=request.query_params.get('provider')).filter(Q(space=request.space) | Q(space__isnull=True)).first()
litellm.callbacks = [AiCallbackHandler(request.space, request.user, ai_provider, AiLog.F_RECIPE_PROPERTIES)]
property_type_list = list(PropertyType.objects.filter(space=request.space).values('id', 'name', 'description', 'unit', 'category', 'fdc_id'))
messages = [
{
"role": "user",
"content": [
{
"type": "text",
"text": "Given the following recipe and the following different types of properties please update the recipe so that the properties attribute contains a list with all property types in the following format [{property_amount: <the property value>, property_type: {id: <the ID of the property type>, name: <the name of the property type>}}]."
"The property values should be in the unit given in the property type and calculated based on the total quantity of the foods used for the recipe."
"property_amount is a decimal number. Please try to keep a precision of two decimal places if given in your source data."
"Do not make up any data. If there is no data available for the given property type that is ok, just return null as a property_amount for that property type. Do not change anything else!"
"Most property types are likely going to be nutritional values. Please do not make up any values, only return values you can find in the sources available to you."
"Under no circumstance are you allowed to change any other value of the given food or change the structure in any way or form."
},
{
"type": "text",
"text": json.dumps(request.data)
},
{
"type": "text",
"text": json.dumps(property_type_list)
},
]
},
]
try:
ai_request = {
'api_key': ai_provider.api_key,
'model': ai_provider.model_name,
'response_format': {"type": "json_object"},
'messages': messages,
}
if ai_provider.url:
ai_request['api_base'] = ai_provider.url
ai_response = completion(**ai_request)
response_text = ai_response.choices[0].message.content
return Response(json.loads(response_text), status=status.HTTP_200_OK)
except BadRequestError as err:
pass
response = {
'error': True,
'msg': 'The AI could not process your request. \n\n' + err.message,
}
return Response(response, status=status.HTTP_400_BAD_REQUEST)
@extend_schema(responses=RecipeSerializer(many=False))
@decorators.action(detail=True, pagination_class=None, methods=['PATCH'], serializer_class=RecipeSerializer)
def delete_external(self, request, pk):
@@ -1635,7 +1933,7 @@ class UnitConversionViewSet(LoggingMixin, viewsets.ModelViewSet):
enum=[m[0] for m in PropertyType.CHOICES])
]
))
class PropertyTypeViewSet(LoggingMixin, viewsets.ModelViewSet):
class PropertyTypeViewSet(LoggingMixin, viewsets.ModelViewSet, DeleteRelationMixing):
queryset = PropertyType.objects
serializer_class = PropertyTypeSerializer
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
@@ -1866,7 +2164,7 @@ class BookmarkletImportViewSet(LoggingMixin, viewsets.ModelViewSet):
return self.queryset.filter(space=self.request.space).all()
class UserFileViewSet(LoggingMixin, StandardFilterModelViewSet):
class UserFileViewSet(LoggingMixin, StandardFilterModelViewSet, DeleteRelationMixing):
queryset = UserFile.objects
serializer_class = UserFileSerializer
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
@@ -2155,7 +2453,7 @@ class AiImportView(APIView):
ai_provider = AiProvider.objects.filter(pk=serializer.validated_data['ai_provider_id']).filter(Q(space=request.space) | Q(space__isnull=True)).first()
litellm.callbacks = [AiCallbackHandler(request.space, request.user, ai_provider)]
litellm.callbacks = [AiCallbackHandler(request.space, request.user, ai_provider, AiLog.F_FILE_IMPORT)]
messages = []
uploaded_file = serializer.validated_data['file']
@@ -2272,6 +2570,80 @@ class AiImportView(APIView):
return Response(RecipeFromSourceResponseSerializer(context={'request': request}).to_representation(response), status=status.HTTP_400_BAD_REQUEST)
class AiStepSortView(APIView):
throttle_classes = [AiEndpointThrottle]
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
@extend_schema(request=RecipeSerializer(many=False), responses=RecipeSerializer(many=False),
parameters=[
OpenApiParameter(name='provider', description='ID of the AI provider that should be used for this AI request', type=int),
])
def post(self, request, *args, **kwargs):
"""
given an image or PDF file convert its content to a structured recipe using AI and the scraping system
"""
serializer = RecipeSerializer(data=request.data, partial=True, context={'request': request})
if serializer.is_valid():
if not request.query_params.get('provider', None) or not re.match(r'^(\d)+$', request.query_params.get('provider', None)):
response = {
'error': True,
'msg': _('You must select an AI provider to perform your request.'),
}
return Response(response, status=status.HTTP_400_BAD_REQUEST)
if not can_perform_ai_request(request.space):
response = {
'error': True,
'msg': _("You don't have any credits remaining to use AI or AI features are not enabled for your space."),
}
return Response(response, status=status.HTTP_400_BAD_REQUEST)
ai_provider = AiProvider.objects.filter(pk=request.query_params.get('provider')).filter(Q(space=request.space) | Q(space__isnull=True)).first()
litellm.callbacks = [AiCallbackHandler(request.space, request.user, ai_provider, AiLog.F_STEP_SORT)]
messages = [
{
"role": "user",
"content": [
{
"type": "text",
"text": "You are given data for a recipe formatted as json. You cannot under any circumstance change the value of any of the fields. You are only allowed to split the instructions into multiple steps and to sort the ingredients to their appropriate step. Your goal is to properly structure the recipe by splitting large instructions into multiple coherent steps and putting the ingredients that belong to this step into the ingredients list. Generally an ingredient of a cooking recipe should occur in the first step where its needed. Please sort the ingredients to the appropriate steps without changing any of the actual field values. Return the recipe in the same format you were given as json. Do not change any field value like strings or numbers, or change the sorting, also do not change the language."
},
{
"type": "text",
"text": json.dumps(request.data)
},
]
},
]
try:
ai_request = {
'api_key': ai_provider.api_key,
'model': ai_provider.model_name,
'response_format': {"type": "json_object"},
'messages': messages,
}
if ai_provider.url:
ai_request['api_base'] = ai_provider.url
ai_response = completion(**ai_request)
response_text = ai_response.choices[0].message.content
# TODO validate by loading/dumping using serializer ?
return Response(json.loads(response_text), status=status.HTTP_200_OK)
except BadRequestError as err:
response = {
'error': True,
'msg': 'The AI could not process your request. \n\n' + err.message,
}
return Response(response, status=status.HTTP_400_BAD_REQUEST)
class AppImportView(APIView):
parser_classes = [MultiPartParser]
throttle_classes = [RecipeImportThrottle]

View File

@@ -15,14 +15,14 @@
<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>
<a href="https://app.tandoor.dev/e/demo-auto-login/" rel="noopener noreferrer"><img src="https://img.shields.io/badge/demo-available-success" ></a>
</p>
<p align="center">
<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://app.tandoor.dev/e/demo-auto-login/" target="_blank" rel="noopener noreferrer">Demo</a> •
<a href="https://community.tandoor.dev" target="_blank" rel="noopener noreferrer">Community</a> •
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer">Discord</a>
</p>

View File

@@ -212,6 +212,6 @@ location /static/ {
Tandoor 1 includes gunicorn, a python WSGI server that handles python code well but is not meant to serve mediafiles. Thus, it has always been recommended to set up a nginx webserver
(not just a reverse proxy) in front of Tandoor to handle mediafiles. The gunicorn server by default is exposed on port 8080.
Tandoor 2 now occasionally bundles nginx inside the container and exposes port 80 where mediafiles are handled by nginx and all the other requests are (mostly) passed to gunicorn.
Tandoor 2 now bundles nginx inside the container and exposes port 80 where mediafiles are handled by nginx and all the other requests are (mostly) passed to gunicorn.
A [GitHub Issue](https://github.com/TandoorRecipes/recipes/issues/3851) has been created to allow for discussions and FAQ's on this issue while this change is fresh. It will later be updated in the docs here if necessary.

View File

@@ -3,7 +3,7 @@
These instructions are inspired from a standard django/gunicorn/postgresql instructions ([for example](https://www.digitalocean.com/community/tutorials/how-to-set-up-django-with-postgres-nginx-and-gunicorn-on-ubuntu-16-04))
!!! warning
Make sure to use at least Python 3.10 (although 3.12 is preferred) or higher, and ensure that `pip` is associated with Python 3. Depending on your system configuration, using `python` or `pip` might default to Python 2. Make sure your machine has at least 2048 MB of memory; otherwise, the `yarn build` process may fail with the error: `FATAL ERROR: Reached heap limit - Allocation failed: JavaScript heap out of memory`.
Make sure to use at least Python 3.12 or higher, and ensure that `pip` is associated with Python 3. Depending on your system configuration, using `python` or `pip` might default to Python 2. Make sure your machine has at least 2048 MB of memory; otherwise, the `yarn build` process may fail with the error: `FATAL ERROR: Reached heap limit - Allocation failed: JavaScript heap out of memory`.
!!! warning
These instructions are **not** regularly reviewed and might be outdated.

View File

@@ -1,9 +1,6 @@
!!! info "Community Contributed"
This guide was contributed by the community and is neither officially supported, nor updated or tested. Since I cannot test it myself, feedback and improvements are always very welcome.
!!! danger "Tandoor 2 Compatibility"
This guide has not been verified/tested for Tandoor 2, which now integrates a nginx service inside the default docker container and exposes its service on port 80 instead of 8080.
## **Instructions**
Basic guide to setup `vabenee1111/recipes` docker container on Synology NAS.
@@ -12,7 +9,7 @@ Basic guide to setup `vabenee1111/recipes` docker container on Synology NAS.
- Login to Synology DSM through your browser
- Install `Container Manager` through package center
- Install `Text Editor` through package center (needed to edit `.env` if you don't edit it locally first)
- If you do not already have a `docker` folder in your File Station, create one at the root of your volume.
- If you do not already have a shared `docker` folder in your File Station, create one at the root of your volume.
- inside of your `volume1/docker` folder, create a `recipes` folder.
- Within, create the necessary folder structure. You will need these folders:
@@ -44,9 +41,13 @@ volume1/docker/
### 4. Edit docker-compose.yml
- Paste the `docker-compose.yml` into the `source` textbox.
- This file tells docker how to setup recipes. Docker will create three containers for recipes to work, recipes, nginx and postgresql. They are all required and need to store and share data through the folders you created before.
- Under the `nginx_recipes` section, look for `ports` that lists `80:80` as the default. This line specifies which external synology port will point to which internal docker port. Chose a free port to use and replace the first number with it. You will open recipes by browsing to http://your.synology.ip:chosen.port, e.g. http://192.168.1.1:2000
- If you want to use port 2000 you would edit the `ports` to `2000:80`
- This file tells docker how to setup recipes. Docker will create two containers for recipes to work, `web_recipes` and `db_recipes`. They are all required and need to store and share data through the folders you created before.
- Under the `web_recipes` section, you can add `ports` . This line specifies which external synology port will point to which internal docker port. Chose a free port to use and replace the first number with it. You will open recipes by browsing to http://your.synology.ip:chosen.port, e.g. http://192.168.1.1:2000
- If you want to use port 2000 you would edit the `ports` to `2000:8080`
```
ports:
- "2000:8080"
```
### 5. Finishing up
- Click `Next`.
@@ -59,25 +60,27 @@ volume1/docker/
Container recipes-db_recipes-1 Started
Container recipes-web_recipes-1 Starting
Container recipes-web_recipes-1 Started
Container recipes-nginx_recipes-1 Starting
Container recipes-nginx_recipes-1 Started
Exit Code: 0
```
- If you get an error, review the error and fix. A common reason it might fail is because you did not create the folders specified in the directory tree in step 1.
- If you notice that `web_recipes` cannot connect to the database there is probably a misconfiguration in you Firewall, see next section.
- Browse to 192.168.1.1:2000 or whatever your IP and port are
### 6. Firewall
!!!info "Depreciated?" This section may be depreciated and may no longer needed. The container may be able to be used without any firewall rules enabled. Further datapoints needed before section or this warning is removed.
You need to set up firewall rules in order for the recipes_web container to be able to connect to the recipes_db container.
You need to set up firewall rules in order for the `recipes_web` container to be able to connect to the `recipes_db` container.
- Control Panel -> Security -> Firewall -> Edit Rules -> Create
- Ports: All
- if you only want to allow the database port 5432, you'll need to create 2 rules to allow ingoing and outgoing port (and eventually also specify ip more restrictive)
- Source IP: Specific IP -> Select -> Subnet
- insert docker network ip (can be found in the docker application, network tab)
- Example: IP address: 172.18.0.0 and Subnet mask/Prefix length: 255.255.255.0
- Action: Allow
- Save and make sure it's above the deny rules
- If you want to skip the SSL Setup via Reverse Proxy you'll also need to allow access to the port you used to expose the container (e.g. 2000 in this example).
- Ports: 2000
- Source: depends on how broad the access should be
- Action: Allow
### 7. Additional SSL Setup
Easiest way is to do it via Reverse Proxy.
@@ -102,7 +105,7 @@ Easiest way is to do it via Reverse Proxy.
- Action: Allow
- Save and make sure it's above the deny rules
### 8. Depreciated Guides
### 8. Deprecated Guides
The following are older guides that may be useful if you are running older versions of DSM.
@@ -111,4 +114,4 @@ The following are older guides that may be useful if you are running older versi
- There is also this
([word](https://github.com/vabene1111/recipes/files/6708738/Tandoor.on.a.Synology.Disk.Station.docx),
[pdf](https://github.com/vabene1111/recipes/files/6901601/Tandoor.on.a.Synology.Disk.Station.pdf)) awesome and very detailed guide provided by @DiversityBug.
[pdf](https://github.com/vabene1111/recipes/files/6901601/Tandoor.on.a.Synology.Disk.Station.pdf)) awesome and very detailed guide provided by @DiversityBug.

View File

@@ -96,12 +96,15 @@ Configuration options for serving related services.
#### Port
> default `8080` - options: `1-65535`
> default `80` - options: `1-65535`
Port for gunicorn to bind to. Should not be changed if using docker stack with reverse proxy.
!!! warning
Changed in version 2.3 to no longer configure the port of gunicorn but the port of the internal nginx
Port where Tandoor exposes its internal web server.
```
TANDOOR_PORT=8080
TANDOOR_PORT=80
```
@@ -186,6 +189,19 @@ See [Gunicorn docs](https://docs.gunicorn.org/en/stable/design.html#how-many-wor
GUNICORN_THREADS=2
```
#### Gunicorn Timeout
> default `30` - options `1-X`
Set the timeout in seconds of gunicorn when starting using `boot.sh` (all container installations).
The default is likely appropriate for most installations. However, if you are using a LLM which high response times gunicornmight time out during the wait until the LLM finished, in such cases you might want to increase the timeout.
See [Gunicorn docs]([https://docs.gunicorn.org/en/stable/design.html#how-many-workers](https://docs.gunicorn.org/en/stable/settings.html#timeout)) for default settings.
```
GUNICORN_TIMEOUT=30
```
#### Gunicorn Media
> default `0` - options `0`, `1`

View File

@@ -1,9 +1,9 @@
server {
listen 80;
listen [::]:80 ipv6only=on;
listen ${TANDOOR_PORT};
listen [::]:${TANDOOR_PORT} ipv6only=on;
server_name localhost;
client_max_body_size 128M;
client_max_body_size 512M;
# serve media files
location /media {
@@ -19,12 +19,17 @@ server {
# pass requests for dynamic content to gunicorn
location / {
proxy_set_header Host $http_host;
proxy_pass http://localhost:${TANDOOR_PORT};
error_page 502 /errors/http502.html;
proxy_pass http://unix:/run/tandoor.sock;
# param needed by django allauth sessions to log IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# disabled for now because it redirects to the error page and not back, also not showing html
#error_page 502 /errors/http502.html;
}
location /errors/ {
alias /etc/nginx/conf.d/errorpages/;
alias /etc/nginx/http.d/errorpages/;
internal;
}
}

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
"POT-Creation-Date: 2025-09-22 20:15+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -69,70 +69,110 @@ msgstr ""
msgid "end"
msgstr ""
#: .\recipes\settings.py:478
#: .\recipes\plugins\enterprise_plugin\templates\enterprise_social_public_view.html:15
msgid "Tandoor Recipe Manager"
msgstr ""
#: .\recipes\settings.py:571
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:479
#: .\recipes\settings.py:572
msgid "Bulgarian"
msgstr ""
#: .\recipes\settings.py:480
#: .\recipes\settings.py:573
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:481
#: .\recipes\settings.py:574
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:482
#: .\recipes\settings.py:575
msgid "Croatian"
msgstr ""
#: .\recipes\settings.py:576
msgid "Danish"
msgstr ""
#: .\recipes\settings.py:483
#: .\recipes\settings.py:577
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:484
#: .\recipes\settings.py:578
msgid "English"
msgstr ""
#: .\recipes\settings.py:485
#: .\recipes\settings.py:579
msgid "French"
msgstr ""
#: .\recipes\settings.py:486
#: .\recipes\settings.py:580
msgid "Finnish"
msgstr ""
#: .\recipes\settings.py:581
msgid "German"
msgstr ""
#: .\recipes\settings.py:487
#: .\recipes\settings.py:582
msgid "Greek"
msgstr ""
#: .\recipes\settings.py:583
msgid "Hebrew"
msgstr ""
#: .\recipes\settings.py:584
msgid "Hungarian"
msgstr ""
#: .\recipes\settings.py:488
#: .\recipes\settings.py:585
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:489
#: .\recipes\settings.py:586
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:490
msgid "Norwegian "
#: .\recipes\settings.py:587
msgid "Norwegian"
msgstr ""
#: .\recipes\settings.py:491
#: .\recipes\settings.py:589
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:492
#: .\recipes\settings.py:590
msgid "Portuguese"
msgstr ""
#: .\recipes\settings.py:591
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:493
#: .\recipes\settings.py:592
msgid "Romanian"
msgstr ""
#: .\recipes\settings.py:593
msgid "Spanish"
msgstr ""
#: .\recipes\settings.py:494
#: .\recipes\settings.py:594
msgid "Slovenian"
msgstr ""
#: .\recipes\settings.py:595
msgid "Swedish"
msgstr ""
#: .\recipes\settings.py:596
msgid "Turkish"
msgstr ""
#: .\recipes\settings.py:597
msgid "Ukranian"
msgstr ""

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
"POT-Creation-Date: 2025-09-22 20:15+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -68,70 +68,110 @@ msgstr ""
msgid "end"
msgstr ""
#: .\recipes\settings.py:478
#: .\recipes\plugins\enterprise_plugin\templates\enterprise_social_public_view.html:15
msgid "Tandoor Recipe Manager"
msgstr ""
#: .\recipes\settings.py:571
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:479
#: .\recipes\settings.py:572
msgid "Bulgarian"
msgstr ""
#: .\recipes\settings.py:480
#: .\recipes\settings.py:573
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:481
#: .\recipes\settings.py:574
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:482
#: .\recipes\settings.py:575
msgid "Croatian"
msgstr ""
#: .\recipes\settings.py:576
msgid "Danish"
msgstr ""
#: .\recipes\settings.py:483
#: .\recipes\settings.py:577
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:484
#: .\recipes\settings.py:578
msgid "English"
msgstr ""
#: .\recipes\settings.py:485
#: .\recipes\settings.py:579
msgid "French"
msgstr ""
#: .\recipes\settings.py:486
#: .\recipes\settings.py:580
msgid "Finnish"
msgstr ""
#: .\recipes\settings.py:581
msgid "German"
msgstr ""
#: .\recipes\settings.py:487
#: .\recipes\settings.py:582
msgid "Greek"
msgstr ""
#: .\recipes\settings.py:583
msgid "Hebrew"
msgstr ""
#: .\recipes\settings.py:584
msgid "Hungarian"
msgstr ""
#: .\recipes\settings.py:488
#: .\recipes\settings.py:585
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:489
#: .\recipes\settings.py:586
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:490
msgid "Norwegian "
#: .\recipes\settings.py:587
msgid "Norwegian"
msgstr ""
#: .\recipes\settings.py:491
#: .\recipes\settings.py:589
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:492
#: .\recipes\settings.py:590
msgid "Portuguese"
msgstr ""
#: .\recipes\settings.py:591
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:493
#: .\recipes\settings.py:592
msgid "Romanian"
msgstr ""
#: .\recipes\settings.py:593
msgid "Spanish"
msgstr ""
#: .\recipes\settings.py:494
#: .\recipes\settings.py:594
msgid "Slovenian"
msgstr ""
#: .\recipes\settings.py:595
msgid "Swedish"
msgstr ""
#: .\recipes\settings.py:596
msgid "Turkish"
msgstr ""
#: .\recipes\settings.py:597
msgid "Ukranian"
msgstr ""

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
"POT-Creation-Date: 2025-09-22 20:15+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -68,70 +68,110 @@ msgstr ""
msgid "end"
msgstr ""
#: .\recipes\settings.py:478
#: .\recipes\plugins\enterprise_plugin\templates\enterprise_social_public_view.html:15
msgid "Tandoor Recipe Manager"
msgstr ""
#: .\recipes\settings.py:571
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:479
#: .\recipes\settings.py:572
msgid "Bulgarian"
msgstr ""
#: .\recipes\settings.py:480
#: .\recipes\settings.py:573
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:481
#: .\recipes\settings.py:574
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:482
#: .\recipes\settings.py:575
msgid "Croatian"
msgstr ""
#: .\recipes\settings.py:576
msgid "Danish"
msgstr ""
#: .\recipes\settings.py:483
#: .\recipes\settings.py:577
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:484
#: .\recipes\settings.py:578
msgid "English"
msgstr ""
#: .\recipes\settings.py:485
#: .\recipes\settings.py:579
msgid "French"
msgstr ""
#: .\recipes\settings.py:486
#: .\recipes\settings.py:580
msgid "Finnish"
msgstr ""
#: .\recipes\settings.py:581
msgid "German"
msgstr ""
#: .\recipes\settings.py:487
#: .\recipes\settings.py:582
msgid "Greek"
msgstr ""
#: .\recipes\settings.py:583
msgid "Hebrew"
msgstr ""
#: .\recipes\settings.py:584
msgid "Hungarian"
msgstr ""
#: .\recipes\settings.py:488
#: .\recipes\settings.py:585
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:489
#: .\recipes\settings.py:586
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:490
msgid "Norwegian "
#: .\recipes\settings.py:587
msgid "Norwegian"
msgstr ""
#: .\recipes\settings.py:491
#: .\recipes\settings.py:589
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:492
#: .\recipes\settings.py:590
msgid "Portuguese"
msgstr ""
#: .\recipes\settings.py:591
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:493
#: .\recipes\settings.py:592
msgid "Romanian"
msgstr ""
#: .\recipes\settings.py:593
msgid "Spanish"
msgstr ""
#: .\recipes\settings.py:494
#: .\recipes\settings.py:594
msgid "Slovenian"
msgstr ""
#: .\recipes\settings.py:595
msgid "Swedish"
msgstr ""
#: .\recipes\settings.py:596
msgid "Turkish"
msgstr ""
#: .\recipes\settings.py:597
msgid "Ukranian"
msgstr ""

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
"POT-Creation-Date: 2025-09-22 20:15+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -69,70 +69,110 @@ msgstr ""
msgid "end"
msgstr ""
#: .\recipes\settings.py:478
#: .\recipes\plugins\enterprise_plugin\templates\enterprise_social_public_view.html:15
msgid "Tandoor Recipe Manager"
msgstr ""
#: .\recipes\settings.py:571
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:479
#: .\recipes\settings.py:572
msgid "Bulgarian"
msgstr ""
#: .\recipes\settings.py:480
#: .\recipes\settings.py:573
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:481
#: .\recipes\settings.py:574
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:482
#: .\recipes\settings.py:575
msgid "Croatian"
msgstr ""
#: .\recipes\settings.py:576
msgid "Danish"
msgstr ""
#: .\recipes\settings.py:483
#: .\recipes\settings.py:577
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:484
#: .\recipes\settings.py:578
msgid "English"
msgstr ""
#: .\recipes\settings.py:485
#: .\recipes\settings.py:579
msgid "French"
msgstr ""
#: .\recipes\settings.py:486
#: .\recipes\settings.py:580
msgid "Finnish"
msgstr ""
#: .\recipes\settings.py:581
msgid "German"
msgstr ""
#: .\recipes\settings.py:487
#: .\recipes\settings.py:582
msgid "Greek"
msgstr ""
#: .\recipes\settings.py:583
msgid "Hebrew"
msgstr ""
#: .\recipes\settings.py:584
msgid "Hungarian"
msgstr ""
#: .\recipes\settings.py:488
#: .\recipes\settings.py:585
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:489
#: .\recipes\settings.py:586
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:490
msgid "Norwegian "
#: .\recipes\settings.py:587
msgid "Norwegian"
msgstr ""
#: .\recipes\settings.py:491
#: .\recipes\settings.py:589
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:492
#: .\recipes\settings.py:590
msgid "Portuguese"
msgstr ""
#: .\recipes\settings.py:591
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:493
#: .\recipes\settings.py:592
msgid "Romanian"
msgstr ""
#: .\recipes\settings.py:593
msgid "Spanish"
msgstr ""
#: .\recipes\settings.py:494
#: .\recipes\settings.py:594
msgid "Slovenian"
msgstr ""
#: .\recipes\settings.py:595
msgid "Swedish"
msgstr ""
#: .\recipes\settings.py:596
msgid "Turkish"
msgstr ""
#: .\recipes\settings.py:597
msgid "Ukranian"
msgstr ""

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
"POT-Creation-Date: 2025-09-22 20:15+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -68,70 +68,110 @@ msgstr ""
msgid "end"
msgstr ""
#: .\recipes\settings.py:478
#: .\recipes\plugins\enterprise_plugin\templates\enterprise_social_public_view.html:15
msgid "Tandoor Recipe Manager"
msgstr ""
#: .\recipes\settings.py:571
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:479
#: .\recipes\settings.py:572
msgid "Bulgarian"
msgstr ""
#: .\recipes\settings.py:480
#: .\recipes\settings.py:573
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:481
#: .\recipes\settings.py:574
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:482
#: .\recipes\settings.py:575
msgid "Croatian"
msgstr ""
#: .\recipes\settings.py:576
msgid "Danish"
msgstr ""
#: .\recipes\settings.py:483
#: .\recipes\settings.py:577
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:484
#: .\recipes\settings.py:578
msgid "English"
msgstr ""
#: .\recipes\settings.py:485
#: .\recipes\settings.py:579
msgid "French"
msgstr ""
#: .\recipes\settings.py:486
#: .\recipes\settings.py:580
msgid "Finnish"
msgstr ""
#: .\recipes\settings.py:581
msgid "German"
msgstr ""
#: .\recipes\settings.py:487
#: .\recipes\settings.py:582
msgid "Greek"
msgstr ""
#: .\recipes\settings.py:583
msgid "Hebrew"
msgstr ""
#: .\recipes\settings.py:584
msgid "Hungarian"
msgstr ""
#: .\recipes\settings.py:488
#: .\recipes\settings.py:585
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:489
#: .\recipes\settings.py:586
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:490
msgid "Norwegian "
#: .\recipes\settings.py:587
msgid "Norwegian"
msgstr ""
#: .\recipes\settings.py:491
#: .\recipes\settings.py:589
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:492
#: .\recipes\settings.py:590
msgid "Portuguese"
msgstr ""
#: .\recipes\settings.py:591
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:493
#: .\recipes\settings.py:592
msgid "Romanian"
msgstr ""
#: .\recipes\settings.py:593
msgid "Spanish"
msgstr ""
#: .\recipes\settings.py:494
#: .\recipes\settings.py:594
msgid "Slovenian"
msgstr ""
#: .\recipes\settings.py:595
msgid "Swedish"
msgstr ""
#: .\recipes\settings.py:596
msgid "Turkish"
msgstr ""
#: .\recipes\settings.py:597
msgid "Ukranian"
msgstr ""

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
"POT-Creation-Date: 2025-09-22 20:15+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -68,72 +68,112 @@ msgstr ""
msgid "end"
msgstr ""
#: .\recipes\settings.py:478
#: .\recipes\plugins\enterprise_plugin\templates\enterprise_social_public_view.html:15
msgid "Tandoor Recipe Manager"
msgstr ""
#: .\recipes\settings.py:571
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:479
#: .\recipes\settings.py:572
msgid "Bulgarian"
msgstr ""
#: .\recipes\settings.py:480
#: .\recipes\settings.py:573
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:481
#: .\recipes\settings.py:574
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:482
#: .\recipes\settings.py:575
msgid "Croatian"
msgstr ""
#: .\recipes\settings.py:576
msgid "Danish"
msgstr ""
#: .\recipes\settings.py:483
#: .\recipes\settings.py:577
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:484
#: .\recipes\settings.py:578
msgid "English"
msgstr "Englisch"
#: .\recipes\settings.py:485
#: .\recipes\settings.py:579
msgid "French"
msgstr ""
#: .\recipes\settings.py:486
#: .\recipes\settings.py:580
msgid "Finnish"
msgstr ""
#: .\recipes\settings.py:581
msgid "German"
msgstr "Deutsch"
#: .\recipes\settings.py:487
#: .\recipes\settings.py:582
msgid "Greek"
msgstr ""
#: .\recipes\settings.py:583
msgid "Hebrew"
msgstr ""
#: .\recipes\settings.py:584
msgid "Hungarian"
msgstr ""
#: .\recipes\settings.py:488
#: .\recipes\settings.py:585
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:489
#: .\recipes\settings.py:586
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:490
msgid "Norwegian "
#: .\recipes\settings.py:587
msgid "Norwegian"
msgstr ""
#: .\recipes\settings.py:491
#: .\recipes\settings.py:589
#, fuzzy
#| msgid "English"
msgid "Polish"
msgstr "Englisch"
#: .\recipes\settings.py:492
#: .\recipes\settings.py:590
msgid "Portuguese"
msgstr ""
#: .\recipes\settings.py:591
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:493
#: .\recipes\settings.py:592
msgid "Romanian"
msgstr ""
#: .\recipes\settings.py:593
msgid "Spanish"
msgstr ""
#: .\recipes\settings.py:494
#: .\recipes\settings.py:594
msgid "Slovenian"
msgstr ""
#: .\recipes\settings.py:595
msgid "Swedish"
msgstr ""
#: .\recipes\settings.py:596
msgid "Turkish"
msgstr ""
#: .\recipes\settings.py:597
msgid "Ukranian"
msgstr ""

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
"POT-Creation-Date: 2025-09-22 20:15+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -68,70 +68,110 @@ msgstr ""
msgid "end"
msgstr ""
#: .\recipes\settings.py:478
#: .\recipes\plugins\enterprise_plugin\templates\enterprise_social_public_view.html:15
msgid "Tandoor Recipe Manager"
msgstr ""
#: .\recipes\settings.py:571
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:479
#: .\recipes\settings.py:572
msgid "Bulgarian"
msgstr ""
#: .\recipes\settings.py:480
#: .\recipes\settings.py:573
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:481
#: .\recipes\settings.py:574
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:482
#: .\recipes\settings.py:575
msgid "Croatian"
msgstr ""
#: .\recipes\settings.py:576
msgid "Danish"
msgstr ""
#: .\recipes\settings.py:483
#: .\recipes\settings.py:577
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:484
#: .\recipes\settings.py:578
msgid "English"
msgstr ""
#: .\recipes\settings.py:485
#: .\recipes\settings.py:579
msgid "French"
msgstr ""
#: .\recipes\settings.py:486
#: .\recipes\settings.py:580
msgid "Finnish"
msgstr ""
#: .\recipes\settings.py:581
msgid "German"
msgstr ""
#: .\recipes\settings.py:487
#: .\recipes\settings.py:582
msgid "Greek"
msgstr ""
#: .\recipes\settings.py:583
msgid "Hebrew"
msgstr ""
#: .\recipes\settings.py:584
msgid "Hungarian"
msgstr ""
#: .\recipes\settings.py:488
#: .\recipes\settings.py:585
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:489
#: .\recipes\settings.py:586
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:490
msgid "Norwegian "
#: .\recipes\settings.py:587
msgid "Norwegian"
msgstr ""
#: .\recipes\settings.py:491
#: .\recipes\settings.py:589
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:492
#: .\recipes\settings.py:590
msgid "Portuguese"
msgstr ""
#: .\recipes\settings.py:591
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:493
#: .\recipes\settings.py:592
msgid "Romanian"
msgstr ""
#: .\recipes\settings.py:593
msgid "Spanish"
msgstr ""
#: .\recipes\settings.py:494
#: .\recipes\settings.py:594
msgid "Slovenian"
msgstr ""
#: .\recipes\settings.py:595
msgid "Swedish"
msgstr ""
#: .\recipes\settings.py:596
msgid "Turkish"
msgstr ""
#: .\recipes\settings.py:597
msgid "Ukranian"
msgstr ""

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
"POT-Creation-Date: 2025-09-22 20:15+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -68,70 +68,110 @@ msgstr ""
msgid "end"
msgstr ""
#: .\recipes\settings.py:478
#: .\recipes\plugins\enterprise_plugin\templates\enterprise_social_public_view.html:15
msgid "Tandoor Recipe Manager"
msgstr ""
#: .\recipes\settings.py:571
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:479
#: .\recipes\settings.py:572
msgid "Bulgarian"
msgstr ""
#: .\recipes\settings.py:480
#: .\recipes\settings.py:573
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:481
#: .\recipes\settings.py:574
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:482
#: .\recipes\settings.py:575
msgid "Croatian"
msgstr ""
#: .\recipes\settings.py:576
msgid "Danish"
msgstr ""
#: .\recipes\settings.py:483
#: .\recipes\settings.py:577
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:484
#: .\recipes\settings.py:578
msgid "English"
msgstr ""
#: .\recipes\settings.py:485
#: .\recipes\settings.py:579
msgid "French"
msgstr ""
#: .\recipes\settings.py:486
#: .\recipes\settings.py:580
msgid "Finnish"
msgstr ""
#: .\recipes\settings.py:581
msgid "German"
msgstr ""
#: .\recipes\settings.py:487
#: .\recipes\settings.py:582
msgid "Greek"
msgstr ""
#: .\recipes\settings.py:583
msgid "Hebrew"
msgstr ""
#: .\recipes\settings.py:584
msgid "Hungarian"
msgstr ""
#: .\recipes\settings.py:488
#: .\recipes\settings.py:585
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:489
#: .\recipes\settings.py:586
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:490
msgid "Norwegian "
#: .\recipes\settings.py:587
msgid "Norwegian"
msgstr ""
#: .\recipes\settings.py:491
#: .\recipes\settings.py:589
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:492
#: .\recipes\settings.py:590
msgid "Portuguese"
msgstr ""
#: .\recipes\settings.py:591
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:493
#: .\recipes\settings.py:592
msgid "Romanian"
msgstr ""
#: .\recipes\settings.py:593
msgid "Spanish"
msgstr ""
#: .\recipes\settings.py:494
#: .\recipes\settings.py:594
msgid "Slovenian"
msgstr ""
#: .\recipes\settings.py:595
msgid "Swedish"
msgstr ""
#: .\recipes\settings.py:596
msgid "Turkish"
msgstr ""
#: .\recipes\settings.py:597
msgid "Ukranian"
msgstr ""

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
"POT-Creation-Date: 2025-09-22 20:15+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -68,70 +68,110 @@ msgstr ""
msgid "end"
msgstr ""
#: .\recipes\settings.py:478
#: .\recipes\plugins\enterprise_plugin\templates\enterprise_social_public_view.html:15
msgid "Tandoor Recipe Manager"
msgstr ""
#: .\recipes\settings.py:571
msgid "Armenian "
msgstr ""
#: .\recipes\settings.py:479
#: .\recipes\settings.py:572
msgid "Bulgarian"
msgstr ""
#: .\recipes\settings.py:480
#: .\recipes\settings.py:573
msgid "Catalan"
msgstr ""
#: .\recipes\settings.py:481
#: .\recipes\settings.py:574
msgid "Czech"
msgstr ""
#: .\recipes\settings.py:482
#: .\recipes\settings.py:575
msgid "Croatian"
msgstr ""
#: .\recipes\settings.py:576
msgid "Danish"
msgstr ""
#: .\recipes\settings.py:483
#: .\recipes\settings.py:577
msgid "Dutch"
msgstr ""
#: .\recipes\settings.py:484
#: .\recipes\settings.py:578
msgid "English"
msgstr ""
#: .\recipes\settings.py:485
#: .\recipes\settings.py:579
msgid "French"
msgstr ""
#: .\recipes\settings.py:486
#: .\recipes\settings.py:580
msgid "Finnish"
msgstr ""
#: .\recipes\settings.py:581
msgid "German"
msgstr ""
#: .\recipes\settings.py:487
#: .\recipes\settings.py:582
msgid "Greek"
msgstr ""
#: .\recipes\settings.py:583
msgid "Hebrew"
msgstr ""
#: .\recipes\settings.py:584
msgid "Hungarian"
msgstr ""
#: .\recipes\settings.py:488
#: .\recipes\settings.py:585
msgid "Italian"
msgstr ""
#: .\recipes\settings.py:489
#: .\recipes\settings.py:586
msgid "Latvian"
msgstr ""
#: .\recipes\settings.py:490
msgid "Norwegian "
#: .\recipes\settings.py:587
msgid "Norwegian"
msgstr ""
#: .\recipes\settings.py:491
#: .\recipes\settings.py:589
msgid "Polish"
msgstr ""
#: .\recipes\settings.py:492
#: .\recipes\settings.py:590
msgid "Portuguese"
msgstr ""
#: .\recipes\settings.py:591
msgid "Russian"
msgstr ""
#: .\recipes\settings.py:493
#: .\recipes\settings.py:592
msgid "Romanian"
msgstr ""
#: .\recipes\settings.py:593
msgid "Spanish"
msgstr ""
#: .\recipes\settings.py:494
#: .\recipes\settings.py:594
msgid "Slovenian"
msgstr ""
#: .\recipes\settings.py:595
msgid "Swedish"
msgstr ""
#: .\recipes\settings.py:596
msgid "Turkish"
msgstr ""
#: .\recipes\settings.py:597
msgid "Ukranian"
msgstr ""

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