mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-25 19:29:30 -05:00
Compare commits
273 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cba0e18af | ||
|
|
ec6e81316a | ||
|
|
b72897b222 | ||
|
|
bca1ebbf99 | ||
|
|
f0342d4568 | ||
|
|
81f62de500 | ||
|
|
f783949a61 | ||
|
|
820fad1b5c | ||
|
|
1169abd942 | ||
|
|
48e175f58f | ||
|
|
5450e18342 | ||
|
|
ea590f8e49 | ||
|
|
13626ca11b | ||
|
|
f53fe1e3c4 | ||
|
|
d177316b47 | ||
|
|
338db1fac2 | ||
|
|
377619473c | ||
|
|
000962c5bb | ||
|
|
9228c1d59f | ||
|
|
27007de7a0 | ||
|
|
29c99b66a1 | ||
|
|
bc179f430d | ||
|
|
58c412ad95 | ||
|
|
4f248afe76 | ||
|
|
f722d24eaa | ||
|
|
723b74509f | ||
|
|
ad4b1393dd | ||
|
|
04bab7072c | ||
|
|
6391cee9eb | ||
|
|
14884fc0d4 | ||
|
|
f2191f79dd | ||
|
|
c2533d9ea2 | ||
|
|
db72fdb1bb | ||
|
|
78252662cb | ||
|
|
4e078bf477 | ||
|
|
2e9e226fe0 | ||
|
|
18cfbd80ab | ||
|
|
4d284b4fff | ||
|
|
b1128dd134 | ||
|
|
3aebf58406 | ||
|
|
f3816a77df | ||
|
|
e4183d79ab | ||
|
|
f4aa1a083f | ||
|
|
ed5508b576 | ||
|
|
040e247487 | ||
|
|
5d28c7b17d | ||
|
|
15b2df07f2 | ||
|
|
ed8f97e9e0 | ||
|
|
034f68fc28 | ||
|
|
0158087a0b | ||
|
|
cb6bfd741d | ||
|
|
afeee5f7cb | ||
|
|
b43d6e08d4 | ||
|
|
1188624376 | ||
|
|
9ac837c969 | ||
|
|
fc4b017d30 | ||
|
|
4636ac28f9 | ||
|
|
397912e87f | ||
|
|
d0b860e623 | ||
|
|
8a90ed1274 | ||
|
|
163c2a53b6 | ||
|
|
286d707347 | ||
|
|
98d308aee9 | ||
|
|
a7c5240227 | ||
|
|
75fcff8e70 | ||
|
|
2f27cf4deb | ||
|
|
686b595f45 | ||
|
|
0f9f9e8f7c | ||
|
|
7be7c5b954 | ||
|
|
0853a9ec64 | ||
|
|
fa3daee965 | ||
|
|
aba45657c3 | ||
|
|
e6abdf8cd4 | ||
|
|
6cedde7b2d | ||
|
|
741e9eb370 | ||
|
|
7db523d8c4 | ||
|
|
41f0060c43 | ||
|
|
5572833f64 | ||
|
|
780e441a3b | ||
|
|
c4fd2d0b4e | ||
|
|
1c6618f452 | ||
|
|
8c96a75a1e | ||
|
|
44baa8322c | ||
|
|
0fbb95438a | ||
|
|
c56dd9563c | ||
|
|
0008b7c975 | ||
|
|
524f086cc5 | ||
|
|
8550387e0c | ||
|
|
1618f8df79 | ||
|
|
22dfb2a410 | ||
|
|
f099e2e5d3 | ||
|
|
6973c65142 | ||
|
|
774c05e76f | ||
|
|
b08c39e284 | ||
|
|
ae036cfa9a | ||
|
|
37628c1735 | ||
|
|
530a6db35c | ||
|
|
2930093da0 | ||
|
|
b7e63a466b | ||
|
|
a01f86a14e | ||
|
|
9704268fdc | ||
|
|
84cc4c1165 | ||
|
|
5cb70becb8 | ||
|
|
5f99abf459 | ||
|
|
4a8ddce391 | ||
|
|
9a14a87c27 | ||
|
|
c01634f9bd | ||
|
|
f055df3b4d | ||
|
|
a83f474d70 | ||
|
|
63d358df36 | ||
|
|
e70548fcc0 | ||
|
|
17b03905e6 | ||
|
|
90403e6a13 | ||
|
|
db400cae25 | ||
|
|
0f8eee4e0f | ||
|
|
1f532f6276 | ||
|
|
b32715e493 | ||
|
|
0d19e12118 | ||
|
|
96e5213fa6 | ||
|
|
44c567d20b | ||
|
|
3c920593cf | ||
|
|
1d90f8b6f1 | ||
|
|
6b1217ec35 | ||
|
|
a71564a424 | ||
|
|
76c2e144fc | ||
|
|
981353380c | ||
|
|
96a520b1af | ||
|
|
05f537dc6b | ||
|
|
948d8da3b1 | ||
|
|
f8e4b39d88 | ||
|
|
6c498f7dac | ||
|
|
d25702b717 | ||
|
|
aca18fcbe0 | ||
|
|
98b57d2854 | ||
|
|
5e1c804fd1 | ||
|
|
a30deb4bae | ||
|
|
45a567856a | ||
|
|
7065d96f90 | ||
|
|
f8cd42dec9 | ||
|
|
8d736c0f88 | ||
|
|
8183e350c9 | ||
|
|
4438bfcb89 | ||
|
|
f42b2cfd31 | ||
|
|
09131e8eae | ||
|
|
f5f001b3d2 | ||
|
|
7f8587922d | ||
|
|
a3460bc023 | ||
|
|
5faa74a75d | ||
|
|
65dbc643d3 | ||
|
|
f0b169647b | ||
|
|
d786ee09fa | ||
|
|
a46f3958fe | ||
|
|
6c17937313 | ||
|
|
a26835ccc4 | ||
|
|
86fc4aa2d0 | ||
|
|
4bd3da451d | ||
|
|
0003405e98 | ||
|
|
b586794337 | ||
|
|
460cb43113 | ||
|
|
5128fcc9eb | ||
|
|
243ff8601c | ||
|
|
97f8d46afb | ||
|
|
e469ebf35e | ||
|
|
e04c729476 | ||
|
|
d98bf9155d | ||
|
|
e98d00a962 | ||
|
|
cf5f896cec | ||
|
|
e8d616ac98 | ||
|
|
7a22d43959 | ||
|
|
6b68f48227 | ||
|
|
115f18889a | ||
|
|
0aaffb7545 | ||
|
|
087cbdade8 | ||
|
|
7e55115a3a | ||
|
|
31ee55a113 | ||
|
|
61be55e4b7 | ||
|
|
e3f695bde1 | ||
|
|
0fb3d22f6a | ||
|
|
7ba5187ecf | ||
|
|
168c0f3a0d | ||
|
|
1179e226ab | ||
|
|
bed22c055d | ||
|
|
c25a1df480 | ||
|
|
d1df772218 | ||
|
|
cbdd23020b | ||
|
|
10f8a56343 | ||
|
|
006c5b3af8 | ||
|
|
562a0dceae | ||
|
|
cde03a0f33 | ||
|
|
b42285a9a5 | ||
|
|
f4d4a5b714 | ||
|
|
ee7d611086 | ||
|
|
e51fda5f20 | ||
|
|
bee759e166 | ||
|
|
5802dfd0a5 | ||
|
|
c18ce7635d | ||
|
|
942a8a6119 | ||
|
|
4015edde90 | ||
|
|
1c32940f5c | ||
|
|
447ffa9fe2 | ||
|
|
8480234592 | ||
|
|
2e0345a4a8 | ||
|
|
49fc0cf80f | ||
|
|
c67ecb6e31 | ||
|
|
b4f12c4e84 | ||
|
|
0b2adf5249 | ||
|
|
7dcb5884d9 | ||
|
|
35bd550101 | ||
|
|
707abfacb0 | ||
|
|
ed4f4c77e8 | ||
|
|
c492fb513b | ||
|
|
310b8e04e1 | ||
|
|
efeae4debc | ||
|
|
6bc25c32ff | ||
|
|
7f2b0438fe | ||
|
|
8481f8c658 | ||
|
|
d842795c25 | ||
|
|
58dd700207 | ||
|
|
1331d2cb6d | ||
|
|
ad2a613fd8 | ||
|
|
0565189580 | ||
|
|
5aa351b885 | ||
|
|
d5226eb5cf | ||
|
|
9ead1d0022 | ||
|
|
67342c3ba9 | ||
|
|
3fecd82cd0 | ||
|
|
a033c4290f | ||
|
|
b6597af0d7 | ||
|
|
af6ed4bd24 | ||
|
|
cc4bddb3fe | ||
|
|
95a9df9c05 | ||
|
|
c44de28c2c | ||
|
|
9f1b87fa4f | ||
|
|
b96e0bab11 | ||
|
|
fe97fb371b | ||
|
|
bb7df960cc | ||
|
|
c3c7d803dc | ||
|
|
99ce3327cc | ||
|
|
d1949df23d | ||
|
|
119b47c3c4 | ||
|
|
8b50c59ad3 | ||
|
|
e2ac65467b | ||
|
|
c5cc492f0a | ||
|
|
8c73b5254c | ||
|
|
4b0315ffd3 | ||
|
|
6a96f5b7c5 | ||
|
|
8875dd4083 | ||
|
|
7299f265d3 | ||
|
|
cac186f63c | ||
|
|
fd4c571e48 | ||
|
|
db99450475 | ||
|
|
ec50add571 | ||
|
|
1fc3746619 | ||
|
|
debf4c124a | ||
|
|
0e071255e5 | ||
|
|
4d0b8c690b | ||
|
|
85b3e0a0a6 | ||
|
|
d8573ce16f | ||
|
|
7f7e3180fa | ||
|
|
57cc6feef0 | ||
|
|
fb198a80d2 | ||
|
|
5b324a86dc | ||
|
|
f633274bef | ||
|
|
65b2eb6d7e | ||
|
|
0c509ec02e | ||
|
|
babcddeeb1 | ||
|
|
f23b282f27 | ||
|
|
fcfedd3026 | ||
|
|
efd65c1024 | ||
|
|
6f4f5381ff | ||
|
|
95b63f5180 | ||
|
|
f33a52a94c | ||
|
|
90baf26eb8 |
@@ -1,12 +1,7 @@
|
||||
FROM python:3.10-alpine3.18
|
||||
FROM python:3.13-alpine3.22
|
||||
|
||||
#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
|
||||
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git yarn libgcc libstdc++ nginx tini envsubst nodejs npm
|
||||
|
||||
#Print all logs without buffering it.
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
@@ -24,8 +19,10 @@ RUN \
|
||||
if [ `apk --print-arch` = "armv7" ]; then \
|
||||
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
|
||||
fi
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev && \
|
||||
echo -n "INPUT ( libldap.so )" > /usr/lib/libldap_r.so && \
|
||||
pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt && \
|
||||
rm -rf /tmp/pip-tmp && \
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev xmlsec-dev xmlsec build-base g++ curl rust && \
|
||||
python -m pip install --upgrade pip && \
|
||||
pip debug -v && \
|
||||
pip install wheel==0.45.1 && \
|
||||
pip install setuptools_rust==1.10.2 && \
|
||||
pip install -r /tmp/pip-tmp/requirements.txt --no-cache-dir &&\
|
||||
apk --purge del .build-deps
|
||||
6
.github/workflows/build-docker.yml
vendored
6
.github/workflows/build-docker.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
suffix: ""
|
||||
continue-on-error: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Get version number
|
||||
id: get_version
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
- name: Discord notification
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@0.3.2
|
||||
uses: Ilshidur/action-discord@0.4.0
|
||||
with:
|
||||
args: '🚀 Version {{ VERSION }} of tandoor has been released 🥳 Check it out https://github.com/vabene1111/recipes/releases/tag/{{ VERSION }}'
|
||||
|
||||
@@ -121,6 +121,6 @@ jobs:
|
||||
- name: Discord notification
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_BETA_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@0.3.2
|
||||
uses: Ilshidur/action-discord@0.4.0
|
||||
with:
|
||||
args: '🚀 The Tandoor 2 Image has been updated! 🥳'
|
||||
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -12,8 +12,8 @@ jobs:
|
||||
python-version: ["3.12"]
|
||||
node-version: ["22"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: awalsh128/cache-apt-pkgs-action@v1.4.3
|
||||
- uses: actions/checkout@v5
|
||||
- uses: awalsh128/cache-apt-pkgs-action@v1.5.3
|
||||
with:
|
||||
packages: libsasl2-dev python3-dev libxml2-dev libxmlsec1-dev libxslt-dev libxmlsec1-openssl libxslt-dev libldap2-dev libssl-dev gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev xmlsec-dev xmlsec build-base g++ curl
|
||||
version: 1.0
|
||||
|
||||
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
if: github.repository_owner == 'TandoorRecipes' && ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.x
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -91,3 +91,4 @@ cookbook/static/vue3
|
||||
vue3/node_modules
|
||||
cookbook/tests/other/docs/reports/tests/tests.html
|
||||
cookbook/tests/other/docs/reports/tests/pytest.xml
|
||||
vue3/src/plugins
|
||||
|
||||
62
.vscode/tasks.json
vendored
62
.vscode/tasks.json
vendored
@@ -14,28 +14,16 @@
|
||||
},
|
||||
{
|
||||
"label": "Setup Dev Server",
|
||||
"dependsOn": ["Run Migrations", "Yarn Build"]
|
||||
"dependsOn": ["Run Migrations"]
|
||||
},
|
||||
{
|
||||
"label": "Run Dev Server",
|
||||
"type": "shell",
|
||||
"type": "shell",
|
||||
"dependsOn": ["Setup Dev Server"],
|
||||
"command": "python3 manage.py runserver"
|
||||
"command": "DEBUG=1 python3 manage.py runserver"
|
||||
},
|
||||
{
|
||||
"label": "Yarn Install",
|
||||
"dependsOn": ["Yarn Install - Vue", "Yarn Install - Vue3"]
|
||||
},
|
||||
{
|
||||
"label": "Yarn Install - Vue",
|
||||
"type": "shell",
|
||||
"command": "yarn install --force",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Yarn Install - Vue3",
|
||||
"type": "shell",
|
||||
"command": "yarn install --force",
|
||||
"options": {
|
||||
@@ -44,18 +32,6 @@
|
||||
},
|
||||
{
|
||||
"label": "Generate API",
|
||||
"dependsOn": ["Generate API - Vue", "Generate API - Vue3"]
|
||||
},
|
||||
{
|
||||
"label": "Generate API - Vue",
|
||||
"type": "shell",
|
||||
"command": "openapi-generator-cli generate -g typescript-axios -i http://127.0.0.1:8000/openapi/",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue/src/utils/openapi"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Generate API - Vue3",
|
||||
"type": "shell",
|
||||
"command": "openapi-generator-cli generate -g typescript-fetch -i http://127.0.0.1:8000/openapi/",
|
||||
"options": {
|
||||
@@ -63,43 +39,19 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Yarn Serve",
|
||||
"label": "Yarn Dev",
|
||||
"type": "shell",
|
||||
"command": "yarn serve",
|
||||
"dependsOn": ["Yarn Install - Vue"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Vite Serve",
|
||||
"type": "shell",
|
||||
"command": "vite",
|
||||
"dependsOn": ["Yarn Install - Vue3"],
|
||||
"command": "yarn dev",
|
||||
"dependsOn": ["Yarn Install"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Yarn Build",
|
||||
"dependsOn": ["Yarn Build - Vue", "Vite Build - Vue3"],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "Yarn Build - Vue",
|
||||
"type": "shell",
|
||||
"command": "yarn build",
|
||||
"dependsOn": ["Yarn Install - Vue"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue"
|
||||
},
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "Vite Build - Vue3",
|
||||
"type": "shell",
|
||||
"command": "vite build",
|
||||
"dependsOn": ["Yarn Install - Vue3"],
|
||||
"dependsOn": ["Yarn Install"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue3"
|
||||
},
|
||||
|
||||
29
Dockerfile
29
Dockerfile
@@ -1,12 +1,11 @@
|
||||
FROM python:3.13-alpine3.21
|
||||
FROM python:3.13-alpine3.22
|
||||
|
||||
#Install all dependencies.
|
||||
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git libgcc libstdc++ nginx
|
||||
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git libgcc libstdc++ nginx tini envsubst nodejs npm
|
||||
|
||||
#Print all logs without buffering it.
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
ENV DOCKER true
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
DOCKER=true
|
||||
|
||||
#This port will be used by gunicorn.
|
||||
EXPOSE 80 8080
|
||||
@@ -17,23 +16,14 @@ WORKDIR /opt/recipes
|
||||
|
||||
COPY requirements.txt ./
|
||||
|
||||
RUN \
|
||||
if [ `apk --print-arch` = "armv7" ]; then \
|
||||
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
|
||||
fi
|
||||
|
||||
# remove Development dependencies from requirements.txt
|
||||
RUN sed -i '/# Development/,$d' requirements.txt
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev xmlsec-dev xmlsec build-base g++ curl && \
|
||||
echo -n "INPUT ( libldap.so )" > /usr/lib/libldap_r.so && \
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev xmlsec-dev xmlsec build-base g++ curl rust && \
|
||||
python -m venv venv && \
|
||||
/opt/recipes/venv/bin/python -m pip install --upgrade pip && \
|
||||
venv/bin/pip debug -v && \
|
||||
venv/bin/pip install wheel==0.45.1 && \
|
||||
venv/bin/pip install setuptools_rust==1.10.2 && \
|
||||
if [ `apk --print-arch` = "aarch64" ]; then \
|
||||
curl https://sh.rustup.rs -sSf | sh -s -- -y; \
|
||||
fi &&\
|
||||
venv/bin/pip install -r requirements.txt --no-cache-dir &&\
|
||||
apk --purge del .build-deps
|
||||
|
||||
@@ -41,8 +31,11 @@ RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-de
|
||||
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
|
||||
# create symlinks to access and error log to show them on stdout
|
||||
RUN rm -rf /etc/nginx/http.d && \
|
||||
ln -s /opt/recipes/http.d /etc/nginx/http.d && \
|
||||
ln -sf /dev/stdout /var/log/nginx/access.log && \
|
||||
ln -sf /dev/stderr /var/log/nginx/error.log
|
||||
|
||||
# commented for now https://github.com/TandoorRecipes/recipes/issues/3478
|
||||
#HEALTHCHECK --interval=30s \
|
||||
@@ -57,4 +50,4 @@ RUN /opt/recipes/venv/bin/python version.py
|
||||
RUN find . -type d -name ".git" | xargs rm -rf
|
||||
|
||||
RUN chmod +x boot.sh
|
||||
ENTRYPOINT ["/opt/recipes/boot.sh"]
|
||||
ENTRYPOINT ["/sbin/tini", "--", "/opt/recipes/boot.sh"]
|
||||
|
||||
13
README.md
13
README.md
@@ -15,14 +15,15 @@
|
||||
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer"><img src="https://badgen.net/badge/icon/discord?icon=discord&label" ></a>
|
||||
<a href="https://hub.docker.com/r/vabene1111/recipes" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/docker/pulls/vabene1111/recipes" ></a>
|
||||
<a href="https://github.com/vabene1111/recipes/releases/latest" rel="noopener noreferrer"><img src="https://img.shields.io/github/v/release/vabene1111/recipes" ></a>
|
||||
<a href="https://app.tandoor.dev/accounts/login/?demo" rel="noopener noreferrer"><img src="https://img.shields.io/badge/demo-available-success" ></a>
|
||||
<a href="https://app.tandoor.dev/e/demo-auto-login/" rel="noopener noreferrer"><img src="https://img.shields.io/badge/demo-available-success" ></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://tandoor.dev" target="_blank" rel="noopener noreferrer">Website</a> •
|
||||
<a href="https://docs.tandoor.dev/install/docker/" target="_blank" rel="noopener noreferrer">Installation</a> •
|
||||
<a href="https://docs.tandoor.dev/" target="_blank" rel="noopener noreferrer">Docs</a> •
|
||||
<a href="https://app.tandoor.dev/accounts/login/?demo" target="_blank" rel="noopener noreferrer">Demo</a> •
|
||||
<a href="https://app.tandoor.dev/e/demo-auto-login/" target="_blank" rel="noopener noreferrer">Demo</a> •
|
||||
<a href="https://community.tandoor.dev" target="_blank" rel="noopener noreferrer">Community</a> •
|
||||
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer">Discord</a>
|
||||
</p>
|
||||
|
||||
@@ -81,13 +82,13 @@ Share some information on how you use Tandoor to help me improve the application
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><a href="https://discord.gg/RhzBrfWgtp">Discord</a></td>
|
||||
<td>We have a public Discord server that anyone can join. This is where all our developers and contributors hang out and where we make announcements</td>
|
||||
<td><a href="https://community.tandoor.dev">Community</a></td>
|
||||
<td>Get support, share best practices, discuss feature ideas, and meet other Tandoor users.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><a href="https://twitter.com/TandoorRecipes">Twitter</a></td>
|
||||
<td>You can follow our Twitter account to get updates on new features or releases</td>
|
||||
<td><a href="https://discord.gg/RhzBrfWgtp">Discord</a></td>
|
||||
<td>We have a public Discord server that anyone can join. This is where all our developers and contributors hang out and where we make announcements</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
19
boot.sh
Normal file → Executable file
19
boot.sh
Normal file → Executable file
@@ -1,11 +1,17 @@
|
||||
#!/bin/sh
|
||||
source venv/bin/activate
|
||||
|
||||
TANDOOR_PORT="${TANDOOR_PORT:-8080}"
|
||||
# these are envsubst in the nginx config, make sure they default to something sensible when unset
|
||||
export TANDOOR_PORT="${TANDOOR_PORT:-8080}"
|
||||
export MEDIA_ROOT=${MEDIA_ROOT:-/opt/recipes/mediafiles};
|
||||
export STATIC_ROOT=${STATIC_ROOT:-/opt/recipes/staticfiles};
|
||||
|
||||
GUNICORN_WORKERS="${GUNICORN_WORKERS:-3}"
|
||||
GUNICORN_THREADS="${GUNICORN_THREADS:-2}"
|
||||
GUNICORN_LOG_LEVEL="${GUNICORN_LOG_LEVEL:-'info'}"
|
||||
|
||||
PLUGINS_BUILD="${PLUGINS_BUILD:-0}"
|
||||
|
||||
if [ "${TANDOOR_PORT}" -eq 80 ]; then
|
||||
echo "TANDOOR_PORT set to 8080 because 80 is now taken by the integrated nginx"
|
||||
TANDOOR_PORT=8080
|
||||
@@ -78,19 +84,26 @@ echo "Database is ready"
|
||||
|
||||
echo "Migrating database"
|
||||
|
||||
|
||||
python manage.py migrate
|
||||
|
||||
if [ "${PLUGINS_BUILD}" -eq 1 ]; then
|
||||
echo "Running yarn build at startup because PLUGINS_BUILD is enabled"
|
||||
python plugin.py
|
||||
fi
|
||||
|
||||
echo "Collecting static files, this may take a while..."
|
||||
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
echo "Done"
|
||||
|
||||
chmod -R 755 /opt/recipes/mediafiles
|
||||
chmod -R 755 ${MEDIA_ROOT:-/opt/recipes/mediafiles}
|
||||
|
||||
ipv6_disable=$(cat /sys/module/ipv6/parameters/disable)
|
||||
|
||||
# prepare nginx config
|
||||
envsubst '$MEDIA_ROOT $STATIC_ROOT $TANDOOR_PORT' < /opt/recipes/http.d/Recipes.conf.template > /opt/recipes/http.d/Recipes.conf
|
||||
|
||||
# start nginx
|
||||
echo "Starting nginx"
|
||||
nginx
|
||||
|
||||
@@ -17,7 +17,7 @@ from .models import (BookmarkletImport, Comment, CookLog, CustomFilter, Food, Im
|
||||
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
||||
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
|
||||
TelegramBot, Unit, UnitConversion, UserFile, UserPreference, UserSpace,
|
||||
ViewLog, ConnectorConfig)
|
||||
ViewLog, ConnectorConfig, AiProvider, AiLog)
|
||||
|
||||
admin.site.login = secure_admin_login(admin.site.login)
|
||||
|
||||
@@ -90,6 +90,20 @@ class SearchPreferenceAdmin(admin.ModelAdmin):
|
||||
admin.site.register(SearchPreference, SearchPreferenceAdmin)
|
||||
|
||||
|
||||
class AiProviderAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'space', 'model',)
|
||||
search_fields = ('name', 'space', 'model',)
|
||||
|
||||
|
||||
admin.site.register(AiProvider, AiProviderAdmin)
|
||||
|
||||
|
||||
class AiLogAdmin(admin.ModelAdmin):
|
||||
list_display = ('ai_provider', 'function', 'credit_cost', 'created_by', 'created_at',)
|
||||
|
||||
admin.site.register(AiLog, AiLogAdmin)
|
||||
|
||||
|
||||
class StorageAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'method')
|
||||
search_fields = ('name',)
|
||||
|
||||
@@ -26,6 +26,7 @@ class ImportExportBase(forms.Form):
|
||||
PAPRIKA = 'PAPRIKA'
|
||||
NEXTCLOUD = 'NEXTCLOUD'
|
||||
MEALIE = 'MEALIE'
|
||||
MEALIE1 = 'MEALIE1'
|
||||
CHOWDOWN = 'CHOWDOWN'
|
||||
SAFFRON = 'SAFFRON'
|
||||
CHEFTAP = 'CHEFTAP'
|
||||
@@ -46,7 +47,7 @@ class ImportExportBase(forms.Form):
|
||||
PDF = 'PDF'
|
||||
GOURMET = 'GOURMET'
|
||||
|
||||
type = forms.ChoiceField(choices=((DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'), (MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'),
|
||||
type = forms.ChoiceField(choices=((DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'), (MEALIE, 'Mealie'), (MEALIE1, 'Mealie1'), (CHOWDOWN, 'Chowdown'),
|
||||
(SAFFRON, 'Saffron'), (CHEFTAP, 'ChefTap'), (PEPPERPLATE, 'Pepperplate'), (RECETTETEK, 'RecetteTek'), (RECIPESAGE, 'Recipe Sage'),
|
||||
(DOMESTICA, 'Domestica'), (MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'), (OPENEATS, 'Openeats'), (RECIPEKEEPER, 'Recipe Keeper'),
|
||||
(PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'), (COPYMETHAT, 'CopyMeThat'), (PDF, 'PDF'), (MELARECIPES, 'Melarecipes'),
|
||||
@@ -75,6 +76,11 @@ class ImportForm(ImportExportBase):
|
||||
files = MultipleFileField(required=True)
|
||||
duplicates = forms.BooleanField(help_text=_('To prevent duplicates recipes with the same name as existing ones are ignored. Check this box to import everything.'),
|
||||
required=False)
|
||||
meal_plans = forms.BooleanField(required=False)
|
||||
shopping_lists = forms.BooleanField(required=False)
|
||||
nutrition_per_serving = forms.BooleanField(required=False) # some managers (e.g. mealie) do not specify what the nutrition's relate to so we let the user choose
|
||||
|
||||
|
||||
class ExportForm(ImportExportBase):
|
||||
recipes = forms.ModelMultipleChoiceField(widget=MultiSelectWidget, queryset=Recipe.objects.none(), required=False)
|
||||
all = forms.BooleanField(required=False)
|
||||
|
||||
83
cookbook/helper/ai_helper.py
Normal file
83
cookbook/helper/ai_helper.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.utils import timezone
|
||||
from django.db.models import Sum
|
||||
from litellm import CustomLogger
|
||||
|
||||
from cookbook.models import AiLog
|
||||
from recipes import settings
|
||||
|
||||
|
||||
def get_monthly_token_usage(space):
|
||||
"""
|
||||
returns the number of credits the space has used in the current month
|
||||
"""
|
||||
token_usage = AiLog.objects.filter(space=space, credits_from_balance=False, created_at__month=timezone.now().month).aggregate(Sum('credit_cost'))['credit_cost__sum']
|
||||
if token_usage is None:
|
||||
token_usage = 0
|
||||
return token_usage
|
||||
|
||||
|
||||
def has_monthly_token(space):
|
||||
"""
|
||||
checks if the monthly credit limit has been exceeded
|
||||
"""
|
||||
return get_monthly_token_usage(space) < space.ai_credits_monthly
|
||||
|
||||
|
||||
def can_perform_ai_request(space):
|
||||
return (has_monthly_token(space) or space.ai_credits_balance > 0) and space.ai_enabled
|
||||
|
||||
|
||||
class AiCallbackHandler(CustomLogger):
|
||||
space = None
|
||||
user = None
|
||||
ai_provider = None
|
||||
|
||||
def __init__(self, space, user, ai_provider):
|
||||
super().__init__()
|
||||
self.space = space
|
||||
self.user = user
|
||||
self.ai_provider = ai_provider
|
||||
|
||||
def log_pre_api_call(self, model, messages, kwargs):
|
||||
pass
|
||||
|
||||
def log_post_api_call(self, kwargs, response_obj, start_time, end_time):
|
||||
pass
|
||||
|
||||
def log_success_event(self, kwargs, response_obj, start_time, end_time):
|
||||
self.create_ai_log(kwargs, response_obj, start_time, end_time)
|
||||
|
||||
def log_failure_event(self, kwargs, response_obj, start_time, end_time):
|
||||
self.create_ai_log(kwargs, response_obj, start_time, end_time)
|
||||
|
||||
def create_ai_log(self, kwargs, response_obj, start_time, end_time):
|
||||
credit_cost = 0
|
||||
credits_from_balance = False
|
||||
if self.ai_provider.log_credit_cost:
|
||||
credit_cost = kwargs.get("response_cost", 0) * 100
|
||||
|
||||
if (not has_monthly_token(self.space)) and self.space.ai_credits_balance > 0:
|
||||
remaining_balance = self.space.ai_credits_balance - Decimal(str(credit_cost))
|
||||
if remaining_balance < 0:
|
||||
remaining_balance = 0
|
||||
if settings.HOSTED:
|
||||
self.space.ai_enabled = False
|
||||
|
||||
self.space.ai_credits_balance = remaining_balance
|
||||
credits_from_balance = True
|
||||
self.space.save()
|
||||
|
||||
AiLog.objects.create(
|
||||
created_by=self.user,
|
||||
space=self.space,
|
||||
ai_provider=self.ai_provider,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
input_tokens=response_obj['usage']['prompt_tokens'],
|
||||
output_tokens=response_obj['usage']['completion_tokens'],
|
||||
function=AiLog.F_FILE_IMPORT,
|
||||
credit_cost=credit_cost,
|
||||
credits_from_balance=credits_from_balance,
|
||||
)
|
||||
22
cookbook/helper/batch_edit_helper.py
Normal file
22
cookbook/helper/batch_edit_helper.py
Normal file
@@ -0,0 +1,22 @@
|
||||
def add_to_relation(relation_model, base_field_name, base_ids, related_field_name, related_ids):
|
||||
"""
|
||||
given a model, the base and related field and the base and related ids, bulk create relation objects
|
||||
"""
|
||||
relation_objects = []
|
||||
for b in base_ids:
|
||||
for r in related_ids:
|
||||
relation_objects.append(relation_model(**{base_field_name: b, related_field_name: r}))
|
||||
relation_model.objects.bulk_create(relation_objects, ignore_conflicts=True, unique_fields=(base_field_name, related_field_name,))
|
||||
|
||||
|
||||
def remove_from_relation(relation_model, base_field_name, base_ids, related_field_name, related_ids):
|
||||
relation_model.objects.filter(**{f'{base_field_name}__in': base_ids, f'{related_field_name}__in': related_ids}).delete()
|
||||
|
||||
|
||||
def remove_all_from_relation(relation_model, base_field_name, base_ids):
|
||||
relation_model.objects.filter(**{f'{base_field_name}__in': base_ids}).delete()
|
||||
|
||||
|
||||
def set_relation(relation_model, base_field_name, base_ids, related_field_name, related_ids):
|
||||
remove_all_from_relation(relation_model, base_field_name, base_ids)
|
||||
add_to_relation(relation_model, base_field_name, base_ids, related_field_name, related_ids)
|
||||
@@ -37,7 +37,7 @@ def get_filetype(name):
|
||||
|
||||
def is_file_type_allowed(filename, image_only=False):
|
||||
is_file_allowed = False
|
||||
allowed_file_types = ['.pdf', '.docx', '.xlsx', '.css']
|
||||
allowed_file_types = ['.pdf', '.docx', '.xlsx', '.css', '.mp4', '.mov']
|
||||
allowed_image_types = ['.png', '.jpg', '.jpeg', '.gif', '.webp']
|
||||
check_list = allowed_image_types
|
||||
if not image_only:
|
||||
@@ -77,6 +77,8 @@ def handle_image(request, image_object, filetype):
|
||||
file_format = 'JPEG'
|
||||
if filetype == '.png':
|
||||
file_format = 'PNG'
|
||||
if filetype == '.webp':
|
||||
file_format = 'WEBP'
|
||||
|
||||
if (image_object.size / 1000) > 500: # if larger than 500 kb compress
|
||||
if filetype == '.jpeg' or filetype == '.jpg':
|
||||
|
||||
@@ -3,17 +3,19 @@ import inspect
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import user_passes_test
|
||||
from django.contrib.auth.models import Group
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope, TokenHasScope
|
||||
from oauth2_provider.models import AccessToken
|
||||
from rest_framework import permissions
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
|
||||
from cookbook.models import Recipe, ShareLink, UserSpace
|
||||
import random
|
||||
from cookbook.models import Recipe, ShareLink, UserSpace, Space
|
||||
|
||||
|
||||
def get_allowed_groups(groups_required):
|
||||
@@ -331,6 +333,25 @@ class CustomRecipePermission(permissions.BasePermission):
|
||||
or has_group_permission(request.user, ['user'])) and obj.space == request.space
|
||||
|
||||
|
||||
class CustomAiProviderPermission(permissions.BasePermission):
|
||||
"""
|
||||
Custom permission class for the AiProvider api endpoint
|
||||
users: can read all
|
||||
admins: can read and write
|
||||
superusers: can read and write + write providers without a space
|
||||
"""
|
||||
message = _('You do not have the required permissions to view this page!')
|
||||
|
||||
def has_permission(self, request, view): # user is either at least a user and the request is safe
|
||||
return (has_group_permission(request.user, ['user']) and request.method in SAFE_METHODS) or (has_group_permission(request.user, ['admin']) or request.user.is_superuser)
|
||||
|
||||
# editing of global providers allowed for superusers, space providers by admins and users can read only access
|
||||
def has_object_permission(self, request, view, obj):
|
||||
return ((obj.space is None and request.user.is_superuser)
|
||||
or (obj.space == request.space and has_group_permission(request.user, ['admin']))
|
||||
or (obj.space == request.space and has_group_permission(request.user, ['user']) and request.method in SAFE_METHODS))
|
||||
|
||||
|
||||
class CustomUserPermission(permissions.BasePermission):
|
||||
"""
|
||||
Custom permission class for user api endpoint
|
||||
@@ -437,3 +458,36 @@ class IsReadOnlyDRF(permissions.BasePermission):
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.method in SAFE_METHODS
|
||||
|
||||
|
||||
class IsCreateDRF(permissions.BasePermission):
|
||||
message = 'You cannot interact with this object, you can only create'
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.method == 'POST'
|
||||
|
||||
|
||||
def create_space_for_user(user, name=None):
|
||||
with scopes_disabled():
|
||||
if not name:
|
||||
name = f"{user.username}'s Space"
|
||||
|
||||
if Space.objects.filter(name=name).exists():
|
||||
name = f'{name} #{random.randrange(1, 10 ** 5)}'
|
||||
|
||||
created_space = Space(name=name,
|
||||
created_by=user,
|
||||
max_file_storage_mb=settings.SPACE_DEFAULT_MAX_FILES,
|
||||
max_recipes=settings.SPACE_DEFAULT_MAX_RECIPES,
|
||||
max_users=settings.SPACE_DEFAULT_MAX_USERS,
|
||||
allow_sharing=settings.SPACE_DEFAULT_ALLOW_SHARING,
|
||||
ai_enabled=settings.SPACE_AI_ENABLED,
|
||||
ai_credits_monthly=settings.SPACE_AI_CREDITS_MONTHLY,
|
||||
space_setup_completed=False, )
|
||||
created_space.save()
|
||||
|
||||
UserSpace.objects.filter(user=user).update(active=False)
|
||||
user_space = UserSpace.objects.create(space=created_space, user=user, active=True)
|
||||
user_space.groups.add(Group.objects.filter(name='admin').get())
|
||||
|
||||
return user_space
|
||||
|
||||
@@ -319,10 +319,10 @@ def clean_instruction_string(instruction):
|
||||
.replace("", _('reverse rotation')) \
|
||||
.replace("", _('careful rotation')) \
|
||||
.replace("", _('knead')) \
|
||||
.replace("Andicken ", _('thicken')) \
|
||||
.replace("Erwärmen ", _('warm up')) \
|
||||
.replace("Fermentieren ", _('ferment')) \
|
||||
.replace("Sous-vide ", _("sous-vide"))
|
||||
.replace("", _('thicken')) \
|
||||
.replace("", _('warm up')) \
|
||||
.replace("", _('ferment')) \
|
||||
.replace("", _("sous-vide"))
|
||||
|
||||
|
||||
def parse_instructions(instructions):
|
||||
@@ -403,6 +403,8 @@ def parse_servings_text(servings):
|
||||
|
||||
|
||||
def parse_time(recipe_time):
|
||||
if not recipe_time:
|
||||
return 0
|
||||
if type(recipe_time) not in [int, float]:
|
||||
try:
|
||||
recipe_time = float(re.search(r'\d+', recipe_time).group())
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
from django.contrib.auth.models import Group
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
|
||||
from psycopg2.errors import UniqueViolation
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
import random
|
||||
|
||||
from cookbook.helper.permission_helper import create_space_for_user
|
||||
from cookbook.models import Space, UserSpace
|
||||
from cookbook.views import views
|
||||
from recipes import settings
|
||||
|
||||
@@ -34,16 +41,28 @@ class ScopeMiddleware:
|
||||
if request.path.startswith(prefix + '/switch-space/'):
|
||||
return self.get_response(request)
|
||||
|
||||
with scopes_disabled():
|
||||
if request.user.userspace_set.count() == 0 and not reverse('account_logout') in request.path:
|
||||
return views.space_overview(request)
|
||||
if request.path.startswith(prefix + '/invite/'):
|
||||
return self.get_response(request)
|
||||
|
||||
# get active user space, if for some reason more than one space is active select first (group permission checks will fail, this is not intended at this point)
|
||||
user_space = request.user.userspace_set.filter(active=True).first()
|
||||
|
||||
if not user_space:
|
||||
return views.space_overview(request)
|
||||
if not user_space and request.user.userspace_set.count() > 0:
|
||||
# if the users has a userspace but nothing is active, activate the first one
|
||||
user_space = request.user.userspace_set.first()
|
||||
if user_space:
|
||||
user_space.active = True
|
||||
user_space.save()
|
||||
|
||||
if not user_space:
|
||||
if 'signup_token' in request.session:
|
||||
# if user is authenticated, has no space but a signup token (InviteLink) is present, redirect to invite link logic
|
||||
return HttpResponseRedirect(reverse('view_invite', args=[request.session.pop('signup_token', '')]))
|
||||
else:
|
||||
# if user does not yet have a space create one for him
|
||||
user_space = create_space_for_user(request.user)
|
||||
|
||||
# TODO remove the need for this view
|
||||
if user_space.groups.count() == 0 and not reverse('account_logout') in request.path:
|
||||
return views.no_groups(request)
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ class Integration:
|
||||
files = None
|
||||
export_type = None
|
||||
ignored_recipes = []
|
||||
import_log = None
|
||||
import_duplicates = False
|
||||
|
||||
import_meal_plans = True
|
||||
import_shopping_lists = True
|
||||
nutrition_per_serving = False
|
||||
|
||||
def __init__(self, request, export_type):
|
||||
"""
|
||||
@@ -102,7 +108,7 @@ class Integration:
|
||||
"""
|
||||
return True
|
||||
|
||||
def do_import(self, files, il, import_duplicates):
|
||||
def do_import(self, files, il, import_duplicates, meal_plans=True, shopping_lists=True, nutrition_per_serving=False):
|
||||
"""
|
||||
Imports given files
|
||||
:param import_duplicates: if true duplicates are imported as well
|
||||
@@ -111,6 +117,12 @@ class Integration:
|
||||
:return: HttpResponseRedirect to the recipe search showing all imported recipes
|
||||
"""
|
||||
with scope(space=self.request.space):
|
||||
self.import_log = il
|
||||
self.import_duplicates = import_duplicates
|
||||
|
||||
self.import_meal_plans = meal_plans
|
||||
self.import_shopping_lists = shopping_lists
|
||||
self.nutrition_per_serving = nutrition_per_serving
|
||||
|
||||
try:
|
||||
self.files = files
|
||||
@@ -166,20 +178,24 @@ class Integration:
|
||||
il.total_recipes = len(new_file_list)
|
||||
file_list = new_file_list
|
||||
|
||||
for z in file_list:
|
||||
try:
|
||||
if not hasattr(z, 'filename') or isinstance(z, Tag):
|
||||
recipe = self.get_recipe_from_file(z)
|
||||
else:
|
||||
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += self.get_recipe_processed_msg(recipe)
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
il.imported_recipes += 1
|
||||
il.save()
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self.handle_exception(e, log=il, message=f'-------------------- \nERROR \n{e}\n--------------------\n')
|
||||
if isinstance(self, cookbook.integration.mealie1.Mealie1):
|
||||
# since the mealie 1.0 export is a backup and not a classic recipe export we treat it a bit differently
|
||||
recipes = self.get_recipe_from_file(import_zip)
|
||||
else:
|
||||
for z in file_list:
|
||||
try:
|
||||
if not hasattr(z, 'filename') or isinstance(z, Tag):
|
||||
recipe = self.get_recipe_from_file(z)
|
||||
else:
|
||||
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += self.get_recipe_processed_msg(recipe)
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
il.imported_recipes += 1
|
||||
il.save()
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self.handle_exception(e, log=il, message=f'-------------------- \nERROR \n{e}\n--------------------\n')
|
||||
import_zip.close()
|
||||
elif '.json' in f['name'] or '.xml' in f['name'] or '.txt' in f['name'] or '.mmf' in f['name'] or '.rk' in f['name'] or '.melarecipe' in f['name']:
|
||||
data_list = self.split_recipe_file(f['file'])
|
||||
|
||||
342
cookbook/integration/mealie1.py
Normal file
342
cookbook/integration/mealie1.py
Normal file
@@ -0,0 +1,342 @@
|
||||
import json
|
||||
import re
|
||||
import traceback
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
from gettext import gettext as _
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from cookbook.helper import ingredient_parser
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Ingredient, Keyword, Recipe, Step, Food, Unit, SupermarketCategory, PropertyType, Property, MealType, MealPlan, CookLog, ShoppingListEntry
|
||||
|
||||
|
||||
class Mealie1(Integration):
|
||||
"""
|
||||
integration for mealie past version 1.0
|
||||
"""
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
mealie_database = json.loads(BytesIO(file.read('database.json')).getvalue().decode("utf-8"))
|
||||
self.import_log.total_recipes = len(mealie_database['recipes'])
|
||||
self.import_log.msg += f"Importing {len(mealie_database["categories"]) + len(mealie_database["tags"])} tags and categories as keywords...\n"
|
||||
self.import_log.save()
|
||||
|
||||
keywords_categories_dict = {}
|
||||
for c in mealie_database['categories']:
|
||||
if keyword := Keyword.objects.filter(name=c['name'], space=self.request.space).first():
|
||||
keywords_categories_dict[c['id']] = keyword.pk
|
||||
else:
|
||||
keyword = Keyword.objects.create(name=c['name'], space=self.request.space)
|
||||
keywords_categories_dict[c['id']] = keyword.pk
|
||||
|
||||
keywords_tags_dict = {}
|
||||
for t in mealie_database['tags']:
|
||||
if keyword := Keyword.objects.filter(name=t['name'], space=self.request.space).first():
|
||||
keywords_tags_dict[t['id']] = keyword.pk
|
||||
else:
|
||||
keyword = Keyword.objects.create(name=t['name'], space=self.request.space)
|
||||
keywords_tags_dict[t['id']] = keyword.pk
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["multi_purpose_labels"])} multi purpose labels as supermarket categories...\n"
|
||||
self.import_log.save()
|
||||
|
||||
supermarket_categories_dict = {}
|
||||
for m in mealie_database['multi_purpose_labels']:
|
||||
if supermarket_category := SupermarketCategory.objects.filter(name=m['name'], space=self.request.space).first():
|
||||
supermarket_categories_dict[m['id']] = supermarket_category.pk
|
||||
else:
|
||||
supermarket_category = SupermarketCategory.objects.create(name=m['name'], space=self.request.space)
|
||||
supermarket_categories_dict[m['id']] = supermarket_category.pk
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["ingredient_foods"])} foods...\n"
|
||||
self.import_log.save()
|
||||
|
||||
foods_dict = {}
|
||||
for f in mealie_database['ingredient_foods']:
|
||||
if food := Food.objects.filter(name=f['name'], space=self.request.space).first():
|
||||
foods_dict[f['id']] = food.pk
|
||||
else:
|
||||
food = {'name': f['name'],
|
||||
'plural_name': f['plural_name'],
|
||||
'description': f['description'],
|
||||
'space': self.request.space}
|
||||
|
||||
if f['label_id'] and f['label_id'] in supermarket_categories_dict:
|
||||
food['supermarket_category_id'] = supermarket_categories_dict[f['label_id']]
|
||||
|
||||
food = Food.objects.create(**food)
|
||||
if f['on_hand']:
|
||||
food.onhand_users.add(self.request.user)
|
||||
foods_dict[f['id']] = food.pk
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["ingredient_units"])} units...\n"
|
||||
self.import_log.save()
|
||||
|
||||
units_dict = {}
|
||||
for u in mealie_database['ingredient_units']:
|
||||
if unit := Unit.objects.filter(name=u['name'], space=self.request.space).first():
|
||||
units_dict[u['id']] = unit.pk
|
||||
else:
|
||||
unit = Unit.objects.create(name=u['name'], plural_name=u['plural_name'], description=u['description'], space=self.request.space)
|
||||
units_dict[u['id']] = unit.pk
|
||||
|
||||
recipes_dict = {}
|
||||
recipe_property_factor_dict = {}
|
||||
recipes = []
|
||||
recipe_keyword_relation = []
|
||||
for r in mealie_database['recipes']:
|
||||
if Recipe.objects.filter(space=self.request.space, name=r['name']).exists() and not self.import_duplicates:
|
||||
self.import_log.msg += f"Ignoring {r['name']} because a recipe with this name already exists.\n"
|
||||
self.import_log.save()
|
||||
else:
|
||||
recipe = Recipe.objects.create(
|
||||
waiting_time=parse_time(r['perform_time']),
|
||||
working_time=parse_time(r['prep_time']),
|
||||
description=r['description'][:512],
|
||||
name=r['name'],
|
||||
source_url=r['org_url'],
|
||||
servings=r['recipe_servings'] if r['recipe_servings'] and r['recipe_servings'] != 0 else 1,
|
||||
servings_text=r['recipe_yield'].strip() if r['recipe_yield'] else "",
|
||||
internal=True,
|
||||
created_at=r['created_at'],
|
||||
space=self.request.space,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
|
||||
if not self.nutrition_per_serving:
|
||||
recipe_property_factor_dict[r['id']] = recipe.servings
|
||||
|
||||
self.import_log.msg += self.get_recipe_processed_msg(recipe)
|
||||
self.import_log.imported_recipes += 1
|
||||
self.import_log.save()
|
||||
|
||||
recipes.append(recipe)
|
||||
recipes_dict[r['id']] = recipe.pk
|
||||
recipe_keyword_relation.append(Recipe.keywords.through(recipe_id=recipe.pk, keyword_id=self.keyword.pk))
|
||||
|
||||
Recipe.keywords.through.objects.bulk_create(recipe_keyword_relation, ignore_conflicts=True)
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["recipe_instructions"])} instructions...\n"
|
||||
self.import_log.save()
|
||||
|
||||
steps_relation = []
|
||||
first_step_of_recipe_dict = {}
|
||||
for s in mealie_database['recipe_instructions']:
|
||||
if s['recipe_id'] in recipes_dict:
|
||||
step = Step.objects.create(instruction=(s['text'] if s['text'] else "") + (f" \n {s['summary']}" if s['summary'] else ""),
|
||||
order=s['position'],
|
||||
name=s['title'],
|
||||
space=self.request.space)
|
||||
steps_relation.append(Recipe.steps.through(recipe_id=recipes_dict[s['recipe_id']], step_id=step.pk))
|
||||
if s['recipe_id'] not in first_step_of_recipe_dict:
|
||||
first_step_of_recipe_dict[s['recipe_id']] = step.pk
|
||||
|
||||
for n in mealie_database['notes']:
|
||||
if n['recipe_id'] in recipes_dict:
|
||||
step = Step.objects.create(instruction=n['text'],
|
||||
name=n['title'],
|
||||
order=100,
|
||||
space=self.request.space)
|
||||
steps_relation.append(Recipe.steps.through(recipe_id=recipes_dict[n['recipe_id']], step_id=step.pk))
|
||||
|
||||
Recipe.steps.through.objects.bulk_create(steps_relation)
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["recipes_ingredients"])} ingredients...\n"
|
||||
self.import_log.save()
|
||||
|
||||
ingredients_relation = []
|
||||
for i in mealie_database['recipes_ingredients']:
|
||||
if i['recipe_id'] in recipes_dict:
|
||||
if i['title']:
|
||||
title_ingredient = Ingredient.objects.create(
|
||||
note=i['title'],
|
||||
is_header=True,
|
||||
space=self.request.space,
|
||||
)
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=first_step_of_recipe_dict[i['recipe_id']], ingredient_id=title_ingredient.pk))
|
||||
if i['food_id']:
|
||||
ingredient = Ingredient.objects.create(
|
||||
food_id=foods_dict[i['food_id']] if i['food_id'] in foods_dict else None,
|
||||
unit_id=units_dict[i['unit_id']] if i['unit_id'] in units_dict else None,
|
||||
original_text=i['original_text'],
|
||||
order=i['position'],
|
||||
amount=i['quantity'],
|
||||
note=i['note'],
|
||||
space=self.request.space,
|
||||
)
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=first_step_of_recipe_dict[i['recipe_id']], ingredient_id=ingredient.pk))
|
||||
elif i['note'].strip():
|
||||
amount, unit, food, note = ingredient_parser.parse(i['note'].strip())
|
||||
f = ingredient_parser.get_food(food)
|
||||
u = ingredient_parser.get_unit(unit)
|
||||
ingredient = Ingredient.objects.create(
|
||||
food=f,
|
||||
unit=u,
|
||||
amount=amount,
|
||||
note=note,
|
||||
original_text=i['original_text'],
|
||||
space=self.request.space,
|
||||
)
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=first_step_of_recipe_dict[i['recipe_id']], ingredient_id=ingredient.pk))
|
||||
Step.ingredients.through.objects.bulk_create(ingredients_relation)
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["recipes_to_categories"]) + len(mealie_database["recipes_to_tags"])} category and keyword relations...\n"
|
||||
self.import_log.save()
|
||||
|
||||
recipe_keyword_relation = []
|
||||
for rC in mealie_database['recipes_to_categories']:
|
||||
if rC['recipe_id'] in recipes_dict:
|
||||
recipe_keyword_relation.append(Recipe.keywords.through(recipe_id=recipes_dict[rC['recipe_id']], keyword_id=keywords_categories_dict[rC['category_id']]))
|
||||
|
||||
for rT in mealie_database['recipes_to_tags']:
|
||||
if rT['recipe_id'] in recipes_dict:
|
||||
recipe_keyword_relation.append(Recipe.keywords.through(recipe_id=recipes_dict[rT['recipe_id']], keyword_id=keywords_tags_dict[rT['tag_id']]))
|
||||
|
||||
Recipe.keywords.through.objects.bulk_create(recipe_keyword_relation, ignore_conflicts=True)
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["recipe_nutrition"])} properties...\n"
|
||||
self.import_log.save()
|
||||
|
||||
property_types_dict = {
|
||||
'calories': PropertyType.objects.get_or_create(name=_('Calories'), space=self.request.space, defaults={'unit': 'kcal', 'fdc_id': 1008})[0],
|
||||
'carbohydrate_content': PropertyType.objects.get_or_create(name=_('Carbohydrates'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1005})[0],
|
||||
'cholesterol_content': PropertyType.objects.get_or_create(name=_('Cholesterol'), space=self.request.space, defaults={'unit': 'mg', 'fdc_id': 1253})[0],
|
||||
'fat_content': PropertyType.objects.get_or_create(name=_('Fat'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1004})[0],
|
||||
'fiber_content': PropertyType.objects.get_or_create(name=_('Fiber'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1079})[0],
|
||||
'protein_content': PropertyType.objects.get_or_create(name=_('Protein'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1003})[0],
|
||||
'saturated_fat_content': PropertyType.objects.get_or_create(name=_('Saturated Fat'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1258})[0],
|
||||
'sodium_content': PropertyType.objects.get_or_create(name=_('Sodium'), space=self.request.space, defaults={'unit': 'mg', 'fdc_id': 1093})[0],
|
||||
'sugar_content': PropertyType.objects.get_or_create(name=_('Sugar'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1063})[0],
|
||||
'trans_fat_content': PropertyType.objects.get_or_create(name=_('Trans Fat'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1257})[0],
|
||||
'unsaturated_fat_content': PropertyType.objects.get_or_create(name=_('Unsaturated Fat'), space=self.request.space, defaults={'unit': 'g'})[0],
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
recipe_properties_relation = []
|
||||
properties_relation = []
|
||||
for r in mealie_database['recipe_nutrition']:
|
||||
if r['recipe_id'] in recipes_dict:
|
||||
for key in property_types_dict:
|
||||
if r[key]:
|
||||
properties_relation.append(
|
||||
Property(property_type_id=property_types_dict[key].pk,
|
||||
property_amount=Decimal(str(r[key])) / (
|
||||
Decimal(str(recipe_property_factor_dict[r['recipe_id']])) if r['recipe_id'] in recipe_property_factor_dict else 1),
|
||||
open_data_food_slug=r['recipe_id'],
|
||||
space=self.request.space))
|
||||
properties = Property.objects.bulk_create(properties_relation)
|
||||
property_ids = []
|
||||
for p in properties:
|
||||
recipe_properties_relation.append(Recipe.properties.through(recipe_id=recipes_dict[p.open_data_food_slug], property_id=p.pk))
|
||||
property_ids.append(p.pk)
|
||||
Recipe.properties.through.objects.bulk_create(recipe_properties_relation, ignore_conflicts=True)
|
||||
Property.objects.filter(id__in=property_ids).update(open_data_food_slug=None)
|
||||
|
||||
# delete unused property types
|
||||
for pT in property_types_dict:
|
||||
try:
|
||||
property_types_dict[pT].delete()
|
||||
except:
|
||||
pass
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["recipe_comments"]) + len(mealie_database["recipe_timeline_events"])} comments and cook logs...\n"
|
||||
self.import_log.save()
|
||||
|
||||
cook_log_list = []
|
||||
for c in mealie_database['recipe_comments']:
|
||||
if c['recipe_id'] in recipes_dict:
|
||||
cook_log_list.append(CookLog(
|
||||
recipe_id=recipes_dict[c['recipe_id']],
|
||||
comment=c['text'],
|
||||
created_at=c['created_at'],
|
||||
created_by=self.request.user,
|
||||
space=self.request.space,
|
||||
))
|
||||
|
||||
for c in mealie_database['recipe_timeline_events']:
|
||||
if c['recipe_id'] in recipes_dict:
|
||||
if c['event_type'] == 'comment':
|
||||
cook_log_list.append(CookLog(
|
||||
recipe_id=recipes_dict[c['recipe_id']],
|
||||
comment=c['message'],
|
||||
created_at=c['created_at'],
|
||||
created_by=self.request.user,
|
||||
space=self.request.space,
|
||||
))
|
||||
|
||||
CookLog.objects.bulk_create(cook_log_list)
|
||||
|
||||
if self.import_meal_plans:
|
||||
self.import_log.msg += f"Importing {len(mealie_database["group_meal_plans"])} meal plans...\n"
|
||||
self.import_log.save()
|
||||
|
||||
meal_types_dict = {}
|
||||
meal_plans = []
|
||||
for m in mealie_database['group_meal_plans']:
|
||||
if m['recipe_id'] in recipes_dict:
|
||||
if not m['entry_type'] in meal_types_dict:
|
||||
meal_type = MealType.objects.get_or_create(name=m['entry_type'], created_by=self.request.user, space=self.request.space)[0]
|
||||
meal_types_dict[m['entry_type']] = meal_type.pk
|
||||
meal_plans.append(MealPlan(
|
||||
recipe_id=recipes_dict[m['recipe_id']] if m['recipe_id'] else None,
|
||||
title=m['title'] if m['title'] else "",
|
||||
note=m['text'] if m['text'] else "",
|
||||
from_date=m['date'],
|
||||
to_date=m['date'],
|
||||
meal_type_id=meal_types_dict[m['entry_type']],
|
||||
created_by=self.request.user,
|
||||
space=self.request.space,
|
||||
))
|
||||
|
||||
MealPlan.objects.bulk_create(meal_plans)
|
||||
|
||||
if self.import_shopping_lists:
|
||||
self.import_log.msg += f"Importing {len(mealie_database["shopping_list_items"])} shopping list items...\n"
|
||||
self.import_log.save()
|
||||
|
||||
shopping_list_items = []
|
||||
for sli in mealie_database['shopping_list_items']:
|
||||
if not sli['checked']:
|
||||
if sli['food_id']:
|
||||
shopping_list_items.append(ShoppingListEntry(
|
||||
amount=sli['quantity'],
|
||||
unit_id=units_dict[sli['unit_id']] if sli['unit_id'] else None,
|
||||
food_id=foods_dict[sli['food_id']] if sli['food_id'] else None,
|
||||
created_by=self.request.user,
|
||||
space=self.request.space,
|
||||
))
|
||||
elif not sli['food_id'] and sli['note'].strip():
|
||||
amount, unit, food, note = ingredient_parser.parse(sli['note'].strip())
|
||||
f = ingredient_parser.get_food(food)
|
||||
u = ingredient_parser.get_unit(unit)
|
||||
shopping_list_items.append(ShoppingListEntry(
|
||||
amount=amount,
|
||||
unit=u,
|
||||
food=f,
|
||||
created_by=self.request.user,
|
||||
space=self.request.space,
|
||||
))
|
||||
ShoppingListEntry.objects.bulk_create(shopping_list_items)
|
||||
|
||||
self.import_log.msg += f"Importing Images. This might take some time ...\n"
|
||||
self.import_log.save()
|
||||
for r in mealie_database['recipes']:
|
||||
try:
|
||||
if recipe := Recipe.objects.filter(pk=recipes_dict[r['id']]).first():
|
||||
self.import_recipe_image(recipe, BytesIO(file.read(f'data/recipes/{str(uuid.UUID(str(r['id'])))}/images/original.webp')), filetype='.webp')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return recipes
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
@@ -14,8 +14,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: 2025-02-16 14:58+0000\n"
|
||||
"Last-Translator: Elvis Gosselin <elvis.gosselin@tutanota.com>\n"
|
||||
"PO-Revision-Date: 2025-08-10 11:36+0000\n"
|
||||
"Last-Translator: Enzo La Rafale <enzo.chaussivert@gmail.com>\n"
|
||||
"Language-Team: French <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/fr/>\n"
|
||||
"Language: fr\n"
|
||||
@@ -405,7 +405,7 @@ msgstr "Rubrique"
|
||||
|
||||
#: .\cookbook\management\commands\fix_duplicate_properties.py:15
|
||||
msgid "Fixes foods with "
|
||||
msgstr ""
|
||||
msgstr "Corriger les aliments avec "
|
||||
|
||||
#: .\cookbook\management\commands\rebuildindex.py:14
|
||||
msgid "Rebuilds full text search index on Recipe"
|
||||
@@ -442,8 +442,6 @@ msgid "Other"
|
||||
msgstr "Autre"
|
||||
|
||||
#: .\cookbook\migrations\0190_auto_20230525_1506.py:17
|
||||
#, fuzzy
|
||||
#| msgid "Fats"
|
||||
msgid "Fat"
|
||||
msgstr "Matières grasses"
|
||||
|
||||
@@ -451,7 +449,7 @@ msgstr "Matières grasses"
|
||||
#: .\cookbook\migrations\0190_auto_20230525_1506.py:18
|
||||
#: .\cookbook\migrations\0190_auto_20230525_1506.py:19
|
||||
msgid "g"
|
||||
msgstr ""
|
||||
msgstr "g"
|
||||
|
||||
#: .\cookbook\migrations\0190_auto_20230525_1506.py:18
|
||||
msgid "Carbohydrates"
|
||||
@@ -467,7 +465,7 @@ msgstr "Calories"
|
||||
|
||||
#: .\cookbook\migrations\0190_auto_20230525_1506.py:20
|
||||
msgid "kcal"
|
||||
msgstr ""
|
||||
msgstr "kcal"
|
||||
|
||||
#: .\cookbook\models.py:325
|
||||
msgid ""
|
||||
@@ -554,30 +552,24 @@ msgid "Instruction Replace"
|
||||
msgstr "Remplacer l'instruction"
|
||||
|
||||
#: .\cookbook\models.py:1472
|
||||
#, fuzzy
|
||||
#| msgid "New Unit"
|
||||
msgid "Never Unit"
|
||||
msgstr "Nouvelle unité"
|
||||
msgstr "Aucune unité"
|
||||
|
||||
#: .\cookbook\models.py:1473
|
||||
msgid "Transpose Words"
|
||||
msgstr ""
|
||||
msgstr "Transposer les mots"
|
||||
|
||||
#: .\cookbook\models.py:1474
|
||||
#, fuzzy
|
||||
#| msgid "Food Alias"
|
||||
msgid "Food Replace"
|
||||
msgstr "Aliment équivalent"
|
||||
msgstr "Aliment alternatif"
|
||||
|
||||
#: .\cookbook\models.py:1475
|
||||
#, fuzzy
|
||||
#| msgid "Description Replace"
|
||||
msgid "Unit Replace"
|
||||
msgstr "Remplacer la Description"
|
||||
msgstr "Remplacer l'unité"
|
||||
|
||||
#: .\cookbook\models.py:1476
|
||||
msgid "Name Replace"
|
||||
msgstr ""
|
||||
msgstr "Remplacer le nom"
|
||||
|
||||
#: .\cookbook\models.py:1503 .\cookbook\views\delete.py:40
|
||||
#: .\cookbook\views\edit.py:210 .\cookbook\views\new.py:39
|
||||
@@ -1040,10 +1032,8 @@ msgid "Properties"
|
||||
msgstr "Propriétés"
|
||||
|
||||
#: .\cookbook\templates\base.html:301 .\cookbook\views\lists.py:255
|
||||
#, fuzzy
|
||||
#| msgid "Account Connections"
|
||||
msgid "Unit Conversions"
|
||||
msgstr "Comptes connectés"
|
||||
msgstr "Conversions d'unités"
|
||||
|
||||
#: .\cookbook\templates\base.html:318 .\cookbook\templates\index.html:47
|
||||
msgid "Import Recipe"
|
||||
@@ -1063,10 +1053,8 @@ msgid "Space Settings"
|
||||
msgstr "Paramètres de groupe"
|
||||
|
||||
#: .\cookbook\templates\base.html:340
|
||||
#, fuzzy
|
||||
#| msgid "External Recipes"
|
||||
msgid "External Connectors"
|
||||
msgstr "Recettes externes"
|
||||
msgstr "Connecteurs externes"
|
||||
|
||||
#: .\cookbook\templates\base.html:345 .\cookbook\templates\system.html:13
|
||||
msgid "System"
|
||||
@@ -1530,10 +1518,8 @@ msgid "Back"
|
||||
msgstr "Retour"
|
||||
|
||||
#: .\cookbook\templates\property_editor.html:7
|
||||
#, fuzzy
|
||||
#| msgid "Ingredient Editor"
|
||||
msgid "Property Editor"
|
||||
msgstr "Éditeur d’ingrédients"
|
||||
msgstr "Éditeur de propriété"
|
||||
|
||||
#: .\cookbook\templates\recipe_view.html:36
|
||||
msgid "Comments"
|
||||
@@ -2011,10 +1997,8 @@ msgid "Sign in using"
|
||||
msgstr "Se connecter avec"
|
||||
|
||||
#: .\cookbook\templates\space_manage.html:7
|
||||
#, fuzzy
|
||||
#| msgid "Space Membership"
|
||||
msgid "Space Management"
|
||||
msgstr "Adhésion à l'espace"
|
||||
msgstr "Gestion de l'espace"
|
||||
|
||||
#: .\cookbook\templates\space_manage.html:26
|
||||
msgid "Space:"
|
||||
@@ -2227,10 +2211,8 @@ msgid "Info"
|
||||
msgstr "Info"
|
||||
|
||||
#: .\cookbook\templates\system.html:110 .\cookbook\templates\system.html:127
|
||||
#, fuzzy
|
||||
#| msgid "Use fractions"
|
||||
msgid "Migrations"
|
||||
msgstr "Utiliser les fractions"
|
||||
msgstr "Migrations"
|
||||
|
||||
#: .\cookbook\templates\system.html:116
|
||||
msgid ""
|
||||
@@ -2265,10 +2247,8 @@ msgid "Hide"
|
||||
msgstr "Cacher"
|
||||
|
||||
#: .\cookbook\templates\system.html:210
|
||||
#, fuzzy
|
||||
#| msgid "Show Log"
|
||||
msgid "Show"
|
||||
msgstr "Afficher le journal"
|
||||
msgstr "Afficher"
|
||||
|
||||
#: .\cookbook\templates\url_import.html:8
|
||||
msgid "URL Import"
|
||||
@@ -2353,11 +2333,9 @@ msgstr ""
|
||||
"MM-DD."
|
||||
|
||||
#: .\cookbook\views\api.py:744
|
||||
#, fuzzy
|
||||
#| msgid "ID of recipe a step is part of. For multiple repeat parameter."
|
||||
msgid "Filter meal plans with MealType ID. For multiple repeat parameter."
|
||||
msgstr ""
|
||||
"Identifiant de la recette dont fait partie une étape. Pour plusieurs "
|
||||
"Filtrer le planning des repas avec l'identifiant MealType. Pour plusieurs "
|
||||
"paramètres de répétition."
|
||||
|
||||
#: .\cookbook\views\api.py:872
|
||||
@@ -2460,18 +2438,27 @@ msgstr ""
|
||||
#: .\cookbook\views\api.py:922
|
||||
msgid "ID of book a recipe should be in. For multiple repeat parameter."
|
||||
msgstr ""
|
||||
"ID du livre dans lequel une recette doit se trouver. Pour plusieurs "
|
||||
"paramètres de répétition."
|
||||
|
||||
#: .\cookbook\views\api.py:923
|
||||
msgid "Book IDs, repeat for multiple. Return recipes with any of the books"
|
||||
msgstr ""
|
||||
"IDs de livre, répéter pour plusieurs livres. Renvoie les recettes dans "
|
||||
"n'importe quel livre."
|
||||
|
||||
#: .\cookbook\views\api.py:924
|
||||
msgid "Book IDs, repeat for multiple. Return recipes with all of the books."
|
||||
msgstr ""
|
||||
"IDs de livre, répéter pour plusieurs livres. Renvoie les recettes dans tous "
|
||||
"les livre."
|
||||
|
||||
#: .\cookbook\views\api.py:925
|
||||
#, fuzzy
|
||||
msgid "Book IDs, repeat for multiple. Exclude recipes with any of the books."
|
||||
msgstr ""
|
||||
"Identifiants de livres : répéter pour plusieurs. Exclure les recettes de "
|
||||
"l'un des livres."
|
||||
|
||||
#: .\cookbook\views\api.py:926
|
||||
msgid "Book IDs, repeat for multiple. Exclude recipes with all of the books."
|
||||
|
||||
@@ -13,8 +13,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: 2025-02-16 14:58+0000\n"
|
||||
"Last-Translator: Cots Partier <cots.pastier.34@icloud.com>\n"
|
||||
"PO-Revision-Date: 2025-07-31 19:14+0000\n"
|
||||
"Last-Translator: Justin Straver <justin.straver@gmail.com>\n"
|
||||
"Language-Team: Dutch <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/nl/>\n"
|
||||
"Language: nl\n"
|
||||
@@ -46,7 +46,7 @@ msgstr "Voorbereidingstijd in minuten"
|
||||
|
||||
#: .\cookbook\forms.py:62
|
||||
msgid "Waiting time (cooking/baking) in minutes"
|
||||
msgstr "Wacht tijd in minuten (koken en bakken)"
|
||||
msgstr "Wachttijd in minuten (koken en bakken)"
|
||||
|
||||
#: .\cookbook\forms.py:63 .\cookbook\forms.py:222 .\cookbook\forms.py:246
|
||||
msgid "Path"
|
||||
|
||||
@@ -8,7 +8,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-07-28 17:58+0000\n"
|
||||
"PO-Revision-Date: 2025-08-01 08:40+0000\n"
|
||||
"Last-Translator: Aleksey <streltsov3@gmail.com>\n"
|
||||
"Language-Team: Russian <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/ru/>\n"
|
||||
@@ -446,30 +446,32 @@ msgstr "г"
|
||||
|
||||
#: .\cookbook\migrations\0190_auto_20230525_1506.py:18
|
||||
msgid "Carbohydrates"
|
||||
msgstr ""
|
||||
msgstr "Углеводы"
|
||||
|
||||
#: .\cookbook\migrations\0190_auto_20230525_1506.py:19
|
||||
msgid "Proteins"
|
||||
msgstr ""
|
||||
msgstr "Протеины"
|
||||
|
||||
#: .\cookbook\migrations\0190_auto_20230525_1506.py:20
|
||||
msgid "Calories"
|
||||
msgstr ""
|
||||
msgstr "Калории"
|
||||
|
||||
#: .\cookbook\migrations\0190_auto_20230525_1506.py:20
|
||||
msgid "kcal"
|
||||
msgstr ""
|
||||
msgstr "ккал"
|
||||
|
||||
#: .\cookbook\models.py:325
|
||||
msgid ""
|
||||
"Maximum file storage for space in MB. 0 for unlimited, -1 to disable file "
|
||||
"upload."
|
||||
msgstr ""
|
||||
"Максимальный объем файлового хранилища в МБ. 0 — без ограничений, -1 — чтобы "
|
||||
"отключить загрузку файлов."
|
||||
|
||||
#: .\cookbook\models.py:454 .\cookbook\templates\search.html:7
|
||||
#: .\cookbook\templates\settings.html:18
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
msgstr "Поиск"
|
||||
|
||||
#: .\cookbook\models.py:455 .\cookbook\templates\base.html:114
|
||||
#: .\cookbook\templates\meal_plan.html:7
|
||||
@@ -488,27 +490,27 @@ msgstr "Покупки"
|
||||
|
||||
#: .\cookbook\models.py:752
|
||||
msgid " is part of a recipe step and cannot be deleted"
|
||||
msgstr ""
|
||||
msgstr " является частью шага рецепта и не может быть удален"
|
||||
|
||||
#: .\cookbook\models.py:918
|
||||
msgid "Nutrition"
|
||||
msgstr ""
|
||||
msgstr "Питательная ценность"
|
||||
|
||||
#: .\cookbook\models.py:918
|
||||
msgid "Allergen"
|
||||
msgstr ""
|
||||
msgstr "Аллерген"
|
||||
|
||||
#: .\cookbook\models.py:919
|
||||
msgid "Price"
|
||||
msgstr ""
|
||||
msgstr "Цена"
|
||||
|
||||
#: .\cookbook\models.py:919
|
||||
msgid "Goal"
|
||||
msgstr ""
|
||||
msgstr "Цель"
|
||||
|
||||
#: .\cookbook\models.py:1408 .\cookbook\templates\search_info.html:28
|
||||
msgid "Simple"
|
||||
msgstr ""
|
||||
msgstr "Простой"
|
||||
|
||||
#: .\cookbook\models.py:1409 .\cookbook\templates\search_info.html:33
|
||||
msgid "Phrase"
|
||||
@@ -516,19 +518,19 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\models.py:1410 .\cookbook\templates\search_info.html:38
|
||||
msgid "Web"
|
||||
msgstr ""
|
||||
msgstr "Веб"
|
||||
|
||||
#: .\cookbook\models.py:1411 .\cookbook\templates\search_info.html:47
|
||||
msgid "Raw"
|
||||
msgstr ""
|
||||
msgstr "Необработанный"
|
||||
|
||||
#: .\cookbook\models.py:1467
|
||||
msgid "Food Alias"
|
||||
msgstr ""
|
||||
msgstr "Синоним продукта"
|
||||
|
||||
#: .\cookbook\models.py:1468
|
||||
msgid "Unit Alias"
|
||||
msgstr ""
|
||||
msgstr "Синоним единицы измерения"
|
||||
|
||||
#: .\cookbook\models.py:1469
|
||||
#, fuzzy
|
||||
@@ -538,149 +540,154 @@ msgstr "Ключевые слова"
|
||||
|
||||
#: .\cookbook\models.py:1470
|
||||
msgid "Description Replace"
|
||||
msgstr ""
|
||||
msgstr "Замена описания"
|
||||
|
||||
#: .\cookbook\models.py:1471
|
||||
msgid "Instruction Replace"
|
||||
msgstr ""
|
||||
msgstr "Замена инструкции"
|
||||
|
||||
#: .\cookbook\models.py:1472
|
||||
#, fuzzy
|
||||
#| msgid "New Unit"
|
||||
msgid "Never Unit"
|
||||
msgstr "Новый юнит"
|
||||
msgstr "Без единицы"
|
||||
|
||||
#: .\cookbook\models.py:1473
|
||||
msgid "Transpose Words"
|
||||
msgstr ""
|
||||
msgstr "Транспонировать слова"
|
||||
|
||||
#: .\cookbook\models.py:1474
|
||||
msgid "Food Replace"
|
||||
msgstr ""
|
||||
msgstr "Замена продукта"
|
||||
|
||||
#: .\cookbook\models.py:1475
|
||||
msgid "Unit Replace"
|
||||
msgstr ""
|
||||
msgstr "Замена единицы измерения"
|
||||
|
||||
#: .\cookbook\models.py:1476
|
||||
msgid "Name Replace"
|
||||
msgstr ""
|
||||
msgstr "Замена названия"
|
||||
|
||||
#: .\cookbook\models.py:1503 .\cookbook\views\delete.py:40
|
||||
#: .\cookbook\views\edit.py:210 .\cookbook\views\new.py:39
|
||||
msgid "Recipe"
|
||||
msgstr ""
|
||||
msgstr "Рецепт"
|
||||
|
||||
#: .\cookbook\models.py:1504
|
||||
#, fuzzy
|
||||
#| msgid "New Food"
|
||||
msgid "Food"
|
||||
msgstr "Новый продукт"
|
||||
msgstr "Продукт"
|
||||
|
||||
#: .\cookbook\models.py:1505 .\cookbook\templates\base.html:149
|
||||
msgid "Keyword"
|
||||
msgstr ""
|
||||
msgstr "Ключевое слово"
|
||||
|
||||
#: .\cookbook\serializer.py:222
|
||||
msgid "File uploads are not enabled for this Space."
|
||||
msgstr ""
|
||||
msgstr "Загрузка файлов не разрешена для этого пространства."
|
||||
|
||||
#: .\cookbook\serializer.py:233
|
||||
msgid "You have reached your file upload limit."
|
||||
msgstr ""
|
||||
msgstr "Вы достигли лимита загрузки файлов."
|
||||
|
||||
#: .\cookbook\serializer.py:328
|
||||
msgid "Cannot modify Space owner permission."
|
||||
msgstr ""
|
||||
msgstr "Нельзя изменить разрешения владельца пространства."
|
||||
|
||||
#: .\cookbook\serializer.py:1270
|
||||
msgid "Hello"
|
||||
msgstr ""
|
||||
msgstr "Привет"
|
||||
|
||||
#: .\cookbook\serializer.py:1270
|
||||
msgid "You have been invited by "
|
||||
msgstr ""
|
||||
msgstr "Вас пригласил "
|
||||
|
||||
#: .\cookbook\serializer.py:1272
|
||||
msgid " to join their Tandoor Recipes space "
|
||||
msgstr ""
|
||||
msgstr " присоединиться к их пространству рецептов Tandoor "
|
||||
|
||||
#: .\cookbook\serializer.py:1274
|
||||
msgid "Click the following link to activate your account: "
|
||||
msgstr ""
|
||||
msgstr "Нажмите на следующую ссылку, чтобы активировать аккаунт: "
|
||||
|
||||
#: .\cookbook\serializer.py:1276
|
||||
msgid ""
|
||||
"If the link does not work use the following code to manually join the space: "
|
||||
msgstr ""
|
||||
"Если ссылка не работает, используйте следующий код для ручного присоединения "
|
||||
"к пространству: "
|
||||
|
||||
#: .\cookbook\serializer.py:1278
|
||||
msgid "The invitation is valid until "
|
||||
msgstr ""
|
||||
msgstr "Приглашение действительно до "
|
||||
|
||||
#: .\cookbook\serializer.py:1280
|
||||
msgid ""
|
||||
"Tandoor Recipes is an Open Source recipe manager. Check it out on GitHub "
|
||||
msgstr ""
|
||||
"Tandoor Recipes — это открытый менеджер рецептов. Посмотрите его на GitHub "
|
||||
|
||||
#: .\cookbook\serializer.py:1283
|
||||
msgid "Tandoor Recipes Invite"
|
||||
msgstr ""
|
||||
msgstr "Приглашение в Tandoor Recipes"
|
||||
|
||||
#: .\cookbook\serializer.py:1426
|
||||
msgid "Existing shopping list to update"
|
||||
msgstr ""
|
||||
msgstr "Существующий список покупок для обновления"
|
||||
|
||||
#: .\cookbook\serializer.py:1428
|
||||
msgid ""
|
||||
"List of ingredient IDs from the recipe to add, if not provided all "
|
||||
"ingredients will be added."
|
||||
msgstr ""
|
||||
"Список ID ингредиентов из рецепта для добавления, если не указано — будут "
|
||||
"добавлены все."
|
||||
|
||||
#: .\cookbook\serializer.py:1430
|
||||
msgid ""
|
||||
"Providing a list_recipe ID and servings of 0 will delete that shopping list."
|
||||
msgstr ""
|
||||
"Если указать ID списка рецептов и порции 0 — этот список покупок будет "
|
||||
"удалён."
|
||||
|
||||
#: .\cookbook\serializer.py:1439
|
||||
msgid "Amount of food to add to the shopping list"
|
||||
msgstr ""
|
||||
msgstr "Количество продукта для добавления в список покупок"
|
||||
|
||||
#: .\cookbook\serializer.py:1441
|
||||
msgid "ID of unit to use for the shopping list"
|
||||
msgstr ""
|
||||
msgstr "ID единицы измерения для списка покупок"
|
||||
|
||||
#: .\cookbook\serializer.py:1443
|
||||
msgid "When set to true will delete all food from active shopping lists."
|
||||
msgstr ""
|
||||
msgstr "Если включено, удалит все продукты из активных списков покупок."
|
||||
|
||||
#: .\cookbook\tables.py:69 .\cookbook\tables.py:83
|
||||
#: .\cookbook\templates\generic\delete_template.html:7
|
||||
#: .\cookbook\templates\generic\delete_template.html:15
|
||||
#: .\cookbook\templates\generic\edit_template.html:28
|
||||
msgid "Delete"
|
||||
msgstr ""
|
||||
msgstr "Удалить"
|
||||
|
||||
#: .\cookbook\templates\404.html:5
|
||||
msgid "404 Error"
|
||||
msgstr ""
|
||||
msgstr "Ошибка 404"
|
||||
|
||||
#: .\cookbook\templates\404.html:18
|
||||
msgid "The page you are looking for could not be found."
|
||||
msgstr ""
|
||||
msgstr "Страница, которую вы ищете, не найдена."
|
||||
|
||||
#: .\cookbook\templates\404.html:33
|
||||
msgid "Take me Home"
|
||||
msgstr ""
|
||||
msgstr "На главную"
|
||||
|
||||
#: .\cookbook\templates\404.html:35
|
||||
msgid "Report a Bug"
|
||||
msgstr ""
|
||||
msgstr "Сообщить об ошибке"
|
||||
|
||||
#: .\cookbook\templates\account\email.html:6
|
||||
#: .\cookbook\templates\account\email.html:17
|
||||
msgid "E-mail Addresses"
|
||||
msgstr ""
|
||||
msgstr "Электронные адреса"
|
||||
|
||||
#: .\cookbook\templates\account\email.html:12
|
||||
#: .\cookbook\templates\account\password_change.html:11
|
||||
@@ -690,68 +697,70 @@ msgstr ""
|
||||
#: .\cookbook\templates\socialaccount\connections.html:10
|
||||
#: .\cookbook\templates\user_settings.html:8
|
||||
msgid "Settings"
|
||||
msgstr ""
|
||||
msgstr "Настройки"
|
||||
|
||||
#: .\cookbook\templates\account\email.html:13
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
msgstr "Электронная почта"
|
||||
|
||||
#: .\cookbook\templates\account\email.html:19
|
||||
msgid "The following e-mail addresses are associated with your account:"
|
||||
msgstr ""
|
||||
msgstr "Следующие электронные адреса связаны с вашим аккаунтом:"
|
||||
|
||||
#: .\cookbook\templates\account\email.html:36
|
||||
msgid "Verified"
|
||||
msgstr ""
|
||||
msgstr "Подтверждён"
|
||||
|
||||
#: .\cookbook\templates\account\email.html:38
|
||||
msgid "Unverified"
|
||||
msgstr ""
|
||||
msgstr "Не подтверждён"
|
||||
|
||||
#: .\cookbook\templates\account\email.html:40
|
||||
msgid "Primary"
|
||||
msgstr ""
|
||||
msgstr "Основной"
|
||||
|
||||
#: .\cookbook\templates\account\email.html:47
|
||||
msgid "Make Primary"
|
||||
msgstr ""
|
||||
msgstr "Сделать основным"
|
||||
|
||||
#: .\cookbook\templates\account\email.html:49
|
||||
msgid "Re-send Verification"
|
||||
msgstr ""
|
||||
msgstr "Отправить подтверждение повторно"
|
||||
|
||||
#: .\cookbook\templates\account\email.html:50
|
||||
#: .\cookbook\templates\generic\delete_template.html:57
|
||||
#: .\cookbook\templates\socialaccount\connections.html:44
|
||||
msgid "Remove"
|
||||
msgstr ""
|
||||
msgstr "Удалить"
|
||||
|
||||
#: .\cookbook\templates\account\email.html:58
|
||||
msgid "Warning:"
|
||||
msgstr ""
|
||||
msgstr "Внимание:"
|
||||
|
||||
#: .\cookbook\templates\account\email.html:58
|
||||
msgid ""
|
||||
"You currently do not have any e-mail address set up. You should really add "
|
||||
"an e-mail address so you can receive notifications, reset your password, etc."
|
||||
msgstr ""
|
||||
"У вас пока не добавлен ни один электронный адрес. Рекомендуется добавить "
|
||||
"адрес для получения уведомлений, сброса пароля и т.д."
|
||||
|
||||
#: .\cookbook\templates\account\email.html:64
|
||||
msgid "Add E-mail Address"
|
||||
msgstr ""
|
||||
msgstr "Добавить электронный адрес"
|
||||
|
||||
#: .\cookbook\templates\account\email.html:69
|
||||
msgid "Add E-mail"
|
||||
msgstr ""
|
||||
msgstr "Добавить e-mail"
|
||||
|
||||
#: .\cookbook\templates\account\email.html:79
|
||||
msgid "Do you really want to remove the selected e-mail address?"
|
||||
msgstr ""
|
||||
msgstr "Вы действительно хотите удалить выбранный электронный адрес?"
|
||||
|
||||
#: .\cookbook\templates\account\email_confirm.html:6
|
||||
#: .\cookbook\templates\account\email_confirm.html:10
|
||||
msgid "Confirm E-mail Address"
|
||||
msgstr ""
|
||||
msgstr "Подтвердить электронный адрес"
|
||||
|
||||
#: .\cookbook\templates\account\email_confirm.html:16
|
||||
#, python-format
|
||||
@@ -765,7 +774,7 @@ msgstr ""
|
||||
#: .\cookbook\templates\account\email_confirm.html:22
|
||||
#: .\cookbook\templates\generic\delete_template.html:72
|
||||
msgid "Confirm"
|
||||
msgstr ""
|
||||
msgstr "Подтвердить"
|
||||
|
||||
#: .\cookbook\templates\account\email_confirm.html:29
|
||||
#, python-format
|
||||
@@ -778,7 +787,7 @@ msgstr ""
|
||||
#: .\cookbook\templates\account\login.html:8 .\cookbook\templates\base.html:388
|
||||
#: .\cookbook\templates\openid\login.html:8
|
||||
msgid "Login"
|
||||
msgstr ""
|
||||
msgstr "Логин"
|
||||
|
||||
#: .\cookbook\templates\account\login.html:15
|
||||
#: .\cookbook\templates\account\login.html:31
|
||||
@@ -790,7 +799,7 @@ msgstr ""
|
||||
#: .\cookbook\templates\openid\login.html:26
|
||||
#: .\cookbook\templates\socialaccount\authentication_error.html:15
|
||||
msgid "Sign In"
|
||||
msgstr ""
|
||||
msgstr "Войти"
|
||||
|
||||
#: .\cookbook\templates\account\login.html:34
|
||||
#: .\cookbook\templates\account\password_reset.html:41
|
||||
@@ -798,34 +807,35 @@ msgstr ""
|
||||
#: .\cookbook\templates\socialaccount\signup.html:8
|
||||
#: .\cookbook\templates\socialaccount\signup.html:57
|
||||
msgid "Sign Up"
|
||||
msgstr ""
|
||||
msgstr "Зарегистрироваться"
|
||||
|
||||
#: .\cookbook\templates\account\login.html:38
|
||||
msgid "Lost your password?"
|
||||
msgstr ""
|
||||
msgstr "Забыли пароль?"
|
||||
|
||||
#: .\cookbook\templates\account\login.html:39
|
||||
#: .\cookbook\templates\account\password_reset.html:29
|
||||
msgid "Reset My Password"
|
||||
msgstr ""
|
||||
msgstr "Сбросить мой пароль"
|
||||
|
||||
#: .\cookbook\templates\account\login.html:50
|
||||
msgid "Social Login"
|
||||
msgstr ""
|
||||
msgstr "Вход через соцсети"
|
||||
|
||||
#: .\cookbook\templates\account\login.html:51
|
||||
msgid "You can use any of the following providers to sign in."
|
||||
msgstr ""
|
||||
msgstr "Для входа вы можете использовать любой из следующих сервисов."
|
||||
|
||||
#: .\cookbook\templates\account\logout.html:5
|
||||
#: .\cookbook\templates\account\logout.html:9
|
||||
#: .\cookbook\templates\account\logout.html:18
|
||||
#, fuzzy
|
||||
msgid "Sign Out"
|
||||
msgstr ""
|
||||
msgstr "Выйти"
|
||||
|
||||
#: .\cookbook\templates\account\logout.html:11
|
||||
msgid "Are you sure you want to sign out?"
|
||||
msgstr ""
|
||||
msgstr "Вы действительно хотите выйти?"
|
||||
|
||||
#: .\cookbook\templates\account\password_change.html:6
|
||||
#: .\cookbook\templates\account\password_change.html:16
|
||||
@@ -835,43 +845,48 @@ msgstr ""
|
||||
#: .\cookbook\templates\account\password_reset_from_key_done.html:7
|
||||
#: .\cookbook\templates\account\password_reset_from_key_done.html:13
|
||||
msgid "Change Password"
|
||||
msgstr ""
|
||||
msgstr "Изменить пароль"
|
||||
|
||||
#: .\cookbook\templates\account\password_change.html:12
|
||||
#: .\cookbook\templates\account\password_set.html:12
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
msgstr "Пароль"
|
||||
|
||||
#: .\cookbook\templates\account\password_change.html:22
|
||||
#, fuzzy
|
||||
msgid "Forgot Password?"
|
||||
msgstr ""
|
||||
msgstr "Забыли пароль?"
|
||||
|
||||
#: .\cookbook\templates\account\password_reset.html:7
|
||||
#: .\cookbook\templates\account\password_reset.html:13
|
||||
#: .\cookbook\templates\account\password_reset_done.html:7
|
||||
#: .\cookbook\templates\account\password_reset_done.html:18
|
||||
msgid "Password Reset"
|
||||
msgstr ""
|
||||
msgstr "Сброс пароля"
|
||||
|
||||
#: .\cookbook\templates\account\password_reset.html:24
|
||||
msgid ""
|
||||
"Forgotten your password? Enter your e-mail address below, and we'll send you "
|
||||
"an e-mail allowing you to reset it."
|
||||
msgstr ""
|
||||
"Забыли пароль? Введите свой электронный адрес ниже, и мы отправим вам письмо "
|
||||
"для сброса пароля."
|
||||
|
||||
#: .\cookbook\templates\account\password_reset.html:32
|
||||
msgid "Password reset is disabled on this instance."
|
||||
msgstr ""
|
||||
msgstr "Сброс пароля отключён для этого экземпляра."
|
||||
|
||||
#: .\cookbook\templates\account\password_reset_done.html:25
|
||||
msgid ""
|
||||
"We have sent you an e-mail. Please contact us if you do not receive it "
|
||||
"within a few minutes."
|
||||
msgstr ""
|
||||
"Мы отправили вам электронное письмо. Пожалуйста, свяжитесь с нами, если не "
|
||||
"получите его в течение нескольких минут."
|
||||
|
||||
#: .\cookbook\templates\account\password_reset_from_key.html:13
|
||||
msgid "Bad Token"
|
||||
msgstr ""
|
||||
msgstr "Неверный токен"
|
||||
|
||||
#: .\cookbook\templates\account\password_reset_from_key.html:25
|
||||
#, python-format
|
||||
@@ -881,71 +896,76 @@ msgid ""
|
||||
" Please request a <a href=\"%(passwd_reset_url)s\">new "
|
||||
"password reset</a>."
|
||||
msgstr ""
|
||||
"Ссылка для сброса пароля недействительна, возможно, она уже была "
|
||||
"использована.\n"
|
||||
" Пожалуйста, запросите <a href=\"%(passwd_reset_url)s\">новую "
|
||||
"ссылку для сброса пароля</a>."
|
||||
|
||||
#: .\cookbook\templates\account\password_reset_from_key.html:33
|
||||
msgid "change password"
|
||||
msgstr ""
|
||||
msgstr "изменить пароль"
|
||||
|
||||
#: .\cookbook\templates\account\password_reset_from_key.html:36
|
||||
#: .\cookbook\templates\account\password_reset_from_key_done.html:19
|
||||
msgid "Your password is now changed."
|
||||
msgstr ""
|
||||
msgstr "Ваш пароль изменён."
|
||||
|
||||
#: .\cookbook\templates\account\password_set.html:6
|
||||
#: .\cookbook\templates\account\password_set.html:16
|
||||
#: .\cookbook\templates\account\password_set.html:21
|
||||
msgid "Set Password"
|
||||
msgstr ""
|
||||
msgstr "Установить пароль"
|
||||
|
||||
#: .\cookbook\templates\account\signup.html:6
|
||||
#, fuzzy
|
||||
msgid "Register"
|
||||
msgstr ""
|
||||
msgstr "Зарегистрироваться"
|
||||
|
||||
#: .\cookbook\templates\account\signup.html:12
|
||||
msgid "Create an Account"
|
||||
msgstr ""
|
||||
msgstr "Создать аккаунт"
|
||||
|
||||
#: .\cookbook\templates\account\signup.html:42
|
||||
#: .\cookbook\templates\socialaccount\signup.html:33
|
||||
msgid "I accept the follwoing"
|
||||
msgstr ""
|
||||
msgstr "Я принимаю следующие"
|
||||
|
||||
#: .\cookbook\templates\account\signup.html:45
|
||||
#: .\cookbook\templates\socialaccount\signup.html:36
|
||||
msgid "Terms and Conditions"
|
||||
msgstr ""
|
||||
msgstr "Условия и положения"
|
||||
|
||||
#: .\cookbook\templates\account\signup.html:48
|
||||
#: .\cookbook\templates\socialaccount\signup.html:39
|
||||
msgid "and"
|
||||
msgstr ""
|
||||
msgstr "и"
|
||||
|
||||
#: .\cookbook\templates\account\signup.html:52
|
||||
#: .\cookbook\templates\socialaccount\signup.html:43
|
||||
msgid "Privacy Policy"
|
||||
msgstr ""
|
||||
msgstr "Политику конфиденциальности"
|
||||
|
||||
#: .\cookbook\templates\account\signup.html:65
|
||||
msgid "Create User"
|
||||
msgstr ""
|
||||
msgstr "Создать пользователя"
|
||||
|
||||
#: .\cookbook\templates\account\signup.html:69
|
||||
msgid "Already have an account?"
|
||||
msgstr ""
|
||||
msgstr "Уже есть аккаунт?"
|
||||
|
||||
#: .\cookbook\templates\account\signup_closed.html:5
|
||||
#: .\cookbook\templates\account\signup_closed.html:11
|
||||
msgid "Sign Up Closed"
|
||||
msgstr ""
|
||||
msgstr "Регистрация закрыта"
|
||||
|
||||
#: .\cookbook\templates\account\signup_closed.html:13
|
||||
msgid "We are sorry, but the sign up is currently closed."
|
||||
msgstr ""
|
||||
msgstr "К сожалению, регистрация сейчас закрыта."
|
||||
|
||||
#: .\cookbook\templates\api_info.html:5 .\cookbook\templates\base.html:378
|
||||
#: .\cookbook\templates\rest_framework\api.html:11
|
||||
msgid "API Documentation"
|
||||
msgstr ""
|
||||
msgstr "Документация API"
|
||||
|
||||
#: .\cookbook\templates\base.html:110 .\cookbook\templates\index.html:87
|
||||
msgid "Recipes"
|
||||
@@ -953,36 +973,38 @@ msgstr "Рецепты"
|
||||
|
||||
#: .\cookbook\templates\base.html:161 .\cookbook\views\lists.py:120
|
||||
msgid "Foods"
|
||||
msgstr ""
|
||||
msgstr "Продукты"
|
||||
|
||||
#: .\cookbook\templates\base.html:173 .\cookbook\views\lists.py:137
|
||||
#, fuzzy
|
||||
msgid "Units"
|
||||
msgstr ""
|
||||
msgstr "Единицы измерения"
|
||||
|
||||
#: .\cookbook\templates\base.html:187
|
||||
msgid "Supermarket"
|
||||
msgstr ""
|
||||
msgstr "Супермаркет"
|
||||
|
||||
#: .\cookbook\templates\base.html:199
|
||||
msgid "Supermarket Category"
|
||||
msgstr ""
|
||||
msgstr "Категория супермаркета"
|
||||
|
||||
#: .\cookbook\templates\base.html:211 .\cookbook\views\lists.py:186
|
||||
#, fuzzy
|
||||
msgid "Automations"
|
||||
msgstr ""
|
||||
msgstr "Автоматизация"
|
||||
|
||||
#: .\cookbook\templates\base.html:225 .\cookbook\views\lists.py:222
|
||||
msgid "Files"
|
||||
msgstr ""
|
||||
msgstr "Файлы"
|
||||
|
||||
#: .\cookbook\templates\base.html:237
|
||||
msgid "Batch Edit"
|
||||
msgstr ""
|
||||
msgstr "Пакетное редактирование"
|
||||
|
||||
#: .\cookbook\templates\base.html:249 .\cookbook\templates\history.html:6
|
||||
#: .\cookbook\templates\history.html:14
|
||||
msgid "History"
|
||||
msgstr ""
|
||||
msgstr "История"
|
||||
|
||||
#: .\cookbook\templates\base.html:263
|
||||
#: .\cookbook\templates\ingredient_editor.html:7
|
||||
@@ -996,58 +1018,58 @@ msgstr "Ингредиенты"
|
||||
#: .\cookbook\templates\export_response.html:7
|
||||
#: .\cookbook\templates\test2.html:14 .\cookbook\templates\test2.html:20
|
||||
msgid "Export"
|
||||
msgstr ""
|
||||
msgstr "Экспортировать"
|
||||
|
||||
#: .\cookbook\templates\base.html:287
|
||||
msgid "Properties"
|
||||
msgstr ""
|
||||
msgstr "Свойства"
|
||||
|
||||
#: .\cookbook\templates\base.html:301 .\cookbook\views\lists.py:255
|
||||
msgid "Unit Conversions"
|
||||
msgstr ""
|
||||
msgstr "Конвертация единиц"
|
||||
|
||||
#: .\cookbook\templates\base.html:318 .\cookbook\templates\index.html:47
|
||||
msgid "Import Recipe"
|
||||
msgstr ""
|
||||
msgstr "Импортировать рецепт"
|
||||
|
||||
#: .\cookbook\templates\base.html:320
|
||||
msgid "Create"
|
||||
msgstr ""
|
||||
msgstr "Создать"
|
||||
|
||||
#: .\cookbook\templates\base.html:333
|
||||
#: .\cookbook\templates\generic\list_template.html:14
|
||||
msgid "External Recipes"
|
||||
msgstr ""
|
||||
msgstr "Внешние рецепты"
|
||||
|
||||
#: .\cookbook\templates\base.html:336 .\cookbook\templates\space_manage.html:15
|
||||
msgid "Space Settings"
|
||||
msgstr ""
|
||||
msgstr "Настройки пространства"
|
||||
|
||||
#: .\cookbook\templates\base.html:340
|
||||
msgid "External Connectors"
|
||||
msgstr ""
|
||||
msgstr "Внешние соединения"
|
||||
|
||||
#: .\cookbook\templates\base.html:345 .\cookbook\templates\system.html:13
|
||||
msgid "System"
|
||||
msgstr ""
|
||||
msgstr "Система"
|
||||
|
||||
#: .\cookbook\templates\base.html:347
|
||||
msgid "Admin"
|
||||
msgstr ""
|
||||
msgstr "Администрирование"
|
||||
|
||||
#: .\cookbook\templates\base.html:351
|
||||
#: .\cookbook\templates\space_overview.html:25
|
||||
msgid "Your Spaces"
|
||||
msgstr ""
|
||||
msgstr "Ваши пространства"
|
||||
|
||||
#: .\cookbook\templates\base.html:362
|
||||
#: .\cookbook\templates\space_overview.html:6
|
||||
msgid "Overview"
|
||||
msgstr ""
|
||||
msgstr "Обзор"
|
||||
|
||||
#: .\cookbook\templates\base.html:372
|
||||
msgid "Markdown Guide"
|
||||
msgstr ""
|
||||
msgstr "Руководство по Markdown"
|
||||
|
||||
#: .\cookbook\templates\base.html:374
|
||||
msgid "GitHub"
|
||||
@@ -1055,53 +1077,55 @@ msgstr "GitHub"
|
||||
|
||||
#: .\cookbook\templates\base.html:376
|
||||
msgid "Translate Tandoor"
|
||||
msgstr ""
|
||||
msgstr "Перевести Tandoor"
|
||||
|
||||
#: .\cookbook\templates\base.html:380
|
||||
msgid "API Browser"
|
||||
msgstr ""
|
||||
msgstr "Браузер API"
|
||||
|
||||
#: .\cookbook\templates\base.html:383
|
||||
msgid "Log out"
|
||||
msgstr ""
|
||||
msgstr "Выйти"
|
||||
|
||||
#: .\cookbook\templates\base.html:406
|
||||
msgid "You are using the free version of Tandor"
|
||||
msgstr ""
|
||||
msgstr "Вы используете бесплатную версию Tandor"
|
||||
|
||||
#: .\cookbook\templates\base.html:407
|
||||
msgid "Upgrade Now"
|
||||
msgstr ""
|
||||
msgstr "Обновить сейчас"
|
||||
|
||||
#: .\cookbook\templates\batch\edit.html:6
|
||||
msgid "Batch edit Category"
|
||||
msgstr ""
|
||||
msgstr "Пакетное редактирование категорий"
|
||||
|
||||
#: .\cookbook\templates\batch\edit.html:15
|
||||
msgid "Batch edit Recipes"
|
||||
msgstr ""
|
||||
msgstr "Пакетное редактирование рецептов"
|
||||
|
||||
#: .\cookbook\templates\batch\edit.html:20
|
||||
msgid "Add the specified keywords to all recipes containing a word"
|
||||
msgstr ""
|
||||
msgstr "Добавить указанные ключевые слова ко всем рецептам, содержащим слово"
|
||||
|
||||
#: .\cookbook\templates\batch\monitor.html:6 .\cookbook\views\edit.py:75
|
||||
msgid "Sync"
|
||||
msgstr ""
|
||||
msgstr "Синхронизировать"
|
||||
|
||||
#: .\cookbook\templates\batch\monitor.html:10
|
||||
msgid "Manage watched Folders"
|
||||
msgstr ""
|
||||
msgstr "Управлять отслеживаемыми папками"
|
||||
|
||||
#: .\cookbook\templates\batch\monitor.html:14
|
||||
msgid ""
|
||||
"On this Page you can manage all storage folder locations that should be "
|
||||
"monitored and synced."
|
||||
msgstr ""
|
||||
"На этой странице вы можете управлять всеми папками для хранения, которые "
|
||||
"нужно отслеживать и синхронизировать."
|
||||
|
||||
#: .\cookbook\templates\batch\monitor.html:16
|
||||
msgid "The path must be in the following format"
|
||||
msgstr ""
|
||||
msgstr "Путь должен быть в следующем формате"
|
||||
|
||||
#: .\cookbook\templates\batch\monitor.html:20
|
||||
#: .\cookbook\templates\forms\edit_import_recipe.html:14
|
||||
@@ -1109,128 +1133,131 @@ msgstr ""
|
||||
#: .\cookbook\templates\generic\new_template.html:23
|
||||
#: .\cookbook\templates\settings.html:57
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
msgstr "Сохранить"
|
||||
|
||||
#: .\cookbook\templates\batch\monitor.html:21
|
||||
msgid "Manage External Storage"
|
||||
msgstr ""
|
||||
msgstr "Управлять внешним хранилищем"
|
||||
|
||||
#: .\cookbook\templates\batch\monitor.html:28
|
||||
msgid "Sync Now!"
|
||||
msgstr ""
|
||||
msgstr "Синхронизировать сейчас!"
|
||||
|
||||
#: .\cookbook\templates\batch\monitor.html:29
|
||||
msgid "Show Recipes"
|
||||
msgstr ""
|
||||
msgstr "Показать рецепты"
|
||||
|
||||
#: .\cookbook\templates\batch\monitor.html:30
|
||||
msgid "Show Log"
|
||||
msgstr ""
|
||||
msgstr "Показать журнал"
|
||||
|
||||
#: .\cookbook\templates\batch\waiting.html:4
|
||||
#: .\cookbook\templates\batch\waiting.html:10
|
||||
msgid "Importing Recipes"
|
||||
msgstr ""
|
||||
msgstr "Импорт рецептов"
|
||||
|
||||
#: .\cookbook\templates\batch\waiting.html:28
|
||||
msgid ""
|
||||
"This can take a few minutes, depending on the number of recipes in sync, "
|
||||
"please wait."
|
||||
msgstr ""
|
||||
"Это может занять несколько минут, в зависимости от количества рецептов в "
|
||||
"синхронизации. Пожалуйста, подождите."
|
||||
|
||||
#: .\cookbook\templates\books.html:7
|
||||
msgid "Recipe Books"
|
||||
msgstr ""
|
||||
msgstr "Кулинарные книги"
|
||||
|
||||
#: .\cookbook\templates\export.html:7 .\cookbook\templates\test2.html:6
|
||||
msgid "Export Recipes"
|
||||
msgstr ""
|
||||
msgstr "Экспорт рецептов"
|
||||
|
||||
#: .\cookbook\templates\forms\edit_import_recipe.html:5
|
||||
#: .\cookbook\templates\forms\edit_import_recipe.html:9
|
||||
msgid "Import new Recipe"
|
||||
msgstr ""
|
||||
msgstr "Импорт новых рецептов"
|
||||
|
||||
#: .\cookbook\templates\forms\edit_internal_recipe.html:7
|
||||
msgid "Edit Recipe"
|
||||
msgstr ""
|
||||
msgstr "Редактировать рецепт"
|
||||
|
||||
#: .\cookbook\templates\generic\delete_template.html:21
|
||||
#, python-format
|
||||
msgid "Are you sure you want to delete the %(title)s: <b>%(object)s</b> "
|
||||
msgstr ""
|
||||
msgstr "Вы уверены, что хотите удалить %(title)s: <b>%(object)s</b> "
|
||||
|
||||
#: .\cookbook\templates\generic\delete_template.html:22
|
||||
msgid "This cannot be undone!"
|
||||
msgstr ""
|
||||
msgstr "Это действие необратимо!"
|
||||
|
||||
#: .\cookbook\templates\generic\delete_template.html:27
|
||||
msgid "Protected"
|
||||
msgstr ""
|
||||
msgstr "Защищённый"
|
||||
|
||||
#: .\cookbook\templates\generic\delete_template.html:42
|
||||
msgid "Cascade"
|
||||
msgstr ""
|
||||
msgstr "Каскад"
|
||||
|
||||
#: .\cookbook\templates\generic\delete_template.html:73
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
msgstr "Отмена"
|
||||
|
||||
#: .\cookbook\templates\generic\edit_template.html:6
|
||||
#: .\cookbook\templates\generic\edit_template.html:14
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
msgstr "Редактировать"
|
||||
|
||||
#: .\cookbook\templates\generic\edit_template.html:32
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
msgstr "Просмотреть"
|
||||
|
||||
#: .\cookbook\templates\generic\edit_template.html:36
|
||||
msgid "Delete original file"
|
||||
msgstr ""
|
||||
msgstr "Удалить оригинальный файл"
|
||||
|
||||
#: .\cookbook\templates\generic\list_template.html:6
|
||||
#: .\cookbook\templates\generic\list_template.html:22
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
msgstr "Список"
|
||||
|
||||
#: .\cookbook\templates\generic\list_template.html:36
|
||||
msgid "Filter"
|
||||
msgstr ""
|
||||
msgstr "Фильтр"
|
||||
|
||||
#: .\cookbook\templates\generic\list_template.html:41
|
||||
msgid "Import all"
|
||||
msgstr ""
|
||||
msgstr "Импортировать всё"
|
||||
|
||||
#: .\cookbook\templates\generic\new_template.html:6
|
||||
#: .\cookbook\templates\generic\new_template.html:14
|
||||
msgid "New"
|
||||
msgstr ""
|
||||
msgstr "Новый"
|
||||
|
||||
#: .\cookbook\templates\generic\table_template.html:76
|
||||
msgid "previous"
|
||||
msgstr ""
|
||||
msgstr "предыдущий"
|
||||
|
||||
#: .\cookbook\templates\generic\table_template.html:98
|
||||
msgid "next"
|
||||
msgstr ""
|
||||
msgstr "следующий"
|
||||
|
||||
#: .\cookbook\templates\history.html:20
|
||||
msgid "View Log"
|
||||
msgstr ""
|
||||
msgstr "Просмотреть журнал"
|
||||
|
||||
#: .\cookbook\templates\history.html:24
|
||||
#, fuzzy
|
||||
msgid "Cook Log"
|
||||
msgstr ""
|
||||
msgstr "Журнал приготовления"
|
||||
|
||||
#: .\cookbook\templates\import_response.html:7 .\cookbook\views\delete.py:90
|
||||
#: .\cookbook\views\edit.py:174
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
msgstr "Импортировать"
|
||||
|
||||
#: .\cookbook\templates\include\storage_backend_warning.html:4
|
||||
msgid "Security Warning"
|
||||
msgstr ""
|
||||
msgstr "Предупреждение о безопасности"
|
||||
|
||||
#: .\cookbook\templates\include\storage_backend_warning.html:5
|
||||
msgid ""
|
||||
@@ -1247,32 +1274,32 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\templates\index.html:29
|
||||
msgid "Search recipe ..."
|
||||
msgstr ""
|
||||
msgstr "Поиск рецепта ..."
|
||||
|
||||
#: .\cookbook\templates\index.html:44
|
||||
msgid "New Recipe"
|
||||
msgstr ""
|
||||
msgstr "Новый рецепт"
|
||||
|
||||
#: .\cookbook\templates\index.html:53
|
||||
msgid "Advanced Search"
|
||||
msgstr ""
|
||||
msgstr "Расширенный поиск"
|
||||
|
||||
#: .\cookbook\templates\index.html:57
|
||||
msgid "Reset Search"
|
||||
msgstr ""
|
||||
msgstr "Сбросить поиск"
|
||||
|
||||
#: .\cookbook\templates\index.html:85
|
||||
msgid "Last viewed"
|
||||
msgstr ""
|
||||
msgstr "Недавно просмотренные"
|
||||
|
||||
#: .\cookbook\templates\index.html:94
|
||||
msgid "Log in to view recipes"
|
||||
msgstr ""
|
||||
msgstr "Войдите, чтобы просмотреть рецепты"
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:5
|
||||
#: .\cookbook\templates\markdown_info.html:13
|
||||
msgid "Markdown Info"
|
||||
msgstr ""
|
||||
msgstr "Информация о Markdown"
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:14
|
||||
msgid ""
|
||||
|
||||
@@ -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: 2025-02-07 08:58+0000\n"
|
||||
"Last-Translator: Mattias G <mattias.granlund@gmail.com>\n"
|
||||
"PO-Revision-Date: 2025-08-10 11:36+0000\n"
|
||||
"Last-Translator: Elias Sjögreen <eliassjogreen1@gmail.com>\n"
|
||||
"Language-Team: Swedish <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/sv/>\n"
|
||||
"Language: sv\n"
|
||||
@@ -641,6 +641,8 @@ msgstr "ID eller enhet att använda för inköpslistan"
|
||||
#: .\cookbook\serializer.py:1443
|
||||
msgid "When set to true will delete all food from active shopping lists."
|
||||
msgstr ""
|
||||
"Om det här alternativet är aktiverat kommer alla matvaror att raderas från "
|
||||
"de aktiva inköpslistorna."
|
||||
|
||||
#: .\cookbook\tables.py:69 .\cookbook\tables.py:83
|
||||
#: .\cookbook\templates\generic\delete_template.html:7
|
||||
@@ -723,10 +725,13 @@ msgid ""
|
||||
"You currently do not have any e-mail address set up. You should really add "
|
||||
"an e-mail address so you can receive notifications, reset your password, etc."
|
||||
msgstr ""
|
||||
"Just nu har du inga e-post adresser konfigurerade. Du borde verkligen lägga "
|
||||
"till en e-post adress så att du kan får notiser, återställa ditt lösenord, "
|
||||
"mm."
|
||||
|
||||
#: .\cookbook\templates\account\email.html:64
|
||||
msgid "Add E-mail Address"
|
||||
msgstr ""
|
||||
msgstr "Lägg till en e-post adress"
|
||||
|
||||
#: .\cookbook\templates\account\email.html:69
|
||||
msgid "Add E-mail"
|
||||
@@ -734,12 +739,12 @@ msgstr "Lägg till email"
|
||||
|
||||
#: .\cookbook\templates\account\email.html:79
|
||||
msgid "Do you really want to remove the selected e-mail address?"
|
||||
msgstr ""
|
||||
msgstr "Vill du verkligen ta bort den valda e-postadressen?"
|
||||
|
||||
#: .\cookbook\templates\account\email_confirm.html:6
|
||||
#: .\cookbook\templates\account\email_confirm.html:10
|
||||
msgid "Confirm E-mail Address"
|
||||
msgstr ""
|
||||
msgstr "Bekräfta e-postadress"
|
||||
|
||||
#: .\cookbook\templates\account\email_confirm.html:16
|
||||
#, python-format
|
||||
@@ -749,6 +754,10 @@ msgid ""
|
||||
"for user %(user_display)s\n"
|
||||
" ."
|
||||
msgstr ""
|
||||
"Vänligen bekräfra att\n"
|
||||
" <a href=\"mailto:%(email)s\">%(email)s</a> är e-postadressen för "
|
||||
"användaren %(user_display)s\n"
|
||||
" ."
|
||||
|
||||
#: .\cookbook\templates\account\email_confirm.html:22
|
||||
#: .\cookbook\templates\generic\delete_template.html:72
|
||||
@@ -762,6 +771,9 @@ msgid ""
|
||||
" <a href=\"%(email_url)s\">issue a new e-mail confirmation "
|
||||
"request</a>."
|
||||
msgstr ""
|
||||
"Denna e-post bekräftelselänk är utgången eller felaktig. Vänligen\n"
|
||||
" <a href=\"%(email_url)s\">skapa en ny "
|
||||
"e-postbekräftelsebegäran</a>."
|
||||
|
||||
#: .\cookbook\templates\account\login.html:8 .\cookbook\templates\base.html:388
|
||||
#: .\cookbook\templates\openid\login.html:8
|
||||
@@ -785,19 +797,17 @@ msgstr "Logga in"
|
||||
#: .\cookbook\templates\account\password_reset_done.html:33
|
||||
#: .\cookbook\templates\socialaccount\signup.html:8
|
||||
#: .\cookbook\templates\socialaccount\signup.html:57
|
||||
#, fuzzy
|
||||
#| msgid "Sign In"
|
||||
msgid "Sign Up"
|
||||
msgstr "Logga in"
|
||||
msgstr "Registrera dig"
|
||||
|
||||
#: .\cookbook\templates\account\login.html:38
|
||||
msgid "Lost your password?"
|
||||
msgstr ""
|
||||
msgstr "Glömt ditt lösenord?"
|
||||
|
||||
#: .\cookbook\templates\account\login.html:39
|
||||
#: .\cookbook\templates\account\password_reset.html:29
|
||||
msgid "Reset My Password"
|
||||
msgstr ""
|
||||
msgstr "Återställ mitt lösenord"
|
||||
|
||||
#: .\cookbook\templates\account\login.html:50
|
||||
msgid "Social Login"
|
||||
@@ -824,10 +834,8 @@ msgstr "Är du säker på att du vill logga ut?"
|
||||
#: .\cookbook\templates\account\password_reset_from_key.html:13
|
||||
#: .\cookbook\templates\account\password_reset_from_key_done.html:7
|
||||
#: .\cookbook\templates\account\password_reset_from_key_done.html:13
|
||||
#, fuzzy
|
||||
#| msgid "Changes saved!"
|
||||
msgid "Change Password"
|
||||
msgstr "Ändringar sparade!"
|
||||
msgstr "Ändra lösenord"
|
||||
|
||||
#: .\cookbook\templates\account\password_change.html:12
|
||||
#: .\cookbook\templates\account\password_set.html:12
|
||||
@@ -850,18 +858,20 @@ msgid ""
|
||||
"Forgotten your password? Enter your e-mail address below, and we'll send you "
|
||||
"an e-mail allowing you to reset it."
|
||||
msgstr ""
|
||||
"Glömt ditt lösenord? Ange din e-postadress nedanför så skickar vi ett "
|
||||
"återställningmail."
|
||||
|
||||
#: .\cookbook\templates\account\password_reset.html:32
|
||||
#, fuzzy
|
||||
#| msgid "Password reset is not implemented for the time being!"
|
||||
msgid "Password reset is disabled on this instance."
|
||||
msgstr "Återställning av lösenord har ännu inte lagts till!"
|
||||
msgstr "Återställning av lösenord är avaktiverat på denna instans."
|
||||
|
||||
#: .\cookbook\templates\account\password_reset_done.html:25
|
||||
msgid ""
|
||||
"We have sent you an e-mail. Please contact us if you do not receive it "
|
||||
"within a few minutes."
|
||||
msgstr ""
|
||||
"Vi har skickat ett e-postmeddelande till dig. Om du inte har fått det inom "
|
||||
"några minuter, vänligen kontakta oss."
|
||||
|
||||
#: .\cookbook\templates\account\password_reset_from_key.html:13
|
||||
#, fuzzy
|
||||
|
||||
@@ -8,22 +8,22 @@ 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-04 10:29+0000\n"
|
||||
"Last-Translator: Johnny Ip <ip.iohnny@gmail.com>\n"
|
||||
"Language-Team: Chinese (Traditional) <http://translate.tandoor.dev/projects/"
|
||||
"tandoor/recipes-backend/zh_Hant/>\n"
|
||||
"PO-Revision-Date: 2025-08-02 07:49+0000\n"
|
||||
"Last-Translator: TC Kuo <tckuo7@gmail.com>\n"
|
||||
"Language-Team: Chinese (Traditional Han script) <http://translate.tandoor."
|
||||
"dev/projects/tandoor/recipes-backend/zh_Hant/>\n"
|
||||
"Language: zh_Hant\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
"X-Generator: Weblate 5.6.2\n"
|
||||
"X-Generator: Weblate 5.8.4\n"
|
||||
|
||||
#: .\cookbook\forms.py:45
|
||||
msgid ""
|
||||
"Both fields are optional. If none are given the username will be displayed "
|
||||
"instead"
|
||||
msgstr "這兩個字段都是可選的。如果沒有輸入,將顯示用戶名"
|
||||
msgstr "這兩個欄位都是可選的。如果沒有輸入,將顯示用戶名"
|
||||
|
||||
#: .\cookbook\forms.py:62 .\cookbook\forms.py:246
|
||||
msgid "Name"
|
||||
@@ -31,7 +31,7 @@ msgstr "名字"
|
||||
|
||||
#: .\cookbook\forms.py:62 .\cookbook\forms.py:246 .\cookbook\views\lists.py:103
|
||||
msgid "Keywords"
|
||||
msgstr "關鍵詞"
|
||||
msgstr "關鍵字"
|
||||
|
||||
#: .\cookbook\forms.py:62
|
||||
msgid "Preparation time in minutes"
|
||||
@@ -51,14 +51,13 @@ msgstr "存儲ID"
|
||||
|
||||
#: .\cookbook\forms.py:93
|
||||
msgid "Default"
|
||||
msgstr "默認"
|
||||
msgstr "預設"
|
||||
|
||||
#: .\cookbook\forms.py:121
|
||||
msgid ""
|
||||
"To prevent duplicates recipes with the same name as existing ones are "
|
||||
"ignored. Check this box to import everything."
|
||||
msgstr ""
|
||||
"為防止重復,忽略與現有同名的菜譜。選中此框可導入所有內容(包括同名菜譜)。"
|
||||
msgstr "為防止重複,忽略與現有同名的食譜。選中此框可導入所有內容(包括同名食譜)。"
|
||||
|
||||
#: .\cookbook\forms.py:143
|
||||
msgid "Add your comment: "
|
||||
@@ -241,7 +240,7 @@ msgstr "你沒有必要的權限來查看這個頁面!"
|
||||
#: .\cookbook\helper\permission_helper.py:237
|
||||
#: .\cookbook\helper\permission_helper.py:252
|
||||
msgid "You cannot interact with this object as it is not owned by you!"
|
||||
msgstr "你不能與此對象交互,因為它不屬於你!"
|
||||
msgstr "你不能與此對象互動,因為它不屬於你!"
|
||||
|
||||
#: .\cookbook\helper\permission_helper.py:402
|
||||
msgid "You have reached the maximum number of recipes for your space."
|
||||
@@ -311,16 +310,16 @@ 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 "導入了%s菜譜。"
|
||||
msgstr "導入了%s食譜。"
|
||||
|
||||
#: .\cookbook\integration\openeats.py:28
|
||||
msgid "Recipe source:"
|
||||
msgstr "菜譜來源:"
|
||||
msgstr "食譜來源:"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:49
|
||||
msgid "Notes"
|
||||
@@ -328,7 +327,7 @@ msgstr "說明"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:52
|
||||
msgid "Nutritional Information"
|
||||
msgstr "營養信息"
|
||||
msgstr "營養資訊"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:56
|
||||
msgid "Source"
|
||||
@@ -645,7 +644,7 @@ msgstr "電子郵件地址"
|
||||
#: .\cookbook\templates\socialaccount\connections.html:10
|
||||
#: .\cookbook\templates\user_settings.html:8
|
||||
msgid "Settings"
|
||||
msgstr "設置"
|
||||
msgstr "設定"
|
||||
|
||||
#: .\cookbook\templates\account\email.html:13
|
||||
msgid "Email"
|
||||
@@ -1878,7 +1877,7 @@ msgstr "你可以被邀請加入現有空間或創建自己的空間。"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:53
|
||||
msgid "Owner"
|
||||
msgstr "所有者"
|
||||
msgstr "擁有者"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:57
|
||||
msgid "Leave Space"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
34
cookbook/migrations/0223_auto_20250831_1111.py
Normal file
34
cookbook/migrations/0223_auto_20250831_1111.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 4.2.22 on 2025-08-31 09:11
|
||||
|
||||
from django.db import migrations
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def migrate_comments(apps, schema_editor):
|
||||
with scopes_disabled():
|
||||
Comment = apps.get_model('cookbook', 'Comment')
|
||||
CookLog = apps.get_model('cookbook', 'CookLog')
|
||||
|
||||
cook_logs = []
|
||||
|
||||
for c in Comment.objects.all():
|
||||
cook_logs.append(CookLog(
|
||||
recipe=c.recipe,
|
||||
created_by=c.created_by,
|
||||
created_at=c.created_at,
|
||||
comment=c.text,
|
||||
space=c.recipe.space,
|
||||
))
|
||||
|
||||
CookLog.objects.bulk_create(cook_logs, unique_fields=('recipe', 'comment', 'created_at', 'created_by'))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0222_alter_shoppinglistrecipe_created_by_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_comments),
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-05 06:51
|
||||
|
||||
import cookbook.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0223_auto_20250831_1111'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='ai_credits_balance',
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='ai_credits_monthly',
|
||||
field=models.IntegerField(default=100),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AiProvider',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=128)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('api_key', models.CharField(max_length=2048)),
|
||||
('model_name', models.CharField(max_length=256)),
|
||||
('url', models.CharField(blank=True, max_length=2048, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('space', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AiLog',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('function', models.CharField(max_length=64)),
|
||||
('credit_cost', models.DecimalField(decimal_places=4, max_digits=16)),
|
||||
('credits_from_balance', models.BooleanField(default=False)),
|
||||
('input_tokens', models.IntegerField(default=0)),
|
||||
('output_tokens', models.IntegerField(default=0)),
|
||||
('start_time', models.DateTimeField(null=True)),
|
||||
('end_time', models.DateTimeField(null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('ai_provider', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.aiprovider')),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
('space', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space')),
|
||||
],
|
||||
bases=(models.Model, cookbook.models.PermissionModelMixin),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0225_space_ai_enabled.py
Normal file
18
cookbook/migrations/0225_space_ai_enabled.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-08 19:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0224_space_ai_credits_balance_space_ai_credits_monthly_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='ai_enabled',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-08 20:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0225_space_ai_enabled'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='aiprovider',
|
||||
name='log_credit_cost',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='space',
|
||||
name='ai_credits_monthly',
|
||||
field=models.IntegerField(default=10000),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 4.2.22 on 2025-09-09 11:40
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0226_aiprovider_log_credit_cost_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='ai_default_provider',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_ai_default_provider', to='cookbook.aiprovider'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='space',
|
||||
name='ai_credits_balance',
|
||||
field=models.DecimalField(decimal_places=4, default=0, max_digits=16),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0228_space_space_setup_completed.py
Normal file
18
cookbook/migrations/0228_space_space_setup_completed.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-10 20:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0001_squashed_0227_space_ai_default_provider_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='space_setup_completed',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
@@ -329,6 +329,13 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
demo = models.BooleanField(default=False)
|
||||
food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
|
||||
|
||||
space_setup_completed = models.BooleanField(default=True)
|
||||
|
||||
ai_enabled = models.BooleanField(default=True)
|
||||
ai_credits_monthly = models.IntegerField(default=100)
|
||||
ai_credits_balance = models.DecimalField(default=0, max_digits=16, decimal_places=4)
|
||||
ai_default_provider = models.ForeignKey("AiProvider", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_ai_default_provider')
|
||||
|
||||
internal_note = models.TextField(blank=True, null=True)
|
||||
|
||||
def safe_delete(self):
|
||||
@@ -341,6 +348,9 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
BookmarkletImport.objects.filter(space=self).delete()
|
||||
CustomFilter.objects.filter(space=self).delete()
|
||||
|
||||
AiLog.objects.filter(space=self).delete()
|
||||
AiProvider.objects.filter(space=self).delete()
|
||||
|
||||
Property.objects.filter(space=self).delete()
|
||||
PropertyType.objects.filter(space=self).delete()
|
||||
|
||||
@@ -393,6 +403,41 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
return self.name
|
||||
|
||||
|
||||
class AiProvider(models.Model):
|
||||
name = models.CharField(max_length=128)
|
||||
description = models.TextField(blank=True)
|
||||
# AiProviders can be global, so space=null is allowed (configurable by superusers)
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True)
|
||||
|
||||
api_key = models.CharField(max_length=2048)
|
||||
model_name = models.CharField(max_length=256)
|
||||
url = models.CharField(max_length=2048, blank=True, null=True)
|
||||
log_credit_cost = models.BooleanField(default=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
class AiLog(models.Model, PermissionModelMixin):
|
||||
F_FILE_IMPORT = 'FILE_IMPORT'
|
||||
|
||||
ai_provider = models.ForeignKey(AiProvider, on_delete=models.SET_NULL, null=True)
|
||||
function = models.CharField(max_length=64)
|
||||
credit_cost = models.DecimalField(max_digits=16, decimal_places=4)
|
||||
# if credits from balance were used, else its from monthly quota
|
||||
credits_from_balance = models.BooleanField(default=False)
|
||||
|
||||
input_tokens = models.IntegerField(default=0)
|
||||
output_tokens = models.IntegerField(default=0)
|
||||
start_time = models.DateTimeField(null=True)
|
||||
end_time = models.DateTimeField(null=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
class ConnectorConfig(models.Model, PermissionModelMixin):
|
||||
HOMEASSISTANT = 'HomeAssistant'
|
||||
CONNECTER_TYPE = ((HOMEASSISTANT, 'HomeAssistant'),)
|
||||
|
||||
@@ -24,8 +24,9 @@ from rest_framework.fields import IntegerField
|
||||
|
||||
from cookbook.helper.CustomStorageClass import CachedS3Boto3Storage
|
||||
from cookbook.helper.HelperFunctions import str2bool
|
||||
from cookbook.helper.ai_helper import get_monthly_token_usage
|
||||
from cookbook.helper.image_processing import is_file_type_allowed
|
||||
from cookbook.helper.permission_helper import above_space_limit
|
||||
from cookbook.helper.permission_helper import above_space_limit, create_space_for_user
|
||||
from cookbook.helper.property_helper import FoodPropertyHelper
|
||||
from cookbook.helper.shopping_helper import RecipeShoppingEditor
|
||||
from cookbook.helper.unit_conversion_helper import UnitConversionHelper
|
||||
@@ -36,7 +37,7 @@ from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Cu
|
||||
ShareLink, ShoppingListEntry, ShoppingListRecipe, Space,
|
||||
Step, Storage, Supermarket, SupermarketCategory,
|
||||
SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion,
|
||||
UserFile, UserPreference, UserSpace, ViewLog, ConnectorConfig, SearchPreference, SearchFields)
|
||||
UserFile, UserPreference, UserSpace, ViewLog, ConnectorConfig, SearchPreference, SearchFields, AiLog, AiProvider)
|
||||
from cookbook.templatetags.custom_tags import markdown
|
||||
from recipes.settings import AWS_ENABLED, MEDIA_URL, EMAIL_HOST
|
||||
|
||||
@@ -325,12 +326,53 @@ class UserFileViewSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = ('id', 'file', 'file_download', 'file_size_kb', 'preview', 'created_by', 'created_at')
|
||||
|
||||
|
||||
class AiProviderSerializer(serializers.ModelSerializer):
|
||||
api_key = serializers.CharField(required=False, write_only=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data = self.handle_global_space_logic(validated_data)
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
validated_data = self.handle_global_space_logic(validated_data)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def handle_global_space_logic(self, validated_data):
|
||||
"""
|
||||
allow superusers to create AI providers without a space but make sure everyone else only uses their own space
|
||||
"""
|
||||
if ('space' not in validated_data or not validated_data['space']) and self.context['request'].user.is_superuser:
|
||||
validated_data['space'] = None
|
||||
else:
|
||||
validated_data['space'] = self.context['request'].space
|
||||
|
||||
return validated_data
|
||||
|
||||
class Meta:
|
||||
model = AiProvider
|
||||
fields = ('id', 'name', 'description', 'api_key', 'model_name', 'url', 'log_credit_cost', 'space', 'created_at', 'updated_at')
|
||||
read_only_fields = ('created_at', 'updated_at',)
|
||||
|
||||
|
||||
class AiLogSerializer(serializers.ModelSerializer):
|
||||
ai_provider = AiProviderSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = AiLog
|
||||
fields = ('id', 'ai_provider', 'function', 'credit_cost', 'credits_from_balance', 'input_tokens', 'output_tokens', 'start_time', 'end_time', 'created_by', 'created_at',
|
||||
'updated_at')
|
||||
read_only_fields = ('__all__',)
|
||||
|
||||
|
||||
class SpaceSerializer(WritableNestedModelSerializer):
|
||||
created_by = UserSerializer(read_only=True)
|
||||
user_count = serializers.SerializerMethodField('get_user_count')
|
||||
recipe_count = serializers.SerializerMethodField('get_recipe_count')
|
||||
file_size_mb = serializers.SerializerMethodField('get_file_size_mb')
|
||||
food_inherit = FoodInheritFieldSerializer(many=True)
|
||||
user_count = serializers.SerializerMethodField('get_user_count', read_only=True)
|
||||
recipe_count = serializers.SerializerMethodField('get_recipe_count', read_only=True)
|
||||
file_size_mb = serializers.SerializerMethodField('get_file_size_mb', read_only=True)
|
||||
ai_monthly_credits_used = serializers.SerializerMethodField('get_ai_monthly_credits_used', read_only=True)
|
||||
ai_default_provider = AiProviderSerializer(required=False, allow_null=True)
|
||||
food_inherit = FoodInheritFieldSerializer(many=True, required=False)
|
||||
image = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
nav_logo = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
custom_space_theme = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
@@ -350,6 +392,10 @@ class SpaceSerializer(WritableNestedModelSerializer):
|
||||
def get_recipe_count(self, obj):
|
||||
return Recipe.objects.filter(space=obj).count()
|
||||
|
||||
@extend_schema_field(int)
|
||||
def get_ai_monthly_credits_used(self, obj):
|
||||
return get_monthly_token_usage(obj)
|
||||
|
||||
@extend_schema_field(float)
|
||||
def get_file_size_mb(self, obj):
|
||||
try:
|
||||
@@ -358,7 +404,36 @@ class SpaceSerializer(WritableNestedModelSerializer):
|
||||
return 0
|
||||
|
||||
def create(self, validated_data):
|
||||
raise ValidationError('Cannot create using this endpoint')
|
||||
if Space.objects.filter(created_by=self.context['request'].user).count() >= self.context['request'].user.userpreference.max_owned_spaces:
|
||||
raise serializers.ValidationError(
|
||||
_('You have the reached the maximum amount of spaces that can be owned by you.') + f' ({self.context['request'].user.userpreference.max_owned_spaces})')
|
||||
|
||||
name = None
|
||||
if 'name' in validated_data:
|
||||
name = validated_data['name']
|
||||
user_space = create_space_for_user(self.context['request'].user, name)
|
||||
return user_space.space
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
validated_data = self.filter_superuser_parameters(validated_data)
|
||||
|
||||
if 'name' in validated_data:
|
||||
if Space.objects.filter(Q(name=validated_data['name']), ~Q(pk=instance.pk)).exists():
|
||||
raise ValidationError(_('Space Name must be unique.'))
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def filter_superuser_parameters(self, validated_data):
|
||||
if 'ai_enabled' in validated_data and not self.context['request'].user.is_superuser:
|
||||
del validated_data['ai_enabled']
|
||||
|
||||
if 'ai_credits_monthly' in validated_data and not self.context['request'].user.is_superuser:
|
||||
del validated_data['ai_credits_monthly']
|
||||
|
||||
if 'ai_credits_balance' in validated_data and not self.context['request'].user.is_superuser:
|
||||
del validated_data['ai_credits_balance']
|
||||
|
||||
return validated_data
|
||||
|
||||
class Meta:
|
||||
model = Space
|
||||
@@ -366,10 +441,11 @@ class SpaceSerializer(WritableNestedModelSerializer):
|
||||
'id', 'name', 'created_by', 'created_at', 'message', 'max_recipes', 'max_file_storage_mb', 'max_users',
|
||||
'allow_sharing', 'demo', 'food_inherit', 'user_count', 'recipe_count', 'file_size_mb',
|
||||
'image', 'nav_logo', 'space_theme', 'custom_space_theme', 'nav_bg_color', 'nav_text_color',
|
||||
'logo_color_32', 'logo_color_128', 'logo_color_144', 'logo_color_180', 'logo_color_192', 'logo_color_512', 'logo_color_svg',)
|
||||
'logo_color_32', 'logo_color_128', 'logo_color_144', 'logo_color_180', 'logo_color_192', 'logo_color_512', 'logo_color_svg', 'ai_credits_monthly',
|
||||
'ai_credits_balance', 'ai_monthly_credits_used', 'ai_enabled', 'ai_default_provider', 'space_setup_completed')
|
||||
read_only_fields = (
|
||||
'id', 'created_by', 'created_at', 'max_recipes', 'max_file_storage_mb', 'max_users', 'allow_sharing',
|
||||
'demo',)
|
||||
'demo', 'ai_monthly_credits_used')
|
||||
|
||||
|
||||
class UserSpaceSerializer(WritableNestedModelSerializer):
|
||||
@@ -592,7 +668,7 @@ class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
||||
fields = (
|
||||
'id', 'name', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe', 'created_at',
|
||||
'updated_at', 'full_name')
|
||||
read_only_fields = ('id', 'label', 'numchild', 'parent', 'image')
|
||||
read_only_fields = ('id', 'label', 'numchild', 'numrecipe', 'parent', 'image')
|
||||
|
||||
|
||||
class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin, OpenDataModelMixin):
|
||||
@@ -787,7 +863,7 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
||||
if plural_name := validated_data.pop('plural_name', None):
|
||||
plural_name = plural_name.strip()
|
||||
|
||||
if food := Food.objects.filter(Q(name=name) | Q(plural_name=name)).first():
|
||||
if food := Food.objects.filter(Q(name__iexact=name) | Q(plural_name__iexact=name)).first():
|
||||
return food
|
||||
|
||||
space = validated_data.pop('space', self.context['request'].space)
|
||||
@@ -1038,7 +1114,7 @@ class RecipeOverviewSerializer(RecipeBaseSerializer):
|
||||
fields = (
|
||||
'id', 'name', 'description', 'image', 'keywords', 'working_time',
|
||||
'waiting_time', 'created_by', 'created_at', 'updated_at',
|
||||
'internal', 'servings', 'servings_text', 'rating', 'last_cooked', 'new', 'recent'
|
||||
'internal', 'private', 'servings', 'servings_text', 'rating', 'last_cooked', 'new', 'recent'
|
||||
)
|
||||
# TODO having these readonly fields makes "RecipeOverview.ts" (API Client) not generate the RecipeOverviewToJSON second else block which leads to errors when using the api
|
||||
# TODO find a solution (custom schema?) to have these fields readonly (to save performance) and generate a proper client (two serializers would probably do the trick)
|
||||
@@ -1112,6 +1188,57 @@ class RecipeImportSerializer(SpacedModelSerializer):
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class RecipeBatchUpdateSerializer(serializers.Serializer):
|
||||
recipes = serializers.ListField(child=serializers.IntegerField())
|
||||
keywords_add = serializers.ListField(child=serializers.IntegerField())
|
||||
keywords_remove = serializers.ListField(child=serializers.IntegerField())
|
||||
keywords_set = serializers.ListField(child=serializers.IntegerField())
|
||||
keywords_remove_all = serializers.BooleanField(default=False)
|
||||
|
||||
working_time = serializers.IntegerField(required=False, allow_null=True)
|
||||
waiting_time = serializers.IntegerField(required=False, allow_null=True)
|
||||
servings = serializers.IntegerField(required=False, allow_null=True)
|
||||
servings_text = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
|
||||
private = serializers.BooleanField(required=False, allow_null=True)
|
||||
shared_add = serializers.ListField(child=serializers.IntegerField())
|
||||
shared_remove = serializers.ListField(child=serializers.IntegerField())
|
||||
shared_set = serializers.ListField(child=serializers.IntegerField())
|
||||
shared_remove_all = serializers.BooleanField(default=False)
|
||||
|
||||
show_ingredient_overview = serializers.BooleanField(required=False, allow_null=True)
|
||||
clear_description = serializers.BooleanField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class FoodBatchUpdateSerializer(serializers.Serializer):
|
||||
foods = serializers.ListField(child=serializers.IntegerField())
|
||||
|
||||
category = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
substitute_add = serializers.ListField(child=serializers.IntegerField())
|
||||
substitute_remove = serializers.ListField(child=serializers.IntegerField())
|
||||
substitute_set = serializers.ListField(child=serializers.IntegerField())
|
||||
substitute_remove_all = serializers.BooleanField(default=False)
|
||||
|
||||
inherit_fields_add = serializers.ListField(child=serializers.IntegerField())
|
||||
inherit_fields_remove = serializers.ListField(child=serializers.IntegerField())
|
||||
inherit_fields_set = serializers.ListField(child=serializers.IntegerField())
|
||||
inherit_fields_remove_all = serializers.BooleanField(default=False)
|
||||
|
||||
child_inherit_fields_add = serializers.ListField(child=serializers.IntegerField())
|
||||
child_inherit_fields_remove = serializers.ListField(child=serializers.IntegerField())
|
||||
child_inherit_fields_set = serializers.ListField(child=serializers.IntegerField())
|
||||
child_inherit_fields_remove_all = serializers.BooleanField(default=False)
|
||||
|
||||
substitute_children = serializers.BooleanField(required=False, allow_null=True)
|
||||
substitute_siblings = serializers.BooleanField(required=False, allow_null=True)
|
||||
ignore_shopping = serializers.BooleanField(required=False, allow_null=True)
|
||||
on_hand = serializers.BooleanField(required=False, allow_null=True)
|
||||
|
||||
parent_remove = serializers.BooleanField(required=False, allow_null=True)
|
||||
parent_set = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class CustomFilterSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
||||
shared = UserSerializer(many=True, required=False)
|
||||
|
||||
@@ -1223,8 +1350,8 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
||||
|
||||
|
||||
class AutoMealPlanSerializer(serializers.Serializer):
|
||||
start_date = serializers.DateField()
|
||||
end_date = serializers.DateField()
|
||||
start_date = serializers.DateTimeField()
|
||||
end_date = serializers.DateTimeField()
|
||||
meal_type_id = serializers.IntegerField()
|
||||
keyword_ids = serializers.ListField()
|
||||
servings = CustomDecimalField()
|
||||
@@ -1480,7 +1607,7 @@ class InviteLinkSerializer(WritableNestedModelSerializer):
|
||||
fields = (
|
||||
'id', 'uuid', 'email', 'group', 'valid_until', 'used_by', 'reusable', 'internal_note', 'created_by',
|
||||
'created_at',)
|
||||
read_only_fields = ('id', 'uuid', 'created_by', 'created_at',)
|
||||
read_only_fields = ('id', 'uuid', 'used_by', 'created_by', 'created_at',)
|
||||
|
||||
|
||||
# CORS, REST and Scopes aren't currently working
|
||||
@@ -1542,7 +1669,6 @@ class ServerSettingsSerializer(serializers.Serializer):
|
||||
# TODO add all other relevant settings including path/url related ones?
|
||||
shopping_min_autosync_interval = serializers.CharField()
|
||||
enable_pdf_export = serializers.BooleanField()
|
||||
enable_ai_import = serializers.BooleanField()
|
||||
disable_external_connectors = serializers.BooleanField()
|
||||
terms_url = serializers.CharField()
|
||||
privacy_url = serializers.CharField()
|
||||
@@ -1766,8 +1892,10 @@ class RecipeFromSourceResponseSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class AiImportSerializer(serializers.Serializer):
|
||||
ai_provider_id = serializers.IntegerField()
|
||||
file = serializers.FileField(allow_null=True)
|
||||
text = serializers.CharField(allow_null=True, allow_blank=True)
|
||||
recipe_id = serializers.CharField(allow_null=True, allow_blank=True)
|
||||
|
||||
|
||||
class ExportRequestSerializer(serializers.Serializer):
|
||||
|
||||
@@ -51,11 +51,6 @@
|
||||
{# {% endif %}#}
|
||||
<p class="card-text"><small
|
||||
class="text-muted">{% trans 'Owner' %}: {{ us.space.created_by }}</small>
|
||||
{% if us.space.created_by != us.user %}
|
||||
<p class="card-text"><small
|
||||
class="text-muted"><a
|
||||
href="{% url 'delete_user_space' us.pk %}">{% trans 'Leave Space' %}</a></small>
|
||||
{% endif %}
|
||||
<!--TODO add direct link to management page -->
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -53,6 +53,17 @@
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
|
||||
<h3 class="mt-5">{% trans 'Plugins' %}</h3>
|
||||
Clone the plugin using git into the <code>recipe/plugin/</code> folder (Docker mount <code>/opt/recipe/recipes/plugins</code> to the host system and clone into it).
|
||||
<table class="table table-bordered">
|
||||
{% for p in plugins %}
|
||||
<tr>
|
||||
<td><a href="{{ p.github }}">{{ p.name }}</a> <br/>{{ p.base_path }}</td>
|
||||
<td><a href="{% url 'view_plugin_update' %}?module={{ p.module }}" class="btn btn-primary">Git Pull</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
<h4 class="mt-3">{% trans 'Media Serving' %} <span class="badge text-bg-{% if gunicorn_media %}danger{% else %}success{% endif %}">{% if gunicorn_media %}
|
||||
{% trans 'Warning' %}{% else %}{% trans 'Ok' %}{% endif %}</span></h4>
|
||||
{% if gunicorn_media %}
|
||||
|
||||
168
cookbook/tests/api/test_api_ai_provider.py
Normal file
168
cookbook/tests/api/test_api_ai_provider.py
Normal file
@@ -0,0 +1,168 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import MealType, PropertyType, AiProvider
|
||||
|
||||
LIST_URL = 'api:aiprovider-list'
|
||||
DETAIL_URL = 'api:aiprovider-detail'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def obj_1(space_1, a1_s1):
|
||||
return AiProvider.objects.get_or_create(name='test_1', space=space_1)[0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def obj_2(space_1, a1_s1):
|
||||
return AiProvider.objects.get_or_create(name='test_2', space=None)[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 200],
|
||||
['a1_s1', 200],
|
||||
])
|
||||
def test_list_permission(arg, request):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
assert c.get(reverse(LIST_URL)).status_code == arg[1]
|
||||
|
||||
|
||||
def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2):
|
||||
assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 2
|
||||
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 1
|
||||
|
||||
obj_1.space = space_2
|
||||
obj_1.save()
|
||||
|
||||
assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 1
|
||||
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 2
|
||||
|
||||
obj_1.space = None
|
||||
obj_1.save()
|
||||
|
||||
assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 2
|
||||
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 403],
|
||||
['a1_s1', 200],
|
||||
['g1_s2', 403],
|
||||
['u1_s2', 403],
|
||||
['a1_s2', 404],
|
||||
])
|
||||
def test_update(arg, request, obj_1):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.patch(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1.id}
|
||||
),
|
||||
{'name': 'new'},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = json.loads(r.content)
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 200:
|
||||
assert response['name'] == 'new'
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 403],
|
||||
['a1_s1', 403],
|
||||
['g1_s2', 403],
|
||||
['u1_s2', 403],
|
||||
['a1_s2', 403],
|
||||
['s1_s1', 200],
|
||||
])
|
||||
def test_update_global(arg, request, obj_2):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.patch(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_2.id}
|
||||
),
|
||||
{'name': 'new'},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = json.loads(r.content)
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 200:
|
||||
assert response['name'] == 'new'
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 403],
|
||||
['a1_s1', 201],
|
||||
])
|
||||
def test_add(arg, request, u1_s2):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.post(
|
||||
reverse(LIST_URL),
|
||||
{'name': 'test', 'api_key': 'test', 'model_name': 'test'},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = json.loads(r.content)
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 201:
|
||||
assert response['name'] == 'test'
|
||||
r = c.get(reverse(DETAIL_URL, args={response['id']}))
|
||||
assert r.status_code == 200
|
||||
r = u1_s2.get(reverse(DETAIL_URL, args={response['id']}))
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_delete(a1_s1, a1_s2, obj_1):
|
||||
# admins cannot delete foreign space providers
|
||||
r = a1_s2.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1.id}
|
||||
)
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
# admins can delete their space providers
|
||||
r = a1_s1.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1.id}
|
||||
)
|
||||
)
|
||||
|
||||
assert r.status_code == 204
|
||||
with scopes_disabled():
|
||||
assert AiProvider.objects.count() == 0
|
||||
|
||||
|
||||
def test_delete_global(a1_s1, s1_s1, obj_2):
|
||||
# admins cant delete global providers
|
||||
r = a1_s1.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_2.id}
|
||||
)
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
# superusers can delete global providers
|
||||
r = s1_s1.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_2.id}
|
||||
)
|
||||
)
|
||||
|
||||
assert r.status_code == 204
|
||||
with scopes_disabled():
|
||||
assert AiProvider.objects.count() == 0
|
||||
@@ -7,6 +7,7 @@ from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import UserSpace
|
||||
from recipes import settings
|
||||
|
||||
LIST_URL = 'api:space-list'
|
||||
DETAIL_URL = 'api:space-detail'
|
||||
@@ -45,7 +46,6 @@ def test_list_multiple(u1_s1, space_1, space_2):
|
||||
assert u1_response['id'] == space_1.id
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
@@ -70,9 +70,9 @@ def test_update(arg, request, space_1, a1_s1):
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 403],
|
||||
['a1_s1', 405],
|
||||
['g1_s1', 201],
|
||||
['u1_s1', 201],
|
||||
['a1_s1', 201],
|
||||
])
|
||||
def test_add(arg, request, u1_s2):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
@@ -90,3 +90,59 @@ def test_delete(u1_s1, u1_s2, a1_s1, space_1):
|
||||
# event the space owner cannot delete his space over the api (this might change later but for now it's only available in the UI)
|
||||
r = a1_s1.delete(reverse(DETAIL_URL, args={space_1.id}))
|
||||
assert r.status_code == 405
|
||||
|
||||
|
||||
def test_superuser_parameters(space_1, a1_s1, s1_s1):
|
||||
# ------- test as normal user -------
|
||||
response = a1_s1.post(reverse(LIST_URL), {'name': 'test', 'ai_enabled': not settings.SPACE_AI_ENABLED, 'ai_credits_monthly': settings.SPACE_AI_CREDITS_MONTHLY + 100, 'ai_credits_balance': 100},
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 201
|
||||
response = json.loads(response.content)
|
||||
assert response['ai_enabled'] == settings.SPACE_AI_ENABLED
|
||||
assert response['ai_credits_monthly'] == settings.SPACE_AI_CREDITS_MONTHLY
|
||||
assert response['ai_credits_balance'] == 0
|
||||
|
||||
space_1.created_by = auth.get_user(a1_s1)
|
||||
space_1.ai_enabled = False
|
||||
space_1.ai_credits_monthly = 0
|
||||
space_1.ai_credits_balance = 0
|
||||
space_1.save()
|
||||
|
||||
response = a1_s1.patch(reverse(DETAIL_URL, args={space_1.id}), {'ai_enabled': True, 'ai_credits_monthly': 100, 'ai_credits_balance': 100},
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
space_1.refresh_from_db()
|
||||
assert space_1.ai_enabled == False
|
||||
assert space_1.ai_credits_monthly == 0
|
||||
assert space_1.ai_credits_balance == 0
|
||||
|
||||
# ------- test as superuser -------
|
||||
|
||||
response = s1_s1.post(reverse(LIST_URL),
|
||||
{'name': 'test', 'ai_enabled': not settings.SPACE_AI_ENABLED, 'ai_credits_monthly': settings.SPACE_AI_CREDITS_MONTHLY + 100, 'ai_credits_balance': 100},
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 201
|
||||
response = json.loads(response.content)
|
||||
assert response['ai_enabled'] == settings.SPACE_AI_ENABLED
|
||||
assert response['ai_credits_monthly'] == settings.SPACE_AI_CREDITS_MONTHLY
|
||||
assert response['ai_credits_balance'] == 0
|
||||
|
||||
space_1.created_by = auth.get_user(s1_s1)
|
||||
space_1.ai_enabled = False
|
||||
space_1.ai_credits_monthly = 0
|
||||
space_1.ai_credits_balance = 0
|
||||
space_1.save()
|
||||
|
||||
response = s1_s1.patch(reverse(DETAIL_URL, args={space_1.id}), {'ai_enabled': True, 'ai_credits_monthly': 100, 'ai_credits_balance': 100},
|
||||
content_type='application/json')
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
space_1.refresh_from_db()
|
||||
assert space_1.ai_enabled == True
|
||||
assert space_1.ai_credits_monthly == 100
|
||||
assert space_1.ai_credits_balance == 100
|
||||
|
||||
@@ -5,6 +5,8 @@ from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import UserSpace
|
||||
|
||||
LIST_URL = 'api:userspace-list'
|
||||
DETAIL_URL = 'api:userspace-detail'
|
||||
|
||||
@@ -13,10 +15,10 @@ DETAIL_URL = 'api:userspace-detail'
|
||||
['a_u', 403, 0],
|
||||
['g1_s1', 200, 1], # sees only own user space
|
||||
['u1_s1', 200, 1],
|
||||
['a1_s1', 200, 3], # sees user space of all users in space
|
||||
['a2_s1', 200, 1],
|
||||
['a1_s1', 200, 4], # admins can see all other members
|
||||
['a2_s1', 200, 4],
|
||||
])
|
||||
def test_list_permission(arg, request, space_1, g1_s1, u1_s1, a1_s1):
|
||||
def test_list_permission(arg, request, space_1, g1_s1, u1_s1, a1_s1, a2_s1):
|
||||
space_1.created_by = auth.get_user(a1_s1)
|
||||
space_1.save()
|
||||
|
||||
@@ -27,6 +29,18 @@ def test_list_permission(arg, request, space_1, g1_s1, u1_s1, a1_s1):
|
||||
assert len(json.loads(result.content)['results']) == arg[2]
|
||||
|
||||
|
||||
def test_list_all_personal(space_2, u1_s1):
|
||||
result = u1_s1.get(reverse('api:userspace-all-personal'))
|
||||
assert result.status_code == 200
|
||||
assert len(json.loads(result.content)) == 1
|
||||
|
||||
UserSpace.objects.create(user=auth.get_user(u1_s1), space=space_2)
|
||||
|
||||
result = u1_s1.get(reverse('api:userspace-all-personal'))
|
||||
assert result.status_code == 200
|
||||
assert len(json.loads(result.content)) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
|
||||
@@ -298,3 +298,11 @@ def a1_s2(client, space_2):
|
||||
@pytest.fixture()
|
||||
def a2_s2(client, space_2):
|
||||
return create_user(client, space_2, group='admin')
|
||||
|
||||
@pytest.fixture()
|
||||
def s1_s1(client, space_1):
|
||||
client = create_user(client, space_1, group='admin')
|
||||
user = auth.get_user(client)
|
||||
user.is_superuser = True
|
||||
user.save()
|
||||
return client
|
||||
|
||||
@@ -61,6 +61,8 @@ router.register(r'search-preference', api.SearchPreferenceViewSet)
|
||||
router.register(r'user-space', api.UserSpaceViewSet)
|
||||
router.register(r'view-log', api.ViewLogViewSet)
|
||||
router.register(r'access-token', api.AccessTokenViewSet)
|
||||
router.register(r'ai-provider', api.AiProviderViewSet)
|
||||
router.register(r'ai-log', api.AiLogViewSet)
|
||||
|
||||
router.register(r'localization', api.LocalizationViewSet, basename='localization')
|
||||
router.register(r'server-settings', api.ServerSettingsViewSet, basename='server-settings')
|
||||
@@ -76,12 +78,14 @@ urlpatterns = [
|
||||
|
||||
path('setup/', views.setup, name='view_setup'),
|
||||
path('no-group/', views.no_groups, name='view_no_group'),
|
||||
path('space-overview/', views.space_overview, name='view_space_overview'),
|
||||
path('switch-space/<int:space_id>', views.switch_space, name='view_switch_space'),
|
||||
path('no-perm/', views.no_perm, name='view_no_perm'),
|
||||
#path('space-overview/', views.space_overview, name='view_space_overview'),
|
||||
#path('switch-space/<int:space_id>', views.switch_space, name='view_switch_space'),
|
||||
#path('no-perm/', views.no_perm, name='view_no_perm'),
|
||||
path('invite/<slug:token>', views.invite_link, name='view_invite'),
|
||||
path('system/', views.system, name='view_system'),
|
||||
path('invite/<slug:token>/', views.invite_link, name='view_invite'),
|
||||
|
||||
path('system/', views.system, name='view_system'),
|
||||
path('plugin/update/', views.plugin_update, name='view_plugin_update'),
|
||||
|
||||
path('abuse/<slug:token>', views.report_share_abuse, name='view_report_share_abuse'),
|
||||
|
||||
@@ -125,10 +129,6 @@ urlpatterns = [
|
||||
|
||||
]
|
||||
|
||||
if DEBUG:
|
||||
urlpatterns.append(path('test/', views.test, name='view_test'))
|
||||
urlpatterns.append(path('test2/', views.test2, name='view_test2'))
|
||||
|
||||
# catchall view for new frontend
|
||||
urlpatterns += [
|
||||
path('', views.index, name='index'),
|
||||
|
||||
@@ -18,8 +18,6 @@ import litellm
|
||||
import redis
|
||||
import requests
|
||||
from PIL import UnidentifiedImageError
|
||||
from PIL.ImImagePlugin import number
|
||||
from PIL.features import check
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
@@ -35,7 +33,6 @@ from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from django.utils.datetime_safe import date
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
@@ -65,6 +62,8 @@ from cookbook.connectors.connector_manager import ConnectorManager, ActionType
|
||||
from cookbook.forms import ImportForm, ImportExportBase
|
||||
from cookbook.helper import recipe_url_import as helper
|
||||
from cookbook.helper.HelperFunctions import str2bool, validate_import_url
|
||||
from cookbook.helper.ai_helper import has_monthly_token, can_perform_ai_request, AiCallbackHandler
|
||||
from cookbook.helper.batch_edit_helper import add_to_relation, remove_from_relation, remove_all_from_relation, set_relation
|
||||
from cookbook.helper.image_processing import handle_image
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.open_data_importer import OpenDataImporter
|
||||
@@ -74,7 +73,7 @@ from cookbook.helper.permission_helper import (CustomIsAdmin, CustomIsOwner, Cus
|
||||
CustomTokenHasScope, CustomUserPermission, IsReadOnlyDRF,
|
||||
above_space_limit,
|
||||
group_required, has_group_permission, is_space_owner,
|
||||
switch_user_active_space
|
||||
switch_user_active_space, CustomAiProviderPermission, IsCreateDRF
|
||||
)
|
||||
from cookbook.helper.recipe_search import RecipeSearch
|
||||
from cookbook.helper.recipe_url_import import clean_dict, get_from_youtube_scraper, get_images_from_soup
|
||||
@@ -85,7 +84,7 @@ from cookbook.models import (Automation, BookmarkletImport, ConnectorConfig, Coo
|
||||
RecipeBookEntry, ShareLink, ShoppingListEntry,
|
||||
ShoppingListRecipe, Space, Step, Storage, Supermarket, SupermarketCategory,
|
||||
SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion,
|
||||
UserFile, UserPreference, UserSpace, ViewLog, RecipeImport, SearchPreference, SearchFields
|
||||
UserFile, UserPreference, UserSpace, ViewLog, RecipeImport, SearchPreference, SearchFields, AiLog, AiProvider
|
||||
)
|
||||
from cookbook.provider.dropbox import Dropbox
|
||||
from cookbook.provider.local import Local
|
||||
@@ -110,12 +109,13 @@ from cookbook.serializer import (AccessTokenSerializer, AutomationSerializer, Au
|
||||
UserSerializer, UserSpaceSerializer, ViewLogSerializer,
|
||||
LocalizationSerializer, ServerSettingsSerializer, RecipeFromSourceResponseSerializer, ShoppingListEntryBulkCreateSerializer, FdcQuerySerializer,
|
||||
AiImportSerializer, ImportOpenDataSerializer, ImportOpenDataMetaDataSerializer, ImportOpenDataResponseSerializer, ExportRequestSerializer,
|
||||
RecipeImportSerializer, ConnectorConfigSerializer, SearchPreferenceSerializer, SearchFieldsSerializer
|
||||
RecipeImportSerializer, ConnectorConfigSerializer, SearchPreferenceSerializer, SearchFieldsSerializer, RecipeBatchUpdateSerializer,
|
||||
AiProviderSerializer, AiLogSerializer, FoodBatchUpdateSerializer
|
||||
)
|
||||
from cookbook.version_info import TANDOOR_VERSION
|
||||
from cookbook.views.import_export import get_integration
|
||||
from recipes import settings
|
||||
from recipes.settings import DRF_THROTTLE_RECIPE_URL_IMPORT, FDC_API_KEY, AI_RATELIMIT, AI_API_KEY, AI_MODEL_NAME
|
||||
from recipes.settings import DRF_THROTTLE_RECIPE_URL_IMPORT, FDC_API_KEY, AI_RATELIMIT
|
||||
|
||||
DateExample = OpenApiExample('Date Format', value='1972-12-05', request_only=True)
|
||||
BeforeDateExample = OpenApiExample('Before Date Format', value='-1972-12-05', request_only=True)
|
||||
@@ -131,7 +131,7 @@ class LoggingMixin(object):
|
||||
|
||||
if settings.REDIS_HOST:
|
||||
try:
|
||||
d = date.today().isoformat()
|
||||
d = timezone.now().isoformat()
|
||||
space = request.space
|
||||
endpoint = request.resolver_match.url_name
|
||||
|
||||
@@ -179,7 +179,10 @@ class StandardFilterModelViewSet(viewsets.ModelViewSet):
|
||||
queryset = self.queryset
|
||||
query = self.request.query_params.get('query', None)
|
||||
if query is not None:
|
||||
queryset = queryset.filter(name__icontains=query)
|
||||
try:
|
||||
queryset = queryset.filter(name__icontains=query)
|
||||
except FieldError:
|
||||
pass
|
||||
|
||||
updated_at = self.request.query_params.get('updated_at', None)
|
||||
if updated_at is not None:
|
||||
@@ -411,6 +414,7 @@ class MergeMixin(ViewSetMixin):
|
||||
description='Return first level children of {obj} with ID [int]. Integer 0 will return root {obj}s.',
|
||||
type=int),
|
||||
OpenApiParameter(name='tree', description='Return all self and children of {obj} with ID [int].', type=int),
|
||||
OpenApiParameter(name='root_tree', description='Return all items belonging to the tree of the given {obj} id', type=int),
|
||||
]),
|
||||
move=extend_schema(parameters=[
|
||||
OpenApiParameter(name="parent", description='The ID of the desired parent of the {obj}.', type=OpenApiTypes.INT,
|
||||
@@ -423,6 +427,7 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
|
||||
def get_queryset(self):
|
||||
root = self.request.query_params.get('root', None)
|
||||
tree = self.request.query_params.get('tree', None)
|
||||
root_tree = self.request.query_params.get('root_tree', None)
|
||||
|
||||
if root:
|
||||
if root.isnumeric():
|
||||
@@ -441,10 +446,21 @@ class TreeMixin(MergeMixin, FuzzyFilterMixin):
|
||||
self.queryset = self.model.objects.get(id=int(tree)).get_descendants_and_self()
|
||||
except self.model.DoesNotExist:
|
||||
self.queryset = self.model.objects.none()
|
||||
elif root_tree:
|
||||
if root_tree.isnumeric():
|
||||
try:
|
||||
self.queryset = self.model.objects.get(id=int(root_tree)).get_root().get_descendants_and_self()
|
||||
except self.model.DoesNotExist:
|
||||
self.queryset = self.model.objects.none()
|
||||
|
||||
else:
|
||||
return self.annotate_recipe(queryset=super().get_queryset(), request=self.request,
|
||||
serializer=self.serializer_class, tree=True)
|
||||
self.queryset = self.queryset.filter(space=self.request.space).order_by(Lower('name').asc())
|
||||
|
||||
self.queryset = self.queryset.filter(space=self.request.space)
|
||||
# only order if not root_tree or tree mde because in these modes the sorting is relevant for the client
|
||||
if not root_tree and not tree:
|
||||
self.queryset = self.queryset.order_by(Lower('name').asc())
|
||||
|
||||
return self.annotate_recipe(queryset=self.queryset, request=self.request, serializer=self.serializer_class,
|
||||
tree=True)
|
||||
@@ -528,9 +544,9 @@ class GroupViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
class SpaceViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
queryset = Space.objects
|
||||
serializer_class = SpaceSerializer
|
||||
permission_classes = [IsReadOnlyDRF & CustomIsGuest | CustomIsOwner & CustomIsAdmin & CustomTokenHasReadWriteScope]
|
||||
permission_classes = [((IsReadOnlyDRF | IsCreateDRF) & CustomIsGuest) | CustomIsOwner & CustomIsAdmin & CustomTokenHasReadWriteScope]
|
||||
pagination_class = DefaultPagination
|
||||
http_method_names = ['get', 'patch']
|
||||
http_method_names = ['get', 'post', 'put', 'patch']
|
||||
|
||||
def get_queryset(self):
|
||||
return self.queryset.filter(
|
||||
@@ -549,7 +565,7 @@ class SpaceViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
class UserSpaceViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
queryset = UserSpace.objects
|
||||
serializer_class = UserSpaceSerializer
|
||||
permission_classes = [(CustomIsSpaceOwner | CustomIsOwnerReadOnly) & CustomTokenHasReadWriteScope]
|
||||
permission_classes = [(CustomIsSpaceOwner | (IsReadOnlyDRF & CustomIsUser) | CustomIsOwnerReadOnly) & CustomTokenHasReadWriteScope]
|
||||
http_method_names = ['get', 'put', 'patch', 'delete']
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
@@ -563,10 +579,23 @@ class UserSpaceViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
if internal_note is not None:
|
||||
self.queryset = self.queryset.filter(internal_note=internal_note)
|
||||
|
||||
if is_space_owner(self.request.user, self.request.space):
|
||||
# >= admins can see all users, guest/user can only see themselves
|
||||
if has_group_permission(self.request.user, ['admin']):
|
||||
return self.queryset.filter(space=self.request.space)
|
||||
else:
|
||||
return self.queryset.filter(user=self.request.user, space=self.request.space)
|
||||
return self.queryset.filter(space=self.request.space, user=self.request.user)
|
||||
|
||||
@extend_schema(responses=UserSpaceSerializer(many=True))
|
||||
@decorators.action(detail=False, pagination_class=DefaultPagination, methods=['GET'], serializer_class=UserSpaceSerializer, )
|
||||
def all_personal(self, request):
|
||||
"""
|
||||
return all userspaces for the user requesting the endpoint
|
||||
:param request:
|
||||
:return:
|
||||
"""
|
||||
with scopes_disabled():
|
||||
self.queryset = self.queryset.filter(user=self.request.user)
|
||||
return Response(self.serializer_class(self.queryset.all(), many=True, context={'request': self.request}).data)
|
||||
|
||||
|
||||
class UserPreferenceViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
@@ -604,6 +633,29 @@ class SearchPreferenceViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
return self.queryset.filter(user=self.request.user)
|
||||
|
||||
|
||||
class AiProviderViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
queryset = AiProvider.objects
|
||||
serializer_class = AiProviderSerializer
|
||||
permission_classes = [CustomAiProviderPermission & CustomTokenHasReadWriteScope]
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
def get_queryset(self):
|
||||
# read only access to all space and global AiProviders
|
||||
with scopes_disabled():
|
||||
return self.queryset.filter(Q(space=self.request.space) | Q(space__isnull=True))
|
||||
|
||||
|
||||
class AiLogViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
queryset = AiLog.objects
|
||||
serializer_class = AiLogSerializer
|
||||
permission_classes = [CustomIsUser & CustomTokenHasReadWriteScope]
|
||||
http_method_names = ['get']
|
||||
pagination_class = DefaultPagination
|
||||
|
||||
def get_queryset(self):
|
||||
return self.queryset.filter(space=self.request.space)
|
||||
|
||||
|
||||
class StorageViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
# TODO handle delete protect error and adjust test
|
||||
queryset = Storage.objects
|
||||
@@ -902,6 +954,94 @@ class FoodViewSet(LoggingMixin, TreeMixin):
|
||||
content = {'error': True, 'msg': e.args[0]}
|
||||
return Response(content, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
@decorators.action(detail=False, methods=['PUT'], serializer_class=FoodBatchUpdateSerializer)
|
||||
def batch_update(self, request):
|
||||
serializer = self.serializer_class(data=request.data, partial=True)
|
||||
|
||||
if serializer.is_valid():
|
||||
foods = Food.objects.filter(id__in=serializer.validated_data['foods'], space=self.request.space)
|
||||
safe_food_ids = Food.objects.filter(id__in=serializer.validated_data['foods'], space=self.request.space).values_list('id', flat=True)
|
||||
|
||||
if 'category' in serializer.validated_data:
|
||||
foods.update(supermarket_category_id=serializer.validated_data['category'])
|
||||
|
||||
if 'ignore_shopping' in serializer.validated_data and serializer.validated_data['ignore_shopping'] is not None:
|
||||
foods.update(ignore_shopping=serializer.validated_data['ignore_shopping'])
|
||||
|
||||
if 'on_hand' in serializer.validated_data and serializer.validated_data['on_hand'] is not None:
|
||||
if serializer.validated_data['on_hand']:
|
||||
user_relation = []
|
||||
for f in safe_food_ids:
|
||||
user_relation.append(Food.onhand_users.through(food_id=f, user_id=request.user.id))
|
||||
Food.onhand_users.through.objects.bulk_create(user_relation, ignore_conflicts=True, unique_fields=('food_id', 'user_id',))
|
||||
else:
|
||||
Food.onhand_users.through.objects.filter(food_id__in=safe_food_ids, user_id=request.user.id).delete()
|
||||
|
||||
if 'substitute_children' in serializer.validated_data and serializer.validated_data['substitute_children'] is not None:
|
||||
foods.update(substitute_children=serializer.validated_data['substitute_children'])
|
||||
|
||||
if 'substitute_siblings' in serializer.validated_data and serializer.validated_data['substitute_siblings'] is not None:
|
||||
foods.update(substitute_siblings=serializer.validated_data['substitute_siblings'])
|
||||
|
||||
# ---------- substitutes -------------
|
||||
if 'substitute_add' in serializer.validated_data:
|
||||
add_to_relation(Food.substitute.through, 'from_food_id', safe_food_ids, 'to_food_id', serializer.validated_data['substitute_add'])
|
||||
|
||||
if 'substitute_remove' in serializer.validated_data:
|
||||
remove_from_relation(Food.substitute.through, 'from_food_id', safe_food_ids, 'to_food_id', serializer.validated_data['substitute_remove'])
|
||||
|
||||
if 'substitute_set' in serializer.validated_data and len(serializer.validated_data['substitute_set']) > 0:
|
||||
set_relation(Food.substitute.through, 'from_food_id', safe_food_ids, 'to_food_id', serializer.validated_data['substitute_set'])
|
||||
|
||||
if 'substitute_remove_all' in serializer.validated_data and serializer.validated_data['substitute_remove_all']:
|
||||
remove_all_from_relation(Food.substitute.through, 'from_food_id', safe_food_ids)
|
||||
|
||||
# ---------- inherit fields -------------
|
||||
if 'inherit_fields_add' in serializer.validated_data:
|
||||
add_to_relation(Food.inherit_fields.through, 'food_id', safe_food_ids, 'foodinheritfield_id', serializer.validated_data['inherit_fields_add'])
|
||||
|
||||
if 'inherit_fields_remove' in serializer.validated_data:
|
||||
remove_from_relation(Food.inherit_fields.through, 'food_id', safe_food_ids, 'foodinheritfield_id', serializer.validated_data['inherit_fields_remove'])
|
||||
|
||||
if 'inherit_fields_set' in serializer.validated_data and len(serializer.validated_data['inherit_fields_set']) > 0:
|
||||
set_relation(Food.inherit_fields.through, 'food_id', safe_food_ids, 'foodinheritfield_id', serializer.validated_data['inherit_fields_set'])
|
||||
|
||||
if 'inherit_fields_remove_all' in serializer.validated_data and serializer.validated_data['inherit_fields_remove_all']:
|
||||
remove_all_from_relation(Food.inherit_fields.through, 'food_id', safe_food_ids)
|
||||
|
||||
# ---------- child inherit fields -------------
|
||||
if 'child_inherit_fields_add' in serializer.validated_data:
|
||||
add_to_relation(Food.child_inherit_fields.through, 'food_id', safe_food_ids, 'foodinheritfield_id', serializer.validated_data['child_inherit_fields_add'])
|
||||
|
||||
if 'child_inherit_fields_remove' in serializer.validated_data:
|
||||
remove_from_relation(Food.child_inherit_fields.through, 'food_id', safe_food_ids, 'foodinheritfield_id', serializer.validated_data['child_inherit_fields_remove'])
|
||||
|
||||
if 'child_inherit_fields_set' in serializer.validated_data and len(serializer.validated_data['child_inherit_fields_set']) > 0:
|
||||
set_relation(Food.child_inherit_fields.through, 'food_id', safe_food_ids, 'foodinheritfield_id', serializer.validated_data['child_inherit_fields_set'])
|
||||
|
||||
if 'child_inherit_fields_remove_all' in serializer.validated_data and serializer.validated_data['child_inherit_fields_remove_all']:
|
||||
remove_all_from_relation(Food.child_inherit_fields.through, 'food_id', safe_food_ids)
|
||||
|
||||
# ------- parent --------
|
||||
if self.model.node_order_by:
|
||||
node_location = 'sorted'
|
||||
else:
|
||||
node_location = 'last'
|
||||
|
||||
if 'parent_remove' in serializer.validated_data and serializer.validated_data['parent_remove']:
|
||||
for f in foods:
|
||||
f.move(Food.get_first_root_node(), f'{node_location}-sibling')
|
||||
|
||||
if 'parent_set' in serializer.validated_data:
|
||||
parent_food = Food.objects.filter(space=request.space, id=serializer.validated_data['parent_set']).first()
|
||||
if parent_food:
|
||||
for f in foods:
|
||||
f.move(parent_food, f'{node_location}-child')
|
||||
|
||||
return Response({}, 200)
|
||||
|
||||
return Response(serializer.errors, 400)
|
||||
|
||||
|
||||
@extend_schema_view(list=extend_schema(parameters=[
|
||||
OpenApiParameter(name='order_field', description='Field to order recipe books on', type=str,
|
||||
@@ -1095,7 +1235,19 @@ class IngredientViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
return self.serializer_class
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = self.queryset.filter(step__recipe__space=self.request.space)
|
||||
queryset = self.queryset.prefetch_related('food',
|
||||
'food__properties',
|
||||
'food__properties__property_type',
|
||||
'food__inherit_fields',
|
||||
'food__supermarket_category',
|
||||
'food__onhand_users',
|
||||
'food__substitute',
|
||||
'food__child_inherit_fields',
|
||||
'unit',
|
||||
'unit__unit_conversion_base_relation',
|
||||
'unit__unit_conversion_base_relation__base_unit',
|
||||
'unit__unit_conversion_converted_relation',
|
||||
'unit__unit_conversion_converted_relation__converted_unit', ).filter(step__recipe__space=self.request.space)
|
||||
food = self.request.query_params.get('food', None)
|
||||
if food and re.match(r'^(\d)+$', food):
|
||||
queryset = queryset.filter(food_id=food)
|
||||
@@ -1359,9 +1511,103 @@ class RecipeViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
|
||||
return Response(self.serializer_class(qs, many=True).data)
|
||||
|
||||
@decorators.action(detail=False, methods=['PUT'], serializer_class=RecipeBatchUpdateSerializer)
|
||||
def batch_update(self, request):
|
||||
serializer = self.serializer_class(data=request.data, partial=True)
|
||||
|
||||
if serializer.is_valid():
|
||||
recipes = Recipe.objects.filter(id__in=serializer.validated_data['recipes'], space=self.request.space)
|
||||
safe_recipe_ids = Recipe.objects.filter(id__in=serializer.validated_data['recipes'], space=self.request.space).values_list('id', flat=True)
|
||||
|
||||
if 'keywords_add' in serializer.validated_data:
|
||||
keyword_relations = []
|
||||
for r in recipes:
|
||||
for k in serializer.validated_data['keywords_add']:
|
||||
keyword_relations.append(Recipe.keywords.through(recipe_id=r.pk, keyword_id=k))
|
||||
Recipe.keywords.through.objects.bulk_create(keyword_relations, ignore_conflicts=True, unique_fields=('recipe_id', 'keyword_id',))
|
||||
|
||||
if 'keywords_remove' in serializer.validated_data:
|
||||
for k in serializer.validated_data['keywords_remove']:
|
||||
Recipe.keywords.through.objects.filter(recipe_id__in=safe_recipe_ids, keyword_id=k).delete()
|
||||
|
||||
if 'keywords_set' in serializer.validated_data and len(serializer.validated_data['keywords_set']) > 0:
|
||||
keyword_relations = []
|
||||
Recipe.keywords.through.objects.filter(recipe_id__in=safe_recipe_ids).delete()
|
||||
for r in recipes:
|
||||
for k in serializer.validated_data['keywords_set']:
|
||||
keyword_relations.append(Recipe.keywords.through(recipe_id=r.pk, keyword_id=k))
|
||||
Recipe.keywords.through.objects.bulk_create(keyword_relations, ignore_conflicts=True, unique_fields=('recipe_id', 'keyword_id',))
|
||||
|
||||
if 'keywords_remove_all' in serializer.validated_data and serializer.validated_data['keywords_remove_all']:
|
||||
Recipe.keywords.through.objects.filter(recipe_id__in=safe_recipe_ids).delete()
|
||||
|
||||
if 'working_time' in serializer.validated_data:
|
||||
recipes.update(working_time=serializer.validated_data['working_time'])
|
||||
|
||||
if 'waiting_time' in serializer.validated_data:
|
||||
recipes.update(waiting_time=serializer.validated_data['waiting_time'])
|
||||
|
||||
if 'servings' in serializer.validated_data:
|
||||
recipes.update(servings=serializer.validated_data['servings'])
|
||||
|
||||
if 'servings_text' in serializer.validated_data:
|
||||
recipes.update(servings_text=serializer.validated_data['servings_text'])
|
||||
|
||||
if 'private' in serializer.validated_data and serializer.validated_data['private'] is not None:
|
||||
recipes.update(private=serializer.validated_data['private'])
|
||||
|
||||
if 'shared_add' in serializer.validated_data:
|
||||
shared_relation = []
|
||||
for r in recipes:
|
||||
for u in serializer.validated_data['shared_add']:
|
||||
shared_relation.append(Recipe.shared.through(recipe_id=r.pk, user_id=u))
|
||||
Recipe.shared.through.objects.bulk_create(shared_relation, ignore_conflicts=True, unique_fields=('recipe_id', 'user_id',))
|
||||
|
||||
if 'shared_remove' in serializer.validated_data:
|
||||
for s in serializer.validated_data['shared_remove']:
|
||||
Recipe.shared.through.objects.filter(recipe_id__in=safe_recipe_ids, user_id=s).delete()
|
||||
|
||||
if 'shared_set' in serializer.validated_data and len(serializer.validated_data['shared_set']) > 0:
|
||||
shared_relation = []
|
||||
Recipe.shared.through.objects.filter(recipe_id__in=safe_recipe_ids).delete()
|
||||
for r in recipes:
|
||||
for u in serializer.validated_data['shared_set']:
|
||||
shared_relation.append(Recipe.shared.through(recipe_id=r.pk, user_id=u))
|
||||
Recipe.shared.through.objects.bulk_create(shared_relation, ignore_conflicts=True, unique_fields=('recipe_id', 'user_id',))
|
||||
|
||||
if 'shared_remove_all' in serializer.validated_data and serializer.validated_data['shared_remove_all']:
|
||||
Recipe.shared.through.objects.filter(recipe_id__in=safe_recipe_ids).delete()
|
||||
|
||||
if 'clear_description' in serializer.validated_data and serializer.validated_data['clear_description']:
|
||||
recipes.update(description='')
|
||||
|
||||
if 'show_ingredient_overview' in serializer.validated_data and serializer.validated_data['show_ingredient_overview'] is not None:
|
||||
recipes.update(show_ingredient_overview=serializer.validated_data['show_ingredient_overview'])
|
||||
|
||||
return Response({}, 200)
|
||||
|
||||
return Response(serializer.errors, 400)
|
||||
|
||||
@extend_schema(responses=RecipeSerializer(many=False))
|
||||
@decorators.action(detail=True, pagination_class=None, methods=['PATCH'], serializer_class=RecipeSerializer)
|
||||
def delete_external(self, request, pk):
|
||||
obj = self.get_object()
|
||||
if obj.get_space() != request.space and has_group_permission(request.user, ['user']):
|
||||
raise PermissionDenied(detail='You do not have the required permission to perform this action', code=403)
|
||||
|
||||
if obj.storage:
|
||||
get_recipe_provider(obj).delete_file(obj)
|
||||
obj.storage = None
|
||||
obj.file_path = ''
|
||||
obj.file_uid = ''
|
||||
obj.save()
|
||||
|
||||
return Response(self.serializer_class(obj, many=False, context={'request': request}).data)
|
||||
|
||||
|
||||
@extend_schema_view(list=extend_schema(
|
||||
parameters=[OpenApiParameter(name='food_id', description='ID of food to filter for', type=int), ]))
|
||||
parameters=[OpenApiParameter(name='food_id', description='ID of food to filter for', type=int),
|
||||
OpenApiParameter(name='query', description='query that looks into food, base unit or converted unit by name', type=str), ]))
|
||||
class UnitConversionViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
queryset = UnitConversion.objects
|
||||
serializer_class = UnitConversionSerializer
|
||||
@@ -1373,6 +1619,10 @@ class UnitConversionViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
if food_id is not None:
|
||||
self.queryset = self.queryset.filter(food_id=food_id)
|
||||
|
||||
query = self.request.query_params.get('query', None)
|
||||
if query is not None:
|
||||
self.queryset = self.queryset.filter(Q(food__name__icontains=query) | Q(base_unit__name__icontains=query) | Q(converted_unit__name__icontains=query))
|
||||
|
||||
return self.queryset.filter(space=self.request.space)
|
||||
|
||||
|
||||
@@ -1654,7 +1904,8 @@ class AutomationViewSet(LoggingMixin, StandardFilterModelViewSet):
|
||||
|
||||
|
||||
@extend_schema_view(list=extend_schema(parameters=[
|
||||
OpenApiParameter(name='internal_note', description=_('Text field to store data that gets carried over to the UserSpace created from the InviteLink'), type=str)
|
||||
OpenApiParameter(name='internal_note', description=_('Text field to store data that gets carried over to the UserSpace created from the InviteLink'), type=str),
|
||||
OpenApiParameter(name='unused', description=_('Only return InviteLinks that have not been used yet.'), type=bool),
|
||||
]))
|
||||
class InviteLinkViewSet(LoggingMixin, StandardFilterModelViewSet):
|
||||
queryset = InviteLink.objects
|
||||
@@ -1667,6 +1918,10 @@ class InviteLinkViewSet(LoggingMixin, StandardFilterModelViewSet):
|
||||
if internal_note is not None:
|
||||
self.queryset = self.queryset.filter(internal_note=internal_note)
|
||||
|
||||
used = self.request.query_params.get('used', False)
|
||||
if not used:
|
||||
self.queryset = self.queryset.filter(used_by=None)
|
||||
|
||||
if is_space_owner(self.request.user, self.request.space):
|
||||
self.queryset = self.queryset.filter(space=self.request.space).all()
|
||||
return super().get_queryset()
|
||||
@@ -1789,9 +2044,9 @@ class RecipeUrlImportView(APIView):
|
||||
return Response(RecipeFromSourceResponseSerializer(context={'request': request}).to_representation(response), status=status.HTTP_200_OK)
|
||||
|
||||
tandoor_url = None
|
||||
if re.match('^(.)*/recipe/[0-9]+/?share=[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', url):
|
||||
if re.match(r'^(.)*/recipe/[0-9]+/\?share=[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', url):
|
||||
tandoor_url = url.replace('/recipe/', '/api/recipe/')
|
||||
elif re.match('^(.)*/view/recipe/[0-9]+/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', url):
|
||||
elif re.match(r'^(.)*/view/recipe/[0-9]+/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', url):
|
||||
tandoor_url = (url.replace('/view/recipe/', '/api/recipe/').replace(re.split('/recipe/[0-9]+', url)[1], '') + '?share=' +
|
||||
re.split('/recipe/[0-9]+', url)[1].replace('/', ''))
|
||||
if tandoor_url and validate_import_url(tandoor_url):
|
||||
@@ -1884,8 +2139,32 @@ class AiImportView(APIView):
|
||||
if serializer.is_valid():
|
||||
# TODO max file size check
|
||||
|
||||
if 'ai_provider_id' not in serializer.validated_data:
|
||||
response = {
|
||||
'error': True,
|
||||
'msg': _('You must select an AI provider to perform your request.'),
|
||||
}
|
||||
return Response(RecipeFromSourceResponseSerializer(context={'request': request}).to_representation(response), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not can_perform_ai_request(request.space):
|
||||
response = {
|
||||
'error': True,
|
||||
'msg': _("You don't have any credits remaining to use AI or AI features are not enabled for your space."),
|
||||
}
|
||||
return Response(RecipeFromSourceResponseSerializer(context={'request': request}).to_representation(response), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
ai_provider = AiProvider.objects.filter(pk=serializer.validated_data['ai_provider_id']).filter(Q(space=request.space) | Q(space__isnull=True)).first()
|
||||
|
||||
litellm.callbacks = [AiCallbackHandler(request.space, request.user, ai_provider)]
|
||||
|
||||
messages = []
|
||||
uploaded_file = serializer.validated_data['file']
|
||||
|
||||
if serializer.validated_data['recipe_id']:
|
||||
if recipe := Recipe.objects.filter(id=serializer.validated_data['recipe_id']).first():
|
||||
if recipe.file_path:
|
||||
uploaded_file = get_recipe_provider(recipe).get_file(recipe)
|
||||
|
||||
if uploaded_file:
|
||||
base64type = None
|
||||
try:
|
||||
@@ -1946,7 +2225,15 @@ class AiImportView(APIView):
|
||||
return Response(RecipeFromSourceResponseSerializer(context={'request': request}).to_representation(response), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
ai_response = completion(api_key=AI_API_KEY, model=AI_MODEL_NAME, response_format={"type": "json_object"}, messages=messages, )
|
||||
ai_request = {
|
||||
'api_key': ai_provider.api_key,
|
||||
'model': ai_provider.model_name,
|
||||
'response_format': {"type": "json_object"},
|
||||
'messages': messages,
|
||||
}
|
||||
if ai_provider.url:
|
||||
ai_request['api_base'] = ai_provider.url
|
||||
ai_response = completion(**ai_request)
|
||||
except BadRequestError as err:
|
||||
response = {
|
||||
'error': True,
|
||||
@@ -2005,7 +2292,13 @@ class AppImportView(APIView):
|
||||
files = []
|
||||
for f in request.FILES.getlist('files'):
|
||||
files.append({'file': io.BytesIO(f.read()), 'name': f.name})
|
||||
t = threading.Thread(target=integration.do_import, args=[files, il, form.cleaned_data['duplicates']])
|
||||
t = threading.Thread(target=integration.do_import,
|
||||
args=[files, il, form.cleaned_data['duplicates']],
|
||||
kwargs={'meal_plans': form.cleaned_data['meal_plans'],
|
||||
'shopping_lists': form.cleaned_data['shopping_lists'],
|
||||
'nutrition_per_serving': form.cleaned_data['nutrition_per_serving']
|
||||
}
|
||||
)
|
||||
t.setDaemon(True)
|
||||
t.start()
|
||||
|
||||
@@ -2251,7 +2544,6 @@ class ServerSettingsViewSet(viewsets.GenericViewSet):
|
||||
# Attention: No login required, do not return sensitive data
|
||||
s['shopping_min_autosync_interval'] = settings.SHOPPING_MIN_AUTOSYNC_INTERVAL
|
||||
s['enable_pdf_export'] = settings.ENABLE_PDF_EXPORT
|
||||
s['enable_ai_import'] = settings.AI_API_KEY != ''
|
||||
s['disable_external_connectors'] = settings.DISABLE_EXTERNAL_CONNECTORS
|
||||
s['terms_url'] = settings.TERMS_URL
|
||||
s['privacy_url'] = settings.PRIVACY_URL
|
||||
@@ -2404,7 +2696,7 @@ def meal_plans_to_ical(queryset, filename):
|
||||
request=inline_serializer(name="IngredientStringSerializer", fields={'text': CharField()}),
|
||||
responses=inline_serializer(name="ParsedIngredientSerializer",
|
||||
fields={'amount': IntegerField(), 'unit': CharField(), 'food': CharField(),
|
||||
'note': CharField()})
|
||||
'note': CharField(), 'original_text': CharField()})
|
||||
)
|
||||
@api_view(['POST'])
|
||||
@permission_classes([CustomIsUser & CustomTokenHasReadWriteScope])
|
||||
@@ -2414,13 +2706,19 @@ def ingredient_from_string(request):
|
||||
ingredient_parser = IngredientParser(request, False)
|
||||
amount, unit, food, note = ingredient_parser.parse(text)
|
||||
|
||||
ingredient = {'amount': amount, 'unit': None, 'food': None, 'note': note}
|
||||
ingredient = {'amount': amount, 'unit': None, 'food': None, 'note': note, 'original_text': text}
|
||||
if food:
|
||||
food, created = Food.objects.get_or_create(space=request.space, name=food)
|
||||
ingredient['food'] = {'name': food.name, 'id': food.id}
|
||||
if food_obj := Food.objects.filter(space=request.space).filter(Q(name=food) | Q(plural_name=food)).first():
|
||||
ingredient['food'] = {'name': food_obj.name, 'id': food_obj.id}
|
||||
else:
|
||||
food_obj = Food.objects.create(space=request.space, name=food)
|
||||
ingredient['food'] = {'name': food_obj.name, 'id': food_obj.id}
|
||||
|
||||
if unit:
|
||||
unit, created = Unit.objects.get_or_create(space=request.space, name=unit)
|
||||
ingredient['unit'] = {'name': unit.name, 'id': unit.id}
|
||||
if unit_obj := Unit.objects.filter(space=request.space).filter(Q(name=unit) | Q(plural_name=unit)).first():
|
||||
ingredient['unit'] = {'name': unit_obj.name, 'id': unit_obj.id}
|
||||
else:
|
||||
unit_obj = Unit.objects.create(space=request.space, name=unit)
|
||||
ingredient['unit'] = {'name': unit_obj.name, 'id': unit_obj.id}
|
||||
|
||||
return JsonResponse(ingredient, status=200)
|
||||
|
||||
@@ -17,6 +17,7 @@ from cookbook.integration.copymethat import CopyMeThat
|
||||
from cookbook.integration.default import Default
|
||||
from cookbook.integration.domestica import Domestica
|
||||
from cookbook.integration.mealie import Mealie
|
||||
from cookbook.integration.mealie1 import Mealie1
|
||||
from cookbook.integration.mealmaster import MealMaster
|
||||
from cookbook.integration.melarecipes import MelaRecipes
|
||||
from cookbook.integration.nextcloud_cookbook import NextcloudCookbook
|
||||
@@ -45,6 +46,8 @@ def get_integration(request, export_type):
|
||||
return NextcloudCookbook(request, export_type)
|
||||
if export_type == ImportExportBase.MEALIE:
|
||||
return Mealie(request, export_type)
|
||||
if export_type == ImportExportBase.MEALIE1:
|
||||
return Mealie1(request, export_type)
|
||||
if export_type == ImportExportBase.CHOWDOWN:
|
||||
return Chowdown(request, export_type)
|
||||
if export_type == ImportExportBase.SAFFRON:
|
||||
|
||||
@@ -54,7 +54,7 @@ def hook(request, token):
|
||||
f = ingredient_parser.get_food(food)
|
||||
u = ingredient_parser.get_unit(unit)
|
||||
|
||||
ShoppingListEntry.objects.create(food=f, unit=u, amount=amount, created_by=request.user, space=request.space)
|
||||
ShoppingListEntry.objects.create(food=f, unit=u, amount=max(1, amount), created_by=request.user, space=request.space)
|
||||
|
||||
return JsonResponse({'data': data['message']['text']})
|
||||
except Exception:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
from io import StringIO
|
||||
from uuid import UUID
|
||||
@@ -13,14 +14,14 @@ 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.exceptions import ValidationError, PermissionDenied, BadRequest
|
||||
from django.core.management import call_command
|
||||
from django.db import models
|
||||
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
|
||||
from django.utils.datetime_safe import date
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
from drf_spectacular.views import SpectacularRedocView, SpectacularSwaggerView
|
||||
@@ -41,6 +42,9 @@ 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 'signup_token' in request.session:
|
||||
return HttpResponseRedirect(reverse('view_invite', args=[request.session.pop('signup_token', '')]))
|
||||
|
||||
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:
|
||||
@@ -96,7 +100,8 @@ def space_overview(request):
|
||||
max_recipes=settings.SPACE_DEFAULT_MAX_RECIPES,
|
||||
max_users=settings.SPACE_DEFAULT_MAX_USERS,
|
||||
allow_sharing=settings.SPACE_DEFAULT_ALLOW_SHARING,
|
||||
)
|
||||
ai_enabled=settings.SPACE_AI_ENABLED,
|
||||
ai_credits_monthly=settings.SPACE_AI_CREDITS_MONTHLY, )
|
||||
|
||||
user_space = UserSpace.objects.create(space=created_space, user=request.user, active=False)
|
||||
user_space.groups.add(Group.objects.filter(name='admin').get())
|
||||
@@ -221,7 +226,7 @@ def system(request):
|
||||
total_stats = ['All', int(r.get('api:request-count'))]
|
||||
|
||||
for i in range(0, 6):
|
||||
d = (date.today() - timedelta(days=i)).isoformat()
|
||||
d = (timezone.now() - timedelta(days=i)).isoformat()
|
||||
api_stats[0].append(d)
|
||||
api_space_stats[0].append(d)
|
||||
total_stats.append(int(r.get(f'api:request-count:{d}')) if r.get(f'api:request-count:{d}') else 0)
|
||||
@@ -232,17 +237,18 @@ def system(request):
|
||||
endpoint = x[0].decode('utf-8')
|
||||
endpoint_stats = [endpoint, x[1]]
|
||||
for i in range(0, 6):
|
||||
d = (date.today() - timedelta(days=i)).isoformat()
|
||||
d = (timezone.now() - timedelta(days=i)).isoformat()
|
||||
endpoint_stats.append(r.zscore(f'api:endpoint-request-count:{d}', endpoint))
|
||||
api_stats.append(endpoint_stats)
|
||||
|
||||
for x in r.zrange('api:space-request-count', 0, 20, withscores=True, desc=True):
|
||||
s = x[0].decode('utf-8')
|
||||
space_stats = [Space.objects.get(pk=s).name, x[1]]
|
||||
for i in range(0, 6):
|
||||
d = (date.today() - timedelta(days=i)).isoformat()
|
||||
space_stats.append(r.zscore(f'api:space-request-count:{d}', s))
|
||||
api_space_stats.append(space_stats)
|
||||
if space := Space.objects.filter(pk=s).first():
|
||||
space_stats = [space.name, x[1]]
|
||||
for i in range(0, 6):
|
||||
d = (timezone.now() - timedelta(days=i)).isoformat()
|
||||
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:
|
||||
@@ -266,6 +272,22 @@ def system(request):
|
||||
})
|
||||
|
||||
|
||||
def plugin_update(request):
|
||||
if not request.user.is_superuser:
|
||||
raise PermissionDenied
|
||||
|
||||
if not 'module' in request.GET:
|
||||
raise BadRequest
|
||||
|
||||
for p in PLUGINS:
|
||||
if p['module'] == request.GET['module']:
|
||||
update_response = subprocess.check_output(['git', 'pull'], cwd=p['base_path'])
|
||||
print(update_response)
|
||||
return HttpResponseRedirect(reverse('view_system'))
|
||||
|
||||
return HttpResponseRedirect(reverse('view_system'))
|
||||
|
||||
|
||||
def setup(request):
|
||||
with scopes_disabled():
|
||||
if User.objects.count() > 0 or 'django.contrib.auth.backends.RemoteUserBackend' in settings.AUTHENTICATION_BACKENDS:
|
||||
@@ -303,7 +325,7 @@ def invite_link(request, token):
|
||||
try:
|
||||
token = UUID(token, version=4)
|
||||
except ValueError:
|
||||
messages.add_message(request, messages.ERROR, _('Malformed Invite Link supplied!'))
|
||||
print('Malformed Invite Link supplied!')
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
if link := InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, uuid=token).first():
|
||||
@@ -312,22 +334,17 @@ def invite_link(request, token):
|
||||
link.used_by = request.user
|
||||
link.save()
|
||||
|
||||
user_space = UserSpace.objects.create(user=request.user, space=link.space, internal_note=link.internal_note, invite_link=link, active=False)
|
||||
|
||||
if request.user.userspace_set.count() == 1:
|
||||
user_space.active = True
|
||||
user_space.save()
|
||||
UserSpace.objects.filter(user=request.user).update(active=False)
|
||||
user_space = UserSpace.objects.create(user=request.user, space=link.space, internal_note=link.internal_note, invite_link=link, active=True)
|
||||
|
||||
user_space.groups.add(link.group)
|
||||
|
||||
messages.add_message(request, messages.SUCCESS, _('Successfully joined space.'))
|
||||
return HttpResponseRedirect(reverse('view_space_overview'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
else:
|
||||
request.session['signup_token'] = str(token)
|
||||
return HttpResponseRedirect(reverse('account_signup'))
|
||||
|
||||
messages.add_message(request, messages.ERROR, _('Invite Link not valid or already used!'))
|
||||
return HttpResponseRedirect(reverse('view_space_overview'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
|
||||
def report_share_abuse(request, token):
|
||||
|
||||
@@ -11,34 +11,14 @@ If you like this application and want it to give back, there are many ways to co
|
||||
If you know any foreign languages you can:
|
||||
Improve the translations for any of the existing languages.
|
||||
|
||||
Add a new language to the long list of existing translations.
|
||||
|
||||
- Armenian
|
||||
- Bulgarian
|
||||
- Catalan
|
||||
- Czech
|
||||
- Danish
|
||||
- Dutch
|
||||
- English
|
||||
- French
|
||||
- German
|
||||
- Hungarian
|
||||
- Italian
|
||||
- Latvian
|
||||
- Norwegian
|
||||
- Polish
|
||||
- Russian
|
||||
- Spanish
|
||||
- Swedish
|
||||
|
||||
See [here](/docs/contribute/translations) for further information on how to contribute translation to Tandoor.
|
||||
See [here](/contribute/translations/) for further information on how to contribute translation to Tandoor.
|
||||
|
||||
## Issues and Feature Requests
|
||||
|
||||
The most basic but also very important way of contributing is reporting issues and commenting on ideas and feature requests
|
||||
The most basic but also crucial way of contributing is reporting issues and commenting on ideas and feature requests
|
||||
over at [GitHub issues](https://github.com/vabene1111/recipes/issues).
|
||||
|
||||
Without feedback improvement can't happen, so don't hesitate to say what you want to say.
|
||||
Without feedback, improvement can't happen, so don't hesitate to say what you want to say.
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -46,12 +26,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](/docs/contribute/documentation) for more information on how to add documentation to Tandoor.
|
||||
See [here](/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](/docs/contribute/guidelines) and
|
||||
[VSCode](/docs/contribute/vscode) or [PyCharm](/docs/contribute/pycharm) specific configurations.
|
||||
Before writing any code, please make sure that you review [contribution guidelines](/contribute/guidelines/) and
|
||||
[VSCode](/contribute/vscode) or [PyCharm](/contribute/pycharm) specific configurations.
|
||||
|
||||
@@ -32,10 +32,10 @@ To contribute to the project you are required to use the following packages with
|
||||
|
||||
## Testing
|
||||
|
||||
Django uses pytest-django to implement a full suite of testing. If you make any functional changes, please implment the appropriate
|
||||
Django uses pytest-django to implement a full suite of testing. If you make any functional changes, please implement the appropriate
|
||||
tests.
|
||||
|
||||
Tandoor is also actively soliciting contribors willing to setup vue3 testing. If you have knowledge in this area it would be greatly appreciated.
|
||||
Tandoor is also actively soliciting contributors willing to setup vue3 testing. If you have knowledge in this area it would be greatly appreciated.
|
||||
|
||||
## API Client
|
||||
|
||||
@@ -44,9 +44,7 @@ Tandoor is also actively soliciting contribors willing to setup vue3 testing. If
|
||||
The OpenAPI Generator is a Java project. You must have the java binary executable available on your PATH for this to work.
|
||||
|
||||
Tandoor uses [django-rest-framework](https://www.django-rest-framework.org/) for API implementation. Making contributions that impact the API requires an understanding of
|
||||
Viewsets and Serializers.
|
||||
|
||||
Also double check that your changes are actively reflected in the schema so that client apis are generated accurately.
|
||||
ViewSets and Serializers.
|
||||
|
||||
The API Client is generated automatically from the OpenAPI interface provided by the Django REST framework.
|
||||
For this [openapi-generator](https://github.com/OpenAPITools/openapi-generator) is used.
|
||||
@@ -55,17 +53,9 @@ Install it using your desired setup method. (For example, using `npm install @op
|
||||
|
||||
### Vue
|
||||
|
||||
Navigate to `vue/src/utils/openapi`.
|
||||
|
||||
Generate the schema using `openapi-generator-cli generate -g typescript-axios -i http://127.0.0.1:8000/openapi/`. (Replace your dev server url if required.)
|
||||
|
||||
### Vue3
|
||||
|
||||
Navigate to `vue3/src/openapi`.
|
||||
|
||||
Generate the schema using `openapi-generator-cli generate -g typescript-fetch -i http://127.0.0.1:8000/openapi/`. (Replace your dev server url if required.)
|
||||
Generate the schema using the `generate_api_client.py` script in the main directory.
|
||||
|
||||
## Install and Configuration
|
||||
|
||||
Instructions for [VSCode](/docs/contribute/vscode)
|
||||
Instructions for [PyCharm](/docs/contribute/pycharm)
|
||||
Instructions for [VSCode](/contribute/vscode)
|
||||
Instructions for [PyCharm](/contribute/pycharm)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<!-- prettier-ignore-start -->
|
||||
!!! info "Development Setup"
|
||||
The dev setup is a little messy as this application combines the best (at least in my opinion) of both Django and Vue.js.
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
### Devcontainer Setup
|
||||
|
||||
@@ -32,17 +30,15 @@ populated from default values.
|
||||
|
||||
### Vue.js
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
!!! warning "Feature Freeze"
|
||||
With the exception of bug fixes, no changes will be accepted on the legacy `vue` front-end.
|
||||
<!-- prettier-ignore-end -->
|
||||
!!! danger "Development Setup"
|
||||
The vite dev server **must** be started before the django runserver command is run or else django will **not** recognize it and try to fallback to the build files.
|
||||
|
||||
Most new frontend pages are build using [Vue.js](https://vuejs.org/).
|
||||
The frontend is build using [Vue.js](https://vuejs.org/).
|
||||
|
||||
In order to work on these pages, you will have to install a Javascript package manager of your choice. The following examples use yarn.
|
||||
|
||||
In the `vue` folder run `yarn install` followed by `yarn build` to install and build the legacy front-end.
|
||||
In the `vue3` folder run `yarn install` followed by `yarn build` to install and build the new front-end.
|
||||
1. go to the `vue3` and run `yarn install` to install the dependencies
|
||||
2. run `yarn serve` to start the dev server that allows hot reloading and easy and quick development
|
||||
|
||||
After that you can use `yarn serve` from the `vue3` folder to start the development server, and proceed to test your changes.
|
||||
If you do not wish to work on those pages, but instead want the application to work properly during development, run `yarn build` to build the frontend pages once.
|
||||
If you do not wish to work on those pages, but instead want the application to work properly during development, run `yarn build` to build the frontend pages once. After that you
|
||||
might need to run `python manage.py collectstatic` to setup the static files.
|
||||
|
||||
@@ -16,8 +16,6 @@ Maintained by [Aimo](https://github.com/aimok04/kitshn)
|
||||
- Website: [https://kitshn.app/](https://kitshn.app/)
|
||||
- Appstores: [Apple](https://apps.apple.com/us/app/kitshn-for-tandoor/id6740168361), [Android](https://play.google.com/store/apps/details?id=de.kitshn.android)
|
||||
|
||||
|
||||
|
||||
### Untare (discontinued)
|
||||
|
||||
Maintained by [phantomate](https://github.com/phantomate/Untare)
|
||||
|
||||
@@ -33,17 +33,26 @@ VS Marketplace Link: https://marketplace.visualstudio.com/items?itemName=esbenp.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
!!! note
|
||||
In order to debug vue yarn and vite servers must be started before starting the django server.
|
||||
In order to hot reload vue, the `yarn dev` server must be started before starting the django server.
|
||||
|
||||
There are a number of built in tasks that are available. Here are a few of the key ones:
|
||||
|
||||
- `Setup Dev Server` - Runs all the prerequisite steps so that the dev server can be run inside VSCode.
|
||||
- `Setup Tests` - Runs all prerequisites so tests can be run inside VSCode.
|
||||
|
||||
Once these are run, you should be able to run/debug a django server in VSCode as well as run/debug tests directly through VSCode.
|
||||
There are also a few other tasks specified in case you have specific development needs:
|
||||
Once these are run, there are 2 options. If you want to run a vue3 server in a hot reload mode for quick development of the frontend, you should run a development vue server:
|
||||
|
||||
- `Yarn Dev` - Runs development Vue.js vite server not connected to VSCode. Useful if you want to make Vue changes and see them in realtime.
|
||||
|
||||
If not, you need to build and copy the frontend to the django server. If you make changes to the frontend, you need to re-run this and restart the django server:
|
||||
|
||||
- `Collect Static Files` - Builds and collects the vue3 frontend so that it can be served via the django server.
|
||||
|
||||
Once either of those steps are done, you can start the django server:
|
||||
|
||||
- `Run Dev Server` - Runs a django development server not connected to VSCode.
|
||||
|
||||
There are also a few other tasks specified in case you have specific development needs:
|
||||
|
||||
- `Run all pytests` - Runs all the pytests outside of VSCode.
|
||||
- `Yarn Serve` - Runs development Vue.js server not connected to VSCode. Useful if you want to make Vue changes and see them in realtime.
|
||||
- `Serve Documentation` - Runs a documentation server. Useful if you want to see how changes to documentation show up.
|
||||
|
||||
18
docs/features/ai.md
Normal file
18
docs/features/ai.md
Normal file
@@ -0,0 +1,18 @@
|
||||
Tandoor has several AI based features. To allow maximum flexibility, you can configure different AI providers and select them based on the task you want to perform.
|
||||
To prevent accidental cost escalation Tandoor has a robust system of tracking and limiting AI costs.
|
||||
|
||||
## Default Configuration
|
||||
By default the AI features are enabled for every space. Each space has a spending limit of roughly 1 USD per month.
|
||||
This can be changed using the [configuration variables](https://docs.tandoor.dev/system/configuration/#ai-integration)
|
||||
|
||||
You can change these settings any time using the django admin. If you do not care about AI cost you can enter a very high limit or disable cost tracking for your providers.
|
||||
The limit resets on the first of every month.
|
||||
|
||||
## Configure AI Providers
|
||||
When AI support is enabled for a space every user in a space can configure AI providers.
|
||||
The models shown in the editor have been tested and work with Tandoor. Most other models that can parse images/files and return text should also work.
|
||||
|
||||
Superusers also have the ability to configure global AI providers that every space can use.
|
||||
|
||||
## AI Log
|
||||
The AI Log allows you to track the usage of AI calls. Here you can also see the usage.
|
||||
@@ -97,10 +97,17 @@ Follow these steps to import your recipes
|
||||
|
||||
Mealie provides structured data similar to nextcloud.
|
||||
|
||||
!!! WARNING "Versions"
|
||||
There are two different versions of the Mealie importer. One for all backups created prior to Version 1.0 and one for all after.
|
||||
|
||||
!!! INFO "Versions"
|
||||
The Mealie UI does not indicate weather or not nutrition information is stored per serving or per recipe. This choice is left to the user. During the import you will have to choose
|
||||
how Tandoor should treat your nutrition data.
|
||||
|
||||
To migrate your recipes
|
||||
|
||||
1. Go to your Mealie settings and create a new Backup.
|
||||
2. Download the backup by clicking on it and pressing download (this wasn't working for me, so I had to manually pull it from the server).
|
||||
1. Go to your Mealie admin settings and create a new backup.
|
||||
2. Download the backup.
|
||||
3. Upload the entire `.zip` file to the importer page and import everything.
|
||||
|
||||
## Chowdown
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<a href="https://docs.tandoor.dev/install/docker/" target="_blank" rel="noopener noreferrer">Installation</a> •
|
||||
<a href="https://docs.tandoor.dev/" target="_blank" rel="noopener noreferrer">Docs</a> •
|
||||
<a href="https://app.tandoor.dev/accounts/login/?demo" target="_blank" rel="noopener noreferrer">Demo</a> •
|
||||
<a href="https://community.tandoor.dev" target="_blank" rel="noopener noreferrer">Community</a> •
|
||||
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer">Discord</a>
|
||||
</p>
|
||||
|
||||
@@ -65,13 +66,13 @@ Share some information on how you use Tandoor to help me improve the application
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><a href="https://discord.gg/RhzBrfWgtp">Discord</a></td>
|
||||
<td>We have a public Discord server that anyone can join. This is where all our developers and contributors hang out and where we make announcements</td>
|
||||
<td><a href="https://community.tandoor.dev">Community</a></td>
|
||||
<td>Get support, share best practices, discuss feature ideas, and meet other Tandoor users.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><a href="https://twitter.com/TandoorRecipes">Twitter</a></td>
|
||||
<td>You can follow our Twitter account to get updates on new features or releases</td>
|
||||
<td><a href="https://discord.gg/RhzBrfWgtp">Discord</a></td>
|
||||
<td>We have a public Discord server that anyone can join. This is where all our developers and contributors hang out and where we make announcements</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@ spec:
|
||||
source venv/bin/activate
|
||||
echo "Updating database"
|
||||
python manage.py migrate
|
||||
python manage.py collectstatic_js_reverse
|
||||
python manage.py collectstatic --noinput
|
||||
echo "Setting media file attributes"
|
||||
chown -R 65534:65534 /opt/recipes/mediafiles
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## K8s Setup
|
||||
|
||||
This is a setup which should be sufficient for production use. Be sure to replace the default secrets!
|
||||
This is a setup which should be sufficient for production use. Be sure to replace the default secrets! You can find the example files [here](https://github.com/MyDigitalLife/recipes/tree/fix-k8s-documentation/docs/install/k8s) on Github.
|
||||
|
||||
## Files
|
||||
|
||||
|
||||
@@ -196,6 +196,7 @@ server {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_pass http://unix:/var/www/recipes/recipes.sock;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -472,15 +472,20 @@ S3_CUSTOM_DOMAIN= # when using a CDN/proxy to S3 (see https://github.com/Tandoor
|
||||
|
||||
#### AI Integration
|
||||
|
||||
To use AI to perform different tasks you need to configure an API key and the AI provider. [LiteLLM](https://www.litellm.ai/) is used
|
||||
to make a standardized request to different AI providers of your liking.
|
||||
|
||||
Configuring this via environment parameters is a temporary solution. In the future I plan on adding support for multiple AI providers per Tandoor instance
|
||||
with the option to select them for various tasks. For now only gemini 2.0 flash has been tested but feel free to try out other models.
|
||||
Most AI features are configured trough the AI Provider settings in the Tandoor web interface. Some defaults can be set for new spaces on your instance.
|
||||
|
||||
Enables AI features for spaces by default
|
||||
```
|
||||
SPACE_AI_ENABLED=1
|
||||
```
|
||||
|
||||
Sets the monthly default credit limit for AI usage
|
||||
```
|
||||
SPACE_AI_CREDITS_MONTHLY=100
|
||||
```
|
||||
|
||||
Ratelimit for AI API
|
||||
```
|
||||
AI_API_KEY=
|
||||
AI_MODEL_NAME=gemini/gemini-2.0-flash
|
||||
AI_RATELIMIT=60/hour
|
||||
```
|
||||
|
||||
|
||||
@@ -7,20 +7,19 @@ server {
|
||||
|
||||
# serve media files
|
||||
location /media {
|
||||
alias /opt/recipes/mediafiles;
|
||||
alias ${MEDIA_ROOT};
|
||||
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;
|
||||
alias ${STATIC_ROOT}/vue3/service-worker.js;
|
||||
}
|
||||
|
||||
# pass requests for dynamic content to gunicorn
|
||||
location / {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_pass http://localhost:8080;
|
||||
|
||||
proxy_pass http://localhost:${TANDOOR_PORT};
|
||||
error_page 502 /errors/http502.html;
|
||||
}
|
||||
|
||||
21
plugin.py
Normal file
21
plugin.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import os
|
||||
import subprocess
|
||||
import traceback
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
#TODO clean existing links for when plugins are uninstalled or not necessary because it will just be empty links?
|
||||
|
||||
PLUGINS_DIRECTORY = os.path.join(BASE_DIR, 'recipes', 'plugins')
|
||||
if os.path.isdir(PLUGINS_DIRECTORY):
|
||||
for d in os.listdir(PLUGINS_DIRECTORY):
|
||||
if d != '__pycache__':
|
||||
try:
|
||||
subprocess.run(['python', 'setup_repo.py'], shell=(os.name == 'nt'), cwd=os.path.join(BASE_DIR, 'recipes', 'plugins', d))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
print(f'ERROR failed to link plugin {d}')
|
||||
|
||||
subprocess.run(['npm', 'install', '--global', 'yarn'], shell=(os.name == 'nt'), cwd=os.path.join(BASE_DIR, 'vue3'))
|
||||
subprocess.run(['yarn', 'install'], shell=(os.name == 'nt'), cwd=os.path.join(BASE_DIR, 'vue3'))
|
||||
subprocess.run(['yarn', 'build'], shell=(os.name == 'nt'), cwd=os.path.join(BASE_DIR, 'vue3'))
|
||||
@@ -59,6 +59,8 @@ SPACE_DEFAULT_MAX_RECIPES = int(os.getenv('SPACE_DEFAULT_MAX_RECIPES', 0))
|
||||
SPACE_DEFAULT_MAX_USERS = int(os.getenv('SPACE_DEFAULT_MAX_USERS', 0))
|
||||
SPACE_DEFAULT_MAX_FILES = int(os.getenv('SPACE_DEFAULT_MAX_FILES', 0))
|
||||
SPACE_DEFAULT_ALLOW_SHARING = extract_bool('SPACE_DEFAULT_ALLOW_SHARING', True)
|
||||
SPACE_AI_ENABLED = extract_bool('SPACE_AI_ENABLED', True)
|
||||
SPACE_AI_CREDITS_MONTHLY = int(os.getenv('SPACE_AI_CREDITS_MONTHLY', 10000))
|
||||
|
||||
INTERNAL_IPS = extract_comma_list('INTERNAL_IPS', '127.0.0.1')
|
||||
|
||||
@@ -137,8 +139,6 @@ HCAPTCHA_SECRET = os.getenv('HCAPTCHA_SECRET', '')
|
||||
|
||||
FDC_API_KEY = os.getenv('FDC_API_KEY', 'DEMO_KEY')
|
||||
|
||||
AI_API_KEY = os.getenv('AI_API_KEY', '')
|
||||
AI_MODEL_NAME = os.getenv('AI_MODEL_NAME', 'gemini/gemini-2.0-flash')
|
||||
AI_RATELIMIT = os.getenv('AI_RATELIMIT', '60/hour')
|
||||
|
||||
SHARING_ABUSE = extract_bool('SHARING_ABUSE', False)
|
||||
@@ -221,10 +221,7 @@ try:
|
||||
'module': f'recipes.plugins.{d}',
|
||||
'base_path': os.path.join(BASE_DIR, 'recipes', 'plugins', d),
|
||||
'base_url': plugin_class.base_url,
|
||||
'bundle_name': plugin_class.bundle_name if hasattr(plugin_class, 'bundle_name') else '',
|
||||
'api_router_name': plugin_class.api_router_name if hasattr(plugin_class, 'api_router_name') else '',
|
||||
'nav_main': plugin_class.nav_main if hasattr(plugin_class, 'nav_main') else '',
|
||||
'nav_dropdown': plugin_class.nav_dropdown if hasattr(plugin_class, 'nav_dropdown') else '',
|
||||
}
|
||||
PLUGINS.append(plugin_config)
|
||||
print(f'PLUGIN {d} loaded')
|
||||
@@ -534,28 +531,6 @@ if REDIS_HOST:
|
||||
# Vue webpack settings
|
||||
VUE_DIR = os.path.join(BASE_DIR, 'vue')
|
||||
|
||||
WEBPACK_LOADER = {
|
||||
'DEFAULT': {
|
||||
'CACHE': not DEBUG,
|
||||
'BUNDLE_DIR_NAME': 'vue/', # must end with slash
|
||||
'STATS_FILE': os.path.join(VUE_DIR, 'webpack-stats.json'),
|
||||
'POLL_INTERVAL': 0.1,
|
||||
'TIMEOUT': None,
|
||||
'IGNORE': [r'.+\.hot-update.js', r'.+\.map'],
|
||||
},
|
||||
}
|
||||
|
||||
for p in PLUGINS:
|
||||
if p['bundle_name'] != '':
|
||||
WEBPACK_LOADER[p['bundle_name']] = {
|
||||
'CACHE': not DEBUG,
|
||||
'BUNDLE_DIR_NAME': 'vue/', # must end with slash
|
||||
'STATS_FILE': os.path.join(p["base_path"], 'vue', 'webpack-stats.json'),
|
||||
'POLL_INTERVAL': 0.1,
|
||||
'TIMEOUT': None,
|
||||
'IGNORE': [r'.+\.hot-update.js', r'.+\.map'],
|
||||
}
|
||||
|
||||
DJANGO_VITE = {
|
||||
"default": {
|
||||
"dev_mode": False,
|
||||
@@ -590,8 +565,6 @@ else:
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_L10N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
LANGUAGES = [
|
||||
@@ -619,8 +592,18 @@ LANGUAGES = [
|
||||
|
||||
AWS_ENABLED = True if os.getenv('S3_ACCESS_KEY', False) else False
|
||||
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
},
|
||||
# Serve static files with gzip
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
if os.getenv('S3_ACCESS_KEY', ''):
|
||||
DEFAULT_FILE_STORAGE = 'cookbook.helper.CustomStorageClass.CachedS3Boto3Storage'
|
||||
STORAGES['default']['BACKEND'] = 'cookbook.helper.CustomStorageClass.CachedS3Boto3Storage'
|
||||
|
||||
AWS_ACCESS_KEY_ID = os.getenv('S3_ACCESS_KEY', '')
|
||||
AWS_SECRET_ACCESS_KEY = os.getenv('S3_SECRET_ACCESS_KEY', '')
|
||||
@@ -635,14 +618,9 @@ if os.getenv('S3_ACCESS_KEY', ''):
|
||||
if os.getenv('S3_CUSTOM_DOMAIN', ''):
|
||||
AWS_S3_CUSTOM_DOMAIN = os.getenv('S3_CUSTOM_DOMAIN', '')
|
||||
|
||||
MEDIA_URL = os.getenv('MEDIA_URL', '/media/')
|
||||
MEDIA_ROOT = os.getenv('MEDIA_ROOT', os.path.join(BASE_DIR, "mediafiles"))
|
||||
else:
|
||||
MEDIA_URL = os.getenv('MEDIA_URL', '/media/')
|
||||
MEDIA_ROOT = os.getenv('MEDIA_ROOT', os.path.join(BASE_DIR, "mediafiles"))
|
||||
|
||||
# Serve static files with gzip
|
||||
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
||||
MEDIA_URL = os.getenv('MEDIA_URL', '/media/')
|
||||
MEDIA_ROOT = os.getenv('MEDIA_ROOT', os.path.join(BASE_DIR, "mediafiles"))
|
||||
|
||||
# settings for cross site origin (CORS)
|
||||
# all origins allowed to support bookmarklet
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
Django==4.2.22
|
||||
Django==5.2.6
|
||||
cryptography===45.0.5
|
||||
django-annoying==0.10.6
|
||||
django-cleanup==9.0.0
|
||||
django-crispy-forms==2.4
|
||||
crispy-bootstrap4==2025.6
|
||||
djangorestframework==3.15.2
|
||||
djangorestframework==3.16.1
|
||||
drf-spectacular==0.27.1
|
||||
drf-spectacular-sidecar==2025.7.1
|
||||
drf-spectacular-sidecar==2025.8.1
|
||||
drf-writable-nested==0.7.2
|
||||
django-oauth-toolkit==2.4.0
|
||||
django-debug-toolbar==4.3.0
|
||||
@@ -14,9 +14,9 @@ bleach==6.2.0
|
||||
gunicorn==23.0.0
|
||||
lxml==5.3.1
|
||||
Markdown==3.7
|
||||
Pillow==11.1.0
|
||||
Pillow==11.3.0
|
||||
psycopg2-binary==2.9.10
|
||||
python-dotenv==1.0.0
|
||||
python-dotenv==1.1.1
|
||||
requests==2.32.4
|
||||
six==1.17.0
|
||||
webdavclient3==3.14.6
|
||||
@@ -26,24 +26,24 @@ pyyaml==6.0.2
|
||||
uritemplate==4.1.1
|
||||
beautifulsoup4==4.12.3
|
||||
microdata==0.8.0
|
||||
mock==5.1.0
|
||||
mock==5.2.0
|
||||
Jinja2==3.1.6
|
||||
django-allauth[mfa,socialaccount]==65.9.0
|
||||
recipe-scrapers==15.6.0
|
||||
recipe-scrapers==15.8.0
|
||||
django-scopes==2.0.0
|
||||
django-treebeard==4.7.1
|
||||
django-cors-headers==4.6.0
|
||||
django-storages==1.14.2
|
||||
django-storages==1.14.6
|
||||
boto3==1.28.75
|
||||
django-prometheus==2.3.1
|
||||
django-prometheus==2.4.1
|
||||
django-hCaptcha==0.2.0
|
||||
python-ldap==3.4.4
|
||||
django-auth-ldap==4.6.0
|
||||
pyppeteer==2.0.0
|
||||
pytubefix==9.2.2
|
||||
aiohttp==3.12.14
|
||||
aiohttp==3.12.15
|
||||
inflection==0.5.1
|
||||
redis==5.2.1
|
||||
redis==6.2.0
|
||||
hiredis==3.2.1
|
||||
requests-oauthlib==2.0.0
|
||||
pyjwt==2.10.1
|
||||
|
||||
@@ -9,21 +9,21 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@vueform/multiselect": "^2.6.11",
|
||||
"@vueuse/core": "^13.1.0",
|
||||
"@vueuse/router": "^13.1.0",
|
||||
"luxon": "^3.6.1",
|
||||
"@vueuse/core": "^13.6.0",
|
||||
"@vueuse/router": "^13.6.0",
|
||||
"luxon": "^3.7.1",
|
||||
"mavon-editor": "^3.0.1",
|
||||
"pinia": "^3.0.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue-draggable-plus": "^0.6.0",
|
||||
"vue-i18n": "^11.1.10",
|
||||
"vue-i18n": "^11.1.11",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-simple-calendar": "7.1.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"vuetify": "^3.9.0"
|
||||
"vuetify": "^3.9.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
@@ -32,20 +32,21 @@
|
||||
"@types/node": "^24.0.8",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"esbuild-register": "^3.6.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"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",
|
||||
"vite": "7.1.5",
|
||||
"vite-plugin-pwa": "^1.0.3",
|
||||
"vite-plugin-vuetify": "^2.1.1",
|
||||
"vue-tsc": "^3.0.6",
|
||||
"workbox-background-sync": "^7.3.0",
|
||||
"workbox-build": "^7.3.0",
|
||||
"workbox-core": "^7.3.0",
|
||||
"workbox-expiration": "^7.3.0",
|
||||
"workbox-navigation-preload": "^7.3.0",
|
||||
"workbox-precaching": "^7.3.0",
|
||||
"workbox-routing": "^7.3.0",
|
||||
"workbox-strategies": "^7.3.0",
|
||||
"vite-plugin-vuetify": "^2.1.1",
|
||||
"vue-tsc": "^2.2.8"
|
||||
"workbox-window": "^7.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,20 +137,46 @@ 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} from "vue";
|
||||
import {nextTick, onMounted} from "vue";
|
||||
import {isSpaceAboveLimit} from "@/utils/logic_utils";
|
||||
import {useMediaQuery} from "@vueuse/core";
|
||||
import {useMediaQuery, useTitle} from "@vueuse/core";
|
||||
import HelpDialog from "@/components/dialogs/HelpDialog.vue";
|
||||
import {NAVIGATION_DRAWER} from "@/utils/navigation.ts";
|
||||
import {useNavigation} from "@/composables/useNavigation.ts";
|
||||
import {useRouter} from "vue-router";
|
||||
import {useI18n} from "vue-i18n";
|
||||
|
||||
const {lgAndUp} = useDisplay()
|
||||
const {getDjangoUrl} = useDjangoUrls()
|
||||
const {t} = useI18n()
|
||||
|
||||
const title = useTitle()
|
||||
const router = useRouter()
|
||||
|
||||
const isPrintMode = useMediaQuery('print')
|
||||
|
||||
onMounted(() => {
|
||||
useUserPreferenceStore()
|
||||
useUserPreferenceStore().init().then(() => {
|
||||
if (useUserPreferenceStore().activeSpace.spaceSetupCompleted != undefined && !useUserPreferenceStore().activeSpace.spaceSetupCompleted) {
|
||||
router.push({name: 'WelcomePage'})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* global title update handler, might be overridden by page specific handlers
|
||||
*/
|
||||
router.afterEach((to, from) => {
|
||||
if(to.name == 'StartPage' && useUserPreferenceStore().initCompleted && !useUserPreferenceStore().activeSpace.spaceSetupCompleted != undefined &&!useUserPreferenceStore().activeSpace.spaceSetupCompleted && useUserPreferenceStore().activeSpace.createdBy.id! == useUserPreferenceStore().userSettings.user.id!){
|
||||
router.push({name: 'WelcomePage'})
|
||||
}
|
||||
nextTick(() => {
|
||||
if (to.meta.title) {
|
||||
title.value = t(to.meta.title)
|
||||
} else {
|
||||
title.value = 'Tandoor'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
@@ -8,54 +8,53 @@ import vuetify from "@/vuetify";
|
||||
import mavonEditor from 'mavon-editor'
|
||||
import 'mavon-editor/dist/css/index.css'
|
||||
import 'vite/modulepreload-polyfill';
|
||||
import { createRulesPlugin } from 'vuetify/labs/rules'
|
||||
import {createRulesPlugin} from 'vuetify/labs/rules'
|
||||
|
||||
import {setupI18n} from "@/i18n";
|
||||
import MealPlanPage from "@/pages/MealPlanPage.vue";
|
||||
import {TANDOOR_PLUGINS, TandoorPlugin} from "@/types/Plugins.ts";
|
||||
|
||||
let routes = [
|
||||
{path: '/', component: () => import("@/pages/StartPage.vue"), name: 'StartPage'},
|
||||
{path: '/', component: () => import("@/pages/StartPage.vue"), name: 'StartPage' },
|
||||
{path: '/search', redirect: {name: 'StartPage'}},
|
||||
{path: '/test', component: () => import("@/pages/TestPage.vue"), name: 'view_test'},
|
||||
{path: '/help', component: () => import("@/pages/HelpPage.vue"), name: 'HelpPage'},
|
||||
{path: '/welcome', component: () => import("@/pages/WelcomePage.vue"), name: 'WelcomePage', meta: {title: 'Welcome'}},
|
||||
{path: '/help', component: () => import("@/pages/HelpPage.vue"), name: 'HelpPage', meta: {title: 'Help'}},
|
||||
{
|
||||
path: '/settings', component: () => import("@/pages/SettingsPage.vue"), name: 'SettingsPage', redirect: '/settings/account',
|
||||
children: [
|
||||
{path: 'account', component: () => import("@/components/settings/AccountSettings.vue"), name: 'AccountSettings'},
|
||||
{path: 'cosmetic', component: () => import("@/components/settings/CosmeticSettings.vue"), name: 'CosmeticSettings'},
|
||||
{path: 'shopping', component: () => import("@/components/settings/ShoppingSettings.vue"), name: 'ShoppingSettings'},
|
||||
{path: 'meal-plan', component: () => import("@/components/settings/MealPlanSettings.vue"), name: 'MealPlanSettings'},
|
||||
{path: 'search', component: () => import("@/components/settings/SearchSettings.vue"), name: 'SearchSettings'},
|
||||
{path: 'space', component: () => import("@/components/settings/SpaceSettings.vue"), name: 'SpaceSettings'},
|
||||
{path: 'space-members', component: () => import("@/components/settings/SpaceMemberSettings.vue"), name: 'SpaceMemberSettings'},
|
||||
{path: 'user-space', component: () => import("@/components/settings/UserSpaceSettings.vue"), name: 'UserSpaceSettings'},
|
||||
{path: 'open-data-import', component: () => import("@/components/settings/OpenDataImportSettings.vue"), name: 'OpenDataImportSettings'},
|
||||
{path: 'export', component: () => import("@/components/settings/ExportDataSettings.vue"), name: 'ExportDataSettings'},
|
||||
{path: 'api', component: () => import("@/components/settings/ApiSettings.vue"), name: 'ApiSettings'},
|
||||
]
|
||||
{path: 'account', component: () => import("@/components/settings/AccountSettings.vue"), name: 'AccountSettings', meta: {title: 'Settings'}},
|
||||
{path: 'cosmetic', component: () => import("@/components/settings/CosmeticSettings.vue"), name: 'CosmeticSettings', meta: {title: 'Settings'}},
|
||||
{path: 'shopping', component: () => import("@/components/settings/ShoppingSettings.vue"), name: 'ShoppingSettings', meta: {title: 'Settings'}},
|
||||
{path: 'meal-plan', component: () => import("@/components/settings/MealPlanSettings.vue"), name: 'MealPlanSettings', meta: {title: 'Settings'}},
|
||||
{path: 'search', component: () => import("@/components/settings/SearchSettings.vue"), name: 'SearchSettings', meta: {title: 'Settings'}},
|
||||
{path: 'space', component: () => import("@/components/settings/SpaceSettings.vue"), name: 'SpaceSettings', meta: {title: 'Settings'}},
|
||||
{path: 'open-data-import', component: () => import("@/components/settings/OpenDataImportSettings.vue"), name: 'OpenDataImportSettings', meta: {title: 'Settings'}},
|
||||
{path: 'export', component: () => import("@/components/settings/ExportDataSettings.vue"), name: 'ExportDataSettings', meta: {title: 'Settings'}},
|
||||
{path: 'api', component: () => import("@/components/settings/ApiSettings.vue"), name: 'ApiSettings', meta: {title: 'Settings'}},
|
||||
], meta: {title: 'Settings'}
|
||||
},
|
||||
//{path: '/settings/:page', component: SettingsPage, name: 'view_settings_page', props: true},
|
||||
{path: '/advanced-search', component: () => import("@/pages/SearchPage.vue"), name: 'SearchPage'},
|
||||
{path: '/shopping', component: () => import("@/pages/ShoppingListPage.vue"), name: 'ShoppingListPage'},
|
||||
{path: '/mealplan', component: MealPlanPage, name: 'MealPlanPage'},
|
||||
{path: '/books', component: () => import("@/pages/BooksPage.vue"), name: 'BooksPage'},
|
||||
{path: '/book/:bookId', component: () => import("@/pages/BookViewPage.vue"), name: 'BookViewPage', props: true},
|
||||
{path: '/recipe/import', component: () => import("@/pages/RecipeImportPage.vue"), name: 'RecipeImportPage'},
|
||||
{path: '/advanced-search', component: () => import("@/pages/SearchPage.vue"), name: 'SearchPage', meta: {title: 'Search'}},
|
||||
{path: '/shopping', component: () => import("@/pages/ShoppingListPage.vue"), name: 'ShoppingListPage', meta: {title: 'Shopping_list'}},
|
||||
{path: '/mealplan', component: MealPlanPage, name: 'MealPlanPage', meta: {title: 'Meal_Plan'}},
|
||||
{path: '/books', component: () => import("@/pages/BooksPage.vue"), name: 'BooksPage', meta: {title: 'Books'}},
|
||||
{path: '/book/:bookId', component: () => import("@/pages/BookViewPage.vue"), name: 'BookViewPage', props: true, meta: {title: 'Book'}},
|
||||
{path: '/recipe/import', component: () => import("@/pages/RecipeImportPage.vue"), name: 'RecipeImportPage', meta: {title: 'Import'}},
|
||||
|
||||
{path: '/recipe/:id', component: () => import("@/pages/RecipeViewPage.vue"), name: 'RecipeViewPage', props: true},
|
||||
{path: '/recipe/:id', component: () => import("@/pages/RecipeViewPage.vue"), name: 'RecipeViewPage', props: true, meta: {title: 'Recipe'}},
|
||||
{path: '/view/recipe/:id', redirect: {name: 'RecipeViewPage'}}, // old Tandoor v1 url pattern
|
||||
|
||||
{path: '/list/:model?', component: () => import("@/pages/ModelListPage.vue"), props: true, name: 'ModelListPage'},
|
||||
{path: '/edit/:model/:id?', component: () => import("@/pages/ModelEditPage.vue"), props: true, name: 'ModelEditPage'},
|
||||
{path: '/database', component: () => import("@/pages/DatabasePage.vue"), props: true, name: 'DatabasePage'},
|
||||
{path: '/database', component: () => import("@/pages/DatabasePage.vue"), props: true, name: 'DatabasePage', meta: {title: 'Database'}},
|
||||
|
||||
{path: '/ingredient-editor', component: () => import("@/pages/IngredientEditorPage.vue"), name: 'IngredientEditorPage'},
|
||||
{path: '/property-editor', component: () => import("@/pages/PropertyEditorPage.vue"), name: 'PropertyEditorPage'},
|
||||
{path: '/ingredient-editor', component: () => import("@/pages/IngredientEditorPage.vue"), name: 'IngredientEditorPage', meta: {title: 'Ingredient Editor'}},
|
||||
{path: '/property-editor', component: () => import("@/pages/PropertyEditorPage.vue"), name: 'PropertyEditorPage', meta: {title: 'Property_Editor'}},
|
||||
|
||||
{path: '/space-setup', component: () => import("@/pages/SpaceSetupPage.vue"), name: 'SpaceSetupPage'},
|
||||
|
||||
{path: '/:pathMatch(.*)*', component: () => import("@/pages/404Page.vue"), name: '404Page'},
|
||||
{path: '/:pathMatch(.*)*', component: () => import("@/pages/404Page.vue"), name: '404Page', meta: {title: 'NotFound'}},
|
||||
]
|
||||
|
||||
// load plugin routes into routing table
|
||||
@@ -63,8 +62,12 @@ TANDOOR_PLUGINS.forEach(plugin => {
|
||||
routes = routes.concat(plugin.routes)
|
||||
})
|
||||
|
||||
const basePath = localStorage.getItem("BASE_PATH")
|
||||
const pathname = basePath?.startsWith("http") ? new URL(basePath).pathname : undefined
|
||||
const base = pathname === "/" ? undefined : pathname
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
history: createWebHistory(base),
|
||||
routes,
|
||||
})
|
||||
|
||||
@@ -74,7 +77,7 @@ const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(vuetify)
|
||||
app.use(createRulesPlugin({ /* options */ }, vuetify.locale))
|
||||
app.use(createRulesPlugin({ /* options */}, vuetify.locale))
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
app.use(mavonEditor) // TODO only use on pages that need it
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 13 KiB |
114
vue3/src/components/dialogs/AutoPlanDialog.vue
Normal file
114
vue3/src/components/dialogs/AutoPlanDialog.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<v-dialog max-width="600px" :activator="props.activator" v-model="dialog">
|
||||
<v-card :loading="loading">
|
||||
<v-closable-card-title v-model="dialog" :title="$t('Auto_Planner')" icon="fa-solid fa-calendar-plus"></v-closable-card-title>
|
||||
|
||||
<v-card-text>
|
||||
|
||||
<v-form>
|
||||
<model-select model="MealType" v-model="autoMealPlan.mealTypeId" :object="false"></model-select>
|
||||
<model-select model="Keyword" v-model="autoMealPlan.keywordIds" mode="tags" :object="false"></model-select>
|
||||
|
||||
<v-number-input :label="$t('Servings')" v-model="autoMealPlan.servings"></v-number-input>
|
||||
|
||||
<v-date-input :label="$t('Date')"
|
||||
multiple="range"
|
||||
v-model="dateRangeValue"
|
||||
:first-day-of-week="useUserPreferenceStore().deviceSettings.mealplan_startingDayOfWeek"
|
||||
:show-week="useUserPreferenceStore().deviceSettings.mealplan_displayWeekNumbers"
|
||||
prepend-icon=""
|
||||
prepend-inner-icon="$calendar"
|
||||
></v-date-input>
|
||||
|
||||
<model-select model="User" v-model="autoMealPlan.shared" mode="tags"></model-select>
|
||||
<v-checkbox v-model="autoMealPlan.addshopping" :label="$t('AddToShopping')" hide-details></v-checkbox>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn @click="dialog = false">{{ $t('Cancel') }}</v-btn>
|
||||
<v-btn color="create" prepend-icon="fa-solid fa-person-running" @click="doAutoPlan()" :loading="loading">{{ $t('Create') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {useI18n} from "vue-i18n";
|
||||
import ModelSelect from "@/components/inputs/ModelSelect.vue";
|
||||
import {ApiApi, AutoMealPlan} from "@/openapi";
|
||||
import {onMounted, ref} from "vue";
|
||||
import VClosableCardTitle from "@/components/dialogs/VClosableCardTitle.vue";
|
||||
import {VDateInput} from 'vuetify/labs/VDateInput'
|
||||
import {DateTime} from "luxon";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
import {ErrorMessageType, PreparedMessage, useMessageStore} from "@/stores/MessageStore.ts";
|
||||
import {useMealPlanStore} from "@/stores/MealPlanStore.ts";
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const props = defineProps({
|
||||
activator: {type: String, default: 'parent'},
|
||||
})
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
const dialog = defineModel<boolean>({default: false})
|
||||
const loading = ref(false)
|
||||
|
||||
const dateRangeValue = ref([] as Date[])
|
||||
const autoMealPlan = ref({} as AutoMealPlan)
|
||||
|
||||
onMounted(() => {
|
||||
initializeRequest()
|
||||
})
|
||||
|
||||
/**
|
||||
* load default values for auto plan creation
|
||||
*/
|
||||
function initializeRequest() {
|
||||
autoMealPlan.value = {
|
||||
servings: 1,
|
||||
startDate: DateTime.now().toJSDate(),
|
||||
endDate: DateTime.now().plus({day: 7}).toJSDate(),
|
||||
shared: useUserPreferenceStore().userSettings.planShare,
|
||||
addshopping: useUserPreferenceStore().userSettings.mealplanAutoaddShopping,
|
||||
} as AutoMealPlan
|
||||
|
||||
dateRangeValue.value = []
|
||||
let currentDate = DateTime.fromJSDate(autoMealPlan.value.startDate).plus({day: 1}).toJSDate()
|
||||
while (currentDate <= autoMealPlan.value.endDate) {
|
||||
dateRangeValue.value.push(currentDate)
|
||||
currentDate = DateTime.fromJSDate(currentDate).plus({day: 1}).toJSDate()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* perform auto plan creation
|
||||
*/
|
||||
function doAutoPlan() {
|
||||
let api = new ApiApi()
|
||||
loading.value = true
|
||||
|
||||
autoMealPlan.value.startDate = dateRangeValue.value[0]
|
||||
autoMealPlan.value.endDate = dateRangeValue.value[dateRangeValue.value.length - 1]
|
||||
console.log('requesting auto plan from ', autoMealPlan.value.startDate, ' to ', autoMealPlan.value.endDate)
|
||||
|
||||
api.apiAutoPlanCreate({autoMealPlan: autoMealPlan.value}).then(r => {
|
||||
dialog.value = false
|
||||
useMealPlanStore().refreshLastUpdatedPeriod()
|
||||
initializeRequest()
|
||||
useMessageStore().addPreparedMessage(PreparedMessage.CREATE_SUCCESS)
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.CREATE_ERROR, err)
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
98
vue3/src/components/dialogs/BatchDeleteDialog.vue
Normal file
98
vue3/src/components/dialogs/BatchDeleteDialog.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<v-dialog max-width="600px" :activator="props.activator" v-model="dialog">
|
||||
<v-card :loading="loading">
|
||||
<v-closable-card-title
|
||||
:title="$t('delete_title', {type: $t(genericModel.model.localizationKey)})"
|
||||
:sub-title="genericModel.getLabel(props.source)"
|
||||
:icon="genericModel.model.icon"
|
||||
v-model="dialog"
|
||||
></v-closable-card-title>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text>
|
||||
{{ $t('BatchDeleteConfirm') }}
|
||||
|
||||
<v-list>
|
||||
<v-list-item border v-for="item in itemsToDelete">
|
||||
{{ genericModel.getLabel(item) }}
|
||||
<template #append>
|
||||
<v-icon icon="fa-solid fa-xmark" color="error" variant="tonal" v-if="failedItems.includes(item)"></v-icon>
|
||||
<v-icon icon="fa-solid fa-check" color="success" variant="tonal" v-else-if="updatedItems.includes(item)"></v-icon>
|
||||
<v-icon icon="fa-solid fa-circle-notch fa-spin" variant="tonal" color="info" v-else-if="loading"></v-icon>
|
||||
|
||||
<v-btn icon="fa-solid fa-up-right-from-square" :to="{name: 'IngredientEditorPage', query: {food_id: item.id}}"
|
||||
v-if="genericModel.model.name == 'Food' && failedItems.includes(item)" size="small"></v-btn>
|
||||
<v-btn icon="fa-solid fa-up-right-from-square" :to="{name: 'IngredientEditorPage', query: {unit_id: item.id}}"
|
||||
v-if="genericModel.model.name == 'Unit' && failedItems.includes(item)" size="small"></v-btn>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<p class="font-italic text-disabled">{{$t('BatchDeleteHelp')}}</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn :disabled="loading" @click="dialog = false">{{ $t('Cancel') }}</v-btn>
|
||||
<v-btn color="error" @click="deleteAll()" :loading="loading">{{ $t('Delete_All') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {onMounted, PropType, ref, watch} from "vue";
|
||||
import {EditorSupportedModels, EditorSupportedTypes, getGenericModelFromString} from "@/types/Models.ts";
|
||||
import VClosableCardTitle from "@/components/dialogs/VClosableCardTitle.vue";
|
||||
import {useI18n} from "vue-i18n";
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const props = defineProps({
|
||||
model: {type: String as PropType<EditorSupportedModels>, required: true},
|
||||
items: {type: Array as PropType<Array<EditorSupportedTypes>>, required: true},
|
||||
activator: {type: String, default: 'parent'},
|
||||
})
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
const dialog = defineModel<boolean>({default: false})
|
||||
const loading = ref(false)
|
||||
|
||||
const genericModel = getGenericModelFromString(props.model, t)
|
||||
|
||||
const itemsToDelete = ref<EditorSupportedTypes[]>([])
|
||||
const failedItems = ref<EditorSupportedTypes[]>([])
|
||||
const updatedItems = ref<EditorSupportedTypes[]>([])
|
||||
|
||||
watch(dialog, (newValue, oldValue) => {
|
||||
if(!oldValue && newValue){
|
||||
itemsToDelete.value = JSON.parse(JSON.stringify(props.items))
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* loop through the items and delete them
|
||||
*/
|
||||
function deleteAll() {
|
||||
let promises: Promise<any>[] = []
|
||||
loading.value = true
|
||||
|
||||
itemsToDelete.value.forEach(item => {
|
||||
promises.push(genericModel.destroy(item.id!).then((r: any) => {
|
||||
updatedItems.value.push(item)
|
||||
}).catch((err: any) => {
|
||||
failedItems.value.push(item)
|
||||
}))
|
||||
})
|
||||
|
||||
Promise.allSettled(promises).then(() => {
|
||||
loading.value = false
|
||||
emit('change')
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
186
vue3/src/components/dialogs/BatchEditFoodDialog.vue
Normal file
186
vue3/src/components/dialogs/BatchEditFoodDialog.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<v-dialog max-width="1200px" :activator="props.activator" v-model="dialog">
|
||||
<v-card :loading="loading">
|
||||
<v-closable-card-title
|
||||
:title="$t('BatchEdit')"
|
||||
:sub-title="$t('BatchEditUpdatingItemsCount', {type: $t('Foods'), count: updateItems.length})"
|
||||
:icon="TFood.icon"
|
||||
v-model="dialog"
|
||||
></v-closable-card-title>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text>
|
||||
|
||||
<v-form>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-card :title="$t('Miscellaneous')" prepend-icon="fa-solid fa-list" variant="flat">
|
||||
<v-card-text>
|
||||
<model-select model="SupermarketCategory" v-model="batchUpdateRequest.foodBatchUpdate.category" :object="false" allow-create mode="single">
|
||||
</model-select>
|
||||
|
||||
<v-select :items="boolUpdateOptions" :label="$t('Ignore_Shopping')" clearable v-model="batchUpdateRequest.foodBatchUpdate.ignoreShopping"></v-select>
|
||||
<v-select :items="boolUpdateOptions" :label="$t('OnHand')" clearable v-model="batchUpdateRequest.foodBatchUpdate.onHand"></v-select>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
<v-label :text="$t('Substitutes')"></v-label>
|
||||
<model-select model="Food" v-model="batchUpdateRequest.foodBatchUpdate.substituteAdd" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-add"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<model-select model="Food" v-model="batchUpdateRequest.foodBatchUpdate.substituteRemove" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-minus"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<model-select model="Food" v-model="batchUpdateRequest.foodBatchUpdate.substituteSet" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-equals"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<v-checkbox :label="$t('RemoveAllType', {type: $t('Substitutes')})" hide-details
|
||||
v-model="batchUpdateRequest.foodBatchUpdate.substituteRemoveAll"></v-checkbox>
|
||||
|
||||
<v-select :items="boolUpdateOptions" :label="$t('substitute_siblings')" clearable v-model="batchUpdateRequest.foodBatchUpdate.substituteChildren"></v-select>
|
||||
<v-select :items="boolUpdateOptions" :label="$t('substitute_children')" clearable v-model="batchUpdateRequest.foodBatchUpdate.substituteSiblings"></v-select>
|
||||
|
||||
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-card :title="$t('Hierarchy')" prepend-icon="fa-solid fa-folder-tree" variant="flat">
|
||||
<v-card-text>
|
||||
<model-select model="Food" :label="$t('Parent')" :object="false" allow-create clearable v-model="batchUpdateRequest.foodBatchUpdate.parentSet">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-equals"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
|
||||
<v-select :items="boolUpdateOptions" :label="$t('RemoveParent')" clearable v-model="batchUpdateRequest.foodBatchUpdate.parentRemove"></v-select>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-label :text="$t('InheritFields')"></v-label>
|
||||
<model-select model="FoodInheritField" v-model="batchUpdateRequest.foodBatchUpdate.inheritFieldsAdd" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-add"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<model-select model="FoodInheritField" v-model="batchUpdateRequest.foodBatchUpdate.inheritFieldsRemove" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-minus"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<model-select model="FoodInheritField" v-model="batchUpdateRequest.foodBatchUpdate.inheritFieldsSet" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-equals"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<v-checkbox :label="$t('RemoveAllType', {type: $t('InheritFields')})" hide-details
|
||||
v-model="batchUpdateRequest.foodBatchUpdate.inheritFieldsRemoveAll"></v-checkbox>
|
||||
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
<v-label :text="$t('ChildInheritFields')"></v-label>
|
||||
|
||||
<model-select model="FoodInheritField" v-model="batchUpdateRequest.foodBatchUpdate.childInheritFieldsAdd" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-add"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<model-select model="FoodInheritField" v-model="batchUpdateRequest.foodBatchUpdate.childInheritFieldsRemove" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-minus"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<model-select model="FoodInheritField" v-model="batchUpdateRequest.foodBatchUpdate.childInheritFieldsSet" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-equals"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<v-checkbox :label="$t('RemoveAllType', {type: $t('ChildInheritFields')})" hide-details
|
||||
v-model="batchUpdateRequest.foodBatchUpdate.childInheritFieldsRemoveAll"></v-checkbox>
|
||||
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
</v-row>
|
||||
|
||||
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn :disabled="loading" @click="dialog = false">{{ $t('Cancel') }}</v-btn>
|
||||
<v-btn color="warning" :loading="loading" @click="batchUpdateFoods()" :disabled="updateItems.length < 1">{{ $t('Update') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {onMounted, PropType, ref, watch} from "vue";
|
||||
import {EditorSupportedModels, EditorSupportedTypes, getGenericModelFromString, TFood, TKeyword, TRecipe} from "@/types/Models.ts";
|
||||
import VClosableCardTitle from "@/components/dialogs/VClosableCardTitle.vue";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {ApiApi, ApiFoodBatchUpdateUpdateRequest, ApiRecipeBatchUpdateUpdateRequest, Food, Recipe, RecipeOverview} from "@/openapi";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts";
|
||||
import ModelSelect from "@/components/inputs/ModelSelect.vue";
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const props = defineProps({
|
||||
items: {type: Array as PropType<Array<Food>>, required: true},
|
||||
activator: {type: String, default: 'parent'},
|
||||
})
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
const dialog = defineModel<boolean>({default: false})
|
||||
const loading = ref(false)
|
||||
|
||||
const updateItems = ref([] as Food[])
|
||||
const batchUpdateRequest = ref({foodBatchUpdate: {}} as ApiFoodBatchUpdateUpdateRequest)
|
||||
|
||||
const boolUpdateOptions = ref([
|
||||
{value: true, title: t('Yes')},
|
||||
{value: false, title: t('No')},
|
||||
])
|
||||
|
||||
/**
|
||||
* copy prop when dialog opens so that items remain when parent is updated after change is emitted
|
||||
*/
|
||||
watch(dialog, (newValue, oldValue) => {
|
||||
if (!oldValue && newValue && props.items != undefined) {
|
||||
batchUpdateRequest.value.foodBatchUpdate.foods = props.items.flatMap(r => r.id!)
|
||||
updateItems.value = JSON.parse(JSON.stringify(props.items))
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* perform batch request to update recipes
|
||||
*/
|
||||
function batchUpdateFoods() {
|
||||
let api = new ApiApi()
|
||||
loading.value = true
|
||||
|
||||
api.apiFoodBatchUpdateUpdate(batchUpdateRequest.value).then(r => {
|
||||
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
|
||||
}).finally(() => {
|
||||
emit('change')
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
166
vue3/src/components/dialogs/BatchEditRecipeDialog.vue
Normal file
166
vue3/src/components/dialogs/BatchEditRecipeDialog.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<v-dialog max-width="1200px" :activator="props.activator" v-model="dialog">
|
||||
<v-card :loading="loading">
|
||||
<v-closable-card-title
|
||||
:title="$t('BatchEdit')"
|
||||
:sub-title="$t('BatchEditUpdatingItemsCount', {type: $t('Recipes'), count: updateItems.length})"
|
||||
:icon="TRecipe.icon"
|
||||
v-model="dialog"
|
||||
></v-closable-card-title>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text>
|
||||
|
||||
<v-form>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-card :title="$t('Keywords')" :prepend-icon="TKeyword.icon" variant="plain">
|
||||
<v-card-text>
|
||||
<model-select model="Keyword" v-model="batchUpdateRequest.recipeBatchUpdate.keywordsAdd" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-add"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<model-select model="Keyword" v-model="batchUpdateRequest.recipeBatchUpdate.keywordsRemove" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-minus"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<model-select model="Keyword" v-model="batchUpdateRequest.recipeBatchUpdate.keywordsSet" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-equals"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<v-checkbox :label="$t('RemoveAllType', {type: $t('Keywords')})" hide-details v-model="batchUpdateRequest.recipeBatchUpdate.keywordsRemoveAll"></v-checkbox>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card :title="$t('Private_Recipe')" :subtitle="$t('Private_Recipe_Help')" prepend-icon="fa-solid fa-eye-slash" variant="plain">
|
||||
<v-card-text>
|
||||
|
||||
<v-select :items="boolUpdateOptions" :label="$t('Private_Recipe')" clearable v-model="batchUpdateRequest.recipeBatchUpdate._private"></v-select>
|
||||
|
||||
<model-select model="User" v-model="batchUpdateRequest.recipeBatchUpdate.sharedAdd" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-add"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<model-select model="User" v-model="batchUpdateRequest.recipeBatchUpdate.sharedRemove" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-minus"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<model-select model="User" v-model="batchUpdateRequest.recipeBatchUpdate.sharedSet" :object="false" allow-create mode="tags">
|
||||
<template #prepend>
|
||||
<v-icon icon="fa-solid fa-equals"></v-icon>
|
||||
</template>
|
||||
</model-select>
|
||||
<v-checkbox :label="$t('RemoveAllType', {type: $t('Users')})" hide-details v-model="batchUpdateRequest.recipeBatchUpdate.sharedRemoveAll"></v-checkbox>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6">
|
||||
<v-card :title="$t('Miscellaneous')" prepend-icon="fa-solid fa-list" variant="plain">
|
||||
<v-card-text>
|
||||
<v-number-input :label="$t('WorkingTime')" v-model="batchUpdateRequest.recipeBatchUpdate.workingTime" :step="5">
|
||||
|
||||
</v-number-input>
|
||||
<v-number-input :label="$t('WaitingTime')" v-model="batchUpdateRequest.recipeBatchUpdate.waitingTime" :step="5">
|
||||
|
||||
</v-number-input>
|
||||
<v-number-input :label="$t('Serving')" v-model="batchUpdateRequest.recipeBatchUpdate.servings">
|
||||
|
||||
</v-number-input>
|
||||
<v-text-field :label="$t('ServingsText')" v-model="batchUpdateRequest.recipeBatchUpdate.servingsText" @update:model-value="updateServings = true">
|
||||
<template #append>
|
||||
<v-checkbox v-model="updateServings" hide-details></v-checkbox>
|
||||
</template>
|
||||
</v-text-field>
|
||||
<v-select :items="boolUpdateOptions" :label="$t('show_ingredient_overview')" clearable v-model="batchUpdateRequest.recipeBatchUpdate.showIngredientOverview"></v-select>
|
||||
<v-checkbox hide-details :label="$t('DeleteSomething', {item: $t('Description')})" v-model="batchUpdateRequest.recipeBatchUpdate.clearDescription"></v-checkbox>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn :disabled="loading" @click="dialog = false">{{ $t('Cancel') }}</v-btn>
|
||||
<v-btn color="warning" :loading="loading" @click="batchUpdateRecipes()" :disabled="updateItems.length < 1">{{ $t('Update') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {onMounted, PropType, ref, watch} from "vue";
|
||||
import {EditorSupportedModels, EditorSupportedTypes, getGenericModelFromString, TKeyword, TRecipe} from "@/types/Models.ts";
|
||||
import VClosableCardTitle from "@/components/dialogs/VClosableCardTitle.vue";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {ApiApi, ApiRecipeBatchUpdateUpdateRequest, Recipe, RecipeOverview} from "@/openapi";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts";
|
||||
import ModelSelect from "@/components/inputs/ModelSelect.vue";
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const props = defineProps({
|
||||
items: {type: Array as PropType<Array<RecipeOverview>>, required: true},
|
||||
activator: {type: String, default: 'parent'},
|
||||
})
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
const dialog = defineModel<boolean>({default: false})
|
||||
const loading = ref(false)
|
||||
|
||||
const updateItems = ref([] as RecipeOverview[])
|
||||
const batchUpdateRequest = ref({recipeBatchUpdate: {servingsText: ''}} as ApiRecipeBatchUpdateUpdateRequest)
|
||||
|
||||
const updateServings = ref(false)
|
||||
|
||||
const boolUpdateOptions = ref([
|
||||
{value: true, title: t('Yes')},
|
||||
{value: false, title: t('No')},
|
||||
])
|
||||
|
||||
/**
|
||||
* copy prop when dialog opens so that items remain when parent is updated after change is emitted
|
||||
*/
|
||||
watch(dialog, (newValue, oldValue) => {
|
||||
if (!oldValue && newValue && props.items != undefined) {
|
||||
batchUpdateRequest.value.recipeBatchUpdate.recipes = props.items.flatMap(r => r.id!)
|
||||
updateItems.value = JSON.parse(JSON.stringify(props.items))
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* perform batch request to update recipes
|
||||
*/
|
||||
function batchUpdateRecipes() {
|
||||
let api = new ApiApi()
|
||||
loading.value = true
|
||||
|
||||
// prevent accidentally clearing the field with extra checkbox
|
||||
if (!updateServings.value) {
|
||||
batchUpdateRequest.value.recipeBatchUpdate.servingsText = undefined
|
||||
}
|
||||
|
||||
api.apiRecipeBatchUpdateUpdate(batchUpdateRequest.value).then(r => {
|
||||
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
|
||||
}).finally(() => {
|
||||
emit('change')
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -3,26 +3,38 @@
|
||||
<v-card :loading="loading">
|
||||
<v-closable-card-title
|
||||
:title="$t('merge_title', {type: $t(genericModel.model.localizationKey)})"
|
||||
:sub-title="genericModel.getLabel(props.source)"
|
||||
:sub-title="sourceNames"
|
||||
:icon="genericModel.model.icon"
|
||||
v-model="dialog"
|
||||
></v-closable-card-title>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text>
|
||||
{{ $t('merge_selection', {source: genericModel.getLabel(props.source), type: $t(genericModel.model.localizationKey)}) }}
|
||||
{{ $t('merge_selection', {source: sourceNames, type: $t(genericModel.model.localizationKey)}) }}
|
||||
<model-select :model="props.model" v-model="target" allow-create></model-select>
|
||||
|
||||
<v-row>
|
||||
<v-col class="text-center">
|
||||
<v-card color="warning" variant="tonal">
|
||||
<v-card-title>{{ genericModel.getLabel(props.source) }}</v-card-title>
|
||||
</v-card>
|
||||
<v-icon icon="fa-solid fa-arrow-down" class="mt-4 mb-4"></v-icon>
|
||||
<v-card color="success" variant="tonal">
|
||||
<v-card-title v-if="!target">?</v-card-title>
|
||||
<v-card-title v-else>{{ genericModel.getLabel(target) }}</v-card-title>
|
||||
</v-card>
|
||||
<v-col>
|
||||
<v-list>
|
||||
<v-list-item border v-for="item in sourceItems">
|
||||
{{ genericModel.getLabel(item) }}
|
||||
|
||||
<template #append>
|
||||
<v-icon icon="fa-solid fa-xmark" color="error" variant="tonal" v-if="failedItems.includes(item)"></v-icon>
|
||||
<v-icon icon="fa-solid fa-check" color="success" variant="tonal" v-else-if="updatedItems.includes(item)"></v-icon>
|
||||
<v-icon icon="fa-solid fa-circle-notch fa-spin" variant="tonal" color="info" v-else-if="loading"></v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="text-center">
|
||||
<v-icon icon="fa-solid fa-arrow-down" class="mt-4 mb-4"></v-icon>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="text-center" border>
|
||||
<span v-if="!target">?</span>
|
||||
<span v-else>{{ genericModel.getLabel(target) }}</span>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -30,7 +42,7 @@
|
||||
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-btn :disabled="loading">{{ $t('Cancel') }}</v-btn>
|
||||
<v-btn :disabled="loading" @click="dialog = false">{{ $t('Cancel') }}</v-btn>
|
||||
<v-btn color="warning" @click="mergeModel()" :loading="loading" :disabled="!target">{{ $t('Merge') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
@@ -40,7 +52,7 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import ModelSelect from "@/components/inputs/ModelSelect.vue";
|
||||
import {PropType, ref} from "vue";
|
||||
import {computed, PropType, ref, watch} from "vue";
|
||||
import {EditorSupportedModels, EditorSupportedTypes, getGenericModelFromString} from "@/types/Models";
|
||||
import {ErrorMessageType, PreparedMessage, useMessageStore} from "@/stores/MessageStore";
|
||||
import {useI18n} from "vue-i18n";
|
||||
@@ -51,7 +63,7 @@ const emit = defineEmits(['change'])
|
||||
|
||||
const props = defineProps({
|
||||
model: {type: String as PropType<EditorSupportedModels>, required: true},
|
||||
source: {type: {} as PropType<EditorSupportedTypes>, required: true},
|
||||
source: {type: Array as PropType<Array<EditorSupportedTypes>>, required: true},
|
||||
activator: {type: String, default: 'parent'},
|
||||
})
|
||||
|
||||
@@ -64,41 +76,62 @@ const automate = ref(false)
|
||||
const genericModel = getGenericModelFromString(props.model, t)
|
||||
const target = ref<null | EditorSupportedTypes>(null)
|
||||
|
||||
const sourceItems = ref<EditorSupportedTypes[]>([])
|
||||
const failedItems = ref<EditorSupportedTypes[]>([])
|
||||
const updatedItems = ref<EditorSupportedTypes[]>([])
|
||||
|
||||
watch(dialog, (newValue, oldValue) => {
|
||||
if (!oldValue && newValue) {
|
||||
sourceItems.value = JSON.parse(JSON.stringify(props.source))
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* generate comma seperated list of item names that act as the source
|
||||
*/
|
||||
const sourceNames = computed(() => {
|
||||
if (sourceItems.value) {
|
||||
return sourceItems.value.map(i => genericModel.getLabel(i)).join(', ')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
/**
|
||||
* merge source into selected target
|
||||
*/
|
||||
function mergeModel() {
|
||||
let api = new ApiApi()
|
||||
let promises: Promise<any>[] = []
|
||||
|
||||
if (target.value != null) {
|
||||
loading.value = true
|
||||
|
||||
genericModel.merge(props.source, target.value).then(r => {
|
||||
useMessageStore().addPreparedMessage(PreparedMessage.UPDATE_SUCCESS)
|
||||
emit('change', target.value)
|
||||
sourceItems.value.forEach(sourceItem => {
|
||||
promises.push(genericModel.merge(sourceItem, target.value).then(r => {
|
||||
|
||||
if (automate.value && target.value != null && Object.hasOwn(props.source, 'name') && Object.hasOwn(target.value, 'name')) {
|
||||
let automation = {
|
||||
name: `${t('Merge') } ${props.source.name} -> ${target.value.name}`.substring(0,128),
|
||||
param1: props.source.name,
|
||||
param2: target.value.name,
|
||||
type: genericModel.model.mergeAutomation
|
||||
} as Automation
|
||||
api.apiAutomationCreate({automation: automation}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
dialog.value = false
|
||||
})
|
||||
}
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
|
||||
}).finally(() => {
|
||||
if (!automate.value) {
|
||||
loading.value = false
|
||||
dialog.value = false
|
||||
}
|
||||
updatedItems.value.push(sourceItem)
|
||||
|
||||
if (automate.value && target.value != null && Object.hasOwn(sourceItem, 'name') && Object.hasOwn(sourceItem, 'name')) {
|
||||
let automation = {
|
||||
name: `${t('Merge')} ${sourceItem.name} -> ${target.value.name}`.substring(0, 128),
|
||||
param1: sourceItem.name,
|
||||
param2: target.value.name,
|
||||
type: genericModel.model.mergeAutomation
|
||||
} as Automation
|
||||
promises.push(api.apiAutomationCreate({automation: automation}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
|
||||
}))
|
||||
}
|
||||
}).catch(err => {
|
||||
updatedItems.value.push(sourceItem)
|
||||
}))
|
||||
})
|
||||
|
||||
Promise.allSettled(promises).then(() => {
|
||||
loading.value = false
|
||||
emit('change')
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
<template>
|
||||
<v-card class="mt-1 h-100">
|
||||
<iframe width="100%" height="700px" :src="externalUrl" v-if="isPdf"></iframe>
|
||||
|
||||
<v-img :src="externalUrl" v-if="isImage"></v-img>
|
||||
</v-card>
|
||||
<v-expansion-panels v-model="panelState">
|
||||
<v-expansion-panel value="show">
|
||||
<v-expansion-panel-title>{{ $t('ExternalRecipe') }}</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<v-card class="mt-1 h-100">
|
||||
<iframe width="100%" height="700px" :src="externalUrl" v-if="isPdf"></iframe>
|
||||
<v-img :src="externalUrl" v-if="isImage"></v-img>
|
||||
</v-card>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, PropType} from "vue";
|
||||
import {computed, onMounted, PropType, ref} from "vue";
|
||||
import {Recipe} from "@/openapi";
|
||||
import {useDjangoUrls} from "@/composables/useDjangoUrls";
|
||||
import {useUrlSearchParams} from "@vueuse/core";
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
recipe: {type: {} as PropType<Recipe>, required: true}
|
||||
})
|
||||
@@ -20,6 +26,15 @@ const props = defineProps({
|
||||
const params = useUrlSearchParams('history')
|
||||
const {getDjangoUrl} = useDjangoUrls()
|
||||
|
||||
const panelState = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
// open panel by default if recipe has not been converted to internal yet or if it does not have any steps
|
||||
if (!props.recipe.internal || props.recipe.steps.length == 0) {
|
||||
panelState.value = 'show'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* determines if the file is a PDF based on the path
|
||||
*/
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<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('AI')" @click="window = 'ai'" prepend-icon="$ai"></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>
|
||||
@@ -45,7 +46,7 @@
|
||||
<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 class="mt-3" border="start" variant="tonal" color="success" v-if="(!useUserPreferenceStore().serverSettings.hosted && !useUserPreferenceStore().activeSpace.demo)">
|
||||
<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.
|
||||
@@ -105,6 +106,35 @@
|
||||
|
||||
<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="ai">
|
||||
<p class="mt-3">Tandoor has several functions that allow you to use AI to automatically perform certain tasks like importing recipes from a PDFs or images.
|
||||
</p>
|
||||
|
||||
<p class="mt-3" v-if="useUserPreferenceStore().serverSettings.hosted">
|
||||
To use AI you must first configure an AI Provider. This can also be done globally for all spaces by the person operating your Tandoor Server.
|
||||
</p>
|
||||
<p class="mt-3" v-if="!useUserPreferenceStore().serverSettings.hosted">
|
||||
Some AI Providers are available globally for every space to use. You can also configure additional AI Providers for your space only.
|
||||
</p>
|
||||
|
||||
<p class="mt-3" v-if="useUserPreferenceStore().serverSettings.hosted">
|
||||
To prevent accidental AI cost you can review your AI usage using the AI Log. The Server Administrator can also set AI usage limits for your space (either monthly or using a balance).
|
||||
</p>
|
||||
<p class="mt-3" v-if="!useUserPreferenceStore().serverSettings.hosted">
|
||||
Depending on your subscription you will have different AI Credits available for your space every month. Additionally you might have a Credit balance
|
||||
that will be used once your monthly limit is reached.
|
||||
</p>
|
||||
|
||||
<v-btn color="primary" variant="tonal" prepend-icon="$ai" class="me-2" :to="{name: 'ModelListPage', params: {model: 'AiProvider'}}">
|
||||
{{ $t('AiProvider') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" variant="tonal" prepend-icon="$ai" class="me-2" :to="{name: 'ModelListPage', params: {model: 'AiLog'}}">
|
||||
{{ $t('AiLog') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" variant="tonal" prepend-icon="$ai" class="me-2" :to="{name: 'SpaceSettings'}">{{ $t('SpaceSettings') }}</v-btn>
|
||||
<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.
|
||||
@@ -337,6 +367,7 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {ref} from "vue";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
|
||||
const drawer = defineModel()
|
||||
const window = ref('start')
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
<template>
|
||||
<!-- <v-row justify="space-between">-->
|
||||
<!-- <v-col>-->
|
||||
<!-- <h2><i class="fas fa-calendar-week fa-fw"></i> Meal Plans</h2>-->
|
||||
<!-- </v-col>-->
|
||||
<!-- </v-row>-->
|
||||
<!-- <v-row justify="space-between">-->
|
||||
<!-- <v-col>-->
|
||||
<!-- <h2><i class="fas fa-calendar-week fa-fw"></i> Meal Plans</h2>-->
|
||||
<!-- </v-col>-->
|
||||
<!-- </v-row>-->
|
||||
|
||||
<v-row class="mt-0" v-if="mealPlanWindows.length > 0">
|
||||
<v-col>
|
||||
<v-window show-arrows>
|
||||
<v-window-item v-for="w in mealPlanWindows" class="pt-1 pb-1">
|
||||
<v-window v-model="currentWindowIndex">
|
||||
<v-window-item v-for="(w, i) in mealPlanWindows" :value="i" class="pt-1 pb-1">
|
||||
<v-row>
|
||||
<v-col v-for="mealPlanGridItem in w">
|
||||
<v-list density="compact" class="pt-0 pb-0">
|
||||
<v-list-item>
|
||||
|
||||
<div class="d-flex justify-space-between">
|
||||
<div class="align-self-center">
|
||||
<v-list-item class="text-center">
|
||||
<div class="d-flex ">
|
||||
<div class="flex-col align-self-start">
|
||||
<v-btn @click="currentWindowIndex--" v-if="currentWindowIndex != 0" icon="fa-solid fa-chevron-left" size="small"></v-btn>
|
||||
</div>
|
||||
<div class="flex-col flex-grow-1 mt-auto mb-auto">
|
||||
{{ mealPlanGridItem.date_label }}
|
||||
</div>
|
||||
<div class="align-self-center">
|
||||
<v-btn variant="flat" icon="">
|
||||
<i class="fas fa-plus"></i>
|
||||
<model-edit-dialog model="MealPlan" :item-defaults="{fromDate: mealPlanGridItem.date.toJSDate()}" :close-after-create="false" :close-after-save="false"></model-edit-dialog>
|
||||
</v-btn>
|
||||
<div class="flex-col align-self-end">
|
||||
<v-btn @click="currentWindowIndex++" v-if="currentWindowIndex + 1 < mealPlanWindows.length" icon="fa-solid fa-chevron-right"
|
||||
size="small"></v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</v-list-item>
|
||||
<v-divider v-if="mealPlanGridItem.plan_entries.length > 0"></v-divider>
|
||||
<v-list-item v-for="p in mealPlanGridItem.plan_entries" :key="p.id" @click="clickMealPlan(p)" link>
|
||||
@@ -47,7 +46,7 @@
|
||||
<v-menu activator="parent">
|
||||
<v-list>
|
||||
<v-list-item prepend-icon="$edit" link>
|
||||
{{$t('Edit')}}
|
||||
{{ $t('Edit') }}
|
||||
<model-edit-dialog model="MealPlan" :item="p"></model-edit-dialog>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
@@ -55,7 +54,11 @@
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="text-center cursor-pointer" variant="tonal">
|
||||
<model-edit-dialog model="MealPlan" :item-defaults="{fromDate: mealPlanGridItem.date.toJSDate()}" :close-after-create="false"
|
||||
:close-after-save="false"></model-edit-dialog>
|
||||
<v-icon icon="$create" size="small"></v-icon>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -79,7 +82,9 @@ import {useRouter} from "vue-router";
|
||||
|
||||
const router = useRouter()
|
||||
const {name} = useDisplay()
|
||||
|
||||
const loading = ref(false)
|
||||
const currentWindowIndex = ref(0)
|
||||
|
||||
let numberOfCols = computed(() => {
|
||||
return homePageCols(name.value)
|
||||
@@ -100,7 +105,12 @@ const meal_plan_grid = computed(() => {
|
||||
grid.push({
|
||||
date: grid_day_date,
|
||||
create_default_date: grid_day_date.toISODate(), // improve meal plan edit modal to do formatting itself and accept dates
|
||||
date_label: grid_day_date.toLocaleString(DateTime.DATE_MED),
|
||||
date_label: grid_day_date.toLocaleString({
|
||||
weekday: 'short',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: '2-digit',
|
||||
}),
|
||||
plan_entries: useMealPlanStore().planList.filter((m: MealPlan) => ((DateTime.fromJSDate(m.fromDate).startOf('day') <= grid_day_date.startOf('day')) && (DateTime.fromJSDate((m.toDate != undefined) ? m.toDate : m.fromDate).startOf('day') >= grid_day_date.startOf('day')))),
|
||||
} as MealPlanGridItem)
|
||||
}
|
||||
@@ -134,9 +144,9 @@ onMounted(() => {
|
||||
})
|
||||
})
|
||||
|
||||
function clickMealPlan(plan: MealPlan){
|
||||
if(plan.recipe){
|
||||
router.push( {name: 'RecipeViewPage', params: {id: plan.recipe.id}})
|
||||
function clickMealPlan(plan: MealPlan) {
|
||||
if (plan.recipe) {
|
||||
router.push({name: 'RecipeViewPage', params: {id: plan.recipe.id}})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
|
||||
<v-row v-if=" props.importLog.importedRecipes != undefined && props.importLog.totalRecipes != undefined">
|
||||
<v-row v-if="props.importLog.importedRecipes != undefined && props.importLog.totalRecipes != undefined">
|
||||
<v-col>
|
||||
<v-progress-linear :model-value="(props.importLog.importedRecipes/props.importLog.totalRecipes)*100" height="24" color="primary">
|
||||
{{ props.importLog.importedRecipes }} / {{ props.importLog.totalRecipes }}
|
||||
@@ -8,9 +8,9 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-row v-if="props.importLog.importedRecipes != undefined && props.importLog.totalRecipes != undefined">
|
||||
<v-col>
|
||||
<v-textarea :model-value="importLog.msg"></v-textarea>
|
||||
<v-textarea :model-value="importLog.msg" max-rows="25" :loading="importLog.running" auto-grow></v-textarea>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
|
||||
@@ -36,11 +36,16 @@
|
||||
<td style="width: 1%; text-wrap: nowrap" class="pa-0 d-print-none" v-if="showCheckbox">
|
||||
<v-checkbox-btn v-model="i.checked" color="success" v-if="!i.isHeader"></v-checkbox-btn>
|
||||
</td>
|
||||
<td style="width: 1%; text-wrap: nowrap" class="pr-1"
|
||||
v-html="calculateFoodAmount(i.amount, props.ingredientFactor, useUserPreferenceStore().userSettings.useFractions)" v-if="!i.noAmount"></td>
|
||||
<td style="width: 1%; text-wrap: nowrap" class="pr-1" v-if="i.noAmount"></td>
|
||||
<!-- display calculated food amount or empty cell -->
|
||||
<td style="width: 1%; text-wrap: nowrap"
|
||||
class="pr-1"
|
||||
v-html="calculateFoodAmount(i.amount, props.ingredientFactor, useUserPreferenceStore().userSettings.useFractions)"
|
||||
v-if="!i.noAmount && i.amount != 0">
|
||||
</td>
|
||||
<td style="width: 1%; text-wrap: nowrap" class="pr-1" v-else></td>
|
||||
|
||||
<td style="width: 1%; text-wrap: nowrap" class="pr-1">
|
||||
<template v-if="i.unit && !i.noAmount"> {{ ingredientToUnitString(i, ingredientFactor) }}</template>
|
||||
<template v-if="i.unit && !i.noAmount && i.amount != 0"> {{ ingredientToUnitString(i, ingredientFactor) }}</template>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="i.food">
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
<div v-if="props.keywords">
|
||||
<slot name="prepend"></slot>
|
||||
|
||||
<v-chip class="me-1 mb-1" :label="props.label" :color="props.color" :size="props.size" :variant="props.variant" v-for="k in keywords"> {{ k.label }}</v-chip>
|
||||
<v-chip class="me-1 mb-1" :label="props.label" :color="props.color" :size="props.size" :variant="props.variant" v-for="k in keywords"
|
||||
:to="useUserPreferenceStore().isAuthenticated ? {name: 'SearchPage', query: {keywords: k.id}} : undefined"> {{ k.label }}
|
||||
</v-chip>
|
||||
|
||||
<slot name="append"></slot>
|
||||
</div>
|
||||
@@ -13,6 +15,7 @@
|
||||
|
||||
import {Keyword, KeywordLabel} from "@/openapi";
|
||||
import {computed, PropType} from "vue";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
|
||||
const props = defineProps({
|
||||
keywords: Array as PropType<Array<Keyword> | Array<KeywordLabel> | undefined>,
|
||||
@@ -21,11 +24,11 @@ const props = defineProps({
|
||||
variant: {type: String as PropType<NonNullable<"tonal" | "flat" | "text" | "elevated" | "outlined" | "plain"> | undefined>, default: 'tonal'},
|
||||
label: {type: Boolean, default: true},
|
||||
// maximum number of keywords, 0 for all
|
||||
maxKeywords : {type: Number, default: 0},
|
||||
maxKeywords: {type: Number, default: 0},
|
||||
})
|
||||
|
||||
const keywords = computed(() => {
|
||||
if (props.maxKeywords > 0){
|
||||
if (props.maxKeywords > 0) {
|
||||
return props.keywords?.slice(0, props.maxKeywords)
|
||||
} else {
|
||||
return props.keywords
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<recipe-image :height="itemHeight" :width="itemHeight" :recipe="mealPlan.recipe"></recipe-image>
|
||||
</div>
|
||||
<div class="flex-column flex-grow-0 pa-1">
|
||||
<span class="font-light" :class="{'two-line-text': detailedItems,'one-line-text': !detailedItems,}">
|
||||
<span class="font-light" :class="{'three-line-text': detailedItems,'one-line-text': !detailedItems,}">
|
||||
<i class="fas fa-shopping-cart fa-xs float-left" v-if="mealPlan.shopping"/>
|
||||
{{ itemTitle }}
|
||||
</span>
|
||||
@@ -82,4 +82,13 @@ const itemTitle = computed(() => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.three-line-text {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -98,9 +98,9 @@ const planItems = computed(() => {
|
||||
*/
|
||||
const calendarItemHeight = computed(() => {
|
||||
if (lgAndUp.value && useUserPreferenceStore().deviceSettings.mealplan_displayPeriod == 'week') {
|
||||
return '2.6rem'
|
||||
return '3.5rem'
|
||||
} else {
|
||||
return '1.3rem'
|
||||
return '1.6rem'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -131,9 +131,9 @@ function refreshVisiblePeriod(startDateUnknown: boolean) {
|
||||
|
||||
// load backwards to as on initial
|
||||
if (startDateUnknown) {
|
||||
useMealPlanStore().refreshFromAPI(DateTime.fromJSDate(calendarDate.value).minus({days: days}).toJSDate(), DateTime.now().plus({days: days}).toJSDate())
|
||||
useMealPlanStore().refreshFromAPI(DateTime.fromJSDate(calendarDate.value).minus({days: days}).toJSDate(), DateTime.fromJSDate(calendarDate.value).plus({days: days}).toJSDate())
|
||||
} else {
|
||||
useMealPlanStore().refreshFromAPI(calendarDate.value, DateTime.now().plus({days: days}).toJSDate())
|
||||
useMealPlanStore().refreshFromAPI(calendarDate.value, DateTime.fromJSDate(calendarDate.value).plus({days: days}).toJSDate())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,17 @@
|
||||
|
||||
<template v-if="route.name == 'MealPlanPage'">
|
||||
<v-divider></v-divider>
|
||||
<v-list-item prepend-icon="fa-solid fa-calendar-plus" link>
|
||||
{{$t('Auto_Planner')}}
|
||||
<auto-plan-dialog></auto-plan-dialog>
|
||||
</v-list-item>
|
||||
<v-list-subheader>{{$t('Settings')}}</v-list-subheader>
|
||||
<v-list-item>
|
||||
<meal-plan-device-settings></meal-plan-device-settings>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -27,6 +32,7 @@ import {useRoute} from "vue-router";
|
||||
import {getListModels} from "@/types/Models";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
|
||||
import MealPlanDeviceSettings from "@/components/settings/MealPlanDeviceSettings.vue";
|
||||
import AutoPlanDialog from "@/components/dialogs/AutoPlanDialog.vue";
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
|
||||
27
vue3/src/components/display/PrivateRecipeBadge.vue
Normal file
27
vue3/src/components/display/PrivateRecipeBadge.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<i class="fa-solid fa-lock"></i>
|
||||
<span v-if="props.showText" class="ms-1 me-1">{{ $t('Private_Recipe') }}</span>
|
||||
<v-chip class="me-1 mb-1" :color="props.color" :size="props.size" :variant="props.variant" v-for="u in users" :key="u.id" prepend-icon="fa-solid fa-share-nodes"> {{ u.displayName }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {User} from "@/openapi";
|
||||
import {PropType} from "vue";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
|
||||
const props = defineProps({
|
||||
showText: {type: Boolean, default: true},
|
||||
users: {type: [] as PropType<Array<User>>, required: false},
|
||||
|
||||
size: {type: String, default: 'x-small'},
|
||||
color: {type: String, default: ''},
|
||||
variant: {type: String as PropType<NonNullable<"tonal" | "flat" | "text" | "elevated" | "outlined" | "plain"> | undefined>, default: 'tonal'},
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-card class="mt-2">
|
||||
<v-card class="mt-2" v-if="hasFoodProperties || hasRecipeProperties">
|
||||
<v-card-title>
|
||||
<v-icon icon="$properties"></v-icon>
|
||||
{{ $t('Properties') }}
|
||||
@@ -22,8 +22,8 @@
|
||||
<tbody>
|
||||
<tr v-for="p in propertyList" :key="p.id">
|
||||
<td>{{ p.name }}</td>
|
||||
<td>{{ $n(p.propertyAmountPerServing) }} {{ p.unit }}</td>
|
||||
<td>{{ $n(p.propertyAmountTotal) }} {{ p.unit }}</td>
|
||||
<td>{{ $n(roundDecimals(p.propertyAmountPerServing)) }} {{ p.unit }}</td>
|
||||
<td>{{ $n(roundDecimals(p.propertyAmountTotal)) }} {{ p.unit }}</td>
|
||||
<td v-if="sourceSelectedToShow == 'food'">
|
||||
<v-btn @click="dialogProperty = p; dialog = true" variant="plain" color="warning" icon="fa-solid fa-triangle-exclamation" size="small" class="d-print-none"
|
||||
v-if="p.missingValue"></v-btn>
|
||||
@@ -86,6 +86,7 @@ import {ApiApi, PropertyType, Recipe} from "@/openapi";
|
||||
import VClosableCardTitle from "@/components/dialogs/VClosableCardTitle.vue";
|
||||
import ModelEditDialog from "@/components/dialogs/ModelEditDialog.vue";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
|
||||
import {roundDecimals} from "@/utils/number_utils.ts";
|
||||
|
||||
type PropertyWrapper = {
|
||||
id: number,
|
||||
@@ -119,6 +120,7 @@ const hasFoodProperties = computed(() => {
|
||||
let propertiesFound = false
|
||||
for (const [key, fp] of Object.entries(recipe.value.foodProperties)) {
|
||||
if (fp.total_value !== 0) {
|
||||
console.log(fp, fp.total_value)
|
||||
propertiesFound = true
|
||||
}
|
||||
}
|
||||
@@ -188,7 +190,7 @@ const dialogProperty = ref<undefined | PropertyWrapper>(undefined)
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
if (!hasFoodProperties) {
|
||||
if (!hasFoodProperties.value) {
|
||||
sourceSelectedToShow.value = "recipe"
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,40 +1,11 @@
|
||||
<template>
|
||||
|
||||
<v-card class="mt-1" v-if="cookLogs.length > 0">
|
||||
<v-card-title>{{ $t('Activity') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list>
|
||||
<v-list-item v-for="c in cookLogs.sort((a,b) => a.createdAt! > b.createdAt! ? 1 : -1)" :key="c.id">
|
||||
<template #prepend>
|
||||
<v-avatar color="primary">V</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-bold">{{ c.createdBy.displayName }}
|
||||
<v-rating density="comfortable" size="x-small" color="tandoor" class="float-right" v-model="c.rating"></v-rating>
|
||||
</v-list-item-title>
|
||||
|
||||
{{ c.comment }}
|
||||
|
||||
<p v-if="c.servings != null && c.servings > 0">
|
||||
{{ c.servings }}
|
||||
<span v-if="recipe.servingsText != ''">{{ recipe.servingsText }}</span>
|
||||
<span v-else-if="c.servings == 1">{{ $t('Serving') }}</span>
|
||||
<span v-else>{{ $t('Servings') }}</span>
|
||||
</p>
|
||||
|
||||
<p class="text-disabled">
|
||||
{{ DateTime.fromJSDate(c.createdAt).toLocaleString(DateTime.DATETIME_SHORT) }}
|
||||
</p>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card class="mt-1 d-print-none">
|
||||
<v-card class="mt-1 d-print-none" v-if="useUserPreferenceStore().isAuthenticated" :loading="loading">
|
||||
<v-card-text>
|
||||
<v-textarea :label="$t('Comment')" rows="2" v-model="newCookLog.comment"></v-textarea>
|
||||
<v-row de>
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="4">
|
||||
<v-label>{{$t('Rating')}}</v-label><br/>
|
||||
<v-label>{{ $t('Rating') }}</v-label>
|
||||
<br/>
|
||||
<v-rating v-model="newCookLog.rating" clearable hover density="compact"></v-rating>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
@@ -52,6 +23,47 @@
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
<v-card class="mt-1" v-if="cookLogs.length > 0" :loading="loading">
|
||||
<v-card-title>{{ $t('Activity') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list>
|
||||
<v-list-item class="border-t-sm" v-for="c in cookLogs" :key="c.id" :link="c.createdBy.id == useUserPreferenceStore().userSettings?.user.id">
|
||||
<template #prepend>
|
||||
<v-avatar color="primary">{{ c.createdBy.displayName.charAt(0) }}</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-bold">
|
||||
{{ c.createdBy.displayName }}
|
||||
</v-list-item-title>
|
||||
<span>{{ c.comment }}</span>
|
||||
|
||||
<v-list-item-subtitle class="font-italic mt-1" v-if="c.servings != null && c.servings > 0">
|
||||
|
||||
{{ c.servings }}
|
||||
<span v-if="recipe.servingsText != ''">{{ recipe.servingsText }}</span>
|
||||
<span v-else-if="c.servings == 1">{{ $t('Serving') }}</span>
|
||||
<span v-else>{{ $t('Servings') }}</span>
|
||||
|
||||
</v-list-item-subtitle>
|
||||
|
||||
<template #append>
|
||||
<v-list-item-action class="flex-column align-end">
|
||||
<v-rating density="comfortable" size="x-small" color="tandoor" v-model="c.rating" half-increments readonly
|
||||
v-if="c.rating != undefined" style="overflow: hidden"></v-rating>
|
||||
<v-spacer></v-spacer>
|
||||
<v-tooltip location="top" :text="DateTime.fromJSDate(c.createdAt).toLocaleString(DateTime.DATETIME_MED)" v-if="c.createdAt != undefined">
|
||||
<template v-slot:activator="{ props }">
|
||||
<span v-bind="props">{{ DateTime.fromJSDate(c.createdAt).toRelative({style: 'narrow'}) }}</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
</v-list-item-action>
|
||||
</template>
|
||||
<model-edit-dialog model="CookLog" :item="c" v-if="c.createdBy.id == useUserPreferenceStore().userSettings?.user.id" @save="recLoadCookLog(props.recipe.id)" @delete="recLoadCookLog(props.recipe.id)"></model-edit-dialog>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
@@ -62,6 +74,8 @@ import {ApiApi, CookLog, Recipe} from "@/openapi";
|
||||
import {DateTime} from "luxon";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
|
||||
import {VDateInput} from 'vuetify/labs/VDateInput'
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
import ModelEditDialog from "@/components/dialogs/ModelEditDialog.vue";
|
||||
|
||||
const props = defineProps({
|
||||
recipe: {
|
||||
@@ -73,21 +87,31 @@ const props = defineProps({
|
||||
const newCookLog = ref({} as CookLog);
|
||||
|
||||
const cookLogs = ref([] as CookLog[])
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
refreshActivity()
|
||||
recLoadCookLog(props.recipe.id)
|
||||
resetForm()
|
||||
})
|
||||
|
||||
/**
|
||||
* load cook logs from database for given recipe
|
||||
* recursively load cook logs from database for given recipe
|
||||
*/
|
||||
function refreshActivity() {
|
||||
function recLoadCookLog(recipeId: number, page: number = 1) {
|
||||
const api = new ApiApi()
|
||||
api.apiCookLogList({recipe: props.recipe.id}).then(r => {
|
||||
// TODO pagination
|
||||
loading.value = true
|
||||
if(page == 1){
|
||||
cookLogs.value = []
|
||||
}
|
||||
api.apiCookLogList({recipe: props.recipe.id, page: page}).then(r => {
|
||||
if (r.results) {
|
||||
cookLogs.value = r.results
|
||||
cookLogs.value = cookLogs.value.concat(r.results)
|
||||
if (r.next) {
|
||||
recLoadCookLog(recipeId, page + 1)
|
||||
} else {
|
||||
cookLogs.value = cookLogs.value.sort((a, b) => a.createdAt! > b.createdAt! ? 1 : -1)
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -96,6 +120,7 @@ function refreshActivity() {
|
||||
* reset new cook log from with proper defaults
|
||||
*/
|
||||
function resetForm() {
|
||||
newCookLog.value = {} as CookLog
|
||||
newCookLog.value.servings = props.recipe.servings
|
||||
newCookLog.value.createdAt = new Date()
|
||||
newCookLog.value.recipe = props.recipe.id!
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
<template>
|
||||
<template v-if="!props.loading">
|
||||
|
||||
<router-link :to="{name: 'RecipeViewPage', params: {id: props.recipe.id}}">
|
||||
<router-link :to="{name: 'RecipeViewPage', params: {id: props.recipe.id}}" :target="linkTarget">
|
||||
<recipe-image :style="{height: props.height}" :recipe="props.recipe" rounded="lg" class="mr-3 ml-3">
|
||||
|
||||
</recipe-image>
|
||||
</router-link>
|
||||
<div class="ml-3">
|
||||
<div class="d-flex ">
|
||||
<div class="flex-grow-1 cursor-pointer" @click="router.push({name: 'RecipeViewPage', params: {id: props.recipe.id}})">
|
||||
<div class="flex-grow-1 cursor-pointer" @click="openRecipe()">
|
||||
<p class="font-weight-bold mt-2">{{ props.recipe.name }}</p>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<!-- <v-btn icon="fas fa-ellipsis-v" size="small" variant="plain"></v-btn>-->
|
||||
<recipe-context-menu :recipe="props.recipe" size="small"></recipe-context-menu>
|
||||
<recipe-context-menu :recipe="props.recipe" size="small" v-if="props.showMenu"></recipe-context-menu>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <p class="text-disabled">{{ props.recipe.createdBy.displayName}}</p>-->
|
||||
<keywords-component variant="outlined" :keywords="props.recipe.keywords" :max-keywords="3">
|
||||
<keywords-component variant="outlined" :keywords="props.recipe.keywords" :max-keywords="3" v-if="props.showKeywords">
|
||||
<template #prepend>
|
||||
|
||||
<v-chip class="mb-1 me-1" size="x-small" label variant="outlined" v-if="recipe._private">
|
||||
<private-recipe-badge :show-text="false"></private-recipe-badge>
|
||||
</v-chip>
|
||||
<v-chip class="mb-1 me-1" size="x-small" label variant="outlined" color="info"
|
||||
v-if="!props.recipe.internal">
|
||||
v-if="props.recipe.internal == false">
|
||||
{{ $t('External') }}
|
||||
</v-chip>
|
||||
<v-chip class="mb-1 me-1" size="x-small" prepend-icon="far fa-clock" label variant="outlined"
|
||||
@@ -32,7 +36,7 @@
|
||||
</div>
|
||||
|
||||
|
||||
<v-card :to="`/recipe/${props.recipe.id}`" :style="{'height': props.height}" v-if="false">
|
||||
<v-card :to="{name: 'RecipeViewPage', params: {id: props.recipe.id}}" :style="{'height': props.height}" v-if="false">
|
||||
<v-tooltip
|
||||
class="align-center justify-center"
|
||||
location="top center" origin="overlap"
|
||||
@@ -100,17 +104,32 @@ import {Recipe, RecipeOverview} from "@/openapi";
|
||||
import RecipeContextMenu from "@/components/inputs/RecipeContextMenu.vue";
|
||||
import RecipeImage from "@/components/display/RecipeImage.vue";
|
||||
import {useRouter} from "vue-router";
|
||||
import PrivateRecipeBadge from "@/components/display/PrivateRecipeBadge.vue";
|
||||
|
||||
const props = defineProps({
|
||||
recipe: {type: {} as PropType<Recipe | RecipeOverview>, required: true,},
|
||||
loading: {type: Boolean, required: false},
|
||||
show_keywords: {type: Boolean, required: false},
|
||||
showKeywords: {type: Boolean, default: true, required: false},
|
||||
show_description: {type: Boolean, required: false},
|
||||
height: {type: String, required: false, default: '15vh'},
|
||||
linkTarget: {type: String, required: false, default: ''},
|
||||
showMenu: {type: Boolean, default: true, required: false}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
/**
|
||||
* open the recipe either in the same tab or in a new tab depending on the link target prop
|
||||
*/
|
||||
function openRecipe() {
|
||||
if (props.linkTarget != '') {
|
||||
const routeData = router.resolve({name: 'RecipeViewPage', params: {id: props.recipe.id}});
|
||||
window.open(routeData.href, props.linkTarget);
|
||||
} else {
|
||||
router.push({name: 'RecipeViewPage', params: {id: props.recipe.id}})
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
|
||||
<template class="d-block d-lg-none">
|
||||
|
||||
<!-- mobile layout -->
|
||||
<v-card class="rounded-0">
|
||||
<recipe-image
|
||||
max-height="25vh"
|
||||
:recipe="recipe"
|
||||
v-if="recipe.internal"
|
||||
>
|
||||
v-if="recipe.image != undefined">
|
||||
</recipe-image>
|
||||
|
||||
<v-card>
|
||||
@@ -27,47 +27,47 @@
|
||||
</span>
|
||||
<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>
|
||||
<keywords-component variant="flat" class="ms-1" :keywords="recipe.keywords"></keywords-component>
|
||||
<private-recipe-badge :users="recipe.shared" v-if="recipe._private"></private-recipe-badge>
|
||||
<v-rating v-model="recipe.rating" size="x-small" v-if="recipe.rating" half-increments readonly></v-rating>
|
||||
<v-sheet class="ps-2 text-disabled">
|
||||
{{ recipe.description }}
|
||||
</v-sheet>
|
||||
</v-card>
|
||||
</v-card>
|
||||
|
||||
<template v-if="recipe.internal">
|
||||
<v-card class="mt-1">
|
||||
<v-container>
|
||||
<v-row class="text-center text-body-2">
|
||||
<v-col class="pt-1 pb-1">
|
||||
<i class="fas fa-cogs fa-fw mr-1"></i> {{ recipe.workingTime }} min<br/>
|
||||
<div class="text-grey">{{ $t('WorkingTime') }}</div>
|
||||
</v-col>
|
||||
<v-col class="pt-1 pb-1">
|
||||
<div><i class="fas fa-hourglass-half fa-fw mr-1"></i> {{ recipe.waitingTime }} min</div>
|
||||
<div class="text-grey">{{ $t('WaitingTime') }}</div>
|
||||
</v-col>
|
||||
<v-col class="pt-1 pb-1">
|
||||
<v-card class="mt-1">
|
||||
<v-container>
|
||||
<v-row class="text-center text-body-2">
|
||||
<v-col class="pt-1 pb-1">
|
||||
<i class="fas fa-cogs fa-fw mr-1"></i> {{ recipe.workingTime }} min<br/>
|
||||
<div class="text-grey">{{ $t('WorkingTime') }}</div>
|
||||
</v-col>
|
||||
<v-col class="pt-1 pb-1">
|
||||
<div><i class="fas fa-hourglass-half fa-fw mr-1"></i> {{ recipe.waitingTime }} min</div>
|
||||
<div class="text-grey">{{ $t('WaitingTime') }}</div>
|
||||
</v-col>
|
||||
<v-col class="pt-1 pb-1">
|
||||
|
||||
<div class="cursor-pointer">
|
||||
<i class="fas fa-sort-numeric-up fa-fw mr-1"></i> {{ servings }} <br/>
|
||||
<div class="text-grey"><span v-if="recipe.servingsText">{{ recipe.servingsText }}</span><span v-else>{{ $t('Servings') }}</span></div>
|
||||
<number-scaler-dialog :number="servings" @confirm="(s: number) => {servings = s}" title="Servings">
|
||||
</number-scaler-dialog>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card>
|
||||
</template>
|
||||
<div class="cursor-pointer">
|
||||
<i class="fas fa-sort-numeric-up fa-fw mr-1"></i> {{ servings }} <br/>
|
||||
<div class="text-grey"><span v-if="recipe.servingsText">{{ recipe.servingsText }}</span><span v-else>{{ $t('Servings') }}</span></div>
|
||||
<number-scaler-dialog :number="servings" @confirm="(s: number) => {servings = s}" title="Servings">
|
||||
</number-scaler-dialog>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card>
|
||||
</template>
|
||||
<!-- Desktop horizontal layout -->
|
||||
<template class="d-none d-lg-block">
|
||||
<v-row dense>
|
||||
<v-col cols="8">
|
||||
<recipe-image
|
||||
:rounded="true"
|
||||
max-height="40vh"
|
||||
:recipe="recipe"
|
||||
v-if="recipe.internal">
|
||||
:recipe="recipe">
|
||||
</recipe-image>
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
@@ -75,7 +75,8 @@
|
||||
<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" v-if="useUserPreferenceStore().isAuthenticated" 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) }})
|
||||
@@ -84,6 +85,10 @@
|
||||
<i>{{ recipe.description }}</i>
|
||||
</p>
|
||||
|
||||
<private-recipe-badge :users="recipe.shared" v-if="recipe._private"></private-recipe-badge>
|
||||
|
||||
<v-rating v-model="recipe.rating" size="x-small" v-if="recipe.rating" readonly></v-rating>
|
||||
|
||||
<keywords-component variant="flat" class="mt-4" :keywords="recipe.keywords"></keywords-component>
|
||||
|
||||
</v-card-text>
|
||||
@@ -113,20 +118,32 @@
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<template v-if="!recipe.internal">
|
||||
<external-recipe-viewer :recipe="recipe"></external-recipe-viewer>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-card class="mt-1" v-if="recipe.steps.length > 1 && recipe.showIngredientOverview">
|
||||
<steps-overview :steps="recipe.steps" :ingredient-factor="ingredientFactor"></steps-overview>
|
||||
</v-card>
|
||||
<template v-if="recipe.filePath">
|
||||
<external-recipe-viewer class="mt-2" :recipe="recipe"></external-recipe-viewer>
|
||||
|
||||
<v-card class="mt-1" v-for="(step, index) in recipe.steps" :key="step.id">
|
||||
<step-view v-model="recipe.steps[index]" :step-number="index+1" :ingredientFactor="ingredientFactor"></step-view>
|
||||
<v-card :title="$t('AI')" prepend-icon="$ai" :loading="fileApiLoading || loading" :disabled="fileApiLoading || loading || !useUserPreferenceStore().activeSpace.aiEnabled"
|
||||
v-if="!recipe.internal">
|
||||
<v-card-text>
|
||||
{{$t('ConvertUsingAI')}}
|
||||
|
||||
<model-select model="AiProvider" v-model="selectedAiProvider">
|
||||
<template #append>
|
||||
<v-btn @click="aiConvertRecipe()" icon="fa-solid fa-person-running" color="success"></v-btn>
|
||||
</template>
|
||||
</model-select>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<property-view v-model="recipe" :servings="servings" v-if="recipe.internal"></property-view>
|
||||
<v-card class="mt-1" v-if="(recipe.steps.length > 1 || (recipe.steps.length == 1 && !recipe.steps[0].showIngredientsTable)) && recipe.showIngredientOverview">
|
||||
<steps-overview :steps="recipe.steps" :ingredient-factor="ingredientFactor"></steps-overview>
|
||||
</v-card>
|
||||
|
||||
<v-card class="mt-1" v-for="(step, index) in recipe.steps" :key="step.id">
|
||||
<step-view v-model="recipe.steps[index]" :step-number="index+1" :ingredientFactor="ingredientFactor"></step-view>
|
||||
</v-card>
|
||||
|
||||
<property-view v-model="recipe" :servings="servings"></property-view>
|
||||
|
||||
<v-card class="mt-2">
|
||||
<v-card-text>
|
||||
@@ -136,7 +153,8 @@
|
||||
variant="outlined"
|
||||
:title="$t('CreatedBy')"
|
||||
:subtitle="recipe.createdBy.displayName"
|
||||
prepend-icon="fa-solid fa-user">
|
||||
prepend-icon="fa-solid fa-user"
|
||||
:to="(useUserPreferenceStore().isAuthenticated) ? {name: 'SearchPage', query: {createdby: recipe.createdBy.id!}}: undefined">
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
@@ -144,7 +162,8 @@
|
||||
variant="outlined"
|
||||
:title="$t('Created')"
|
||||
:subtitle="DateTime.fromJSDate(recipe.createdAt).toLocaleString(DateTime.DATETIME_MED)"
|
||||
prepend-icon="$create">
|
||||
prepend-icon="$create"
|
||||
:to="(useUserPreferenceStore().isAuthenticated) ? {name: 'SearchPage', query: {createdon: DateTime.fromJSDate(recipe.createdAt).toISODate()}} : undefined">
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
@@ -152,7 +171,8 @@
|
||||
variant="outlined"
|
||||
:title="$t('Updated')"
|
||||
:subtitle="DateTime.fromJSDate(recipe.updatedAt).toLocaleString(DateTime.DATETIME_MED)"
|
||||
prepend-icon="$edit">
|
||||
prepend-icon="$edit"
|
||||
:to="(useUserPreferenceStore().isAuthenticated) ? {name: 'SearchPage', query: {updatedon: DateTime.fromJSDate(recipe.updatedAt).toISODate()}}: undefined">
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3" v-if="recipe.sourceUrl">
|
||||
@@ -176,7 +196,7 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {computed, onBeforeUnmount, onMounted, ref, watch} from 'vue'
|
||||
import {Recipe} from "@/openapi"
|
||||
import {AiProvider, ApiApi, Recipe} from "@/openapi"
|
||||
import NumberScalerDialog from "@/components/inputs/NumberScalerDialog.vue"
|
||||
import StepsOverview from "@/components/display/StepsOverview.vue";
|
||||
import RecipeActivity from "@/components/display/RecipeActivity.vue";
|
||||
@@ -184,23 +204,37 @@ import RecipeContextMenu from "@/components/inputs/RecipeContextMenu.vue";
|
||||
import KeywordsComponent from "@/components/display/KeywordsBar.vue";
|
||||
import RecipeImage from "@/components/display/RecipeImage.vue";
|
||||
import ExternalRecipeViewer from "@/components/display/ExternalRecipeViewer.vue";
|
||||
import {useMediaQuery, useWakeLock} from "@vueuse/core";
|
||||
import {useWakeLock} from "@vueuse/core";
|
||||
import StepView from "@/components/display/StepView.vue";
|
||||
import {DateTime} from "luxon";
|
||||
import PropertyView from "@/components/display/PropertyView.vue";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts";
|
||||
import {useFileApi} from "@/composables/useFileApi.ts";
|
||||
import PrivateRecipeBadge from "@/components/display/PrivateRecipeBadge.vue";
|
||||
import ModelSelect from "@/components/inputs/ModelSelect.vue";
|
||||
|
||||
const {request, release} = useWakeLock()
|
||||
const {doAiImport, fileApiLoading} = useFileApi()
|
||||
|
||||
const loading = ref(false)
|
||||
const recipe = defineModel<Recipe>({required: true})
|
||||
|
||||
const servings = ref(1)
|
||||
const showFullRecipeName = ref(false)
|
||||
|
||||
const selectedAiProvider = ref<undefined | AiProvider>(useUserPreferenceStore().activeSpace.aiDefaultProvider)
|
||||
|
||||
/**
|
||||
* factor for multiplying ingredient amounts based on recipe base servings and user selected servings
|
||||
*/
|
||||
const ingredientFactor = computed(() => {
|
||||
return servings.value / ((recipe.value.servings != undefined) ? recipe.value.servings : 1)
|
||||
})
|
||||
|
||||
/**
|
||||
* change servings when recipe servings are changed
|
||||
*/
|
||||
watch(() => recipe.value.servings, () => {
|
||||
if (recipe.value.servings) {
|
||||
servings.value = recipe.value.servings
|
||||
@@ -217,6 +251,43 @@ onBeforeUnmount(() => {
|
||||
release()
|
||||
})
|
||||
|
||||
/**
|
||||
* converts the recipe into an internal recipe using AI
|
||||
*/
|
||||
function aiConvertRecipe() {
|
||||
let api = new ApiApi()
|
||||
|
||||
doAiImport(selectedAiProvider.value.id!,null, '', recipe.value.id!).then(r => {
|
||||
if (r.recipe) {
|
||||
recipe.value.internal = true
|
||||
recipe.value.steps = r.recipe.steps
|
||||
recipe.value.keywords = r.recipe.keywords
|
||||
recipe.value.servings = r.recipe.servings
|
||||
recipe.value.servingsText = r.recipe.servingsText
|
||||
recipe.value.workingTime = r.recipe.workingTime
|
||||
recipe.value.waitingTime = r.recipe.waitingTime
|
||||
|
||||
servings.value = r.recipe.servings
|
||||
loading.value = true
|
||||
|
||||
api.apiRecipeUpdate({id: recipe.value.id!, recipe: recipe.value}).then(r => {
|
||||
recipe.value = r
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
} else {
|
||||
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, [r.error, r.msg])
|
||||
}
|
||||
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-list-item class="swipe-container" :id="itemContainerId" @touchend="handleSwipe()"
|
||||
<v-list-item class="swipe-container border-t-sm mt-0 mb-0 pt-0 pb-0 pe-0 pa-0" :id="itemContainerId" @touchend="handleSwipe()" @click="dialog = true;"
|
||||
v-if="isShoppingListFoodVisible(props.shoppingListFood, useUserPreferenceStore().deviceSettings)"
|
||||
>
|
||||
<!-- <div class="swipe-action" :class="{'bg-success': !isChecked , 'bg-warning': isChecked }">-->
|
||||
@@ -7,34 +7,36 @@
|
||||
<!-- </div>-->
|
||||
|
||||
|
||||
<div class="flex-grow-1 p-2" @click="dialog = true;">
|
||||
<div class="flex-grow-1 p-2">
|
||||
<div class="d-flex">
|
||||
<div class="d-flex flex-column pr-2">
|
||||
<div class="d-flex flex-column pr-2 pl-4">
|
||||
<span v-for="a in amounts" v-bind:key="a.key">
|
||||
<span>
|
||||
<i class="fas fa-check text-success fa-fw" v-if="a.checked"></i>
|
||||
<i class="fas fa-clock-rotate-left text-info fa-fw" v-if="a.delayed"></i> <b>
|
||||
<span :class="{'text-disabled': a.checked || a.delayed}" class="text-no-wrap">
|
||||
{{ $n(a.amount) }}
|
||||
<span v-if="a.unit">{{ a.unit.name }}</span>
|
||||
<span v-if="amounts.length > 1 || (amounts.length == 1 && a.amount != 1) || a.unit">{{ $n(a.amount) }}</span>
|
||||
<span class="ms-1" v-if="a.unit">{{ pluralString(a.unit, a.amount) }}</span>
|
||||
</span>
|
||||
|
||||
</b>
|
||||
</span>
|
||||
<br/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex flex-column flex-grow-1 align-self-center">
|
||||
{{ shoppingListFood.food.name }} <br/>
|
||||
{{ pluralString(shoppingListFood.food, (amounts.length > 1 || (amounts.length == 1 && amounts[0].amount > 1) ? 2 : 1)) }} <br/>
|
||||
<span v-if="infoRow"><small class="text-disabled">{{ infoRow }}</small></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<template v-slot:[checkBtnSlot]>
|
||||
<v-btn color="success" @click.native.stop="useShoppingStore().setEntriesCheckedState(entries, !isChecked, true);"
|
||||
:class="{'btn-success': !isChecked, 'btn-warning': isChecked}" :icon="actionButtonIcon" variant="plain">
|
||||
</v-btn>
|
||||
<div class="ps-3 pe-3" @click.native.stop="useShoppingStore().setEntriesCheckedState(entries, !isChecked, true);">
|
||||
<v-btn color="success" size="large"
|
||||
:class="{'btn-success': !isChecked, 'btn-warning': isChecked}" :icon="actionButtonIcon" variant="plain">
|
||||
</v-btn>
|
||||
</div>
|
||||
<!-- <i class="d-print-none fa-fw fas" :class="{'fa-check': !isChecked , 'fa-cart-plus': isChecked }"></i>-->
|
||||
</template>
|
||||
|
||||
@@ -59,6 +61,7 @@ import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore";
|
||||
import {IShoppingListFood, ShoppingLineAmount} from "@/types/Shopping";
|
||||
import {isDelayed, isEntryVisible, isShoppingListFoodDelayed, isShoppingListFoodVisible} from "@/utils/logic_utils";
|
||||
import ShoppingLineItemDialog from "@/components/dialogs/ShoppingLineItemDialog.vue";
|
||||
import {pluralString} from "@/utils/model_utils.ts";
|
||||
|
||||
const emit = defineEmits(['clicked'])
|
||||
|
||||
|
||||
@@ -123,6 +123,8 @@
|
||||
</template>
|
||||
</v-list>
|
||||
|
||||
<!-- TODO remove once append to body for model select is working properly -->
|
||||
<v-spacer style="margin-top: 120px;"></v-spacer>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -180,7 +182,8 @@
|
||||
<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>
|
||||
<add-to-shopping-dialog :recipe="manualAddRecipe" v-if="manualAddRecipe != undefined"
|
||||
@created="useShoppingStore().refreshFromAPI(); manualAddRecipe = undefined"></add-to-shopping-dialog>
|
||||
</v-btn>
|
||||
</template>
|
||||
</ModelSelect>
|
||||
|
||||
72
vue3/src/components/display/SpaceLimitsInfo.vue
Normal file
72
vue3/src/components/display/SpaceLimitsInfo.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<v-row v-if="props.space.name != undefined">
|
||||
<v-col cols="12" md="4">
|
||||
<v-card :to="{name: 'SearchPage'}">
|
||||
<v-card-title><i class="fa-solid fa-book"></i> {{ $t('Recipes') }}</v-card-title>
|
||||
<v-card-text>{{ $n(props.space.recipeCount) }} / {{ props.space.maxRecipes == 0 ? '∞' : $n(props.space.maxRecipes) }}</v-card-text>
|
||||
<v-progress-linear :color="isSpaceAboveRecipeLimit(props.space) ? 'error' : 'success'" height="10"
|
||||
:model-value="(props.space.recipeCount / props.space.maxRecipes) * 100"></v-progress-linear>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-card :to="{name: 'ModelListPage', params: {model: 'UserSpace'}}">
|
||||
|
||||
<v-card-title><i class="fa-solid fa-users"></i> {{ $t('Users') }}</v-card-title>
|
||||
<v-card-text>{{ $n(props.space.userCount) }} / {{ props.space.maxUsers == 0 ? '∞' : $n(props.space.maxUsers) }}</v-card-text>
|
||||
<v-progress-linear :color="isSpaceAboveUserLimit(props.space) ? 'error' : 'success'" height="10"
|
||||
:model-value="(props.space.userCount / props.space.maxUsers) * 100"></v-progress-linear>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-card :to="{name: 'ModelListPage', params: {model: 'UserFile'}}">
|
||||
<v-card-title><i class="fa-solid fa-file"></i> {{ $t('Files') }}</v-card-title>
|
||||
<v-card-text v-if="props.space.maxFileStorageMb > -1">{{ $n(Math.round(props.space.fileSizeMb)) }} /
|
||||
{{ props.space.maxFileStorageMb == 0 ? '∞' : $n(props.space.maxFileStorageMb) }}
|
||||
MB
|
||||
</v-card-text>
|
||||
<v-card-text v-if="props.space.maxFileStorageMb == -1">{{ $t('file_upload_disabled') }}</v-card-text>
|
||||
<v-progress-linear v-if="props.space.maxFileStorageMb > -1" :color="isSpaceAboveStorageLimit(props.space) ? 'error' : 'success'" height="10"
|
||||
:model-value="(props.space.fileSizeMb / props.space.maxFileStorageMb) * 100"></v-progress-linear>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-card :to="{name: 'ModelListPage', params: {model: 'AiLog'}}">
|
||||
<v-card-title><i class="fa-solid hand-holding-dollar"></i> {{ $t('MonthlyCredits') }}</v-card-title>
|
||||
<v-card-text>{{ $n(props.space.aiMonthlyCreditsUsed) }} / {{ $n(props.space.aiCreditsMonthly) }} {{ $t('Credits') }}
|
||||
</v-card-text>
|
||||
<v-progress-linear :model-value="props.space.aiMonthlyCreditsUsed" :max="props.space.aiCreditsMonthly" height="10"
|
||||
></v-progress-linear>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-card :to="{name: 'ModelListPage', params: {model: 'AiLog'}}">
|
||||
<v-card-title><i class="fa-solid hand-holding-dollar"></i> {{ $t('AiCreditsBalance') }}</v-card-title>
|
||||
<v-card-text>{{ $n(props.space.aiCreditsBalance) }} {{ $t('Credits') }}
|
||||
</v-card-text>
|
||||
<v-progress-linear height="10"
|
||||
></v-progress-linear>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {PropType} from "vue";
|
||||
import {Space} from "@/openapi";
|
||||
import {isSpaceAboveRecipeLimit, isSpaceAboveStorageLimit, isSpaceAboveUserLimit} from "@/utils/logic_utils.ts";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
|
||||
const props = defineProps({
|
||||
space: {type: {} as PropType<Space>, required: true},
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -23,7 +23,7 @@
|
||||
<timer :seconds="step.time != undefined ? step.time*60 : 0" @stop="timerRunning = false" v-if="timerRunning"></timer>
|
||||
<v-card-text v-if="step.ingredients.length > 0 || step.instruction != ''">
|
||||
<v-row>
|
||||
<v-col cols="12" md="6" v-if="step.ingredients.length > 0">
|
||||
<v-col cols="12" md="6" v-if="step.ingredients.length > 0 && step.showIngredientsTable">
|
||||
<ingredients-table v-model="step.ingredients" :ingredient-factor="ingredientFactor"></ingredients-table>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6" class="markdown-body">
|
||||
@@ -35,11 +35,11 @@
|
||||
</v-card-text>
|
||||
|
||||
<template v-if="step.stepRecipe">
|
||||
<v-card-text>
|
||||
<v-card class="mt-1" v-for="(subRecipeStep, subRecipeStepIndex) in step.stepRecipeData.steps" :key="subRecipeStep.id">
|
||||
<v-card class="ma-2 border-md" prepend-icon="$recipes" :title="step.stepRecipeData.name">
|
||||
<v-card-text class="mt-1" v-for="(subRecipeStep, subRecipeStepIndex) in step.stepRecipeData.steps" :key="subRecipeStep.id">
|
||||
<step-view v-model="step.stepRecipeData.steps[subRecipeStepIndex]" :step-number="subRecipeStepIndex+1" :ingredientFactor="ingredientFactor"></step-view>
|
||||
</v-card>
|
||||
</v-card-text>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
<template v-if="step.file">
|
||||
<v-img :src="step.file.preview" v-if="step.file.preview"></v-img>
|
||||
|
||||
@@ -1,16 +1,42 @@
|
||||
<template>
|
||||
<v-expansion-panels>
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title><i class="far fa-list-alt fa-fw me-2"></i> {{$t('StepsOverview')}}</v-expansion-panel-title>
|
||||
<v-expansion-panel-title>
|
||||
<i class="far fa-list-alt fa-fw me-2"></i> {{ $t('StepsOverview') }}
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<v-container>
|
||||
<v-row v-for="(s, i) in props.steps">
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-btn-toggle density="compact" v-model="useUserPreferenceStore().deviceSettings.recipe_mergeStepOverview" border divided>
|
||||
<v-btn :value="false" prepend-icon="fa-solid fa-folder-tree">{{ $t('Structured') }}</v-btn>
|
||||
<v-btn :value="true" prepend-icon="fa-solid fa-arrows-to-circle">{{ $t('Summary') }}</v-btn>
|
||||
</v-btn-toggle>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-for="(s, i) in props.steps" v-if="!useUserPreferenceStore().deviceSettings.recipe_mergeStepOverview">
|
||||
<v-col class="pa-1" cols="12" md="6">
|
||||
<b v-if="s.showAsHeader">{{ i + 1 }}. {{ s.name }} </b>
|
||||
<ingredients-table v-model="s.ingredients" :ingredient-factor="props.ingredientFactor"></ingredients-table>
|
||||
|
||||
<template v-if="s.stepRecipe">
|
||||
<v-card class="ma-2 border-md" prepend-icon="$recipes" :title="s.stepRecipeData.name"
|
||||
:to="{name: 'RecipeViewPage', params: {id: s.stepRecipeData.id}}" target="_blank">
|
||||
<v-row v-for="subRecipeStep in s.stepRecipeData.steps">
|
||||
<v-col>
|
||||
<ingredients-table v-model="subRecipeStep.ingredients" :ingredient-factor="props.ingredientFactor"></ingredients-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<v-row v-if="useUserPreferenceStore().deviceSettings.recipe_mergeStepOverview">
|
||||
<v-col class="pa-1" cols="12" md="6">
|
||||
<ingredients-table v-model="mergedIngredients" :ingredient-factor="props.ingredientFactor" :show-checkbox="false"></ingredients-table>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
|
||||
@@ -19,9 +45,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {PropType} from 'vue'
|
||||
import {Step} from "@/openapi";
|
||||
import {computed, PropType, ref} from 'vue'
|
||||
import {Ingredient, Step} from "@/openapi";
|
||||
import IngredientsTable from "@/components/display/IngredientsTable.vue";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
|
||||
const props = defineProps({
|
||||
steps: {
|
||||
@@ -34,6 +61,70 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const showMergedIngredients = ref(false)
|
||||
|
||||
const mergedIngredients = computed(() => {
|
||||
// Function to collect all ingredients from recipe steps
|
||||
const getAllIngredients = () => {
|
||||
const ingredients: Array<Ingredient> = [];
|
||||
|
||||
// Add ingredients from steps
|
||||
props.steps.forEach(step => {
|
||||
step.ingredients.forEach(ingredient => {
|
||||
if (ingredient.food && !ingredient.isHeader ) {
|
||||
ingredients.push(ingredient);
|
||||
}
|
||||
});
|
||||
|
||||
// Add ingredients from step recipes if they exist
|
||||
if (step.stepRecipeData) {
|
||||
step.stepRecipeData.steps?.forEach((subStep: Step) => {
|
||||
subStep.ingredients.forEach((ingredient: Ingredient) => {
|
||||
if (ingredient.food && !ingredient.isHeader) {
|
||||
ingredients.push(ingredient);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return ingredients;
|
||||
};
|
||||
|
||||
// Get all ingredients
|
||||
const allIngredients = getAllIngredients();
|
||||
|
||||
// Create a map to group and sum ingredients by food and unit
|
||||
const groupedIngredients = new Map<string, Ingredient>();
|
||||
|
||||
allIngredients.forEach(ingredient => {
|
||||
if (!ingredient.food || !ingredient.unit) return;
|
||||
|
||||
// Create a unique key for food-unit combination
|
||||
const key = `${ingredient.food.id}-${ingredient.unit.id}`;
|
||||
|
||||
if (groupedIngredients.has(key)) {
|
||||
// If this food-unit combination already exists, sum the amounts
|
||||
const existingIngredient = groupedIngredients.get(key)!;
|
||||
existingIngredient.amount += ingredient.amount;
|
||||
} else {
|
||||
// Create a new entry with the adjusted amount
|
||||
const clonedIngredient = {...ingredient};
|
||||
groupedIngredients.set(key, clonedIngredient);
|
||||
}
|
||||
});
|
||||
|
||||
// Convert the map back to an array
|
||||
const result = Array.from(groupedIngredients.values());
|
||||
|
||||
// Sort alphabetically by food name
|
||||
return result.sort((a, b) => {
|
||||
const foodNameA = a.food?.name.toLowerCase() || '';
|
||||
const foodNameB = b.food?.name.toLowerCase() || '';
|
||||
return foodNameA.localeCompare(foodNameB);
|
||||
});
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
47
vue3/src/components/display/ThankYouNote.vue
Normal file
47
vue3/src/components/display/ThankYouNote.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<v-alert color="primary" variant="tonal" v-if="useUserPreferenceStore().serverSettings.hosted">
|
||||
<v-alert-title>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-avatar image="../../assets/logo_color.svg" class="me-2"></v-avatar>
|
||||
{{ $t('ThankYou') }}!
|
||||
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn color="primary" class="float-right" href="https://tandoor.dev/manage" target="_blank">{{ $t('ManageSubscription') }}</v-btn>
|
||||
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-alert-title>
|
||||
<p class="mt-2">{{ $t('ThanksTextHosted') }}</p>
|
||||
</v-alert>
|
||||
|
||||
<v-alert color="primary" variant="tonal" v-if="!useUserPreferenceStore().serverSettings.hosted">
|
||||
<v-alert-title>
|
||||
<v-row>
|
||||
<v-col>
|
||||
<v-avatar image="../../assets/logo_color.svg" class="me-2"></v-avatar>
|
||||
{{ $t('ThankYou') }}!
|
||||
|
||||
</v-col>
|
||||
<v-col>
|
||||
<v-btn color="primary" class="float-right" href="https://github.com/sponsors/vabene1111" target="_blank"><i class="fa-brands fa-github"></i> GitHub Sponsors
|
||||
</v-btn>
|
||||
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-alert-title>
|
||||
<p class="mt-2">{{ $t('ThanksTextSelfhosted') }}</p>
|
||||
</v-alert>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -15,6 +15,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {useI18n} from "vue-i18n";
|
||||
|
||||
const emit = defineEmits(['stop'])
|
||||
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
@@ -24,6 +26,8 @@ const props = defineProps({
|
||||
seconds: {type: Number, required: true}
|
||||
})
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
const initialDurationSeconds = ref(props.seconds)
|
||||
const durationSeconds = ref(initialDurationSeconds.value)
|
||||
const timerRunning = ref(true)
|
||||
|
||||
178
vue3/src/components/inputs/HierarchyEditor.vue
Normal file
178
vue3/src/components/inputs/HierarchyEditor.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<v-row justify="space-between" dense>
|
||||
<v-col cols="6">
|
||||
<v-card :loading="loading" variant="outlined">
|
||||
<v-card-text>
|
||||
<v-treeview
|
||||
v-model:activated="activeObjs"
|
||||
return-object
|
||||
activatable
|
||||
rounded
|
||||
indent-lines
|
||||
hide-actions
|
||||
density="compact"
|
||||
open-all
|
||||
item-title="name"
|
||||
:items="objTree"
|
||||
:disabled="loading">
|
||||
<template v-slot:append="{ item, depth, isFirst, isLast }">
|
||||
<v-icon icon="fa-solid fa-location-crosshairs" v-if="item.id == editingObj.id!"></v-icon>
|
||||
</template>
|
||||
</v-treeview>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-card v-if="activeObjs.length == 1" :title="activeObjs[0].name" :prepend-icon="genericModel.model.icon" variant="outlined">
|
||||
<v-card-text>
|
||||
<v-label>{{$t('AddChild')}}</v-label>
|
||||
<model-select :model="genericModel.model.name" v-model="addChildObj" allow-create>
|
||||
<template #append>
|
||||
<v-btn color="save" icon="$save" :disabled="addChildObj == undefined" @click="moveObject(addChildObj,activeObjs[0].id!); addChildObj = undefined"></v-btn>
|
||||
</template>
|
||||
</model-select>
|
||||
<v-label>{{$t('Parent')}}</v-label>
|
||||
<model-select :model="genericModel.model.name" v-model="setParentObj" allow-create>
|
||||
<template #append>
|
||||
<v-btn color="save" icon="$save" :disabled="setParentObj == undefined" @click="moveObject(activeObjs[0], setParentObj.id!); setParentObj = undefined"></v-btn>
|
||||
</template>
|
||||
</model-select>
|
||||
|
||||
<v-btn @click="moveObject(activeObjs[0],0)" class="mt-2" color="warning" prepend-icon="fa-solid fa-link-slash" block>
|
||||
{{$t('RemoveParent')}}
|
||||
</v-btn>
|
||||
<v-btn block prepend-icon="$edit" color="info" class="mt-4"
|
||||
:to="{name: 'ModelEditPage' , params: {model: genericModel.model.name, id: activeObjs[0].id }}"
|
||||
v-if="activeObjs[0].id != editingObj.id!">
|
||||
{{ $t('Edit') }}
|
||||
</v-btn>
|
||||
<v-btn block prepend-icon="$search" color="success" class="mt-4 mb-10"
|
||||
:to="searchLink" target="_blank"
|
||||
v-if="searchLink">
|
||||
{{ $t('Recipes') }}
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {computed, onMounted, PropType, ref} from "vue";
|
||||
import ModelSelect from "@/components/inputs/ModelSelect.vue";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts";
|
||||
import {EditorSupportedModels, EditorSupportedTypes, getGenericModelFromString} from "@/types/Models.ts";
|
||||
import {useI18n} from "vue-i18n";
|
||||
|
||||
type Tree<T> = T & { children: Tree<T>[] };
|
||||
|
||||
const props = defineProps({
|
||||
model: {type: String as PropType<EditorSupportedModels>, required: true},
|
||||
})
|
||||
|
||||
const editingObj = defineModel<EditorSupportedTypes>({required: true})
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
/**
|
||||
* compute tree structure based on object list
|
||||
*/
|
||||
const objTree = computed(() => {
|
||||
return buildTreeDFS(objList.value)
|
||||
})
|
||||
|
||||
/**
|
||||
* link to search for recipes using the selected object
|
||||
*/
|
||||
const searchLink = computed(() => {
|
||||
if (activeObjs.value.length == 1) {
|
||||
if (props.model == 'Keyword') {
|
||||
return {name: 'SearchPage', query: {keywords: activeObjs.value[0].id!}}
|
||||
} else if (props.model == 'Food') {
|
||||
return {name: 'SearchPage', query: {keywords: activeObjs.value[0].id!}}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
|
||||
const objList = ref([] as EditorSupportedTypes[])
|
||||
|
||||
const activeObjs = ref([] as EditorSupportedTypes[])
|
||||
const addChildObj = ref<undefined | EditorSupportedTypes>(undefined)
|
||||
const setParentObj = ref<undefined | EditorSupportedTypes>(undefined)
|
||||
|
||||
const genericModel = ref(getGenericModelFromString(props.model, t))
|
||||
|
||||
onMounted(() => {
|
||||
recLoadObjectTree(editingObj.value.id!, 1)
|
||||
})
|
||||
|
||||
/**
|
||||
* recursively load all objects belonging to the selected objects tree
|
||||
* @param objId base object id to look for tree
|
||||
* @param page page to load
|
||||
*/
|
||||
function recLoadObjectTree(objId: number, page: number) {
|
||||
loading.value = true
|
||||
|
||||
genericModel.value.list({rootTree: objId, pageSize: 100}).then(response => {
|
||||
objList.value = objList.value.concat(response.results)
|
||||
if (response.next) {
|
||||
recLoadObjectTree(objId, page + 1)
|
||||
} else {
|
||||
if (activeObjs.value.length == 0) {
|
||||
activeObjs.value = [objTree.value.find(x => x.id! == editingObj.value.id!)]
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* move the given obj to the desired parent and update in local data cache
|
||||
* @param obj object to change parent for
|
||||
* @param parentId parent id to change the object to or 0 to remove parent
|
||||
*/
|
||||
function moveObject(obj: EditorSupportedTypes, parentId: number) {
|
||||
loading.value = true
|
||||
|
||||
genericModel.value.move(obj, parentId).then((r: any) => {
|
||||
objList.value = []
|
||||
recLoadObjectTree(editingObj.value.id!, 1)
|
||||
}).catch((err: any) => {
|
||||
loading.value = false
|
||||
useMessageStore().addError(ErrorMessageType.UPDATE_ERROR, err)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively build a tree datastructures from a DFS ordered list of objects
|
||||
*/
|
||||
function buildTreeDFS<T extends { id: number; parent: number | null }>(items: T[]): Tree<T>[] {
|
||||
let index = 0;
|
||||
|
||||
function buildSubtree(parentId: number | null): Tree<T>[] {
|
||||
const children: Tree<T>[] = [];
|
||||
while (index < items.length && items[index].parent === parentId) {
|
||||
const item = items[index++];
|
||||
const node: Tree<T> = {
|
||||
...item,
|
||||
children: buildSubtree(item.id) // recurse immediately, consumes children in DFS order
|
||||
};
|
||||
children.push(node);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
return buildSubtree(null); // start from roots (parent === null)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
51
vue3/src/components/inputs/LanguageSelect.vue
Normal file
51
vue3/src/components/inputs/LanguageSelect.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<v-select
|
||||
:label="$t('Language')"
|
||||
v-model="$i18n.locale"
|
||||
:items="availableLocalizations"
|
||||
item-title="language"
|
||||
item-value="code"
|
||||
@update:model-value="updateLanguage()"
|
||||
></v-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {onMounted, ref} from "vue";
|
||||
import {ApiApi, Localization} from "@/openapi";
|
||||
import {ErrorMessageType, useMessageStore} from "@/stores/MessageStore.ts";
|
||||
import {useI18n} from "vue-i18n";
|
||||
|
||||
const availableLocalizations = ref([] as Localization[])
|
||||
const {locale} = useI18n()
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
const api = new ApiApi()
|
||||
|
||||
api.apiLocalizationList().then(r => {
|
||||
availableLocalizations.value = r
|
||||
}).catch(err => {
|
||||
useMessageStore().addError(ErrorMessageType.FETCH_ERROR, err)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* update the django language cookie
|
||||
* this is used by django to inject the language into the template which in turn
|
||||
* sets the frontend language in i18n.ts when the frontend is initialized
|
||||
*/
|
||||
function updateLanguage() {
|
||||
const expires = new Date();
|
||||
expires.setTime(expires.getTime() + (100 * 365 * 24 * 60 * 60 * 1000));
|
||||
document.cookie = `django_language=${locale.value}; expires=${expires.toUTCString()}; path=/`;
|
||||
location.reload()
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -58,7 +58,6 @@
|
||||
<template v-if="hasMoreItems && !loading" #afterlist>
|
||||
<span class="text-disabled font-italic text-caption ms-3">{{ $t('ModelSelectResultsHelp') }}</span>
|
||||
</template>
|
||||
|
||||
</Multiselect>
|
||||
|
||||
<template #append v-if="$slots.append">
|
||||
@@ -73,7 +72,7 @@
|
||||
import {computed, onBeforeMount, onMounted, PropType, ref, useTemplateRef} from "vue"
|
||||
import {EditorSupportedModels, GenericModel, getGenericModelFromString} from "@/types/Models"
|
||||
import Multiselect from '@vueform/multiselect'
|
||||
import {ErrorMessageType, MessageType, useMessageStore} from "@/stores/MessageStore";
|
||||
import {ErrorMessageType, MessageType, PreparedMessage, useMessageStore} from "@/stores/MessageStore";
|
||||
import {useI18n} from "vue-i18n";
|
||||
|
||||
const {t} = useI18n()
|
||||
@@ -171,7 +170,7 @@ function search(query: string) {
|
||||
*/
|
||||
async function createObject(object: any, select$: Multiselect) {
|
||||
return await modelClass.value.create({name: object[itemLabel.value]}).then((createdObj: any) => {
|
||||
useMessageStore().addMessage(MessageType.SUCCESS, 'Created', 5000, createdObj)
|
||||
useMessageStore().addPreparedMessage(PreparedMessage.CREATE_SUCCESS, createdObj)
|
||||
emit('create', object)
|
||||
return createdObj
|
||||
}).catch((err: any) => {
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
<v-list-item :to="{ name: 'ModelEditPage', params: {model: 'recipe', id: recipe.id} }" prepend-icon="$edit">
|
||||
{{ $t('Edit') }}
|
||||
</v-list-item>
|
||||
<v-list-item prepend-icon="$mealplan" link>
|
||||
<v-list-item prepend-icon="$mealplan" @click="mealPlanDialog = true">
|
||||
{{ $t('Add_to_Plan') }}
|
||||
<model-edit-dialog model="MealPlan" :itemDefaults="{recipe: recipe, servings: recipe.servings}"></model-edit-dialog>
|
||||
</v-list-item>
|
||||
<v-list-item prepend-icon="$shopping" link>
|
||||
{{ $t('Add_to_Shopping') }}
|
||||
@@ -30,11 +29,12 @@
|
||||
</v-menu>
|
||||
</v-btn>
|
||||
|
||||
<model-edit-dialog model="MealPlan" :itemDefaults="{recipe: recipe, servings: recipe.servings}" :close-after-create="false" :close-after-save="false" v-model="mealPlanDialog"></model-edit-dialog>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {nextTick, PropType} from 'vue'
|
||||
import {nextTick, PropType, ref} from 'vue'
|
||||
import {Recipe, RecipeFlat, RecipeOverview} from "@/openapi";
|
||||
import ModelEditDialog from "@/components/dialogs/ModelEditDialog.vue";
|
||||
import RecipeShareDialog from "@/components/dialogs/RecipeShareDialog.vue";
|
||||
@@ -46,6 +46,8 @@ const props = defineProps({
|
||||
size: {type: String, default: 'medium'},
|
||||
})
|
||||
|
||||
const mealPlanDialog = ref(false)
|
||||
|
||||
function openPrintView() {
|
||||
print()
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
valueProp="id"
|
||||
label="name"
|
||||
:delay="300"
|
||||
append-to-body
|
||||
:searchable="true"
|
||||
:strict="false"
|
||||
:classes="{
|
||||
@@ -68,9 +67,7 @@ function addIngredient(amount: number, unit: Unit|null, food: Food|null) {
|
||||
food: food,
|
||||
} as ShoppingListEntry
|
||||
|
||||
console.log('adding SLR ? ', props.mealPlan)
|
||||
if (props.mealPlan) {
|
||||
console.log('yes')
|
||||
sle.mealplanId = props.mealPlan.id
|
||||
}
|
||||
|
||||
@@ -134,7 +131,6 @@ function search(query: string) {
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<style src="@vueform/multiselect/themes/default.css"></style>
|
||||
<!-- style can't be scoped (for whatever reason) -->
|
||||
<style>
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
<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.stepRecipeData" @update:modelValue="step.stepRecipe = (step.stepRecipeData != null) ? step.stepRecipeData.id! : null"></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"></model-select>
|
||||
@@ -60,6 +61,10 @@
|
||||
<div v-if="!mobile">
|
||||
<vue-draggable v-model="step.ingredients" handle=".drag-handle" :on-sort="sortIngredients" :empty-insert-threshold="25" group="ingredients">
|
||||
<v-row v-for="(ingredient, index) in step.ingredients" :key="ingredient.id" dense>
|
||||
<v-col cols="12" class="pa-0 ma-0 text-center text-disabled" v-if="ingredient.originalText">
|
||||
<v-icon icon="$import" size="x-small"></v-icon>
|
||||
{{ ingredient.originalText }}
|
||||
</v-col>
|
||||
<v-col cols="2" v-if="!ingredient.isHeader">
|
||||
<v-input hide-details>
|
||||
<template #prepend>
|
||||
@@ -99,26 +104,22 @@
|
||||
<v-list-item link>
|
||||
<v-switch v-model="step.ingredients[index].noAmount" :label="$t('Disable_Amount')" hide-details></v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item @click="editingIngredientIndex = index; dialogIngredientSorter = true" prepend-icon="fa-solid fa-sort">{{
|
||||
$t('Move')
|
||||
}}
|
||||
</v-list-item>
|
||||
<v-list-item v-if="ingredient.originalText" prepend-icon="$import">
|
||||
<v-list-item-title>{{ $t('Original_Text') }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ ingredient.originalText }}</v-list-item-subtitle>
|
||||
<v-list-item @click="editingIngredientIndex = index; dialogIngredientSorter = true" prepend-icon="fa-solid fa-sort">
|
||||
{{ $t('Move') }}
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-btn>
|
||||
|
||||
</v-col>
|
||||
|
||||
</v-row>
|
||||
</vue-draggable>
|
||||
</div>
|
||||
|
||||
<v-list v-if="mobile">
|
||||
<vue-draggable v-model="step.ingredients" handle=".drag-handle" :on-sort="sortIngredients" group="ingredients" empty-insert-threshold="25">
|
||||
<v-list-item v-for="(ingredient, index) in step.ingredients" :key="ingredient.id" border @click="editingIngredientIndex = index; dialogIngredientEditor = true">
|
||||
<v-list-item v-for="(ingredient, index) in step.ingredients" :key="ingredient.id" border
|
||||
@click="editingIngredientIndex = index; dialogIngredientEditor = true">
|
||||
<ingredient-string :ingredient="ingredient"></ingredient-string>
|
||||
<template #append>
|
||||
<v-icon icon="$dragHandle" class="drag-handle"></v-icon>
|
||||
@@ -305,6 +306,7 @@ function parseAndInsertIngredients() {
|
||||
r.forEach(i => {
|
||||
console.log(i)
|
||||
step.value.ingredients.push({
|
||||
originalText: i.value.originalText,
|
||||
amount: i.value.amount,
|
||||
food: i.value.food,
|
||||
unit: i.value.unit,
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {ApiApi, UserFile, UserFileFromJSON} from "@/openapi";
|
||||
import {onMounted, ref} from "vue";
|
||||
import {onMounted, ref, watch} from "vue";
|
||||
import {DateTime} from "luxon";
|
||||
import {ErrorMessageType, PreparedMessage, useMessageStore} from "@/stores/MessageStore";
|
||||
import {getCookie} from "@/utils/cookie";
|
||||
@@ -131,8 +131,13 @@ const tableHeaders = ref([
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
//TODO move to open function of file tab
|
||||
loadFiles()
|
||||
|
||||
})
|
||||
|
||||
watch(() => dialog.value, (value, oldValue) => {
|
||||
if (value && !oldValue) {
|
||||
loadFiles()
|
||||
}
|
||||
})
|
||||
|
||||
function loadFiles() {
|
||||
|
||||
105
vue3/src/components/model_editors/AiProviderEditor.vue
Normal file
105
vue3/src/components/model_editors/AiProviderEditor.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<model-editor-base
|
||||
:loading="loading"
|
||||
:dialog="dialog"
|
||||
@save="saveObject"
|
||||
@delete="deleteObject"
|
||||
@close="emit('close'); editingObjChanged = false"
|
||||
:is-update="isUpdate()"
|
||||
:is-changed="editingObjChanged"
|
||||
:model-class="modelClass"
|
||||
:object-name="editingObjName()">
|
||||
<v-card-text>
|
||||
<v-form :disabled="loading">
|
||||
<v-text-field :label="$t('Name')" v-model="editingObj.name"></v-text-field>
|
||||
<v-textarea :label="$t('Description')" v-model="editingObj.description"></v-textarea>
|
||||
|
||||
|
||||
<v-text-field :label="$t('APIKey')" v-model="editingObj.apiKey"></v-text-field>
|
||||
|
||||
<v-combobox :label="$t('Model')" :items="aiModels" v-model="editingObj.modelName" hide-details>
|
||||
|
||||
</v-combobox>
|
||||
|
||||
<p class="mt-2 mb-2">{{ $t('AiModelHelp') }} <a href="https://docs.litellm.ai/docs/providers" target="_blank">LiteLLM</a></p>
|
||||
|
||||
<v-checkbox :label="$t('LogCredits')" :hint="$t('LogCreditsHelp')" v-model="editingObj.logCreditCost" v-if="useUserPreferenceStore().userSettings.user.isSuperuser" persistent-hint
|
||||
class="mb-2"></v-checkbox>
|
||||
<v-text-field :label="$t('Url')" v-model="editingObj.url"></v-text-field>
|
||||
|
||||
<v-checkbox :label="$t('Global')" :hint="$t('GlobalHelp')" v-model="globalProvider" v-if="useUserPreferenceStore().userSettings.user.isSuperuser" persistent-hint
|
||||
class="mb-2"></v-checkbox>
|
||||
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</model-editor-base>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import {onMounted, PropType, ref, watch} from "vue";
|
||||
import {AiProvider} from "@/openapi";
|
||||
|
||||
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
|
||||
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore.ts";
|
||||
import editor from "mavon-editor";
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
item: {type: {} as PropType<AiProvider>, required: false, default: null},
|
||||
itemId: {type: [Number, String], required: false, default: undefined},
|
||||
itemDefaults: {type: {} as PropType<AiProvider>, required: false, default: {} as AiProvider},
|
||||
dialog: {type: Boolean, default: false}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
|
||||
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<AiProvider>('AiProvider', 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 aiModels = ref(['gemini/gemini-2.5-pro', 'gemini/gemini-2.5-flash', 'gemini/gemini-2.5-flash-lite', 'gpt-5', 'gpt-5-mini', 'gpt-5-nano'])
|
||||
|
||||
const globalProvider = ref(false)
|
||||
|
||||
watch(() => globalProvider.value, () => {
|
||||
if (globalProvider.value) {
|
||||
editingObj.value.space = undefined
|
||||
} else {
|
||||
editingObj.value.space = useUserPreferenceStore().activeSpace.id!
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initializeEditor()
|
||||
})
|
||||
|
||||
/**
|
||||
* component specific state setup logic
|
||||
*/
|
||||
function initializeEditor() {
|
||||
setupState(props.item, props.itemId, {
|
||||
itemDefaults: props.itemDefaults,
|
||||
newItemFunction: () => {
|
||||
editingObj.value.logCreditCost = true
|
||||
editingObj.value.space = useUserPreferenceStore().activeSpace.id!
|
||||
},
|
||||
}).then(() => {
|
||||
globalProvider.value = editingObj.value.space == undefined
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -12,11 +12,22 @@
|
||||
<v-card-text>
|
||||
<v-form :disabled="loading">
|
||||
|
||||
<v-textarea :label="$t('Comment')" rows="2" v-model="editingObj.comment"></v-textarea>
|
||||
<v-row dense>
|
||||
<v-col cols="12" md="4">
|
||||
<v-label>{{ $t('Rating') }}</v-label>
|
||||
<br/>
|
||||
<v-rating v-model="editingObj.rating" clearable hover density="compact"></v-rating>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
|
||||
<v-text-field :label="$t('Name')" v-model="editingObj.name"></v-text-field>
|
||||
<v-text-field :label="$t('Code')" v-model="editingObj.code"></v-text-field>
|
||||
<v-number-input :label="$t('Servings')" v-model="editingObj.servings" :precision="2"></v-number-input>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<v-date-input :label="$t('Date')" v-model="editingObj.createdAt"></v-date-input>
|
||||
|
||||
<v-textarea :label="$t('Comment')" v-model="editingObj.comment"></v-textarea>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
@@ -27,19 +38,20 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {onMounted, PropType, watch} from "vue";
|
||||
import {OpenDataFood, OpenDataVersion} from "@/openapi";
|
||||
import {CookLog} from "@/openapi";
|
||||
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
|
||||
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
item: {type: {} as PropType<OpenDataVersion>, required: false, default: null},
|
||||
item: {type: {} as PropType<CookLog>, required: false, default: null},
|
||||
itemId: {type: [Number, String], required: false, default: undefined},
|
||||
itemDefaults: {type: {} as PropType<OpenDataVersion>, required: false, default: {} as OpenDataVersion},
|
||||
itemDefaults: {type: {} as PropType<CookLog>, required: false, default: {} as CookLog},
|
||||
dialog: {type: Boolean, default: false}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['create', 'save', 'delete', 'close', 'changedState'])
|
||||
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<OpenDataVersion>('OpenDataVersion', emit)
|
||||
const {setupState, deleteObject, saveObject, isUpdate, editingObjName, loading, editingObj, editingObjChanged, modelClass} = useModelEditorFunctions<CookLog>('CookLog', emit)
|
||||
|
||||
/**
|
||||
* watch prop changes and re-initialize editor
|
||||
@@ -58,7 +70,7 @@ onMounted(() => {
|
||||
/**
|
||||
* component specific state setup logic
|
||||
*/
|
||||
function initializeEditor(){
|
||||
function initializeEditor() {
|
||||
setupState(props.item, props.itemId, {itemDefaults: props.itemDefaults})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user