mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-02-24 08:43:11 -05:00
Compare commits
372 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6de0d0b3d | ||
|
|
dce6d86cbf | ||
|
|
3539385429 | ||
|
|
e97f1f805b | ||
|
|
83edff1c78 | ||
|
|
efb72b1859 | ||
|
|
5afa611ec3 | ||
|
|
82cc9e11f7 | ||
|
|
3fc120236d | ||
|
|
e32bae4575 | ||
|
|
327da02fc8 | ||
|
|
c8cd68b4f0 | ||
|
|
f31b76e2ff | ||
|
|
426f91fb50 | ||
|
|
f194a6d8c8 | ||
|
|
6e4f9a234b | ||
|
|
76eccdff8c | ||
|
|
a7330f11e6 | ||
|
|
d993ddf600 | ||
|
|
54f994defc | ||
|
|
db4789099a | ||
|
|
172698afce | ||
|
|
8f9d602004 | ||
|
|
d3b574ea84 | ||
|
|
4f5a0bf9f5 | ||
|
|
d965ceaff6 | ||
|
|
bcd0fcc920 | ||
|
|
085c489b05 | ||
|
|
af46a6ce33 | ||
|
|
b1f81b4b95 | ||
|
|
622c1b11f5 | ||
|
|
7ada42a791 | ||
|
|
ea4adfa335 | ||
|
|
365d77e599 | ||
|
|
0ef8c52c6a | ||
|
|
d419acd61e | ||
|
|
65c35adc9d | ||
|
|
83b4846f0c | ||
|
|
6bc7ada20a | ||
|
|
8ce6f9038a | ||
|
|
e3c6d4c66c | ||
|
|
381a698220 | ||
|
|
c866557d58 | ||
|
|
bb5da2cb54 | ||
|
|
0fed5f54f6 | ||
|
|
f4bde93960 | ||
|
|
62300deea0 | ||
|
|
87f4b23711 | ||
|
|
8983745106 | ||
|
|
8872fd52cd | ||
|
|
b81b97d934 | ||
|
|
f798fafb3e | ||
|
|
dbbbe06a23 | ||
|
|
4b9eb5077a | ||
|
|
ff6db2374d | ||
|
|
3e69ea94d5 | ||
|
|
2e114cfa69 | ||
|
|
eb34ef0156 | ||
|
|
446755f678 | ||
|
|
08fe2d32b0 | ||
|
|
fb653ee2f6 | ||
|
|
a326a8c717 | ||
|
|
6e7cb5fb86 | ||
|
|
9289bd8e05 | ||
|
|
985b5634b7 | ||
|
|
2b2bc041bd | ||
|
|
6e16d4cc91 | ||
|
|
53a566d08a | ||
|
|
fb0a747549 | ||
|
|
6e045bf0c3 | ||
|
|
8d1ce5c190 | ||
|
|
3bf6840cbc | ||
|
|
0053f76531 | ||
|
|
05ac18f00b | ||
|
|
8b6c75877d | ||
|
|
0e25c7485d | ||
|
|
ea0d2ece6a | ||
|
|
f7e595b404 | ||
|
|
d48320f0a5 | ||
|
|
2240ab01d2 | ||
|
|
ae9276b55c | ||
|
|
b5643a9399 | ||
|
|
702180aeda | ||
|
|
458d2bb61b | ||
|
|
5a83f55a00 | ||
|
|
04ef4037b7 | ||
|
|
fdb5ff9ec0 | ||
|
|
302002d630 | ||
|
|
2305438423 | ||
|
|
34bd4a74c2 | ||
|
|
dacd0acff6 | ||
|
|
010c6d8eb2 | ||
|
|
3eac3e6648 | ||
|
|
3dd61f7742 | ||
|
|
99fec90288 | ||
|
|
d05f27dfe5 | ||
|
|
4c84f48e81 | ||
|
|
441b51a6e7 | ||
|
|
bf2a69735d | ||
|
|
61511d17d3 | ||
|
|
248e560a5c | ||
|
|
09beac24c8 | ||
|
|
a7c8b33cca | ||
|
|
674ad237f1 | ||
|
|
8ecddb07ae | ||
|
|
b86c01e405 | ||
|
|
124301823c | ||
|
|
c784a64c44 | ||
|
|
e79aeb9e8c | ||
|
|
85fe770be0 | ||
|
|
6f157b60a5 | ||
|
|
1e6bbc6699 | ||
|
|
543a53cab4 | ||
|
|
a17529bd71 | ||
|
|
1dc7b24146 | ||
|
|
60ea83d737 | ||
|
|
8180aefc0b | ||
|
|
007c99c77a | ||
|
|
292ff8313b | ||
|
|
79b3308f15 | ||
|
|
56a557fc82 | ||
|
|
b8e62ab8dd | ||
|
|
2a9a6fa5e6 | ||
|
|
79b36024a4 | ||
|
|
c40d2d0486 | ||
|
|
8da08cdd60 | ||
|
|
e47d171463 | ||
|
|
80caa5ffaf | ||
|
|
03485ecc73 | ||
|
|
b3ad32ee31 | ||
|
|
6d89fe37ad | ||
|
|
1af2473a72 | ||
|
|
77de9fee98 | ||
|
|
cba381cb67 | ||
|
|
02791e294d | ||
|
|
7f396ab483 | ||
|
|
0e299e98ee | ||
|
|
4e839711eb | ||
|
|
02c0fe993b | ||
|
|
3aea229f2d | ||
|
|
d08b3d8943 | ||
|
|
91353d6d7e | ||
|
|
d12d3d12ef | ||
|
|
e06572b7ca | ||
|
|
a2bdb02a7f | ||
|
|
433336b8b4 | ||
|
|
d50d4bee08 | ||
|
|
445f55812b | ||
|
|
0e247f22f8 | ||
|
|
4e4bc1b542 | ||
|
|
8379e3565f | ||
|
|
d36041fa75 | ||
|
|
655122c390 | ||
|
|
07e70b419d | ||
|
|
b4ebe4f9a6 | ||
|
|
172160c862 | ||
|
|
01a43f28f2 | ||
|
|
00baa397dd | ||
|
|
a2beab6cbb | ||
|
|
56bd68d824 | ||
|
|
a559335bd7 | ||
|
|
b0ed242ff2 | ||
|
|
b5c0104aba | ||
|
|
5ed0ec029b | ||
|
|
4f1abcf4a3 | ||
|
|
7140dcb188 | ||
|
|
8f229b0dde | ||
|
|
113347a6e5 | ||
|
|
c904f2d818 | ||
|
|
49fe6a6057 | ||
|
|
fef3f1cee3 | ||
|
|
fa32a6489c | ||
|
|
3d1b08779b | ||
|
|
f1d56cad9c | ||
|
|
14dbd79c7f | ||
|
|
1bd3d38dfc | ||
|
|
49a392f4e2 | ||
|
|
4c1d855690 | ||
|
|
edf420491f | ||
|
|
75bbe34ce5 | ||
|
|
743d52ef81 | ||
|
|
e125d1a45a | ||
|
|
df359a58a4 | ||
|
|
73fafa9fb3 | ||
|
|
28b0190648 | ||
|
|
8fe1b0c123 | ||
|
|
30cf37effa | ||
|
|
cd305cd47d | ||
|
|
a9dcb1538a | ||
|
|
30699ac4cd | ||
|
|
4712994242 | ||
|
|
38502e82d4 | ||
|
|
f8cd8b00a5 | ||
|
|
8f2f4d45af | ||
|
|
291808b204 | ||
|
|
75166b5b0f | ||
|
|
f7608071d8 | ||
|
|
64a1a652ca | ||
|
|
2053f29ff8 | ||
|
|
82737613b4 | ||
|
|
04dc593b16 | ||
|
|
d1e3b64a19 | ||
|
|
7922e4d2c1 | ||
|
|
f393c05d6d | ||
|
|
eb640ac270 | ||
|
|
02a36509b6 | ||
|
|
1d4ff66824 | ||
|
|
13522a0402 | ||
|
|
ea1f727a8b | ||
|
|
7c274de778 | ||
|
|
2a6922a85c | ||
|
|
c4f753ee32 | ||
|
|
c774f626ee | ||
|
|
abc1174877 | ||
|
|
f7a1ef597a | ||
|
|
12938f9cd5 | ||
|
|
7f4e958198 | ||
|
|
67791e4d86 | ||
|
|
ba363da251 | ||
|
|
e971efd809 | ||
|
|
09b688cc22 | ||
|
|
22edec4d9a | ||
|
|
821766a6ae | ||
|
|
96a2ccb5e4 | ||
|
|
d41273592a | ||
|
|
64273144d9 | ||
|
|
edb43a80a1 | ||
|
|
f736423fff | ||
|
|
fd0257c1b8 | ||
|
|
b1820f9b23 | ||
|
|
cff33cb15c | ||
|
|
8778559a20 | ||
|
|
77208384ed | ||
|
|
dbbd662e7d | ||
|
|
abe4504640 | ||
|
|
d8dbcac196 | ||
|
|
8bd26d2230 | ||
|
|
400d251381 | ||
|
|
7088bea90a | ||
|
|
0eb3e3f7ca | ||
|
|
6f1df3a95e | ||
|
|
c97053ef83 | ||
|
|
f796258529 | ||
|
|
f6cf58334f | ||
|
|
dda6f297d8 | ||
|
|
97c3135a43 | ||
|
|
871160cb42 | ||
|
|
e77b9e972f | ||
|
|
e1f382f8ba | ||
|
|
baf78573f7 | ||
|
|
e579017e6d | ||
|
|
589b226360 | ||
|
|
98ac9cd290 | ||
|
|
6253fdb3db | ||
|
|
7ff532429f | ||
|
|
a14a4bcf89 | ||
|
|
12a4b16bde | ||
|
|
98f91351d5 | ||
|
|
6a4a88371f | ||
|
|
fc749f520a | ||
|
|
5b3be18fe2 | ||
|
|
0aaa40432d | ||
|
|
6ecdb39a2d | ||
|
|
29c5944d92 | ||
|
|
68ec3f7e42 | ||
|
|
59e5094669 | ||
|
|
ed5cd2a0c5 | ||
|
|
bb2badc526 | ||
|
|
41df1d67e0 | ||
|
|
4d3edbfccb | ||
|
|
979a977d77 | ||
|
|
b2c8585ec5 | ||
|
|
70d5f1a918 | ||
|
|
82169fc316 | ||
|
|
c2fb4d3fa7 | ||
|
|
4734fae891 | ||
|
|
787b826aa1 | ||
|
|
22cdb7305b | ||
|
|
107fc138fd | ||
|
|
e9285881f0 | ||
|
|
e0b5d76278 | ||
|
|
9acf9ec27c | ||
|
|
7ef2e91ecf | ||
|
|
220c383ccb | ||
|
|
a3f474e088 | ||
|
|
2ad6e1b198 | ||
|
|
8b6d8e60eb | ||
|
|
9e392cbdad | ||
|
|
29f21a0cd8 | ||
|
|
22e9c8b462 | ||
|
|
eae474d9b9 | ||
|
|
296a1a036c | ||
|
|
0ca1b6e57c | ||
|
|
9636fc82f6 | ||
|
|
e4aeb01acb | ||
|
|
4c6357e8dc | ||
|
|
91a7e09569 | ||
|
|
2c2c07feb2 | ||
|
|
c49610ec74 | ||
|
|
587002c523 | ||
|
|
7466e5d7f5 | ||
|
|
795f5ea4f1 | ||
|
|
9b6f323b6f | ||
|
|
c688114e15 | ||
|
|
042ac6bfa5 | ||
|
|
67dc0d7066 | ||
|
|
22057cad19 | ||
|
|
ce13242f61 | ||
|
|
7dd66a52d5 | ||
|
|
6ed2b99188 | ||
|
|
29f88eade0 | ||
|
|
a63cdf8534 | ||
|
|
eb170cc7e5 | ||
|
|
0c29cef17d | ||
|
|
f86d4d5d8d | ||
|
|
c721533557 | ||
|
|
0d06494bbf | ||
|
|
9c04950948 | ||
|
|
34c37a2bee | ||
|
|
2cd1e0ad37 | ||
|
|
828afe6674 | ||
|
|
6a705b7352 | ||
|
|
3fa931466e | ||
|
|
4ba8269ff1 | ||
|
|
581310b57d | ||
|
|
ace18ab4aa | ||
|
|
abae973454 | ||
|
|
08bc29ca8a | ||
|
|
9d7ef1837b | ||
|
|
8a15f400e1 | ||
|
|
3d921cb677 | ||
|
|
f0e065efa4 | ||
|
|
d06589b31b | ||
|
|
3405bc4eb6 | ||
|
|
a75eb07a47 | ||
|
|
432914e310 | ||
|
|
65ece35966 | ||
|
|
f11af52d30 | ||
|
|
b4da5c3d5a | ||
|
|
a4e416cabc | ||
|
|
26173704aa | ||
|
|
5876c1ecf7 | ||
|
|
c4a339ed36 | ||
|
|
2967eca819 | ||
|
|
66b19eecfb | ||
|
|
37d93d4e4b | ||
|
|
656d46e9cb | ||
|
|
169d659b72 | ||
|
|
f92a9afbe5 | ||
|
|
d53f81cdfb | ||
|
|
bd4f858ba7 | ||
|
|
ce3a95f38a | ||
|
|
e3ffa03ffd | ||
|
|
79910deb8a | ||
|
|
e0c532ab94 | ||
|
|
73c09ab138 | ||
|
|
7edf0ee3cc | ||
|
|
48381fe897 | ||
|
|
e68300037c | ||
|
|
c643f24a72 | ||
|
|
7820ddc8f7 | ||
|
|
ca3cb2447c | ||
|
|
a55fdb634d | ||
|
|
8b0c607712 | ||
|
|
8c990a5dd2 | ||
|
|
abf5cf0116 | ||
|
|
84069bf9df | ||
|
|
059e5b7ea2 | ||
|
|
3677d04b56 | ||
|
|
05c034fca2 | ||
|
|
17d1cd26dc | ||
|
|
c9bbae6f77 |
@@ -25,6 +25,7 @@
|
||||
"python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8",
|
||||
"python.formatting.blackPath": "/usr/local/py-utils/bin/black",
|
||||
"python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf",
|
||||
"mypy.runUsingActiveInterpreter": true
|
||||
},
|
||||
"extensions": [
|
||||
"charliermarsh.ruff",
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/task.yaml
vendored
2
.github/ISSUE_TEMPLATE/task.yaml
vendored
@@ -4,7 +4,7 @@ description: "CONTRIBUTORS ONLY: Submit a Task that needs to be completed"
|
||||
title: "[Task] - TASK DESCRIPTION"
|
||||
labels:
|
||||
- task
|
||||
- v1
|
||||
- v2
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
8
.github/pull_request_template.md
vendored
8
.github/pull_request_template.md
vendored
@@ -6,6 +6,7 @@
|
||||
- `fix: `
|
||||
- `docs: `
|
||||
- `chore: `
|
||||
- `dev:`
|
||||
|
||||
If a section of the PR template does not apply to this PR, then delete that section.
|
||||
|
||||
@@ -27,10 +28,11 @@ _(REQUIRED)_
|
||||
Delete any of the following that do not apply:
|
||||
-->
|
||||
|
||||
- bug
|
||||
- cleanup
|
||||
- documentation
|
||||
- feature
|
||||
- bug
|
||||
- documentation
|
||||
- cleanup
|
||||
- dev (Internal development)
|
||||
|
||||
## What this PR does / why we need it:
|
||||
|
||||
|
||||
88
.github/release-drafter.yml
vendored
Normal file
88
.github/release-drafter.yml
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
name-template: "v$RESOLVED_VERSION"
|
||||
tag-template: "v$RESOLVED_VERSION"
|
||||
change-template: "- $TITLE @$AUTHOR (#$NUMBER)"
|
||||
sort-direction: ascending
|
||||
|
||||
categories:
|
||||
- title: "🚨 Breaking changes"
|
||||
labels:
|
||||
- "breaking-change"
|
||||
- "major"
|
||||
- title: "✨ New features"
|
||||
labels:
|
||||
- "feature"
|
||||
- "minor"
|
||||
- title: "🐛 Bug fixes"
|
||||
labels:
|
||||
- "bugfix"
|
||||
- title: "🧰 Maintenance"
|
||||
collapse-after: 3
|
||||
labels:
|
||||
- "ci"
|
||||
- "chore"
|
||||
- "l10n"
|
||||
- title: "📚 Documentation"
|
||||
labels:
|
||||
- "documentation"
|
||||
- title: "🔨 Internal development"
|
||||
labels:
|
||||
- "dev"
|
||||
- title: "⬆️ Dependency updates"
|
||||
collapse-after: 3
|
||||
labels:
|
||||
- "dependencies"
|
||||
|
||||
version-resolver:
|
||||
major:
|
||||
labels:
|
||||
- "major"
|
||||
- "breaking-change"
|
||||
minor:
|
||||
labels:
|
||||
- "minor"
|
||||
- "feature"
|
||||
patch:
|
||||
labels:
|
||||
- "bugfix"
|
||||
- "chore"
|
||||
- "ci"
|
||||
- "dependencies"
|
||||
- "documentation"
|
||||
- "l10n"
|
||||
- "dev"
|
||||
default: patch
|
||||
|
||||
template: |
|
||||
# 🍴🍴🍴🍴🍴🍴
|
||||
|
||||
## 🎉 Highlights
|
||||
|
||||
- Highlight 1
|
||||
|
||||
- Highlight 2
|
||||
|
||||
$CHANGES
|
||||
|
||||
## 🙏 New Contributors
|
||||
|
||||
!!! Need to source this from GitHub's auto generated release notes !!!
|
||||
|
||||
# 🍴🍴🍴🍴🍴🍴
|
||||
|
||||
autolabeler:
|
||||
- label: 'feature'
|
||||
title:
|
||||
- '/feat/i'
|
||||
- label: 'bugfix'
|
||||
title:
|
||||
- '/fix:/i'
|
||||
- label: 'documentation'
|
||||
title:
|
||||
- '/docs:/i'
|
||||
- label: 'chore'
|
||||
title:
|
||||
- '/chore:/i'
|
||||
- label: 'dev'
|
||||
title:
|
||||
- '/dev:/i'
|
||||
5
.github/workflows/codeql.yml
vendored
5
.github/workflows/codeql.yml
vendored
@@ -14,10 +14,9 @@ name: "CodeQL"
|
||||
on:
|
||||
push:
|
||||
branches: [ "mealie-next" ]
|
||||
pull_request:
|
||||
branches: [ "mealie-next" ]
|
||||
schedule:
|
||||
- cron: '36 9 * * 3'
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
@@ -45,7 +44,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
||||
9
.github/workflows/e2e.yml
vendored
9
.github/workflows/e2e.yml
vendored
@@ -1,8 +1,7 @@
|
||||
name: E2E Tests
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- mealie-next
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
@@ -11,8 +10,8 @@ jobs:
|
||||
run:
|
||||
working-directory: ./tests/e2e
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
cache: 'yarn'
|
||||
|
||||
6
.github/workflows/partial-backend.yml
vendored
6
.github/workflows/partial-backend.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
# Steps
|
||||
steps:
|
||||
- name: Install Task
|
||||
uses: arduino/setup-task@v1
|
||||
uses: arduino/setup-task@v2
|
||||
with:
|
||||
version: 3.x
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
- name: Load cached venv
|
||||
id: cached-poetry-dependencies
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
|
||||
|
||||
4
.github/workflows/partial-frontend.yml
vendored
4
.github/workflows/partial-frontend.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache node_modules 📦
|
||||
uses: actions/cache@v3.3.2
|
||||
uses: actions/cache@v4
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache node_modules 📦
|
||||
uses: actions/cache@v3.3.2
|
||||
uses: actions/cache@v4
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
|
||||
@@ -24,8 +24,10 @@ jobs:
|
||||
image-ref: "mealie"
|
||||
format: "sarif"
|
||||
output: "trivy-results.sarif"
|
||||
env:
|
||||
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db,public.ecr.aws/aquasecurity/trivy-db
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
uses: github/codeql-action/upload-sarif@v2
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: "trivy-results.sarif"
|
||||
|
||||
3
.github/workflows/pull-request-lint.yml
vendored
3
.github/workflows/pull-request-lint.yml
vendored
@@ -20,10 +20,11 @@ jobs:
|
||||
# Configure which types are allowed (newline-delimited).
|
||||
# Default: https://github.com/commitizen/conventional-commit-types
|
||||
types: |
|
||||
fix
|
||||
feat
|
||||
fix
|
||||
docs
|
||||
chore
|
||||
dev
|
||||
# Configure which scopes are allowed (newline-delimited).
|
||||
# These are regex patterns auto-wrapped in `^ $`.
|
||||
scopes: |
|
||||
|
||||
16
.github/workflows/pull-requests.yml
vendored
16
.github/workflows/pull-requests.yml
vendored
@@ -5,6 +5,10 @@ on:
|
||||
branches:
|
||||
- mealie-next
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pull-request-lint:
|
||||
name: "Lint PR"
|
||||
@@ -21,3 +25,15 @@ jobs:
|
||||
container-scanning:
|
||||
name: "Trivy Container Scanning"
|
||||
uses: ./.github/workflows/partial-trivy-container-scanning.yml
|
||||
|
||||
end-to-end:
|
||||
name: "End-to-End Tests"
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
|
||||
code-ql:
|
||||
name: "CodeQL"
|
||||
uses: ./.github/workflows/codeql.yml
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
30
.github/workflows/release-drafter.yml
vendored
Normal file
30
.github/workflows/release-drafter.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: Release Drafter
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- mealie-next
|
||||
# pull_request event is required for autolabeler
|
||||
pull_request:
|
||||
types: [opened, labeled, unlabeled, reopened, synchronize]
|
||||
# pull_request_target event is required for autolabeler to support PRs from forks
|
||||
pull_request_target:
|
||||
types: [opened, labeled, unlabeled, reopened, synchronize]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update_release_draft:
|
||||
permissions:
|
||||
# write permission is required to create a github release
|
||||
contents: write
|
||||
# write permission is required for autolabeler
|
||||
# otherwise, read permission is required at least
|
||||
pull-requests: write
|
||||
name: ✏️ Draft release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 🚀 Run Release Drafter
|
||||
uses: release-drafter/release-drafter@v6.0.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -66,6 +66,7 @@ jobs:
|
||||
|
||||
- name: Modify version strings
|
||||
run: |
|
||||
sed -i 's/:v[0-9]*.[0-9]*.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/installation-checklist.md
|
||||
sed -i 's/:v[0-9]*.[0-9]*.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/sqlite.md
|
||||
sed -i 's/:v[0-9]*.[0-9]*.[0-9]*/:v${{ env.VERSION_NUM }}/' docs/docs/documentation/getting-started/installation/postgres.md
|
||||
sed -i 's/^version = "[^"]*"/version = "${{ env.VERSION_NUM }}"/' pyproject.toml
|
||||
@@ -79,6 +80,8 @@ jobs:
|
||||
with:
|
||||
commit-message: "Update image tag, for release ${{ github.event.release.tag_name }}"
|
||||
branch: "docs/newrelease-update-version-${{ github.event.release.tag_name }}"
|
||||
labels: |
|
||||
documentation
|
||||
delete-branch: true
|
||||
base: mealie-next
|
||||
title: "docs(auto): Update image tag, for release ${{ github.event.release.tag_name }}"
|
||||
|
||||
30
.github/workflows/scheduled-checks.yml
vendored
30
.github/workflows/scheduled-checks.yml
vendored
@@ -15,8 +15,30 @@ jobs:
|
||||
- name: Checkout 🛎
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Update pre-commit Hooks
|
||||
uses: vrslev/pre-commit-autoupdate@v1.0.0
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Set PY
|
||||
shell: bash
|
||||
run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pre-commit
|
||||
~/.cache/pip
|
||||
key: pre-commit-${{ env.PY }}-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
|
||||
- name: Install pre-commit
|
||||
shell: bash
|
||||
run: pip install -U pre-commit
|
||||
|
||||
- name: Run `pre-commit autoupdate`
|
||||
shell: bash
|
||||
run: pre-commit autoupdate --color=always
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
@@ -26,7 +48,9 @@ jobs:
|
||||
with:
|
||||
commit-message: "Update pre-commit hooks"
|
||||
branch: "fix/update-pre-commit-hooks"
|
||||
labels: |
|
||||
chore
|
||||
delete-branch: true
|
||||
base: mealie-next
|
||||
title: "fix(auto): Update pre-commit hooks"
|
||||
title: "chore(auto): Update pre-commit hooks"
|
||||
body: "Auto-generated by `.github/workflows/scheduled-checks.yml`"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
exclude: "mkdocs.yml"
|
||||
@@ -12,6 +12,7 @@ repos:
|
||||
exclude: ^tests/data/
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.5.5
|
||||
rev: v0.8.0
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
|
||||
@@ -90,7 +90,7 @@ Thanks to Depot for providing build instances for our Docker image builds.
|
||||
[contributors-shield]: https://img.shields.io/github/contributors/mealie-recipes/mealie.svg?style=flat-square
|
||||
[docker-pull]: https://img.shields.io/docker/pulls/hkotel/mealie?style=flat-square
|
||||
[docker-url]: https://hub.docker.com/r/hkotel/mealie
|
||||
[ghcr-pulls]: https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fipitio%2Fghcr-pulls%2Fmaster%2Findex.json&query=%24%5B%3F(%40.owner%3D%3D%22mealie-recipes%22%20%26%26%20%40.repo%3D%3D%22mealie%22%20%26%26%20%40.image%3D%3D%22mealie%22)%5D.pulls&style=flat-square&label=ghcr%20pulls
|
||||
[ghcr-pulls]: https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fipitio.github.io%2Fbackage%2Fmealie-recipes%2Fmealie%2Fmealie.json&query=%24.downloads&style=flat-square&label=ghcr%20pulls
|
||||
[ghcr-url]: https://github.com/mealie-recipes/mealie/pkgs/container/mealie
|
||||
[contributors-url]: https://github.com/mealie-recipes/mealie/graphs/contributors
|
||||
[stars-shield]: https://img.shields.io/github/stars/mealie-recipes/mealie.svg?style=flat-square
|
||||
|
||||
@@ -5,6 +5,7 @@ vars:
|
||||
GREETING: Hello, World!
|
||||
env:
|
||||
DEFAULT_GROUP: Home
|
||||
DEFAULT_HOUSEHOLD: Family
|
||||
PRODUCTION: false
|
||||
API_PORT: 9000
|
||||
API_DOCS: True
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
import mealie.db.models._all_models # noqa: F401
|
||||
from alembic import context
|
||||
@@ -29,6 +31,28 @@ if not settings.DB_URL:
|
||||
config.set_main_option("sqlalchemy.url", settings.DB_URL.replace("%", "%%"))
|
||||
|
||||
|
||||
def include_object(object: Any, name: str, type_: str, reflected: bool, compare_to: Any):
|
||||
# skip dropping food/unit unique constraints; they are defined manually so alembic doesn't see them
|
||||
# see: revision dded3119c1fe
|
||||
if type_ == "unique_constraint" and name == "ingredient_foods_name_group_id_key" and compare_to is None:
|
||||
return False
|
||||
if type_ == "unique_constraint" and name == "ingredient_units_name_group_id_key" and compare_to is None:
|
||||
return False
|
||||
|
||||
# skip changing the quantity column in recipes_ingredients; it's a float on postgres, but an integer on sqlite
|
||||
# see: revision 263dd6707191
|
||||
if (
|
||||
type_ == "column"
|
||||
and name == "quantity"
|
||||
and object.table.name == "recipes_ingredients"
|
||||
and hasattr(compare_to, "type")
|
||||
and isinstance(compare_to.type, sa.Integer)
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
@@ -60,15 +84,19 @@ def run_migrations_online():
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
connectable = sa.engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
poolclass=sa.pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata, user_module_prefix="mealie.db.migration_types."
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
user_module_prefix="mealie.db.migration_types.",
|
||||
render_as_batch=True,
|
||||
include_object=include_object,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
|
||||
@@ -9,13 +9,15 @@ import sqlalchemy as sa
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
${imports if imports else ""}
|
||||
% if imports:
|
||||
${imports}
|
||||
% endif
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
down_revision: str | None = ${repr(down_revision)}
|
||||
branch_labels: str | tuple[str, ...] | None = ${repr(branch_labels)}
|
||||
depends_on: str | tuple[str, ...] | None = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -14,9 +14,9 @@ from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "6b0f5f32d602"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
# Adapted from https://improveandrepeat.com/2021/09/python-friday-87-handling-pre-existing-tables-with-alembic-and-sqlalchemy/
|
||||
|
||||
@@ -13,8 +13,8 @@ from alembic import op
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "263dd6707191"
|
||||
down_revision = "6b0f5f32d602"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def is_postgres():
|
||||
|
||||
@@ -13,8 +13,8 @@ from alembic import op
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f1a2dbee5fe9"
|
||||
down_revision = "263dd6707191"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -14,8 +14,8 @@ from alembic import op
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "59eb59135381"
|
||||
down_revision = "f1a2dbee5fe9"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -14,8 +14,8 @@ from alembic import op
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "09dfc897ad62"
|
||||
down_revision = "59eb59135381"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -13,8 +13,8 @@ from alembic import op
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "ab0bae02578f"
|
||||
down_revision = "09dfc897ad62"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -14,8 +14,8 @@ from alembic import op
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "f30cf048c228"
|
||||
down_revision = "ab0bae02578f"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -13,8 +13,8 @@ from alembic import op
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "188374910655"
|
||||
down_revision = "f30cf048c228"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -13,8 +13,8 @@ from alembic import op
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "089bfa50d0ed"
|
||||
down_revision = "188374910655"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -14,8 +14,8 @@ from alembic import op
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "44e8d670719d"
|
||||
down_revision = "089bfa50d0ed"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -14,8 +14,8 @@ from alembic import op
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "2ea7a807915c"
|
||||
down_revision = "44e8d670719d"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -13,8 +13,8 @@ from alembic import op
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "1923519381ad"
|
||||
down_revision = "2ea7a807915c"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -13,8 +13,8 @@ from alembic import op
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "167eb69066ad"
|
||||
down_revision = "1923519381ad"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -14,8 +14,8 @@ from alembic import op
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "165d943c64ee"
|
||||
down_revision = "167eb69066ad"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -11,8 +11,8 @@ from alembic import op
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "ff5f73b01a7a"
|
||||
down_revision = "165d943c64ee"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -6,16 +6,13 @@ Create Date: 2023-02-10 21:18:32.405130
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "16160bf731a0"
|
||||
down_revision = "ff5f73b01a7a"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -11,15 +11,14 @@ from sqlalchemy import orm, select
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
from text_unidecode import unidecode
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "5ab195a474eb"
|
||||
down_revision = "16160bf731a0"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
class SqlAlchemyBase(DeclarativeBase):
|
||||
|
||||
@@ -13,14 +13,32 @@ from sqlalchemy import orm
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
from mealie.db.models.group.shopping_list import ShoppingList
|
||||
from mealie.db.models.labels import MultiPurposeLabel
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b04a08da2108"
|
||||
down_revision = "5ab195a474eb"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
# Intermediate table definitions
|
||||
class SqlAlchemyBase(orm.DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class ShoppingList(SqlAlchemyBase):
|
||||
__tablename__ = "shopping_lists"
|
||||
|
||||
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
|
||||
|
||||
|
||||
class MultiPurposeLabel(SqlAlchemyBase):
|
||||
__tablename__ = "multi_purpose_labels"
|
||||
|
||||
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
|
||||
|
||||
|
||||
def populate_shopping_lists_multi_purpose_labels(
|
||||
|
||||
@@ -8,14 +8,13 @@ Create Date: 2023-02-22 21:45:52.900964
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "38514b39a824"
|
||||
down_revision = "b04a08da2108"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def is_postgres():
|
||||
|
||||
@@ -6,18 +6,13 @@ Create Date: 2023-04-13 06:47:04.617131
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
import alembic.context as context
|
||||
from mealie.core.config import get_app_settings
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b3dbb554ba53"
|
||||
down_revision = "38514b39a824"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def get_db_type():
|
||||
|
||||
@@ -16,8 +16,8 @@ from mealie.db.models.group.group import Group
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "04ac51cbe9a4"
|
||||
down_revision = "b3dbb554ba53"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def populate_group_slugs(session: Session):
|
||||
|
||||
@@ -13,8 +13,8 @@ from alembic import op
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "1825b5225403"
|
||||
down_revision = "04ac51cbe9a4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -11,8 +11,8 @@ from alembic import op
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "bcfdad6b7355"
|
||||
down_revision = "1825b5225403"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -15,8 +15,8 @@ from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUn
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0341b154f79a"
|
||||
down_revision = "bcfdad6b7355"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def populate_normalized_fields():
|
||||
|
||||
@@ -12,19 +12,40 @@ from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
from pydantic import UUID4
|
||||
from sqlalchemy import orm
|
||||
from sqlalchemy.orm import Session, load_only
|
||||
|
||||
from alembic import op
|
||||
from mealie.db.models._model_base import SqlAlchemyBase
|
||||
from mealie.db.models.group.shopping_list import ShoppingListItem
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
from mealie.db.models.labels import MultiPurposeLabel
|
||||
from mealie.db.models.recipe.ingredient import IngredientFoodModel, IngredientUnitModel, RecipeIngredientModel
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "dded3119c1fe"
|
||||
down_revision = "0341b154f79a"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
# Intermediate table definitions
|
||||
class SqlAlchemyBase(orm.DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class ShoppingList(SqlAlchemyBase):
|
||||
__tablename__ = "shopping_lists"
|
||||
|
||||
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
group_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("groups.id"), nullable=False, index=True)
|
||||
|
||||
|
||||
class ShoppingListItem(SqlAlchemyBase):
|
||||
__tablename__ = "shopping_list_items"
|
||||
|
||||
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
food_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("ingredient_foods.id"))
|
||||
unit_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("ingredient_units.id"))
|
||||
label_id: orm.Mapped[GUID] = orm.mapped_column(GUID, sa.ForeignKey("multi_purpose_labels.id"))
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -42,7 +63,7 @@ def _is_postgres():
|
||||
return op.get_context().dialect.name == "postgresql"
|
||||
|
||||
|
||||
def _get_duplicates(session: Session, model: SqlAlchemyBase) -> defaultdict[str, list]:
|
||||
def _get_duplicates(session: Session, model: orm.DeclarativeBase) -> defaultdict[str, list]:
|
||||
duplicate_map: defaultdict[str, list] = defaultdict(list)
|
||||
|
||||
query = session.execute(sa.text(f"SELECT id, group_id, name FROM {model.__tablename__}"))
|
||||
|
||||
@@ -14,8 +14,8 @@ from alembic import op
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "ba1e4a6cfe99"
|
||||
down_revision = "dded3119c1fe"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -20,8 +20,8 @@ logger = get_logger()
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "2298bb460ffd"
|
||||
down_revision = "ba1e4a6cfe99"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def is_postgres():
|
||||
@@ -66,7 +66,7 @@ def populate_shopping_list_users():
|
||||
user_id = find_user_id_for_group(group_id)
|
||||
if user_id:
|
||||
session.execute(
|
||||
sa.text(f"UPDATE shopping_lists SET user_id=:user_id WHERE id=:id").bindparams(
|
||||
sa.text("UPDATE shopping_lists SET user_id=:user_id WHERE id=:id").bindparams(
|
||||
user_id=user_id, id=list_id
|
||||
)
|
||||
)
|
||||
@@ -74,7 +74,7 @@ def populate_shopping_list_users():
|
||||
logger.warning(
|
||||
f"No user found for shopping list {list_id} with group {group_id}; deleting shopping list"
|
||||
)
|
||||
session.execute(sa.text(f"DELETE FROM shopping_lists WHERE id=:id").bindparams(id=list_id))
|
||||
session.execute(sa.text("DELETE FROM shopping_lists WHERE id=:id").bindparams(id=list_id))
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -6,16 +6,13 @@ Create Date: 2024-03-10 05:08:32.397027
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "09aba125b57a"
|
||||
down_revision = "2298bb460ffd"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def is_postgres():
|
||||
|
||||
@@ -20,8 +20,8 @@ from alembic import op
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "d7c6efd2de42"
|
||||
down_revision = "09aba125b57a"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def is_postgres():
|
||||
@@ -32,7 +32,7 @@ def new_user_rating(user_id: Any, recipe_id: Any, rating: float | None = None, i
|
||||
if is_postgres():
|
||||
id = str(uuid4())
|
||||
else:
|
||||
id = "%.32x" % uuid4().int
|
||||
id = "%.32x" % uuid4().int # noqa: UP031
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
return {
|
||||
@@ -202,8 +202,6 @@ def downgrade():
|
||||
)
|
||||
op.drop_index(op.f("ix_recipes_rating"), table_name="recipes")
|
||||
op.alter_column("recipes", "rating", existing_type=sa.Float(), type_=sa.INTEGER(), existing_nullable=True)
|
||||
op.create_unique_constraint("ingredient_units_name_group_id_key", "ingredient_units", ["name", "group_id"])
|
||||
op.create_unique_constraint("ingredient_foods_name_group_id_key", "ingredient_foods", ["name", "group_id"])
|
||||
op.create_table(
|
||||
"users_to_favorites",
|
||||
sa.Column("user_id", sa.CHAR(length=32), nullable=True),
|
||||
|
||||
@@ -14,8 +14,8 @@ from alembic import op
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "7788478a0338"
|
||||
down_revision = "d7c6efd2de42"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
|
||||
@@ -11,12 +11,11 @@ from sqlalchemy import orm
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "32d69327997b"
|
||||
down_revision = "7788478a0338"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def is_postgres():
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""add households
|
||||
|
||||
Revision ID: feecc8ffb956
|
||||
Revises: 32d69327997b
|
||||
Create Date: 2024-07-12 16:16:29.973929
|
||||
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import sqlalchemy as sa
|
||||
from slugify import slugify
|
||||
from sqlalchemy import orm
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
from mealie.core.config import get_app_settings
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "feecc8ffb956"
|
||||
down_revision = "32d69327997b"
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
settings = get_app_settings()
|
||||
|
||||
|
||||
def is_postgres():
|
||||
return op.get_context().dialect.name == "postgresql"
|
||||
|
||||
|
||||
def generate_id() -> str:
|
||||
"""See GUID.convert_value_to_guid"""
|
||||
val = uuid4()
|
||||
if is_postgres():
|
||||
return str(val)
|
||||
else:
|
||||
return f"{val.int:032x}"
|
||||
|
||||
|
||||
def dedupe_cookbook_slugs():
|
||||
bind = op.get_bind()
|
||||
session = orm.Session(bind=bind)
|
||||
with session:
|
||||
sql = sa.text(
|
||||
dedent(
|
||||
"""
|
||||
SELECT slug, group_id, COUNT(*)
|
||||
FROM cookbooks
|
||||
GROUP BY slug, group_id
|
||||
HAVING COUNT(*) > 1
|
||||
"""
|
||||
)
|
||||
)
|
||||
rows = session.execute(sql).fetchall()
|
||||
|
||||
for slug, group_id, _ in rows:
|
||||
sql = sa.text(
|
||||
dedent(
|
||||
"""
|
||||
SELECT id
|
||||
FROM cookbooks
|
||||
WHERE slug = :slug AND group_id = :group_id
|
||||
ORDER BY id
|
||||
"""
|
||||
)
|
||||
)
|
||||
cookbook_ids = session.execute(sql, {"slug": slug, "group_id": group_id}).fetchall()
|
||||
|
||||
for i, (cookbook_id,) in enumerate(cookbook_ids):
|
||||
if i == 0:
|
||||
continue
|
||||
|
||||
sql = sa.text(
|
||||
dedent(
|
||||
"""
|
||||
UPDATE cookbooks
|
||||
SET slug = :slug || '-' || :i
|
||||
WHERE id = :id
|
||||
"""
|
||||
)
|
||||
)
|
||||
session.execute(sql, {"slug": slug, "i": i, "id": cookbook_id})
|
||||
|
||||
|
||||
def create_household(session: orm.Session, group_id: str) -> str:
|
||||
# create/insert household
|
||||
household_id = generate_id()
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
household_data = {
|
||||
"id": household_id,
|
||||
"name": settings.DEFAULT_HOUSEHOLD,
|
||||
"slug": slugify(settings.DEFAULT_HOUSEHOLD),
|
||||
"group_id": group_id,
|
||||
"created_at": timestamp,
|
||||
"update_at": timestamp,
|
||||
}
|
||||
columns = ", ".join(household_data.keys())
|
||||
placeholders = ", ".join(f":{key}" for key in household_data.keys())
|
||||
sql_statement = f"INSERT INTO households ({columns}) VALUES ({placeholders})"
|
||||
|
||||
session.execute(sa.text(sql_statement), household_data)
|
||||
|
||||
# fetch group preferences so we can copy them over to household preferences
|
||||
migrated_field_defaults = {
|
||||
"private_group": True, # this is renamed later
|
||||
"first_day_of_week": 0,
|
||||
"recipe_public": True,
|
||||
"recipe_show_nutrition": False,
|
||||
"recipe_show_assets": False,
|
||||
"recipe_landscape_view": False,
|
||||
"recipe_disable_comments": False,
|
||||
"recipe_disable_amount": True,
|
||||
}
|
||||
sql_statement = (
|
||||
f"SELECT {', '.join(migrated_field_defaults.keys())} FROM group_preferences WHERE group_id = :group_id"
|
||||
)
|
||||
group_preferences = session.execute(sa.text(sql_statement), {"group_id": group_id}).fetchone()
|
||||
|
||||
# build preferences data
|
||||
if group_preferences:
|
||||
preferences_data: dict[str, Any] = {}
|
||||
for i, (field, default_value) in enumerate(migrated_field_defaults.items()):
|
||||
value = group_preferences[i]
|
||||
preferences_data[field] = value if value is not None else default_value
|
||||
else:
|
||||
preferences_data = migrated_field_defaults
|
||||
|
||||
preferences_data["id"] = generate_id()
|
||||
preferences_data["household_id"] = household_id
|
||||
preferences_data["created_at"] = timestamp
|
||||
preferences_data["update_at"] = timestamp
|
||||
preferences_data["private_household"] = preferences_data.pop("private_group")
|
||||
|
||||
# insert preferences data
|
||||
columns = ", ".join(preferences_data.keys())
|
||||
placeholders = ", ".join(f":{key}" for key in preferences_data.keys())
|
||||
sql_statement = f"INSERT INTO household_preferences ({columns}) VALUES ({placeholders})"
|
||||
|
||||
session.execute(sa.text(sql_statement), preferences_data)
|
||||
|
||||
return household_id
|
||||
|
||||
|
||||
def create_households_for_groups() -> dict[str, str]:
|
||||
bind = op.get_bind()
|
||||
session = orm.Session(bind=bind)
|
||||
group_id_household_id_map: dict[str, str] = {}
|
||||
with session:
|
||||
rows = session.execute(sa.text("SELECT id FROM groups")).fetchall()
|
||||
for row in rows:
|
||||
group_id = row[0]
|
||||
group_id_household_id_map[group_id] = create_household(session, group_id)
|
||||
|
||||
return group_id_household_id_map
|
||||
|
||||
|
||||
def _do_assignment(session: orm.Session, table: str, group_id: str, household_id: str):
|
||||
sql = sa.text(
|
||||
dedent(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET household_id = :household_id
|
||||
WHERE group_id = :group_id
|
||||
""",
|
||||
)
|
||||
)
|
||||
session.execute(sql, {"group_id": group_id, "household_id": household_id})
|
||||
|
||||
|
||||
def assign_households(group_id_household_id_map: dict[str, str]):
|
||||
tables = [
|
||||
"cookbooks",
|
||||
"group_events_notifiers",
|
||||
"group_meal_plan_rules",
|
||||
"invite_tokens",
|
||||
"recipe_actions",
|
||||
"users",
|
||||
"webhook_urls",
|
||||
]
|
||||
|
||||
bind = op.get_bind()
|
||||
session = orm.Session(bind=bind)
|
||||
with session:
|
||||
for table in tables:
|
||||
for group_id, household_id in group_id_household_id_map.items():
|
||||
_do_assignment(session, table, group_id, household_id)
|
||||
|
||||
|
||||
def populate_household_data():
|
||||
group_id_household_id_map = create_households_for_groups()
|
||||
assign_households(group_id_household_id_map)
|
||||
|
||||
|
||||
def upgrade():
|
||||
dedupe_cookbook_slugs()
|
||||
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"households",
|
||||
sa.Column("id", mealie.db.migration_types.GUID(), nullable=False),
|
||||
sa.Column("name", sa.String(), nullable=False),
|
||||
sa.Column("slug", sa.String(), nullable=True),
|
||||
sa.Column("group_id", mealie.db.migration_types.GUID(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("update_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["group_id"],
|
||||
["groups.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("group_id", "name", name="household_name_group_id_key"),
|
||||
sa.UniqueConstraint("group_id", "slug", name="household_slug_group_id_key"),
|
||||
)
|
||||
op.create_index(op.f("ix_households_created_at"), "households", ["created_at"], unique=False)
|
||||
op.create_index(op.f("ix_households_group_id"), "households", ["group_id"], unique=False)
|
||||
op.create_index(op.f("ix_households_name"), "households", ["name"], unique=False)
|
||||
op.create_index(op.f("ix_households_slug"), "households", ["slug"], unique=False)
|
||||
op.create_table(
|
||||
"household_preferences",
|
||||
sa.Column("id", mealie.db.migration_types.GUID(), nullable=False),
|
||||
sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=False),
|
||||
sa.Column("private_household", sa.Boolean(), nullable=True),
|
||||
sa.Column("first_day_of_week", sa.Integer(), nullable=True),
|
||||
sa.Column("recipe_public", sa.Boolean(), nullable=True),
|
||||
sa.Column("recipe_show_nutrition", sa.Boolean(), nullable=True),
|
||||
sa.Column("recipe_show_assets", sa.Boolean(), nullable=True),
|
||||
sa.Column("recipe_landscape_view", sa.Boolean(), nullable=True),
|
||||
sa.Column("recipe_disable_comments", sa.Boolean(), nullable=True),
|
||||
sa.Column("recipe_disable_amount", sa.Boolean(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("update_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["household_id"],
|
||||
["households.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_household_preferences_created_at"), "household_preferences", ["created_at"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_household_preferences_household_id"), "household_preferences", ["household_id"], unique=False
|
||||
)
|
||||
|
||||
with op.batch_alter_table("cookbooks") as batch_op:
|
||||
batch_op.add_column(sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True))
|
||||
batch_op.create_index(op.f("ix_cookbooks_household_id"), ["household_id"], unique=False)
|
||||
batch_op.create_foreign_key("fk_cookbooks_household_id", "households", ["household_id"], ["id"])
|
||||
|
||||
# not directly related to households, but important for frontend routes
|
||||
batch_op.create_unique_constraint("cookbook_slug_group_id_key", ["slug", "group_id"])
|
||||
|
||||
with op.batch_alter_table("group_events_notifiers") as batch_op:
|
||||
batch_op.add_column(sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True))
|
||||
batch_op.create_index(op.f("ix_group_events_notifiers_household_id"), ["household_id"], unique=False)
|
||||
batch_op.create_foreign_key("fk_group_events_notifiers_household_id", "households", ["household_id"], ["id"])
|
||||
|
||||
with op.batch_alter_table("group_meal_plan_rules") as batch_op:
|
||||
batch_op.add_column(sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True))
|
||||
batch_op.create_index(op.f("ix_group_meal_plan_rules_household_id"), ["household_id"], unique=False)
|
||||
batch_op.create_foreign_key("fk_group_meal_plan_rules_household_id", "households", ["household_id"], ["id"])
|
||||
|
||||
with op.batch_alter_table("invite_tokens") as batch_op:
|
||||
batch_op.add_column(sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True))
|
||||
batch_op.create_index(op.f("ix_invite_tokens_household_id"), ["household_id"], unique=False)
|
||||
batch_op.create_foreign_key("fk_invite_tokens_household_id", "households", ["household_id"], ["id"])
|
||||
|
||||
with op.batch_alter_table("recipe_actions") as batch_op:
|
||||
batch_op.add_column(sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True))
|
||||
batch_op.create_index(op.f("ix_recipe_actions_household_id"), ["household_id"], unique=False)
|
||||
batch_op.create_foreign_key("fk_recipe_actions_household_id", "households", ["household_id"], ["id"])
|
||||
|
||||
with op.batch_alter_table("users") as batch_op:
|
||||
batch_op.add_column(sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True))
|
||||
batch_op.create_index(op.f("ix_users_household_id"), ["household_id"], unique=False)
|
||||
batch_op.create_foreign_key("fk_users_household_id", "households", ["household_id"], ["id"])
|
||||
|
||||
with op.batch_alter_table("webhook_urls") as batch_op:
|
||||
batch_op.add_column(sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True))
|
||||
batch_op.create_index(op.f("ix_webhook_urls_household_id"), ["household_id"], unique=False)
|
||||
batch_op.create_foreign_key("fk_webhook_urls_household_id", "households", ["household_id"], ["id"])
|
||||
# ### end Alembic commands ###
|
||||
|
||||
populate_household_data()
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, "webhook_urls", type_="foreignkey")
|
||||
op.drop_index(op.f("ix_webhook_urls_household_id"), table_name="webhook_urls")
|
||||
op.drop_column("webhook_urls", "household_id")
|
||||
op.drop_constraint(None, "users", type_="foreignkey")
|
||||
op.drop_index(op.f("ix_users_household_id"), table_name="users")
|
||||
op.drop_column("users", "household_id")
|
||||
op.drop_constraint(None, "recipe_actions", type_="foreignkey")
|
||||
op.drop_index(op.f("ix_recipe_actions_household_id"), table_name="recipe_actions")
|
||||
op.drop_column("recipe_actions", "household_id")
|
||||
op.drop_constraint(None, "invite_tokens", type_="foreignkey")
|
||||
op.drop_index(op.f("ix_invite_tokens_household_id"), table_name="invite_tokens")
|
||||
op.drop_column("invite_tokens", "household_id")
|
||||
op.drop_constraint(None, "group_meal_plan_rules", type_="foreignkey")
|
||||
op.drop_index(op.f("ix_group_meal_plan_rules_household_id"), table_name="group_meal_plan_rules")
|
||||
op.drop_column("group_meal_plan_rules", "household_id")
|
||||
op.drop_constraint(None, "group_events_notifiers", type_="foreignkey")
|
||||
op.drop_index(op.f("ix_group_events_notifiers_household_id"), table_name="group_events_notifiers")
|
||||
op.drop_column("group_events_notifiers", "household_id")
|
||||
op.drop_constraint(None, "cookbooks", type_="foreignkey")
|
||||
op.drop_index(op.f("ix_cookbooks_household_id"), table_name="cookbooks")
|
||||
op.drop_column("cookbooks", "household_id")
|
||||
op.drop_constraint("cookbook_slug_group_id_key", "cookbooks", type_="unique")
|
||||
op.drop_index(op.f("ix_household_preferences_household_id"), table_name="household_preferences")
|
||||
op.drop_index(op.f("ix_household_preferences_created_at"), table_name="household_preferences")
|
||||
op.drop_table("household_preferences")
|
||||
op.drop_index(op.f("ix_households_slug"), table_name="households")
|
||||
op.drop_index(op.f("ix_households_name"), table_name="households")
|
||||
op.drop_index(op.f("ix_households_group_id"), table_name="households")
|
||||
op.drop_index(op.f("ix_households_created_at"), table_name="households")
|
||||
op.drop_table("households")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,75 @@
|
||||
"""added household recipe lock setting and household management user permission
|
||||
|
||||
Revision ID: be568e39ffdf
|
||||
Revises: feecc8ffb956
|
||||
Create Date: 2024-09-02 21:39:49.210355
|
||||
|
||||
"""
|
||||
|
||||
from textwrap import dedent
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "be568e39ffdf"
|
||||
down_revision = "feecc8ffb956"
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def populate_defaults():
|
||||
if op.get_context().dialect.name == "postgresql":
|
||||
TRUE = "TRUE"
|
||||
FALSE = "FALSE"
|
||||
else:
|
||||
TRUE = "1"
|
||||
FALSE = "0"
|
||||
|
||||
op.execute(
|
||||
dedent(
|
||||
f"""
|
||||
UPDATE household_preferences
|
||||
SET lock_recipe_edits_from_other_households = {TRUE}
|
||||
WHERE lock_recipe_edits_from_other_households IS NULL
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
dedent(
|
||||
f"""
|
||||
UPDATE users
|
||||
SET can_manage_household = {FALSE}
|
||||
WHERE can_manage_household IS NULL AND admin = {FALSE}
|
||||
"""
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
dedent(
|
||||
f"""
|
||||
UPDATE users
|
||||
SET can_manage_household = {TRUE}
|
||||
WHERE can_manage_household IS NULL AND admin = {TRUE}
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"household_preferences",
|
||||
sa.Column("lock_recipe_edits_from_other_households", sa.Boolean(), nullable=True),
|
||||
)
|
||||
op.add_column("users", sa.Column("can_manage_household", sa.Boolean(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
populate_defaults()
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("users", "can_manage_household")
|
||||
op.drop_column("household_preferences", "lock_recipe_edits_from_other_households")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,53 @@
|
||||
"""add households filter to meal plans
|
||||
|
||||
Revision ID: 1fe4bd37ccc8
|
||||
Revises: be568e39ffdf
|
||||
Create Date: 2024-09-18 14:52:55.831540
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "1fe4bd37ccc8"
|
||||
down_revision: str | None = "be568e39ffdf"
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"plan_rules_to_households",
|
||||
sa.Column("group_plan_rule_id", mealie.db.migration_types.GUID(), nullable=True),
|
||||
sa.Column("household_id", mealie.db.migration_types.GUID(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["group_plan_rule_id"],
|
||||
["group_meal_plan_rules.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["household_id"],
|
||||
["households.id"],
|
||||
),
|
||||
sa.UniqueConstraint("group_plan_rule_id", "household_id", name="group_plan_rule_id_household_id_key"),
|
||||
)
|
||||
with op.batch_alter_table("plan_rules_to_households", schema=None) as batch_op:
|
||||
batch_op.create_index(
|
||||
batch_op.f("ix_plan_rules_to_households_group_plan_rule_id"), ["group_plan_rule_id"], unique=False
|
||||
)
|
||||
batch_op.create_index(batch_op.f("ix_plan_rules_to_households_household_id"), ["household_id"], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("plan_rules_to_households", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_plan_rules_to_households_household_id"))
|
||||
batch_op.drop_index(batch_op.f("ix_plan_rules_to_households_group_plan_rule_id"))
|
||||
|
||||
op.drop_table("plan_rules_to_households")
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,39 @@
|
||||
"""'add the rest of the schema.org nutrition properties'
|
||||
|
||||
Revision ID: 602927e1013e
|
||||
Revises: 1fe4bd37ccc8
|
||||
Create Date: 2024-10-01 14:17:00.611398
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "602927e1013e"
|
||||
down_revision: str | None = "1fe4bd37ccc8"
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("recipe_nutrition", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("cholesterol_content", sa.String(), nullable=True))
|
||||
batch_op.add_column(sa.Column("saturated_fat_content", sa.String(), nullable=True))
|
||||
batch_op.add_column(sa.Column("trans_fat_content", sa.String(), nullable=True))
|
||||
batch_op.add_column(sa.Column("unsaturated_fat_content", sa.String(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("recipe_nutrition", schema=None) as batch_op:
|
||||
batch_op.drop_column("unsaturated_fat_content")
|
||||
batch_op.drop_column("trans_fat_content")
|
||||
batch_op.drop_column("saturated_fat_content")
|
||||
batch_op.drop_column("cholesterol_content")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,188 @@
|
||||
"""added query_filter_string to cookbook and mealplan
|
||||
|
||||
Revision ID: 86054b40fd06
|
||||
Revises: 602927e1013e
|
||||
Create Date: 2024-10-08 21:17:31.601903
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import orm
|
||||
|
||||
from alembic import op
|
||||
from mealie.db.models._model_utils import guid
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "86054b40fd06"
|
||||
down_revision: str | None = "602927e1013e"
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
# Intermediate table definitions
|
||||
class SqlAlchemyBase(orm.DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Category(SqlAlchemyBase):
|
||||
__tablename__ = "categories"
|
||||
id: orm.Mapped[guid.GUID] = orm.mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
|
||||
|
||||
|
||||
class Tag(SqlAlchemyBase):
|
||||
__tablename__ = "tags"
|
||||
id: orm.Mapped[guid.GUID] = orm.mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
|
||||
|
||||
|
||||
class Tool(SqlAlchemyBase):
|
||||
__tablename__ = "tools"
|
||||
id: orm.Mapped[guid.GUID] = orm.mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
|
||||
|
||||
|
||||
class Household(SqlAlchemyBase):
|
||||
__tablename__ = "households"
|
||||
id: orm.Mapped[guid.GUID] = orm.mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
|
||||
|
||||
|
||||
cookbooks_to_categories = sa.Table(
|
||||
"cookbooks_to_categories",
|
||||
SqlAlchemyBase.metadata,
|
||||
sa.Column("cookbook_id", guid.GUID, sa.ForeignKey("cookbooks.id"), index=True),
|
||||
sa.Column("category_id", guid.GUID, sa.ForeignKey("categories.id"), index=True),
|
||||
)
|
||||
|
||||
cookbooks_to_tags = sa.Table(
|
||||
"cookbooks_to_tags",
|
||||
SqlAlchemyBase.metadata,
|
||||
sa.Column("cookbook_id", guid.GUID, sa.ForeignKey("cookbooks.id"), index=True),
|
||||
sa.Column("tag_id", guid.GUID, sa.ForeignKey("tags.id"), index=True),
|
||||
)
|
||||
|
||||
cookbooks_to_tools = sa.Table(
|
||||
"cookbooks_to_tools",
|
||||
SqlAlchemyBase.metadata,
|
||||
sa.Column("cookbook_id", guid.GUID, sa.ForeignKey("cookbooks.id"), index=True),
|
||||
sa.Column("tool_id", guid.GUID, sa.ForeignKey("tools.id"), index=True),
|
||||
)
|
||||
|
||||
plan_rules_to_categories = sa.Table(
|
||||
"plan_rules_to_categories",
|
||||
SqlAlchemyBase.metadata,
|
||||
sa.Column("group_plan_rule_id", guid.GUID, sa.ForeignKey("group_meal_plan_rules.id"), index=True),
|
||||
sa.Column("category_id", guid.GUID, sa.ForeignKey("categories.id"), index=True),
|
||||
)
|
||||
|
||||
plan_rules_to_tags = sa.Table(
|
||||
"plan_rules_to_tags",
|
||||
SqlAlchemyBase.metadata,
|
||||
sa.Column("plan_rule_id", guid.GUID, sa.ForeignKey("group_meal_plan_rules.id"), index=True),
|
||||
sa.Column("tag_id", guid.GUID, sa.ForeignKey("tags.id"), index=True),
|
||||
)
|
||||
|
||||
plan_rules_to_households = sa.Table(
|
||||
"plan_rules_to_households",
|
||||
SqlAlchemyBase.metadata,
|
||||
sa.Column("group_plan_rule_id", guid.GUID, sa.ForeignKey("group_meal_plan_rules.id"), index=True),
|
||||
sa.Column("household_id", guid.GUID, sa.ForeignKey("households.id"), index=True),
|
||||
)
|
||||
|
||||
|
||||
class CookBook(SqlAlchemyBase):
|
||||
__tablename__ = "cookbooks"
|
||||
|
||||
id: orm.Mapped[guid.GUID] = orm.mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
|
||||
query_filter_string: orm.Mapped[str] = orm.mapped_column(sa.String, nullable=False, default="")
|
||||
|
||||
categories: orm.Mapped[list[Category]] = orm.relationship(
|
||||
Category, secondary=cookbooks_to_categories, single_parent=True
|
||||
)
|
||||
require_all_categories: orm.Mapped[bool | None] = orm.mapped_column(sa.Boolean, default=True)
|
||||
|
||||
tags: orm.Mapped[list[Tag]] = orm.relationship(Tag, secondary=cookbooks_to_tags, single_parent=True)
|
||||
require_all_tags: orm.Mapped[bool | None] = orm.mapped_column(sa.Boolean, default=True)
|
||||
|
||||
tools: orm.Mapped[list[Tool]] = orm.relationship(Tool, secondary=cookbooks_to_tools, single_parent=True)
|
||||
require_all_tools: orm.Mapped[bool | None] = orm.mapped_column(sa.Boolean, default=True)
|
||||
|
||||
|
||||
class GroupMealPlanRules(SqlAlchemyBase):
|
||||
__tablename__ = "group_meal_plan_rules"
|
||||
|
||||
id: orm.Mapped[guid.GUID] = orm.mapped_column(guid.GUID, primary_key=True, default=guid.GUID.generate)
|
||||
query_filter_string: orm.Mapped[str] = orm.mapped_column(sa.String, nullable=False, default="")
|
||||
|
||||
categories: orm.Mapped[list[Category]] = orm.relationship(Category, secondary=plan_rules_to_categories)
|
||||
tags: orm.Mapped[list[Tag]] = orm.relationship(Tag, secondary=plan_rules_to_tags)
|
||||
households: orm.Mapped[list["Household"]] = orm.relationship("Household", secondary=plan_rules_to_households)
|
||||
|
||||
|
||||
def migrate_cookbooks():
|
||||
bind = op.get_bind()
|
||||
session = orm.Session(bind=bind)
|
||||
|
||||
cookbooks = session.query(CookBook).all()
|
||||
for cookbook in cookbooks:
|
||||
parts = []
|
||||
if cookbook.categories:
|
||||
relop = "CONTAINS ALL" if cookbook.require_all_categories else "IN"
|
||||
vals = ",".join([f'"{cat.id}"' for cat in cookbook.categories])
|
||||
parts.append(f"recipe_category.id {relop} [{vals}]")
|
||||
if cookbook.tags:
|
||||
relop = "CONTAINS ALL" if cookbook.require_all_tags else "IN"
|
||||
vals = ",".join([f'"{tag.id}"' for tag in cookbook.tags])
|
||||
parts.append(f"tags.id {relop} [{vals}]")
|
||||
if cookbook.tools:
|
||||
relop = "CONTAINS ALL" if cookbook.require_all_tools else "IN"
|
||||
vals = ",".join([f'"{tool.id}"' for tool in cookbook.tools])
|
||||
parts.append(f"tools.id {relop} [{vals}]")
|
||||
|
||||
cookbook.query_filter_string = " AND ".join(parts)
|
||||
|
||||
session.commit()
|
||||
|
||||
|
||||
def migrate_mealplan_rules():
|
||||
bind = op.get_bind()
|
||||
session = orm.Session(bind=bind)
|
||||
|
||||
rules = session.query(GroupMealPlanRules).all()
|
||||
for rule in rules:
|
||||
parts = []
|
||||
if rule.categories:
|
||||
vals = ",".join([f'"{cat.id}"' for cat in rule.categories])
|
||||
parts.append(f"recipe_category.id CONTAINS ALL [{vals}]")
|
||||
if rule.tags:
|
||||
vals = ",".join([f'"{tag.id}"' for tag in rule.tags])
|
||||
parts.append(f"tags.id CONTAINS ALL [{vals}]")
|
||||
if rule.households:
|
||||
vals = ",".join([f'"{household.id}"' for household in rule.households])
|
||||
parts.append(f"household_id IN [{vals}]")
|
||||
|
||||
rule.query_filter_string = " AND ".join(parts)
|
||||
|
||||
session.commit()
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("cookbooks", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("query_filter_string", sa.String(), nullable=False, server_default=""))
|
||||
|
||||
with op.batch_alter_table("group_meal_plan_rules", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("query_filter_string", sa.String(), nullable=False, server_default=""))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
migrate_cookbooks()
|
||||
migrate_mealplan_rules()
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("group_meal_plan_rules", schema=None) as batch_op:
|
||||
batch_op.drop_column("query_filter_string")
|
||||
|
||||
with op.batch_alter_table("cookbooks", schema=None) as batch_op:
|
||||
batch_op.drop_column("query_filter_string")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,33 @@
|
||||
"""'Add summary to recipe instructions'
|
||||
|
||||
Revision ID: 3897397b4631
|
||||
Revises: 86054b40fd06
|
||||
Create Date: 2024-10-20 09:47:46.844436
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "3897397b4631"
|
||||
down_revision: str | None = "86054b40fd06"
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("recipe_instructions", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("summary", sa.String(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("recipe_instructions", schema=None) as batch_op:
|
||||
batch_op.drop_column("summary")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,72 @@
|
||||
"""add recipe yield quantity
|
||||
|
||||
Revision ID: b1020f328e98
|
||||
Revises: 3897397b4631
|
||||
Create Date: 2024-10-23 15:50:59.888793
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import orm
|
||||
|
||||
from alembic import op
|
||||
from mealie.db.models._model_utils.guid import GUID
|
||||
from mealie.services.scraper.cleaner import clean_yield
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b1020f328e98"
|
||||
down_revision: str | None = "3897397b4631"
|
||||
branch_labels: str | tuple[str, ...] | None = None
|
||||
depends_on: str | tuple[str, ...] | None = None
|
||||
|
||||
|
||||
# Intermediate table definitions
|
||||
class SqlAlchemyBase(orm.DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class RecipeModel(SqlAlchemyBase):
|
||||
__tablename__ = "recipes"
|
||||
|
||||
id: orm.Mapped[GUID] = orm.mapped_column(GUID, primary_key=True, default=GUID.generate)
|
||||
recipe_yield: orm.Mapped[str | None] = orm.mapped_column(sa.String)
|
||||
recipe_yield_quantity: orm.Mapped[float] = orm.mapped_column(sa.Float, index=True, default=0)
|
||||
recipe_servings: orm.Mapped[float] = orm.mapped_column(sa.Float, index=True, default=0)
|
||||
|
||||
|
||||
def parse_recipe_yields():
|
||||
bind = op.get_bind()
|
||||
session = orm.Session(bind=bind)
|
||||
|
||||
for recipe in session.query(RecipeModel).all():
|
||||
try:
|
||||
recipe.recipe_servings, recipe.recipe_yield_quantity, recipe.recipe_yield = clean_yield(recipe.recipe_yield)
|
||||
except Exception:
|
||||
recipe.recipe_servings = 0
|
||||
recipe.recipe_yield_quantity = 0
|
||||
|
||||
session.commit()
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("recipes", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("recipe_yield_quantity", sa.Float(), nullable=False, server_default="0"))
|
||||
batch_op.create_index(batch_op.f("ix_recipes_recipe_yield_quantity"), ["recipe_yield_quantity"], unique=False)
|
||||
batch_op.add_column(sa.Column("recipe_servings", sa.Float(), nullable=False, server_default="0"))
|
||||
batch_op.create_index(batch_op.f("ix_recipes_recipe_servings"), ["recipe_servings"], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
parse_recipe_yields()
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table("recipes", schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f("ix_recipes_recipe_servings"))
|
||||
batch_op.drop_column("recipe_servings")
|
||||
batch_op.drop_index(batch_op.f("ix_recipes_recipe_yield_quantity"))
|
||||
batch_op.drop_column("recipe_yield_quantity")
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,5 +1,8 @@
|
||||
import json
|
||||
|
||||
from fastapi import FastAPI
|
||||
from freezegun import freeze_time
|
||||
|
||||
from mealie.app import app
|
||||
from mealie.core.config import determine_data_dir
|
||||
|
||||
@@ -36,11 +39,12 @@ HTML_TEMPLATE = """<!-- Custom HTML site displayed as the Home chapter -->
|
||||
HTML_PATH = DATA_DIR.parent.parent.joinpath("docs/docs/overrides/api.html")
|
||||
|
||||
|
||||
def generate_api_docs(my_app):
|
||||
def generate_api_docs(my_app: FastAPI):
|
||||
with open(HTML_PATH, "w") as fd:
|
||||
text = HTML_TEMPLATE.replace("MY_SPECIFIC_TEXT", json.dumps(my_app.openapi()))
|
||||
fd.write(text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_api_docs(app)
|
||||
with freeze_time("2024-01-20T17:00:55Z"):
|
||||
generate_api_docs(app)
|
||||
|
||||
@@ -67,7 +67,7 @@ def rename_non_compliant_paths():
|
||||
kabab case.
|
||||
"""
|
||||
|
||||
ignore_files = ["DS_Store", ".gitkeep"]
|
||||
ignore_files = ["DS_Store", ".gitkeep", "af-ZA.json", "en-US.json"]
|
||||
|
||||
ignore_extensions = [".pyc", ".pyo", ".py"]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
from fastapi import FastAPI
|
||||
from jinja2 import Template
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from utils import PROJECT_DIR, CodeTemplates, HTTPRequest, RouteObject, RequestType
|
||||
from utils import PROJECT_DIR, CodeTemplates, HTTPRequest, RouteObject
|
||||
|
||||
CWD = Path(__file__).parent
|
||||
|
||||
@@ -17,9 +17,16 @@ class PathObject(BaseModel):
|
||||
http_verbs: list[HTTPRequest]
|
||||
|
||||
|
||||
def get_path_objects(app: FastAPI):
|
||||
paths = []
|
||||
def force_include_in_schema(app: FastAPI):
|
||||
# clear schema cache
|
||||
app.openapi_schema = None
|
||||
for route in app.routes:
|
||||
route.include_in_schema = True
|
||||
|
||||
|
||||
def get_path_objects(app: FastAPI):
|
||||
force_include_in_schema(app)
|
||||
paths = []
|
||||
for key, value in app.openapi().items():
|
||||
if key == "paths":
|
||||
for key, value2 in value.items():
|
||||
|
||||
@@ -35,6 +35,7 @@ LOCALE_DATA: dict[str, LocaleData] = {
|
||||
"es-ES": LocaleData(name="Español (Spanish)"),
|
||||
"fi-FI": LocaleData(name="Suomi (Finnish)"),
|
||||
"fr-FR": LocaleData(name="Français (French)"),
|
||||
"fr-BE": LocaleData(name="Belge (Belgian)"),
|
||||
"gl-ES": LocaleData(name="Galego (Galician)"),
|
||||
"he-IL": LocaleData(name="עברית (Hebrew)", dir="rtl"),
|
||||
"hr-HR": LocaleData(name="Hrvatski (Croatian)"),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Template
|
||||
@@ -64,7 +65,112 @@ def generate_global_components_types() -> None:
|
||||
# Pydantic To Typescript Generator
|
||||
|
||||
|
||||
def generate_typescript_types() -> None:
|
||||
def generate_typescript_types() -> None: # noqa: C901
|
||||
def contains_number(s: str) -> bool:
|
||||
return bool(re.search(r"\d", s))
|
||||
|
||||
def remove_numbers(s: str) -> str:
|
||||
return re.sub(r"\d", "", s)
|
||||
|
||||
def extract_type_name(line: str) -> str:
|
||||
# Looking for "export type EnumName = enumVal1 | enumVal2 | ..."
|
||||
if not (line.startswith("export type") and "=" in line):
|
||||
return ""
|
||||
|
||||
return line.split(" ")[2]
|
||||
|
||||
def extract_property_type_name(line: str) -> str:
|
||||
# Looking for " fieldName: FieldType;" or " fieldName: FieldType & string;"
|
||||
if not (line.startswith(" ") and ":" in line):
|
||||
return ""
|
||||
|
||||
return line.split(":")[1].strip().split(";")[0]
|
||||
|
||||
def extract_interface_name(line: str) -> str:
|
||||
# Looking for "export interface InterfaceName {"
|
||||
if not (line.startswith("export interface") and "{" in line):
|
||||
return ""
|
||||
|
||||
return line.split(" ")[2]
|
||||
|
||||
def is_comment_line(line: str) -> bool:
|
||||
s = line.strip()
|
||||
return s.startswith("/*") or s.startswith("*")
|
||||
|
||||
def clean_output_file(file: Path) -> None:
|
||||
"""
|
||||
json2ts generates duplicate types off of our enums and appends a number to the end of the type name.
|
||||
Our Python code (hopefully) doesn't have any duplicate enum names, or types with numbers in them,
|
||||
so we can safely remove the numbers.
|
||||
|
||||
To do this, we read the output line-by-line and replace any type names that contain numbers with
|
||||
the same type name, but without the numbers.
|
||||
|
||||
Note: the issue arrises from the JSON package json2ts, not the Python package pydantic2ts,
|
||||
otherwise we could just fix pydantic2ts.
|
||||
"""
|
||||
|
||||
# First pass: build a map of type names to their numberless counterparts and lines to skip
|
||||
replacement_map = {}
|
||||
lines_to_skip = set()
|
||||
wait_for_semicolon = False
|
||||
wait_for_close_bracket = False
|
||||
skip_comments = False
|
||||
with open(file) as f:
|
||||
for i, line in enumerate(f.readlines()):
|
||||
if wait_for_semicolon:
|
||||
if ";" in line:
|
||||
wait_for_semicolon = False
|
||||
lines_to_skip.add(i)
|
||||
continue
|
||||
if wait_for_close_bracket:
|
||||
if "}" in line:
|
||||
wait_for_close_bracket = False
|
||||
lines_to_skip.add(i)
|
||||
continue
|
||||
|
||||
if type_name := extract_type_name(line):
|
||||
if not contains_number(type_name):
|
||||
continue
|
||||
|
||||
replacement_map[type_name] = remove_numbers(type_name)
|
||||
if ";" not in line:
|
||||
wait_for_semicolon = True
|
||||
lines_to_skip.add(i)
|
||||
|
||||
elif type_name := extract_interface_name(line):
|
||||
if not contains_number(type_name):
|
||||
continue
|
||||
|
||||
replacement_map[type_name] = remove_numbers(type_name)
|
||||
if "}" not in line:
|
||||
wait_for_close_bracket = True
|
||||
lines_to_skip.add(i)
|
||||
|
||||
elif skip_comments and is_comment_line(line):
|
||||
lines_to_skip.add(i)
|
||||
|
||||
# we've passed the opening comments and empty line at the header
|
||||
elif not skip_comments and not line.strip():
|
||||
skip_comments = True
|
||||
|
||||
# Second pass: rewrite or remove lines as needed.
|
||||
# We have to do two passes here because definitions don't always appear in the same order as their usage.
|
||||
lines = []
|
||||
with open(file) as f:
|
||||
for i, line in enumerate(f.readlines()):
|
||||
if i in lines_to_skip:
|
||||
continue
|
||||
|
||||
if type_name := extract_property_type_name(line):
|
||||
if type_name in replacement_map:
|
||||
line = line.replace(type_name, replacement_map[type_name])
|
||||
|
||||
lines.append(line)
|
||||
|
||||
with open(file, "w") as f:
|
||||
f.writelines(lines)
|
||||
|
||||
def path_to_module(path: Path):
|
||||
str_path: str = str(path)
|
||||
|
||||
@@ -98,9 +204,10 @@ def generate_typescript_types() -> None:
|
||||
try:
|
||||
path_as_module = path_to_module(module)
|
||||
generate_typescript_defs(path_as_module, str(out_path), exclude=("MealieModel")) # type: ignore
|
||||
except Exception as e:
|
||||
clean_output_file(out_path)
|
||||
except Exception:
|
||||
failed_modules.append(module)
|
||||
log.error(f"Module Error: {e}")
|
||||
log.exception(f"Module Error: {module}")
|
||||
|
||||
log.debug("\n📁 Skipped Directories:")
|
||||
for skipped_dir in skipped_dirs:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
@@ -23,6 +24,11 @@ def render_python_template(template_file: Path | str, dest: Path, data: dict):
|
||||
|
||||
dest.write_text(text)
|
||||
|
||||
# lint/format file with Ruff
|
||||
log.info(f"Formatting {dest}")
|
||||
subprocess.run(["poetry", "run", "ruff", "check", str(dest), "--fix"])
|
||||
subprocess.run(["poetry", "run", "ruff", "format", str(dest)])
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodeSlicer:
|
||||
|
||||
@@ -173,7 +173,7 @@ def recipe_data(name: str, slug: str, id: str, userId: str, groupId: str) -> dic
|
||||
"dateAdded": "2022-09-03",
|
||||
"dateUpdated": "2022-09-10T15:18:19.866085",
|
||||
"createdAt": "2022-09-03T18:31:17.488118",
|
||||
"updateAt": "2022-09-10T15:18:19.869630",
|
||||
"updatedAt": "2022-09-10T15:18:19.869630",
|
||||
"recipeInstructions": [
|
||||
{
|
||||
"id": "60ae53a3-b3ff-40ee-bae3-89fea0b1c637",
|
||||
|
||||
@@ -78,6 +78,7 @@ RUN echo "crfpp-container"
|
||||
# Production Image
|
||||
###############################################
|
||||
FROM python-base as production
|
||||
LABEL org.opencontainers.image.source="https://github.com/mealie-recipes/mealie"
|
||||
ENV PRODUCTION=true
|
||||
ENV TESTING=false
|
||||
|
||||
@@ -130,7 +131,6 @@ HEALTHCHECK CMD python $MEALIE_HOME/mealie/scripts/healthcheck.py || exit 1
|
||||
# ----------------------------------
|
||||
# Copy Frontend
|
||||
|
||||
# copying caddy into image
|
||||
ENV STATIC_FILES=/spa/static
|
||||
COPY --from=builder /app/dist ${STATIC_FILES}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
version: "3.4"
|
||||
services:
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
version: "3.4"
|
||||
services:
|
||||
mealie:
|
||||
container_name: mealie
|
||||
@@ -24,7 +23,6 @@ services:
|
||||
POSTGRES_SERVER: postgres
|
||||
POSTGRES_PORT: 5432
|
||||
POSTGRES_DB: mealie
|
||||
|
||||
# =====================================
|
||||
# Email Configuration
|
||||
# SMTP_HOST=
|
||||
|
||||
@@ -11,75 +11,5 @@ How exactly you need to modify it is of course highly contextual to the change y
|
||||
|
||||
In your dev container you can run something like (change the message) `task py:migrate -- "Add creation tag to group preferences"` to have Alembic generate an upgrade script for you.
|
||||
|
||||
The script Alembic generates, will be limited! (Perhaps there's a way to resolve that? Haven't looked into it yet)
|
||||
For example, Alembic generated a script _similar_ to this (it has been modified already to have accurate foreign key names, for instance):
|
||||
|
||||
```Python
|
||||
"""Add creation tag to group preferences
|
||||
|
||||
Revision ID: 0ea6eb8eaa44
|
||||
Revises: ba1e4a6cfe99
|
||||
Create Date: 2024-01-04 12:40:03.062671
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0ea6eb8eaa44"
|
||||
down_revision = "ba1e4a6cfe99"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"group_preferences", sa.Column("recipe_creation_tag", mealie.db.migration_types.GUID(), nullable=True)
|
||||
)
|
||||
op.create_foreign_key("fk_groupprefs_tags", "group_preferences", "tags", ["recipe_creation_tag"], ["id"])
|
||||
### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint("fk_groupprefs_tags", "group_preferences", type_="foreignkey")
|
||||
op.drop_column("group_preferences", "recipe_creation_tag")
|
||||
### end Alembic commands ###
|
||||
```
|
||||
|
||||
But when trying to actually use that upgrade script, it becomes clear that our SQLite database doesn't like them. The minor modification needed looks like:
|
||||
|
||||
```Python
|
||||
"""Add creation tag to group preferences
|
||||
|
||||
Revision ID: 0ea6eb8eaa44
|
||||
Revises: ba1e4a6cfe99
|
||||
Create Date: 2024-01-04 12:40:03.062671
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
import mealie.db.migration_types
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0ea6eb8eaa44"
|
||||
down_revision = "ba1e4a6cfe99"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("group_preferences", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("recipe_creation_tag", mealie.db.migration_types.GUID(), nullable=True))
|
||||
batch_op.create_foreign_key("fk_groupprefs_tags", "tags", ["recipe_creation_tag"], ["id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("group_preferences", schema=None) as batch_op:
|
||||
batch_op.drop_constraint("fk_groupprefs_tags", type_="foreignkey")
|
||||
batch_op.drop_column("recipe_creation_tag")
|
||||
```
|
||||
Alembic's script migration isn't perfect, so you will need to review which changes are generated. You will also need to make sure any custom operations work on both SQLite and Postgres.
|
||||
There are some known limitations with our migrations and Alembic's auto-generation, which is accounted for in `/alembic/env.py`. If any of your migrations overlap with the columns in `include_object`, you may need to manually adjust the migration.
|
||||
|
||||
59
docs/docs/contributors/developers-guide/migration-guide.md
Normal file
59
docs/docs/contributors/developers-guide/migration-guide.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Migration Guide
|
||||
|
||||
This guide is a reference for developers maintaining custom integrations with Mealie. While we aim to keep breaking changes to a minimum, major versions are likely to contain at least *some* breaking changes. To clarify: *most users do not need to worry about this, this is **only** for those maintaining integrations and/or leveraging the API*.
|
||||
|
||||
While this guide aims to simplify the migration process for developers, it's not necessarily a comprehensive list of breaking changes. Starting with v2, a comprehensive list of breaking changes are highlighted in the release notes.
|
||||
|
||||
## V1 → V2
|
||||
|
||||
The biggest change between V1 and V2 is the introduction of Households. For more information on how households work in relation to groups/users, check out the [Groups and Households](./features.md#groups-and-households) section in the Features guide.
|
||||
|
||||
### `updateAt` is now `updatedAt`
|
||||
|
||||
We have renamed the `updateAt` field to `updatedAt`. While the API will still accept `updateAt` as an alias, the API will return it as `updatedAt`. The field's behavior has otherwise been unchanged.
|
||||
|
||||
### Backend Endpoint Changes
|
||||
|
||||
These endpoints have moved, but are otherwise unchanged:
|
||||
|
||||
- `/recipes/create-url` -> `/recipes/create/url`
|
||||
- `/recipes/create-url/bulk` -> `/recipes/create/url/bulk`
|
||||
- `/recipes/create-from-zip` -> `/recipes/create/zip`
|
||||
- `/recipes/create-from-image` -> `/recipes/create/image`
|
||||
- `/groups/webhooks` -> `/households/webhooks`
|
||||
- `/groups/shopping/items` -> `/households/shopping/items`
|
||||
- `/groups/shopping/lists` -> `/households/shopping/lists`
|
||||
- `/groups/mealplans` -> `/households/mealplans`
|
||||
- `/groups/mealplans/rules` -> `/households/mealplans/rules`
|
||||
- `/groups/invitations` -> `/households/invitations`
|
||||
- `/groups/recipe-actions` -> `/households/recipe-actions`
|
||||
- `/groups/events/notifications` -> `/households/events/notifications`
|
||||
- `/groups/cookbooks` -> `/households/cookbooks`
|
||||
- `/explore/foods/{group_slug}` -> `/explore/groups/{group_slug}/foods`
|
||||
- `/explore/organizers/{group_slug}/categories` -> `/explore/groups/{group_slug}/categories`
|
||||
- `/explore/organizers/{group_slug}/tags` -> `/explore/groups/{group_slug}/tags`
|
||||
- `/explore/organizers/{group_slug}/tools` -> `/explore/groups/{group_slug}/tools`
|
||||
- `/explore/cookbooks/{group_slug}` -> `/explore/groups/{group_slug}/cookbooks`
|
||||
- `/explore/recipes/{group_slug}` -> `/explore/groups/{group_slug}/recipes`
|
||||
|
||||
`/groups/members` previously returned a `UserOut` object, but now returns a `UserSummary`. Should you need the full user information (username, email, etc.), rather than just the summary, see `/households/members` instead for the household members.
|
||||
`/groups/members` previously returned a list of users, but now returns paginated users (similar to all other list endpoints).
|
||||
|
||||
These endpoints have been completely removed:
|
||||
|
||||
- `/admin/analytics` (no longer used)
|
||||
- `/groups/permissions` (see household permissions)
|
||||
- `/groups/statistics` (see household statistics)
|
||||
- `/groups/categories` (see organizer endpoints)
|
||||
- `/recipes/summary/untagged` (no longer used)
|
||||
- `/recipes/summary/uncategorized` (no longer used)
|
||||
- `/users/group-users` (see `/groups/members` and `/households/members`)
|
||||
|
||||
### Frontend Links
|
||||
|
||||
These frontend pages have moved:
|
||||
|
||||
- `/group/mealplan/...` -> `/household/mealplan/...`
|
||||
- `/group/members` -> `/household/members`
|
||||
- `/group/notifiers` -> `/household/notifiers`
|
||||
- `/group/webhooks` -> `/household/webhooks`
|
||||
@@ -23,7 +23,7 @@ function import_from_file () {
|
||||
do
|
||||
echo $line
|
||||
curl -X 'POST' \
|
||||
"$3/api/recipes/create-url" \
|
||||
"$3/api/recipes/create/url" \
|
||||
-H "Authorization: Bearer $2" \
|
||||
-H 'accept: application/json' \
|
||||
-H 'Content-Type: application/json' \
|
||||
@@ -81,7 +81,7 @@ def import_from_file(input_file, token, mealie_url):
|
||||
data = {
|
||||
'url': line
|
||||
}
|
||||
response = requests.post(mealie_url + "/api/recipes/create-url", headers=headers, json=data)
|
||||
response = requests.post(mealie_url + "/api/recipes/create/url", headers=headers, json=data)
|
||||
print(response.text)
|
||||
|
||||
input_file="list"
|
||||
|
||||
@@ -18,32 +18,26 @@ Create an API token from Mealie's User Settings page (https://hay-kot.github.io/
|
||||
#### 2. Create Home Assistant Sensors
|
||||
|
||||
Create REST sensors in home assistant to get the details of today's meal.
|
||||
We will create sensors to get the name and ID of the first meal in today's meal plan (note that this may not be what is wanted if there is more than one meal planned for the day). We need the ID as well as the name to be able to retreive the image for the meal.
|
||||
We will create sensors to get the name and ID of the first meal in today's meal plan (note that this may not be what is wanted if there is more than one meal planned for the day). We need the ID as well as the name to be able to retrieve the image for the meal.
|
||||
|
||||
Make sure the url and port (`http://mealie:9000` ) matches your installation's address and _API_ port.
|
||||
|
||||
```yaml
|
||||
- platform: rest
|
||||
resource: "http://mealie:9000/api/groups/mealplans/today"
|
||||
method: GET
|
||||
name: Mealie todays meal
|
||||
headers:
|
||||
Authorization: Bearer <<API_TOKEN>>
|
||||
value_template: "{{ value_json[0].recipe.name }}"
|
||||
force_update: true
|
||||
scan_interval: 30
|
||||
```
|
||||
|
||||
```yaml
|
||||
- platform: rest
|
||||
resource: "http://mealie:9000/api/groups/mealplans/today"
|
||||
method: GET
|
||||
name: Mealie todays meal ID
|
||||
headers:
|
||||
Authorization: Bearer <<API_TOKEN>>
|
||||
value_template: "{{ value_json[0].recipe.id }}"
|
||||
force_update: true
|
||||
scan_interval: 30
|
||||
rest:
|
||||
- resource: "http://mealie:9000/api/households/mealplans/today"
|
||||
method: GET
|
||||
headers:
|
||||
Authorization: Bearer <<API_TOKEN>>
|
||||
scan_interval: 3600
|
||||
sensor:
|
||||
- name: Mealie todays meal
|
||||
value_template: "{{ value_json[0]['recipe']['name'] }}"
|
||||
force_update: true
|
||||
unique_id: mealie_todays_meal
|
||||
- name: Mealie todays meal ID
|
||||
value_template: "{{ value_json[0]['recipe']['id'] }}"
|
||||
force_update: true
|
||||
unique_id: mealie_todays_meal_id
|
||||
```
|
||||
|
||||
#### 3. Create a Camera Entity
|
||||
|
||||
@@ -7,7 +7,9 @@ You can use bookmarklets to generate a bookmark that will take your current loca
|
||||
You can use a [bookmarklet generator site](https://caiorss.github.io/bookmarklet-maker/) and the code below to generate a bookmark for your site. Just change the `http://localhost:8080` to your sites web address and follow the instructions.
|
||||
|
||||
```js
|
||||
var url = document.URL;
|
||||
var url = document.URL.endsWith('/') ?
|
||||
document.URL.slice(0, -1) :
|
||||
document.URL;
|
||||
var mealie = "http://localhost:8080";
|
||||
var group_slug = "home" // Change this to your group slug. You can obtain this from your URL after logging-in to Mealie
|
||||
var use_keywords= "&use_keywords=1" // Optional - use keywords from recipe - update to "" if you don't want that
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# OpenID Connect (OIDC) Authentication
|
||||
|
||||
:octicons-tag-24: v2.0.0
|
||||
|
||||
!!! note
|
||||
Breaking changes to OIDC Authentication were introduced with Mealie v2. Please see the below for [migration steps](#migration-from-mealie-v1x).
|
||||
|
||||
Looking instead for the docs for Mealie :octicons-tag-24: v1.x? [Click here](./oidc.md)
|
||||
|
||||
Mealie supports 3rd party authentication via [OpenID Connect (OIDC)](https://openid.net/connect/), an identity layer built on top of OAuth2. OIDC is supported by many Identity Providers (IdP), including:
|
||||
|
||||
- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect)
|
||||
- [Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/)
|
||||
- [Keycloak](https://www.keycloak.org/docs/latest/securing_apps/#_oidc)
|
||||
- [Okta](https://www.okta.com/openid-connect/)
|
||||
|
||||
## Account Linking
|
||||
|
||||
Signing in with OAuth will automatically find your account in Mealie and link to it. If a user does not exist in Mealie, then one will be created (if enabled), but will be unable to log in with any other authentication method. An admin can configure another authentication method for such a user.
|
||||
|
||||
If a user previously accessed Mealie via credentials and you want to no longer allow users to log in with `LDAP` or `Mealie` credentials, then you can set the user's *Authentication Method* to `OIDC`. Conversely, if a user's auth method is not `OIDC`, then they can still log in with whatever their auth method is as well as OIDC.
|
||||
|
||||
## Provider Setup
|
||||
|
||||
Before you can start using OIDC Authentication, you must first configure a new client application in your identity provider. Your identity provider must support the OAuth **Authorization Code flow with PKCE**. The steps will vary by provider, but generally, the steps are as follows.
|
||||
|
||||
1. Create a new client application
|
||||
- The Provider type should be OIDC or OAuth2
|
||||
- The Grant type should be `Authorization Code`
|
||||
- The Client type should be `confidential` (you should have a **Client Secret**)
|
||||
|
||||
2. Configure redirect URI
|
||||
|
||||
The redirect URI(s) that are needed:
|
||||
|
||||
1. `http(s)://DOMAIN:PORT/login`
|
||||
2. `http(s)://DOMAIN:PORT/login?direct=1`
|
||||
1. This URI is only required if your IdP supports [RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) such as Keycloak. You may also be able to combine this into the previous URI by using a wildcard: `http(s)://DOMAIN:PORT/login*`
|
||||
|
||||
The redirect URI(s) should include any URL that Mealie is accessible from. Some examples include
|
||||
|
||||
http://localhost:9091/login
|
||||
https://mealie.example.com/login
|
||||
|
||||
3. Configure allowed scopes
|
||||
|
||||
The scopes required are `openid profile email`
|
||||
|
||||
If you plan to use the [groups](#groups) to configure access within Mealie, you will need to also add the scope defined by the `OIDC_GROUPS_CLAIM` environment variable. The default claim is `groups`
|
||||
|
||||
## Mealie Setup
|
||||
|
||||
Take the client id and your discovery URL and update your environment variables to include the required OIDC variables described in [Installation - Backend Configuration](../installation/backend-config.md#openid-connect-oidc).
|
||||
|
||||
### Groups
|
||||
|
||||
There are two (optional) [environment variables](../installation/backend-config.md#openid-connect-oidc) that can control which of the users in your IdP can log in to Mealie and what permissions they will have. Keep in mind that these groups **do not necessarily correspond to groups in Mealie**. The groups claim is configurable via the `OIDC_GROUPS_CLAIM` environment variable. The groups should be **defined in your IdP** and be returned in the configured claim value.
|
||||
|
||||
`OIDC_USER_GROUP`: Users must be a part of this group (within your IdP) to be able to log in.
|
||||
|
||||
`OIDC_ADMIN_GROUP`: Users that are in this group (within your IdP) will be made an **admin** in Mealie. Users in this group do not need to be in the `OIDC_USER_GROUP`
|
||||
|
||||
## Examples
|
||||
|
||||
Example configurations for several Identity Providers have been provided by the Community in the [GitHub Discussions](https://github.com/mealie-recipes/mealie/discussions/categories/oauth-provider-example).
|
||||
|
||||
If you don't see your provider and have successfully set it up, please consider [creating your own example](https://github.com/mealie-recipes/mealie/discussions/new?category=oauth-provider-example) so that others can have a smoother setup.
|
||||
|
||||
|
||||
## Migration from Mealie v1.x
|
||||
|
||||
**High level changes**
|
||||
|
||||
- A Client Secret is now required
|
||||
- CORS is no longer a requirement since all authentication happens server-side
|
||||
- A user will be successfully authenticated if they are part of *either* `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP`. Admins no longer need to be part of both groups
|
||||
- ID Token signing algorithm is now inferred using the `id_token_signing_alg_values_supported` metadata from the discovery URL
|
||||
|
||||
### Changes in your IdP
|
||||
|
||||
**Required**
|
||||
|
||||
- You must change the Mealie client in your IdP to be **confidential**. The option is different for every provider, but you need to obtain a **client secret**.
|
||||
|
||||
**Optional**
|
||||
|
||||
- You may now also remove the `OIDC_USER_GROUP` from your admin users if you so desire. Users within the `OIDC_ADMIN_GROUP` will now be able to successfully authenticate with only that group.
|
||||
- You may remove any CORS configuration. i.e. configured origins
|
||||
|
||||
### Changes in Mealie
|
||||
|
||||
**Required**
|
||||
|
||||
- After obtaining the **client secret** from your IdP, you must add it to Mealie using the `OIDC_CLIENT_SECRET` environment variable or via [docker secrets](../installation/backend-config.md#docker-secrets). This secret will not be logged on startup.
|
||||
|
||||
**Optional**
|
||||
|
||||
- Remove `OIDC_SIGNING_ALGORITHM` from your environment. It will no longer have any effect.
|
||||
@@ -28,7 +28,7 @@ Before you can start using OIDC Authentication, you must first configure a new c
|
||||
The redirect URI(s) that are needed:
|
||||
|
||||
1. `http(s)://DOMAIN:PORT/login`
|
||||
2. `https(s)://DOMAIN:PORT/login?direct=1`
|
||||
2. `http(s)://DOMAIN:PORT/login?direct=1`
|
||||
1. This URI is only required if your IdP supports [RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) such as Keycloak. You may also be able to combine this into the previous URI by using a wildcard: `http(s)://DOMAIN:PORT/login*`
|
||||
|
||||
The redirect URI(s) should include any URL that Mealie is accessible from. Some examples include
|
||||
|
||||
@@ -79,7 +79,7 @@ Mealie's Recipe Steps and other fields support markdown syntax and therefore sup
|
||||
If your account has been locked by bad password attempts, you can use an administrator account to unlock another account. Alternatively, you can unlock all accounts via a script within the container.
|
||||
|
||||
```shell
|
||||
docker exec -it mealie-next bash
|
||||
docker exec -it mealie bash
|
||||
|
||||
python /app/mealie/scripts/reset_locked_users.py
|
||||
```
|
||||
@@ -89,7 +89,7 @@ python /app/mealie/scripts/reset_locked_users.py
|
||||
You can change your password by going to the user profile page and clicking the "Change Password" button. Alternatively you can use the following script to change your password via the CLI if you are locked out of your account.
|
||||
|
||||
```shell
|
||||
docker exec -it mealie-next bash
|
||||
docker exec -it mealie bash
|
||||
|
||||
python /app/mealie/scripts/change_password.py
|
||||
```
|
||||
@@ -98,13 +98,14 @@ python /app/mealie/scripts/change_password.py
|
||||
|
||||
Follow the [steps above](#how-can-i-change-my-password) for changing your password. You will be prompted if you would like to switch your authentication method back to local auth so you can log in again.
|
||||
|
||||
## How do private groups and recipes work?
|
||||
## How do private groups, households, and recipes work?
|
||||
|
||||
Managing private groups and recipes can be confusing. The following diagram and notes should help explain how they work to determine if a recipe can be shared publicly.
|
||||
|
||||
- Private links that are generated from the recipe page using the `Share` button bypass all group and recipe permissions
|
||||
- Private groups block all access to recipes, including those that are public, except as noted above.
|
||||
- Groups with "Allow users outside of your group to see your recipes" disabled block all access to recipes, except as noted above.
|
||||
- Private households, similar to private groups, block all access to recipes, except as noted above.
|
||||
- Households with "Allow users outside of your group to see your recipes" disabled block all access to recipes, except as noted above.
|
||||
- Private recipes block all access to the recipe from public links. This does not affect Private Links.
|
||||
|
||||
```mermaid
|
||||
@@ -112,7 +113,8 @@ stateDiagram-v2
|
||||
r1: Request Access
|
||||
p1: Using Private Link?
|
||||
p2: Is Group Private?
|
||||
p3: Is Recipe Private?
|
||||
p3: Is Household Private?
|
||||
p4: Is Recipe Private?
|
||||
s1: Deny Access
|
||||
n1: Allow Access
|
||||
|
||||
@@ -125,10 +127,13 @@ stateDiagram-v2
|
||||
p2 --> p3: No
|
||||
|
||||
p3 --> s1: Yes
|
||||
p3 --> n1: No
|
||||
p3 --> p4: No
|
||||
|
||||
p4 --> s1: Yes
|
||||
p4 --> n1: No
|
||||
```
|
||||
|
||||
For more information, check out the [Permissions and Public Access guide](./usage/permissions-and-public-access.md).
|
||||
For more information on public access, check out the [Permissions and Public Access guide](./usage/permissions-and-public-access.md). For more information on groups vs. households, check out the [Groups and Households](./features.md#groups-and-households) section in the Features guide.
|
||||
|
||||
## Can I use fail2ban with Mealie?
|
||||
Yes, Mealie is configured to properly forward external IP addresses into the `mealie.log` logfile. Note that due to restrictions in docker, IP address forwarding only works on Linux.
|
||||
@@ -149,3 +154,11 @@ As to why we need a database?
|
||||
|
||||
- **Developer Experience:** Without a database, a lot of the work to maintain your data is taken on by the developer instead of a battle-tested platform for storing data.
|
||||
- **Multi User Support:** With a solid database as backend storage for your data, Mealie can better support multi-user sites and avoid read/write access errors when multiple actions are taken at the same time.
|
||||
|
||||
## Why is there no "Keep Screen Alive" button when I access a recipe?
|
||||
You've perhaps visited the Mealie Demo and noticed that it had a "Keep Screen Alive" button, but it doesn't show up in your own Mealie instance.
|
||||
There are typically two possible reasons for this:
|
||||
1. You're accessing your Mealie instance without using HTTPS. The Wake Lock API is only available if HTTPS is used. Read more here: https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API
|
||||
2. You're accessing your Mealie instance on a browser which doesn't support the API. You can test this here: https://vueuse.org/core/useWakeLock/#demo
|
||||
|
||||
Solving the above points will most likely resolve your issues. However, if you're still having problems, you are welcome to create an issue. Just remember to add that you've tried the above two options first in your description.
|
||||
|
||||
@@ -35,7 +35,6 @@ Mealie has a robust and flexible recipe organization system with a few different
|
||||
|
||||
#### Categories
|
||||
|
||||
|
||||
Categories are the overarching organizer for recipes. You can assign as many categories as you'd like to a recipe, but we recommend that you try to limit the categories you assign to a recipe to one or two. This helps keep categories as focused as possible while still allowing you to find recipes that are related to each other. For example, you might assign a recipe to the category **Breakfast**, **Lunch**, **Dinner**, or **Side**.
|
||||
|
||||
[Categories Demo](https://demo.mealie.io/g/home/recipes/categories){ .md-button .md-button--primary }
|
||||
@@ -73,13 +72,13 @@ Mealie uses a calendar like view to help you plan your meals. It shows you the p
|
||||
!!! tip
|
||||
You can also add a "Note" type entry to your meal-plan when you want to include something that might not have a specific recipes. This is great for leftovers, or for ordering out.
|
||||
|
||||
[Mealplanner Demo](https://demo.mealie.io/group/mealplan/planner/view){ .md-button .md-button--primary }
|
||||
[Mealplanner Demo](https://demo.mealie.io/household/mealplan/planner/view){ .md-button .md-button--primary }
|
||||
|
||||
### Planner Rules
|
||||
|
||||
The meal planner has the concept of plan rules. These offer a flexible way to use your organizers to customize how a random recipe is inserted into your meal plan. You can set rules to restrict the pool of recipes based on the Tags and/or Categories of a recipe. Additionally, since meal plans have a Breakfast, Lunch, Dinner, and Snack labels, you can specifically set a rule to be active for a **specific meal type** or even a **specific day of the week.**
|
||||
|
||||
[Planner Settings Demo](https://demo.mealie.io/group/mealplan/settings){ .md-button .md-button--primary }
|
||||
[Planner Settings Demo](https://demo.mealie.io/household/mealplan/settings){ .md-button .md-button--primary }
|
||||
|
||||
## Shopping Lists
|
||||
|
||||
@@ -105,22 +104,22 @@ Notifiers use the [Apprise library](https://github.com/caronc/apprise/wiki), whi
|
||||
- `json` and `jsons`
|
||||
- `xml` and `xmls`
|
||||
|
||||
[Notifiers Demo](https://demo.mealie.io/group/notifiers){ .md-button .md-button--primary }
|
||||
[Notifiers Demo](https://demo.mealie.io/household/notifiers){ .md-button .md-button--primary }
|
||||
|
||||
### Webhooks
|
||||
|
||||
Unlike notifiers, which are event-driven notifications, Webhooks allow you to send scheduled notifications to your desired endpoint. Webhooks are sent on the day of a scheduled mealplan, at the specified time, and contain the mealplan data in the request.
|
||||
|
||||
[Webhooks Demo](https://demo.mealie.io/group/webhooks){ .md-button .md-button--primary }
|
||||
[Webhooks Demo](https://demo.mealie.io/household/webhooks){ .md-button .md-button--primary }
|
||||
|
||||
### Recipe Actions
|
||||
|
||||
Recipe Actions are custom actions you can add to all recipes in Mealie. This is a great way to add custom integrations that are fired manually. There are two types of recipe actions:
|
||||
|
||||
1. link - these actions will take you directly to an external page
|
||||
1. link - these actions will take you directly to an external page. Merge fields can be used to customize the URL for each recipe
|
||||
2. post - these actions will send a `POST` request to the specified URL, with the recipe JSON in the request body. These can be used, for instance, to manually trigger a webhook in Home Assistant
|
||||
|
||||
Recipe Action URLs can include merge fields to inject the current recipe's data. For instance, you can use the following URL to include a Google search with the recipe's slug:
|
||||
When using the "link" action type, Recipe Action URLs can include merge fields to inject the current recipe's data. For instance, you can use the following URL to include a Google search with the recipe's slug:
|
||||
```
|
||||
https://www.google.com/search?q=${slug}
|
||||
```
|
||||
@@ -164,6 +163,46 @@ Managing a robust collection of recipes inevitable requires a lot of data. Meali
|
||||
|
||||
[Data Management Demo](https://demo.mealie.io/group/data/foods){ .md-button .md-button--primary }
|
||||
|
||||
## Groups and Households
|
||||
|
||||
Mealie lets you fully customize how you organize your users. You can use Groups to host multiple instances (or tenants) of Mealie which are completely isolated from each other. Within each Group you can organize users into Households which allow users to share recipes, but keep other items separate (e.g. meal plans and shopping lists).
|
||||
|
||||
### Groups
|
||||
|
||||
Groups are fully isolated instances of Mealie. Think of a goup as a completely separate, fully self-contained site. There is no data shared between groups. Each group has its own users, recipes, tags, categories, etc. A user logged-in to one group cannot make any changes to another.
|
||||
|
||||
Common use cases for groups include:
|
||||
- Hosting multiple instances of Mealie for others who want to keep their data private and secure
|
||||
- Creating completely isolated recipe pools
|
||||
|
||||
### Households
|
||||
|
||||
Households are subdivisions within a single Group. Households maintain their own users and settings, while sharing their recipes with other households. Households also share organizers (tags, categories, etc.) with the entire group. Meal Plans, Shopping Lists, and Integrations are only accessible within a household.
|
||||
|
||||
Common use cases for households include:
|
||||
- Sharing a common recipe pool amongst families
|
||||
- Maintaining separate meal plans and shopping lists from other households
|
||||
- Maintaining separate integrations and customizations from other households
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
mealie[(Mealie)] ==> groups
|
||||
|
||||
%% Groups
|
||||
groups((Groups)) --> ingredients & organizers
|
||||
groups((Groups)) ====> households
|
||||
ingredients("Ingredients<br/>(Foods, Units, Labels)")
|
||||
organizers("Organizers<br/>(Categories, Tags, Tools)")
|
||||
|
||||
%% Households
|
||||
households((Households)) --> recipes & mealplans & shoppinglists & integrations
|
||||
|
||||
recipes(Recipes & Cookbooks)
|
||||
mealplans(Meal Plans)
|
||||
shoppinglists(Shopping Lists)
|
||||
integrations("Integrations<br/>(Notifiers, Webhooks)")
|
||||
```
|
||||
|
||||
## Server Administration
|
||||
|
||||
### Site Settings
|
||||
@@ -172,11 +211,13 @@ The site settings page contains general information about your installation like
|
||||
|
||||
[Settings Demo](https://demo.mealie.io/admin/site-settings){ .md-button .md-button--primary }
|
||||
|
||||
### Users and Group
|
||||
### Users, Households, and Groups
|
||||
|
||||
There is a small management area for users and groups that allows you to create, edit, and delete users and groups.
|
||||
There is a small management area for users, households, and groups.
|
||||
|
||||
[Users Demo](https://demo.mealie.io/admin/manage/users){ .md-button .md-button--primary }
|
||||
[Households Demo](https://demo.mealie.io/admin/manage/households){ .md-button .md-button--primary }
|
||||
[Groups Demo](https://demo.mealie.io/admin/manage/groups){ .md-button .md-button--primary }
|
||||
|
||||
### Backups
|
||||
|
||||
|
||||
@@ -4,20 +4,21 @@
|
||||
|
||||
### General
|
||||
|
||||
| Variables | Default | Description |
|
||||
| ----------------------------- | :-------------------: | --------------------------------------------------------------------------------------------------------- |
|
||||
| PUID | 911 | UserID permissions between host OS and container |
|
||||
| PGID | 911 | GroupID permissions between host OS and container |
|
||||
| DEFAULT_GROUP | Home | The default group for users |
|
||||
| BASE_URL | http://localhost:8080 | Used for Notifications |
|
||||
| TOKEN_TIME | 48 | The time in hours that a login/auth token is valid |
|
||||
| API_PORT | 9000 | The port exposed by backend API. **Do not change this if you're running in Docker** |
|
||||
| API_DOCS | True | Turns on/off access to the API documentation locally |
|
||||
| TZ | UTC | Must be set to get correct date/time on the server |
|
||||
| ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token |
|
||||
| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
|
||||
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug, trace) |
|
||||
| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC |
|
||||
| Variables | Default | Description |
|
||||
| ----------------------------- | :-------------------: | -------------------------------------------------------------------------------------------------- |
|
||||
| PUID | 911 | UserID permissions between host OS and container |
|
||||
| PGID | 911 | GroupID permissions between host OS and container |
|
||||
| DEFAULT_GROUP | Home | The default group for users |
|
||||
| DEFAULT_HOUSEHOLD | Family | The default household for users in each group |
|
||||
| BASE_URL | http://localhost:8080 | Used for Notifications |
|
||||
| TOKEN_TIME | 48 | The time in hours that a login/auth token is valid |
|
||||
| API_PORT | 9000 | The port exposed by backend API. **Do not change this if you're running in Docker** |
|
||||
| API_DOCS | True | Turns on/off access to the API documentation locally |
|
||||
| TZ | UTC | Must be set to get correct date/time on the server |
|
||||
| ALLOW_SIGNUP<super>\*</super> | false | Allow user sign-up without token |
|
||||
| LOG_CONFIG_OVERRIDE | | Override the config for logging with a custom path |
|
||||
| LOG_LEVEL | info | Logging level (e.g. critical, error, warning, info, debug) |
|
||||
| DAILY_SCHEDULE_TIME | 23:45 | The time of day to run daily server tasks, in HH:MM format. Use the server's local time, *not* UTC |
|
||||
|
||||
<super>\*</super> Starting in v1.4.0 this was changed to default to `false` as part of a security review of the application.
|
||||
|
||||
@@ -56,9 +57,18 @@
|
||||
|
||||
Changing the webworker settings may cause unforeseen memory leak issues with Mealie. It's best to leave these at the defaults unless you begin to experience issues with multiple users. Exercise caution when changing these settings
|
||||
|
||||
| Variables | Default | Description |
|
||||
| --------------- | :-----: | ----------------------------------------------------------------------------- |
|
||||
| UVICORN_WORKERS | 1 | Sets the number of works for the web server [more info here][unicorn_workers] |
|
||||
| Variables | Default | Description |
|
||||
| --------------- | :-----: | -------------------------------------------------------------------------------- |
|
||||
| UVICORN_WORKERS | 1 | Sets the number of workers for the web server. [More info here][unicorn_workers] |
|
||||
|
||||
### TLS
|
||||
|
||||
Use this only when mealie is run without a webserver or reverse proxy.
|
||||
|
||||
| Variables | Default | Description |
|
||||
| -------------------- | :-----: | ------------------------ |
|
||||
| TLS_CERTIFICATE_PATH | None | File path to Certificate |
|
||||
| TLS_PRIVATE_KEY_PATH | None | File path to private key |
|
||||
|
||||
### LDAP
|
||||
|
||||
@@ -82,40 +92,46 @@ Changing the webworker settings may cause unforeseen memory leak issues with Mea
|
||||
|
||||
:octicons-tag-24: v1.4.0
|
||||
|
||||
For usage, see [Usage - OpenID Connect](../authentication/oidc.md)
|
||||
For usage, see [Usage - OpenID Connect](../authentication/oidc-v2.md)
|
||||
|
||||
| Variables | Default | Description |
|
||||
| ---------------------- | :-----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| OIDC_AUTH_ENABLED | False | Enables authentication via OpenID Connect |
|
||||
| OIDC_SIGNUP_ENABLED | True | Enables new users to be created when signing in for the first time with OIDC |
|
||||
| OIDC_CONFIGURATION_URL | None | The URL to the OIDC configuration of your provider. This is usually something like https://auth.example.com/.well-known/openid-configuration |
|
||||
| OIDC_CLIENT_ID | None | The client id of your configured client in your provider |
|
||||
| OIDC_USER_GROUP | None | If specified, only users belonging to this group will be able to successfully authenticate, regardless of the `OIDC_ADMIN_GROUP`. For more information see [this page](../authentication/oidc.md#groups) |
|
||||
| OIDC_ADMIN_GROUP | None | If specified, users belonging to this group will be made an admin. For more information see [this page](../authentication/oidc.md#groups) |
|
||||
| OIDC_AUTO_REDIRECT | False | If `True`, then the login page will be bypassed an you will be sent directly to your Identity Provider. You can still get to the login page by adding `?direct=1` to the login URL |
|
||||
| OIDC_PROVIDER_NAME | OAuth | The provider name is shown in SSO login button. "Login with <OIDC_PROVIDER_NAME\>" |
|
||||
| OIDC_REMEMBER_ME | False | Because redirects bypass the login screen, you cant extend your session by clicking the "Remember Me" checkbox. By setting this value to true, a session will be extended as if "Remember Me" was checked |
|
||||
| OIDC_SIGNING_ALGORITHM | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
|
||||
| OIDC_USER_CLAIM | email | This is the claim which Mealie will use to look up an existing user by (e.g. "email", "preferred_username") |
|
||||
| OIDC_GROUPS_CLAIM | groups | Optional if not using `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP`. This is the claim Mealie will request from your IdP and will use to compare to `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP` to allow the user to log in to Mealie or is set as an admin. **Your IdP must be configured to grant this claim** |
|
||||
| OIDC_TLS_CACERTFILE | None | File path to Certificate Authority used to verify server certificate (e.g. `/path/to/ca.crt`) |
|
||||
| Variables | Default | Description |
|
||||
|---------------------------------------------------|:-------:|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| OIDC_AUTH_ENABLED | False | Enables authentication via OpenID Connect |
|
||||
| OIDC_SIGNUP_ENABLED | True | Enables new users to be created when signing in for the first time with OIDC |
|
||||
| OIDC_CONFIGURATION_URL | None | The URL to the OIDC configuration of your provider. This is usually something like https://auth.example.com/.well-known/openid-configuration |
|
||||
| OIDC_CLIENT_ID | None | The client id of your configured client in your provider |
|
||||
| OIDC_CLIENT_SECRET <br/> :octicons-tag-24: v2.0.0 | None | The client secret of your configured client in your provider |
|
||||
| OIDC_USER_GROUP | None | If specified, only users belonging to this group will be able to successfully authenticate. For more information see [this page](../authentication/oidc-v2.md#groups) |
|
||||
| OIDC_ADMIN_GROUP | None | If specified, users belonging to this group will be able to successfully authenticate *and* be made an admin. For more information see [this page](../authentication/oidc-v2.md#groups) |
|
||||
| OIDC_AUTO_REDIRECT | False | If `True`, then the login page will be bypassed and you will be sent directly to your Identity Provider. You can still get to the login page by adding `?direct=1` to the login URL |
|
||||
| OIDC_PROVIDER_NAME | OAuth | The provider name is shown in SSO login button. "Login with <OIDC_PROVIDER_NAME\>" |
|
||||
| OIDC_REMEMBER_ME | False | Because redirects bypass the login screen, you cant extend your session by clicking the "Remember Me" checkbox. By setting this value to true, a session will be extended as if "Remember Me" was checked |
|
||||
| OIDC_USER_CLAIM | email | This is the claim which Mealie will use to look up an existing user by (e.g. "email", "preferred_username") |
|
||||
| OIDC_NAME_CLAIM | name | This is the claim which Mealie will use for the users Full Name |
|
||||
| OIDC_GROUPS_CLAIM | groups | Optional if not using `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP`. This is the claim Mealie will request from your IdP and will use to compare to `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP` to allow the user to log in to Mealie or is set as an admin. **Your IdP must be configured to grant this claim** |
|
||||
| OIDC_SCOPES_OVERRIDE | None | Advanced configuration used to override the scopes requested from the IdP. **Most users won't need to change this**. At a minimum, 'openid profile email' are required. |
|
||||
| OIDC_TLS_CACERTFILE | None | File path to Certificate Authority used to verify server certificate (e.g. `/path/to/ca.crt`) |
|
||||
|
||||
### OpenAI
|
||||
|
||||
:octicons-tag-24: v1.7.0
|
||||
|
||||
Mealie supports various integrations using OpenAI. To enable OpenAI, [you must provide your OpenAI API key](https://platform.openai.com/api-keys). You can tweak how OpenAI is used using these backend settings. Please note that while OpenAI usage is optimized to reduce API costs, you're unlikely to be able to use OpenAI features with the free tier limits.
|
||||
Mealie supports various integrations using OpenAI. For more information, check out our [OpenAI documentation](./open-ai.md).
|
||||
For custom mapping variables (e.g. OPENAI_CUSTOM_HEADERS) you should pass values as JSON encoded strings (e.g. `OPENAI_CUSTOM_PARAMS='{"k1": "v1", "k2": "v2"}'`)
|
||||
|
||||
| Variables | Default | Description |
|
||||
| ------------------------- | :-----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| OPENAI_BASE_URL | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
|
||||
| OPENAI_API_KEY | None | Your OpenAI API Key. Enables OpenAI-related features |
|
||||
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
|
||||
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
|
||||
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
|
||||
| OPENAI_REQUEST_TIMEOUT | 10 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
|
||||
| Variables | Default | Description |
|
||||
| ---------------------------- | :-----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| OPENAI_BASE_URL | None | The base URL for the OpenAI API. If you're not sure, leave this empty to use the standard OpenAI platform |
|
||||
| OPENAI_API_KEY | None | Your OpenAI API Key. Enables OpenAI-related features |
|
||||
| OPENAI_MODEL | gpt-4o | Which OpenAI model to use. If you're not sure, leave this empty |
|
||||
| OPENAI_CUSTOM_HEADERS | None | Custom HTTP headers to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
|
||||
| OPENAI_CUSTOM_PARAMS | None | Custom HTTP query params to add to all OpenAI requests. This should generally be left empty unless your custom service requires them |
|
||||
| OPENAI_ENABLE_IMAGE_SERVICES | True | Whether to enable OpenAI image services, such as creating recipes via image. Leave this enabled unless your custom model doesn't support it, or you want to reduce costs |
|
||||
| OPENAI_WORKERS | 2 | Number of OpenAI workers per request. Higher values may increase processing speed, but will incur additional API costs |
|
||||
| OPENAI_SEND_DATABASE_DATA | True | Whether to send Mealie data to OpenAI to improve request accuracy. This will incur additional API costs |
|
||||
| OPENAI_REQUEST_TIMEOUT | 60 | The number of seconds to wait for an OpenAI request to complete before cancelling the request. Leave this empty unless you're running into timeout issues on slower hardware |
|
||||
|
||||
### Themeing
|
||||
### Theming
|
||||
|
||||
Setting the following environmental variables will change the theme of the frontend. Note that the themes are the same for all users. This is a break-change when migration from v0.x.x -> 1.x.x.
|
||||
|
||||
@@ -143,7 +159,7 @@ This can be used to avoid leaking passwords through compose files, environment v
|
||||
For example, to configure the Postgres database password in Docker compose, create a file on the host that contains only the password, and expose that file to the Mealie service as a secret with the correct name.
|
||||
Note that environment variables take priority over secrets, so any previously defined environment variables should be removed when migrating to secrets.
|
||||
|
||||
```
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
...
|
||||
|
||||
@@ -31,7 +31,7 @@ To deploy mealie on your local network, it is highly recommended to use Docker t
|
||||
We've gone through a few versions of Mealie v1 deployment targets. We have settled on a single container deployment, and we've begun publishing the nightly container on github containers. If you're looking to move from the old nightly (split containers _or_ the omni image) to the new nightly, there are a few things you need to do:
|
||||
|
||||
1. Take a backup just in case!
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v1.0.0-RC1.1`
|
||||
2. Replace the image for the API container with `ghcr.io/mealie-recipes/mealie:v2.2.0`
|
||||
3. Take the external port from the frontend container and set that as the port mapped to port `9000` on the new container. The frontend is now served on port 9000 from the new container, so it will need to be mapped for you to have access.
|
||||
4. Restart the container
|
||||
|
||||
@@ -65,7 +65,7 @@ After you've decided setup the files it's important to set a few ENV variables t
|
||||
- [x] You've configured the relevant ENV variables for your database selection in the `docker-compose.yaml` files.
|
||||
- [x] You've configured the [SMTP server settings](./backend-config.md#email) (used for invitations, password resets, etc). You can setup a [google app password](https://support.google.com/accounts/answer/185833?hl=en) if you want to send email via gmail.
|
||||
- [x] You've set the [`BASE_URL`](./backend-config.md#general) variable.
|
||||
- [x] You've set the `DEFAULT_EMAIL` and `DEFAULT_GROUP` variable.
|
||||
- [x] You've set the `DEFAULT_EMAIL`, `DEFAULT_GROUP`, and `DEFAULT_HOUSEHOLD` variables.
|
||||
|
||||
## Step 4: Startup
|
||||
|
||||
@@ -101,7 +101,7 @@ These backups are just plain .zip files that you can download from the UI or acc
|
||||
|
||||
### Docker Tags
|
||||
|
||||
See all available tags on [GitHub](https://github.com/mealie-recipes/mealie/pkgs/container/mealie). We do not currently publish new images to Dockerhub.
|
||||
See all available tags on [GitHub](https://github.com/mealie-recipes/mealie/pkgs/container/mealie).
|
||||
|
||||
`ghcr.io/mealie-recipes/mealie:nightly`
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# OpenAI Integration
|
||||
|
||||
:octicons-tag-24: v1.7.0
|
||||
|
||||
Mealie's OpenAI integration enables several features and enhancements throughout the application. To enable OpenAI features, you must have an account with OpenAI and configure Mealie to use the OpenAI API key (for more information, check out the [backend configuration](./backend-config.md#openai)).
|
||||
|
||||
## Configuration
|
||||
|
||||
For most users, supplying the OpenAI API key is all you need to do; you will use the regular OpenAI service with the default language model. Note that while OpenAI has a free tier, it's not sufficiently capable for Mealie (or most other production use cases). For more information, check out [OpenAI's rate limits](https://platform.openai.com/docs/guides/rate-limits). If you deposit $5 into your OpenAI account, you will be permanently bumped up to Tier 1, which is sufficient for Mealie. Cost per-request is dependant on many factors, but Mealie tries to keep token counts conservative.
|
||||
|
||||
Alternatively, if you have another service you'd like to use in-place of OpenAI, you can configure Mealie to use that instead, as long as it has an OpenAI-compatible API. For instance, a common self-hosted alternative to OpenAI is [Ollama](https://ollama.com/). To use Ollama with Mealie, change your `OPENAI_BASE_URL` to `http://localhost:11434/v1` (where `http://localhost:11434` is wherever you're hosting Ollama, and `/v1` enables the OpenAI-compatible endpoints). Note that you *must* provide an API key, even though it is ultimately ignored by Ollama.
|
||||
|
||||
If you wish to disable image recognition features (to save costs, or because your custom model doesn't support them) you can set `OPENAI_ENABLE_IMAGE_SERVICES` to `False`. For more information on what configuration options are available, check out the [backend configuration](./backend-config.md#openai).
|
||||
|
||||
## OpenAI Features
|
||||
- The OpenAI Ingredient Parser can be used as an alternative to the NLP and Brute Force parsers. Simply choose the OpenAI parser while parsing ingredients (:octicons-tag-24: v1.7.0)
|
||||
- When importing a recipe via URL, if the default recipe scraper is unable to read the recipe data from a webpage, the webpage contents will be parsed by OpenAI (:octicons-tag-24: v1.9.0)
|
||||
- You can import an image of a written recipe, which is sent to OpenAI and imported into Mealie. The recipe can be hand-written or typed, as long as the text is in the picture. You can also optionally have OpenAI translate the recipe into your own language (:octicons-tag-24: v1.12.0)
|
||||
@@ -7,7 +7,7 @@ PostgreSQL might be considered if you need to support many concurrent users. In
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v1.10.2 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v2.2.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
@@ -20,12 +20,10 @@ services:
|
||||
- mealie-data:/app/data/
|
||||
environment:
|
||||
# Set Backend ENV Variables Here
|
||||
ALLOW_SIGNUP: false
|
||||
ALLOW_SIGNUP: "false"
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
TZ: America/Anchorage
|
||||
MAX_WORKERS: 1
|
||||
WEB_CONCURRENCY: 1
|
||||
BASE_URL: https://mealie.yourdomain.com
|
||||
# Database Settings
|
||||
DB_ENGINE: postgres
|
||||
|
||||
@@ -18,7 +18,7 @@ Use your best judgement when deciding what to do.
|
||||
|
||||
By default, the API is **not** rate limited. This leaves Mealie open to a potential **Denial of Service Attack**. While it's possible to perform a **Denial of Service Attack** on any endpoint, there are a few key endpoints that are more vulnerable than others.
|
||||
|
||||
- `/api/recipes/create-url`
|
||||
- `/api/recipes/create/url`
|
||||
- `/api/recipes/{id}/image`
|
||||
|
||||
These endpoints are used to scrape data based off a user provided URL. It is possible for a malicious user to issue multiple requests to download an arbitrarily large external file (e.g a Debian ISO) and sufficiently saturate a CPU assigned to the container. While we do implement some protections against this by chunking the response, and using a timeout strategy, it's still possible to overload the CPU if an attacker issues multiple requests concurrently.
|
||||
@@ -33,7 +33,7 @@ If you'd like to mitigate this risk, we suggest that you rate limit the API in g
|
||||
|
||||
## Server Side Request Forgery
|
||||
|
||||
- `/api/recipes/create-url`
|
||||
- `/api/recipes/create/url`
|
||||
- `/api/recipes/{id}/image`
|
||||
|
||||
Given the nature of these APIs it's possible to perform a **Server Side Request Forgery** attack. This is where a malicious user can issue a request to an internal network resource, and potentially exfiltrate data. We _do_ perform some checks to mitigate access to resources within your network but at the end of the day, users of Mealie are allowed to trigger HTTP requests on **your server**.
|
||||
|
||||
@@ -11,7 +11,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
|
||||
```yaml
|
||||
services:
|
||||
mealie:
|
||||
image: ghcr.io/mealie-recipes/mealie:v1.10.2 # (3)
|
||||
image: ghcr.io/mealie-recipes/mealie:v2.2.0 # (3)
|
||||
container_name: mealie
|
||||
restart: always
|
||||
ports:
|
||||
@@ -24,12 +24,10 @@ services:
|
||||
- mealie-data:/app/data/
|
||||
environment:
|
||||
# Set Backend ENV Variables Here
|
||||
ALLOW_SIGNUP: false
|
||||
ALLOW_SIGNUP: "false"
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
TZ: America/Anchorage
|
||||
MAX_WORKERS: 1
|
||||
WEB_CONCURRENCY: 1
|
||||
BASE_URL: https://mealie.yourdomain.com
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -11,7 +11,8 @@ Mealie is a self hosted recipe manager and meal planner with a RestAPI backend a
|
||||
- 🕸 Import recipes from around the web by URL
|
||||
- 📱 Progressive Web App
|
||||
- 📆 Create Meal Plans
|
||||
- 🛒 Generate shopping lists
|
||||
- 🛒 Generate Shopping Lists
|
||||
- 🏠 Separate Users into Households and share Recipes
|
||||
- 🐳 Easy setup with Docker
|
||||
- 🎨 Customize your interface with color themed layouts
|
||||
- 🌍 localized in many languages
|
||||
@@ -27,7 +28,7 @@ Mealie is a self hosted recipe manager and meal planner with a RestAPI backend a
|
||||
- Copy Me That
|
||||
- Paprika
|
||||
- Tandoor Recipes
|
||||
- Random meal plan generation
|
||||
- Random Meal Plan generation
|
||||
- Advanced rule configuration to fine tune random recipes
|
||||
|
||||
## FAQ
|
||||
|
||||
@@ -9,15 +9,15 @@
|
||||
- Create a Backup and Download from the UI
|
||||
- Upgrade
|
||||
|
||||
## Upgrading to Mealie v1
|
||||
If you are upgrading from pre-v1.0.0 to v1.0.0, make sure you read [Migrating to Mealie v1](./migrating-to-mealie-v1.md)!
|
||||
## Upgrading to Mealie v1 or later
|
||||
If you are upgrading from pre-v1.0.0 to v1.0.0 or later (v2.0.0, etc.), make sure you read [Migrating to Mealie v1](./migrating-to-mealie-v1.md)!
|
||||
|
||||
## Backing Up Your Data
|
||||
|
||||
[See Backups and Restore Section](../getting-started/usage/backups-and-restoring.md) for details on backing up your data
|
||||
|
||||
## Docker
|
||||
For all setups using Docker the updating process looks something like this
|
||||
For all setups using Docker, the updating process looks something like this:
|
||||
|
||||
- Stop the container using `docker compose down`
|
||||
- If you are not using the latest tag, change the version (image tag) in your docker-compose file
|
||||
|
||||
@@ -19,9 +19,10 @@ Administrators can navigate to the Settings page and access the User Management
|
||||
|
||||
## Public Recipe Access
|
||||
|
||||
By default, groups are set to private, meaning only logged-in users may access the group. In order for a recipe to be viewable by public (not logged-in) users, two criteria must be met:
|
||||
By default, groups and households are set to private, meaning only logged-in users may access the group/household. In order for a recipe to be viewable by public (not logged-in) users, three criteria must be met:
|
||||
|
||||
1. The group must not be private, *and* the group setting for allowing users outside of your group to see your recipes must be enabled. These can be toggled on the Group Settings page
|
||||
1. The group must not be private
|
||||
2. The household must not be private, *and* the household setting for allowing users outside of your group to see your recipes must be enabled. These can be toggled on the Household Settings page
|
||||
2. The recipe must be set to public. This can be toggled for each recipe individually, or in bulk using the Recipe Data Management page
|
||||
|
||||
Additionally, if the group is not private, public users can view all public group data (public recipes, public cookbooks, etc.) from the home page ([e.g. the demo home page](https://demo.mealie.io/g/home)).
|
||||
@@ -32,7 +33,8 @@ More broadly, here are the rules for how recipe access is determined:
|
||||
|
||||
- Private links that are generated from the recipe page using the `Share` button bypass all group and recipe permissions
|
||||
- Private groups block all access to recipes, including those that are public, except as noted above.
|
||||
- Groups with "Allow users outside of your group to see your recipes" disabled block all access to recipes, except as noted above.
|
||||
- Private households, similar to private groups, block all access to recipes, except as noted above.
|
||||
- Households with "Allow users outside of your group to see your recipes" disabled block all access to recipes, except as noted above.
|
||||
- Private recipes block all access to the recipe from public links. This does not affect Private Links.
|
||||
|
||||
```mermaid
|
||||
@@ -40,7 +42,8 @@ stateDiagram-v2
|
||||
r1: Request Access
|
||||
p1: Using Private Link?
|
||||
p2: Is Group Private?
|
||||
p3: Is Recipe Private?
|
||||
p3: Is Household Private?
|
||||
p4: Is Recipe Private?
|
||||
s1: Deny Access
|
||||
n1: Allow Access
|
||||
|
||||
@@ -53,5 +56,8 @@ stateDiagram-v2
|
||||
p2 --> p3: No
|
||||
|
||||
p3 --> s1: Yes
|
||||
p3 --> n1: No
|
||||
p3 --> p4: No
|
||||
|
||||
p4 --> s1: Yes
|
||||
p4 --> n1: No
|
||||
```
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
site_name: Mealie
|
||||
demo_url: https://demo.mealie.io
|
||||
site_url: https://hay-kot.github.io/mealie/
|
||||
site_url: https://docs.mealie.io
|
||||
use_directory_urls: true
|
||||
theme:
|
||||
palette:
|
||||
@@ -74,13 +74,14 @@ nav:
|
||||
- Backend Configuration: "documentation/getting-started/installation/backend-config.md"
|
||||
- Security: "documentation/getting-started/installation/security.md"
|
||||
- Logs: "documentation/getting-started/installation/logs.md"
|
||||
- OpenAI: "documentation/getting-started/installation/open-ai.md"
|
||||
- Usage:
|
||||
- Backup and Restoring: "documentation/getting-started/usage/backups-and-restoring.md"
|
||||
- Permissions and Public Access: "documentation/getting-started/usage/permissions-and-public-access.md"
|
||||
|
||||
- Authentication:
|
||||
- LDAP: "documentation/getting-started/authentication/ldap.md"
|
||||
- OpenID Connect: "documentation/getting-started/authentication/oidc.md"
|
||||
- OpenID Connect: "documentation/getting-started/authentication/oidc-v2.md"
|
||||
|
||||
- Community Guides:
|
||||
- iOS Shortcuts: "documentation/community-guide/ios.md"
|
||||
@@ -100,5 +101,6 @@ nav:
|
||||
- Dev Getting Started: "contributors/developers-guide/starting-dev-server.md"
|
||||
- Database Changes: "contributors/developers-guide/database-changes.md"
|
||||
- Maintainers Guide: "contributors/developers-guide/maintainers.md"
|
||||
- Migration Guide: "contributors/developers-guide/migration-guide.md"
|
||||
- Guides:
|
||||
- Improving Ingredient Parser: "contributors/guides/ingredient-parser.md"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<v-card-text v-if="cookbook">
|
||||
<v-card-text v-if="cookbook" class="px-1">
|
||||
<v-text-field v-model="cookbook.name" :label="$t('cookbook.cookbook-name')"></v-text-field>
|
||||
<v-textarea v-model="cookbook.description" auto-grow :rows="2" :label="$t('recipe.description')"></v-textarea>
|
||||
<RecipeOrganizerSelector v-model="cookbook.categories" selector-type="categories" />
|
||||
<RecipeOrganizerSelector v-model="cookbook.tags" selector-type="tags" />
|
||||
<RecipeOrganizerSelector v-model="cookbook.tools" selector-type="tools" />
|
||||
<QueryFilterBuilder
|
||||
:field-defs="fieldDefs"
|
||||
:initial-query-filter="cookbook.queryFilter"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<v-switch v-model="cookbook.public" hide-details single-line>
|
||||
<template #label>
|
||||
{{ $t('cookbook.public-cookbook') }}
|
||||
@@ -14,33 +16,19 @@
|
||||
</HelpIcon>
|
||||
</template>
|
||||
</v-switch>
|
||||
<div class="mt-4">
|
||||
<h3 class="text-subtitle-1 d-flex align-center mb-0 pb-0">
|
||||
{{ $t('cookbook.filter-options') }}
|
||||
<HelpIcon right small class="ml-2">
|
||||
{{ $t('cookbook.filter-options-description') }}
|
||||
</HelpIcon>
|
||||
</h3>
|
||||
<v-switch v-model="cookbook.requireAllCategories" class="mt-0" hide-details single-line>
|
||||
<template #label> {{ $t('cookbook.require-all-categories') }} </template>
|
||||
</v-switch>
|
||||
<v-switch v-model="cookbook.requireAllTags" hide-details single-line>
|
||||
<template #label> {{ $t('cookbook.require-all-tags') }} </template>
|
||||
</v-switch>
|
||||
<v-switch v-model="cookbook.requireAllTools" hide-details single-line>
|
||||
<template #label> {{ $t('cookbook.require-all-tools') }} </template>
|
||||
</v-switch>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
import { defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import { ReadCookBook } from "~/lib/api/types/cookbook";
|
||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||
import { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||
|
||||
export default defineComponent({
|
||||
components: { RecipeOrganizerSelector },
|
||||
components: { QueryFilterBuilder },
|
||||
props: {
|
||||
cookbook: {
|
||||
type: Object as () => ReadCookBook,
|
||||
@@ -51,5 +39,55 @@ export default defineComponent({
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { i18n } = useContext();
|
||||
|
||||
function handleInput(value: string | undefined) {
|
||||
props.cookbook.queryFilterString = value || "";
|
||||
}
|
||||
|
||||
const fieldDefs: FieldDefinition[] = [
|
||||
{
|
||||
name: "recipe_category.id",
|
||||
label: i18n.tc("category.categories"),
|
||||
type: Organizer.Category,
|
||||
},
|
||||
{
|
||||
name: "tags.id",
|
||||
label: i18n.tc("tag.tags"),
|
||||
type: Organizer.Tag,
|
||||
},
|
||||
{
|
||||
name: "recipe_ingredient.food.id",
|
||||
label: i18n.tc("recipe.ingredients"),
|
||||
type: Organizer.Food,
|
||||
},
|
||||
{
|
||||
name: "tools.id",
|
||||
label: i18n.tc("tool.tools"),
|
||||
type: Organizer.Tool,
|
||||
},
|
||||
{
|
||||
name: "household_id",
|
||||
label: i18n.tc("household.households"),
|
||||
type: Organizer.Household,
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
label: i18n.tc("general.date-created"),
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "updated_at",
|
||||
label: i18n.tc("general.date-updated"),
|
||||
type: "date",
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
handleInput,
|
||||
fieldDefs,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
<BaseDialog
|
||||
v-if="editTarget"
|
||||
v-model="dialogStates.edit"
|
||||
:width="650"
|
||||
width="100%"
|
||||
max-width="1100px"
|
||||
:icon="$globals.icons.pages"
|
||||
:title="$t('general.edit')"
|
||||
:title="$tc('general.edit')"
|
||||
:submit-icon="$globals.icons.save"
|
||||
:submit-text="$tc('general.save')"
|
||||
:submit-disabled="!editTarget.queryFilterString"
|
||||
@submit="editCookbook"
|
||||
>
|
||||
<v-card-text>
|
||||
@@ -23,7 +25,7 @@
|
||||
<v-toolbar-title class="headline"> {{ book.name }} </v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<BaseButton
|
||||
v-if="isOwnGroup"
|
||||
v-if="canEdit"
|
||||
class="mx-1"
|
||||
:edit="true"
|
||||
@click="handleEditCookbook"
|
||||
@@ -77,6 +79,15 @@
|
||||
const tab = ref(null);
|
||||
const book = getOne(slug);
|
||||
|
||||
const isOwnHousehold = computed(() => {
|
||||
if (!($auth.user && book.value?.householdId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $auth.user.householdId === book.value.householdId;
|
||||
})
|
||||
const canEdit = computed(() => isOwnGroup.value && isOwnHousehold.value);
|
||||
|
||||
const dialogStates = reactive({
|
||||
edit: false,
|
||||
});
|
||||
@@ -116,7 +127,7 @@
|
||||
recipes,
|
||||
removeRecipe,
|
||||
replaceRecipes,
|
||||
isOwnGroup,
|
||||
canEdit,
|
||||
dialogStates,
|
||||
editTarget,
|
||||
handleEditCookbook,
|
||||
|
||||
@@ -2,30 +2,11 @@
|
||||
<div v-if="preferences">
|
||||
<BaseCardSectionTitle :title="$tc('group.general-preferences')"></BaseCardSectionTitle>
|
||||
<v-checkbox v-model="preferences.privateGroup" class="mt-n4" :label="$t('group.private-group')"></v-checkbox>
|
||||
<v-select
|
||||
v-model="preferences.firstDayOfWeek"
|
||||
:prepend-icon="$globals.icons.calendarWeekBegin"
|
||||
:items="allDays"
|
||||
item-text="name"
|
||||
item-value="value"
|
||||
:label="$t('settings.first-day-of-week')"
|
||||
/>
|
||||
|
||||
<BaseCardSectionTitle class="mt-5" :title="$tc('group.group-recipe-preferences')"></BaseCardSectionTitle>
|
||||
<template v-for="(_, key) in preferences">
|
||||
<v-checkbox
|
||||
v-if="labels[key]"
|
||||
:key="key"
|
||||
v-model="preferences[key]"
|
||||
class="mt-n4"
|
||||
:label="labels[key]"
|
||||
></v-checkbox>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
|
||||
import { defineComponent, computed } from "@nuxtjs/composition-api";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -35,48 +16,6 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const { i18n } = useContext();
|
||||
|
||||
const labels = {
|
||||
recipePublic: i18n.tc("group.allow-users-outside-of-your-group-to-see-your-recipes"),
|
||||
recipeShowNutrition: i18n.tc("group.show-nutrition-information"),
|
||||
recipeShowAssets: i18n.tc("group.show-recipe-assets"),
|
||||
recipeLandscapeView: i18n.tc("group.default-to-landscape-view"),
|
||||
recipeDisableComments: i18n.tc("group.disable-users-from-commenting-on-recipes"),
|
||||
recipeDisableAmount: i18n.tc("group.disable-organizing-recipe-ingredients-by-units-and-food"),
|
||||
};
|
||||
|
||||
const allDays = [
|
||||
{
|
||||
name: i18n.t("general.sunday"),
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.monday"),
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.tuesday"),
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.wednesday"),
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.thursday"),
|
||||
value: 4,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.friday"),
|
||||
value: 5,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.saturday"),
|
||||
value: 6,
|
||||
},
|
||||
];
|
||||
|
||||
const preferences = computed({
|
||||
get() {
|
||||
return props.value;
|
||||
@@ -87,8 +26,6 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
return {
|
||||
allDays,
|
||||
labels,
|
||||
preferences,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<v-select
|
||||
v-model="selected"
|
||||
:items="households"
|
||||
:label="label"
|
||||
:hint="description"
|
||||
:persistent-hint="!!description"
|
||||
item-text="name"
|
||||
:multiple="multiselect"
|
||||
:prepend-inner-icon="$globals.icons.household"
|
||||
return-object
|
||||
>
|
||||
<template #selection="data">
|
||||
<v-chip
|
||||
:key="data.index"
|
||||
class="ma-1"
|
||||
:input-value="data.selected"
|
||||
small
|
||||
close
|
||||
label
|
||||
color="accent"
|
||||
dark
|
||||
@click:close="removeByIndex(data.index)"
|
||||
>
|
||||
{{ data.item.name || data.item }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-select>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, useContext } from "@nuxtjs/composition-api";
|
||||
import { useHouseholdStore } from "~/composables/store/use-household-store";
|
||||
|
||||
interface HouseholdLike {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: Array as () => HouseholdLike[],
|
||||
required: true,
|
||||
},
|
||||
multiselect: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const selected = computed({
|
||||
get: () => props.value,
|
||||
set: (val) => {
|
||||
context.emit("input", val);
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (selected.value === undefined) {
|
||||
selected.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
const { i18n } = useContext();
|
||||
const label = computed(
|
||||
() => props.multiselect ? i18n.tc("household.households") : i18n.tc("household.household")
|
||||
);
|
||||
|
||||
const { store: households } = useHouseholdStore();
|
||||
function removeByIndex(index: number) {
|
||||
if (selected.value === undefined) {
|
||||
return;
|
||||
}
|
||||
const newSelected = selected.value.filter((_, i) => i !== index);
|
||||
selected.value = [...newSelected];
|
||||
}
|
||||
|
||||
return {
|
||||
selected,
|
||||
label,
|
||||
households,
|
||||
removeByIndex,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -39,7 +39,7 @@
|
||||
import { computed, defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import RecipeDialogAddToShoppingList from "~/components/Domain/Recipe/RecipeDialogAddToShoppingList.vue";
|
||||
import { ShoppingListSummary } from "~/lib/api/types/group";
|
||||
import { ShoppingListSummary } from "~/lib/api/types/household";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
@@ -5,8 +5,13 @@
|
||||
<v-select v-model="inputEntryType" :items="MEAL_TYPE_OPTIONS" :label="$t('meal-plan.meal-type')"></v-select>
|
||||
</div>
|
||||
|
||||
<RecipeOrganizerSelector v-model="inputCategories" selector-type="categories" />
|
||||
<RecipeOrganizerSelector v-model="inputTags" selector-type="tags" />
|
||||
<div class="mb-5">
|
||||
<QueryFilterBuilder
|
||||
:field-defs="fieldDefs"
|
||||
:initial-query-filter="queryFilter"
|
||||
@input="handleQueryFilterInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- TODO: proper pluralization of inputDay -->
|
||||
{{ $t('meal-plan.this-rule-will-apply', {
|
||||
@@ -18,12 +23,14 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
|
||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||
import { RecipeTag, RecipeCategory } from "~/lib/api/types/recipe";
|
||||
import QueryFilterBuilder from "~/components/Domain/QueryFilterBuilder.vue";
|
||||
import { FieldDefinition } from "~/composables/use-query-filter-builder";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import { QueryFilterJSON } from "~/lib/api/types/response";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeOrganizerSelector,
|
||||
QueryFilterBuilder,
|
||||
},
|
||||
props: {
|
||||
day: {
|
||||
@@ -34,13 +41,13 @@ export default defineComponent({
|
||||
type: String,
|
||||
default: "unset",
|
||||
},
|
||||
categories: {
|
||||
type: Array as () => RecipeCategory[],
|
||||
default: () => [],
|
||||
queryFilterString: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
tags: {
|
||||
type: Array as () => RecipeTag[],
|
||||
default: () => [],
|
||||
queryFilter: {
|
||||
type: Object as () => QueryFilterJSON,
|
||||
default: null,
|
||||
},
|
||||
showHelp: {
|
||||
type: Boolean,
|
||||
@@ -87,31 +94,70 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
const inputCategories = computed({
|
||||
const inputQueryFilterString = computed({
|
||||
get: () => {
|
||||
return props.categories;
|
||||
return props.queryFilterString;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("update:categories", val);
|
||||
context.emit("update:query-filter-string", val);
|
||||
},
|
||||
});
|
||||
|
||||
const inputTags = computed({
|
||||
get: () => {
|
||||
return props.tags;
|
||||
function handleQueryFilterInput(value: string | undefined) {
|
||||
inputQueryFilterString.value = value || "";
|
||||
};
|
||||
|
||||
const fieldDefs: FieldDefinition[] = [
|
||||
{
|
||||
name: "recipe_category.id",
|
||||
label: i18n.tc("category.categories"),
|
||||
type: Organizer.Category,
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("update:tags", val);
|
||||
{
|
||||
name: "tags.id",
|
||||
label: i18n.tc("tag.tags"),
|
||||
type: Organizer.Tag,
|
||||
},
|
||||
});
|
||||
{
|
||||
name: "recipe_ingredient.food.id",
|
||||
label: i18n.tc("recipe.ingredients"),
|
||||
type: Organizer.Food,
|
||||
},
|
||||
{
|
||||
name: "tools.id",
|
||||
label: i18n.tc("tool.tools"),
|
||||
type: Organizer.Tool,
|
||||
},
|
||||
{
|
||||
name: "household_id",
|
||||
label: i18n.tc("household.households"),
|
||||
type: Organizer.Household,
|
||||
},
|
||||
{
|
||||
name: "last_made",
|
||||
label: i18n.tc("general.last-made"),
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "created_at",
|
||||
label: i18n.tc("general.date-created"),
|
||||
type: "date",
|
||||
},
|
||||
{
|
||||
name: "updated_at",
|
||||
label: i18n.tc("general.date-updated"),
|
||||
type: "date",
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
MEAL_TYPE_OPTIONS,
|
||||
MEAL_DAY_OPTIONS,
|
||||
inputDay,
|
||||
inputEntryType,
|
||||
inputCategories,
|
||||
inputTags,
|
||||
inputQueryFilterString,
|
||||
handleQueryFilterInput,
|
||||
fieldDefs,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, ref } from "@nuxtjs/composition-api";
|
||||
import { ReadWebhook } from "~/lib/api/types/group";
|
||||
import { ReadWebhook } from "~/lib/api/types/household";
|
||||
import { timeLocalToUTC, timeUTCToLocal } from "~/composables/use-group-webhooks";
|
||||
|
||||
export default defineComponent({
|
||||
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div v-if="preferences">
|
||||
<BaseCardSectionTitle class="mt-10" :title="$tc('household.household-preferences')"></BaseCardSectionTitle>
|
||||
<div class="mb-6">
|
||||
<v-checkbox
|
||||
v-model="preferences.privateHousehold"
|
||||
hide-details
|
||||
dense
|
||||
:label="$t('household.private-household')"
|
||||
/>
|
||||
<div class="ml-8">
|
||||
<p class="text-subtitle-2 my-0 py-0">
|
||||
{{ $t("household.private-household-description") }}
|
||||
</p>
|
||||
<DocLink class="mt-2" link="/documentation/getting-started/faq/#how-do-private-groups-and-recipes-work" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<v-checkbox
|
||||
v-model="preferences.lockRecipeEditsFromOtherHouseholds"
|
||||
hide-details
|
||||
dense
|
||||
:label="$t('household.lock-recipe-edits-from-other-households')"
|
||||
/>
|
||||
<div class="ml-8">
|
||||
<p class="text-subtitle-2 my-0 py-0">
|
||||
{{ $t("household.lock-recipe-edits-from-other-households-description") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<v-select
|
||||
v-model="preferences.firstDayOfWeek"
|
||||
:prepend-icon="$globals.icons.calendarWeekBegin"
|
||||
:items="allDays"
|
||||
item-text="name"
|
||||
item-value="value"
|
||||
:label="$t('settings.first-day-of-week')"
|
||||
/>
|
||||
|
||||
<BaseCardSectionTitle class="mt-5" :title="$tc('household.household-recipe-preferences')"></BaseCardSectionTitle>
|
||||
<div class="preference-container">
|
||||
<div v-for="p in recipePreferences" :key="p.key">
|
||||
<v-checkbox
|
||||
v-model="preferences[p.key]"
|
||||
hide-details
|
||||
dense
|
||||
:label="p.label"
|
||||
/>
|
||||
<p class="ml-8 text-subtitle-2 my-0 py-0">
|
||||
{{ p.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, useContext } from "@nuxtjs/composition-api";
|
||||
import { ReadHouseholdPreferences } from "~/lib/api/types/household";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const { i18n } = useContext();
|
||||
|
||||
type Preference = {
|
||||
key: keyof ReadHouseholdPreferences;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const recipePreferences: Preference[] = [
|
||||
{
|
||||
key: "recipePublic",
|
||||
label: i18n.tc("group.allow-users-outside-of-your-group-to-see-your-recipes"),
|
||||
description: i18n.tc("group.allow-users-outside-of-your-group-to-see-your-recipes-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeShowNutrition",
|
||||
label: i18n.tc("group.show-nutrition-information"),
|
||||
description: i18n.tc("group.show-nutrition-information-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeShowAssets",
|
||||
label: i18n.tc("group.show-recipe-assets"),
|
||||
description: i18n.tc("group.show-recipe-assets-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeLandscapeView",
|
||||
label: i18n.tc("group.default-to-landscape-view"),
|
||||
description: i18n.tc("group.default-to-landscape-view-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeDisableComments",
|
||||
label: i18n.tc("group.disable-users-from-commenting-on-recipes"),
|
||||
description: i18n.tc("group.disable-users-from-commenting-on-recipes-description"),
|
||||
},
|
||||
{
|
||||
key: "recipeDisableAmount",
|
||||
label: i18n.tc("group.disable-organizing-recipe-ingredients-by-units-and-food"),
|
||||
description: i18n.tc("group.disable-organizing-recipe-ingredients-by-units-and-food-description"),
|
||||
},
|
||||
];
|
||||
|
||||
const allDays = [
|
||||
{
|
||||
name: i18n.t("general.sunday"),
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.monday"),
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.tuesday"),
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.wednesday"),
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.thursday"),
|
||||
value: 4,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.friday"),
|
||||
value: 5,
|
||||
},
|
||||
{
|
||||
name: i18n.t("general.saturday"),
|
||||
value: 6,
|
||||
},
|
||||
];
|
||||
|
||||
const preferences = computed({
|
||||
get() {
|
||||
return props.value;
|
||||
},
|
||||
set(val) {
|
||||
context.emit("input", val);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
allDays,
|
||||
preferences,
|
||||
recipePreferences,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="css">
|
||||
.preference-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-width: 600px;
|
||||
}
|
||||
</style>
|
||||
624
frontend/components/Domain/QueryFilterBuilder.vue
Normal file
624
frontend/components/Domain/QueryFilterBuilder.vue
Normal file
@@ -0,0 +1,624 @@
|
||||
<template>
|
||||
<v-card class="ma-0" style="overflow-x: auto;">
|
||||
<v-card-text class="ma-0 pa-0">
|
||||
<v-container fluid class="ma-0 pa-0">
|
||||
<draggable
|
||||
:value="fields"
|
||||
handle=".handle"
|
||||
delay="250"
|
||||
:delay-on-touch-only="true"
|
||||
v-bind="{
|
||||
animation: 200,
|
||||
group: 'recipe-instructions',
|
||||
ghostClass: 'ghost',
|
||||
}"
|
||||
@start="drag = true"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<v-row
|
||||
v-for="(field, index) in fields"
|
||||
:key="index"
|
||||
class="d-flex flex-nowrap"
|
||||
style="max-width: 100%;"
|
||||
>
|
||||
<v-col
|
||||
:cols="attrs.fields.icon.cols"
|
||||
:class="attrs.col.class"
|
||||
:style="attrs.fields.icon.style"
|
||||
>
|
||||
<v-icon
|
||||
class="handle"
|
||||
style="width: 100%; height: 100%;"
|
||||
>
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</v-col>
|
||||
<v-col
|
||||
:cols="attrs.fields.logicalOperator.cols"
|
||||
:class="attrs.col.class"
|
||||
:style="attrs.fields.logicalOperator.style"
|
||||
>
|
||||
<v-select
|
||||
v-if="index"
|
||||
v-model="field.logicalOperator"
|
||||
:items="[logOps.AND, logOps.OR]"
|
||||
item-text="label"
|
||||
item-value="value"
|
||||
@input="setLogicalOperatorValue(field, index, $event)"
|
||||
>
|
||||
<template #selection="{ item }">
|
||||
<span :class="attrs.select.textClass" style="width: 100%;">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-if="showAdvanced"
|
||||
:cols="attrs.fields.leftParens.cols"
|
||||
:class="attrs.col.class"
|
||||
:style="attrs.fields.leftParens.style"
|
||||
>
|
||||
<v-select
|
||||
v-model="field.leftParenthesis"
|
||||
:items="['', '(', '((', '(((']"
|
||||
@input="setLeftParenthesisValue(field, index, $event)"
|
||||
>
|
||||
<template #selection="{ item }">
|
||||
<span :class="attrs.select.textClass" style="width: 100%;">
|
||||
{{ item }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col
|
||||
:cols="attrs.fields.fieldName.cols"
|
||||
:class="attrs.col.class"
|
||||
:style="attrs.fields.fieldName.style"
|
||||
>
|
||||
<v-select
|
||||
v-model="field.label"
|
||||
:items="fieldDefs"
|
||||
item-text="label"
|
||||
@change="setField(index, $event)"
|
||||
>
|
||||
<template #selection="{ item }">
|
||||
<span :class="attrs.select.textClass" style="width: 100%;">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col
|
||||
:cols="attrs.fields.relationalOperator.cols"
|
||||
:class="attrs.col.class"
|
||||
:style="attrs.fields.relationalOperator.style"
|
||||
>
|
||||
<v-select
|
||||
v-if="field.type !== 'boolean'"
|
||||
v-model="field.relationalOperatorValue"
|
||||
:items="field.relationalOperatorOptions"
|
||||
item-text="label"
|
||||
item-value="value"
|
||||
@input="setRelationalOperatorValue(field, index, $event)"
|
||||
>
|
||||
<template #selection="{ item }">
|
||||
<span :class="attrs.select.textClass" style="width: 100%;">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col
|
||||
:cols="attrs.fields.fieldValue.cols"
|
||||
:class="attrs.col.class"
|
||||
:style="attrs.fields.fieldValue.style"
|
||||
>
|
||||
<v-select
|
||||
v-if="field.fieldOptions"
|
||||
v-model="field.values"
|
||||
:items="field.fieldOptions"
|
||||
item-text="label"
|
||||
item-value="value"
|
||||
multiple
|
||||
@input="setFieldValues(field, index, $event)"
|
||||
/>
|
||||
<v-text-field
|
||||
v-else-if="field.type === 'string'"
|
||||
v-model="field.value"
|
||||
@input="setFieldValue(field, index, $event)"
|
||||
/>
|
||||
<v-text-field
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model="field.value"
|
||||
type="number"
|
||||
@input="setFieldValue(field, index, $event)"
|
||||
/>
|
||||
<v-checkbox
|
||||
v-else-if="field.type === 'boolean'"
|
||||
v-model="field.value"
|
||||
@change="setFieldValue(field, index, $event)"
|
||||
/>
|
||||
<v-menu
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="datePickers[index]"
|
||||
:close-on-content-click="false"
|
||||
transition="scale-transition"
|
||||
offset-y
|
||||
max-width="290px"
|
||||
min-width="auto"
|
||||
>
|
||||
<template #activator="{ on, attrs: menuAttrs }">
|
||||
<v-text-field
|
||||
v-model="field.value"
|
||||
persistent-hint
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
v-bind="menuAttrs"
|
||||
readonly
|
||||
v-on="on"
|
||||
/>
|
||||
</template>
|
||||
<v-date-picker
|
||||
v-model="field.value"
|
||||
no-title
|
||||
:first-day-of-week="firstDayOfWeek"
|
||||
:local="$i18n.locale"
|
||||
@input="setFieldValue(field, index, $event)"
|
||||
/>
|
||||
</v-menu>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Category"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Category"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
@input="setOrganizerValues(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Tag"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Tag"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
@input="setOrganizerValues(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Tool"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Tool"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
@input="setOrganizerValues(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Food"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Food"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
@input="setOrganizerValues(field, index, $event)"
|
||||
/>
|
||||
<RecipeOrganizerSelector
|
||||
v-else-if="field.type === Organizer.Household"
|
||||
v-model="field.organizers"
|
||||
:selector-type="Organizer.Household"
|
||||
:show-add="false"
|
||||
:show-label="false"
|
||||
:show-icon="false"
|
||||
@input="setOrganizerValues(field, index, $event)"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-if="showAdvanced"
|
||||
:cols="attrs.fields.rightParens.cols"
|
||||
:class="attrs.col.class"
|
||||
:style="attrs.fields.rightParens.style"
|
||||
>
|
||||
<v-select
|
||||
v-model="field.rightParenthesis"
|
||||
:items="['', ')', '))', ')))']"
|
||||
@input="setRightParenthesisValue(field, index, $event)"
|
||||
>
|
||||
<template #selection="{ item }">
|
||||
<span :class="attrs.select.textClass" style="width: 100%;">
|
||||
{{ item }}
|
||||
</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
<v-col
|
||||
:cols="attrs.fields.fieldActions.cols"
|
||||
:class="attrs.col.class"
|
||||
:style="attrs.fields.fieldActions.style"
|
||||
>
|
||||
<BaseButtonGroup
|
||||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $tc('general.delete'),
|
||||
event: 'delete',
|
||||
disabled: fields.length === 1,
|
||||
}
|
||||
]"
|
||||
class="my-auto"
|
||||
@delete="removeField(index)"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</draggable>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-container fluid class="d-flex justify-end pa-0">
|
||||
<v-checkbox
|
||||
v-model="showAdvanced"
|
||||
hide-details
|
||||
:label="$tc('general.show-advanced')"
|
||||
class="my-auto mr-4"
|
||||
/>
|
||||
<BaseButton create :text="$tc('general.add-field')" @click="addField(fieldDefs[0])" />
|
||||
</v-container>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import draggable from "vuedraggable";
|
||||
import { computed, defineComponent, reactive, ref, toRefs, watch } from "@nuxtjs/composition-api";
|
||||
import { useHouseholdSelf } from "~/composables/use-households";
|
||||
import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||
import { Organizer } from "~/lib/api/types/non-generated";
|
||||
import { LogicalOperator, QueryFilterJSON, QueryFilterJSONPart, RelationalKeyword, RelationalOperator } from "~/lib/api/types/response";
|
||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||
import { Field, FieldDefinition, FieldValue, OrganizerBase, useQueryFilterBuilder } from "~/composables/use-query-filter-builder";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
draggable,
|
||||
RecipeOrganizerSelector,
|
||||
},
|
||||
props: {
|
||||
fieldDefs: {
|
||||
type: Array as () => FieldDefinition[],
|
||||
required: true,
|
||||
},
|
||||
initialQueryFilter: {
|
||||
type: Object as () => QueryFilterJSON | null,
|
||||
default: null,
|
||||
}
|
||||
},
|
||||
setup(props, context) {
|
||||
const { household } = useHouseholdSelf();
|
||||
const { logOps, relOps, buildQueryFilterString, getFieldFromFieldDef, isOrganizerType } = useQueryFilterBuilder();
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
showAdvanced: false,
|
||||
qfValid: false,
|
||||
datePickers: [] as boolean[],
|
||||
drag: false,
|
||||
});
|
||||
|
||||
const storeMap = {
|
||||
[Organizer.Category]: useCategoryStore(),
|
||||
[Organizer.Tag]: useTagStore(),
|
||||
[Organizer.Tool]: useToolStore(),
|
||||
[Organizer.Food]: useFoodStore(),
|
||||
[Organizer.Household]: useHouseholdStore(),
|
||||
};
|
||||
|
||||
function onDragEnd(event: any) {
|
||||
state.drag = false;
|
||||
|
||||
const oldIndex: number = event.oldIndex;
|
||||
const newIndex: number = event.newIndex;
|
||||
state.datePickers[oldIndex] = false;
|
||||
state.datePickers[newIndex] = false;
|
||||
|
||||
const field = fields.value.splice(oldIndex, 1)[0];
|
||||
fields.value.splice(newIndex, 0, field);
|
||||
}
|
||||
|
||||
const fields = ref<Field[]>([]);
|
||||
|
||||
function addField(field: FieldDefinition) {
|
||||
fields.value.push(getFieldFromFieldDef(field));
|
||||
state.datePickers.push(false);
|
||||
};
|
||||
|
||||
function setField(index: number, fieldLabel: string) {
|
||||
state.datePickers[index] = false;
|
||||
const fieldDef = props.fieldDefs.find((fieldDef) => fieldDef.label === fieldLabel);
|
||||
if (!fieldDef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resetValue = (fieldDef.type !== fields.value[index].type) || (fieldDef.fieldOptions !== fields.value[index].fieldOptions);
|
||||
const updatedField = {...fields.value[index], ...fieldDef};
|
||||
|
||||
// we have to set this explicitly since it might be undefined
|
||||
updatedField.fieldOptions = fieldDef.fieldOptions;
|
||||
|
||||
fields.value.splice(index, 1, getFieldFromFieldDef(updatedField, resetValue));
|
||||
}
|
||||
|
||||
function setLeftParenthesisValue(field: Field, index: number, value: string) {
|
||||
fields.value.splice(index, 1, {
|
||||
...field,
|
||||
leftParenthesis: value,
|
||||
});
|
||||
}
|
||||
|
||||
function setRightParenthesisValue(field: Field, index: number, value: string) {
|
||||
fields.value.splice(index, 1, {
|
||||
...field,
|
||||
rightParenthesis: value,
|
||||
});
|
||||
}
|
||||
|
||||
function setLogicalOperatorValue(field: Field, index: number, value: LogicalOperator | undefined) {
|
||||
if (!value) {
|
||||
value = logOps.value.AND.value;
|
||||
}
|
||||
|
||||
fields.value.splice(index, 1, {
|
||||
...field,
|
||||
logicalOperator: value ? logOps.value[value] : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function setRelationalOperatorValue(field: Field, index: number, value: RelationalKeyword | RelationalOperator) {
|
||||
fields.value.splice(index, 1, {
|
||||
...field,
|
||||
relationalOperatorValue: relOps.value[value],
|
||||
});
|
||||
}
|
||||
|
||||
function setFieldValue(field: Field, index: number, value: FieldValue) {
|
||||
state.datePickers[index] = false;
|
||||
fields.value.splice(index, 1, {
|
||||
...field,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
function setFieldValues(field: Field, index: number, values: FieldValue[]) {
|
||||
fields.value.splice(index, 1, {
|
||||
...field,
|
||||
values,
|
||||
});
|
||||
}
|
||||
|
||||
function setOrganizerValues(field: Field, index: number, values: OrganizerBase[]) {
|
||||
setFieldValues(field, index, values.map((value) => value.id.toString()));
|
||||
}
|
||||
|
||||
function removeField(index: number) {
|
||||
fields.value.splice(index, 1);
|
||||
state.datePickers.splice(index, 1);
|
||||
};
|
||||
|
||||
watch(
|
||||
// Toggling showAdvanced changes the builder logic without changing the field values,
|
||||
// so we need to manually trigger reactivity to re-run the builder.
|
||||
() => state.showAdvanced,
|
||||
() => {
|
||||
if (fields.value?.length) {
|
||||
fields.value = [...fields.value];
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => fields.value,
|
||||
(newFields) => {
|
||||
newFields.forEach((field, index) => {
|
||||
const updatedField = getFieldFromFieldDef(field);
|
||||
fields.value[index] = updatedField;
|
||||
});
|
||||
|
||||
const qf = buildQueryFilterString(fields.value, state.showAdvanced);
|
||||
if (qf) {
|
||||
console.debug(`Set query filter: ${qf}`);
|
||||
}
|
||||
state.qfValid = !!qf;
|
||||
|
||||
context.emit("input", qf || undefined);
|
||||
},
|
||||
{
|
||||
deep: true
|
||||
},
|
||||
);
|
||||
|
||||
async function hydrateOrganizers(field: Field, index: number) {
|
||||
if (!field.values?.length || !isOrganizerType(field.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
field.organizers = [];
|
||||
|
||||
const { store, actions } = storeMap[field.type];
|
||||
if (!store.value.length) {
|
||||
await actions.refresh();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
const organizers = field.values.map((value) => store.value.find((organizer) => organizer.id === value));
|
||||
field.organizers = organizers.filter((organizer) => organizer !== undefined) as OrganizerBase[];
|
||||
setOrganizerValues(field, index, field.organizers);
|
||||
}
|
||||
|
||||
function initFieldsError(error = "") {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
fields.value = [];
|
||||
if (props.fieldDefs.length) {
|
||||
addField(props.fieldDefs[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeFields() {
|
||||
if (!props.initialQueryFilter?.parts?.length) {
|
||||
return initFieldsError();
|
||||
};
|
||||
|
||||
const initFields: Field[] = [];
|
||||
let error = false;
|
||||
props.initialQueryFilter.parts.forEach((part: QueryFilterJSONPart, index: number) => {
|
||||
const fieldDef = props.fieldDefs.find((fieldDef) => fieldDef.name === part.attributeName);
|
||||
if (!fieldDef) {
|
||||
error = true;
|
||||
return initFieldsError(`Invalid query filter; unknown attribute name "${part.attributeName || ""}"`);
|
||||
}
|
||||
|
||||
const field = getFieldFromFieldDef(fieldDef);
|
||||
field.leftParenthesis = part.leftParenthesis || field.leftParenthesis;
|
||||
field.rightParenthesis = part.rightParenthesis || field.rightParenthesis;
|
||||
field.logicalOperator = part.logicalOperator ?
|
||||
logOps.value[part.logicalOperator] : field.logicalOperator;
|
||||
field.relationalOperatorValue = part.relationalOperator ?
|
||||
relOps.value[part.relationalOperator] : field.relationalOperatorValue;
|
||||
|
||||
if (field.leftParenthesis || field.rightParenthesis) {
|
||||
state.showAdvanced = true;
|
||||
}
|
||||
|
||||
if (field.fieldOptions?.length || isOrganizerType(field.type)) {
|
||||
if (typeof part.value === "string") {
|
||||
field.values = part.value ? [part.value] : [];
|
||||
} else {
|
||||
field.values = part.value || [];
|
||||
}
|
||||
|
||||
if (isOrganizerType(field.type)) {
|
||||
hydrateOrganizers(field, index);
|
||||
}
|
||||
|
||||
} else if (field.type === "boolean") {
|
||||
const boolString = part.value || "false";
|
||||
field.value = (
|
||||
boolString[0].toLowerCase() === "t" ||
|
||||
boolString[0].toLowerCase() === "y" ||
|
||||
boolString[0] === "1"
|
||||
);
|
||||
} else if (field.type === "number") {
|
||||
field.value = Number(part.value as string || "0");
|
||||
if (isNaN(field.value)) {
|
||||
error = true;
|
||||
return initFieldsError(`Invalid query filter; invalid number value "${(part.value || "").toString()}"`);
|
||||
}
|
||||
} else if (field.type === "date") {
|
||||
field.value = part.value as string || "";
|
||||
const date = new Date(field.value);
|
||||
if (isNaN(date.getTime())) {
|
||||
error = true;
|
||||
return initFieldsError(`Invalid query filter; invalid date value "${(part.value || "").toString()}"`);
|
||||
}
|
||||
} else {
|
||||
field.value = part.value as string || "";
|
||||
}
|
||||
|
||||
initFields.push(field);
|
||||
});
|
||||
|
||||
if (initFields.length && !error) {
|
||||
fields.value = initFields;
|
||||
} else {
|
||||
initFieldsError();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
initializeFields();
|
||||
} catch (error) {
|
||||
initFieldsError(`Error initializing fields: ${(error || "").toString()}`);
|
||||
}
|
||||
|
||||
|
||||
const attrs = computed(() => {
|
||||
const baseColMaxWidth = 55;
|
||||
const attrs = {
|
||||
col: {
|
||||
class: "d-flex justify-center align-end field-col pa-1",
|
||||
},
|
||||
select: {
|
||||
textClass: "d-flex justify-center text-center",
|
||||
},
|
||||
fields: {
|
||||
icon: {
|
||||
cols: 1,
|
||||
style: "width: fit-content;",
|
||||
},
|
||||
leftParens: {
|
||||
cols: state.showAdvanced ? 1 : 0,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
|
||||
},
|
||||
logicalOperator: {
|
||||
cols: 1,
|
||||
style: `min-width: ${baseColMaxWidth}px;`,
|
||||
},
|
||||
fieldName: {
|
||||
cols: state.showAdvanced ? 2 : 3,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
|
||||
},
|
||||
relationalOperator: {
|
||||
cols: 2,
|
||||
style: `min-width: ${baseColMaxWidth * 2}px;`,
|
||||
},
|
||||
fieldValue: {
|
||||
cols: state.showAdvanced ? 3 : 4,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth * 2 : baseColMaxWidth * 3}px;`,
|
||||
},
|
||||
rightParens: {
|
||||
cols: state.showAdvanced ? 1 : 0,
|
||||
style: `min-width: ${state.showAdvanced ? baseColMaxWidth : 0}px;`,
|
||||
},
|
||||
fieldActions: {
|
||||
cols: 1,
|
||||
style: `min-width: ${baseColMaxWidth}px;`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return attrs;
|
||||
})
|
||||
|
||||
return {
|
||||
Organizer,
|
||||
...toRefs(state),
|
||||
logOps,
|
||||
relOps,
|
||||
attrs,
|
||||
firstDayOfWeek,
|
||||
onDragEnd,
|
||||
// Fields
|
||||
fields,
|
||||
addField,
|
||||
setField,
|
||||
setLeftParenthesisValue,
|
||||
setRightParenthesisValue,
|
||||
setLogicalOperatorValue,
|
||||
setRelationalOperatorValue,
|
||||
setFieldValue,
|
||||
setFieldValues,
|
||||
setOrganizerValues,
|
||||
removeField,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
* {
|
||||
font-size: 1em;
|
||||
}
|
||||
</style>
|
||||
@@ -21,31 +21,23 @@
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
<div v-if="!open" class="custom-btn-group ma-1">
|
||||
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :recipe-id="recipe.id" show-always />
|
||||
<RecipeTimelineBadge v-if="loggedIn" button-style :slug="recipe.slug" :recipe-name="recipe.name" />
|
||||
<RecipeFavoriteBadge v-if="loggedIn" class="ml-1" color="info" button-style :recipe-id="recipe.id" show-always />
|
||||
<RecipeTimelineBadge v-if="loggedIn" button-style class="ml-1" :slug="recipe.slug" :recipe-name="recipe.name" />
|
||||
<div v-if="loggedIn">
|
||||
<v-tooltip v-if="!locked" bottom color="info">
|
||||
<v-tooltip v-if="canEdit" bottom color="info">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn fab small class="mx-1" color="info" v-bind="attrs" v-on="on" @click="$emit('edit', true)">
|
||||
<v-btn fab small class="ml-1" color="info" v-bind="attrs" v-on="on" @click="$emit('edit', true)">
|
||||
<v-icon> {{ $globals.icons.edit }} </v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<span>{{ $t("general.edit") }}</span>
|
||||
</v-tooltip>
|
||||
<v-tooltip v-else bottom color="info">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn fab small class="mx-1" color="info" v-bind="attrs" v-on="on">
|
||||
<v-icon> {{ $globals.icons.lock }} </v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<span> {{ $t("recipe.locked-by-owner") }} </span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
|
||||
<RecipeTimerMenu
|
||||
fab
|
||||
color="info"
|
||||
class="mr-1"
|
||||
class="ml-1"
|
||||
/>
|
||||
|
||||
<RecipeContextMenu
|
||||
@@ -72,6 +64,7 @@
|
||||
share: loggedIn,
|
||||
recipeActions: true,
|
||||
}"
|
||||
class="ml-1"
|
||||
@print="$emit('print')"
|
||||
/>
|
||||
</div>
|
||||
@@ -135,7 +128,7 @@ export default defineComponent({
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
locked: {
|
||||
canEdit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
:recipe-id="recipeId"
|
||||
:use-items="{
|
||||
delete: false,
|
||||
edit: true,
|
||||
edit: false,
|
||||
download: true,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<v-img
|
||||
v-if="!fallBackImage"
|
||||
:height="height"
|
||||
min-height="125"
|
||||
max-height="fill-height"
|
||||
:src="getImage(recipeId)"
|
||||
@click="$emit('click')"
|
||||
@load="fallBackImage = false"
|
||||
@@ -52,8 +54,8 @@ export default defineComponent({
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 200,
|
||||
type: [Number, String],
|
||||
default: "fill-height",
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
|
||||
@@ -1,83 +1,86 @@
|
||||
<template>
|
||||
<v-expand-transition>
|
||||
<v-card
|
||||
:ripple="false"
|
||||
:class="isFlat ? 'mx-auto flat' : 'mx-auto'"
|
||||
:style="{ cursor }"
|
||||
hover
|
||||
:to="$listeners.selected ? undefined : recipeRoute"
|
||||
@click="$emit('selected')"
|
||||
>
|
||||
<v-img v-if="vertical" class="rounded-sm">
|
||||
<RecipeCardImage
|
||||
:icon-size="100"
|
||||
:height="150"
|
||||
:slug="slug"
|
||||
:recipe-id="recipeId"
|
||||
small
|
||||
:image-version="image"
|
||||
/>
|
||||
</v-img>
|
||||
<v-list-item three-line :class="vertical ? 'px-2' : 'px-0'">
|
||||
<slot v-if="!vertical" name="avatar">
|
||||
<v-list-item-avatar tile size="125" class="v-mobile-img rounded-sm my-0">
|
||||
<RecipeCardImage
|
||||
:icon-size="100"
|
||||
:height="125"
|
||||
:slug="slug"
|
||||
:recipe-id="recipeId"
|
||||
small
|
||||
:image-version="image"
|
||||
/>
|
||||
</v-list-item-avatar>
|
||||
</slot>
|
||||
<v-list-item-content class="py-0">
|
||||
<v-list-item-title class="mt-3 mb-1">{{ name }} </v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
<SafeMarkdown :source="description" />
|
||||
</v-list-item-subtitle>
|
||||
<div class="d-flex flex-wrap justify-start">
|
||||
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" />
|
||||
</div>
|
||||
<div class="d-flex flex-wrap justify-end align-center">
|
||||
<slot name="actions">
|
||||
<RecipeFavoriteBadge v-if="isOwnGroup && showRecipeContent" :recipe-id="recipeId" show-always />
|
||||
<RecipeRating
|
||||
:class="isOwnGroup ? 'ml-auto' : 'ml-auto pb-2'"
|
||||
:value="rating"
|
||||
:recipe-id="recipeId"
|
||||
<div :style="`height: ${height}`">
|
||||
<v-expand-transition>
|
||||
<v-card
|
||||
:ripple="false"
|
||||
:class="isFlat ? 'mx-auto flat' : 'mx-auto'"
|
||||
:style="{ cursor }"
|
||||
hover
|
||||
:to="$listeners.selected ? undefined : recipeRoute"
|
||||
@click="$emit('selected')"
|
||||
>
|
||||
<v-img v-if="vertical" class="rounded-sm">
|
||||
<RecipeCardImage
|
||||
:icon-size="100"
|
||||
:height="height"
|
||||
:slug="slug"
|
||||
:recipe-id="recipeId"
|
||||
small
|
||||
:image-version="image"
|
||||
/>
|
||||
</v-img>
|
||||
<v-list-item three-line :class="vertical ? 'px-2' : 'px-0'">
|
||||
<slot v-if="!vertical" name="avatar">
|
||||
<v-list-item-avatar tile :height="height" width="125" class="v-mobile-img rounded-sm my-0">
|
||||
<RecipeCardImage
|
||||
:icon-size="100"
|
||||
:height="height"
|
||||
:slug="slug"
|
||||
:small="true"
|
||||
:recipe-id="recipeId"
|
||||
:image-version="image"
|
||||
small
|
||||
/>
|
||||
<v-spacer></v-spacer>
|
||||
</v-list-item-avatar>
|
||||
</slot>
|
||||
<v-list-item-content class="py-0">
|
||||
<v-list-item-title class="mt-1 mb-1 text-top">{{ name }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="ma-0 text-top">
|
||||
<SafeMarkdown :source="description" />
|
||||
</v-list-item-subtitle>
|
||||
<div class="d-flex flex-wrap justify-start ma-0">
|
||||
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" />
|
||||
</div>
|
||||
<div class="d-flex flex-wrap justify-end align-center">
|
||||
<slot name="actions">
|
||||
<RecipeFavoriteBadge v-if="isOwnGroup && showRecipeContent" :recipe-id="recipeId" show-always />
|
||||
<RecipeRating
|
||||
v-if="showRecipeContent"
|
||||
:class="isOwnGroup ? 'ml-auto' : 'ml-auto pb-2'"
|
||||
:value="rating"
|
||||
:recipe-id="recipeId"
|
||||
:slug="slug"
|
||||
:small="true"
|
||||
/>
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
||||
<!-- We also add padding to the v-rating above to compensate -->
|
||||
<RecipeContextMenu
|
||||
v-if="isOwnGroup && showRecipeContent"
|
||||
:slug="slug"
|
||||
:menu-icon="$globals.icons.dotsHorizontal"
|
||||
:name="name"
|
||||
:recipe-id="recipeId"
|
||||
:use-items="{
|
||||
delete: false,
|
||||
edit: true,
|
||||
download: true,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
print: false,
|
||||
printPreferences: false,
|
||||
share: true,
|
||||
}"
|
||||
@deleted="$emit('delete', slug)"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<slot />
|
||||
</v-card>
|
||||
</v-expand-transition>
|
||||
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
||||
<!-- We also add padding to the v-rating above to compensate -->
|
||||
<RecipeContextMenu
|
||||
v-if="isOwnGroup && showRecipeContent"
|
||||
:slug="slug"
|
||||
:menu-icon="$globals.icons.dotsHorizontal"
|
||||
:name="name"
|
||||
:recipe-id="recipeId"
|
||||
:use-items="{
|
||||
delete: false,
|
||||
edit: false,
|
||||
download: true,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
print: false,
|
||||
printPreferences: false,
|
||||
share: true,
|
||||
}"
|
||||
@deleted="$emit('delete', slug)"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<slot />
|
||||
</v-card>
|
||||
</v-expand-transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -135,6 +138,14 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
height: {
|
||||
type: [Number, String],
|
||||
default: 150,
|
||||
},
|
||||
imageHeight: {
|
||||
type: [Number, String],
|
||||
default: "fill-height",
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { $auth } = useContext();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user