Compare commits

..

104 Commits

Author SHA1 Message Date
vabene1111
9119d773f1 Merge branch 'develop' 2025-07-31 19:28:03 +02:00
vabene1111
4ea5cdb8b9 Merge branch 'develop' of https://github.com/TandoorRecipes/recipes into develop 2025-07-31 19:27:56 +02:00
vabene1111
f36e5f1d89 re-enabled latest push 2025-07-31 19:27:51 +02:00
Justin Straver
bce95ff604 Translated using Weblate (Slovenian)
Currently translated at 99.6% (794 of 797 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/sl/
2025-07-31 17:26:43 +00:00
Justin Straver
0f0a5b32cd Translated using Weblate (Dutch)
Currently translated at 99.4% (793 of 797 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/nl/
2025-07-31 17:26:43 +00:00
Justin Straver
0bd0b794df Translated using Weblate (Spanish)
Currently translated at 95.6% (762 of 797 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/es/
2025-07-31 17:26:43 +00:00
Justin Straver
5267ac12b0 Translated using Weblate (German)
Currently translated at 99.1% (790 of 797 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/de/
2025-07-31 17:26:43 +00:00
Justin Straver
02678ffe30 Translated using Weblate (English)
Currently translated at 100.0% (797 of 797 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/en/
2025-07-31 17:26:43 +00:00
Justin Straver
2907e29a11 Translated using Weblate (Dutch)
Currently translated at 99.2% (791 of 797 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/nl/
2025-07-31 16:42:55 +00:00
vabene1111
9d49c4d550 Merge branch 'develop' of http://translate.tandoor.dev/git/tandoor/recipes-backend into develop
# Conflicts:
#	vue3/src/locales/it.json
#	vue3/src/locales/ru.json
2025-07-31 18:04:43 +02:00
vabene1111
e2c6eec628 updated release flow 2025-07-31 17:52:47 +02:00
vabene1111
63716e4397 removed debug code 2025-07-31 17:43:42 +02:00
vabene1111
27e5955c78 Merge branch 'develop' 2025-07-31 17:29:28 +02:00
vabene1111
e9e6cdccca improved help page and dialog 2025-07-31 17:28:36 +02:00
vabene1111
8c8096e348 add ability to disable frontend plugins 2025-07-31 16:17:28 +02:00
vabene1111
9fcbbc17e8 port change logic 2025-07-31 16:04:30 +02:00
vabene1111
0a2f83cf85 localhost in nginx config 2025-07-31 15:49:48 +02:00
vabene1111
01fff0783f additions to help page 2025-07-29 18:04:40 +02:00
vabene1111
7ccdb90f9b updated docs for tandoor 2 setup 2025-07-29 17:37:42 +02:00
vabene1111
c2e522d9f2 service worker 2025-07-29 16:57:51 +02:00
vabene1111
92578dd6a2 new nginx config directory 2025-07-29 16:01:32 +02:00
vabene1111
3103f28fc8 maybe now ? 2025-07-28 22:19:06 +02:00
vabene1111
a5df1275ec try a different way 2025-07-28 22:09:58 +02:00
vabene1111
a4308f9864 run nginx as part of boot script 2025-07-28 22:06:55 +02:00
vabene1111
21526fb676 nginx run 2025-07-28 21:48:19 +02:00
vabene1111
5dc3116c44 service worker settings 2025-07-28 21:45:45 +02:00
vabene1111
2a6a87ec16 run nginx 2025-07-28 21:37:22 +02:00
vabene1111
8149b05185 delete http.d by default 2025-07-28 21:32:59 +02:00
vabene1111
61afbbdfbe tweaking nginx 2025-07-28 21:22:43 +02:00
vabene1111
a37455ccda fix delete failure? 2025-07-28 20:32:22 +02:00
vabene1111
6d711aff41 first try adding nginx directly to container 2025-07-28 20:28:07 +02:00
vabene1111
d4adb975ec fixed theming test 2025-07-28 20:16:37 +02:00
Aleksey
9b581d58bd Translated using Weblate (Russian)
Currently translated at 100.0% (795 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/ru/
2025-07-28 17:58:41 +00:00
Vincenzo Reale
79db8a2fe0 Translated using Weblate (Italian)
Currently translated at 100.0% (795 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/it/
2025-07-28 17:58:41 +00:00
Aleksey
f722d4751b Translated using Weblate (Russian)
Currently translated at 17.4% (85 of 488 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/ru/
2025-07-28 17:58:40 +00:00
vabene1111
368ed2aaf3 updated pytest and fixed ingredient parser 2025-07-28 19:29:20 +02:00
vabene1111
50400e1d20 fixed theming issue 2025-07-28 18:20:30 +02:00
vabene1111
750115cab5 improved properties dialog 2025-07-28 18:20:24 +02:00
vabene1111
9d8acdc41f removed duplicate package.json 2025-07-28 17:37:16 +02:00
vabene1111
7ab36f1a7a small fixes 2025-07-28 17:36:55 +02:00
Justin Straver
b8d0e32550 Translated using Weblate (Dutch)
Currently translated at 99.4% (791 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/nl/
2025-07-23 21:11:45 +00:00
Justin Straver
d9f0889b36 Translated using Weblate (English)
Currently translated at 100.0% (795 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/en/
2025-07-23 21:11:44 +00:00
Justin Straver
35f40f175c Translated using Weblate (Dutch)
Currently translated at 99.6% (792 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/nl/
2025-07-23 08:15:32 +00:00
Aija Kozlovska
291ff86c42 Translated using Weblate (Latvian)
Currently translated at 70.0% (342 of 488 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/lv/
2025-07-21 09:43:37 +00:00
Matjaž T
d2b0aeab52 Translated using Weblate (Slovenian)
Currently translated at 100.0% (795 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/sl/
2025-07-21 07:29:54 +00:00
Vincenzo Reale
3cab6e538e Translated using Weblate (Italian)
Currently translated at 100.0% (795 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/it/
2025-07-21 07:29:54 +00:00
Justin Straver
db67ab6b30 Translated using Weblate (English)
Currently translated at 100.0% (795 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/en/
2025-07-20 16:23:25 +00:00
Justin Straver
b5b31b3dc6 Translated using Weblate (Dutch)
Currently translated at 99.6% (792 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/nl/
2025-07-20 15:05:13 +00:00
Justin Straver
a15dd2ccbc Translated using Weblate (German)
Currently translated at 99.3% (790 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/de/
2025-07-20 15:05:13 +00:00
Justin Straver
62cc54f9f5 Translated using Weblate (English)
Currently translated at 100.0% (795 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/en/
2025-07-20 15:05:13 +00:00
Justin Straver
75c5bba7e5 Translated using Weblate (Dutch)
Currently translated at 100.0% (795 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/nl/
2025-07-20 14:51:34 +00:00
Justin Straver
642a0493af Translated using Weblate (Dutch)
Currently translated at 95.2% (757 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/nl/
2025-07-20 14:23:53 +00:00
Justin Straver
8d8e0be328 Translated using Weblate (Dutch)
Currently translated at 89.3% (710 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/nl/
2025-07-20 14:07:24 +00:00
Justin Straver
744b588cea Translated using Weblate (Dutch)
Currently translated at 87.2% (694 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/nl/
2025-07-20 13:56:22 +00:00
Justin Straver
d3a21b9ff0 Translated using Weblate (Dutch)
Currently translated at 87.1% (693 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/nl/
2025-07-20 11:08:36 +00:00
Justin Straver
3a9c40c566 Translated using Weblate (Dutch)
Currently translated at 79.3% (631 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/nl/
2025-07-20 09:49:52 +00:00
Justin Straver
387e0a5250 Translated using Weblate (Dutch)
Currently translated at 78.8% (627 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/nl/
2025-07-20 09:42:17 +00:00
Justin Straver
4ea28ba22a Translated using Weblate (Dutch)
Currently translated at 74.8% (595 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/nl/
2025-07-20 09:19:02 +00:00
Justin Straver
20660f547c Translated using Weblate (Dutch)
Currently translated at 74.7% (594 of 795 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/nl/
2025-07-20 09:17:57 +00:00
Justin Straver
2ee63d8568 Translated using Weblate (Dutch)
Currently translated at 74.4% (590 of 793 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/nl/
2025-07-20 08:37:14 +00:00
vabene1111
2179d7d1f7 playing with plugins 2025-07-18 15:49:46 +02:00
Matjaž T
034d59373f Translated using Weblate (Slovenian)
Currently translated at 100.0% (793 of 793 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/sl/
2025-07-18 08:00:26 +00:00
vabene1111
d1ad0ade0f playing with programmatic navigation creation 2025-07-17 16:21:10 +02:00
vabene1111
991089c17a various little fixes 2025-07-17 16:04:31 +02:00
vabene1111
54960d8480 basics of an open data plugin (tmp in this repo)
temporarily in the main repo while testing and playing around
2025-07-17 15:40:05 +02:00
vabene1111
5fcfe09bb6 added basic plugin support 2025-07-17 15:34:51 +02:00
vabene1111
01c4974507 Merge pull request #3815 from caffeinated-tech/cookbookapp-images-import
Cookbookapp images import
2025-07-17 09:44:27 +02:00
vabene1111
2d57e0dab2 Merge pull request #3832 from TandoorRecipes/dependabot/npm_and_yarn/vue3/vue-i18n-11.1.10
Bump vue-i18n from 11.1.7 to 11.1.10 in /vue3
2025-07-17 08:11:05 +02:00
vabene1111
d52e5408c0 Merge pull request #3825 from TandoorRecipes/dependabot/pip/aiohttp-3.12.14
Bump aiohttp from 3.10.11 to 3.12.14
2025-07-17 08:10:48 +02:00
dependabot[bot]
fdce69daf4 Bump vue-i18n from 11.1.7 to 11.1.10 in /vue3
Bumps [vue-i18n](https://github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n) from 11.1.7 to 11.1.10.
- [Release notes](https://github.com/intlify/vue-i18n/releases)
- [Changelog](https://github.com/intlify/vue-i18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/intlify/vue-i18n/commits/v11.1.10/packages/vue-i18n)

---
updated-dependencies:
- dependency-name: vue-i18n
  dependency-version: 11.1.10
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-16 19:36:39 +00:00
Vincenzo Reale
cb3ffcb12d Translated using Weblate (Italian)
Currently translated at 100.0% (793 of 793 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/it/
2025-07-16 03:44:42 +00:00
vabene1111
d7342a349b Merge branch 'develop' of http://translate.tandoor.dev/git/tandoor/recipes-backend into develop
# Conflicts:
#	vue3/src/locales/es.json
#	vue3/src/locales/it.json
#	vue3/src/locales/pt_BR.json
#	vue3/src/locales/sl.json
2025-07-15 12:59:52 +02:00
dependabot[bot]
794bbed833 Bump aiohttp from 3.10.11 to 3.12.14
Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.10.11 to 3.12.14.
- [Release notes](https://github.com/aio-libs/aiohttp/releases)
- [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.10.11...v3.12.14)

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.12.14
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-15 09:25:01 +00:00
vabene1111
0b335e80a6 first help dialog draft 2025-07-11 22:54:03 +02:00
vabene1111
2716d72e31 fixed redis settings? 2025-07-11 22:22:31 +02:00
vabene1111
8c849a1077 added redis as cache backend 2025-07-11 21:51:54 +02:00
vabene1111
8c1769458d multiselect CSS for every variant and fix is-selected class 2025-07-11 21:17:55 +02:00
vabene1111
2ac6451370 added FDC import to food editor 2025-07-11 21:08:56 +02:00
vabene1111
7841397b59 fixed step editor recipe selection 2025-07-11 20:51:21 +02:00
vabene1111
cd11194ce5 fixed css 2025-07-11 20:07:25 +02:00
vabene1111
be7558f82b allow automation directly from ingredient editor 2025-07-11 19:59:10 +02:00
vabene1111
35a7875f6f fixed generic model merge for multi word model names 2025-07-11 19:50:17 +02:00
vabene1111
55f1f834c2 improved meal plan editor shopping integration 2025-07-11 19:43:34 +02:00
vabene1111
f5f32912b1 added ability to add recipes to shopping list from shopping list view 2025-07-11 19:16:06 +02:00
vabene1111
5709435d43 removed JS_REVERSE_SCRIPT_PREFIX config variable 2025-07-11 19:04:37 +02:00
vabene1111
1c219dbc3b fixed error on account pages 2025-07-11 19:03:18 +02:00
vabene1111
1262982588 time picker out of labs 2025-07-11 18:46:54 +02:00
vabene1111
be8a340a0c fixed custom CSS problem after build 2025-07-11 18:44:40 +02:00
liam
fb1de15de6 cookbook app: only import the first valid image 2025-07-07 20:39:00 +00:00
liam
2180f11768 ignore venv in dockerfile to prevent my local venv overwriting the alpine one 2025-07-07 20:39:00 +00:00
caffeinated-tech
1083b7521e Merge branch 'TandoorRecipes:develop' into cookbookapp-images-import 2025-07-07 21:33:23 +01:00
vabene1111
70d40f9e70 fixed missing template 2025-07-07 17:56:54 +02:00
vabene1111
1094cf2d92 temporarily disabled service worker url 2025-07-07 17:39:18 +02:00
vabene1111
aaf6e0f197 Merge branch 'develop' of https://github.com/TandoorRecipes/recipes into develop 2025-07-06 18:26:50 +02:00
vabene1111
ec59cd6e4f playing around with a help dialog 2025-07-06 18:26:46 +02:00
caffeinated-tech
5a0a5b09a1 Merge branch 'TandoorRecipes:develop' into cookbookapp-images-import 2025-07-06 01:16:27 +01:00
liam
e698d14ec3 fixed documentation links 2025-07-06 00:10:53 +00:00
liam
0caf2fe77f added libxml workaround for building devcontainers 2025-07-06 00:03:19 +00:00
liam
c079f49d71 import all images from cookbookapp, ignoring branding images 2025-07-06 00:02:53 +00:00
Lucas Ortega
8490ac01cc Translated using Weblate (Portuguese (Brazil))
Currently translated at 78.4% (609 of 776 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/pt_BR/
2025-06-23 08:28:59 +00:00
Ángel
84477ef52a Translated using Weblate (Spanish)
Currently translated at 57.3% (280 of 488 strings)

Translation: Tandoor/Recipes Backend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-backend/es/
2025-06-23 08:28:59 +00:00
Matjaž T
b789573de3 Translated using Weblate (Slovenian)
Currently translated at 100.0% (776 of 776 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/sl/
2025-06-23 08:28:59 +00:00
Ángel
d5d8e7ce63 Translated using Weblate (Spanish)
Currently translated at 98.8% (767 of 776 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/es/
2025-06-23 08:28:58 +00:00
Vincenzo Reale
c7a49458b9 Translated using Weblate (Italian)
Currently translated at 100.0% (776 of 776 strings)

Translation: Tandoor/Recipes Frontend
Translate-URL: http://translate.tandoor.dev/projects/tandoor/recipes-frontend/it/
2025-06-23 08:28:58 +00:00
171 changed files with 9314 additions and 6428 deletions

View File

@@ -3,6 +3,11 @@ FROM python:3.10-alpine3.18
#Install all dependencies.
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git yarn
# Fix libxml error from xmlsec https://github.com/xmlsec/python-xmlsec/issues/257#issuecomment-1738620862
RUN echo "https://dl-cdn.alpinelinux.org/alpine/v3.15/community/" | tee -a /etc/apk/repositories
RUN echo "https://dl-cdn.alpinelinux.org/alpine/v3.15/main" | tee -a /etc/apk/repositories
RUN apk add --no-cache libxml2-dev=2.9.14-r2 xmlsec-dev=1.2.33-r0
#Print all logs without buffering it.
ENV PYTHONUNBUFFERED 1

View File

@@ -29,3 +29,4 @@ vue/babel.config*
vue/package.json
vue/tsconfig.json
vue/src/utils/openapi
venv

View File

@@ -74,9 +74,8 @@ jobs:
flavor: |
latest=false
suffix=${{ matrix.suffix }}
# disable latest for tagged releases while in beta
# type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
tags: |
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
@@ -94,29 +93,29 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
# notify-stable:
# name: Notify Stable
# runs-on: ubuntu-latest
# needs: build-container
# if: startsWith(github.ref, 'refs/tags/')
# steps:
# - name: Set tag name
# run: |
# # Strip "refs/tags/" prefix
# echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
# # Send stable discord notification
# - name: Discord notification
# env:
# DISCORD_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
# uses: Ilshidur/action-discord@0.3.2
# with:
# args: '🚀 Version {{ VERSION }} of tandoor has been released 🥳 Check it out https://github.com/vabene1111/recipes/releases/tag/{{ VERSION }}'
notify-stable:
name: Notify Stable
runs-on: ubuntu-latest
needs: build-container
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Set tag name
run: |
# Strip "refs/tags/" prefix
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
# Send stable discord notification
- name: Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
uses: Ilshidur/action-discord@0.3.2
with:
args: '🚀 Version {{ VERSION }} of tandoor has been released 🥳 Check it out https://github.com/vabene1111/recipes/releases/tag/{{ VERSION }}'
notify-beta:
name: Notify Beta
runs-on: ubuntu-latest
needs: build-container
if: startsWith(github.ref, 'refs/tags/')
if: github.ref == 'refs/heads/beta'
steps:
# Send beta discord notification
- name: Discord notification

View File

@@ -38,7 +38,7 @@ jobs:
./cookbook/static
./staticfiles
key: |
${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.node-version }}-collectstatic-${{ hashFiles('**/*.css', '**/*.js', 'vue/src/*') }}
${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.node-version }}-collectstatic-${{ hashFiles('**/*.css', '**/*.js', 'vue3/src/*') }}
# Build Vue frontend & Dependencies
- name: Set up Node ${{ matrix.node-version }}

2
.gitignore vendored
View File

@@ -89,3 +89,5 @@ venv/
.idea/easy-i18n.xml
cookbook/static/vue3
vue3/node_modules
cookbook/tests/other/docs/reports/tests/tests.html
cookbook/tests/other/docs/reports/tests/pytest.xml

View File

@@ -1,7 +1,7 @@
FROM python:3.13-alpine3.21
#Install all dependencies.
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git libgcc libstdc++
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git libgcc libstdc++ nginx
#Print all logs without buffering it.
ENV PYTHONUNBUFFERED 1
@@ -9,7 +9,7 @@ ENV PYTHONUNBUFFERED 1
ENV DOCKER true
#This port will be used by gunicorn.
EXPOSE 8080
EXPOSE 80 8080
#Create app dir and install requirements.
RUN mkdir /opt/recipes
@@ -40,6 +40,10 @@ RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-de
#Copy project and execute it.
COPY . ./
# delete default nginx config and link it to tandoors config
RUN rm -rf /etc/nginx/http.d
RUN ln -s /opt/recipes/http.d /etc/nginx/http.d
# commented for now https://github.com/TandoorRecipes/recipes/issues/3478
#HEALTHCHECK --interval=30s \
# --timeout=5s \

17
boot.sh
View File

@@ -5,7 +5,11 @@ TANDOOR_PORT="${TANDOOR_PORT:-8080}"
GUNICORN_WORKERS="${GUNICORN_WORKERS:-3}"
GUNICORN_THREADS="${GUNICORN_THREADS:-2}"
GUNICORN_LOG_LEVEL="${GUNICORN_LOG_LEVEL:-'info'}"
NGINX_CONF_FILE=/opt/recipes/nginx/conf.d/Recipes.conf
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]"
@@ -14,11 +18,6 @@ display_warning() {
echo "Checking configuration..."
# Nginx config file must exist if gunicorn is not active
if [ ! -f "$NGINX_CONF_FILE" ] && [ $GUNICORN_MEDIA -eq 0 ]; then
display_warning "Nginx configuration file could not be found at the default location!\nPath: ${NGINX_CONF_FILE}"
fi
# SECRET_KEY (or a valid file at SECRET_KEY_FILE) must be set in .env file
if [ -f "${SECRET_KEY_FILE}" ]; then
@@ -84,7 +83,6 @@ python manage.py migrate
echo "Collecting static files, this may take a while..."
python manage.py collectstatic_js_reverse
python manage.py collectstatic --noinput
echo "Done"
@@ -93,6 +91,11 @@ chmod -R 755 /opt/recipes/mediafiles
ipv6_disable=$(cat /sys/module/ipv6/parameters/disable)
# 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

View File

@@ -109,7 +109,7 @@ class AutomationEngine:
Moves a string that should never be treated as a unit to next token and optionally replaced with default unit
e.g. NEVER_UNIT: param1: egg, param2: None would modify ['1', 'egg', 'white'] to ['1', '', 'egg', 'white']
or NEVER_UNIT: param1: egg, param2: pcs would modify ['1', 'egg', 'yolk'] to ['1', 'pcs', 'egg', 'yolk']
:param1 string: string that should never be considered a unit, will be moved to token[2]
:param1 tokens: string that should never be considered a unit, will be moved to token[2]
:param2 (optional) unit as string: will insert unit string into token[1]
:return: unit as string (possibly changed by automation)
"""
@@ -135,7 +135,7 @@ class AutomationEngine:
new_unit = self.never_unit[tokens[1].lower()]
never_unit = True
except KeyError:
return tokens
return tokens, never_unit
else:
if a := Automation.objects.annotate(param_1_lower=Lower('param_1')).filter(space=self.request.space, type=Automation.NEVER_UNIT, param_1_lower__in=[
tokens[1].lower(), alt_unit.lower()], disabled=False).order_by('order').first():
@@ -144,7 +144,7 @@ class AutomationEngine:
if never_unit:
tokens.insert(1, new_unit)
return tokens
return tokens, never_unit
def apply_transpose_automation(self, string):
"""

View File

@@ -84,7 +84,6 @@ def handle_image(request, image_object, filetype):
if filetype == '.png':
return rescale_image_png(image_object)
else:
print('STripping image')
return strip_image_meta(image_object, file_format)
# TODO webp and gifs bypass the scaling and metadata checks, fix

View File

@@ -176,7 +176,6 @@ class IngredientParser:
# if something like this is detected move it to the beginning so the parser can handle it
if len(ingredient) < 1000 and re.search(r'^([^\W\d_])+(.)*[1-9](\d)*\s*([^\W\d_])+', ingredient):
match = re.search(r'[1-9](\d)*\s*([^\W\d_])+', ingredient)
print(f'reording from {ingredient} to {ingredient[match.start():match.end()] + " " + ingredient.replace(ingredient[match.start():match.end()], "")}')
ingredient = ingredient[match.start():match.end()] + ' ' + ingredient.replace(ingredient[match.start():match.end()], '')
# if the string contains parenthesis early on remove it and place it at the end
@@ -211,39 +210,46 @@ class IngredientParser:
# three arguments if it already has a unit there can't be
# a fraction for the amount
if len(tokens) > 2:
never_unit_applied = False
if not self.ignore_rules:
tokens = self.automation.apply_never_unit_automation(tokens)
try:
if unit is not None:
# a unit is already found, no need to try the second argument for a fraction
# probably not the best method to do it, but I didn't want to make an if check and paste the exact same thing in the else as already is in the except
raise ValueError
# try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½'
amount += self.parse_fraction(tokens[1])
# assume that units can't end with a comma
if len(tokens) > 3 and not tokens[2].endswith(','):
# try to use third argument as unit and everything else as food, use everything as food if it fails
try:
food, note = self.parse_food(tokens[3:])
unit = tokens[2]
except ValueError:
tokens, never_unit_applied = self.automation.apply_never_unit_automation(tokens)
if never_unit_applied:
unit = tokens[1]
food, note = self.parse_food(tokens[2:])
else:
try:
if unit is not None:
# a unit is already found, no need to try the second argument for a fraction
# probably not the best method to do it, but I didn't want to make an if check and paste the exact same thing in the else as already is in the except
raise ValueError
# try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½'
if tokens[1]:
amount += self.parse_fraction(tokens[1])
# assume that units can't end with a comma
if len(tokens) > 3 and not tokens[2].endswith(','):
# try to use third argument as unit and everything else as food, use everything as food if it fails
try:
food, note = self.parse_food(tokens[3:])
unit = tokens[2]
except ValueError:
food, note = self.parse_food(tokens[2:])
else:
food, note = self.parse_food(tokens[2:])
else:
food, note = self.parse_food(tokens[2:])
except ValueError:
# assume that units can't end with a comma
if not tokens[1].endswith(','):
# try to use second argument as unit and everything else as food, use everything as food if it fails
try:
food, note = self.parse_food(tokens[2:])
if unit is None:
unit = tokens[1]
else:
note = tokens[1]
except ValueError:
except ValueError:
# assume that units can't end with a comma
if not tokens[1].endswith(','):
# try to use second argument as unit and everything else as food, use everything as food if it fails
try:
food, note = self.parse_food(tokens[2:])
if unit is None:
unit = tokens[1]
else:
note = tokens[1]
except ValueError:
food, note = self.parse_food(tokens[1:])
else:
food, note = self.parse_food(tokens[1:])
else:
food, note = self.parse_food(tokens[1:])
else:
# only two arguments, first one is the amount
# which means this is the food
@@ -264,6 +270,7 @@ class IngredientParser:
if food and not self.ignore_rules:
food = self.automation.apply_food_automation(food)
if len(food) > Food._meta.get_field('name').max_length: # test if food name is to long
# try splitting it at a space and taking only the first arg
if len(food.split()) > 1 and len(food.split()[0]) < Food._meta.get_field('name').max_length:

View File

@@ -62,9 +62,14 @@ class FoodPropertyHelper:
computed_properties[pt.id]['food_values'] = self.add_or_create(
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 i.amount == 0 or i.no_amount: # don't count ingredients without an amount as missing
computed_properties[pt.id]['missing_value'] = computed_properties[pt.id]['missing_value'] or False # don't override if another food was already missing
# 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 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']:
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': {'id': i.food.id, 'name': i.food.name}, 'value': 0}
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}

View File

@@ -12,7 +12,7 @@ class ScopeMiddleware:
self.get_response = get_response
def __call__(self, request):
prefix = settings.JS_REVERSE_SCRIPT_PREFIX or ''
prefix = settings.SCRIPT_NAME or ''
# need to disable scopes for writing requests into userpref and enable for loading ?
if request.path.startswith(prefix + '/api/user-preference/'):

View File

@@ -60,14 +60,15 @@ class CookBookApp(Integration):
food=f, unit=u, amount=ingredient.get('amount', None), note=ingredient.get('note', None), original_text=ingredient.get('original_text', None), space=self.request.space,
))
if len(images) > 0:
try:
url = images[0]
if validate_import_url(url):
try:
for url in images:
# import the first valid image which is not cookbookapp branding
if validate_import_url(url) and not url.startswith("https://media.cookbookmanager.com/brand/"):
response = requests.get(url)
self.import_recipe_image(recipe, BytesIO(response.content))
except Exception as e:
print('failed to import image ', str(e))
break
except Exception as e:
print('failed to import image ', str(e))
recipe.save()
return recipe

View File

@@ -14,7 +14,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
"PO-Revision-Date: 2025-01-29 13:44+0000\n"
"PO-Revision-Date: 2025-06-23 08:28+0000\n"
"Last-Translator: Ángel <1024mb@users.noreply.translate.tandoor.dev>\n"
"Language-Team: Spanish <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/es/>\n"
@@ -284,14 +284,12 @@ msgid "You have more users than allowed in your space."
msgstr "Tenés mas usuarios que los permitidos en tu espacio"
#: .\cookbook\helper\recipe_url_import.py:310
#, fuzzy
#| msgid "Use fractions"
msgid "reverse rotation"
msgstr "Usar fracciones"
msgstr "rotación inversa"
#: .\cookbook\helper\recipe_url_import.py:311
msgid "careful rotation"
msgstr ""
msgstr "rotación cuidadosa"
#: .\cookbook\helper\recipe_url_import.py:312
msgid "knead"
@@ -398,8 +396,9 @@ msgid "Section"
msgstr "Sección"
#: .\cookbook\management\commands\fix_duplicate_properties.py:15
#, fuzzy
msgid "Fixes foods with "
msgstr ""
msgstr "Corrige alimentos con "
#: .\cookbook\management\commands\rebuildindex.py:14
msgid "Rebuilds full text search index on Recipe"
@@ -436,16 +435,14 @@ msgid "Other"
msgstr "Otro"
#: .\cookbook\migrations\0190_auto_20230525_1506.py:17
#, fuzzy
#| msgid "Fats"
msgid "Fat"
msgstr "Grasas"
msgstr "Grasa"
#: .\cookbook\migrations\0190_auto_20230525_1506.py:17
#: .\cookbook\migrations\0190_auto_20230525_1506.py:18
#: .\cookbook\migrations\0190_auto_20230525_1506.py:19
msgid "g"
msgstr ""
msgstr "gr."
#: .\cookbook\migrations\0190_auto_20230525_1506.py:18
msgid "Carbohydrates"
@@ -468,6 +465,8 @@ msgid ""
"Maximum file storage for space in MB. 0 for unlimited, -1 to disable file "
"upload."
msgstr ""
"Almacenamiento máximo de archivos para el espacio en MB. 0 para ilimitado, -"
"1 para desactivar la carga de archivos."
#: .\cookbook\models.py:454 .\cookbook\templates\search.html:7
#: .\cookbook\templates\settings.html:18
@@ -498,18 +497,16 @@ msgid "Nutrition"
msgstr "Información Nutricional"
#: .\cookbook\models.py:918
#, fuzzy
#| msgid "Merge"
msgid "Allergen"
msgstr "Combinar"
msgstr "Alérgeno"
#: .\cookbook\models.py:919
msgid "Price"
msgstr ""
msgstr "Precio"
#: .\cookbook\models.py:919
msgid "Goal"
msgstr ""
msgstr "Objetivo"
#: .\cookbook\models.py:1408 .\cookbook\templates\search_info.html:28
msgid "Simple"
@@ -532,54 +529,40 @@ msgid "Food Alias"
msgstr "Alias de la Comida"
#: .\cookbook\models.py:1468
#, fuzzy
#| msgid "Units"
msgid "Unit Alias"
msgstr "Unidades"
msgstr "Alias de unidad"
#: .\cookbook\models.py:1469
#, fuzzy
#| msgid "Keywords"
msgid "Keyword Alias"
msgstr "Palabras clave"
msgstr "Alias de palabra clave"
#: .\cookbook\models.py:1470
#, fuzzy
#| msgid "Description"
msgid "Description Replace"
msgstr "Descripción"
msgstr "Reemplazo de descripción"
#: .\cookbook\models.py:1471
#, fuzzy
#| msgid "Instructions"
msgid "Instruction Replace"
msgstr "Instrucciones"
msgstr "Reemplazo de instrucciones"
#: .\cookbook\models.py:1472
#, fuzzy
#| msgid "New Unit"
msgid "Never Unit"
msgstr "Nueva Unidad"
msgstr "Unidad prohibida"
#: .\cookbook\models.py:1473
msgid "Transpose Words"
msgstr ""
msgstr "Transponer palabras"
#: .\cookbook\models.py:1474
#, fuzzy
#| msgid "Food Alias"
msgid "Food Replace"
msgstr "Alias de la Comida"
msgstr "Reemplazo de alimento"
#: .\cookbook\models.py:1475
#, fuzzy
#| msgid "Description"
msgid "Unit Replace"
msgstr "Descripción"
msgstr "Reemplazo de unidad"
#: .\cookbook\models.py:1476
msgid "Name Replace"
msgstr ""
msgstr "Reemplazo de nombre"
#: .\cookbook\models.py:1503 .\cookbook\views\delete.py:40
#: .\cookbook\views\edit.py:210 .\cookbook\views\new.py:39
@@ -587,10 +570,8 @@ msgid "Recipe"
msgstr "Receta"
#: .\cookbook\models.py:1504
#, fuzzy
#| msgid "Food"
msgid "Food"
msgstr "Comida"
msgstr "Alimento"
#: .\cookbook\models.py:1505 .\cookbook\templates\base.html:149
msgid "Keyword"
@@ -648,22 +629,26 @@ msgstr "Invitación para Tandoor Recipes"
#: .\cookbook\serializer.py:1426
msgid "Existing shopping list to update"
msgstr ""
msgstr "Lista de compras existente para actualizar"
#: .\cookbook\serializer.py:1428
msgid ""
"List of ingredient IDs from the recipe to add, if not provided all "
"ingredients will be added."
msgstr ""
"Lista de IDs de ingredientes de la receta para agregar; si no se "
"proporciona, se agregarán todos los ingredientes."
#: .\cookbook\serializer.py:1430
msgid ""
"Providing a list_recipe ID and servings of 0 will delete that shopping list."
msgstr ""
"Proporcionar un ID list_recipe y porciones igual a 0 eliminará esa lista de "
"compras."
#: .\cookbook\serializer.py:1439
msgid "Amount of food to add to the shopping list"
msgstr ""
msgstr "Cantidad de alimento a agregar a la lista de compras"
#: .\cookbook\serializer.py:1441
msgid "ID of unit to use for the shopping list"

View File

@@ -11,7 +11,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
"PO-Revision-Date: 2024-11-05 10:58+0000\n"
"PO-Revision-Date: 2025-07-21 09:43+0000\n"
"Last-Translator: Aija Kozlovska <kozlovska.aija@gmail.com>\n"
"Language-Team: Latvian <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/lv/>\n"
@@ -20,7 +20,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2;\n"
"X-Generator: Weblate 5.6.2\n"
"X-Generator: Weblate 5.8.4\n"
#: .\cookbook\forms.py:45
msgid ""
@@ -83,8 +83,8 @@ msgid ""
"Leave empty for dropbox and enter only base url for nextcloud (<code>/remote."
"php/webdav/</code> is added automatically)"
msgstr ""
"Atstājiet tukšu Dropbox un ievadiet tikai Nextcloud bāzes URL (<kods> /"
"remote.php/webdav/ </code> tiek pievienots automātiski)"
"Atstājiet tukšu Dropbox un ievadiet tikai Nextcloud bāzes URL (<code> /remote"
".php/webdav/ </code> tiek pievienots automātiski)"
#: .\cookbook\forms.py:188
msgid ""
@@ -147,48 +147,65 @@ msgid ""
"Determines how fuzzy a search is if it uses trigram similarity matching (e."
"g. low values mean more typos are ignored)."
msgstr ""
"Nosaka cik precīza ir meklēšana gadījumā, ja tiek izmantota trigram līdzība ("
"jo zemāka vērtība, jo vairāk rakstīšanas kļūdas tiek ignorētas)."
#: .\cookbook\forms.py:340
msgid ""
"Select type method of search. Click <a href=\"/docs/search/\">here</a> for "
"full description of choices."
msgstr ""
"Izvēlies meklēšanas veidu. Spied <a href=\"/docs/search/\">šeit</a>, lai "
"apskatītu visas iespējas."
#: .\cookbook\forms.py:341
msgid ""
"Use fuzzy matching on units, keywords and ingredients when editing and "
"importing recipes."
msgstr ""
"Izmanto aptuveno meklēšanu vienībām, atslēgas vārdiem un sastāvdaļām "
"importējot un labojot receptes."
#: .\cookbook\forms.py:342
msgid ""
"Fields to search ignoring accents. Selecting this option can improve or "
"degrade search quality depending on language"
msgstr ""
"Lauki, kurus meklējot ignorēt akcentus. Šī varianta izvēlēšanās var uzlabot "
"vai pasliktināt meklēšanas kvalitāti atkarībā no valodas"
#: .\cookbook\forms.py:343
msgid ""
"Fields to search for partial matches. (e.g. searching for 'Pie' will return "
"'pie' and 'piece' and 'soapie')"
msgstr ""
"Lauki, kuros meklēt aptuveno līdzību. (piem. meklējot vārdu 'Kūka' tiks "
"atrasts arī 'kūka' un 'ābolkūka')"
#: .\cookbook\forms.py:344
msgid ""
"Fields to search for beginning of word matches. (e.g. searching for 'sa' "
"will return 'salad' and 'sandwich')"
msgstr ""
"Lauki, kuros meklēt vārdu līdzības sākumu. (piem meklējot 'la' atradīt "
"'lapas' un 'laims')"
#: .\cookbook\forms.py:345
msgid ""
"Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) "
"Note: this option will conflict with 'web' and 'raw' methods of search."
msgstr ""
"Lauki, kuriem izmantot aptuveno meklēšanu. (piem. meklējot 'recpte' tiks "
"atrasts 'recepte'.) Piezīme: šis variants konfliktēs ar 'web' un 'raw' "
"meklēšanas metodēm."
#: .\cookbook\forms.py:346
msgid ""
"Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods "
"only function with fulltext fields."
msgstr ""
"Lauki priekš pilnās teksta meklēšanas. Piezīme: 'web', 'phrase' un 'raw' "
"meklēšanas metodes darbojās tikai ar pilno teksta meklēšanu."
#: .\cookbook\forms.py:350
#, fuzzy
@@ -198,11 +215,11 @@ msgstr "Meklēt"
#: .\cookbook\forms.py:350
msgid "Fuzzy Lookups"
msgstr ""
msgstr "Aptuvenā meklēšana"
#: .\cookbook\forms.py:350
msgid "Ignore Accent"
msgstr ""
msgstr "Ignorēt akcentus"
#: .\cookbook\forms.py:350
msgid "Partial Match"
@@ -745,8 +762,8 @@ msgid ""
" ."
msgstr ""
"Lūdzu apstipriniet, ka\n"
" <a href=\"mailto:%(email)s\">%(email)s</a> ir lietotāja "
"%(user_display) e-pasta adrese\n"
" <a href=\"mailto:%(email)s\">%(email)s</a> ir e-pasta adrese "
"lietotājam %(user_display)\n"
" ."
#: .\cookbook\templates\account\email_confirm.html:22
@@ -1331,7 +1348,7 @@ msgstr ""
#: .\cookbook\templates\markdown_info.html:57
#: .\cookbook\templates\markdown_info.html:73
msgid "or by leaving a blank line in between."
msgstr "vai atstājot tukšu rindu starp ."
msgstr "vai atstājot tukšu rindu starp."
#: .\cookbook\templates\markdown_info.html:59
#: .\cookbook\templates\markdown_info.html:74
@@ -1443,7 +1460,7 @@ msgstr "Nav Tiesību"
#: .\cookbook\templates\no_groups_info.html:17
msgid "You do not have any groups and therefor cannot use this application."
msgstr "Jūs neesat nevienā grupā un tādēļ nevarat izmantot šo lietotni!"
msgstr "Jūs neesat nevienā grupā un tādēļ nevarat izmantot šo lietotni."
#: .\cookbook\templates\no_groups_info.html:18
#: .\cookbook\templates\no_perm_info.html:15
@@ -1460,7 +1477,7 @@ msgid ""
"You do not have the required permissions to view this page or perform this "
"action."
msgstr ""
"Jums nav nepieciešamo atļauju, lai skatītu šo vietni vai veiktu šo darbību!"
"Jums nav nepieciešamo atļauju, lai skatītu šo vietni vai veiktu šo darbību."
#: .\cookbook\templates\offline.html:6
msgid "Offline"
@@ -1548,6 +1565,16 @@ msgid ""
"html#TEXTSEARCH-PARSING-QUERIES>Postgresql's website.</a>\n"
" "
msgstr ""
" \n"
" Pilnā teksta meklēšanas mēģinājums vienkāršot dotos vārdus, lai "
"tie sakristu ar tipiskajiem variantiem. Piemēram: 'griezt', 'griezšana', "
"'griezums' tiks vienkāršots uz 'griez'.\n"
" Lai kontrolētu meklētāja darbību ievadot vairākus meklējamos "
"vārdus, ir pieejamas vairākas zemāk aprakstītās metodes.\n"
" Pilno tehnisko informāciju par tām var apskatīt <a "
"href=https://www.postgresql.org/docs/current/textsearch-controls.html"
"#TEXTSEARCH-PARSING-QUERIES>Postgresql mājas lapā.</a>\n"
" "
#: .\cookbook\templates\search_info.html:29
msgid ""
@@ -2547,22 +2574,12 @@ msgid "Unable to determine PostgreSQL version."
msgstr ""
#: .\cookbook\views\views.py:317
#, fuzzy
#| msgid ""
#| "\n"
#| " This application is not running with a Postgres database "
#| "backend. This is ok but not recommended as some\n"
#| " features only work with postgres databases.\n"
#| " "
msgid ""
"This application is not running with a Postgres database backend. This is ok "
"but not recommended as some features only work with postgres databases."
msgstr ""
"\n"
" Šī lietojumprogramma nedarbojas, izmantojot Postgres datubāzi. "
"Tas ir labi, bet nav ieteicams, jo dažas\n"
" funkcijas darbojas tikai ar Postgres datu bāzēm.\n"
" "
"Šī lietojumprogramma nedarbojas, izmantojot Postgres datubāzi. Tas ir labi, "
"bet nav ieteicams, jo dažas funkcijas darbojas tikai ar Postgres datu bāzēm."
#: .\cookbook\views\views.py:360
#, fuzzy

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
"PO-Revision-Date: 2024-11-12 17:58+0000\n"
"Last-Translator: Владислав <vlad@kelonmyosa.ru>\n"
"PO-Revision-Date: 2025-07-28 17:58+0000\n"
"Last-Translator: Aleksey <streltsov3@gmail.com>\n"
"Language-Team: Russian <http://translate.tandoor.dev/projects/tandoor/"
"recipes-backend/ru/>\n"
"Language: ru\n"
@@ -18,7 +18,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
"X-Generator: Weblate 5.6.2\n"
"X-Generator: Weblate 5.8.4\n"
#: .\cookbook\forms.py:45
msgid ""
@@ -90,22 +90,24 @@ msgid ""
"<a href=\"https://www.home-assistant.io/docs/authentication/#your-account-"
"profile\">Long Lived Access Token</a> for your HomeAssistant instance"
msgstr ""
"<a href=\"https://www.home-assistant.io/docs/authentication/#your-account-"
"profile\">Длительный токен доступа</a> для вашей установки HomeAssistant"
#: .\cookbook\forms.py:193
msgid "Something like http://homeassistant.local:8123/api"
msgstr ""
msgstr "Что-то вроде http://homeassistant.local:8123/api"
#: .\cookbook\forms.py:205
msgid "http://homeassistant.local:8123/api for example"
msgstr ""
msgstr "Например, http://homeassistant.local:8123/api"
#: .\cookbook\forms.py:222 .\cookbook\views\edit.py:117
msgid "Storage"
msgstr ""
msgstr "Хранилище"
#: .\cookbook\forms.py:222
msgid "Active"
msgstr ""
msgstr "Активный"
#: .\cookbook\forms.py:226
msgid "Search String"
@@ -125,16 +127,12 @@ msgid "Email address already taken!"
msgstr "Этот email уже используется!"
#: .\cookbook\forms.py:275
#, fuzzy
#| msgid ""
#| "An email address is not required but if present the invite link will be "
#| "send to the user."
msgid ""
"An email address is not required but if present the invite link will be sent "
"to the user."
msgstr ""
"email не требуется, но если он присутствует, пользователю будет отправлена "
"ссылка для приглашения."
"Адрес электронной почты не обязателен, но если он указан, ссылка-приглашение "
"будет отправлена пользователю."
#: .\cookbook\forms.py:287
msgid "Name already taken."
@@ -149,6 +147,9 @@ msgid ""
"Determines how fuzzy a search is if it uses trigram similarity matching (e."
"g. low values mean more typos are ignored)."
msgstr ""
"Определяет степень нечёткости поиска при использовании сопоставления по "
"триграммам (например, низкие значения означают, что больше опечаток "
"игнорируется)."
#: .\cookbook\forms.py:340
#, fuzzy
@@ -183,24 +184,33 @@ msgid ""
"Fields to search for partial matches. (e.g. searching for 'Pie' will return "
"'pie' and 'piece' and 'soapie')"
msgstr ""
"Поля для поиска по частичному совпадению. (например, поиск по слову «Pie» "
"вернёт «pie», «piece» и «soapie»)"
#: .\cookbook\forms.py:344
msgid ""
"Fields to search for beginning of word matches. (e.g. searching for 'sa' "
"will return 'salad' and 'sandwich')"
msgstr ""
"Поля для поиска по совпадению начала слова. (например, поиск по «sa» вернёт "
"«salad» и «sandwich»)"
#: .\cookbook\forms.py:345
msgid ""
"Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) "
"Note: this option will conflict with 'web' and 'raw' methods of search."
msgstr ""
"Поля для нечёткого поиска. (например, поиск по слову «recpie» найдёт "
"«recipe»). Примечание: эта опция конфликтует с методами поиска «web» и "
"«raw»."
#: .\cookbook\forms.py:346
msgid ""
"Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods "
"only function with fulltext fields."
msgstr ""
"Поля для полнотекстового поиска. Примечание: методы поиска «web», «phrase» "
"и «raw» работают только с полнотекстовыми полями."
#: .\cookbook\forms.py:350
msgid "Search Method"
@@ -208,38 +218,40 @@ msgstr "Способ поиска"
#: .\cookbook\forms.py:350
msgid "Fuzzy Lookups"
msgstr ""
msgstr "Нечёткое сопоставление"
#: .\cookbook\forms.py:350
msgid "Ignore Accent"
msgstr ""
msgstr "Игнорировать акценты"
#: .\cookbook\forms.py:350
msgid "Partial Match"
msgstr ""
msgstr "Частичное совпадение"
#: .\cookbook\forms.py:350
msgid "Starts With"
msgstr ""
msgstr "Начинается с"
#: .\cookbook\forms.py:351
msgid "Fuzzy Search"
msgstr ""
msgstr "Неточный поиск"
#: .\cookbook\forms.py:351
msgid "Full Text"
msgstr ""
msgstr "Полный текст"
#: .\cookbook\helper\AllAuthCustomAdapter.py:41
msgid ""
"In order to prevent spam, the requested email was not send. Please wait a "
"few minutes and try again."
msgstr ""
"Во избежание спама запрошенное электронное письмо не было отправлено. "
"Подождите несколько минут и попробуйте снова."
#: .\cookbook\helper\permission_helper.py:164
#: .\cookbook\helper\permission_helper.py:187 .\cookbook\views\views.py:117
msgid "You are not logged in and therefore cannot view this page!"
msgstr ""
msgstr "Вы не вошли в систему и поэтому не можете просматривать эту страницу!"
#: .\cookbook\helper\permission_helper.py:168
#: .\cookbook\helper\permission_helper.py:174
@@ -252,7 +264,7 @@ msgstr ""
#: .\cookbook\helper\permission_helper.py:341 .\cookbook\views\data.py:35
#: .\cookbook\views\views.py:127 .\cookbook\views\views.py:131
msgid "You do not have the required permissions to view this page!"
msgstr ""
msgstr "У вас нет необходимых разрешений для просмотра этой страницы!"
#: .\cookbook\helper\permission_helper.py:192
#: .\cookbook\helper\permission_helper.py:215
@@ -260,102 +272,108 @@ msgstr ""
#: .\cookbook\helper\permission_helper.py:252
msgid "You cannot interact with this object as it is not owned by you!"
msgstr ""
"Вы не можете взаимодействовать с этим объектом, так как он не принадлежит "
"вам!"
#: .\cookbook\helper\permission_helper.py:402
msgid "You have reached the maximum number of recipes for your space."
msgstr ""
msgstr "Вы достигли максимального количества рецептов для вашего пространства."
#: .\cookbook\helper\permission_helper.py:414
msgid "You have more users than allowed in your space."
msgstr ""
msgstr "У вас больше пользователей, чем разрешено в вашем пространстве."
#: .\cookbook\helper\recipe_url_import.py:310
msgid "reverse rotation"
msgstr ""
msgstr "обратное вращение"
#: .\cookbook\helper\recipe_url_import.py:311
msgid "careful rotation"
msgstr ""
msgstr "осторожное вращение"
#: .\cookbook\helper\recipe_url_import.py:312
msgid "knead"
msgstr ""
msgstr "замесить"
#: .\cookbook\helper\recipe_url_import.py:313
msgid "thicken"
msgstr ""
msgstr "загустить"
#: .\cookbook\helper\recipe_url_import.py:314
msgid "warm up"
msgstr ""
msgstr "разогреть"
#: .\cookbook\helper\recipe_url_import.py:315
msgid "ferment"
msgstr ""
msgstr "ферментировать"
#: .\cookbook\helper\recipe_url_import.py:316
msgid "sous-vide"
msgstr ""
msgstr "су-вид"
#: .\cookbook\helper\shopping_helper.py:150
msgid "You must supply a servings size"
msgstr ""
msgstr "Вы должны указать размер порции"
#: .\cookbook\helper\template_helper.py:95
#: .\cookbook\helper\template_helper.py:97
msgid "Could not parse template code."
msgstr ""
msgstr "Не удалось разобрать код шаблона."
#: .\cookbook\integration\copymethat.py:44
#: .\cookbook\integration\melarecipes.py:37
msgid "Favorite"
msgstr ""
msgstr "Избранное"
#: .\cookbook\integration\copymethat.py:50
msgid "I made this"
msgstr ""
msgstr "Я это сделал"
#: .\cookbook\integration\integration.py:209
msgid ""
"Importer expected a .zip file. Did you choose the correct importer type for "
"your data ?"
msgstr ""
"Импортер требует файл .zip. Вы выбрали правильный тип импортера для ваших "
"данных?"
#: .\cookbook\integration\integration.py:212
msgid ""
"An unexpected error occurred during the import. Please make sure you have "
"uploaded a valid file."
msgstr ""
"Во время импорта произошла непредвиденная ошибка. Пожалуйста, убедитесь, что "
"вы загрузили корректный файл."
#: .\cookbook\integration\integration.py:217
msgid "The following recipes were ignored because they already existed:"
msgstr ""
msgstr "Следующие рецепты были проигнорированы, так как уже существуют:"
#: .\cookbook\integration\integration.py:221
#, python-format
msgid "Imported %s recipes."
msgstr ""
msgstr "Импортировано %s рецептов."
#: .\cookbook\integration\openeats.py:28
msgid "Recipe source:"
msgstr ""
msgstr "Источник рецепта:"
#: .\cookbook\integration\paprika.py:49
msgid "Notes"
msgstr ""
msgstr "Заметки"
#: .\cookbook\integration\paprika.py:52
msgid "Nutritional Information"
msgstr ""
msgstr "Пищевая ценность"
#: .\cookbook\integration\paprika.py:56
msgid "Source"
msgstr ""
msgstr "Источник"
#: .\cookbook\integration\recettetek.py:54
#: .\cookbook\integration\recipekeeper.py:70
msgid "Imported from"
msgstr ""
msgstr "Импортировано из"
#: .\cookbook\integration\saffron.py:23
#, fuzzy
@@ -364,65 +382,67 @@ msgstr "Порции"
#: .\cookbook\integration\saffron.py:25
msgid "Waiting time"
msgstr ""
msgstr "Время ожидания"
#: .\cookbook\integration\saffron.py:27
msgid "Preparation Time"
msgstr ""
msgstr "Время подготовки"
#: .\cookbook\integration\saffron.py:29 .\cookbook\templates\index.html:7
msgid "Cookbook"
msgstr ""
msgstr "Книга рецептов"
#: .\cookbook\integration\saffron.py:31
msgid "Section"
msgstr ""
msgstr "Раздел"
#: .\cookbook\management\commands\fix_duplicate_properties.py:15
msgid "Fixes foods with "
msgstr ""
msgstr "Исправляет продукты с "
#: .\cookbook\management\commands\rebuildindex.py:14
msgid "Rebuilds full text search index on Recipe"
msgstr ""
msgstr "Перестраивает полнотекстовый индекс поиска для рецептов"
#: .\cookbook\management\commands\rebuildindex.py:18
msgid "Only Postgresql databases use full text search, no index to rebuild"
msgstr ""
"Полнотекстовый поиск используется только в базах данных Postgresql, индекс "
"перестраивать не нужно"
#: .\cookbook\management\commands\rebuildindex.py:29
msgid "Recipe index rebuild complete."
msgstr ""
msgstr "Перестройка индекса рецептов завершена."
#: .\cookbook\management\commands\rebuildindex.py:31
msgid "Recipe index rebuild failed."
msgstr ""
msgstr "Перестройка индекса рецептов не удалась."
#: .\cookbook\migrations\0047_auto_20200602_1133.py:14
msgid "Breakfast"
msgstr ""
msgstr "Завтрак"
#: .\cookbook\migrations\0047_auto_20200602_1133.py:19
msgid "Lunch"
msgstr ""
msgstr "Обед"
#: .\cookbook\migrations\0047_auto_20200602_1133.py:24
msgid "Dinner"
msgstr ""
msgstr "Ужин"
#: .\cookbook\migrations\0047_auto_20200602_1133.py:29 .\cookbook\models.py:919
msgid "Other"
msgstr ""
msgstr "Другое"
#: .\cookbook\migrations\0190_auto_20230525_1506.py:17
msgid "Fat"
msgstr ""
msgstr "Толстый"
#: .\cookbook\migrations\0190_auto_20230525_1506.py:17
#: .\cookbook\migrations\0190_auto_20230525_1506.py:18
#: .\cookbook\migrations\0190_auto_20230525_1506.py:19
msgid "g"
msgstr ""
msgstr "г"
#: .\cookbook\migrations\0190_auto_20230525_1506.py:18
msgid "Carbohydrates"

View File

@@ -41,15 +41,6 @@
<script src="{% static 'js/popper.min.js' %}"></script>
<script src="{% static 'js/bootstrap.min.js' %}"></script>
<!-- Select2 for use with django autocomplete light -->
<link href="{% static 'css/select2.min.css' %}" rel="stylesheet"/>
<script src="{% static 'js/select2.min.js' %}"></script>
<!-- Bootstrap theme for select2 -->
<link rel="stylesheet" href="{% static 'css/select2-bootstrap.css' %}"/>
<link rel="stylesheet" href="{% static 'themes/select2-bootstrap-theme.css' %}"/>
<!-- Fontawesome icons -->
<link rel="stylesheet" href="{% static "fontawesome/fontawesome_all.min.css" %}">

View File

@@ -37,8 +37,20 @@
<link id="id_custom_space_css" href="{{ theme_values.custom_theme }}" rel="stylesheet">
{% endif %}
<style>
{% if request.user.userpreference.theme == 'TANDOOR_DARK' %}
/* vueform/multiselect */
/* when append to body is true the multiselects dropdown does not recognize the .v-theme--dark condition and renders a white background otherwise */
.multiselect-dropdown, .multiselect-options, .multiselect-option {
background: #212121 !important;
}
{% endif %}
</style>
</head>
<body>
<div id="app"></div>
{% vite_hmr_client %}
@@ -48,16 +60,16 @@
localStorage.setItem('BASE_PATH', "{% base_path request 'base' %}")
{#window.addEventListener("load", () => {#}
{# if ("serviceWorker" in navigator) {#}
{# navigator.serviceWorker.register("{% url 'service_worker' %}", {scope: "{% base_path request 'base' %}" + '/'}).then(function (reg) {#}
{# }).catch(function (err) {#}
{# console.warn('Error whilst registering service worker', err);#}
{# });#}
{# } else {#}
{# console.warn('service worker not in navigator');#}
{# }#}
{#});#}
window.addEventListener("load", () => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("{% url 'service_worker' %}", {scope: "{% base_path request 'base' %}" + '/'}).then(function (reg) {
}).catch(function (err) {
console.warn('Error whilst registering service worker', err);
});
} else {
console.warn('service worker not in navigator');
}
});
</script>

File diff suppressed because one or more lines are too long

View File

@@ -20,7 +20,7 @@
<div class="container">
<h1 >{% trans 'System' %}</h1>
<h1>{% trans 'System' %}</h1>
{% blocktrans %}
Tandoor Recipes is an open source free software application. It can be found on
<a href="https://github.com/TandoorRecipes/recipes">GitHub</a>.
@@ -213,6 +213,14 @@
{% endfor %}
</table>
{% endif %}
<h4 class="mt-3">Cache Test</h4>
On first load this should be None, on second load it should be the time of the first load. Expiration is set to 10 seconds after that it should be None again. <br/>
{% if cache_response %}
<span class="badge text-bg-success">Cache entry from {{ cache_response|date:" d m Y H:i:s" }}</span>
{% else %}
<span class="badge text-bg-info">No cache entry before load</span>
{% endif %}
<h4 class="mt-3">Debug</h4>
<textarea class="form-control" rows="20">
Gunicorn Media: {{ gunicorn_media }}

View File

@@ -25,10 +25,6 @@ def get_theming_values(request):
space = Space.objects.filter(id=FORCE_THEME_FROM_SPACE).first()
themes = {
UserPreference.BOOTSTRAP: 'themes/bootstrap.min.css',
UserPreference.FLATLY: 'themes/flatly.min.css',
UserPreference.DARKLY: 'themes/darkly.min.css',
UserPreference.SUPERHERO: 'themes/superhero.min.css',
UserPreference.TANDOOR: 'themes/tandoor.min.css',
UserPreference.TANDOOR_DARK: 'themes/tandoor_dark.min.css',
}

View File

@@ -1 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><testsuites><testsuite name="pytest" errors="0" failures="0" skipped="0" tests="2" time="38.353" timestamp="2025-03-31T09:44:57.025358" hostname="vabene-pc"><testcase classname="cookbook.tests.other.test_recipe_full_text_search" name="test_search_count[found_recipe0-rating]" time="29.368" /><testcase classname="cookbook.tests.other.test_recipe_full_text_search" name="test_search_count[found_recipe1-timescooked]" time="29.371" /></testsuite></testsuites>

View File

@@ -1,770 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title id="head-title">tests.html</title>
<link href="assets\style.css" rel="stylesheet" type="text/css"/>
</head>
<body>
<h1 id="title">tests.html</h1>
<p>Report generated on 31-Mar-2025 at 09:45:35 by <a href="https://pypi.python.org/pypi/pytest-html">pytest-html</a>
v4.1.1</p>
<div id="environment-header">
<h2>Environment</h2>
</div>
<table id="environment"></table>
<!-- TEMPLATES -->
<template id="template_environment_row">
<tr>
<td></td>
<td></td>
</tr>
</template>
<template id="template_results-table__body--empty">
<tbody class="results-table-row">
<tr id="not-found-message">
<td colspan="4">No results found. Check the filters.</th>
</tr>
</template>
<template id="template_results-table__tbody">
<tbody class="results-table-row">
<tr class="collapsible">
</tr>
<tr class="extras-row">
<td class="extra" colspan="4">
<div class="extraHTML"></div>
<div class="media">
<div class="media-container">
<div class="media-container__nav--left"><</div>
<div class="media-container__viewport">
<img src="" />
<video controls>
<source src="" type="video/mp4">
</video>
</div>
<div class="media-container__nav--right">></div>
</div>
<div class="media__name"></div>
<div class="media__counter"></div>
</div>
<div class="logwrapper">
<div class="logexpander"></div>
<div class="log"></div>
</div>
</td>
</tr>
</tbody>
</template>
<!-- END TEMPLATES -->
<div class="summary">
<div class="summary__data">
<h2>Summary</h2>
<div class="additional-summary prefix">
</div>
<p class="run-count">2 tests took 00:00:59.</p>
<p class="filter">(Un)check the boxes to filter the results.</p>
<div class="summary__reload">
<div class="summary__reload__button hidden" onclick="location.reload()">
<div>There are still tests running. <br />Reload this page to get the latest results!</div>
</div>
</div>
<div class="summary__spacer"></div>
<div class="controls">
<div class="filters">
<input checked="true" class="filter" name="filter_checkbox" type="checkbox" data-test-result="failed" disabled/>
<span class="failed">0 Failed,</span>
<input checked="true" class="filter" name="filter_checkbox" type="checkbox" data-test-result="passed" />
<span class="passed">2 Passed,</span>
<input checked="true" class="filter" name="filter_checkbox" type="checkbox" data-test-result="skipped" disabled/>
<span class="skipped">0 Skipped,</span>
<input checked="true" class="filter" name="filter_checkbox" type="checkbox" data-test-result="xfailed" disabled/>
<span class="xfailed">0 Expected failures,</span>
<input checked="true" class="filter" name="filter_checkbox" type="checkbox" data-test-result="xpassed" disabled/>
<span class="xpassed">0 Unexpected passes,</span>
<input checked="true" class="filter" name="filter_checkbox" type="checkbox" data-test-result="error" disabled/>
<span class="error">0 Errors,</span>
<input checked="true" class="filter" name="filter_checkbox" type="checkbox" data-test-result="rerun" disabled/>
<span class="rerun">0 Reruns</span>
</div>
<div class="collapse">
<button id="show_all_details">Show all details</button>&nbsp;/&nbsp;<button id="hide_all_details">Hide all details</button>
</div>
</div>
</div>
<div class="additional-summary summary">
</div>
<div class="additional-summary postfix">
</div>
</div>
<table id="results-table">
<thead id="results-table-head">
<tr>
<th class="sortable" data-column-type="result">Result</th>
<th class="sortable" data-column-type="testId">Test</th>
<th class="sortable" data-column-type="duration">Duration</th>
<th>Links</th>
</tr>
</thead>
</table>
</body>
<footer>
<div id="data-container" data-jsonblob="{&#34;environment&#34;: {&#34;Python&#34;: &#34;3.12.9&#34;, &#34;Platform&#34;: &#34;Windows-11-10.0.26100-SP0&#34;, &#34;Packages&#34;: {&#34;pytest&#34;: &#34;8.0.0&#34;, &#34;pluggy&#34;: &#34;1.4.0&#34;}, &#34;Plugins&#34;: {&#34;anyio&#34;: &#34;4.8.0&#34;, &#34;Faker&#34;: &#34;23.2.1&#34;, &#34;asyncio&#34;: &#34;0.23.5&#34;, &#34;cov&#34;: &#34;5.0.0&#34;, &#34;django&#34;: &#34;4.9.0&#34;, &#34;factoryboy&#34;: &#34;2.7.0&#34;, &#34;html&#34;: &#34;4.1.1&#34;, &#34;metadata&#34;: &#34;3.1.1&#34;, &#34;xdist&#34;: &#34;3.6.1&#34;}}, &#34;tests&#34;: {&#34;cookbook/tests/other/test_recipe_full_text_search.py::test_search_count[found_recipe0-rating]&#34;: [{&#34;extras&#34;: [], &#34;result&#34;: &#34;Passed&#34;, &#34;testId&#34;: &#34;cookbook/tests/other/test_recipe_full_text_search.py::test_search_count[found_recipe0-rating]&#34;, &#34;duration&#34;: &#34;00:00:29&#34;, &#34;resultsTableRow&#34;: [&#34;&lt;td class=\&#34;col-result\&#34;&gt;Passed&lt;/td&gt;&#34;, &#34;&lt;td class=\&#34;col-testId\&#34;&gt;cookbook/tests/other/test_recipe_full_text_search.py::test_search_count[found_recipe0-rating]&lt;/td&gt;&#34;, &#34;&lt;td class=\&#34;col-duration\&#34;&gt;00:00:29&lt;/td&gt;&#34;, &#34;&lt;td class=\&#34;col-links\&#34;&gt;&lt;/td&gt;&#34;], &#34;log&#34;: &#34;[gw1] win32 -- Python 3.12.9 C:\\Users\\vaben\\Documents\\Development\\Django\\recipes\\venv\\Scripts\\python.exe\n\n---------------------------- Captured stdout setup -----------------------------\nTransforming nutrition information, this might take a while on large databases\nmigrating shopping list recipe space attribute, this might take a while ...\n&#34;}], &#34;cookbook/tests/other/test_recipe_full_text_search.py::test_search_count[found_recipe1-timescooked]&#34;: [{&#34;extras&#34;: [], &#34;result&#34;: &#34;Passed&#34;, &#34;testId&#34;: &#34;cookbook/tests/other/test_recipe_full_text_search.py::test_search_count[found_recipe1-timescooked]&#34;, &#34;duration&#34;: &#34;00:00:29&#34;, &#34;resultsTableRow&#34;: [&#34;&lt;td class=\&#34;col-result\&#34;&gt;Passed&lt;/td&gt;&#34;, &#34;&lt;td class=\&#34;col-testId\&#34;&gt;cookbook/tests/other/test_recipe_full_text_search.py::test_search_count[found_recipe1-timescooked]&lt;/td&gt;&#34;, &#34;&lt;td class=\&#34;col-duration\&#34;&gt;00:00:29&lt;/td&gt;&#34;, &#34;&lt;td class=\&#34;col-links\&#34;&gt;&lt;/td&gt;&#34;], &#34;log&#34;: &#34;[gw0] win32 -- Python 3.12.9 C:\\Users\\vaben\\Documents\\Development\\Django\\recipes\\venv\\Scripts\\python.exe\n\n---------------------------- Captured stdout setup -----------------------------\nTransforming nutrition information, this might take a while on large databases\nmigrating shopping list recipe space attribute, this might take a while ...\n&#34;}]}, &#34;renderCollapsed&#34;: [&#34;passed&#34;], &#34;initialSort&#34;: &#34;result&#34;, &#34;title&#34;: &#34;tests.html&#34;}"></div>
<script>
(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
const { getCollapsedCategory, setCollapsedIds } = require('./storage.js')
class DataManager {
setManager(data) {
const collapsedCategories = [...getCollapsedCategory(data.renderCollapsed)]
const collapsedIds = []
const tests = Object.values(data.tests).flat().map((test, index) => {
const collapsed = collapsedCategories.includes(test.result.toLowerCase())
const id = `test_${index}`
if (collapsed) {
collapsedIds.push(id)
}
return {
...test,
id,
collapsed,
}
})
const dataBlob = { ...data, tests }
this.data = { ...dataBlob }
this.renderData = { ...dataBlob }
setCollapsedIds(collapsedIds)
}
get allData() {
return { ...this.data }
}
resetRender() {
this.renderData = { ...this.data }
}
setRender(data) {
this.renderData.tests = [...data]
}
toggleCollapsedItem(id) {
this.renderData.tests = this.renderData.tests.map((test) =>
test.id === id ? { ...test, collapsed: !test.collapsed } : test,
)
}
set allCollapsed(collapsed) {
this.renderData = { ...this.renderData, tests: [...this.renderData.tests.map((test) => (
{ ...test, collapsed }
))] }
}
get testSubset() {
return [...this.renderData.tests]
}
get environment() {
return this.renderData.environment
}
get initialSort() {
return this.data.initialSort
}
}
module.exports = {
manager: new DataManager(),
}
},{"./storage.js":8}],2:[function(require,module,exports){
const mediaViewer = require('./mediaviewer.js')
const templateEnvRow = document.getElementById('template_environment_row')
const templateResult = document.getElementById('template_results-table__tbody')
function htmlToElements(html) {
const temp = document.createElement('template')
temp.innerHTML = html
return temp.content.childNodes
}
const find = (selector, elem) => {
if (!elem) {
elem = document
}
return elem.querySelector(selector)
}
const findAll = (selector, elem) => {
if (!elem) {
elem = document
}
return [...elem.querySelectorAll(selector)]
}
const dom = {
getStaticRow: (key, value) => {
const envRow = templateEnvRow.content.cloneNode(true)
const isObj = typeof value === 'object' && value !== null
const values = isObj ? Object.keys(value).map((k) => `${k}: ${value[k]}`) : null
const valuesElement = htmlToElements(
values ? `<ul>${values.map((val) => `<li>${val}</li>`).join('')}<ul>` : `<div>${value}</div>`)[0]
const td = findAll('td', envRow)
td[0].textContent = key
td[1].appendChild(valuesElement)
return envRow
},
getResultTBody: ({ testId, id, log, extras, resultsTableRow, tableHtml, result, collapsed }) => {
const resultBody = templateResult.content.cloneNode(true)
resultBody.querySelector('tbody').classList.add(result.toLowerCase())
resultBody.querySelector('tbody').id = testId
resultBody.querySelector('.collapsible').dataset.id = id
resultsTableRow.forEach((html) => {
const t = document.createElement('template')
t.innerHTML = html
resultBody.querySelector('.collapsible').appendChild(t.content)
})
if (log) {
// Wrap lines starting with "E" with span.error to color those lines red
const wrappedLog = log.replace(/^E.*$/gm, (match) => `<span class="error">${match}</span>`)
resultBody.querySelector('.log').innerHTML = wrappedLog
} else {
resultBody.querySelector('.log').remove()
}
if (collapsed) {
resultBody.querySelector('.collapsible > td')?.classList.add('collapsed')
resultBody.querySelector('.extras-row').classList.add('hidden')
} else {
resultBody.querySelector('.collapsible > td')?.classList.remove('collapsed')
}
const media = []
extras?.forEach(({ name, format_type, content }) => {
if (['image', 'video'].includes(format_type)) {
media.push({ path: content, name, format_type })
}
if (format_type === 'html') {
resultBody.querySelector('.extraHTML').insertAdjacentHTML('beforeend', `<div>${content}</div>`)
}
})
mediaViewer.setup(resultBody, media)
// Add custom html from the pytest_html_results_table_html hook
tableHtml?.forEach((item) => {
resultBody.querySelector('td[class="extra"]').insertAdjacentHTML('beforeend', item)
})
return resultBody
},
}
module.exports = {
dom,
htmlToElements,
find,
findAll,
}
},{"./mediaviewer.js":6}],3:[function(require,module,exports){
const { manager } = require('./datamanager.js')
const { doSort } = require('./sort.js')
const storageModule = require('./storage.js')
const getFilteredSubSet = (filter) =>
manager.allData.tests.filter(({ result }) => filter.includes(result.toLowerCase()))
const doInitFilter = () => {
const currentFilter = storageModule.getVisible()
const filteredSubset = getFilteredSubSet(currentFilter)
manager.setRender(filteredSubset)
}
const doFilter = (type, show) => {
if (show) {
storageModule.showCategory(type)
} else {
storageModule.hideCategory(type)
}
const currentFilter = storageModule.getVisible()
const filteredSubset = getFilteredSubSet(currentFilter)
manager.setRender(filteredSubset)
const sortColumn = storageModule.getSort()
doSort(sortColumn, true)
}
module.exports = {
doFilter,
doInitFilter,
}
},{"./datamanager.js":1,"./sort.js":7,"./storage.js":8}],4:[function(require,module,exports){
const { redraw, bindEvents, renderStatic } = require('./main.js')
const { doInitFilter } = require('./filter.js')
const { doInitSort } = require('./sort.js')
const { manager } = require('./datamanager.js')
const data = JSON.parse(document.getElementById('data-container').dataset.jsonblob)
function init() {
manager.setManager(data)
doInitFilter()
doInitSort()
renderStatic()
redraw()
bindEvents()
}
init()
},{"./datamanager.js":1,"./filter.js":3,"./main.js":5,"./sort.js":7}],5:[function(require,module,exports){
const { dom, find, findAll } = require('./dom.js')
const { manager } = require('./datamanager.js')
const { doSort } = require('./sort.js')
const { doFilter } = require('./filter.js')
const {
getVisible,
getCollapsedIds,
setCollapsedIds,
getSort,
getSortDirection,
possibleFilters,
} = require('./storage.js')
const removeChildren = (node) => {
while (node.firstChild) {
node.removeChild(node.firstChild)
}
}
const renderStatic = () => {
const renderEnvironmentTable = () => {
const environment = manager.environment
const rows = Object.keys(environment).map((key) => dom.getStaticRow(key, environment[key]))
const table = document.getElementById('environment')
removeChildren(table)
rows.forEach((row) => table.appendChild(row))
}
renderEnvironmentTable()
}
const addItemToggleListener = (elem) => {
elem.addEventListener('click', ({ target }) => {
const id = target.parentElement.dataset.id
manager.toggleCollapsedItem(id)
const collapsedIds = getCollapsedIds()
if (collapsedIds.includes(id)) {
const updated = collapsedIds.filter((item) => item !== id)
setCollapsedIds(updated)
} else {
collapsedIds.push(id)
setCollapsedIds(collapsedIds)
}
redraw()
})
}
const renderContent = (tests) => {
const sortAttr = getSort(manager.initialSort)
const sortAsc = JSON.parse(getSortDirection())
const rows = tests.map(dom.getResultTBody)
const table = document.getElementById('results-table')
const tableHeader = document.getElementById('results-table-head')
const newTable = document.createElement('table')
newTable.id = 'results-table'
// remove all sorting classes and set the relevant
findAll('.sortable', tableHeader).forEach((elem) => elem.classList.remove('asc', 'desc'))
tableHeader.querySelector(`.sortable[data-column-type="${sortAttr}"]`)?.classList.add(sortAsc ? 'desc' : 'asc')
newTable.appendChild(tableHeader)
if (!rows.length) {
const emptyTable = document.getElementById('template_results-table__body--empty').content.cloneNode(true)
newTable.appendChild(emptyTable)
} else {
rows.forEach((row) => {
if (!!row) {
findAll('.collapsible td:not(.col-links', row).forEach(addItemToggleListener)
find('.logexpander', row).addEventListener('click',
(evt) => evt.target.parentNode.classList.toggle('expanded'),
)
newTable.appendChild(row)
}
})
}
table.replaceWith(newTable)
}
const renderDerived = () => {
const currentFilter = getVisible()
possibleFilters.forEach((result) => {
const input = document.querySelector(`input[data-test-result="${result}"]`)
input.checked = currentFilter.includes(result)
})
}
const bindEvents = () => {
const filterColumn = (evt) => {
const { target: element } = evt
const { testResult } = element.dataset
doFilter(testResult, element.checked)
const collapsedIds = getCollapsedIds()
const updated = manager.renderData.tests.map((test) => {
return {
...test,
collapsed: collapsedIds.includes(test.id),
}
})
manager.setRender(updated)
redraw()
}
const header = document.getElementById('environment-header')
header.addEventListener('click', () => {
const table = document.getElementById('environment')
table.classList.toggle('hidden')
header.classList.toggle('collapsed')
})
findAll('input[name="filter_checkbox"]').forEach((elem) => {
elem.addEventListener('click', filterColumn)
})
findAll('.sortable').forEach((elem) => {
elem.addEventListener('click', (evt) => {
const { target: element } = evt
const { columnType } = element.dataset
doSort(columnType)
redraw()
})
})
document.getElementById('show_all_details').addEventListener('click', () => {
manager.allCollapsed = false
setCollapsedIds([])
redraw()
})
document.getElementById('hide_all_details').addEventListener('click', () => {
manager.allCollapsed = true
const allIds = manager.renderData.tests.map((test) => test.id)
setCollapsedIds(allIds)
redraw()
})
}
const redraw = () => {
const { testSubset } = manager
renderContent(testSubset)
renderDerived()
}
module.exports = {
redraw,
bindEvents,
renderStatic,
}
},{"./datamanager.js":1,"./dom.js":2,"./filter.js":3,"./sort.js":7,"./storage.js":8}],6:[function(require,module,exports){
class MediaViewer {
constructor(assets) {
this.assets = assets
this.index = 0
}
nextActive() {
this.index = this.index === this.assets.length - 1 ? 0 : this.index + 1
return [this.activeFile, this.index]
}
prevActive() {
this.index = this.index === 0 ? this.assets.length - 1 : this.index -1
return [this.activeFile, this.index]
}
get currentIndex() {
return this.index
}
get activeFile() {
return this.assets[this.index]
}
}
const setup = (resultBody, assets) => {
if (!assets.length) {
resultBody.querySelector('.media').classList.add('hidden')
return
}
const mediaViewer = new MediaViewer(assets)
const container = resultBody.querySelector('.media-container')
const leftArrow = resultBody.querySelector('.media-container__nav--left')
const rightArrow = resultBody.querySelector('.media-container__nav--right')
const mediaName = resultBody.querySelector('.media__name')
const counter = resultBody.querySelector('.media__counter')
const imageEl = resultBody.querySelector('img')
const sourceEl = resultBody.querySelector('source')
const videoEl = resultBody.querySelector('video')
const setImg = (media, index) => {
if (media?.format_type === 'image') {
imageEl.src = media.path
imageEl.classList.remove('hidden')
videoEl.classList.add('hidden')
} else if (media?.format_type === 'video') {
sourceEl.src = media.path
videoEl.classList.remove('hidden')
imageEl.classList.add('hidden')
}
mediaName.innerText = media?.name
counter.innerText = `${index + 1} / ${assets.length}`
}
setImg(mediaViewer.activeFile, mediaViewer.currentIndex)
const moveLeft = () => {
const [media, index] = mediaViewer.prevActive()
setImg(media, index)
}
const doRight = () => {
const [media, index] = mediaViewer.nextActive()
setImg(media, index)
}
const openImg = () => {
window.open(mediaViewer.activeFile.path, '_blank')
}
if (assets.length === 1) {
container.classList.add('media-container--fullscreen')
} else {
leftArrow.addEventListener('click', moveLeft)
rightArrow.addEventListener('click', doRight)
}
imageEl.addEventListener('click', openImg)
}
module.exports = {
setup,
}
},{}],7:[function(require,module,exports){
const { manager } = require('./datamanager.js')
const storageModule = require('./storage.js')
const genericSort = (list, key, ascending, customOrder) => {
let sorted
if (customOrder) {
sorted = list.sort((a, b) => {
const aValue = a.result.toLowerCase()
const bValue = b.result.toLowerCase()
const aIndex = customOrder.findIndex((item) => item.toLowerCase() === aValue)
const bIndex = customOrder.findIndex((item) => item.toLowerCase() === bValue)
// Compare the indices to determine the sort order
return aIndex - bIndex
})
} else {
sorted = list.sort((a, b) => a[key] === b[key] ? 0 : a[key] > b[key] ? 1 : -1)
}
if (ascending) {
sorted.reverse()
}
return sorted
}
const durationSort = (list, ascending) => {
const parseDuration = (duration) => {
if (duration.includes(':')) {
// If it's in the format "HH:mm:ss"
const [hours, minutes, seconds] = duration.split(':').map(Number)
return (hours * 3600 + minutes * 60 + seconds) * 1000
} else {
// If it's in the format "nnn ms"
return parseInt(duration)
}
}
const sorted = list.sort((a, b) => parseDuration(a['duration']) - parseDuration(b['duration']))
if (ascending) {
sorted.reverse()
}
return sorted
}
const doInitSort = () => {
const type = storageModule.getSort(manager.initialSort)
const ascending = storageModule.getSortDirection()
const list = manager.testSubset
const initialOrder = ['Error', 'Failed', 'Rerun', 'XFailed', 'XPassed', 'Skipped', 'Passed']
storageModule.setSort(type)
storageModule.setSortDirection(ascending)
if (type?.toLowerCase() === 'original') {
manager.setRender(list)
} else {
let sortedList
switch (type) {
case 'duration':
sortedList = durationSort(list, ascending)
break
case 'result':
sortedList = genericSort(list, type, ascending, initialOrder)
break
default:
sortedList = genericSort(list, type, ascending)
break
}
manager.setRender(sortedList)
}
}
const doSort = (type, skipDirection) => {
const newSortType = storageModule.getSort(manager.initialSort) !== type
const currentAsc = storageModule.getSortDirection()
let ascending
if (skipDirection) {
ascending = currentAsc
} else {
ascending = newSortType ? false : !currentAsc
}
storageModule.setSort(type)
storageModule.setSortDirection(ascending)
const list = manager.testSubset
const sortedList = type === 'duration' ? durationSort(list, ascending) : genericSort(list, type, ascending)
manager.setRender(sortedList)
}
module.exports = {
doInitSort,
doSort,
}
},{"./datamanager.js":1,"./storage.js":8}],8:[function(require,module,exports){
const possibleFilters = [
'passed',
'skipped',
'failed',
'error',
'xfailed',
'xpassed',
'rerun',
]
const getVisible = () => {
const url = new URL(window.location.href)
const settings = new URLSearchParams(url.search).get('visible')
const lower = (item) => {
const lowerItem = item.toLowerCase()
if (possibleFilters.includes(lowerItem)) {
return lowerItem
}
return null
}
return settings === null ?
possibleFilters :
[...new Set(settings?.split(',').map(lower).filter((item) => item))]
}
const hideCategory = (categoryToHide) => {
const url = new URL(window.location.href)
const visibleParams = new URLSearchParams(url.search).get('visible')
const currentVisible = visibleParams ? visibleParams.split(',') : [...possibleFilters]
const settings = [...new Set(currentVisible)].filter((f) => f !== categoryToHide).join(',')
url.searchParams.set('visible', settings)
window.history.pushState({}, null, unescape(url.href))
}
const showCategory = (categoryToShow) => {
if (typeof window === 'undefined') {
return
}
const url = new URL(window.location.href)
const currentVisible = new URLSearchParams(url.search).get('visible')?.split(',').filter(Boolean) ||
[...possibleFilters]
const settings = [...new Set([categoryToShow, ...currentVisible])]
const noFilter = possibleFilters.length === settings.length || !settings.length
noFilter ? url.searchParams.delete('visible') : url.searchParams.set('visible', settings.join(','))
window.history.pushState({}, null, unescape(url.href))
}
const getSort = (initialSort) => {
const url = new URL(window.location.href)
let sort = new URLSearchParams(url.search).get('sort')
if (!sort) {
sort = initialSort || 'result'
}
return sort
}
const setSort = (type) => {
const url = new URL(window.location.href)
url.searchParams.set('sort', type)
window.history.pushState({}, null, unescape(url.href))
}
const getCollapsedCategory = (renderCollapsed) => {
let categories
if (typeof window !== 'undefined') {
const url = new URL(window.location.href)
const collapsedItems = new URLSearchParams(url.search).get('collapsed')
switch (true) {
case !renderCollapsed && collapsedItems === null:
categories = ['passed']
break
case collapsedItems?.length === 0 || /^["']{2}$/.test(collapsedItems):
categories = []
break
case /^all$/.test(collapsedItems) || collapsedItems === null && /^all$/.test(renderCollapsed):
categories = [...possibleFilters]
break
default:
categories = collapsedItems?.split(',').map((item) => item.toLowerCase()) || renderCollapsed
break
}
} else {
categories = []
}
return categories
}
const getSortDirection = () => JSON.parse(sessionStorage.getItem('sortAsc')) || false
const setSortDirection = (ascending) => sessionStorage.setItem('sortAsc', ascending)
const getCollapsedIds = () => JSON.parse(sessionStorage.getItem('collapsedIds')) || []
const setCollapsedIds = (list) => sessionStorage.setItem('collapsedIds', JSON.stringify(list))
module.exports = {
getVisible,
hideCategory,
showCategory,
getCollapsedIds,
setCollapsedIds,
getSort,
setSort,
getSortDirection,
setSortDirection,
getCollapsedCategory,
possibleFilters,
}
},{}]},{},[4]);
</script>
</footer>
</html>

View File

@@ -91,7 +91,8 @@ def test_never_unit_automation(u1_s1, arg):
with scope(space=space):
Automation.objects.get_or_create(name='never unit test', type=Automation.NEVER_UNIT, param_1='egg', param_2=arg[1], created_by=user, space=space)
assert automation.apply_never_unit_automation(arg[0]) == arg[2]
tokens, automation_applied = automation.apply_never_unit_automation(arg[0])
assert tokens == arg[2]
@pytest.mark.parametrize("source", [

View File

@@ -1,3 +1,4 @@
import pytest
from django.contrib import auth
from django.test import RequestFactory
from django_scopes import scope
@@ -5,7 +6,11 @@ from django_scopes import scope
from cookbook.helper.ingredient_parser import IngredientParser
def test_ingredient_parser(u1_s1):
@pytest.mark.parametrize("arg", [
[True],
[False],
])
def test_ingredient_parser(arg, u1_s1):
expectations = {
"2¼ l Wasser": (2.25, "l", "Wasser", ""),
"3¼l Wasser": (3.25, "l", "Wasser", ""),
@@ -67,7 +72,8 @@ def test_ingredient_parser(u1_s1):
"1 Porreestange(n) , ca. 200 g": (1.0, None, 'Porreestange(n)', 'ca. 200 g'), # leading space before comma
# test for over long food entries to get properly split into the note field
"1 Lorem ipsum dolor sit amet consetetur sadipscing elitr sed diam nonumy eirmod tempor invidunt ut l Lorem ipsum dolor sit amet consetetur sadipscing elitr sed diam nonumy eirmod tempor invidunt ut l": (
1.0, 'Lorem', 'ipsum', 'dolor sit amet consetetur sadipscing elitr sed diam nonumy eirmod tempor invidunt ut l Lorem ipsum dolor sit amet consetetur sadipscing elitr sed diam nonumy eirmod tempor invidunt ut l'),
1.0, 'Lorem', 'ipsum',
'dolor sit amet consetetur sadipscing elitr sed diam nonumy eirmod tempor invidunt ut l Lorem ipsum dolor sit amet consetetur sadipscing elitr sed diam nonumy eirmod tempor invidunt ut l'),
"1 LoremipsumdolorsitametconsetetursadipscingelitrseddiamnonumyeirmodtemporinviduntutlLoremipsumdolorsitametconsetetursadipscingelitrseddiamnonumyeirmodtemporinviduntutl": (
1.0, None, 'LoremipsumdolorsitametconsetetursadipscingelitrseddiamnonumyeirmodtemporinviduntutlLoremipsumdolorsitametconsetetursadipscingeli',
'LoremipsumdolorsitametconsetetursadipscingelitrseddiamnonumyeirmodtemporinviduntutlLoremipsumdolorsitametconsetetursadipscingelitrseddiamnonumyeirmodtemporinviduntutl'),
@@ -86,7 +92,7 @@ def test_ingredient_parser(u1_s1):
request = RequestFactory()
request.user = user
request.space = space
ingredient_parser = IngredientParser(request, False, ignore_automations=True)
ingredient_parser = IngredientParser(request, False, ignore_automations=arg[0])
count = 0
with scope(space=space):

View File

@@ -42,7 +42,7 @@ def test_theming_function(space_1, u1_s1):
assert get_theming_values(request)['sticky_nav'] == ''
assert get_theming_values(request)['app_name'] == 'Tandoor Recipes'
space_1.space_theme = Space.BOOTSTRAP
space_1.space_theme = Space.TANDOOR
space_1.nav_bg_color = '#000000'
space_1.nav_text_color = UserPreference.DARK
space_1.app_name = 'test_app_name'
@@ -53,7 +53,7 @@ def test_theming_function(space_1, u1_s1):
request.space = space_1
# space settings apply when set
assert get_theming_values(request)['theme'] == static('themes/bootstrap.min.css')
assert get_theming_values(request)['theme'] == static('themes/tandoor.min.css')
assert get_theming_values(request)['nav_bg_color'] == '#000000'
assert get_theming_values(request)['nav_text_class'] == 'navbar-light'
assert get_theming_values(request)['app_name'] == 'test_app_name'

View File

@@ -120,7 +120,7 @@ urlpatterns = [
path('api-token-auth/', CustomAuthToken.as_view()),
path('offline/', views.offline, name='view_offline'),
path('service-worker.js', (TemplateView.as_view(template_name="sw.js", content_type='application/javascript')), name='service_worker'),
path('service-worker.js', views.service_worker, name='service_worker'),
path('manifest.json', views.web_manifest, name='web_manifest'),
]

View File

@@ -83,6 +83,8 @@ def get_integration(request, export_type):
return Rezeptsuitede(request, export_type)
if export_type == ImportExportBase.GOURMET:
return Gourmet(request, export_type)
@group_required('user')
def export_file(request, pk):
el = get_object_or_404(ExportLog, pk=pk, space=request.space)
@@ -92,7 +94,7 @@ def export_file(request, pk):
if cacheData is None:
el.possibly_not_expired = False
el.save()
return render(request, 'export_response.html', {'pk': pk})
return JsonResponse({'msg': 'Export Expired or not found'}, status=404)
response = HttpResponse(cacheData['file'], content_type='application/force-download')
response['Content-Disposition'] = 'attachment; filename="' + cacheData['filename'] + '"'

View File

@@ -12,10 +12,11 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import Group
from django.contrib.auth.password_validation import validate_password
from django.core.cache import caches
from django.core.exceptions import ValidationError
from django.core.management import call_command
from django.db import models
from django.http import HttpResponseRedirect, JsonResponse
from django.http import HttpResponseRedirect, JsonResponse, HttpResponse
from django.shortcuts import get_object_or_404, render
from django.templatetags.static import static
from django.urls import reverse, reverse_lazy
@@ -30,7 +31,7 @@ from cookbook.helper.permission_helper import CustomIsGuest, GroupRequiredMixin,
from cookbook.models import InviteLink, ShareLink, Space, UserSpace
from cookbook.templatetags.theming_tags import get_theming_values
from cookbook.version_info import VERSION_INFO
from recipes.settings import PLUGINS
from recipes.settings import PLUGINS, BASE_DIR
def index(request, path=None, resource=None):
@@ -40,7 +41,7 @@ def index(request, path=None, resource=None):
if User.objects.count() < 1 and 'django.contrib.auth.backends.RemoteUserBackend' not in settings.AUTHENTICATION_BACKENDS:
return HttpResponseRedirect(reverse_lazy('view_setup'))
if request.user.is_authenticated or re.search(r'/recipe/\d+/', request.path[:512]) and request.GET.get('share') :
if request.user.is_authenticated or re.search(r'/recipe/\d+/', request.path[:512]) and request.GET.get('share'):
return render(request, 'frontend/tandoor.html', {})
else:
return HttpResponseRedirect(reverse('account_login') + '?next=' + request.path)
@@ -133,15 +134,16 @@ def no_perm(request):
return HttpResponseRedirect(reverse('account_login') + '?next=' + request.GET.get('next', '/search/'))
return render(request, 'no_perm_info.html')
def recipe_pdf_viewer(request, pk):
with scopes_disabled():
recipe = get_object_or_404(Recipe, pk=pk)
if share_link_valid(recipe, request.GET.get('share', None)) or (has_group_permission(
request.user, ['guest']) and recipe.space == request.space):
return render(request, 'pdf_viewer.html', {'recipe_id': pk, 'share': request.GET.get('share', None)})
return HttpResponseRedirect(reverse('index'))
def system(request):
if not request.user.is_superuser:
return HttpResponseRedirect(reverse('index'))
@@ -242,6 +244,10 @@ def system(request):
space_stats.append(r.zscore(f'api:space-request-count:{d}', s))
api_space_stats.append(space_stats)
cache_response = caches['default'].get(f'system_view_test_cache_entry', None)
if not cache_response:
caches['default'].set(f'system_view_test_cache_entry', datetime.now(), 10)
return render(
request, 'system.html', {
'gunicorn_media': settings.GUNICORN_MEDIA,
@@ -256,6 +262,7 @@ def system(request):
'orphans': orphans,
'migration_info': migration_info,
'missing_migration': missing_migration,
'cache_response': cache_response,
})
@@ -445,6 +452,13 @@ def offline(request):
return render(request, 'offline.html', {})
def service_worker(request):
with open(os.path.join(BASE_DIR, 'cookbook', 'static', 'vue3', 'service-worker.js'), 'rb') as service_worker_file:
response = HttpResponse(content=service_worker_file)
response['Content-Type'] = 'text/javascript'
return response
def test(request):
if not settings.DEBUG:
return HttpResponseRedirect(reverse('index'))
@@ -510,4 +524,3 @@ def get_orphan_files(delete_orphans=False):
orphans = find_orphans()
return [img[1] for img in orphans]

View File

@@ -31,7 +31,7 @@ Add a new language to the long list of existing translations.
- Spanish
- Swedish
See [here](/contribute/translations) for further information on how to contribute translation to Tandoor.
See [here](/docs/contribute/translations) for further information on how to contribute translation to Tandoor.
## Issues and Feature Requests
@@ -46,12 +46,12 @@ Helping improve the documentation for Tandoor is one of the easiest ways to give
You can write guides on how to install and configure Tandoor expanding our repository of non-standard configuations.
Or you can write how-to guides using some of Tandoor's advanced features such as authentication or automation.
See [here](/contribute/documentation) for more information on how to add documentation to Tandoor.
See [here](/docs/contribute/documentation) for more information on how to add documentation to Tandoor.
## Contributing Code
For the truly ambitious, you can help write code to fix issues, add additional features, or write your own scripts using
Tandoor's extensive API and share your work with the community.
Before writing any code, please make sure that you review [contribution guidelines](/contribute/guidelines) and
[VSCode](/contribute/vscode) or [PyCharm](/contribute/pycharm) specific configurations.
Before writing any code, please make sure that you review [contribution guidelines](/docs/contribute/guidelines) and
[VSCode](/docs/contribute/vscode) or [PyCharm](/docs/contribute/pycharm) specific configurations.

View File

@@ -67,5 +67,5 @@ Generate the schema using `openapi-generator-cli generate -g typescript-fetch -i
## Install and Configuration
Instructions for [VSCode](/contribute/vscode)
Instructions for [PyCharm](/contribute/pycharm)
Instructions for [VSCode](/docs/contribute/vscode)
Instructions for [PyCharm](/docs/contribute/pycharm)

View File

@@ -1,6 +1,9 @@
!!! info "Community Contributed"
This guide was contributed by the community and is neither officially supported, nor updated or tested.
!!! 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.
These are instructions for pacman based distributions, like ArchLinux. The package is available from the [AUR](https://aur.archlinux.org/packages/tandoor-recipes-git) or from [GitHub](https://github.com/jdecourval/tandoor-recipes-pkgbuild).
## Features

View File

@@ -1,28 +1,32 @@
!!! success "Recommended Installation"
Setting up this application using Docker is recommended. This does not mean that other options are bad, just that
support is much easier for this setup.
Setting up this application using Docker is recommended. This does not mean that other options are bad, but its the only method
that is officially maintained and gets regularly tested.
It is possible to install this application using many different Docker configurations.
This guide shows you some basic setups using Docker and docker compose. For configuration options see the [configuration page](https://docs.tandoor.dev/system/configuration/).
Please read the instructions on each example carefully and decide if this is the way for you.
## **Versions**
## **DockSTARTer**
There are different versions (tags) released on [Docker Hub](https://hub.docker.com/r/vabene1111/recipes/tags).
The main goal of [DockSTARTer](https://dockstarter.com/) is to make it quick and easy to get up and running with Docker.
You may choose to rely on DockSTARTer for various changes to your Docker system or use DockSTARTer as a stepping stone and learn to do more advanced configurations.
Follow the guide for installing DockSTARTer and then run `ds` then select 'Configuration' and 'Select Apps' to get Tandoor up and running quickly and easily.
- **latest** Default image. The one you should use if you don't know that you need anything else.
- **beta** Partially stable version that gets updated every now and then. Expect to have some problems.
- **develop** If you want the most bleeding-edge version with potentially many breaking changes, feel free to use this version (not recommended!).
- **X.Y.Z** each released version has its own image. If you need to revert to an old version or want to make sure you stay on one specific use these tags.
!!! danger "No Downgrading"
There is currently no way to migrate back to an older version as there is no mechanism to downgrade the database.
You could probably do it but I cannot help you with that. Choose wisely if you want to use the unstable images.
That said **beta** should usually be working if you like frequent updates and new stuff.
## **Docker**
The docker image (`vabene1111/recipes`) simply exposes the application on the container's port `8080`.
It can be run and accessed on port 80 using:
The docker image (`vabene1111/recipes`) simply exposes the application on the container's port `80` through the integrated nginx webserver.
```shell
docker run -d \
-v "$(pwd)"/staticfiles:/opt/recipes/staticfiles \
-v "$(pwd)"/mediafiles:/opt/recipes/mediafiles \
-p 80:8080 \
-p 80:80 \
-e SECRET_KEY=YOUR_SECRET_KEY \
-e DB_ENGINE=django.db.backends.postgresql \
-e POSTGRES_HOST=db_recipes \
@@ -34,25 +38,7 @@ docker run -d \
vabene1111/recipes
```
Please make sure, if you run your image this way, to consult
the [.env.template](https://raw.githubusercontent.com/vabene1111/recipes/master/.env.template)
file in the GitHub repository to verify if additional environment variables are required for your setup.
Also, don't forget to replace the placeholders for ```SECRET_KEY``` and ```POSTGRES_PASSWORD```!
## **Versions**
There are different versions (tags) released on [Docker Hub](https://hub.docker.com/r/vabene1111/recipes/tags).
- **latest** Default image. The one you should use if you don't know that you need anything else.
- **beta** Partially stable version that gets updated every now and then. Expect to have some problems.
- **develop** If you want the most bleeding edge version with potentially many breaking changes feel free to use this version (not recommended!).
- **X.Y.Z** each released version has its own image. If you need to revert to an old version or want to make sure you stay on one specific use these tags.
!!! danger "No Downgrading"
There is currently no way to migrate back to an older version as there is no mechanism to downgrade the database.
You could probably do it but I cannot help you with that. Choose wisely if you want to use the unstable images.
That said **beta** should usually be working if you like frequent updates and new stuff.
Please make sure to replace the ```SECRET_KEY``` and ```POSTGRES_PASSWORD``` placeholders!
## **Docker Compose**
@@ -63,7 +49,7 @@ The main, and also recommended, installation option for this application is Dock
```shell
wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template -O .env
```
3. **Edit it accordingly** (you NEED to set `SECRET_KEY` and `POSTGRES_PASSWORD`).
3. **Edit it accordingly** (you NEED to set `SECRET_KEY` and `POSTGRES_PASSWORD`), see [configuration page](https://docs.tandoor.dev/system/configuration/).
4. Start your container using `docker-compose up -d`.
### **Plain**
@@ -79,9 +65,6 @@ wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/d
{% include "./docker/plain/docker-compose.yml" %}
~~~
!!!note
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
### **Reverse Proxy**
Most deployments will likely use a reverse proxy.
@@ -105,8 +88,6 @@ wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/d
{% include "./docker/traefik-nginx/docker-compose.yml" %}
~~~
!!!note
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
#### **jwilder's Nginx-proxy**
@@ -134,206 +115,25 @@ wget https://raw.githubusercontent.com/vabene1111/recipes/develop/docs/install/d
{% include "./docker/nginx-proxy/docker-compose.yml" %}
~~~
!!!note
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
## **DockSTARTer**
#### **Nginx Swag by LinuxServer**
The main goal of [DockSTARTer](https://dockstarter.com/) is to make it quick and easy to get up and running with Docker.
You may choose to rely on DockSTARTer for various changes to your Docker system or use DockSTARTer as a stepping stone and learn to do more advanced configurations.
Follow the guide for installing DockSTARTer and then run `ds` then select 'Configuration' and 'Select Apps' to get Tandoor up and running quickly and easily.
[This container](https://github.com/linuxserver/docker-swag) is an all in one solution created by LinuxServer.io.
It contains templates for popular apps, including Tandoor Recipes, so you don't have to manually configure nginx and discard the template provided in Tandoor repo. Tandoor config is called `recipes.subdomain.conf.sample` which you can adapt for your instance.
If you're running Swag on the default port, you'll just need to change the container name to yours.
If your running Swag on a custom port, some headers must be changed:
- Create a copy of `proxy.conf`
- Replace `proxy_set_header X-Forwarded-Host $host;` and `proxy_set_header Host $host;` to
- `proxy_set_header X-Forwarded-Host $http_host;` and `proxy_set_header Host $http_host;`
- Update `recipes.subdomain.conf` to use the new file
- Restart the linuxserver/swag container and Recipes will work correctly
More information [here](https://github.com/TandoorRecipes/recipes/issues/959#issuecomment-962648627).
In both cases, also make sure to mount `/media/` in your swag container to point to your Tandoor Recipes Media directory.
Please refer to the [appropriate documentation](https://github.com/linuxserver/docker-swag#usage) for the container setup.
For step-by-step instructions to set this up from scratch, see [this example](swag.md).
#### **Pure Nginx**
If you have Nginx installed locally on your host system without using any third party integration like Swag or similar, this is for you.
You can use the Docker-Compose file from [Plain](#plain).
!!!warning "Adjust Docker-Compose file"
Replace `80:80` with `PORT:80` with PORT being your desired outward-facing port.
In the nginx config example below, 8080 is used.
An example configuration with LetsEncrypt to get you started can be seen below.
Please note, that since every setup is different, you might need to adjust some things.
!!!warning "Placeholders"
Don't forget to replace the domain and port.
```nginx
server {
if ($host = recipes.mydomain.tld) { # replace domain
return 301 https://$host$request_uri;
}
server_name recipes.mydomain.tld; # replace domain
listen 80;
return 404;
}
server {
server_name recipes.mydomain.tld; # replace domain
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/recipes.mydomain.tld/fullchain.pem; # replace domain
ssl_certificate_key /etc/letsencrypt/live/recipes.mydomain.tld/privkey.pem; # replace domain
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_set_header Host $http_host; # try $host instead if this doesn't work
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:8080; # replace port
proxy_redirect http://127.0.0.1:8080 https://recipes.domain.tld; # replace port and domain
}
}
```
Tandoor does not support directly serving of images, as explained in the [Nginx vs Gunicorn"](#nginx-vs-gunicorn) section. If you are already using nginx to serve as a reverse proxy, you can configure it to serve images as well.
Add the following directly after the `location /` context:
```
location /media/ {
root /media/;
index index.html index.htm;
}
```
Make sure you also update your `docker-compose.yml` file to mount the `mediafiles` directory. If you are using the [Plain](#plain) deployment, you do not need to make any changes. If you are using nginx to act as a reverse proxy for other apps, it may not be optimal to have `mediafiles` mounted to `/media`. In that case, adjust the directory declarations as needed, utilizing nginx's [`alias`](https://nginx.org/en/docs/http/ngx_http_core_module.html#alias) if needed.
!!!note
Use `alias` if your mount point directory is not the same as the URL request path. Tandoor media files are requested from `$http_host/media/recipes/xxx.jpg`. This means if you are mounting to a directory that does **NOT** end in `./media`, you will need to use `alias`.
!!!note
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
#### **Apache**
You can use the Docker-Compose file from [Plain](#plain).
!!!warning "Adjust Docker-Compose file"
Replace `80:80` with `PORT:80` with PORT being your desired outward-facing port.
In the Apache config example below, 8080 is used.
If you use e.g. LetsEncrypt for SSL encryption, you can use the example configuration from [solaris7590](https://github.com/TandoorRecipes/recipes/issues/1312#issuecomment-1020034375) below.
!!!warning "Placeholders"
Don't forget to replace the domain and port.
```apache
<IfModule mod_ssl.c>
<VirtualHost *:80>
ServerAdmin webmaster@mydomain.de # replace domain
ServerName mydomain.de # replace domain
Redirect permanent / https://mydomain.de/ # replace domain
</VirtualHost>
<VirtualHost *:443>
ServerAdmin webmaster@mydomain.de # replace domain
ServerName mydomain.de # replace domain
SSLEngine on
RequestHeader set X-Forwarded-Proto "https"
Header always set Access-Control-Allow-Origin "*"
ProxyPreserveHost On
ProxyRequests Off
ProxyPass / http://localhost:8080/ # replace port
ProxyPassReverse / http://localhost:8080/ # replace port
SSLCertificateFile /etc/letsencrypt/live/mydomain.de/fullchain.pem # replace domain/path
SSLCertificateKeyFile /etc/letsencrypt/live/mydomain.de/privkey.pem # replace domain/path
Include /etc/letsencrypt/options-ssl-apache.conf
ErrorLog ${APACHE_LOG_DIR}/recipes_error.log
CustomLog ${APACHE_LOG_DIR}/recipes_access.log combined
</VirtualHost>
</IfModule>
```
If you're having issues with the example configuration above, you can try [beedaddy](https://github.com/TandoorRecipes/recipes/issues/1312#issuecomment-1015252663)'s example config.
!!!note
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
#### **Others**
If you use none of the above mentioned reverse proxies or want to use an existing one on your host machine (like a local nginx or Caddy), simply use the [Plain](#plain) setup above and change the outbound port to one of your liking.
An example port config (inside the respective docker-compose.yml) would be: `8123:80` instead of the `80:80` or if you want to be sure, that Tandoor is **just** accessible via your proxy and don't wanna bother with your firewall, then `127.0.0.1:8123:80` is a viable option too.
!!!note
Don't forget to [download and configure](#docker-compose) your ```.env``` file!
DockSTARTer might not be updated for Tandoor 2 configurations
## **Additional Information**
### **Nginx vs Gunicorn**
All examples use an additional `nginx` container to serve mediafiles and act as the forward facing webserver.
This is **technically not required** but **very much recommended**.
I do not 100% understand the deep technical details but the [developers of gunicorn](https://serverfault.com/questions/331256/why-do-i-need-nginx-and-something-like-gunicorn/331263#331263),
the WSGi server that handles the Python execution, explicitly state that it is not recommended to deploy without nginx.
You will also likely not see any decrease in performance or a lot of space used as nginx is a very light container.
!!! info
Even if you run behind a reverse proxy as described above, using an additional nginx container is the recommended option.
If you run a small private deployment and don't care about performance, security and whatever else feel free to run
without a nginx container.
!!! warning
When running without nginx make sure to enable `GUNICORN_MEDIA` in the `.env`. Without it, media files will be uploaded
but not shown on the page.
For additional information please refer to the [0.9.0 Release](https://github.com/vabene1111/recipes/releases?after=0.9.0)
and [Issue 201](https://github.com/vabene1111/recipes/issues/201) where these topics have been discussed.
See also refer to the [official gunicorn docs](https://docs.gunicorn.org/en/stable/deploy.html).
### **Nginx Config**
Starting with Tandoor 2 the Docker container includes a nginx service. Its default configuration is pulled from the [http.d](https://github.com/TandoorRecipes/recipes/tree/develop/http.d) folder
in the repository.
In order to give the user (you) the greatest amount of freedom when choosing how to deploy this application the
webserver is not directly bundled with the Docker image.
You can setup a volume to link to the ```/opt/recipes/http.d``` folder inside your container to change the configuration. Keep in mind that you will not receive any updates on the configuration
if you manually change it/bind the folder as a volume.
This has the downside that it is difficult to supply the configuration to the webserver (e.g. nginx). Up until
version `0.13.0`, this had to be done manually by downloading the nginx config file and placing it in a directory that
was then mounted into the nginx container.
From version `0.13.0`, the config file is supplied using the application image (`vabene1111/recipes`). It is then mounted
to the host system and from there into the nginx container.
This is not really a clean solution, but I could not find any better alternative that provided the same amount of
usability. If you know of any better way, feel free to open an issue.
### **Volumes vs Bind Mounts**
Since I personally prefer to have my data where my `docker-compose.yml` resides, bind mounts are used in the example
configuration files for all user generated data (e.g. Postgresql and media files).
!!!warning
Please note that [there is a difference in functionality](https://docs.docker.com/storage/volumes/)
between the two and you cannot always simply interchange them.
You can move everything to volumes if you prefer it this way, **but you cannot convert the nginx config file to a bind
mount.**
If you do so you will have to manually create the nginx config file and restart the container once after creating it.
### **Required Headers**
@@ -362,12 +162,13 @@ ProxyPassReverse / http://localhost:8080/ # replace port
### **Setup issues on Raspberry Pi**
!!! danger
Tandoor 2 does no longer build images for arm/v7 architectures. You can certainly get Tandoor working there but it has simply been to much effort to maintain these architectures over the past years
to justify the continued support of this mostly deprecated platform.
!!!info
Always wait at least 2-3 minutes after the very first start, since migrations will take some time!
!!!info
In the past there was a special `*-raspi` version of the image. This no longer exists. The normal Tags all support Arm/v7 architectures which should work on all Raspberry Pi's above Version 1 and the first generation Zero.
See [Wikipedia Raspberry Pi specifications](https://en.wikipedia.org/wiki/Raspberry_Pi#Specifications).
If you're having issues with installing Tandoor on your Raspberry Pi or similar device,
follow these instructions:
@@ -380,7 +181,7 @@ follow these instructions:
### Sub Path nginx config
If hosting under a sub-path you might want to change the default nginx config (which gets mounted through the named volume from the application container into the nginx container)
If hosting under a sub-path you might want to change the default nginx config
with the following config.
```nginx
@@ -407,3 +208,10 @@ location /static/ {
}
```
### Tandoor 1 vs Tandoor 2
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.
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

@@ -16,28 +16,11 @@ services:
- ./.env
volumes:
- staticfiles:/opt/recipes/staticfiles
# Do not make this a bind mount, see https://docs.tandoor.dev/install/docker/#volumes-vs-bind-mounts
- nginx_config:/opt/recipes/nginx/conf.d
- ./mediafiles:/opt/recipes/mediafiles
depends_on:
- db_recipes
networks:
- default
nginx_recipes:
image: nginx:mainline-alpine
restart: always
env_file:
- ./.env
depends_on:
- web_recipes
volumes:
# Do not make this a bind mount, see https://docs.tandoor.dev/install/docker/#volumes-vs-bind-mounts
- nginx_config:/etc/nginx/conf.d:ro
- staticfiles:/static:ro
- ./mediafiles:/media:ro
networks:
- default
- nginx-proxy
networks:
@@ -47,5 +30,4 @@ networks:
name: nginx-proxy
volumes:
nginx_config:
staticfiles:

View File

@@ -14,27 +14,9 @@ services:
- ./.env
volumes:
- staticfiles:/opt/recipes/staticfiles
# Do not make this a bind mount, see https://docs.tandoor.dev/install/docker/#volumes-vs-bind-mounts
- nginx_config:/opt/recipes/nginx/conf.d
- ./mediafiles:/opt/recipes/mediafiles
depends_on:
- db_recipes
nginx_recipes:
image: nginx:mainline-alpine
restart: always
ports:
- 80:80
env_file:
- ./.env
depends_on:
- web_recipes
volumes:
# Do not make this a bind mount, see https://docs.tandoor.dev/install/docker/#volumes-vs-bind-mounts
- nginx_config:/etc/nginx/conf.d:ro
- staticfiles:/static:ro
- ./mediafiles:/media:ro
volumes:
nginx_config:
staticfiles:

View File

@@ -16,40 +16,23 @@ services:
- ./.env
volumes:
- staticfiles:/opt/recipes/staticfiles
# Do not make this a bind mount, see https://docs.tandoor.dev/install/docker/#volumes-vs-bind-mounts
- nginx_config:/opt/recipes/nginx/conf.d
- ./mediafiles:/opt/recipes/mediafiles
depends_on:
- db_recipes
networks:
- default
nginx_recipes:
image: nginx:mainline-alpine
restart: always
env_file:
- ./.env
volumes:
# Do not make this a bind mount, see https://docs.tandoor.dev/install/docker/#volumes-vs-bind-mounts
- nginx_config:/etc/nginx/conf.d:ro
- staticfiles:/static:ro
- ./mediafiles:/media:ro
labels: # traefik example labels
- "traefik.enable=true"
- "traefik.http.routers.recipes.rule=Host(`recipes.mydomain.com`, `recipes.myotherdomain.com`)"
- "traefik.http.routers.recipes.entrypoints=web_secure" # your https endpoint
- "traefik.http.routers.recipes.tls.certresolver=le_resolver" # your cert resolver
depends_on:
- web_recipes
networks:
- default
- traefik
networks:
default:
traefik: # This is your external traefik network
external: true
volumes:
nginx_config:
staticfiles:

View File

@@ -3,6 +3,9 @@
Many thanks to [alexbelgium](https://github.com/alexbelgium) for making implementing everything required to have
Tandoor run in HA.
!!! 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.
![Addon version](https://img.shields.io/badge/dynamic/json?label=Version&query=%24.version&url=https%3A%2F%2Fraw.githubusercontent.com%2Falexbelgium%2Fhassio-addons%2Fmaster%2Ftandoor_recipes%2Fconfig.json) ![Last update](https://img.shields.io/badge/dynamic/json?label=Updated&query=%24.last_update&url=https%3A%2F%2Fraw.githubusercontent.com%2Falexbelgium%2Fhassio-addons%2Fmaster%2Ftandoor_recipes%2Fupdater.json) ![aarch64][aarch64-badge] ![amd64][amd64-badge] ![armv7][armv7-badge]
### Introduction

View File

@@ -1,6 +1,9 @@
!!! info "Community Contributed"
This guide was contributed by the community and is neither officially supported, nor updated or tested.
!!! 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.
## K8s Setup
This is a setup which should be sufficient for production use. Be sure to replace the default secrets!

View File

@@ -1,6 +1,9 @@
!!! info "Community Contributed"
This guide was contributed by the community and is neither officially supported, nor updated or tested.
!!! 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.
[KubeSail](https://kubesail.com/) lets you install Tandoor by providing a simple web interface for installing and managing apps. You can connect any server running Kubernetes, or get a pre-configured [PiBox](https://pibox.io).
<!-- A portion of every PiBox sale goes toward supporting Tandoor development. -->

View File

@@ -5,6 +5,9 @@ These instructions are inspired from a standard django/gunicorn/postgresql instr
!!! 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`.
!!! warning
These instructions are **not** regularly reviewed and might be outdated.
## Prerequisites
Setup user: `sudo useradd recipes`

View File

@@ -3,6 +3,8 @@
This page especially contains some setups that might help you if you really want to go down a certain path but none
of the examples are supported (as I simply am not able to give you support for them).
!!! 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.
## Apache + Traefik + Sub-Path
@@ -35,7 +37,6 @@ docker-compose:
environment:
# all the other env
- SCRIPT_NAME=/<sub path>
- JS_REVERSE_SCRIPT_PREFIX=/<sub path>/
- STATIC_URL=/<www path>/static/
- MEDIA_URL=/<www path>/media/
labels:
@@ -100,7 +101,6 @@ and the relevant section from the docker-compose.yml:
image: vabene1111/recipes
environment:
- SCRIPT_NAME=/tandoor
- JS_REVERSE_SCRIPT_PREFIX=/tandoor
- STATIC_URL=/tandoor/static/
- MEDIA_URL=/tandoor/media/
- GUNICORN_MEDIA=0

View File

@@ -1,6 +1,9 @@
!!! danger
Please refer to the [official documentation](https://github.com/linuxserver/docker-swag#usage) for the container setup. This example shows just one setup that may or may not differ from yours in significant ways. This tutorial does not cover security measures, backups, and many other things that you might want to consider.
!!! 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.
## Prerequisites
- You have a newly spun-up Ubuntu server with docker (pre-)installed.

View File

@@ -1,6 +1,9 @@
!!! 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.

View File

@@ -1,60 +0,0 @@
!!! danger
Please refer to [the official documentation](https://doc.traefik.io/traefik/).
This example just shows something similar to my setup in case you dont understand the official documentation.
You need to create a network called `traefik` using `docker network create traefik`.
## docker-compose.yml
```
version: "3.3"
services:
traefik:
image: "traefik:v2.1"
container_name: "traefik"
ports:
- "443:443"
- "80:80"
- "8080:8080"
volumes:
- "./letsencrypt:/letsencrypt"
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./config:/etc/traefik/"
networks:
default:
external:
name: traefik
```
## traefik.toml
Place this in a directory called `config` as this is mounted into the traefik container (see docer compose).
**Change the email address accordingly**.
```
[api]
insecure=true
[providers.docker]
endpoint = "unix:///var/run/docker.sock"
exposedByDefault = false
network = "traefik"
#[log]
# level = "DEBUG"
[entryPoints]
[entryPoints.web]
address = ":80"
[entryPoints.web_secure]
address = ":443"
[certificatesResolvers.le_resolver.acme]
email = "you_email@mail.com"
storage = "/letsencrypt/acme.json"
tlsChallenge=true
```

View File

@@ -1,6 +1,9 @@
!!! info "Community Contributed"
This guide was contributed by the community and is neither officially supported, nor updated or tested.
!!! 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.
This guide is to assist those installing Tandoor Recipes on Truenas Core using Docker and or Portainer
Docker install instructions adapted from [PhasedLogix IT Services's guide](https://getmethegeek.com/blog/2021-01-07-add-docker-capabilities-to-truenas-core/). Portainer install instructions adopted from the [Portainer Official Documentation](https://docs.portainer.io/start/install-ce/server/docker/linux). Tandoor installation on Portainer provided by users `Szeraax` and `TransatlanticFoe` on Discord (Thank you two!)

View File

@@ -1,6 +1,9 @@
!!! info "Community Contributed"
This guide was contributed by the community and is neither officially supported, nor updated or tested.
!!! 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.
[Unraid](https://unraid.net/) is an operating system that allows you to easily install and setup applications.
Thanks to [CorneliousJD](https://github.com/CorneliousJD) this application can easily be installed using unraid.

View File

@@ -1,3 +1,10 @@
!!! 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.
# Ubuntu Installation on Windows (WSL) and Docker Desktop
Install Docker from https://docs.docker.com/desktop/install/windows-install/

View File

@@ -1,15 +1,15 @@
:root {
--md-primary-fg-color: #ffcb76;
--md-accent-fg-color: #FF6F00;
:root > * {
--md-primary-fg-color: #ddbf86;
--md-accent-fg-color: #b55e4f;
--md-primary-fg-color--light: #ffcb76;
--md-primary-fg-color--light: #ddbf86;
/* not working part, has no effect */
--md-primary-bg-color: #272727;
--md-default-bg-color: #272727;
--md-default-bg-color--light: #272727;
--md-default-bg-color--lighter: #272727;
--md-default-bg-color--lightest: #272727;
--md-primary-bg-color: #121212;
--md-default-bg-color: #121212;
--md-default-bg-color--light: #f5efea;
--md-default-bg-color--lighter: #f5efea;
--md-default-bg-color--lightest: #f5efea;
}
/*

31
http.d/Recipes.conf Normal file
View File

@@ -0,0 +1,31 @@
server {
listen 80;
listen [::]:80 ipv6only=on;
server_name localhost;
client_max_body_size 128M;
# serve media files
location /media {
alias /opt/recipes/mediafiles;
add_header Content-Disposition 'attachment; filename="$args"';
}
# serve service worker under main path
location = /service-worker.js {
alias /opt/recipes/staticfiles/vue3/service-worker.js;
}
# pass requests for dynamic content to gunicorn
location / {
proxy_set_header Host $http_host;
proxy_pass http://localhost:8080;
error_page 502 /errors/http502.html;
}
location /errors/ {
alias /etc/nginx/conf.d/errorpages/;
internal;
}
}

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Simple HttpErrorPages | MIT License | https://github.com/HttpErrorPages -->
<meta charset="utf-8" /><meta http-equiv="X-UA-Compatible" content="IE=edge" /><meta name="viewport" content="width=device-width, initial-scale=1" />
<title>502 - Webservice currently unavailable</title>
<style type="text/css">/*! normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figcaption,figure,main{display:block}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}a:active,a:hover{outline-width:0}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:inherit}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{display:inline-block;vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details,menu{display:block}summary{display:list-item}canvas{display:inline-block}template{display:none}[hidden]{display:none}/*! Simple HttpErrorPages | MIT X11 License | https://github.com/AndiDittrich/HttpErrorPages */body,html{width:100%;height:100%;background-color:#21232a}body{color:#fff;text-align:center;text-shadow:0 2px 4px rgba(0,0,0,.5);padding:0;min-height:100%;-webkit-box-shadow:inset 0 0 100px rgba(0,0,0,.8);box-shadow:inset 0 0 100px rgba(0,0,0,.8);display:table;font-family:"Open Sans",Arial,sans-serif}h1{font-family:inherit;font-weight:500;line-height:1.1;color:inherit;font-size:36px}h1 small{font-size:68%;font-weight:400;line-height:1;color:#777}a{text-decoration:none;color:#fff;font-size:inherit;border-bottom:dotted 1px #707070}.lead{color:silver;font-size:21px;line-height:1.4}.cover{display:table-cell;vertical-align:middle;padding:0 20px}footer{position:fixed;width:100%;height:40px;left:0;bottom:0;color:#a0a0a0;font-size:14px}</style>
</head>
<body>
<div class="cover"><h1>Tandoor Recipes is not yet available <small>502</small></h1>
<p class="lead">
Services are still trying to start.<br>
Please allow up to 3 minutes after you started the application on your server.<br><br>
If this status persists, check the application or docker logs for further information.<br>
After checking and trying everything mentioned in the <a href="https://docs.tandoor.dev/" target="_blank">docs</a>, you can request help on the project's <a href="https://github.com/TandoorRecipes/recipes/issues/new?assignees=&amp;labels=setup+issue&amp;template=help_request.yml" target="_blank">GitHub</a> page.
</p>
</div>
</body>
</html>

View File

@@ -13,6 +13,8 @@ theme:
favicon: logo_color.svg
palette:
scheme: slate
primary: custom
accent: custom

View File

@@ -6,10 +6,16 @@ server {
client_max_body_size 128M;
# serve media files
location /media/ {
alias /media/;
location /media {
alias /opt/recipes/mediafiles;
add_header Content-Disposition 'attachment; filename="$args"';
}
# serve service worker under main path
location = /service-worker.js {
alias /opt/recipes/staticfiles/vue3/service-worker.js;
}
# pass requests for dynamic content to gunicorn
location / {
proxy_set_header Host $http_host;

View File

@@ -1,5 +0,0 @@
{
"dependencies": {
"@vueuse/core": "^10.9.0"
}
}

View File

@@ -4,4 +4,5 @@ testpaths = cookbook/tests
python_files = tests.py test_*.py *_tests.py
# uncomment to run coverage reports
addopts = -n auto --cov=. --cov-report=html:docs/reports/coverage --cov-report=xml:docs/reports/coverage/coverage.xml --junitxml=docs/reports/tests/pytest.xml --html=docs/reports/tests/tests.html
# addopts = -n auto --junitxml=docs/reports/tests/pytest.xml --html=docs/reports/tests/tests.html
# addopts = -n auto --junitxml=docs/reports/tests/pytest.xml --html=docs/reports/tests/tests.html
asyncio_default_fixture_loop_scope = fixture

View File

@@ -40,9 +40,6 @@ def extract_comma_list(env_key, default=None):
load_dotenv()
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCRIPT_NAME = os.getenv('SCRIPT_NAME', '')
# path for django_js_reverse to generate the javascript file containing all urls. Only done because the default command (collectstatic_js_reverse) fails to update the manifest
JS_REVERSE_OUTPUT_PATH = os.path.join(BASE_DIR, "cookbook/static/django_js_reverse")
JS_REVERSE_SCRIPT_PREFIX = os.getenv('JS_REVERSE_SCRIPT_PREFIX', SCRIPT_NAME)
STATIC_URL = os.getenv('STATIC_URL', '/static/')
STATIC_ROOT = os.getenv('STATIC_ROOT', os.path.join(BASE_DIR, "staticfiles"))
@@ -86,6 +83,10 @@ LOGGING = {
'handlers': ['console'],
'level': LOG_LEVEL,
},
'django': {
'handlers': ['console'],
'level': LOG_LEVEL,
},
},
}
@@ -245,7 +246,6 @@ ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 90
ACCOUNT_LOGOUT_ON_GET = True
USERSESSIONS_TRACK_ACTIVITY = True
HEADLESS_SERVE_SPECIFICATION = True
try:
@@ -521,6 +521,16 @@ CACHES = {
}
}
if REDIS_HOST:
CACHES['default'] = {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': f'redis://{REDIS_HOST}:{REDIS_PORT}',
}
if REDIS_USERNAME and not REDIS_PASSWORD:
CACHES['default']['LOCATION'] = f'redis://{REDIS_USERNAME}@{REDIS_HOST}:{REDIS_PORT}'
if REDIS_USERNAME and REDIS_PASSWORD:
CACHES['default']['LOCATION'] = f'redis://{REDIS_USERNAME}:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}'
# Vue webpack settings
VUE_DIR = os.path.join(BASE_DIR, 'vue')
@@ -674,17 +684,5 @@ ACCOUNT_RATE_LIMITS = {
DISABLE_EXTERNAL_CONNECTORS = extract_bool('DISABLE_EXTERNAL_CONNECTORS', False)
EXTERNAL_CONNECTORS_QUEUE_SIZE = int(os.getenv('EXTERNAL_CONNECTORS_QUEUE_SIZE', 100))
# ACCOUNT_SIGNUP_FORM_CLASS = 'cookbook.forms.AllAuthSignupForm'
ACCOUNT_FORMS = {'signup': 'cookbook.forms.AllAuthSignupForm', 'reset_password': 'cookbook.forms.CustomPasswordResetForm'}
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False
ACCOUNT_RATE_LIMITS = {
"change_password": "1/m/user",
"reset_password": "1/m/ip,1/m/key",
"reset_password_from_key": "1/m/ip",
"signup": "5/m/ip",
"login": "5/m/ip",
}
mimetypes.add_type("text/javascript", ".js", True)
mimetypes.add_type("text/javascript", ".mjs", True)

View File

@@ -41,9 +41,10 @@ python-ldap==3.4.4
django-auth-ldap==4.6.0
pyppeteer==2.0.0
pytubefix==9.2.2
aiohttp==3.10.11
aiohttp==3.12.14
inflection==0.5.1
redis==5.2.1
hiredis==3.2.1
requests-oauthlib==2.0.0
pyjwt==2.10.1
python3-openid==3.2.0
@@ -54,11 +55,11 @@ litellm==1.64.1
# Development
pytest==8.4.1
pytest-django==4.11.0
pytest-cov===6.0.0
pytest-factoryboy==2.8.0
pytest-cov===6.2.1
pytest-factoryboy==2.8.1
pytest-html==4.1.1
pytest-asyncio==0.25.3
pytest-xdist==3.7.0
pytest-asyncio==1.1.0
pytest-xdist==3.8.0
autopep8==2.3.2
flake8==7.3.0
yapf==0.40.2

View File

@@ -18,12 +18,12 @@
"pinia": "^3.0.2",
"vue": "^3.5.13",
"vue-draggable-plus": "^0.6.0",
"vue-i18n": "^11.1.7",
"vue-i18n": "^11.1.10",
"vue-router": "^4.5.0",
"vue-simple-calendar": "7.1.0",
"vuedraggable": "^4.1.0",
"@types/sortablejs": "^1.15.8",
"vuetify": "^3.8.12"
"vuetify": "^3.9.0"
},
"devDependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
@@ -36,6 +36,7 @@
"typescript": "^5.8.3",
"vite": "6.3.5",
"vite-plugin-pwa": "^1.0.1",
"workbox-core": "^7.3.0",
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0",
"workbox-background-sync": "^7.3.0",

View File

@@ -3,92 +3,45 @@
<v-app-bar color="tandoor" flat density="comfortable" v-if="!useUserPreferenceStore().isAuthenticated">
</v-app-bar>
<v-app-bar :color="useUserPreferenceStore().activeSpace.navBgColor ? useUserPreferenceStore().activeSpace.navBgColor : useUserPreferenceStore().userSettings.navBgColor" flat density="comfortable" v-if="useUserPreferenceStore().isAuthenticated" :scroll-behavior="useUserPreferenceStore().userSettings.navSticky ? '' : 'hide'">
<v-app-bar :color="useUserPreferenceStore().activeSpace.navBgColor ? useUserPreferenceStore().activeSpace.navBgColor : useUserPreferenceStore().userSettings.navBgColor"
flat density="comfortable" v-if="useUserPreferenceStore().isAuthenticated" :scroll-behavior="useUserPreferenceStore().userSettings.navSticky ? '' : 'hide'">
<router-link :to="{ name: 'StartPage', params: {} }">
<v-img src="../../assets/brand_logo.svg" width="140px" class="ms-2" v-if="useUserPreferenceStore().userSettings.navShowLogo && !useUserPreferenceStore().activeSpace.navLogo"></v-img>
<v-img :src="useUserPreferenceStore().activeSpace.navLogo.preview" width="140px" class="ms-2" v-if="useUserPreferenceStore().userSettings.navShowLogo && useUserPreferenceStore().activeSpace.navLogo != undefined"></v-img>
<v-img src="../../assets/brand_logo.svg" width="140px" class="ms-2"
v-if="useUserPreferenceStore().userSettings.navShowLogo && !useUserPreferenceStore().activeSpace.navLogo"></v-img>
<v-img :src="useUserPreferenceStore().activeSpace.navLogo.preview" width="140px" class="ms-2"
v-if="useUserPreferenceStore().userSettings.navShowLogo && useUserPreferenceStore().activeSpace.navLogo != undefined"></v-img>
</router-link>
<v-spacer></v-spacer>
<global-search-dialog ></global-search-dialog>
<v-btn icon="$add" class="d-print-none">
<v-icon icon="$add" class="fa-fw"></v-icon>
<v-menu activator="parent">
<v-list>
<v-list-item prepend-icon="$add" :to="{ name: 'ModelEditPage', params: {model: 'recipe'} }">{{ $t('Create Recipe') }}</v-list-item>
<v-list-item prepend-icon="fa-solid fa-globe" :to="{ name: 'RecipeImportPage', params: {} }">{{ $t('Import Recipe') }}</v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-spacer></v-spacer>
<global-search-dialog></global-search-dialog>
<v-btn icon="$add" class="d-print-none">
<v-icon icon="$add" class="fa-fw"></v-icon>
<v-menu activator="parent">
<v-list>
<v-list-item prepend-icon="$add" :to="{ name: 'ModelEditPage', params: {model: 'recipe'} }">{{ $t('Create Recipe') }}</v-list-item>
<v-list-item prepend-icon="fa-solid fa-globe" :to="{ name: 'RecipeImportPage', params: {} }">{{ $t('Import Recipe') }}</v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-avatar color="primary" class="me-2 cursor-pointer d-print-none">{{ useUserPreferenceStore().userSettings.user.displayName.charAt(0) }}
<v-menu activator="parent">
<v-avatar color="primary" class="me-2 cursor-pointer d-print-none">{{ useUserPreferenceStore().userSettings.user.displayName.charAt(0) }}
<v-menu activator="parent">
<v-list density="compact">
<v-list-item class="mb-1">
<template #prepend>
<v-avatar color="primary">{{ useUserPreferenceStore().userSettings.user.displayName.charAt(0) }}</v-avatar>
</template>
<v-list-item-title>{{ useUserPreferenceStore().userSettings.user.displayName }}</v-list-item-title>
<v-list-item-subtitle>{{ useUserPreferenceStore().activeSpace.name }}</v-list-item-subtitle>
</v-list-item>
<v-divider></v-divider>
<v-list-item :to="{ name: 'SettingsPage', params: {} }">
<template #prepend>
<v-icon icon="fa-solid fa-sliders"></v-icon>
</template>
{{ $t('Settings') }}
</v-list-item>
<v-list-item :to="{ name: 'DatabasePage', params: {} }">
<template #prepend>
<v-icon icon="fa-solid fa-folder-tree"></v-icon>
</template>
{{ $t('Database') }}
</v-list-item>
<v-list-item :to="{ name: 'HelpPage' }">
<template #prepend>
<v-icon icon="fa-solid fa-question"></v-icon>
</template>
{{ $t('Help') }}
</v-list-item>
<!-- <v-list-item><template #prepend><v-icon icon="fa-solid fa-user-shield"></v-icon></template>Admin</v-list-item>-->
<!-- <v-list-item><template #prepend><v-icon icon="fa-solid fa-question"></v-icon></template>Help</v-list-item>-->
<template v-if="useUserPreferenceStore().spaces.length > 1">
<v-divider></v-divider>
<v-list-subheader>{{ $t('YourSpaces') }}</v-list-subheader>
<v-list-item v-for="s in useUserPreferenceStore().spaces" :key="s.id" @click="useUserPreferenceStore().switchSpace(s)">
<template #prepend>
<v-icon icon="fa-solid fa-circle-dot" v-if="s.id == useUserPreferenceStore().activeSpace.id"></v-icon>
<v-icon icon="fa-solid fa-circle" v-else></v-icon>
</template>
{{ s.name }}
</v-list-item>
<v-list density="compact">
<v-list-item class="mb-1">
<template #prepend>
<v-avatar color="primary">{{ useUserPreferenceStore().userSettings.user.displayName.charAt(0) }}</v-avatar>
</template>
<v-list-item-title>{{ useUserPreferenceStore().userSettings.user.displayName }}</v-list-item-title>
<v-list-item-subtitle>{{ useUserPreferenceStore().activeSpace.name }}</v-list-item-subtitle>
</v-list-item>
<v-divider></v-divider>
<v-divider></v-divider>
<v-list-item link>
<template #prepend>
<v-icon icon="fa-solid fa-database"></v-icon>
</template>
{{ $t('Messages') }}
<message-list-dialog></message-list-dialog>
</v-list-item>
<v-list-item :href="getDjangoUrl('admin')" target="_blank" v-if="useUserPreferenceStore().userSettings.user.isSuperuser">
<template #prepend>
<v-icon icon="fa-solid fa-shield"></v-icon>
</template>
{{ $t('Admin') }}
</v-list-item>
<v-list-item :href="getDjangoUrl('accounts/logout')" link>
<template #prepend>
<v-icon icon="fa-solid fa-arrow-right-from-bracket"></v-icon>
</template>
{{ $t('Logout') }}
</v-list-item>
</v-list>
</v-menu>
</v-avatar>
<component :is="item.component" :="item" :key="item.title" v-for="item in useNavigation().getUserNavigation()"></component>
</v-list>
</v-menu>
</v-avatar>
</v-app-bar>
<v-app-bar color="info" density="compact"
@@ -126,23 +79,17 @@
<v-list-item-subtitle>{{ useUserPreferenceStore().activeSpace.name }}</v-list-item-subtitle>
</v-list-item>
<v-divider></v-divider>
<v-list-item prepend-icon="$recipes" title="Home" :to="{ name: 'StartPage', params: {} }"></v-list-item>
<v-list-item prepend-icon="$search" :title="$t('Search')" :to="{ name: 'SearchPage' }"></v-list-item>
<v-list-item prepend-icon="$mealplan" :title="$t('Meal_Plan')" :to="{ name: 'MealPlanPage', params: {} }"></v-list-item>
<v-list-item prepend-icon="$shopping" :title="$t('Shopping_list')" :to="{ name: 'ShoppingListPage', params: {} }"></v-list-item>
<v-list-item prepend-icon="fas fa-globe" :title="$t('Import')" :to="{ name: 'RecipeImportPage', params: {} }"></v-list-item>
<v-list-item prepend-icon="$books" :title="$t('Books')" :to="{ name: 'BooksPage', params: {} }"></v-list-item>
<v-list-item prepend-icon="fa-solid fa-folder-tree" :title="$t('Database')" :to="{ name: 'DatabasePage' }"></v-list-item>
<component :is="item.component" :="item" :key="item.title" v-for="item in useNavigation().getNavigationDrawer()"></component>
<navigation-drawer-context-menu></navigation-drawer-context-menu>
</v-list>
<template #append>
<v-list nav>
<v-list-item prepend-icon="fas fa-sliders" :title="$t('Settings')" :to="{ name: 'SettingsPage', params: {} }"></v-list-item>
<v-list-item prepend-icon="fa-solid fa-heart" href="https://tandoor.dev" target="_blank">
<v-list-item prepend-icon="fa-solid fa-heart" link>
Tandoor {{ useUserPreferenceStore().serverSettings.version }}
<help-dialog></help-dialog>
</v-list-item>
</v-list>
</template>
@@ -166,10 +113,7 @@
<v-icon icon="fa-fw fas fa-bars"></v-icon>
<v-bottom-sheet activator="parent" close-on-content-click>
<v-list nav>
<v-list-item prepend-icon="fa-solid fa-sliders" :to="{ name: 'SettingsPage', params: {} }" :title="$t('Settings')"></v-list-item>
<v-list-item prepend-icon="fas fa-globe" :title="$t('Import')" :to="{ name: 'RecipeImportPage', params: {} }"></v-list-item>
<v-list-item prepend-icon="fa-solid fa-folder-tree" :to="{ name: 'DatabasePage' }" :title="$t('Database')"></v-list-item>
<v-list-item prepend-icon="$books" :title="$t('Books')" :to="{ name: 'BooksPage', params: {} }"></v-list-item>
<component :is="item.component" :="item" :key="item.title" v-for="item in useNavigation().getBottomNavigation()"></component>
</v-list>
</v-bottom-sheet>
</v-btn>
@@ -187,16 +131,18 @@
<script lang="ts" setup>
import GlobalSearchDialog from "@/components/inputs/GlobalSearchDialog.vue"
import {useDisplay, useTheme} from "vuetify"
import {useDisplay} from "vuetify"
import VSnackbarQueued from "@/components/display/VSnackbarQueued.vue";
import MessageListDialog from "@/components/dialogs/MessageListDialog.vue";
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
import NavigationDrawerContextMenu from "@/components/display/NavigationDrawerContextMenu.vue";
import {useDjangoUrls} from "@/composables/useDjangoUrls";
import {onMounted, ref} from "vue";
import {onMounted} from "vue";
import {isSpaceAboveLimit} from "@/utils/logic_utils";
import '@/assets/tandoor_light.css'
import {useMediaQuery} from "@vueuse/core";
import HelpDialog from "@/components/dialogs/HelpDialog.vue";
import {NAVIGATION_DRAWER} from "@/utils/navigation.ts";
import {useNavigation} from "@/composables/useNavigation.ts";
const {lgAndUp} = useDisplay()
const {getDjangoUrl} = useDjangoUrls()
@@ -209,6 +155,93 @@ onMounted(() => {
</script>
<style scoped>
<style>
.v-theme--dark {
a:not([class]) {
color: #b98766;
text-decoration: none;
background-color: transparent
}
a:hover {
color: #fff;
text-decoration: none
}
a:not([href]):not([tabindex]), a:not([href]):not([tabindex]):focus, a:not([href]):not([tabindex]):hover {
color: inherit;
text-decoration: none
}
a:not([href]):not([tabindex]):focus {
outline: 0
}
/* Meal-Plan */
.cv-header {
background-color: #303030 !important;
}
.cv-weeknumber, .cv-header-day {
background-color: #303030 !important;
color: #fff !important;
}
.cv-day.past {
background-color: #333333 !important;
}
.cv-day.today {
background-color: rgba(185, 135, 102, 0.2) !important;
}
.cv-day.outsideOfMonth {
background-color: #0d0d0d !important;
}
.cv-item {
background-color: #4E4E4E !important;
}
.d01 .cv-day-number {
background-color: #b98766 !important;
}
}
.v-theme--light {
a:not([class]) {
color: #b98766;
text-decoration: none;
background-color: transparent
}
a:hover {
color: #000;
text-decoration: none
}
a:not([href]):not([tabindex]), a:not([href]):not([tabindex]):focus, a:not([href]):not([tabindex]):hover {
color: inherit;
text-decoration: none
}
a:not([href]):not([tabindex]):focus {
outline: 0
}
}
/* vueform/multiselect */
.multiselect-option.is-pointed {
background: #b98766 !important;
}
.multiselect-option.is-selected {
background: #b55e4f !important;
}
</style>

View File

@@ -1,5 +1,5 @@
import {createApp} from "vue";
import {createRouter, createWebHashHistory, createWebHistory} from 'vue-router'
import {createRouter, createWebHistory} from 'vue-router'
import {createPinia} from 'pinia'
// @ts-ignore
import App from './Tandoor.vue'
@@ -12,8 +12,9 @@ import { createRulesPlugin } from 'vuetify/labs/rules'
import {setupI18n} from "@/i18n";
import MealPlanPage from "@/pages/MealPlanPage.vue";
import {TANDOOR_PLUGINS, TandoorPlugin} from "@/types/Plugins.ts";
const routes = [
let routes = [
{path: '/', component: () => import("@/pages/StartPage.vue"), name: 'StartPage'},
{path: '/search', redirect: {name: 'StartPage'}},
{path: '/test', component: () => import("@/pages/TestPage.vue"), name: 'view_test'},
@@ -53,8 +54,15 @@ const routes = [
{path: '/property-editor', component: () => import("@/pages/PropertyEditorPage.vue"), name: 'PropertyEditorPage'},
{path: '/space-setup', component: () => import("@/pages/SpaceSetupPage.vue"), name: 'SpaceSetupPage'},
{path: '/:pathMatch(.*)*', component: () => import("@/pages/404Page.vue"), name: '404Page'},
]
// load plugin routes into routing table
TANDOOR_PLUGINS.forEach(plugin => {
routes = routes.concat(plugin.routes)
})
const router = createRouter({
history: createWebHistory(),
routes,

View File

@@ -1,60 +0,0 @@
a {
color: #b98766;
text-decoration: none;
background-color: transparent
}
a:hover {
color: #fff;
text-decoration: none
}
a:not([href]):not([tabindex]), a:not([href]):not([tabindex]):focus, a:not([href]):not([tabindex]):hover {
color: inherit;
text-decoration: none
}
a:not([href]):not([tabindex]):focus {
outline: 0
}
/* Meal-Plan */
.cv-header {
background-color: #303030 !important;
}
.cv-weeknumber, .cv-header-day {
background-color: #303030 !important;
color: #fff !important;
}
.cv-day.past {
background-color: #333333 !important;
}
.cv-day.today {
background-color: var(--primary) !important;
}
.cv-day.outsideOfMonth {
background-color: #0d0d0d !important;
}
.cv-item {
background-color: #4E4E4E !important;
}
.d01 .cv-day-number {
background-color: #b98766!important;
}
/* vueform/multiselect */
.multiselect-dropdown {
background: #212121!important;
}
.multiselect-option.is-pointed {
background: #b98766!important;
}

View File

@@ -1,20 +0,0 @@
a {
color: #b98766;
text-decoration: none;
background-color: transparent
}
a:hover {
color: #000;
text-decoration: none
}
a:not([href]):not([tabindex]), a:not([href]):not([tabindex]):focus, a:not([href]):not([tabindex]):hover {
color: inherit;
text-decoration: none
}
a:not([href]):not([tabindex]):focus {
outline: 0
}

View File

@@ -27,10 +27,10 @@
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
<v-number-input v-model="servings" class="mt-3" control-variant="split" :label="$t('Servings')" :precision="2"></v-number-input>
<v-number-input v-model="servings" class="mt-3" control-variant="split" :label="$t('Servings')" :precision="2" :disabled="loading"></v-number-input>
</v-card-text>
<v-card-actions>
<v-btn class="float-right" prepend-icon="$create" color="create" @click="createShoppingListRecipe()">{{ $t('Add_to_Shopping') }}</v-btn>
<v-btn class="float-right" prepend-icon="$create" color="create" @click="createShoppingListRecipe()" :disabled="loading">{{ $t('Add_to_Shopping') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -40,15 +40,18 @@
import {computed, onMounted, PropType, ref} from "vue";
import VClosableCardTitle from "@/components/dialogs/VClosableCardTitle.vue";
import {ApiApi, Recipe, RecipeFlat, RecipeOverview, type ShoppingListEntryBulkCreate, ShoppingListRecipe} from "@/openapi";
import {ApiApi, MealPlan, Recipe, RecipeFlat, RecipeOverview, type ShoppingListEntryBulkCreate, ShoppingListRecipe} from "@/openapi";
import {ErrorMessageType, PreparedMessage, useMessageStore} from "@/stores/MessageStore";
import {ShoppingDialogRecipe, ShoppingDialogRecipeEntry} from "@/types/Shopping";
import {calculateFoodAmount} from "@/utils/number_utils";
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
import {ingredientToUnitString, ingredientToFoodString} from "@/utils/model_utils.ts";
const emit = defineEmits(['created'])
const props = defineProps({
recipe: {type: Object as PropType<Recipe | RecipeFlat | RecipeOverview>, required: true},
mealPlan: {type: Object as PropType<MealPlan>, required: false},
})
const dialog = ref(false)
@@ -75,6 +78,7 @@ onMounted(() => {
function loadRecipeData() {
let api = new ApiApi()
let promises: Promise<any>[] = []
loading.value = true
let recipeRequest = api.apiRecipeRetrieve({id: props.recipe.id!}).then(r => {
recipe.value = r
@@ -106,7 +110,7 @@ function loadRecipeData() {
recipe.steps.forEach(step => {
step.ingredients.forEach(ingredient => {
if(!ingredient.isHeader){
if (!ingredient.isHeader) {
dialogRecipe.entries.push({
amount: ingredient.amount,
food: ingredient.food,
@@ -136,6 +140,10 @@ function createShoppingListRecipe() {
servings: servings.value,
} as ShoppingListRecipe
if (props.mealPlan && props.mealPlan.id) {
shoppingListRecipe.mealplan = props.mealPlan.id!
}
let shoppingListEntries = {
entries: []
} as ShoppingListEntryBulkCreate
@@ -157,6 +165,7 @@ function createShoppingListRecipe() {
api.apiShoppingListRecipeBulkCreateEntriesCreate({id: slr.id!, shoppingListEntryBulkCreate: shoppingListEntries}).then(r => {
useMessageStore().addPreparedMessage(PreparedMessage.CREATE_SUCCESS)
dialog.value = false
emit('created')
}).catch(err => {
useMessageStore().addError(ErrorMessageType.CREATE_ERROR, err)
}).finally(() => {

View File

@@ -0,0 +1,38 @@
<template>
<v-dialog max-width="1200px" activator="parent" v-model="dialog">
<v-card>
<v-closable-card-title v-model="dialog" :title="$t('Help')" icon="fa-solid fa-question">
<template #content>
<div class="d-flex align-center">
<v-btn variant="text" icon="fa-solid fa-bars" @click.stop="drawer = !drawer"></v-btn>
<span>{{ $t('Help') }}</span>
</div>
</template>
</v-closable-card-title>
<v-divider></v-divider>
<v-card-text class="pa-0">
<help-view v-model="drawer"></help-view>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import VClosableCardTitle from "@/components/dialogs/VClosableCardTitle.vue";
import {ref} from "vue";
import HelpView from "@/components/display/HelpView.vue";
const dialog = ref(false)
const drawer = ref(true)
</script>
<style scoped>
</style>

View File

@@ -28,7 +28,7 @@ const props = defineProps({
closeAfterDelete: {default: true},
})
const editorComponent = shallowRef(defineAsyncComponent(() => import(`@/components/model_editors/${getGenericModelFromString(props.model, t).model.name}Editor.vue`)))
const editorComponent = shallowRef(getGenericModelFromString(props.model, t).model.editorComponent)
const dialog = defineModel<Boolean|undefined>({default: undefined})
const dialogActivator = (dialog.value !== undefined) ? undefined : props.activator
@@ -40,7 +40,7 @@ const editingObjChangedState = ref(false)
* because of this watch prop changes and update manually if prop is changed
*/
watch(() => props.model, () => {
editorComponent.value = defineAsyncComponent(() => import(`@/components/model_editors/${getGenericModelFromString(props.model, t).model.name}Editor.vue`))
editorComponent.value = getGenericModelFromString(props.model, t).model.editorComponent
})
/**

View File

@@ -6,7 +6,7 @@
<v-card-text class="pt-0 pr-4 pl-4">
<v-label>{{ $t('Choose_Category') }}</v-label>
<model-select model="SupermarketCategory" @update:modelValue="categoryUpdate"></model-select>
<model-select model="SupermarketCategory" @update:modelValue="categoryUpdate" allow-create></model-select>
<v-row>
<v-col class="pr-0">

View File

@@ -1,10 +1,12 @@
<template>
<v-card-title class="pb-0">
<v-row align="center">
<v-col cols="10" md="11" class="text-truncate">
<i :class="props.icon" v-if="props.icon != ''"></i>
{{ props.title }}
<v-card-subtitle class="pa-0" v-if="props.subTitle != ''">{{ props.subTitle }}</v-card-subtitle>
<v-card-title class="pb-1 pt-1 pl-1 pr-1">
<v-row no-gutters align="center">
<v-col cols="10" md="11" class="text-truncate pt-0 pb-0 pl-2">
<slot name="content">
<i :class="props.icon" v-if="props.icon != ''"></i>
{{ props.title }}
<v-card-subtitle class="pa-0" v-if="props.subTitle != ''">{{ props.subTitle }}</v-card-subtitle>
</slot>
</v-col>
<v-col cols="2" md="1">
<v-btn class="float-right pr-2" icon="$close" variant="plain" @click="model = false; emit('close')" v-if="!props.hideClose"></v-btn>

View File

@@ -0,0 +1,349 @@
<template>
<v-layout style="height: 70vh">
<v-navigation-drawer style="height: calc(100% + 0px)" v-model="drawer">
<v-list>
<!-- <v-list-item>-->
<!-- <v-text-field density="compact" variant="outlined" class="pt-2 pb-2" :label="$t('Search')" hide-details clearable></v-text-field>-->
<!-- </v-list-item>-->
<!-- <v-divider></v-divider>-->
<v-list-item link title="Start" @click="window = 'start'" prepend-icon="fa-solid fa-house"></v-list-item>
<v-list-item link title="Space" @click="window = 'space'" prepend-icon="fa-solid fa-database"></v-list-item>
<v-list-item link :title="$t('Recipes')" @click="window = 'recipes'" prepend-icon="$recipes"></v-list-item>
<v-list-item link :title="$t('Import')" @click="window = 'import'" prepend-icon="$import"></v-list-item>
<v-list-item link :title="$t('Unit')" @click="window = 'unit'" prepend-icon="fa-solid fa-scale-balanced"></v-list-item>
<v-list-item link :title="$t('Food')" @click="window = 'food'" prepend-icon="fa-solid fa-carrot"></v-list-item>
<v-list-item link :title="$t('Keyword')" @click="window = 'keyword'" prepend-icon="fa-solid fa-tags"></v-list-item>
<v-list-item link title="Recipe Structure" @click="window = 'recipe_structure'" prepend-icon="fa-solid fa-diagram-project"></v-list-item>
<v-list-item link :title="$t('Properties')" @click="window = 'properties'" prepend-icon="fa-solid fa-database"></v-list-item>
<v-list-item link :title="$t('Search')" @click="window = 'recipe_search'" prepend-icon="$search"></v-list-item>
<v-list-item link :title="$t('SavedSearch')" @click="window = 'search_filter'" prepend-icon="fa-solid fa-sd-card"></v-list-item>
<v-list-item link :title="$t('Books')" @click="window = 'books'" prepend-icon="$books"></v-list-item>
<v-list-item link :title="$t('Shopping')" @click="window = 'shopping'" prepend-icon="$shopping"></v-list-item>
<v-list-item link :title="$t('Meal_Plan')" @click="window = 'meal_plan'" prepend-icon="$mealplan"></v-list-item>
</v-list>
</v-navigation-drawer>
<v-main>
<v-container>
<v-window v-model="window">
<v-window-item value="start">
<h2>Welcome to Tandoor 2</h2>
<p class="mt-3">Tandoor is one of the most most powerful recipe management suits available. It has constantly been improved since its first
version in 2018.
This knowledgebase explains all important features and concepts. Explore it to find out how Tandoor can help you improve your daily cooking
routine or search
for specific features to help you understand them.</p>
<v-btn class="mt-2" color="primary" href="https://tandoor.dev" target="_blank" prepend-icon="fa-solid fa-globe">
Website
</v-btn>
<v-btn class="mt-2 ms-2" color="info" href="https://github.com/TandoorRecipes/recipes" target="_blank" prepend-icon="fa-solid fa-code-branch">GitHub
</v-btn>
<v-alert class="mt-3" border="start" variant="tonal" color="success">
<v-alert-title>Did you know?</v-alert-title>
Tandoor is Open Source and available to anyone for free to host on their own server. Thousands of hours have been spend
making Tandoor what it is today. You can help make Tandoor even better by contributing or helping financing the effort.
<br/>
<v-btn class="mt-2" color="secondary" href="https://docs.tandoor.dev/contribute/contribute/" target="_blank" prepend-icon="fa-solid fa-code-branch">
Contribute
</v-btn>
<v-btn class="mt-2 ms-2" color="success" href="https://github.com/sponsors/vabene1111" target="_blank" prepend-icon="fa-solid fa-dollar-sign">Sponsor
</v-btn>
</v-alert>
</v-window-item>
<v-window-item value="space">
<p class="mt-3">All your data is stored in a Space where you can invite other people to collaborate on your recipe database. Typcially the members of a space
belong to one family/household/organization.</p>
<p class="mt-3">While everyone can access all recipes by default, Books, Shopping Lists and Mealplans are not shared by default. You can share them with other
members of your space
using the settings.
</p>
<p class="mt-3">You can create and be a member of multiple spaces. Switch between them freely using the navigation or space settings. Depending
on the permission configured by the space owner you might not have access to all features of a space.</p>
<p class="mt-3"></p>
<v-btn color="primary" variant="tonal" prepend-icon="fa-solid fa-database" class="me-2" :to="{name: 'UserSpaceSettings'}">{{ $t('YourSpaces') }}</v-btn>
<v-btn color="primary" variant="tonal" prepend-icon="$settings" class="me-2" :to="{name: 'SpaceSettings'}">{{ $t('SpaceSettings') }}</v-btn>
<v-btn color="primary" variant="tonal" prepend-icon="fa-solid fa-users" class="me-2" :to="{name: 'SpaceMemberSettings'}">{{ $t('Invites') }}</v-btn>
</v-window-item>
<v-window-item value="recipes">
<p class="mt-3">Recipes are the foundation of your Tandoor space. A Recipe has one or more steps that contain ingredients, instructions and other information.
Ingredients in turn consist of an amount, a unit and a food, allowing recipes to be scaled, nutrition's to be calculated and shopping to be organized.
</p>
<p class="mt-3">Besides manually creating them you can also import them from various different places.
</p>
<p class="mt-3">Recipes, by default, are visible to all members of your space. Setting them to private means only you can see it. After setting it to private you
can manually specify the people who should be able to view the recipe.
You can also create a share link for the recipe to share it with everyone that has access to the link.
</p>
<p class="mt-3"></p>
<v-btn color="primary" variant="tonal" prepend-icon="$create" class="me-2" :to="{name: 'ModelEditPage', params: {model: 'Recipe'}}">{{ $t('Create') }}</v-btn>
<v-btn color="primary" variant="tonal" prepend-icon="$search" class="me-2" :to="{name: 'SearchPage'}">{{ $t('Search') }}</v-btn>
</v-window-item>
<v-window-item value="import">
<p class="mt-3">The Recipe importer is one of the most powerful features of Tandoor and allows you to quickly add recipes in multiple different ways.
</p>
<p class="mt-3">The easiest is to import from a URL. If that is not enough you can also import from an Image or PDF file using AI.
</p>
<p class="mt-3">If you already have an existing Recipe database in another format there is also a good chance Tandoor will have an
importer for that program.
</p>
<p class="mt-3"></p>
<v-btn color="primary" variant="tonal" prepend-icon="$import" class="me-2" :to="{name: 'RecipeImportPage'}">{{ $t('Import') }}</v-btn>
</v-window-item>
<v-window-item value="unit">
<p class="mt-3">Units allow you to measure how much of something you need in a recipe or on a shopping list.
They are also essential for the calculation of Properties.
</p>
<p class="mt-3">Setting a base unit allows you to name your Unit however you want (e.g. grams, g, G, gram) while allowing Tandoor
to automatically convert between the units in the same system (weight/volume, e.g. from g to kg or from cup to pint).
</p>
<p class="mt-3">Additionally you can use custom unit conversion to convert between volume and weight trough the specific density
of a food (e.g. 1 cup of flour = 120 g). These conversions are used to calculate the Properties for a Recipe
and might allow cosmetic display changes later.
</p>
<v-btn color="primary" variant="tonal" prepend-icon="fa-solid fa-scale-balanced" class="me-2" :to="{name: 'ModelListPage', params: {model: 'Unit'}}">
{{ $t('Unit') }}
</v-btn>
<v-btn color="primary" variant="tonal" prepend-icon="fa-solid fa-exchange-alt" class="me-2" :to="{name: 'ModelListPage', params: {model: 'UnitConversion'}}">
{{ $t('Conversion') }}
</v-btn>
</v-window-item>
<v-window-item value="food">
<p class="mt-3">Foods have multiple uses in Tandoor. Their most important task is to be part of recipe ingredients together with an amount and
a unit.
</p>
<p class="mt-3">Using the Food editor you can also add properties to a food or link the food to another recipe or external URL.
</p>
<p class="mt-3">Foods are also used or created when adding entries to the shopping list.
</p>
<v-btn color="primary" variant="tonal" prepend-icon="fa-solid fa-carrot" class="me-2" :to="{name: 'ModelListPage', params: {model: 'Food'}}">
{{ $t('Food') }}
</v-btn>
</v-window-item>
<v-window-item value="keyword">
<p class="mt-3">Keywords are a very flexible Tool to help you organize your recipe collection.
Keywords can quickly be created when editing a Recipe by just typing into the Keywords field or they can
be created trough the Keyword Editor.
</p>
<p class="mt-3">Typical keywords include meal types (breakfast, lunch, dinner, ...), couise (american, italian, ...) or diet (vegan, vegetarian, ..).
</p>
<p class="mt-3">Tip: Using Emojis in Keywords makes them easy to recognize.
</p>
<v-btn color="primary" variant="tonal" prepend-icon="fa-solid fa-tags" class="me-2" :to="{name: 'ModelListPage', params: {model: 'Keyword'}}">
{{ $t('Keyword') }}
</v-btn>
</v-window-item>
<v-window-item value="recipe_structure">
<p class="mt-3">A Recipe consists of multiple Steps.
</p>
<p class="mt-3">Each Step has Ingreditens (which are at least a Food but typically consist of amount,
Unit and Food). A Step can also contain instuctions, times, files or link to another Recipe.
</p>
<p class="mt-3">Additionally a Recipe can have Properties, Comments, Keywords and more.
</p>
<!-- TODO diagram -->
</v-window-item>
<v-window-item value="properties">
<p class="mt-3">The Properties system allows you to add additional data to your Foods and Recipes in the respective editors.
Most commonly you would use this to add nutrition facts but the system can also be used to track prices,
dietary points or any other kind of property.
</p>
<p class="mt-3">You first need to create the Property Types that you need (e.g. Carbohydrates, Sugar, Price, Points, ..).
Setting the FDC ID for a Property Type allows Tandoor to connect your custom Property Type to a property in the FDC database.
You can then go to a Food, set its FDC ID and Tandoor can automatically pull the properties you want from the FDC database.
</p>
<p class="mt-3">When adding a Property to the Recipe it will just be statically displayed in the Recipe view.
Adding properties to a Foods will allow Tandoor to calculate the properties for all the Ingredients in a Recipe based
on the Foods and their respective Units and Amounts.
</p>
<p class="mt-3">Food Properties are entered based on a certain amount of food (often 100 g). Unit Conversions allow Tandoor to
calculate the property amount if a Food is given in a different unit (e.g. 1kg or 1 cup).
</p>
<v-btn color="primary" variant="tonal" prepend-icon="fa-solid fa-database" class="me-2 mt-2 mb-2" :to="{name: 'ModelListPage', params: {model: 'PropertyType'}}">
{{ $t('Property') }}
</v-btn>
<h3>Editor</h3>
<p class="mt-3">Adding Properties manually to every food can be cumbersome. To make it easier you can import the Community curated
Open Data Database. If that is not enough you can open the Property Editor trough the context menu on your recipe.
</p>
<p class="mt-3">Here you can view all Foods in a Recipe and their respective properties. You can also quickly assign FDC ID's to both
Foods and Property Types and import the data from the FDC Database.
</p>
<h3>View</h3>
<p class="mt-3">
Properties are shown below every recipe as soon as you setup your first Property Types.
A small warning triangle is shown if there are missing values for one of the Foods in the recipe.
</p>
<p class="mt-3">
Clicking on the warning triangle allows you to see the individual property amounts of each food and where Properties
or Unit Conversions are missing.
</p>
</v-window-item>
<v-window-item value="recipe_search">
<p class="mt-3">There are two ways to search for Recipes.
</p>
<p class="mt-3">The global quick search can be opened from any page in Tandoor by pressing the search icon in the top right corner.
Here you can quickly search trough your recipes and open them.
</p>
<p class="mt-3">
If you need a bit more fine tuning for your search you can open the advances search and search for all kinds of different things like keywords,
foods or ratings.
</p>
<v-btn color="primary" variant="tonal" prepend-icon="$search" class="me-2" :to="{name: 'SearchPage', }">
{{ $t('Search') }}
</v-btn>
</v-window-item>
<v-window-item value="search_filter">
<p class="mt-3">Once you have performed an advanced search you can save the search filter to easily retrieve it later.
</p>
<p class="mt-3">
This is done by hitting the save button in the advanced search form. To load a search filter you simply select it from the
selection box and hit the load button.
</p>
<p class="mt-3">
You can use saved search filters to automatically create recipe books.
</p>
</v-window-item>
<v-window-item value="books">
<p class="mt-3">Books are a a way to structure and explore your recipe collection. They are similar to keywords but show you a bit more details when
looking trough them.
</p>
<p class="mt-3">After creating a new Book on the books page you can either add recipes manually or you can add a Saved Search Filter to automatically
load recipes into your book based on pre defined search criteria.
</p>
<v-btn color="primary" variant="tonal" prepend-icon="$books" class="me-2" :to="{name: 'BooksPage', }">
{{ $t('Books') }}
</v-btn>
</v-window-item>
<v-window-item value="shopping">
<p class="mt-3">
You can add inidivitual Foods (including non Food items of course) or whole recipes to your shopping list.
By default only you can see the entries you make, by going to the settings you can share them with other users and they can share them with you.
</p>
<p class="mt-3">
You can assign Supermarket Categories to your Foods, either trough the Food Editor or directly by clicking on a Shopping List Entry, to automatically sort the list
according to the Category Order defined in the Supermarket.
</p>
<p class="mt-3">
Each line in the shopping list can contain multiple entries of the same Food.
By clicking on a line you can open a dialog that allows you to see the details and perform various actions.
</p>
<v-list>
<v-list-item>Postpone: Hide the entry from the shopping list for a certain time (specified in the settings)</v-list-item>
<v-list-item>Ignore: Check this Food of the list and do not add it again when adding a recipe to the shopping list</v-list-item>
<v-list-item>Edit: Open the Food's Editor</v-list-item>
<v-list-item>Delete all: Delete all entries associated with this line.</v-list-item>
</v-list>
<p class="mt-3">
The Shopping list automatically syncronizes when multiple people have it open so you can shop with multiple devices.
</p>
<p class="mt-3">
Trough the menu you can also configure which information you want to be displayed or how the list should be sorted.
</p>
<v-btn color="primary" variant="tonal" prepend-icon="$shopping" class="me-2" :to="{name: 'ShoppingListPage', }">
{{ $t('Shopping') }}
</v-btn>
<v-btn color="primary" variant="tonal" prepend-icon="$settings" class="me-2" :to="{name: 'ShoppingSettings', }">
{{ $t('Settings') }}
</v-btn>
</v-window-item>
<v-window-item value="meal_plan">
<p class="mt-3">
To plan what you want to eat you can create a Meal Plan. Each Meal Plan consists of at least a title or a recipe, a date and a Meal Type.
Meal Plan entries a private by default and can either be shared individually or using a preset in the meal plan settings.
</p>
<p class="mt-3">
When selecting a Recipe in a Meal Plan you can automatically add its ingredients to the shopping list. You can also manually add more entries trough the
shopping tab in the Meal Plan editor. When deleting a Meal Plan all Shopping List Entries associated with that Meal Plan are deleted as well. When changing the
number of servings in a Meal Plan the Servings of the connected Recipe in the Shopping list are automatically changed as well.
</p>
<v-btn color="primary" variant="tonal" prepend-icon="$mealplan" class="me-2 mt-2" :to="{name: 'MealPlanPage', }">
{{ $t('Meal_Plan') }}
</v-btn>
<h3 class="mt-3">{{ $t('Meal_Type') }}</h3>
<p class="mt-3">
Meal Types allow you to categorize the different Meal Plan Entries.
</p>
<p class="mt-3">
You can also define a time that is used for sorting and calendar integration.
</p>
<v-btn color="primary" variant="tonal" prepend-icon="fa-solid fa-utensils" class="me-2 mt-2" :to="{name: 'ModelListPage', params: {model: 'MealType'}}">
{{ $t('Meal_Type') }}
</v-btn>
</v-window-item>
</v-window>
</v-container>
</v-main>
</v-layout>
</template>
<script setup lang="ts">
import {ref} from "vue";
const drawer = defineModel()
const window = ref('start')
</script>
<style scoped>
</style>

View File

@@ -82,7 +82,7 @@ const planItems = computed(() => {
startDate.setHours(hour, minutes, seconds)
endDate.setHours(hour, minutes, seconds)
}
console.log(startDate, endDate)
items.push({
startDate: startDate,
endDate: endDate,

View File

@@ -36,7 +36,7 @@
</v-card>
<v-dialog max-width="900px" v-model="dialog">
<v-card v-if="dialogProperty">
<v-card v-if="dialogProperty" :loading="loading">
<v-closable-card-title :title="`${dialogProperty.propertyAmountTotal} ${dialogProperty.unit} ${dialogProperty.name}`" :sub-title="$t('total')" icon="$properties"
v-model="dialog"></v-closable-card-title>
<v-card-text>
@@ -60,8 +60,11 @@
<model-edit-dialog model="UnitConversion" @create="refreshRecipe()"
:item-defaults="{baseAmount: 1, baseUnit: fv.missing_conversion.base_unit, convertedUnit: fv.missing_conversion.converted_unit, food: fv.food}"></model-edit-dialog>
</v-chip>
<v-chip color="warning" prepend-icon="$edit" class="cursor-pointer" :to="{name: 'ModelEditPage', params: {model: 'Recipe', id: recipe.id}}" v-else-if="fv.missing_unit">
{{ $t('NoUnit') }}
</v-chip>
<v-chip color="error" prepend-icon="$edit" class="cursor-pointer" v-else>
{{ $t('Edit') }}
{{ $t('MissingProperties') }}
<model-edit-dialog model="Food" :item-id="fv.food.id" @update:model-value="refreshRecipe()"></model-edit-dialog>
</v-chip>
</template>
@@ -182,6 +185,8 @@ const sourceSelectedToShow = ref<'recipe' | 'food'>("food")
const dialog = ref(false)
const dialogProperty = ref<undefined | PropertyWrapper>(undefined)
const loading = ref(false)
onMounted(() => {
if (!hasFoodProperties) {
sourceSelectedToShow.value = "recipe"
@@ -193,6 +198,7 @@ onMounted(() => {
*/
function refreshRecipe() {
let api = new ApiApi()
loading.value = true
api.apiRecipeRetrieve({id: recipe.value.id!}).then(r => {
recipe.value = r
@@ -205,6 +211,8 @@ function refreshRecipe() {
}
})
}
loading.value = false
})
}).catch(err => {
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)

View File

@@ -29,10 +29,6 @@ const image = computed(() => {
}
})
watch(() => props.recipe, () => {
console.log('changed')
})
</script>
<style scoped>

View File

@@ -25,7 +25,7 @@
<span class="ps-2 text-h5 flex-grow-1 pa-1" :class="{'text-truncate': !showFullRecipeName}" @click="showFullRecipeName = !showFullRecipeName">
{{ recipe.name }}
</span>
<recipe-context-menu :recipe="recipe"></recipe-context-menu>
<recipe-context-menu :recipe="recipe" v-if="useUserPreferenceStore().isAuthenticated"></recipe-context-menu>
</v-sheet>
<keywords-component variant="flat" class="ms-1 mb-2" :keywords="recipe.keywords"></keywords-component>
<v-sheet class="ps-2 text-disabled">
@@ -75,7 +75,7 @@
<v-card-text class="flex-grow-1">
<div class="d-flex">
<h1 class="flex-column flex-grow-1">{{ recipe.name }}</h1>
<recipe-context-menu :recipe="recipe" class="flex-column mb-auto mt-2 float-right"></recipe-context-menu>
<recipe-context-menu :recipe="recipe" v-if="useUserPreferenceStore().isAuthenticated" class="flex-column mb-auto mt-2 float-right"></recipe-context-menu>
</div>
<p>
{{ $t('created_by') }} {{ recipe.createdBy.displayName }} ({{ DateTime.fromJSDate(recipe.createdAt).toLocaleString(DateTime.DATE_SHORT) }})

View File

@@ -81,8 +81,8 @@
<v-container>
<v-row class="pa-0" dense>
<v-col class="pa-0">
<v-chip-group v-model="useUserPreferenceStore().deviceSettings.shopping_selected_supermarket" v-if="supermarkets.length > 0" >
<v-chip v-for="s in supermarkets" :value="s" :key="s.id" label density="compact" variant="outlined" color="primary">{{s.name}}</v-chip>
<v-chip-group v-model="useUserPreferenceStore().deviceSettings.shopping_selected_supermarket" v-if="supermarkets.length > 0">
<v-chip v-for="s in supermarkets" :value="s" :key="s.id" label density="compact" variant="outlined" color="primary">{{ s.name }}</v-chip>
</v-chip-group>
</v-col>
</v-row>
@@ -176,6 +176,15 @@
<v-card>
<v-card-title>{{ $t('Recipes') }} / {{ $t('Meal_Plan') }}</v-card-title>
<v-card-text>
<ModelSelect model="Recipe" v-model="manualAddRecipe" append-to-body>
<template #append>
<v-btn icon="$create" color="create" :disabled="manualAddRecipe == undefined">
<v-icon icon="$create"></v-icon>
<add-to-shopping-dialog :recipe="manualAddRecipe" v-if="manualAddRecipe != undefined" @created="useShoppingStore().refreshFromAPI(); manualAddRecipe = undefined"></add-to-shopping-dialog>
</v-btn>
</template>
</ModelSelect>
<v-list>
<v-list-item v-for="r in useShoppingStore().getAssociatedRecipes()">
<template #prepend>
@@ -208,6 +217,8 @@
</template>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
@@ -234,7 +245,7 @@
import {computed, onMounted, ref} from "vue";
import {useShoppingStore} from "@/stores/ShoppingStore";
import {ApiApi, ResponseError, ShoppingListEntry, ShoppingListRecipe, Supermarket} from "@/openapi";
import {ApiApi, Recipe, ResponseError, ShoppingListEntry, ShoppingListRecipe, Supermarket} from "@/openapi";
import {ErrorMessageType, PreparedMessage, useMessageStore} from "@/stores/MessageStore";
import ShoppingLineItem from "@/components/display/ShoppingLineItem.vue";
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
@@ -250,11 +261,13 @@ import ModelEditDialog from "@/components/dialogs/ModelEditDialog.vue";
import {onBeforeRouteLeave} from "vue-router";
import {isShoppingCategoryVisible} from "@/utils/logic_utils.ts";
import ShoppingExportDialog from "@/components/dialogs/ShoppingExportDialog.vue";
import AddToShoppingDialog from "@/components/dialogs/AddToShoppingDialog.vue";
const {t} = useI18n()
const currentTab = ref("shopping")
const supermarkets = ref([] as Supermarket[])
const manualAddRecipe = ref<undefined | Recipe>(undefined)
/**
* VSelect items for shopping list grouping options with localized names
@@ -351,7 +364,7 @@ function deleteListRecipe(slr: ShoppingListRecipe) {
/**
* load a list of supermarkets
*/
function loadSupermarkets(){
function loadSupermarkets() {
let api = new ApiApi()
api.apiSupermarketList().then(r => {

View File

@@ -0,0 +1,39 @@
<template>
<v-select :label="$t('BaseUnit')" :hint="$t('BaseUnitHelp')" :items="BASE_UNITS" v-model="model"></v-select>
</template>
<script setup lang="ts">
import {useI18n} from "vue-i18n";
const {t} = useI18n()
const model = defineModel()
const BASE_UNITS = [
{value: "g", title: t("g")},
{value: "kg", title: t("kg")},
{value: "ounce", title: t("ounce")},
{value: "pound", title: t("pound")},
{value: "ml", title: t("ml")},
{value: "l", title: t("l")},
{value: "fluid_ounce", title: t("fluid_ounce")},
{value: "us_cup", title: t("us_cup")},
{value: "pint", title: t("pint")},
{value: "quart", title: t("quart")},
{value: "gallon", title: t("gallon")},
{value: "tbsp", title: t("tbsp")},
{value: "tsp", title: t("tsp")},
{value: "imperial_fluid_ounce", title: t("imperial_fluid_ounce")},
{value: "imperial_pint", title: t("imperial_pint")},
{value: "imperial_quart", title: t("imperial_quart")},
{value: "imperial_gallon", title: t("imperial_gallon")},
{value: "imperial_tbsp", title: t("imperial_tbsp")},
{value: "imperial_tsp", title: t("imperial_tsp")},
]
</script>
<style scoped>
</style>

View File

@@ -1,7 +1,7 @@
<template>
<!-- TODO label is not showing for some reason, for now in placeholder -->
<v-label class="mt-2" v-if="props.label" >{{props.label}}</v-label>
<v-label class="mt-2" v-if="props.label">{{ props.label }}</v-label>
<v-input :hint="props.hint" persistent-hint :label="props.label" :hide-details="props.hideDetails">
<template #prepend v-if="$slots.prepend">
<slot name="prepend"></slot>
@@ -92,7 +92,7 @@ const props = defineProps({
mode: {type: String as PropType<'single' | 'multiple' | 'tags'>, default: 'single'},
appendToBody: {type: Boolean, default: false},
object: {type: Boolean, default: true},
object: {type: Boolean, default: true}, // TODO broken either fix or finally get other multiselect working
allowCreate: {type: Boolean, default: false},
placeholder: {type: String, default: undefined},
@@ -150,9 +150,7 @@ function search(query: string) {
loading.value = true
return modelClass.value.list({query: query, page: 1, pageSize: props.limit}).then((r: any) => {
if (modelClass.value.model.isPaginated) {
if (r.next) {
hasMoreItems.value = true
}
hasMoreItems.value = !!r.next
return r.results
} else {
hasMoreItems.value = false

View File

@@ -5,7 +5,7 @@
</v-btn-group>
<v-row class="d-none d-md-flex mt-2" v-for="p in properties" dense>
<v-col cols="0" md="5">
<v-col cols="0" md="6">
<v-number-input :step="10" v-model="p.propertyAmount" control-variant="stacked" :precision="2">
<template #append-inner v-if="p.propertyType">
<v-chip class="me-4">{{ p.propertyType.unit }} / {{ props.amountFor }}
@@ -14,13 +14,13 @@
</v-number-input>
</v-col>
<v-col cols="0" md="6">
<!-- TODO fix card overflow invisible, overflow-visible class is not working -->
<model-select :label="$t('Property')" v-model="p.propertyType" model="PropertyType"></model-select>
</v-col>
<v-col cols="0" md="1">
<v-btn color="delete" @click="deleteProperty(p)">
<v-icon icon="$delete"></v-icon>
</v-btn>
<model-select v-model="p.propertyType" model="PropertyType">
<template #append>
<v-btn color="delete" icon @click="deleteProperty(p)">
<v-icon icon="$delete"></v-icon>
</v-btn>
</template>
</model-select>
</v-col>
</v-row>
<v-list class="d-md-none">

View File

@@ -47,10 +47,10 @@
<v-number-input :label="$t('Time')" v-model="step.time" :min="0" :step="5" control-variant="split"></v-number-input>
</v-col>
<v-col cols="12" md="6" v-if="showRecipe || step.stepRecipe != null">
<model-select model="Recipe" v-model="step.stepRecipe" :object="false" append-to-body></model-select>
<model-select model="Recipe" v-model="step.stepRecipeData" @update:modelValue="step.stepRecipe = (step.stepRecipeData != null) ? step.stepRecipeData.id! : null"></model-select>
</v-col>
<v-col cols="12" md="6" v-if="showFile || step.file != null">
<model-select model="UserFile" v-model="step.file" append-to-body></model-select>
<model-select model="UserFile" v-model="step.file"></model-select>
</v-col>
</v-row>

View File

@@ -30,8 +30,8 @@
<script setup lang="ts">
import {VDateInput} from 'vuetify/labs/VDateInput' //TODO remove once component is out of labs
import {onMounted, PropType} from "vue";
import {AccessToken} from "@/openapi";
import {onMounted, PropType, watch} from "vue";
import {AccessToken, ApiApi} from "@/openapi";
import {DateTime} from "luxon";
import BtnCopy from "@/components/buttons/BtnCopy.vue";
@@ -49,8 +49,22 @@ const props = defineProps({
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<AccessToken>('AccessToken', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
onMounted(() => {
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
setupState(props.item, props.itemId, {
newItemFunction: () => {
editingObj.value.expires = DateTime.now().plus({year: 1}).toJSDate()
@@ -58,7 +72,7 @@ onMounted(() => {
},
itemDefaults: props.itemDefaults
})
})
}
</script>

View File

@@ -22,7 +22,7 @@
<v-number-input :label="$t('Order')" :step="10" v-model="editingObj.order" :hint="$t('OrderInformation')" control-variant="stacked"></v-number-input>
<v-checkbox :label="$t('Disabled')" v-model="editingObj.disabled"></v-checkbox>
<a href="https://docs.tandoor.dev/features/automation/" target="_blank">{{$t('Learn_More')}}</a>
<a href="https://docs.tandoor.dev/features/automation/" target="_blank">{{ $t('Learn_More') }}</a>
</v-form>
</v-card-text>
</model-editor-base>
@@ -31,7 +31,7 @@
<script setup lang="ts">
import {onMounted, PropType} from "vue";
import {onMounted, PropType, watch} from "vue";
import {Automation} from "@/openapi";
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
@@ -47,7 +47,26 @@ const props = defineProps({
})
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<Automation>('Automation', emit)
const {
setupState,
deleteObject,
saveObject,
isUpdate,
editingObjName,
loading,
editingObj,
editingObjChanged,
modelClass,
applyItemDefaults
} = useModelEditorFunctions<Automation>('Automation', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
@@ -65,13 +84,22 @@ const AUTOMATION_TYPES = [
]
onMounted(() => {
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
setupState(props.item, props.itemId, {
newItemFunction: () => {
editingObj.value.order = 0
applyItemDefaults(props.itemDefaults)
},
itemDefaults: props.itemDefaults
})
})
}
</script>

View File

@@ -36,7 +36,7 @@
<script setup lang="ts">
import {onMounted, PropType} from "vue";
import {onMounted, PropType, watch} from "vue";
import {ConnectorConfig} from "@/openapi";
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
@@ -61,12 +61,27 @@ const {
modelClass
} = useModelEditorFunctions<ConnectorConfig>('ConnectorConfig', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
onMounted(() => {
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
}
</script>
<style scoped>

View File

@@ -19,7 +19,7 @@
<script setup lang="ts">
import {onMounted, PropType} from "vue";
import {onMounted, PropType, watch} from "vue";
import {CustomFilter} from "@/openapi";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
@@ -35,11 +35,27 @@ const props = defineProps({
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<CustomFilter>('CustomFilter', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
onMounted(() => {
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
}
</script>
<style scoped>

View File

@@ -34,6 +34,19 @@
<v-tabs-window-item value="properties">
<v-alert icon="$help">{{ $t('PropertiesFoodHelp') }}</v-alert>
<v-form :disabled="loading" class="mt-5">
<v-number-input :label="$t('FDC_ID')" v-model="editingObj.fdcId" :precision="0" control-variant="hidden" clearable>
<template #append-inner>
<v-btn icon="$search" size="small" density="compact" variant="plain" v-if="editingObj.fdcId == undefined"
@click="fdcDialog = true"></v-btn>
<v-btn @click="updateFoodFdcData()" icon="fa-solid fa-arrows-rotate" size="small" density="compact" variant="plain"
v-if="editingObj.fdcId"></v-btn>
<v-btn @click="openFdcPage(editingObj.fdcId)" :href="`https://fdc.nal.usda.gov/food-details/${editingObj.fdcId}/nutrients`" target="_blank"
icon="fa-solid fa-arrow-up-right-from-square"
size="small" variant="plain" v-if="editingObj.fdcId"></v-btn>
</template>
</v-number-input>
<v-number-input :label="$t('Properties_Food_Amount')" v-model="editingObj.propertiesFoodAmount" :precision="2"></v-number-input>
<model-select :label="$t('Properties_Food_Unit')" v-model="editingObj.propertiesFoodUnit" model="Unit"></model-select>
@@ -61,22 +74,27 @@
</v-btn>
</v-card-title>
<v-card-text class="d-none d-md-block">
<v-row>
<v-row dense>
<v-col md="6">
<v-number-input :label="$t('Amount')" :step="10" v-model="uc.baseAmount" control-variant="stacked" :precision="3"></v-number-input>
<v-number-input :label="$t('Amount')" :step="10" v-model="uc.baseAmount" control-variant="stacked" :precision="3" hide-details></v-number-input>
</v-col>
<v-col md="6">
<!-- TODO fix card overflow invisible, overflow-visible class is not working -->
<model-select :label="$t('Unit')" v-model="uc.baseUnit" model="Unit"></model-select>
<model-select v-model="uc.baseUnit" model="Unit" hide-details></model-select>
</v-col>
</v-row>
<v-row>
<v-row dense>
<v-col cols="12" class="text-center">
<v-icon icon="fa-solid fa-arrows-up-down" class="mt-4 mb-4"></v-icon>
</v-col>
</v-row>
<v-row dense>
<v-col md="6">
<v-number-input :label="$t('Amount')" :step="10" v-model="uc.convertedAmount" control-variant="stacked" :precision="3"></v-number-input>
</v-col>
<v-col md="6">
<!-- TODO fix card overflow invisible, overflow-visible class is not working -->
<model-select :label="$t('Unit')" v-model="uc.convertedUnit" model="Unit"></model-select>
<model-select v-model="uc.convertedUnit" model="Unit"></model-select>
</v-col>
</v-row>
</v-card-text>
@@ -110,6 +128,8 @@
</v-card-text>
<fdc-search-dialog v-model="fdcDialog"
@selected="(fdcId:number) => {editingObj.fdcId = fdcId;}"></fdc-search-dialog>
</model-editor-base>
@@ -118,7 +138,7 @@
<script setup lang="ts">
import {computed, onMounted, PropType, ref, watch} from "vue";
import {ApiApi, Food, Unit, UnitConversion} from "@/openapi";
import {ApiApi, Food, Unit, UnitConversion} from "@/openapi";
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
import ModelSelect from "@/components/inputs/ModelSelect.vue";
import ModelEditDialog from "@/components/dialogs/ModelEditDialog.vue";
@@ -126,6 +146,9 @@ import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
import PropertiesEditor from "@/components/inputs/PropertiesEditor.vue";
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
import FdcSearchDialog from "@/components/dialogs/FdcSearchDialog.vue";
import {openFdcPage} from "@/utils/fdc.ts";
import {DateTime} from "luxon";
const props = defineProps({
@@ -138,6 +161,13 @@ const props = defineProps({
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<Food>('Food', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
@@ -147,10 +177,10 @@ const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading,
*/
const propertiesAmountFor = computed(() => {
let amountFor = ''
if(editingObj.value.propertiesFoodAmount){
if (editingObj.value.propertiesFoodAmount) {
amountFor += editingObj.value.propertiesFoodAmount
}
if(editingObj.value.propertiesFoodUnit){
if (editingObj.value.propertiesFoodUnit) {
amountFor += " " + editingObj.value.propertiesFoodUnit.name
}
return amountFor
@@ -160,6 +190,8 @@ const tab = ref("food")
const unitConversions = ref([] as UnitConversion[])
const fdcDialog = ref(false)
// load conversions the first time the conversions tab is opened
const stopConversionsWatcher = watch(tab, (value, oldValue, onCleanup) => {
if (value == 'conversions') {
@@ -168,8 +200,14 @@ const stopConversionsWatcher = watch(tab, (value, oldValue, onCleanup) => {
}
})
onMounted(() => {
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
setupState(props.item, props.itemId, {
newItemFunction: () => {
editingObj.value.propertiesFoodAmount = 100
@@ -177,7 +215,7 @@ onMounted(() => {
},
itemDefaults: props.itemDefaults,
})
})
}
/**
@@ -229,6 +267,27 @@ function deleteUnitConversion(unitConversion: UnitConversion, database = false)
}
}
/**
* Update the food FDC data on the server and update the editing object
*/
function updateFoodFdcData() {
let api = new ApiApi()
if (editingObj.value.fdcId) {
saveObject().then(() => {
loading.value = true
api.apiFoodFdcCreate({id: editingObj.value.id!, food: editingObj.value}).then(r => {
editingObj.value = r
}).catch(err => {
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
}).finally(() => {
loading.value = false
editingObjChanged.value = false
})
})
}
}
</script>
<style scoped>

View File

@@ -25,7 +25,7 @@
<script setup lang="ts">
import {VDateInput} from 'vuetify/labs/VDateInput' //TODO remove once component is out of labs
import {onMounted, PropType, ref} from "vue";
import {onMounted, PropType, ref, watch} from "vue";
import {ApiApi, Group, InviteLink} from "@/openapi";
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
import {DateTime} from "luxon";
@@ -43,11 +43,26 @@ const props = defineProps({
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<InviteLink>('InviteLink', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
const groups = ref([] as Group[])
onMounted(() => {
const api = new ApiApi()
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
const api = new ApiApi()
api.apiGroupList().then(r => {
groups.value = r
@@ -63,8 +78,7 @@ onMounted(() => {
}).catch(err => {
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
})
})
}
</script>
<style scoped>

View File

@@ -23,7 +23,7 @@
<script setup lang="ts">
import {onMounted, PropType} from "vue";
import {onMounted, PropType, watch} from "vue";
import {Keyword} from "@/openapi";
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
@@ -38,12 +38,27 @@ const props = defineProps({
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<Keyword>('Keyword', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
onMounted(() => {
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
}
</script>
<style scoped>

View File

@@ -23,6 +23,19 @@
<v-form :disabled="loading">
<v-row>
<v-col cols="12" md="6">
<ModelSelect model="Recipe" v-model="editingObj.recipe"
@update:modelValue="editingObj.servings = editingObj.recipe ? editingObj.recipe.servings : 1"></ModelSelect>
<!-- <v-number-input label="Days" control-variant="split" :min="1"></v-number-input>-->
<!--TODO create days input with +/- synced to date -->
<recipe-card :recipe="editingObj.recipe" v-if="editingObj && editingObj.recipe"></recipe-card>
<v-btn prepend-icon="$shopping" color="create" class="mt-1" v-if="!editingObj.shopping && editingObj.recipe && isUpdate()">
{{$t('Add')}}
<add-to-shopping-dialog :recipe="editingObj.recipe" :meal-plan="editingObj" @created="loadShoppingListEntries(); editingObj.shopping = true;"></add-to-shopping-dialog>
</v-btn>
<v-checkbox :label="$t('AddToShopping')" v-model="editingObj.addshopping" hide-details v-if="editingObj.recipe && !isUpdate()"></v-checkbox>
</v-col>
<v-col cols="12" md="6">
<v-text-field :label="$t('Title')" v-model="editingObj.title"></v-text-field>
<v-date-input
@@ -51,16 +64,7 @@
<v-number-input control-variant="split" :min="0" v-model="editingObj.servings" :label="$t('Servings')" :precision="2"></v-number-input>
<ModelSelect model="User" :allow-create="false" v-model="editingObj.shared" item-label="displayName" mode="tags"></ModelSelect>
</v-col>
<v-col cols="12" md="6">
<ModelSelect model="Recipe" v-model="editingObj.recipe"
@update:modelValue="editingObj.servings = editingObj.recipe ? editingObj.recipe.servings : 1"></ModelSelect>
<!-- <v-number-input label="Days" control-variant="split" :min="1"></v-number-input>-->
<!--TODO create days input with +/- synced to date -->
<recipe-card :recipe="editingObj.recipe" v-if="editingObj && editingObj.recipe"></recipe-card>
<v-checkbox :label="$t('AddToShopping')" v-model="editingObj.addshopping" hide-details v-if="editingObj.recipe && !isUpdate()"></v-checkbox>
<!-- TODO review shopping before add -->
</v-col>
</v-row>
<v-row dense>
<v-col cols="12">
@@ -96,7 +100,7 @@
<script setup lang="ts">
import {nextTick, onMounted, PropType, ref, toRaw} from "vue";
import {nextTick, onMounted, PropType, ref, toRaw, watch} from "vue";
import {ApiApi, MealPlan, MealType, ShoppingListRecipe} from "@/openapi";
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
@@ -112,6 +116,7 @@ import {useShoppingStore} from "@/stores/ShoppingStore";
import ShoppingListEntryInput from "@/components/inputs/ShoppingListEntryInput.vue";
import ClosableHelpAlert from "@/components/display/ClosableHelpAlert.vue";
import {useMealPlanStore} from "@/stores/MealPlanStore";
import AddToShoppingDialog from "@/components/dialogs/AddToShoppingDialog.vue";
const props = defineProps({
item: {type: {} as PropType<MealPlan>, required: false, default: null},
@@ -134,12 +139,27 @@ const {
modelClass
} = useModelEditorFunctions<MealPlan>('MealPlan', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
const tab = ref('plan')
const dateRangeValue = ref([] as Date[])
onMounted(() => {
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
const api = new ApiApi()
// load meal types and create new object based on default type when initially loading
@@ -185,7 +205,7 @@ onMounted(() => {
}
},)
})
})
}
/**
* update the editing object with data from the date range selector whenever its changed (could probably be a watcher)

View File

@@ -41,13 +41,10 @@
<script setup lang="ts">
import {onMounted, PropType, ref} from "vue";
import {onMounted, PropType, ref, watch} from "vue";
import {MealType} from "@/openapi";
import {VTimePicker} from 'vuetify/labs/VTimePicker'; // TODO remove once out of labs
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
import {DateTime} from "luxon";
const props = defineProps({
item: {type: {} as PropType<MealType>, required: false, default: null},
@@ -59,14 +56,29 @@ const props = defineProps({
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<MealType>('MealType', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
const timePickerMenu = ref(false)
onMounted(() => {
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
}
</script>
<style scoped>

View File

@@ -25,7 +25,7 @@
<script setup lang="ts">
import {onMounted, PropType} from "vue";
import {onMounted, PropType, watch} from "vue";
import {Property} from "@/openapi";
import ModelSelect from "@/components/inputs/ModelSelect.vue";
@@ -44,11 +44,27 @@ const props = defineProps({
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<Property>('Property', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
onMounted(() => {
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
}
</script>

View File

@@ -27,7 +27,7 @@
<script setup lang="ts">
import {onMounted, PropType, ref} from "vue";
import {onMounted, PropType, ref, watch} from "vue";
import {PropertyType} from "@/openapi";
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
@@ -53,12 +53,27 @@ const {
modelClass
} = useModelEditorFunctions<PropertyType>('PropertyType', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
onMounted(() => {
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
}
</script>
<style scoped>

View File

@@ -58,7 +58,7 @@
<script setup lang="ts">
import {onMounted, PropType, ref} from "vue";
import {onMounted, PropType, ref, watch} from "vue";
import {ApiApi, Recipe, RecipeBook, RecipeBookEntry, User} from "@/openapi";
import {VDataTableUpdateOptions} from "@/vuetify";
@@ -79,6 +79,14 @@ const props = defineProps({
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<RecipeBook>('RecipeBook', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
const {t} = useI18n()
const tab = ref("book")
const recipeBookEntries = ref([] as RecipeBookEntry[])
@@ -94,6 +102,13 @@ const tableHeaders = [
]
onMounted(() => {
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor() {
setupState(props.item, props.itemId, {
newItemFunction: () => {
editingObj.value.shared = [] as User[]
@@ -104,7 +119,7 @@ onMounted(() => {
},
itemDefaults: props.itemDefaults
})
})
}
/**
* add selected recipe into the book and client list
@@ -116,12 +131,12 @@ function addRecipeToBook() {
let duplicateFound = false
recipeBookEntries.value.forEach(rBE => {
if (rBE.recipe == selectedRecipe.value.id){
if (rBE.recipe == selectedRecipe.value.id) {
duplicateFound = true
}
})
if (!duplicateFound){
if (!duplicateFound) {
api.apiRecipeBookEntryCreate({recipeBookEntry: {book: editingObj.value.id!, recipe: selectedRecipe.value.id!}}).then(r => {
recipeBookEntries.value.push(r)
selectedRecipe.value = {} as Recipe

View File

@@ -137,7 +137,7 @@
<script setup lang="ts">
import {onMounted, PropType, ref, shallowRef} from "vue";
import {onMounted, PropType, ref, shallowRef, watch} from "vue";
import {Ingredient, Recipe, Step} from "@/openapi";
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
@@ -164,6 +164,14 @@ const props = defineProps({
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<Recipe>('Recipe', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
const {mobile} = useDisplay()
@@ -174,6 +182,13 @@ const {fileApiLoading, updateRecipeImage} = useFileApi()
const file = shallowRef<File | null>(null)
onMounted(() => {
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
setupState(props.item, props.itemId, {
newItemFunction: () => {
editingObj.value.steps = [] as Step[]
@@ -181,7 +196,7 @@ onMounted(() => {
},
itemDefaults: props.itemDefaults,
})
})
}
/**
* save recipe via normal saveMethod and update image afterward if it was changed

View File

@@ -33,7 +33,7 @@
<script setup lang="ts">
import {onMounted, PropType} from "vue";
import {onMounted, PropType, watch} from "vue";
import {ShoppingListEntry} from "@/openapi";
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
@@ -49,12 +49,27 @@ const props = defineProps({
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<ShoppingListEntry>('ShoppingListEntry', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
onMounted(() => {
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
}
</script>
<style scoped>

View File

@@ -32,7 +32,7 @@
<script setup lang="ts">
import {onMounted, PropType} from "vue";
import {onMounted, PropType, watch} from "vue";
import { Storage } from "@/openapi";
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
@@ -47,12 +47,27 @@ const props = defineProps({
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<Storage>('Storage', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
onMounted(() => {
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
}
</script>
<style scoped>

View File

@@ -24,7 +24,7 @@
<script setup lang="ts">
import {onMounted, PropType} from "vue";
import {onMounted, PropType, watch} from "vue";
import {SupermarketCategory} from "@/openapi";
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
@@ -40,12 +40,27 @@ const props = defineProps({
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<SupermarketCategory>('SupermarketCategory', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
onMounted(() => {
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
}
</script>
<style scoped>

View File

@@ -103,7 +103,7 @@
<script setup lang="ts">
import {computed, onMounted, PropType, ref} from "vue";
import {computed, onMounted, PropType, ref, watch} from "vue";
import {ApiApi, Supermarket, SupermarketCategory, SupermarketCategoryRelation} from "@/openapi";
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
@@ -121,6 +121,14 @@ const props = defineProps({
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<Supermarket>('Supermarket', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
const tab = ref("supermarket")
@@ -148,6 +156,13 @@ const unusedSupermarketCategories = computed(() => {
})
onMounted(() => {
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
const api = new ApiApi()
api.apiSupermarketCategoryList({pageSize: 100}).then(r => {
@@ -161,7 +176,7 @@ onMounted(() => {
itemDefaults: props.itemDefaults,
})
})
})
}
/**
* called whenever something in the list is moved to track the last moved element (to be used in add/remove functions)

View File

@@ -26,7 +26,7 @@
<script setup lang="ts">
import {onMounted, PropType} from "vue";
import {onMounted, PropType, watch} from "vue";
import { Sync} from "@/openapi";
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
@@ -42,12 +42,27 @@ const props = defineProps({
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<Sync>('Sync', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
onMounted(() => {
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
}
</script>
<style scoped>

View File

@@ -39,6 +39,11 @@
<model-select :label="$t('Unit')" v-model="editingObj.convertedUnit" model="Unit"></model-select>
</v-col>
</v-row>
<v-row>
<v-col>
<v-text-field :label="$t('Open_Data_Slug')" :hint="$t('open_data_help_text')" persistent-hint v-model="editingObj.openDataSlug" disabled></v-text-field>
</v-col>
</v-row>
</v-form>
</v-card-text>
</model-editor-base>
@@ -47,7 +52,7 @@
<script setup lang="ts">
import {onMounted, PropType} from "vue";
import {onMounted, PropType, watch} from "vue";
import {UnitConversion} from "@/openapi";
import ModelSelect from "@/components/inputs/ModelSelect.vue";
@@ -64,11 +69,27 @@ const props = defineProps({
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<UnitConversion>('UnitConversion', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
onMounted(() => {
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
}
</script>
<style scoped>

View File

@@ -14,7 +14,7 @@
<v-text-field :label="$t('Name')" v-model="editingObj.name"></v-text-field>
<v-text-field :label="$t('Plural')" v-model="editingObj.pluralName"></v-text-field>
<v-textarea :label="$t('Description')" v-model="editingObj.description"></v-textarea>
<v-select :label="$t('BaseUnit')" :hint="$t('BaseUnitHelp')" :items="BASE_UNITS" v-model="editingObj.baseUnit"></v-select>
<base-unit-select v-model="editingObj.baseUnit"></base-unit-select>
<v-text-field :label="$t('Open_Data_Slug')" :hint="$t('open_data_help_text')" persistent-hint v-model="editingObj.openDataSlug" disabled></v-text-field>
</v-form>
</v-card-text>
@@ -24,11 +24,12 @@
<script setup lang="ts">
import {onMounted, PropType} from "vue";
import {onMounted, PropType, watch} from "vue";
import {Unit} from "@/openapi";
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
import {useI18n} from "vue-i18n";
import BaseUnitSelect from "@/components/inputs/BaseUnitSelect.vue";
const {t} = useI18n()
@@ -41,35 +42,29 @@ const props = defineProps({
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<Unit>('Unit', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
const BASE_UNITS = [
{value: "g", title: t("g")},
{value: "kg", title: t("kg")},
{value: "ounce", title: t("ounce")},
{value: "pound", title: t("pound")},
{value: "ml", title: t("ml")},
{value: "l", title: t("l")},
{value: "fluid_ounce", title: t("fluid_ounce")},
{value: "us_cup", title: t("us_cup")},
{value: "pint", title: t("pint")},
{value: "quart", title: t("quart")},
{value: "gallon", title: t("gallon")},
{value: "tbsp", title: t("tbsp")},
{value: "tsp", title: t("tsp")},
{value: "imperial_fluid_ounce", title: t("imperial_fluid_ounce")},
{value: "imperial_pint", title: t("imperial_pint")},
{value: "imperial_quart", title: t("imperial_quart")},
{value: "imperial_gallon", title: t("imperial_gallon")},
{value: "imperial_tbsp", title: t("imperial_tbsp")},
{value: "imperial_tsp", title: t("imperial_tsp")},
]
onMounted(() => {
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
}
</script>
<style scoped>

View File

@@ -47,7 +47,7 @@
<script setup lang="ts">
import {onMounted, PropType, shallowRef} from "vue";
import {onMounted, PropType, shallowRef, watch} from "vue";
import {UserFile, UserSpace} from "@/openapi";
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
@@ -66,15 +66,30 @@ const props = defineProps({
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<UserFile>('UserFile', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
const {fileApiLoading, createOrUpdateUserFile} = useFileApi()
const file = shallowRef<File | null>(null)
onMounted(() => {
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
}
/**
* save file to database via fileApi composable
*/

View File

@@ -21,7 +21,7 @@
<script setup lang="ts">
import {onMounted, PropType, ref} from "vue";
import {onMounted, PropType, ref, watch} from "vue";
import {ApiApi, Group, UserSpace} from "@/openapi";
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
@@ -39,10 +39,25 @@ const props = defineProps({
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<UserSpace>('UserSpace', emit)
/**
* watch prop changes and re-initialize editor
* required to embed editor directly into pages and be able to change item from the outside
*/
watch([() => props.item, () => props.itemId], () => {
initializeEditor()
})
// object specific data (for selects/display)
const groups = ref([] as Group[])
onMounted(() => {
initializeEditor()
})
/**
* component specific state setup logic
*/
function initializeEditor(){
const api = new ApiApi()
api.apiGroupList().then(r => {
groups.value = r
@@ -51,8 +66,7 @@ onMounted(() => {
})
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
})
}
</script>

View File

@@ -0,0 +1,94 @@
import {useI18n} from "vue-i18n";
import {VDivider, VListItem} from "vuetify/components";
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
import {useDjangoUrls} from "@/composables/useDjangoUrls.ts";
import {TANDOOR_PLUGINS} from "@/types/Plugins.ts";
import {plugin} from "@/plugins/open_data_plugin/plugin.ts";
/**
* manages configuration and loading of navigation entries for tandoor main app and plugins
*/
export function useNavigation() {
const {t} = useI18n()
function getNavigationDrawer() {
let navigation = [
{component: VListItem, prependIcon: '$recipes', title: 'Home', to: {name: 'StartPage', params: {}}},
{component: VListItem, prependIcon: '$search', title: t('Search'), to: {name: 'SearchPage', params: {}}},
{component: VListItem, prependIcon: '$mealplan', title: t('Meal_Plan'), to: {name: 'MealPlanPage', params: {}}},
{component: VListItem, prependIcon: '$shopping', title: t('Shopping_list'), to: {name: 'ShoppingListPage', params: {}}},
{component: VListItem, prependIcon: 'fas fa-globe', title: t('Import'), to: {name: 'RecipeImportPage', params: {}}},
{component: VListItem, prependIcon: '$books', title: t('Books'), to: {name: 'BooksPage', params: {}}},
{component: VListItem, prependIcon: 'fa-solid fa-folder-tree', title: t('Database'), to: {name: 'DatabasePage', params: {}}},
]
TANDOOR_PLUGINS.forEach(plugin => {
plugin.navigationDrawer.forEach(navEntry => {
let navEntryCopy = Object.assign({}, navEntry)
navEntryCopy.title = t(navEntryCopy.title)
navigation.push(navEntryCopy)
})
})
return navigation
}
function getBottomNavigation() {
let navigation = [
{component: VListItem, prependIcon: 'fa-solid fa-sliders', title: t('Settings'), to: {name: 'SettingsPage', params: {}}},
{component: VListItem, prependIcon: 'fas fa-globe', title: t('Import'), to: {name: 'RecipeImportPage', params: {}}},
{component: VListItem, prependIcon: 'fa-solid fa-folder-tree', title: t('Database'), to: {name: 'DatabasePage', params: {}}},
{component: VListItem, prependIcon: '$books', title: t('Books'), to: {name: 'BooksPage', params: {}}},
]
TANDOOR_PLUGINS.forEach(plugin => {
plugin.bottomNavigation.forEach(navEntry => {
let navEntryCopy = Object.assign({}, navEntry)
navEntryCopy.title = t(navEntryCopy.title)
navigation.push(navEntryCopy)
})
})
return navigation
}
function getUserNavigation() {
let navigation = []
navigation.push({component: VListItem, prependIcon: 'fa-solid fa-sliders', title: t('Settings'), to: {name: 'SettingsPage', params: {}}})
navigation.push({component: VListItem, prependIcon: 'fa-solid fa-question', title: t('Help'), to: {name: 'HelpPage', params: {}}})
if (useUserPreferenceStore().userSettings.user.isSuperuser) {
navigation.push({component: VListItem, prependIcon: 'fa-solid fa-shield', title: t('Admin'), href: useDjangoUrls().getDjangoUrl('admin')})
}
if (useUserPreferenceStore().spaces.length > 1) {
navigation.push({component: VDivider})
useUserPreferenceStore().spaces.forEach(space => {
navigation.push({
component: VListItem,
prependIcon: (useUserPreferenceStore().activeSpace.id == space.id) ? 'fa-solid fa-circle-dot' : 'fa-solid fa-circle',
title: space.name,
onClick: () => {
useUserPreferenceStore().switchSpace(space)
}
})
})
navigation.push({component: VDivider})
}
TANDOOR_PLUGINS.forEach(plugin => {
plugin.userNavigation.forEach(navEntry => {
let navEntryCopy = Object.assign({}, navEntry)
navEntryCopy.title = t(navEntryCopy.title)
navigation.push(navEntryCopy)
})
})
navigation.push({component: VListItem, prependIcon: 'fa-solid fa-arrow-right-from-bracket', title: t('Logout'), href: useDjangoUrls().getDjangoUrl('accounts/logout')})
return navigation
}
return {getNavigationDrawer, getBottomNavigation, getUserNavigation}
}

View File

@@ -7,6 +7,7 @@ import type {
import {createI18n} from "vue-i18n";
import en from "../../vue3/src/locales/en.json";
import {TANDOOR_PLUGINS} from "@/types/Plugins.ts";
/**
* lazy loading of translation, resources:
@@ -31,10 +32,17 @@ export function setupI18n() {
locale: 'en',
fallbackLocale: 'en',
messages: {
en
en,
},
}) as I18n
// async load plugin default locales
TANDOOR_PLUGINS.forEach(plugin => {
plugin.defaultLocale.then(pluginMessages => {
i18n.global.mergeLocaleMessage('en', pluginMessages)
})
})
// async load user locale into existing i18n instance
loadLocaleMessages(i18n, locale).then()
@@ -48,9 +56,10 @@ export function setupI18n() {
*/
export async function loadLocaleMessages(i18n: I18n, locale: Locale) {
// load locale messages
const messages = await import(`./locales/${locale}.json`).then(
(r: any) => r.default || r
)
let messages = en
if (locale != 'en') {
messages = await import(`./locales/${locale}.json`).then((r: any) => r.default || r)
}
// remove empty strings
Object.entries(messages).forEach(([key, value]) => {
@@ -62,6 +71,25 @@ export async function loadLocaleMessages(i18n: I18n, locale: Locale) {
// set messages for locale
i18n.global.setLocaleMessage(locale, messages)
// async load and merge messages from plugins
TANDOOR_PLUGINS.forEach(plugin => {
let pluginLocales = getSupportedLocales(plugin.localeFiles)
if (pluginLocales.includes(locale)) {
import(`@/plugins/${plugin.basePath}/locales/${locale}.json`).then((r: any) => {
let pluginMessages = r.default || r
// remove empty strings
Object.entries(pluginMessages).forEach(([key, value]) => {
if (value === '') {
delete pluginMessages[key]
}
})
i18n.global.mergeLocaleMessage(locale, pluginMessages)
})
}
})
// switch to given locale
setLocale(i18n, locale)
}
@@ -69,10 +97,11 @@ export async function loadLocaleMessages(i18n: I18n, locale: Locale) {
/**
* loop trough translation files to determine for which locales a translation is available
* @return string[] of supported locales
* @param localeFiles module import of locale files to loop trough
*/
function getSupportedLocales() {
function getSupportedLocales(localeFiles = import.meta.glob('@/locales/*.json')) {
let supportedLocales: string[] = []
let localeFiles = import.meta.glob('@/locales/*.json');
for (const path in localeFiles) {
supportedLocales.push(path.split('/').slice(-1)[0].split('.')[0]);
}

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