Compare commits
428 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2b1115b3b | ||
|
|
b6b505c361 | ||
|
|
97cef449c9 | ||
|
|
fef6f695ce | ||
|
|
73a24a8ef0 | ||
|
|
e727cae020 | ||
|
|
894d2d2e6b | ||
|
|
543e52d596 | ||
|
|
f39433142d | ||
|
|
f2765c75c6 | ||
|
|
47049808b7 | ||
|
|
150d4c7309 | ||
|
|
d116d08adf | ||
|
|
82d2e479b2 | ||
|
|
df81aec02e | ||
|
|
74779fc488 | ||
|
|
ac9922ff61 | ||
|
|
ff0cd6fa93 | ||
|
|
777f4518be | ||
|
|
84591fd17a | ||
|
|
7536425e39 | ||
|
|
9d28ce48fe | ||
|
|
d6b438b5f4 | ||
|
|
87d6ca0200 | ||
|
|
bcda57a4fa | ||
|
|
3e55207a8d | ||
|
|
80eee945a0 | ||
|
|
3436ef4877 | ||
|
|
20e9d4a990 | ||
|
|
3a1c9aa462 | ||
|
|
de9f0ad8f8 | ||
|
|
61f43f78ec | ||
|
|
a3f2c1bed2 | ||
|
|
6781128c1b | ||
|
|
73b7f60222 | ||
|
|
46a9d19374 | ||
|
|
6ba1ff4505 | ||
|
|
58c5b2c301 | ||
|
|
5d1d6d4248 | ||
|
|
0f251bee9b | ||
|
|
149c5b5f5e | ||
|
|
7d051336d3 | ||
|
|
79da8db889 | ||
|
|
ec842aa657 | ||
|
|
61c2d5eb61 | ||
|
|
41e3ec41e9 | ||
|
|
086570ce90 | ||
|
|
d2783429a1 | ||
|
|
de19a10cba | ||
|
|
f312631676 | ||
|
|
6c52b7bbd9 | ||
|
|
900f1a6f7a | ||
|
|
ff0a7c5262 | ||
|
|
e0acd1de83 | ||
|
|
585c31490a | ||
|
|
3e7f96c0b8 | ||
|
|
d45adc1688 | ||
|
|
b0fe98c091 | ||
|
|
103878e107 | ||
|
|
175fca2b39 | ||
|
|
4600aab13a | ||
|
|
966a107414 | ||
|
|
69674e2648 | ||
|
|
bcd2e44493 | ||
|
|
e745e4be0c | ||
|
|
3afd18ccdc | ||
|
|
24f1fb228e | ||
|
|
431e213514 | ||
|
|
1e00fa16db | ||
|
|
ac1c283efb | ||
|
|
0a6a8b760f | ||
|
|
4cdd784259 | ||
|
|
461cb20a4f | ||
|
|
7ebf4d5e2a | ||
|
|
e50d3233fd | ||
|
|
cc980b2e8a | ||
|
|
93e965697a | ||
|
|
8d65d20d1f | ||
|
|
a112824578 | ||
|
|
6192277778 | ||
|
|
148324b37f | ||
|
|
c30ce471c2 | ||
|
|
63cfa14a21 | ||
|
|
53c715b6f6 | ||
|
|
96146a388a | ||
|
|
d2a0bb1ec1 | ||
|
|
d826b9f38a | ||
|
|
e8d9cc6ad9 | ||
|
|
e9689d347c | ||
|
|
b275fdcf62 | ||
|
|
a0ebc47ade | ||
|
|
b698fad83a | ||
|
|
5e53c66eaa | ||
|
|
37008ef290 | ||
|
|
35ee5847ca | ||
|
|
935dee853e | ||
|
|
7b75e279b0 | ||
|
|
15c758b24a | ||
|
|
26ec1724a5 | ||
|
|
96c4823664 | ||
|
|
5ab19b7958 | ||
|
|
09716f2b00 | ||
|
|
138a29770a | ||
|
|
36584346cb | ||
|
|
c7dd5dd8bb | ||
|
|
a16ad2c887 | ||
|
|
ca728b45ca | ||
|
|
9fd87dbf23 | ||
|
|
384a49b1c6 | ||
|
|
477236009c | ||
|
|
93cff8873e | ||
|
|
d9feb61e85 | ||
|
|
00875c0d8e | ||
|
|
f1b7ed7d7a | ||
|
|
fce293e722 | ||
|
|
09062cb12c | ||
|
|
098f88e0b8 | ||
|
|
6992bf83aa | ||
|
|
32044907bf | ||
|
|
3fcd613ca8 | ||
|
|
5b8a22762b | ||
|
|
c41c319d25 | ||
|
|
6690c3b206 | ||
|
|
56bcd4f887 | ||
|
|
47c690526e | ||
|
|
ec14338159 | ||
|
|
bf3fe1c716 | ||
|
|
fede79fc04 | ||
|
|
9251613cd6 | ||
|
|
0bd6df9d57 | ||
|
|
24e660156c | ||
|
|
345ffe4d6d | ||
|
|
e5b7cf5f30 | ||
|
|
b563447674 | ||
|
|
523a2b41d1 | ||
|
|
a0741f6ad3 | ||
|
|
b52c3d6bd4 | ||
|
|
803369a7a6 | ||
|
|
2e3e629406 | ||
|
|
d7894e07e9 | ||
|
|
63dbdfa4a6 | ||
|
|
961b3f07b5 | ||
|
|
7a1ee9a9b2 | ||
|
|
f18980a9e2 | ||
|
|
08733751aa | ||
|
|
b271f81af2 | ||
|
|
0900e4c57d | ||
|
|
d99e523608 | ||
|
|
50829dce47 | ||
|
|
910dc29f06 | ||
|
|
486c871cb5 | ||
|
|
2b94500ffe | ||
|
|
d25ea34512 | ||
|
|
fbc3dcdfef | ||
|
|
642015b368 | ||
|
|
99f06955dc | ||
|
|
9e5a7b2cc0 | ||
|
|
948eb9be3e | ||
|
|
ec778edb93 | ||
|
|
a431031c04 | ||
|
|
082a656210 | ||
|
|
9f51b9fd16 | ||
|
|
98aadf2869 | ||
|
|
54107000af | ||
|
|
999fe2bc61 | ||
|
|
23bd0a7d90 | ||
|
|
f9059f636c | ||
|
|
95aff5c998 | ||
|
|
bf9b8a0230 | ||
|
|
c79432567c | ||
|
|
ddc484562b | ||
|
|
97b5f64718 | ||
|
|
b042ab72cd | ||
|
|
a59ac44f3b | ||
|
|
acafcbc077 | ||
|
|
6ff0e3b7b3 | ||
|
|
bb43ed203a | ||
|
|
1bb412e007 | ||
|
|
e69d1c3408 | ||
|
|
cd51d12618 | ||
|
|
ee130f9077 | ||
|
|
0eebd438ca | ||
|
|
983d40f2c1 | ||
|
|
bbd01fdb04 | ||
|
|
ea2f493e01 | ||
|
|
9b364d57c7 | ||
|
|
068a09e28e | ||
|
|
83c7e318ea | ||
|
|
24ed6a1cd2 | ||
|
|
816ced83b5 | ||
|
|
b93fb99e1b | ||
|
|
9203b8e985 | ||
|
|
7b936ec4fd | ||
|
|
99f0ab830b | ||
|
|
34028587fc | ||
|
|
df0cfc3677 | ||
|
|
16e2af8c5d | ||
|
|
02aec7d6d6 | ||
|
|
cb913f6cea | ||
|
|
5bb20bd479 | ||
|
|
c561ddc08c | ||
|
|
fd3743377b | ||
|
|
381c3bf3fa | ||
|
|
5d37a1dc0b | ||
|
|
cf07040ece | ||
|
|
f2028ee928 | ||
|
|
0c39ddcf66 | ||
|
|
8ddbc34017 | ||
|
|
6ef06b2650 | ||
|
|
67581c7fa4 | ||
|
|
bc1f28eda6 | ||
|
|
61daf9d5c9 | ||
|
|
a37c77bb84 | ||
|
|
6d2c48a1c8 | ||
|
|
bed5b72864 | ||
|
|
325d6e4326 | ||
|
|
f7ff700c7a | ||
|
|
41c8e53569 | ||
|
|
ff8e431630 | ||
|
|
eb9b2ac6fe | ||
|
|
1ad468e652 | ||
|
|
986bda0c81 | ||
|
|
bb361001b9 | ||
|
|
26aa0207aa | ||
|
|
8b1bd3c555 | ||
|
|
b59c7288b1 | ||
|
|
f5b456018d | ||
|
|
4dad26102a | ||
|
|
afc7718c95 | ||
|
|
e5ef19ffe4 | ||
|
|
ecf065db2b | ||
|
|
4c03d1eb87 | ||
|
|
b71e9fe57d | ||
|
|
7ab6276397 | ||
|
|
c7da37e7e7 | ||
|
|
00f9bc087c | ||
|
|
f2c658cb2d | ||
|
|
c35f71370e | ||
|
|
edb9c883f7 | ||
|
|
0405c123f4 | ||
|
|
b84a330883 | ||
|
|
2d8c6ef44a | ||
|
|
6af5f59c80 | ||
|
|
3c4384e2f6 | ||
|
|
e50239f067 | ||
|
|
4fa6919ca0 | ||
|
|
db6fe4256f | ||
|
|
8a73f018f0 | ||
|
|
b93b16d6eb | ||
|
|
6acf4bb831 | ||
|
|
86134eecb4 | ||
|
|
3716e2bb0f | ||
|
|
b5e08a4828 | ||
|
|
976cedd536 | ||
|
|
4af5a4e96e | ||
|
|
9044f9e1ff | ||
|
|
4b719af4e1 | ||
|
|
78fa5338d3 | ||
|
|
e9f2b875b9 | ||
|
|
fe3f817bc5 | ||
|
|
32f5cf64e5 | ||
|
|
5aadb66013 | ||
|
|
6225648e3a | ||
|
|
29903af085 | ||
|
|
8ed2562454 | ||
|
|
fd255fd6ad | ||
|
|
8931fa8557 | ||
|
|
251bd88f70 | ||
|
|
2ac076afa5 | ||
|
|
2d82fc1ddd | ||
|
|
ba9d85dfc9 | ||
|
|
c752b2e81b | ||
|
|
19df1cf65d | ||
|
|
ebdd8fc053 | ||
|
|
924576c8ba | ||
|
|
f4fa28bfbc | ||
|
|
0c2cb599ba | ||
|
|
54f85196e7 | ||
|
|
a1093ed918 | ||
|
|
caa33810c4 | ||
|
|
fd8229684c | ||
|
|
320d94a223 | ||
|
|
43ccc351c7 | ||
|
|
d36e4c6e0a | ||
|
|
fdeede5717 | ||
|
|
738b601462 | ||
|
|
2c93a2f177 | ||
|
|
6b2d164585 | ||
|
|
ee707eba5c | ||
|
|
f26b09cc0a | ||
|
|
4e88f846af | ||
|
|
6a1d892e8b | ||
|
|
b90c70b2a3 | ||
|
|
bcf50f30bc | ||
|
|
065ed6c437 | ||
|
|
285e09f40a | ||
|
|
0398f36949 | ||
|
|
ea30eb96cd | ||
|
|
b787ae49bb | ||
|
|
f8e2283a69 | ||
|
|
13d51a7b46 | ||
|
|
e74ae06b64 | ||
|
|
aa495250c9 | ||
|
|
f8ee48c23b | ||
|
|
320246b18b | ||
|
|
00992da998 | ||
|
|
2b9ad2feed | ||
|
|
257127bd4e | ||
|
|
b1df118140 | ||
|
|
da6b437b20 | ||
|
|
6fe4c79b0d | ||
|
|
1793753cb4 | ||
|
|
9ed1aff0d2 | ||
|
|
51c3ec5762 | ||
|
|
5feeabb498 | ||
|
|
c4aa3eb019 | ||
|
|
e8b9f473a6 | ||
|
|
279b4dc025 | ||
|
|
6a1226ca26 | ||
|
|
b9ee7d53fa | ||
|
|
ace7ee4274 | ||
|
|
16968db1cf | ||
|
|
2b24155dd2 | ||
|
|
5a7c914fe7 | ||
|
|
f822e03be0 | ||
|
|
1bdf14dbf9 | ||
|
|
6ef173d82d | ||
|
|
1e471ad40d | ||
|
|
4ff1a6bc93 | ||
|
|
7d1f47edc5 | ||
|
|
f69d7898d5 | ||
|
|
9692e2386b | ||
|
|
93b2e2d7e4 | ||
|
|
8b2833f353 | ||
|
|
643dbbc294 | ||
|
|
c4273a4c3f | ||
|
|
95461316a5 | ||
|
|
1775b64ba4 | ||
|
|
5a9270373f | ||
|
|
37f98ce9fe | ||
|
|
fa556c9a7f | ||
|
|
29e1d1286c | ||
|
|
f489043077 | ||
|
|
bdd004518c | ||
|
|
840f5ec60d | ||
|
|
566eea1d75 | ||
|
|
bb48655acb | ||
|
|
d723165b1c | ||
|
|
592bd4f11e | ||
|
|
0aec23fcdd | ||
|
|
a23dc717aa | ||
|
|
d364994ed7 | ||
|
|
a38ed28512 | ||
|
|
1f5c02bcc3 | ||
|
|
f4afdfbc07 | ||
|
|
f753b63b13 | ||
|
|
6f3068a28c | ||
|
|
aa57b47d18 | ||
|
|
113e9ef1e3 | ||
|
|
5899527621 | ||
|
|
0a40de0f14 | ||
|
|
bc31f013c0 | ||
|
|
e7fc15dc72 | ||
|
|
79396cec9e | ||
|
|
5e07c6130f | ||
|
|
94e1fdfbff | ||
|
|
a0d414c83f | ||
|
|
1441368465 | ||
|
|
6f301c4771 | ||
|
|
ec31d251ea | ||
|
|
289625923f | ||
|
|
a42a76a2cf | ||
|
|
fd1216cd22 | ||
|
|
3f6a342026 | ||
|
|
f72fc699f8 | ||
|
|
cdcca80196 | ||
|
|
400cd2f6a0 | ||
|
|
37a4821d01 | ||
|
|
d165075a96 | ||
|
|
a062173ebd | ||
|
|
806963c396 | ||
|
|
851853740d | ||
|
|
7ca88f3c0a | ||
|
|
ac2e9dd6cb | ||
|
|
b2a34ce59a | ||
|
|
6124501f5a | ||
|
|
4ec313f752 | ||
|
|
dd07c56ede | ||
|
|
77fae46aee | ||
|
|
ff573b0358 | ||
|
|
247eab2a4f | ||
|
|
dc46502667 | ||
|
|
ac58f1959d | ||
|
|
f4543f8d65 | ||
|
|
d0ef5e27df | ||
|
|
9ea90f1c87 | ||
|
|
e7922a7e47 | ||
|
|
d3bc440c83 | ||
|
|
910b28fe2d | ||
|
|
89cd8bc2d2 | ||
|
|
d2e9ad2ae6 | ||
|
|
56c9edd328 | ||
|
|
7732aa7646 | ||
|
|
9863447bac | ||
|
|
53b00cc4c8 | ||
|
|
4f34ec1be8 | ||
|
|
e444ba91f0 | ||
|
|
fa3513eb65 | ||
|
|
d323778f1d | ||
|
|
53cb5afef6 | ||
|
|
0349301919 | ||
|
|
a5d2bd75d6 | ||
|
|
26499ad431 | ||
|
|
2eb72953f0 | ||
|
|
6fd9cf0d8c | ||
|
|
1e800889e4 | ||
|
|
422113a745 | ||
|
|
e687d0e569 | ||
|
|
76c1529ec1 | ||
|
|
d30b2b7ec8 | ||
|
|
c4fbad614e | ||
|
|
2f038edf8c | ||
|
|
a754002f4e | ||
|
|
31dabd4757 | ||
|
|
7a89015ac5 | ||
|
|
2b1cde2efc | ||
|
|
368d631602 | ||
|
|
652b4bf2af |
@@ -14,5 +14,4 @@ LICENSE
|
||||
.idea
|
||||
LICENSE.md
|
||||
docs
|
||||
nginx
|
||||
update.sh
|
||||
@@ -5,22 +5,36 @@ DEBUG=0
|
||||
# hosts the application can run under e.g. recipes.mydomain.com,cooking.mydomain.com,...
|
||||
ALLOWED_HOSTS=*
|
||||
|
||||
# random secret key, use for example base64 /dev/urandom | head -c50 to generate one
|
||||
# random secret key, use for example `base64 /dev/urandom | head -c50` to generate one
|
||||
SECRET_KEY=
|
||||
|
||||
# your default timezone See https://timezonedb.com/time-zones for a list of timezones
|
||||
TIMEZONE=Europe/Berlin
|
||||
|
||||
# add only a database password if you want to run with the default postgres, otherwise change settings accordingly
|
||||
DB_ENGINE=django.db.backends.postgresql_psycopg2
|
||||
DB_ENGINE=django.db.backends.postgresql
|
||||
POSTGRES_HOST=db_recipes
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=djangodb
|
||||
POSTGRES_USER=djangouser
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_DB=djangodb
|
||||
|
||||
# the default value for the user preference 'fractions' (enable/disable fraction support)
|
||||
# default: disabled=0
|
||||
FRACTION_PREF_DEFAULT=0
|
||||
|
||||
# the default value for the user preference 'comments' (enable/disable commenting system)
|
||||
# default comments enabled=1
|
||||
COMMENT_PREF_DEFAULT=1
|
||||
|
||||
# Users can set a amount of time after which the shopping list is refreshed when they are in viewing mode
|
||||
# This is the minimum interval users can set. Setting this to low will allow users to refresh very frequently which
|
||||
# might cause high load on the server. (Technically they can obviously refresh as often as they want with their own scripts)
|
||||
SHOPPING_MIN_AUTOSYNC_INTERVAL=5
|
||||
|
||||
# Default for user setting sticky navbar
|
||||
#STICKY_NAV_PREF_DEFAULT=1
|
||||
|
||||
# If staticfiles are stored at a different location uncomment and change accordingly
|
||||
# STATIC_URL=/static/
|
||||
|
||||
@@ -33,14 +47,13 @@ SHOPPING_MIN_AUTOSYNC_INTERVAL=5
|
||||
# when unset: 1 (true) - this is temporary until an appropriate amount of time has passed for everyone to migrate
|
||||
GUNICORN_MEDIA=0
|
||||
|
||||
|
||||
# allow authentication via reverse proxy (e.g. authelia), leave of if you dont know what you are doing
|
||||
# docs: https://github.com/vabene1111/recipes/tree/develop/docs/docker/nginx-proxy%20with%20proxy%20authentication
|
||||
# allow authentication via reverse proxy (e.g. authelia), leave off if you dont know what you are doing
|
||||
# see docs for more information https://vabene1111.github.io/recipes/features/authentication/
|
||||
# when unset: 0 (false)
|
||||
REVERSE_PROXY_AUTH=0
|
||||
|
||||
|
||||
# the default value for the user preference 'comments' (enable/disable commenting system)
|
||||
# when unset: 1 (true)
|
||||
COMMENT_PREF_DEFAULT=1
|
||||
# allows you to setup OAuth providers
|
||||
# see docs for more information https://vabene1111.github.io/recipes/features/authentication/
|
||||
# SOCIAL_PROVIDERS = allauth.socialaccount.providers.github, allauth.socialaccount.providers.nextcloud,
|
||||
|
||||
|
||||
1
.github/workflows/ci.yml
vendored
@@ -22,6 +22,7 @@ jobs:
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
python3 manage.py collectstatic --noinput
|
||||
python3 manage.py collectstatic_js_reverse
|
||||
- name: Django Testing project
|
||||
run: |
|
||||
python3 manage.py test
|
||||
|
||||
26
.github/workflows/docker-publish-beta.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: publish beta image docker
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'beta'
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- name: Update version file
|
||||
uses: DamianReeves/write-file-action@v1.0
|
||||
with:
|
||||
path: recipes/version.py
|
||||
contents: |
|
||||
VERSION_NUMBER = 'beta'
|
||||
BUILD_REF = '${{ github.sha }}'
|
||||
write-mode: overwrite
|
||||
- name: Build and publish image
|
||||
uses: ilteoood/docker_buildx@master
|
||||
with:
|
||||
publish: true
|
||||
imageName: vabene1111/recipes
|
||||
tag: beta
|
||||
dockerHubUser: ${{ secrets.DOCKER_USERNAME }}
|
||||
dockerHubPassword: ${{ secrets.DOCKER_PASSWORD }}
|
||||
17
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Make Docs
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.x
|
||||
- run: pip install mkdocs-material
|
||||
- run: mkdocs gh-deploy --force
|
||||
1
.gitignore
vendored
@@ -77,3 +77,4 @@ postgresql/
|
||||
|
||||
|
||||
/docker-compose.override.yml
|
||||
vue/node_modules
|
||||
|
||||
3
.idea/dictionaries/vabene1111_PC.xml
generated
@@ -2,9 +2,12 @@
|
||||
<dictionary name="vabene1111-PC">
|
||||
<words>
|
||||
<w>autosync</w>
|
||||
<w>chowdown</w>
|
||||
<w>csrftoken</w>
|
||||
<w>gunicorn</w>
|
||||
<w>ical</w>
|
||||
<w>mealie</w>
|
||||
<w>safron</w>
|
||||
<w>traefik</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
|
||||
4
.idea/recipes.iml
generated
@@ -20,10 +20,6 @@
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.8 (recipes)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
<orderEntry type="library" name="jquery-3.4.1" level="application" />
|
||||
<orderEntry type="library" name="pretty-checkbox" level="application" />
|
||||
<orderEntry type="library" name="pdf" level="application" />
|
||||
<orderEntry type="library" name="pdf_viewer" level="application" />
|
||||
</component>
|
||||
<component name="TemplatesService">
|
||||
<option name="TEMPLATE_CONFIGURATION" value="Django" />
|
||||
|
||||
31
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
# See https://pre-commit.com for more information
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pre-commit-yarn-build
|
||||
name: Build javascript files
|
||||
entry: yarn --cwd ./vue build
|
||||
always_run: true
|
||||
language: system
|
||||
types: [ python ]
|
||||
pass_filenames: false
|
||||
|
||||
#- id: pre-commit-django-migrations
|
||||
# name: Check django migrations
|
||||
# entry: bash -c './venv/bin/activate && ./manage.py makemigrations --check'
|
||||
# language: system
|
||||
# types: [ python ]
|
||||
# pass_filenames: false
|
||||
# - id: pre-commit-django-make-messages
|
||||
# name: Make messages if necessary
|
||||
# entry: ./manage.py makemessages -i venv -a
|
||||
# language: system
|
||||
# types: [ python ]
|
||||
# pass_filenames: false
|
||||
# - id: pre-commit-django-compile-messages
|
||||
# name: Compile messages if necessary
|
||||
# entry: ./manage.py compilemessages -i venv
|
||||
# language: system
|
||||
# types: [ python ]
|
||||
# pass_filenames: false
|
||||
@@ -1,11 +1,63 @@
|
||||
Many thanks to everyone who contributed to this project!
|
||||
Many thanks to everyone who contributed to this project! If you add something or help out feel free to add yourself
|
||||
to this list.
|
||||
|
||||
## Code/Features
|
||||
Please have a look at the [list of pull requests](https://github.com/vabene1111/recipes/pulls) for
|
||||
a complete list of contributions.
|
||||
Below are some of the larger contributions made yet.
|
||||
|
||||
|
||||
- @tourn provided the serving feature and **several** other improvements!
|
||||
- @l0c4lh057 provided a much improved ingredient text parser in [#277](https://github.com/vabene1111/recipes/pull/277)
|
||||
- @sebimarkgraf added nutritional information [#199](https://github.com/vabene1111/recipes/pull/199)
|
||||
- @cazier added reverse proxy authentication [#88](https://github.com/vabene1111/recipes/pull/88)
|
||||
|
||||
## Translations
|
||||
|
||||
### Catalan
|
||||
[Rubenix](https://www.transifex.com/user/profile/rubenix/)
|
||||
|
||||
### Dutch
|
||||
[D0T1X](https://www.transifex.com/user/profile/D0T1X/)
|
||||
[D0T1X](https://www.transifex.com/user/profile/D0T1X/)
|
||||
[ikbenfrank](https://www.transifex.com/user/profile/ikbenfrank/)
|
||||
[kampsj](https://www.transifex.com/user/profile/kampsj/)
|
||||
|
||||
### French
|
||||
[jt117](https://www.transifex.com/user/profile/jt117/)
|
||||
[nerdinator](https://www.transifex.com/user/profile/nerdinator/)
|
||||
[nerdinator](https://www.transifex.com/user/profile/nerdinator/)
|
||||
[agaume](https://www.transifex.com/user/profile/agaume/)
|
||||
|
||||
### German
|
||||
[eTaurus](https://www.transifex.com/user/profile/eTaurus/)
|
||||
[l0c4lh057](https://www.transifex.com/user/profile/l0c4lh057/)
|
||||
|
||||
### Hungarian
|
||||
[igazka](https://www.transifex.com/user/profile/igazka/)
|
||||
|
||||
### Italian
|
||||
[SK3LA](https://www.transifex.com/user/profile/SK3LA/)
|
||||
[auanasgheps](https://www.transifex.com/user/profile/auanasgheps/)
|
||||
|
||||
### Latvian
|
||||
[melkypie](https://github.com/melkypie)
|
||||
|
||||
### Portuguese
|
||||
|
||||
[hds](https://www.transifex.com/user/profile/hds/)
|
||||
[mlopezifu](https://www.transifex.com/user/profile/mlopezifu/)
|
||||
[stormsz](https://www.transifex.com/user/profile/stormsz/)
|
||||
|
||||
### Spanish
|
||||
|
||||
[albertocp](https://www.transifex.com/user/profile/albertocp/)
|
||||
[alfa5](https://www.transifex.com/user/profile/alfa5/)
|
||||
[mlopezifu](https://www.transifex.com/user/profile/mlopezifu/)
|
||||
[sergio.laya](https://www.transifex.com/user/profile/sergio.laya/)
|
||||
|
||||
### Turkish
|
||||
|
||||
[batmanisnaked](https://www.transifex.com/user/profile/batmanisnaked/)
|
||||
|
||||
### Vietnamese
|
||||
|
||||
[vuongtrunghieu](https://www.transifex.com/user/profile/vuongtrunghieu/)
|
||||
@@ -7,11 +7,11 @@ EXPOSE 8080
|
||||
RUN mkdir /opt/recipes
|
||||
WORKDIR /opt/recipes
|
||||
COPY . ./
|
||||
RUN chmod +x boot.sh setup.sh
|
||||
RUN ln -s /opt/recipes/setup.sh /usr/local/bin/createsuperuser
|
||||
RUN chmod +x boot.sh
|
||||
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev && \
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libressl-dev libffi-dev cargo && \
|
||||
python -m venv venv && \
|
||||
/opt/recipes/venv/bin/python -m pip install --upgrade pip && \
|
||||
venv/bin/pip install -r requirements.txt --no-cache-dir &&\
|
||||
apk --purge del .build-deps
|
||||
|
||||
|
||||
109
README.md
@@ -1,78 +1,65 @@
|
||||
# Recipes 
|
||||
<h1 align="center">
|
||||
<br>
|
||||
<a href="https://app.tandoor.dev"><img src="https://github.com/vabene1111/recipes/raw/develop/docs/logo_color.svg" height="256px" width="256px"></a>
|
||||
<br>
|
||||
Tandoor Recipes
|
||||
<br>
|
||||
</h1>
|
||||
|
||||
Recipes is a Django application to manage, tag and search recipes using either built in models or external storage providers hosting PDF's, Images or other files.
|
||||
<h4 align="center">The recipe manager that allows you to manage your ever growing collection of digital recipes.</h4>
|
||||
|
||||
<p align="center">
|
||||
|
||||
<img src="https://github.com/vabene1111/recipes/workflows/Continous%20Integration/badge.svg?branch=develop" >
|
||||
<img src="https://img.shields.io/github/stars/vabene1111/recipes" >
|
||||
<img src="https://img.shields.io/github/forks/vabene1111/recipes" >
|
||||
<img src="https://img.shields.io/docker/pulls/vabene1111/recipes" >
|
||||
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://docs.tandoor.dev/install/docker/" target="_blank" rel="noopener noreferrer">Installation</a> •
|
||||
<a href="https://docs.tandoor.dev/" target="_blank" rel="noopener noreferrer">Documentation</a> •
|
||||
<a href="https://app.tandoor.dev/" target="_blank" rel="noopener noreferrer">Demo</a>
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
[More Screenshots](https://imgur.com/a/V01151p)
|
||||
|
||||
## Features
|
||||
|
||||
- :package: **Sync** files with Dropbox and Nextcloud (more can easily be added)
|
||||
- :mag: Powerful **search** with Djangos [TrigramSimilarity](https://docs.djangoproject.com/en/3.0/ref/contrib/postgres/search/#trigram-similarity)
|
||||
- :label: Create and search for **tags**, assign them in batch to all files matching certain filters
|
||||
- :page_facing_up: **Create recipes** locally within a nice, standardized web interface
|
||||
- :arrow_down: **Import recipes** from thousands of websites supporting [ld+json or microdata](https://schema.org/Recipe)
|
||||
- :iphone: Optimized for use on **mobile** devices like phones and tablets
|
||||
- :shopping_cart: Generate **shopping** lists from recipes
|
||||
- :calendar: Create a **Plan** on what to eat when
|
||||
- :family: **Share** recipes with friends and comment on them to suggest or remember changes you made
|
||||
- :whale: Easy setup with **Docker**
|
||||
- :art: Customize your interface with **themes**
|
||||
- :envelope: Export and import recipes from other users
|
||||
- :heavy_plus_sign: Many more like recipe scaling, image compression, cookbooks, printing views, ...
|
||||
- 📦 **Sync** files with Dropbox and Nextcloud (more can easily be added)
|
||||
- 🔍 Powerful **search** with Djangos [TrigramSimilarity](https://docs.djangoproject.com/en/3.0/ref/contrib/postgres/search/#trigram-similarity)
|
||||
- 🏷️ Create and search for **tags**, assign them in batch to all files matching certain filters
|
||||
- 📄 **Create recipes** locally within a nice, standardized web interface
|
||||
- ⬇️ **Import recipes** from thousands of websites supporting [ld+json or microdata](https://schema.org/Recipe)
|
||||
- 📱 Optimized for use on **mobile** devices like phones and tablets
|
||||
- 🛒 Generate **shopping** lists from recipes
|
||||
- 📆 Create a **Plan** on what to eat when
|
||||
- 👪 **Share** recipes with friends and comment on them to suggest or remember changes you made
|
||||
- ➗ automatically convert decimal units to **fractions** for those who like this
|
||||
- 🐳 Easy setup with **Docker** and included examples for Kubernetes, Unraid and Synology
|
||||
- 🎨 Customize your interface with **themes**
|
||||
- ✉️ Export and import recipes from other users
|
||||
- 🌍 localized in many languages thanks to the awesome community
|
||||
- ➕ Many more like recipe scaling, image compression, cookbooks, printing views, ...
|
||||
|
||||
This application is meant for people with a collection of recipes they want to share with family and friends or simply
|
||||
store them in a nicely organized way. A basic permission system exists but this application is not meant to be run as a public page.
|
||||
Some Documentation can be found [here](https://github.com/vabene1111/recipes/wiki)
|
||||
|
||||
## Installation
|
||||
|
||||
The docker image (`vabene1111/recipes`) simply exposes the application on port `8080`. You may choose any preferred installation method, the following are just examples to make it easier.
|
||||
|
||||
### Docker-Compose
|
||||
|
||||
1. Choose one of the included configurations [here](docs/docker).
|
||||
2. Download the environment (config) file template and fill it out `wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template -O .env`
|
||||
3. Start the container `docker-compose up -d`
|
||||
4. Open the page to create the first user. Alternatively use `docker-compose exec web_recipes createsuperuser`
|
||||
|
||||
### Manual
|
||||
|
||||
**Python >= 3.8** is required to run this!
|
||||
|
||||
Refer to [manual install](docs/manual_install) for detailled instructions.
|
||||
|
||||
## Updating
|
||||
|
||||
While intermediate updates can be skipped when updating please make sure to **read the release notes** in case some special action is required to update.
|
||||
|
||||
0. Before updating it is recommended to **create a backup!**
|
||||
1. Stop the container using `docker-compose down`
|
||||
2. Pull the latest image using `docker-compose pull`
|
||||
3. Start the container again using `docker-compose up -d`
|
||||
|
||||
## Kubernetes
|
||||
|
||||
You can find a basic kubernetes setup [here](docs/k8s/). Please see the README in the folder for more detail.
|
||||
|
||||
## Contributing
|
||||
|
||||
Pull Requests and ideas are welcome, feel free to contribute in any way.
|
||||
For any questions on how to work with django please refer to their excellent [documentation](https://www.djangoproject.com/start/).
|
||||
|
||||
### Translating
|
||||
|
||||
There is a [transifex project](https://www.transifex.com/django-recipes/django-cookbook/) project to enable community driven translations. If you want to contribute a new language or help maintain an already existing one feel free to create a transifex account (using the link above) and request to join the project.
|
||||
|
||||
It is also possible to provide the translations directly by creating a new language using `manage.py makemessages -l <language_code> -i venv`. Once finished simply open a PR with the changed files.
|
||||
store them in a nicely organized way. A basic permission system exists but this application is not meant to be run as
|
||||
a public page.
|
||||
Documentation can be found [here](https://docs.tandoor.dev/).
|
||||
|
||||
While this application has been around for a while and is actively used by many (including myself), it is still considered
|
||||
**beta** software that has a lot of rough edges and unpolished parts.
|
||||
## License
|
||||
|
||||
Beginning with version 0.10.0 the code in this repository is licensed under the [GNU AGPL v3](https://www.gnu.org/licenses/agpl-3.0.de.html) license with an
|
||||
[common clause](https://commonsclause.com/) selling exception. See [LICENSE.md](https://github.com/vabene1111/recipes/blob/develop/LICENSE.md) for details.
|
||||
|
||||
**Reasoning**
|
||||
> NOTE: There appears to be a whole range of legal issues with licensing anything else then the standard completely open licenses.
|
||||
> I am in the process of getting some professional legal advice to sort out these issues.
|
||||
> Please also see [Issue 238](https://github.com/vabene1111/recipes/issues/238) for some discussion and **reasoning** regarding the topic.
|
||||
|
||||
**Reasoning**
|
||||
**This software and *all* its features are and will always be free for everyone to use and enjoy.**
|
||||
|
||||
The reason for the selling exception is that a significant amount of time was spend over multiple years to develop this software.
|
||||
|
||||
10
SECURITY.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Since this software is still considered beta/WIP support is always only given for the latest version. There are no backports of security or any other fixes.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please open a normal public issue if you have any security related concerns. If you feel like the issue should not be discussed in
|
||||
public just open a generic issue and we will discuss further communitcation there (since GitHub does not allow everyone to create a security advisory :/).
|
||||
1
boot.sh
@@ -3,6 +3,7 @@ source venv/bin/activate
|
||||
|
||||
echo "Updating database"
|
||||
python manage.py migrate
|
||||
python manage.py collectstatic_js_reverse
|
||||
python manage.py collectstatic --noinput
|
||||
echo "Done"
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
from django.contrib import admin
|
||||
from .models import *
|
||||
|
||||
from .models import (Comment, CookLog, Food, Ingredient, InviteLink, Keyword,
|
||||
MealPlan, MealType, NutritionInformation, Recipe,
|
||||
RecipeBook, RecipeBookEntry, RecipeImport, ShareLink,
|
||||
ShoppingList, ShoppingListEntry, ShoppingListRecipe,
|
||||
Space, Step, Storage, Sync, SyncLog, Unit, UserPreference,
|
||||
ViewLog, Supermarket, SupermarketCategory, SupermarketCategoryRelation)
|
||||
|
||||
|
||||
class SpaceAdmin(admin.ModelAdmin):
|
||||
@@ -10,7 +16,10 @@ admin.site.register(Space, SpaceAdmin)
|
||||
|
||||
|
||||
class UserPreferenceAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'theme', 'nav_color', 'default_page', 'search_style', 'comments')
|
||||
list_display = (
|
||||
'name', 'theme', 'nav_color',
|
||||
'default_page', 'search_style', 'comments'
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def name(obj):
|
||||
@@ -34,6 +43,18 @@ class SyncAdmin(admin.ModelAdmin):
|
||||
admin.site.register(Sync, SyncAdmin)
|
||||
|
||||
|
||||
class SupermarketCategoryInline(admin.TabularInline):
|
||||
model = SupermarketCategoryRelation
|
||||
|
||||
|
||||
class SupermarketAdmin(admin.ModelAdmin):
|
||||
inlines = (SupermarketCategoryInline,)
|
||||
|
||||
|
||||
admin.site.register(Supermarket, SupermarketAdmin)
|
||||
admin.site.register(SupermarketCategory)
|
||||
|
||||
|
||||
class SyncLogAdmin(admin.ModelAdmin):
|
||||
list_display = ('sync', 'status', 'msg', 'created_at')
|
||||
|
||||
@@ -133,7 +154,10 @@ admin.site.register(ViewLog, ViewLogAdmin)
|
||||
|
||||
|
||||
class InviteLinkAdmin(admin.ModelAdmin):
|
||||
list_display = ('username', 'group', 'valid_until', 'created_by', 'created_at', 'used_by')
|
||||
list_display = (
|
||||
'username', 'group', 'valid_until',
|
||||
'created_by', 'created_at', 'used_by'
|
||||
)
|
||||
|
||||
|
||||
admin.site.register(InviteLink, InviteLinkAdmin)
|
||||
@@ -147,7 +171,7 @@ admin.site.register(CookLog, CookLogAdmin)
|
||||
|
||||
|
||||
class ShoppingListRecipeAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'recipe', 'multiplier')
|
||||
list_display = ('id', 'recipe', 'servings')
|
||||
|
||||
|
||||
admin.site.register(ShoppingListRecipe, ShoppingListRecipeAdmin)
|
||||
@@ -172,3 +196,10 @@ class ShareLinkAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
admin.site.register(ShareLink, ShareLinkAdmin)
|
||||
|
||||
|
||||
class NutritionInformationAdmin(admin.ModelAdmin):
|
||||
list_display = ('id',)
|
||||
|
||||
|
||||
admin.site.register(NutritionInformation, NutritionInformationAdmin)
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import django_filters
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.db.models import Q
|
||||
from cookbook.forms import MultiSelectWidget
|
||||
from cookbook.models import Recipe, Keyword, Food, ShoppingList
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.forms import MultiSelectWidget
|
||||
from cookbook.models import Food, Keyword, Recipe, ShoppingList
|
||||
|
||||
|
||||
class RecipeFilter(django_filters.FilterSet):
|
||||
name = django_filters.CharFilter(method='filter_name')
|
||||
keywords = django_filters.ModelMultipleChoiceFilter(queryset=Keyword.objects.all(), widget=MultiSelectWidget,
|
||||
method='filter_keywords')
|
||||
foods = django_filters.ModelMultipleChoiceFilter(queryset=Food.objects.all(), widget=MultiSelectWidget,
|
||||
method='filter_foods', label=_('Ingredients'))
|
||||
keywords = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Keyword.objects.all(),
|
||||
widget=MultiSelectWidget,
|
||||
method='filter_keywords'
|
||||
)
|
||||
foods = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Food.objects.all(),
|
||||
widget=MultiSelectWidget,
|
||||
method='filter_foods',
|
||||
label=_('Ingredients')
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def filter_keywords(queryset, name, value):
|
||||
@@ -27,16 +35,20 @@ class RecipeFilter(django_filters.FilterSet):
|
||||
if not name == 'foods':
|
||||
return queryset
|
||||
for x in value:
|
||||
queryset = queryset.filter(steps__ingredients__food__name=x).distinct()
|
||||
queryset = queryset.filter(
|
||||
steps__ingredients__food__name=x
|
||||
).distinct()
|
||||
return queryset
|
||||
|
||||
@staticmethod
|
||||
def filter_name(queryset, name, value):
|
||||
if not name == 'name':
|
||||
return queryset
|
||||
if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql_psycopg2':
|
||||
queryset = queryset.annotate(similarity=TrigramSimilarity('name', value), ).filter(
|
||||
Q(similarity__gt=0.1) | Q(name__icontains=value)).order_by('-similarity')
|
||||
if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql_psycopg2': # noqa: E501
|
||||
queryset = queryset \
|
||||
.annotate(similarity=TrigramSimilarity('name', value), ) \
|
||||
.filter(Q(similarity__gt=0.1) | Q(name__unaccent__icontains=value)) \
|
||||
.order_by('-similarity')
|
||||
else:
|
||||
queryset = queryset.filter(name__icontains=value)
|
||||
return queryset
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from django import forms
|
||||
from django.forms import widgets
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from emoji_picker.widgets import EmojiPickerTextInput
|
||||
|
||||
from .models import *
|
||||
from .models import (Comment, Food, InviteLink, Keyword, MealPlan, Recipe,
|
||||
RecipeBook, RecipeBookEntry, Storage, Sync, Unit, User,
|
||||
UserPreference)
|
||||
|
||||
|
||||
class SelectWidget(widgets.Select):
|
||||
@@ -17,7 +18,8 @@ class MultiSelectWidget(widgets.SelectMultiple):
|
||||
js = ('custom/js/form_multiselect.js',)
|
||||
|
||||
|
||||
# yes there are some stupid browsers that still dont support this but i dont support people using these browsers
|
||||
# Yes there are some stupid browsers that still dont support this but
|
||||
# I dont support people using these browsers.
|
||||
class DateWidget(forms.DateInput):
|
||||
input_type = 'date'
|
||||
|
||||
@@ -31,18 +33,26 @@ class UserPreferenceForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = UserPreference
|
||||
fields = ('default_unit', 'theme', 'nav_color', 'default_page', 'show_recent', 'search_style', 'plan_share', 'ingredient_decimals', 'shopping_auto_sync', 'comments')
|
||||
fields = (
|
||||
'default_unit', 'use_fractions', 'theme', 'nav_color',
|
||||
'sticky_navbar', 'default_page', 'show_recent', 'search_style',
|
||||
'plan_share', 'ingredient_decimals', 'shopping_auto_sync',
|
||||
'comments'
|
||||
)
|
||||
|
||||
help_texts = {
|
||||
'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!'),
|
||||
'default_unit': _('Default Unit to be used when inserting a new ingredient into a recipe.'),
|
||||
'plan_share': _('Default user to share newly created meal plan entries with.'),
|
||||
'show_recent': _('Show recently viewed recipes on search page.'),
|
||||
'ingredient_decimals': _('Number of decimals to round ingredients.'),
|
||||
'comments': _('If you want to be able to create and see comments underneath recipes.'),
|
||||
'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!'), # noqa: E501
|
||||
'default_unit': _('Default Unit to be used when inserting a new ingredient into a recipe.'), # noqa: E501
|
||||
'use_fractions': _('Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'), # noqa: E501
|
||||
'plan_share': _('Users with whom newly created meal plan/shopping list entries should be shared by default.'), # noqa: E501
|
||||
'show_recent': _('Show recently viewed recipes on search page.'), # noqa: E501
|
||||
'ingredient_decimals': _('Number of decimals to round ingredients.'), # noqa: E501
|
||||
'comments': _('If you want to be able to create and see comments underneath recipes.'), # noqa: E501
|
||||
'shopping_auto_sync': _(
|
||||
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit '
|
||||
'of mobile data. If lower than instance limit it is reset when saving.')
|
||||
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' # noqa: E501
|
||||
'of mobile data. If lower than instance limit it is reset when saving.' # noqa: E501
|
||||
),
|
||||
'sticky_navbar': _('Makes the navbar stick to the top of the page.') # noqa: E501
|
||||
}
|
||||
|
||||
widgets = {
|
||||
@@ -58,18 +68,25 @@ class UserNameForm(forms.ModelForm):
|
||||
fields = ('first_name', 'last_name')
|
||||
|
||||
help_texts = {
|
||||
'first_name': _('Both fields are optional. If none are given the username will be displayed instead')
|
||||
'first_name': _('Both fields are optional. If none are given the username will be displayed instead') # noqa: E501
|
||||
}
|
||||
|
||||
|
||||
class ExternalRecipeForm(forms.ModelForm):
|
||||
file_path = forms.CharField(disabled=True, required=False)
|
||||
storage = forms.ModelChoiceField(queryset=Storage.objects.all(), disabled=True, required=False)
|
||||
storage = forms.ModelChoiceField(
|
||||
queryset=Storage.objects.all(),
|
||||
disabled=True,
|
||||
required=False
|
||||
)
|
||||
file_uid = forms.CharField(disabled=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ('name', 'keywords', 'working_time', 'waiting_time', 'file_path', 'storage', 'file_uid')
|
||||
fields = (
|
||||
'name', 'keywords', 'description', 'servings', 'working_time', 'waiting_time',
|
||||
'file_path', 'storage', 'file_uid'
|
||||
)
|
||||
|
||||
labels = {
|
||||
'name': _('Name'),
|
||||
@@ -87,13 +104,17 @@ class InternalRecipeForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ('name', 'image', 'working_time', 'waiting_time', 'keywords')
|
||||
fields = (
|
||||
'name', 'image', 'working_time',
|
||||
'waiting_time', 'servings', 'keywords'
|
||||
)
|
||||
|
||||
labels = {
|
||||
'name': _('Name'),
|
||||
'keywords': _('Keywords'),
|
||||
'working_time': _('Preparation time in minutes'),
|
||||
'waiting_time': _('Waiting time (cooking/baking) in minutes'),
|
||||
'servings': _('Number of servings'),
|
||||
}
|
||||
widgets = {'keywords': MultiSelectWidget}
|
||||
|
||||
@@ -104,29 +125,32 @@ class ShoppingForm(forms.Form):
|
||||
widget=MultiSelectWidget
|
||||
)
|
||||
markdown_format = forms.BooleanField(
|
||||
help_text=_('Include <code>- [ ]</code> in list for easier usage in markdown based documents.'),
|
||||
help_text=_('Include <code>- [ ]</code> in list for easier usage in markdown based documents.'), # noqa: E501
|
||||
required=False,
|
||||
initial=False
|
||||
)
|
||||
|
||||
|
||||
class ExportForm(forms.Form):
|
||||
recipe = forms.ModelChoiceField(
|
||||
queryset=Recipe.objects.filter(internal=True).all(),
|
||||
widget=SelectWidget
|
||||
)
|
||||
image = forms.BooleanField(
|
||||
help_text=_('Export Base64 encoded image?'),
|
||||
required=False
|
||||
)
|
||||
download = forms.BooleanField(
|
||||
help_text=_('Download export directly or show on page?'),
|
||||
required=False
|
||||
)
|
||||
class ImportExportBase(forms.Form):
|
||||
DEFAULT = 'DEFAULT'
|
||||
PAPRIKA = 'PAPRIKA'
|
||||
NEXTCLOUD = 'NEXTCLOUD'
|
||||
MEALIE = 'MEALIE'
|
||||
CHOWDOWN = 'CHOWDOWN'
|
||||
SAFRON = 'SAFRON'
|
||||
|
||||
type = forms.ChoiceField(choices=(
|
||||
(DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'),
|
||||
(MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'), (SAFRON, 'Safron'),
|
||||
))
|
||||
|
||||
|
||||
class ImportForm(forms.Form):
|
||||
recipe = forms.CharField(widget=forms.Textarea, help_text=_('Simply paste a JSON export into this textarea and click import.'))
|
||||
class ImportForm(ImportExportBase):
|
||||
files = forms.FileField(required=True, widget=forms.ClearableFileInput(attrs={'multiple': True}))
|
||||
|
||||
|
||||
class ExportForm(ImportExportBase):
|
||||
recipes = forms.ModelMultipleChoiceField(queryset=Recipe.objects.filter(internal=True).all(), widget=MultiSelectWidget)
|
||||
|
||||
|
||||
class UnitMergeForm(forms.Form):
|
||||
@@ -188,26 +212,36 @@ class KeywordForm(forms.ModelForm):
|
||||
class FoodForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = ('name', 'recipe')
|
||||
fields = ('name', 'description', 'ignore_shopping', 'recipe', 'supermarket_category')
|
||||
widgets = {'recipe': SelectWidget}
|
||||
|
||||
|
||||
class StorageForm(forms.ModelForm):
|
||||
username = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password'}), required=False)
|
||||
password = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}),
|
||||
required=False,
|
||||
help_text=_('Leave empty for dropbox and enter app password for nextcloud.'))
|
||||
token = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}),
|
||||
required=False,
|
||||
help_text=_('Leave empty for nextcloud and enter api token for dropbox.'))
|
||||
username = forms.CharField(
|
||||
widget=forms.TextInput(attrs={'autocomplete': 'new-password'}),
|
||||
required=False
|
||||
)
|
||||
password = forms.CharField(
|
||||
widget=forms.TextInput(
|
||||
attrs={'autocomplete': 'new-password', 'type': 'password'}
|
||||
),
|
||||
required=False,
|
||||
help_text=_('Leave empty for dropbox and enter app password for nextcloud.') # noqa: E501
|
||||
)
|
||||
token = forms.CharField(
|
||||
widget=forms.TextInput(
|
||||
attrs={'autocomplete': 'new-password', 'type': 'password'}
|
||||
),
|
||||
required=False,
|
||||
help_text=_('Leave empty for nextcloud and enter api token for dropbox.') # noqa: E501
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Storage
|
||||
fields = ('name', 'method', 'username', 'password', 'token', 'url')
|
||||
fields = ('name', 'method', 'username', 'password', 'token', 'url', 'path')
|
||||
|
||||
help_texts = {
|
||||
'url': _(
|
||||
'Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'),
|
||||
'url': _('Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'), # noqa: E501
|
||||
}
|
||||
|
||||
|
||||
@@ -227,8 +261,11 @@ class SyncForm(forms.ModelForm):
|
||||
|
||||
class BatchEditForm(forms.Form):
|
||||
search = forms.CharField(label=_('Search String'))
|
||||
keywords = forms.ModelMultipleChoiceField(queryset=Keyword.objects.all().order_by('id'), required=False,
|
||||
widget=MultiSelectWidget)
|
||||
keywords = forms.ModelMultipleChoiceField(
|
||||
queryset=Keyword.objects.all().order_by('id'),
|
||||
required=False,
|
||||
widget=MultiSelectWidget
|
||||
)
|
||||
|
||||
|
||||
class ImportRecipeForm(forms.ModelForm):
|
||||
@@ -258,21 +295,29 @@ class MealPlanForm(forms.ModelForm):
|
||||
cleaned_data = super(MealPlanForm, self).clean()
|
||||
|
||||
if cleaned_data['title'] == '' and cleaned_data['recipe'] is None:
|
||||
raise forms.ValidationError(_('You must provide at least a recipe or a title.'))
|
||||
raise forms.ValidationError(
|
||||
_('You must provide at least a recipe or a title.')
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
class Meta:
|
||||
model = MealPlan
|
||||
fields = ('recipe', 'title', 'meal_type', 'note', 'recipe_multiplier', 'date', 'shared')
|
||||
fields = (
|
||||
'recipe', 'title', 'meal_type', 'note',
|
||||
'servings', 'date', 'shared'
|
||||
)
|
||||
|
||||
help_texts = {
|
||||
'shared': _('You can list default users to share recipes with in the settings.'),
|
||||
'note': _('You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>'),
|
||||
'recipe_multiplier': _('Scaling factor for recipe.')
|
||||
'shared': _('You can list default users to share recipes with in the settings.'), # noqa: E501
|
||||
'note': _('You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>') # noqa: E501
|
||||
}
|
||||
|
||||
widgets = {'recipe': SelectWidget, 'date': DateWidget, 'shared': MultiSelectWidget}
|
||||
widgets = {
|
||||
'recipe': SelectWidget,
|
||||
'date': DateWidget,
|
||||
'shared': MultiSelectWidget
|
||||
}
|
||||
|
||||
|
||||
class InviteLinkForm(forms.ModelForm):
|
||||
@@ -280,11 +325,19 @@ class InviteLinkForm(forms.ModelForm):
|
||||
model = InviteLink
|
||||
fields = ('username', 'group', 'valid_until')
|
||||
help_texts = {
|
||||
'username': _('A username is not required, if left blank the new user can choose one.')
|
||||
'username': _('A username is not required, if left blank the new user can choose one.') # noqa: E501
|
||||
}
|
||||
|
||||
|
||||
class UserCreateForm(forms.Form):
|
||||
name = forms.CharField(label='Username')
|
||||
password = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}))
|
||||
password_confirm = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}))
|
||||
password = forms.CharField(
|
||||
widget=forms.TextInput(
|
||||
attrs={'autocomplete': 'new-password', 'type': 'password'}
|
||||
)
|
||||
)
|
||||
password_confirm = forms.CharField(
|
||||
widget=forms.TextInput(
|
||||
attrs={'autocomplete': 'new-password', 'type': 'password'}
|
||||
)
|
||||
)
|
||||
|
||||
19
cookbook/helper/AllAuthCustomAdapter.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django.conf import settings
|
||||
|
||||
from allauth.account.adapter import DefaultAccountAdapter
|
||||
|
||||
|
||||
class AllAuthCustomAdapter(DefaultAccountAdapter):
|
||||
|
||||
def is_open_for_signup(self, request):
|
||||
"""
|
||||
Whether to allow sign ups.
|
||||
"""
|
||||
if request.resolver_match.view_name == 'account_signup':
|
||||
return False
|
||||
else:
|
||||
return super(AllAuthCustomAdapter, self).is_open_for_signup(request)
|
||||
|
||||
# disable password reset for now
|
||||
def send_mail(self, template_prefix, email, context):
|
||||
pass
|
||||
@@ -1 +1,6 @@
|
||||
from cookbook.helper.dal import *
|
||||
import cookbook.helper.dal
|
||||
from cookbook.helper.AllAuthCustomAdapter import AllAuthCustomAdapter
|
||||
|
||||
__all__ = [
|
||||
'dal',
|
||||
]
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
from cookbook.models import Food, Keyword, Recipe, Unit
|
||||
|
||||
from dal import autocomplete
|
||||
|
||||
from cookbook.models import Keyword, Recipe, Unit, Food
|
||||
|
||||
class BaseAutocomplete(autocomplete.Select2QuerySetView):
|
||||
model = None
|
||||
|
||||
class KeywordAutocomplete(autocomplete.Select2QuerySetView):
|
||||
def get_queryset(self):
|
||||
if not self.request.user.is_authenticated:
|
||||
return Keyword.objects.none()
|
||||
return self.model.objects.none()
|
||||
|
||||
qs = Keyword.objects.all()
|
||||
|
||||
if self.q:
|
||||
qs = qs.filter(name__istartswith=self.q)
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
class IngredientsAutocomplete(autocomplete.Select2QuerySetView):
|
||||
def get_queryset(self):
|
||||
if not self.request.user.is_authenticated:
|
||||
return Food.objects.none()
|
||||
|
||||
qs = Food.objects.all()
|
||||
qs = self.model.objects.all()
|
||||
|
||||
if self.q:
|
||||
qs = qs.filter(name__icontains=self.q)
|
||||
@@ -29,27 +18,17 @@ class IngredientsAutocomplete(autocomplete.Select2QuerySetView):
|
||||
return qs
|
||||
|
||||
|
||||
class RecipeAutocomplete(autocomplete.Select2QuerySetView):
|
||||
def get_queryset(self):
|
||||
if not self.request.user.is_authenticated:
|
||||
return Recipe.objects.none()
|
||||
|
||||
qs = Recipe.objects.all()
|
||||
|
||||
if self.q:
|
||||
qs = qs.filter(name__icontains=self.q)
|
||||
|
||||
return qs
|
||||
class KeywordAutocomplete(BaseAutocomplete):
|
||||
model = Keyword
|
||||
|
||||
|
||||
class UnitAutocomplete(autocomplete.Select2QuerySetView):
|
||||
def get_queryset(self):
|
||||
if not self.request.user.is_authenticated:
|
||||
return Unit.objects.none()
|
||||
class IngredientsAutocomplete(BaseAutocomplete):
|
||||
model = Food
|
||||
|
||||
qs = Unit.objects.all()
|
||||
|
||||
if self.q:
|
||||
qs = qs.filter(name__icontains=self.q)
|
||||
class RecipeAutocomplete(BaseAutocomplete):
|
||||
model = Recipe
|
||||
|
||||
return qs
|
||||
|
||||
class UnitAutocomplete(BaseAutocomplete):
|
||||
model = Unit
|
||||
|
||||
159
cookbook/helper/ingredient_parser.py
Normal file
@@ -0,0 +1,159 @@
|
||||
import string
|
||||
import unicodedata
|
||||
|
||||
|
||||
def parse_fraction(x):
|
||||
if len(x) == 1 and 'fraction' in unicodedata.decomposition(x):
|
||||
frac_split = unicodedata.decomposition(x[-1:]).split()
|
||||
return (float((frac_split[1]).replace('003', ''))
|
||||
/ float((frac_split[3]).replace('003', '')))
|
||||
else:
|
||||
frac_split = x.split('/')
|
||||
if not len(frac_split) == 2:
|
||||
raise ValueError
|
||||
try:
|
||||
return int(frac_split[0]) / int(frac_split[1])
|
||||
except ZeroDivisionError:
|
||||
raise ValueError
|
||||
|
||||
|
||||
def parse_amount(x):
|
||||
amount = 0
|
||||
unit = ''
|
||||
|
||||
did_check_frac = False
|
||||
end = 0
|
||||
while (
|
||||
end < len(x)
|
||||
and (
|
||||
x[end] in string.digits
|
||||
or (
|
||||
(x[end] == '.' or x[end] == ',' or x[end] == '/')
|
||||
and end + 1 < len(x)
|
||||
and x[end + 1] in string.digits
|
||||
)
|
||||
)
|
||||
):
|
||||
end += 1
|
||||
if end > 0:
|
||||
if "/" in x[:end]:
|
||||
amount = parse_fraction(x[:end])
|
||||
else:
|
||||
amount = float(x[:end].replace(',', '.'))
|
||||
else:
|
||||
amount = parse_fraction(x[0])
|
||||
end += 1
|
||||
did_check_frac = True
|
||||
if end < len(x):
|
||||
if did_check_frac:
|
||||
unit = x[end:]
|
||||
else:
|
||||
try:
|
||||
amount += parse_fraction(x[end])
|
||||
unit = x[end + 1:]
|
||||
except ValueError:
|
||||
unit = x[end:]
|
||||
return amount, unit
|
||||
|
||||
|
||||
def parse_ingredient_with_comma(tokens):
|
||||
ingredient = ''
|
||||
note = ''
|
||||
start = 0
|
||||
# search for first occurrence of an argument ending in a comma
|
||||
while start < len(tokens) and not tokens[start].endswith(','):
|
||||
start += 1
|
||||
if start == len(tokens):
|
||||
# no token ending in a comma found -> use everything as ingredient
|
||||
ingredient = ' '.join(tokens)
|
||||
else:
|
||||
ingredient = ' '.join(tokens[:start + 1])[:-1]
|
||||
note = ' '.join(tokens[start + 1:])
|
||||
return ingredient, note
|
||||
|
||||
|
||||
def parse_ingredient(tokens):
|
||||
ingredient = ''
|
||||
note = ''
|
||||
if tokens[-1].endswith(')'):
|
||||
# Check if the matching opening bracket is in the same token
|
||||
if (not tokens[-1].startswith('(')) and ('(' in tokens[-1]):
|
||||
return parse_ingredient_with_comma(tokens)
|
||||
# last argument ends with closing bracket -> look for opening bracket
|
||||
start = len(tokens) - 1
|
||||
while not tokens[start].startswith('(') and not start == 0:
|
||||
start -= 1
|
||||
if start == 0:
|
||||
# the whole list is wrapped in brackets -> assume it is an error (e.g. assumed first argument was the unit) # noqa: E501
|
||||
raise ValueError
|
||||
elif start < 0:
|
||||
# no opening bracket anywhere -> just ignore the last bracket
|
||||
ingredient, note = parse_ingredient_with_comma(tokens)
|
||||
else:
|
||||
# opening bracket found -> split in ingredient and note, remove brackets from note # noqa: E501
|
||||
note = ' '.join(tokens[start:])[1:-1]
|
||||
ingredient = ' '.join(tokens[:start])
|
||||
else:
|
||||
ingredient, note = parse_ingredient_with_comma(tokens)
|
||||
return ingredient, note
|
||||
|
||||
|
||||
def parse(x):
|
||||
# initialize default values
|
||||
amount = 0
|
||||
unit = ''
|
||||
ingredient = ''
|
||||
note = ''
|
||||
|
||||
tokens = x.split()
|
||||
if len(tokens) == 1:
|
||||
# there only is one argument, that must be the ingredient
|
||||
ingredient = tokens[0]
|
||||
else:
|
||||
try:
|
||||
# try to parse first argument as amount
|
||||
amount, unit = parse_amount(tokens[0])
|
||||
# only try to parse second argument as amount if there are at least
|
||||
# three arguments if it already has a unit there can't be
|
||||
# a fraction for the amount
|
||||
if len(tokens) > 2:
|
||||
try:
|
||||
if not unit == '':
|
||||
# a unit is already found, no need to try the second argument for a fraction # noqa: E501
|
||||
# probably not the best method to do it, but I didn't want to make an if check and paste the exact same thing in the else as already is in the except # noqa: E501
|
||||
raise ValueError
|
||||
# try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½' # noqa: E501
|
||||
amount += parse_fraction(tokens[1])
|
||||
# assume that units can't end with a comma
|
||||
if len(tokens) > 3 and not tokens[2].endswith(','):
|
||||
# try to use third argument as unit and everything else as ingredient, use everything as ingredient if it fails # noqa: E501
|
||||
try:
|
||||
ingredient, note = parse_ingredient(tokens[3:])
|
||||
unit = tokens[2]
|
||||
except ValueError:
|
||||
ingredient, note = parse_ingredient(tokens[2:])
|
||||
else:
|
||||
ingredient, note = parse_ingredient(tokens[2:])
|
||||
except ValueError:
|
||||
# assume that units can't end with a comma
|
||||
if not tokens[1].endswith(','):
|
||||
# try to use second argument as unit and everything else as ingredient, use everything as ingredient if it fails # noqa: E501
|
||||
try:
|
||||
ingredient, note = parse_ingredient(tokens[2:])
|
||||
unit = tokens[1]
|
||||
except ValueError:
|
||||
ingredient, note = parse_ingredient(tokens[1:])
|
||||
else:
|
||||
ingredient, note = parse_ingredient(tokens[1:])
|
||||
else:
|
||||
# only two arguments, first one is the amount
|
||||
# which means this is the ingredient
|
||||
ingredient = tokens[1]
|
||||
except ValueError:
|
||||
try:
|
||||
# can't parse first argument as amount
|
||||
# -> no unit -> parse everything as ingredient
|
||||
ingredient, note = parse_ingredient(tokens)
|
||||
except ValueError:
|
||||
ingredient = ' '.join(tokens[1:])
|
||||
return amount, unit.strip(), ingredient.strip(), note.strip()
|
||||
@@ -1,5 +1,4 @@
|
||||
import markdown
|
||||
|
||||
from markdown.treeprocessors import Treeprocessor
|
||||
|
||||
|
||||
@@ -21,4 +20,8 @@ class StyleTreeprocessor(Treeprocessor):
|
||||
|
||||
class MarkdownFormatExtension(markdown.Extension):
|
||||
def extendMarkdown(self, md, md_globals):
|
||||
md.treeprocessors.register(StyleTreeprocessor(), 'StyleTreeprocessor', 10)
|
||||
md.treeprocessors.register(
|
||||
StyleTreeprocessor(),
|
||||
'StyleTreeprocessor',
|
||||
10
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""A more liberal autolinker
|
||||
"""
|
||||
A more liberal autolinker
|
||||
|
||||
Inspired by Django's urlize function.
|
||||
|
||||
@@ -45,27 +46,30 @@ URLIZE_RE = '(%s)' % '|'.join([
|
||||
r'[^(<\s]+\.(?:com|net|org)\b',
|
||||
])
|
||||
|
||||
|
||||
class UrlizePattern(markdown.inlinepatterns.Pattern):
|
||||
""" Return a link Element given an autolink (`http://example/com`). """
|
||||
|
||||
def handleMatch(self, m):
|
||||
url = m.group(2)
|
||||
|
||||
|
||||
if url.startswith('<'):
|
||||
url = url[1:-1]
|
||||
|
||||
|
||||
text = url
|
||||
|
||||
if not url.split('://')[0] in ('http','https','ftp'):
|
||||
if '@' in url and not '/' in url:
|
||||
|
||||
if not url.split('://')[0] in ('http', 'https', 'ftp'):
|
||||
if '@' in url and '/' not in url:
|
||||
url = 'mailto:' + url
|
||||
else:
|
||||
url = 'http://' + url
|
||||
|
||||
|
||||
el = markdown.util.etree.Element("a")
|
||||
el.set('href', url)
|
||||
el.text = markdown.util.AtomicString(text)
|
||||
return el
|
||||
|
||||
|
||||
class UrlizeExtension(markdown.Extension):
|
||||
""" Urlize Extension for Python-Markdown. """
|
||||
|
||||
@@ -73,9 +77,12 @@ class UrlizeExtension(markdown.Extension):
|
||||
""" Replace autolink with UrlizePattern """
|
||||
md.inlinePatterns['autolink'] = UrlizePattern(URLIZE_RE, md)
|
||||
|
||||
|
||||
def makeExtension(*args, **kwargs):
|
||||
return UrlizeExtension(*args, **kwargs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import doctest
|
||||
|
||||
doctest.testmod()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# Permission Config
|
||||
from cookbook.helper.permission_helper import CustomIsUser, CustomIsOwner, CustomIsAdmin, CustomIsGuest
|
||||
from cookbook.helper.permission_helper import CustomIsUser
|
||||
|
||||
|
||||
class PermissionConfig:
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
"""
|
||||
Source: https://djangosnippets.org/snippets/1703/
|
||||
"""
|
||||
from cookbook.models import ShareLink
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import user_passes_test
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse_lazy, reverse
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import permissions
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
|
||||
from cookbook.models import ShareLink
|
||||
|
||||
|
||||
# Helper Functions
|
||||
|
||||
def get_allowed_groups(groups_required):
|
||||
"""
|
||||
@@ -34,8 +30,8 @@ def get_allowed_groups(groups_required):
|
||||
def has_group_permission(user, groups):
|
||||
"""
|
||||
Tests if a given user is member of a certain group (or any higher group)
|
||||
Superusers always bypass permission checks. Unauthenticated users cant be member of any
|
||||
group thus always return false.
|
||||
Superusers always bypass permission checks.
|
||||
Unauthenticated users cant be member of any group thus always return false.
|
||||
:param user: django auth user object
|
||||
:param groups: list or tuple of groups the user should be checked for
|
||||
:return: True if user is in allowed groups, false otherwise
|
||||
@@ -44,7 +40,8 @@ def has_group_permission(user, groups):
|
||||
return False
|
||||
groups_allowed = get_allowed_groups(groups)
|
||||
if user.is_authenticated:
|
||||
if user.is_superuser | bool(user.groups.filter(name__in=groups_allowed)):
|
||||
if (user.is_superuser
|
||||
| bool(user.groups.filter(name__in=groups_allowed))):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -52,13 +49,15 @@ def has_group_permission(user, groups):
|
||||
def is_object_owner(user, obj):
|
||||
"""
|
||||
Tests if a given user is the owner of a given object
|
||||
test performed by checking user against the objects user and create_by field (if exists)
|
||||
test performed by checking user against the objects user
|
||||
and create_by field (if exists)
|
||||
superusers bypass all checks, unauthenticated users cannot own anything
|
||||
:param user django auth user object
|
||||
:param obj any object that should be tested
|
||||
:return: true if user is owner of object, false otherwise
|
||||
"""
|
||||
# TODO this could be improved/cleaned up by adding get_owner methods to all models that allow owner checks
|
||||
# TODO this could be improved/cleaned up by adding
|
||||
# get_owner methods to all models that allow owner checks
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
if user.is_superuser:
|
||||
@@ -81,7 +80,8 @@ def is_object_shared(user, obj):
|
||||
:param obj any object that should be tested
|
||||
:return: true if user is shared for object, false otherwise
|
||||
"""
|
||||
# TODO this could be improved/cleaned up by adding share checks for relevant objects
|
||||
# TODO this could be improved/cleaned up by adding
|
||||
# share checks for relevant objects
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
if user.is_superuser:
|
||||
@@ -94,10 +94,14 @@ def share_link_valid(recipe, share):
|
||||
Verifies the validity of a share uuid
|
||||
:param recipe: recipe object
|
||||
:param share: share uuid
|
||||
:return: true if a share link with the given recipe and uuid exists, false otherwise
|
||||
:return: true if a share link with the given recipe and uuid exists
|
||||
"""
|
||||
try:
|
||||
return True if ShareLink.objects.filter(recipe=recipe, uuid=share).exists() else False
|
||||
return (
|
||||
True
|
||||
if ShareLink.objects.filter(recipe=recipe, uuid=share).exists()
|
||||
else False
|
||||
)
|
||||
except ValidationError:
|
||||
return False
|
||||
|
||||
@@ -106,8 +110,8 @@ def share_link_valid(recipe, share):
|
||||
|
||||
def group_required(*groups_required):
|
||||
"""
|
||||
Decorator that tests the requesting user to be member of at least one of the provided groups
|
||||
or higher level groups
|
||||
Decorator that tests the requesting user to be member
|
||||
of at least one of the provided groups or higher level groups
|
||||
:param groups_required: list of required groups
|
||||
:return: true if member of group, false otherwise
|
||||
"""
|
||||
@@ -115,7 +119,7 @@ def group_required(*groups_required):
|
||||
def in_groups(u):
|
||||
return has_group_permission(u, groups_required)
|
||||
|
||||
return user_passes_test(in_groups, login_url='index')
|
||||
return user_passes_test(in_groups, login_url='view_no_group')
|
||||
|
||||
|
||||
class GroupRequiredMixin(object):
|
||||
@@ -127,7 +131,11 @@ class GroupRequiredMixin(object):
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not has_group_permission(request.user, self.groups_required):
|
||||
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.ERROR,
|
||||
_('You do not have the required permissions to view this page!') # noqa: E501
|
||||
)
|
||||
return HttpResponseRedirect(reverse_lazy('index'))
|
||||
|
||||
return super(GroupRequiredMixin, self).dispatch(request, *args, **kwargs)
|
||||
@@ -137,14 +145,25 @@ class OwnerRequiredMixin(object):
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
messages.add_message(request, messages.ERROR, _('You are not logged in and therefore cannot view this page!'))
|
||||
return HttpResponseRedirect(reverse_lazy('login'))
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.ERROR,
|
||||
_('You are not logged in and therefore cannot view this page!')
|
||||
)
|
||||
return HttpResponseRedirect(
|
||||
reverse_lazy('account_login') + '?next=' + request.path
|
||||
)
|
||||
else:
|
||||
if not is_object_owner(request.user, self.get_object()):
|
||||
messages.add_message(request, messages.ERROR, _('You cannot interact with this object as its not owned by you!'))
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.ERROR,
|
||||
_('You cannot interact with this object as it is not owned by you!') # noqa: E501
|
||||
)
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
return super(OwnerRequiredMixin, self).dispatch(request, *args, **kwargs)
|
||||
return super(OwnerRequiredMixin, self) \
|
||||
.dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
# Django Rest Framework Permission classes
|
||||
@@ -155,7 +174,7 @@ class CustomIsOwner(permissions.BasePermission):
|
||||
verifies user has ownership over object
|
||||
(either user or created_by or user is request user)
|
||||
"""
|
||||
message = _('You cannot interact with this object as its not owned by you!')
|
||||
message = _('You cannot interact with this object as it is not owned by you!') # noqa: E501
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.user.is_authenticated
|
||||
@@ -164,12 +183,13 @@ class CustomIsOwner(permissions.BasePermission):
|
||||
return is_object_owner(request.user, obj)
|
||||
|
||||
|
||||
class CustomIsShared(permissions.BasePermission): # TODO function duplicate/too similar name
|
||||
# TODO function duplicate/too similar name
|
||||
class CustomIsShared(permissions.BasePermission):
|
||||
"""
|
||||
Custom permission class for django rest framework views
|
||||
verifies user is shared for the object he is trying to access
|
||||
"""
|
||||
message = _('You cannot interact with this object as its not owned by you!')
|
||||
message = _('You cannot interact with this object as it is not owned by you!') # noqa: E501
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.user.is_authenticated
|
||||
|
||||
@@ -5,12 +5,12 @@ from json import JSONDecodeError
|
||||
|
||||
import microdata
|
||||
from bs4 import BeautifulSoup
|
||||
from cookbook.helper.ingredient_parser import parse as parse_ingredient
|
||||
from cookbook.models import Keyword
|
||||
from django.http import JsonResponse
|
||||
from django.utils.dateparse import parse_duration
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.models import Keyword
|
||||
|
||||
|
||||
def get_from_html(html_text, url):
|
||||
soup = BeautifulSoup(html_text, "html.parser")
|
||||
@@ -29,26 +29,37 @@ def get_from_html(html_text, url):
|
||||
if '@type' in x and x['@type'] == 'Recipe':
|
||||
ld_json_item = x
|
||||
|
||||
if '@type' in ld_json_item and ld_json_item['@type'] == 'Recipe':
|
||||
return find_recipe_json(ld_json_item, url)
|
||||
except JSONDecodeError as e:
|
||||
return JsonResponse({'error': True, 'msg': _('The requested site provided malformed data and cannot be read.')}, status=400)
|
||||
if ('@type' in ld_json_item
|
||||
and ld_json_item['@type'] == 'Recipe'):
|
||||
return JsonResponse(find_recipe_json(ld_json_item, url))
|
||||
except JSONDecodeError:
|
||||
return JsonResponse(
|
||||
{
|
||||
'error': True,
|
||||
'msg': _('The requested site provided malformed data and cannot be read.') # noqa: E501
|
||||
},
|
||||
status=400)
|
||||
|
||||
# now try to find microdata
|
||||
items = microdata.get_items(html_text)
|
||||
for i in items:
|
||||
md_json = json.loads(i.json())
|
||||
if 'schema.org/Recipe' in str(md_json['type']):
|
||||
return find_recipe_json(md_json['properties'], url)
|
||||
return JsonResponse(find_recipe_json(md_json['properties'], url))
|
||||
|
||||
return JsonResponse({'error': True, 'msg': _('The requested site does not provide any recognized data format to import the recipe from.')}, status=400)
|
||||
return JsonResponse(
|
||||
{
|
||||
'error': True,
|
||||
'msg': _('The requested site does not provide any recognized data format to import the recipe from.') # noqa: E501
|
||||
},
|
||||
status=400)
|
||||
|
||||
|
||||
def find_recipe_json(ld_json, url):
|
||||
if type(ld_json['name']) == list:
|
||||
try:
|
||||
ld_json['name'] = ld_json['name'][0]
|
||||
except:
|
||||
except Exception:
|
||||
ld_json['name'] = 'ERROR'
|
||||
|
||||
# some sites use ingredients instead of recipeIngredients
|
||||
@@ -57,8 +68,9 @@ def find_recipe_json(ld_json, url):
|
||||
|
||||
if 'recipeIngredient' in ld_json:
|
||||
# some pages have comma separated ingredients in a single array entry
|
||||
if len(ld_json['recipeIngredient']) == 1 and len(ld_json['recipeIngredient'][0]) > 30:
|
||||
ld_json['recipeIngredient'] = ld_json['recipeIngredient'][0].split(',')
|
||||
if (len(ld_json['recipeIngredient']) == 1
|
||||
and len(ld_json['recipeIngredient'][0]) > 30):
|
||||
ld_json['recipeIngredient'] = ld_json['recipeIngredient'][0].split(',') # noqa: E501
|
||||
|
||||
for x in ld_json['recipeIngredient']:
|
||||
if '\n' in x:
|
||||
@@ -69,31 +81,41 @@ def find_recipe_json(ld_json, url):
|
||||
ingredients = []
|
||||
|
||||
for x in ld_json['recipeIngredient']:
|
||||
ingredient_split = x.split()
|
||||
ingredient = None
|
||||
amount = 0
|
||||
unit = ''
|
||||
if len(ingredient_split) > 2:
|
||||
ingredient = " ".join(ingredient_split[2:])
|
||||
unit = ingredient_split[1]
|
||||
if x.replace(' ', '') != '':
|
||||
try:
|
||||
amount = float(ingredient_split[0].replace(',', '.'))
|
||||
except ValueError:
|
||||
amount = 0
|
||||
ingredient = " ".join(ingredient_split)
|
||||
if len(ingredient_split) == 2:
|
||||
ingredient = " ".join(ingredient_split[1:])
|
||||
unit = ''
|
||||
try:
|
||||
amount = float(ingredient_split[0].replace(',', '.'))
|
||||
except ValueError:
|
||||
amount = 0
|
||||
ingredient = " ".join(ingredient_split)
|
||||
if len(ingredient_split) == 1:
|
||||
ingredient = " ".join(ingredient_split)
|
||||
|
||||
if ingredient:
|
||||
ingredients.append({'amount': amount, 'unit': {'text': unit, 'id': random.randrange(10000, 99999)}, 'ingredient': {'text': ingredient, 'id': random.randrange(10000, 99999)}, 'original': x})
|
||||
amount, unit, ingredient, note = parse_ingredient(x)
|
||||
if ingredient:
|
||||
ingredients.append(
|
||||
{
|
||||
'amount': amount,
|
||||
'unit': {
|
||||
'text': unit,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'ingredient': {
|
||||
'text': ingredient,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'note': note,
|
||||
'original': x
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
ingredients.append(
|
||||
{
|
||||
'amount': 0,
|
||||
'unit': {
|
||||
'text': '',
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'ingredient': {
|
||||
'text': x,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'note': '',
|
||||
'original': x
|
||||
}
|
||||
)
|
||||
|
||||
ld_json['recipeIngredient'] = ingredients
|
||||
else:
|
||||
@@ -107,7 +129,9 @@ def find_recipe_json(ld_json, url):
|
||||
ld_json['keywords'] = ld_json['keywords'].split(',')
|
||||
|
||||
# keywords as string in list
|
||||
if type(ld_json['keywords']) == list and len(ld_json['keywords']) == 1 and ',' in ld_json['keywords'][0]:
|
||||
if (type(ld_json['keywords']) == list
|
||||
and len(ld_json['keywords']) == 1
|
||||
and ',' in ld_json['keywords'][0]):
|
||||
ld_json['keywords'] = ld_json['keywords'][0].split(',')
|
||||
|
||||
# keywords as list
|
||||
@@ -115,7 +139,7 @@ def find_recipe_json(ld_json, url):
|
||||
if k := Keyword.objects.filter(name=kw).first():
|
||||
keywords.append({'id': str(k.id), 'text': str(k).strip()})
|
||||
else:
|
||||
keywords.append({'id': "null", 'text': kw.strip()})
|
||||
keywords.append({'id': random.randrange(1111111, 9999999, 1), 'text': kw.strip()})
|
||||
|
||||
ld_json['keywords'] = keywords
|
||||
else:
|
||||
@@ -142,14 +166,15 @@ def find_recipe_json(ld_json, url):
|
||||
instructions += str(i)
|
||||
ld_json['recipeInstructions'] = instructions
|
||||
|
||||
ld_json['recipeInstructions'] = re.sub(r'\n\s*\n', '\n\n', ld_json['recipeInstructions'])
|
||||
ld_json['recipeInstructions'] = re.sub(' +', ' ', ld_json['recipeInstructions'])
|
||||
ld_json['recipeInstructions'] = ld_json['recipeInstructions'].replace('<p>', '')
|
||||
ld_json['recipeInstructions'] = ld_json['recipeInstructions'].replace('</p>', '')
|
||||
ld_json['recipeInstructions'] = re.sub(r'\n\s*\n', '\n\n', ld_json['recipeInstructions']) # noqa: E501
|
||||
ld_json['recipeInstructions'] = re.sub(' +', ' ', ld_json['recipeInstructions']) # noqa: E501
|
||||
ld_json['recipeInstructions'] = ld_json['recipeInstructions'].replace('<p>', '') # noqa: E501
|
||||
ld_json['recipeInstructions'] = ld_json['recipeInstructions'].replace('</p>', '') # noqa: E501
|
||||
else:
|
||||
ld_json['recipeInstructions'] = ''
|
||||
|
||||
ld_json['recipeInstructions'] += '\n\n' + _('Imported from ') + url
|
||||
if url != '':
|
||||
ld_json['recipeInstructions'] += '\n\n' + _('Imported from') + ' ' + url
|
||||
|
||||
if 'image' in ld_json:
|
||||
# check if list of images is returned, take first if so
|
||||
@@ -165,9 +190,14 @@ def find_recipe_json(ld_json, url):
|
||||
|
||||
if 'cookTime' in ld_json:
|
||||
try:
|
||||
if type(ld_json['cookTime']) == list and len(ld_json['cookTime']) > 0:
|
||||
if (type(ld_json['cookTime']) == list
|
||||
and len(ld_json['cookTime']) > 0):
|
||||
ld_json['cookTime'] = ld_json['cookTime'][0]
|
||||
ld_json['cookTime'] = round(parse_duration(ld_json['cookTime']).seconds / 60)
|
||||
ld_json['cookTime'] = round(
|
||||
parse_duration(
|
||||
ld_json['cookTime']
|
||||
).seconds / 60
|
||||
)
|
||||
except TypeError:
|
||||
ld_json['cookTime'] = 0
|
||||
else:
|
||||
@@ -175,16 +205,32 @@ def find_recipe_json(ld_json, url):
|
||||
|
||||
if 'prepTime' in ld_json:
|
||||
try:
|
||||
if type(ld_json['prepTime']) == list and len(ld_json['prepTime']) > 0:
|
||||
if (type(ld_json['prepTime']) == list
|
||||
and len(ld_json['prepTime']) > 0):
|
||||
ld_json['prepTime'] = ld_json['prepTime'][0]
|
||||
ld_json['prepTime'] = round(parse_duration(ld_json['prepTime']).seconds / 60)
|
||||
ld_json['prepTime'] = round(
|
||||
parse_duration(
|
||||
ld_json['prepTime']
|
||||
).seconds / 60
|
||||
)
|
||||
except TypeError:
|
||||
ld_json['prepTime'] = 0
|
||||
else:
|
||||
ld_json['prepTime'] = 0
|
||||
|
||||
ld_json['servings'] = 1
|
||||
try:
|
||||
if 'recipeYield' in ld_json:
|
||||
if type(ld_json['recipeYield']) == str:
|
||||
ld_json['servings'] = int(re.findall(r'\b\d+\b', ld_json['recipeYield'])[0])
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
for key in list(ld_json):
|
||||
if key not in ['prepTime', 'cookTime', 'image', 'recipeInstructions', 'keywords', 'name', 'recipeIngredient']:
|
||||
if key not in [
|
||||
'prepTime', 'cookTime', 'image', 'recipeInstructions',
|
||||
'keywords', 'name', 'recipeIngredient', 'servings'
|
||||
]:
|
||||
ld_json.pop(key, None)
|
||||
|
||||
return JsonResponse(ld_json)
|
||||
return ld_json
|
||||
|
||||
62
cookbook/helper/template_helper.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import bleach
|
||||
import markdown as md
|
||||
from bleach_whitelist import markdown_attrs, markdown_tags
|
||||
from cookbook.helper.mdx_attributes import MarkdownFormatExtension
|
||||
from cookbook.helper.mdx_urlize import UrlizeExtension
|
||||
from jinja2 import Template, TemplateSyntaxError
|
||||
|
||||
|
||||
class IngredientObject(object):
|
||||
amount = ""
|
||||
unit = ""
|
||||
food = ""
|
||||
note = ""
|
||||
|
||||
def __init__(self, ingredient):
|
||||
if ingredient.no_amount:
|
||||
self.amount = ""
|
||||
else:
|
||||
self.amount = f"<scalable-number v-bind:number='{bleach.clean(str(ingredient.amount))}' v-bind:factor='ingredient_factor'></scalable-number>"
|
||||
if ingredient.unit:
|
||||
self.unit = bleach.clean(str(ingredient.unit))
|
||||
else:
|
||||
self.unit = ""
|
||||
self.food = bleach.clean(str(ingredient.food))
|
||||
self.note = bleach.clean(str(ingredient.note))
|
||||
|
||||
def __str__(self):
|
||||
ingredient = self.amount
|
||||
if self.unit != "":
|
||||
ingredient += f' {self.unit}'
|
||||
return f'{ingredient} {self.food}'
|
||||
|
||||
|
||||
def render_instructions(step): # TODO deduplicate markdown cleanup code
|
||||
instructions = step.instruction
|
||||
|
||||
tags = markdown_tags + [
|
||||
'pre', 'table', 'td', 'tr', 'th', 'tbody', 'style', 'thead'
|
||||
]
|
||||
parsed_md = md.markdown(
|
||||
instructions,
|
||||
extensions=[
|
||||
'markdown.extensions.fenced_code', 'tables',
|
||||
UrlizeExtension(), MarkdownFormatExtension()
|
||||
]
|
||||
)
|
||||
markdown_attrs['*'] = markdown_attrs['*'] + ['class']
|
||||
|
||||
instructions = bleach.clean(parsed_md, tags, markdown_attrs)
|
||||
|
||||
ingredients = []
|
||||
|
||||
for i in step.ingredients.all():
|
||||
ingredients.append(IngredientObject(i))
|
||||
|
||||
try:
|
||||
template = Template(instructions)
|
||||
instructions = template.render(ingredients=ingredients)
|
||||
except TemplateSyntaxError:
|
||||
pass
|
||||
|
||||
return instructions
|
||||
79
cookbook/integration/chowdown.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient, Keyword
|
||||
|
||||
|
||||
class Chowdown(Integration):
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
print("testing", zip_info_object.filename)
|
||||
return re.match(r'^(_)*recipes/([A-Za-z\d\s-])+.md$', zip_info_object.filename)
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
ingredient_mode = False
|
||||
direction_mode = False
|
||||
description_mode = False
|
||||
|
||||
ingredients = []
|
||||
directions = []
|
||||
descriptions = []
|
||||
for fl in file.readlines():
|
||||
line = fl.decode("utf-8")
|
||||
if 'title:' in line:
|
||||
title = line.replace('title:', '').replace('"', '').strip()
|
||||
if 'image:' in line:
|
||||
image = line.replace('image:', '').strip()
|
||||
if 'tags:' in line:
|
||||
tags = line.replace('tags:', '').strip()
|
||||
if ingredient_mode:
|
||||
if len(line) > 2 and 'directions:' not in line:
|
||||
ingredients.append(line[2:])
|
||||
if '---' in line and direction_mode:
|
||||
direction_mode = False
|
||||
description_mode = True
|
||||
if direction_mode:
|
||||
if len(line) > 2:
|
||||
directions.append(line[2:])
|
||||
if 'ingredients:' in line:
|
||||
ingredient_mode = True
|
||||
if 'directions:' in line:
|
||||
ingredient_mode = False
|
||||
direction_mode = True
|
||||
if description_mode and len(line) > 3 and '---' not in line:
|
||||
descriptions.append(line)
|
||||
|
||||
recipe = Recipe.objects.create(name=title, created_by=self.request.user, internal=True, )
|
||||
|
||||
for k in tags.split(','):
|
||||
keyword, created = Keyword.objects.get_or_create(name=k.strip())
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction='\n'.join(directions) + '\n\n' + '\n'.join(descriptions)
|
||||
)
|
||||
|
||||
for ingredient in ingredients:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f, created = Food.objects.get_or_create(name=ingredient)
|
||||
u, created = Unit.objects.get_or_create(name=unit)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
for f in self.files:
|
||||
if '.zip' in f.name:
|
||||
import_zip = ZipFile(f.file)
|
||||
for z in import_zip.filelist:
|
||||
if re.match(f'^images/{image}$', z.filename):
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)))
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
34
cookbook/integration/default.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import json
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.serializer import RecipeExportSerializer
|
||||
|
||||
|
||||
class Default(Integration):
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
recipe_zip = ZipFile(file)
|
||||
|
||||
recipe_string = recipe_zip.read('recipe.json').decode("utf-8")
|
||||
recipe = self.decode_recipe(recipe_string)
|
||||
if 'image.png' in recipe_zip.namelist():
|
||||
self.import_recipe_image(recipe, BytesIO(recipe_zip.read('image.png')))
|
||||
return recipe
|
||||
|
||||
def decode_recipe(self, string):
|
||||
data = json.loads(string)
|
||||
serialized_recipe = RecipeExportSerializer(data=data, context={'request': self.request})
|
||||
if serialized_recipe.is_valid():
|
||||
recipe = serialized_recipe.save()
|
||||
return recipe
|
||||
|
||||
return None
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
export = RecipeExportSerializer(recipe).data
|
||||
|
||||
return 'recipe.json', JSONRenderer().render(export).decode("utf-8")
|
||||
128
cookbook/integration/integration.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
from io import BytesIO, StringIO
|
||||
from zipfile import ZipFile, BadZipFile
|
||||
|
||||
from django.contrib import messages
|
||||
from django.core.files import File
|
||||
from django.http import HttpResponseRedirect, HttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext as _
|
||||
from cookbook.models import Keyword
|
||||
|
||||
|
||||
class Integration:
|
||||
request = None
|
||||
keyword = None
|
||||
files = None
|
||||
|
||||
def __init__(self, request):
|
||||
"""
|
||||
Integration for importing and exporting recipes
|
||||
:param request: request context of import session (used to link user to created objects)
|
||||
"""
|
||||
self.request = request
|
||||
self.keyword = Keyword.objects.create(
|
||||
name=f'Import {date_format(datetime.datetime.now(), "DATETIME_FORMAT")}.{datetime.datetime.now().strftime("%S")}',
|
||||
description=f'Imported by {request.user.get_user_name()} at {date_format(datetime.datetime.now(), "DATETIME_FORMAT")}',
|
||||
icon='📥'
|
||||
)
|
||||
|
||||
def do_export(self, recipes):
|
||||
"""
|
||||
Perform the export based on a list of recipes
|
||||
:param recipes: list of recipe objects
|
||||
:return: HttpResponse with a ZIP file that is directly downloaded
|
||||
"""
|
||||
export_zip_stream = BytesIO()
|
||||
export_zip_obj = ZipFile(export_zip_stream, 'w')
|
||||
|
||||
for r in recipes:
|
||||
if r.internal:
|
||||
recipe_zip_stream = BytesIO()
|
||||
recipe_zip_obj = ZipFile(recipe_zip_stream, 'w')
|
||||
|
||||
recipe_stream = StringIO()
|
||||
filename, data = self.get_file_from_recipe(r)
|
||||
recipe_stream.write(data)
|
||||
recipe_zip_obj.writestr(filename, recipe_stream.getvalue())
|
||||
recipe_stream.close()
|
||||
|
||||
try:
|
||||
recipe_zip_obj.write(r.image.path, 'image.png')
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
recipe_zip_obj.close()
|
||||
export_zip_obj.writestr(str(r.pk) + '.zip', recipe_zip_stream.getvalue())
|
||||
|
||||
export_zip_obj.close()
|
||||
|
||||
response = HttpResponse(export_zip_stream.getvalue(), content_type='application/force-download')
|
||||
response['Content-Disposition'] = 'attachment; filename="export.zip"'
|
||||
return response
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
"""
|
||||
Since zipfile.namelist() returns all files in all subdirectories this function allows filtering of files
|
||||
If false is returned the file will be ignored
|
||||
By default all files are included
|
||||
:param zip_info_object: ZipInfo object
|
||||
:return: Boolean if object should be included
|
||||
"""
|
||||
return True
|
||||
|
||||
def do_import(self, files):
|
||||
"""
|
||||
Imports given files
|
||||
:param files: List of in memory files
|
||||
:return: HttpResponseRedirect to the recipe search showing all imported recipes
|
||||
"""
|
||||
try:
|
||||
self.files = files
|
||||
for f in files:
|
||||
if '.zip' in f.name:
|
||||
import_zip = ZipFile(f.file)
|
||||
for z in import_zip.filelist:
|
||||
if self.import_file_name_filter(z):
|
||||
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
|
||||
recipe.keywords.add(self.keyword)
|
||||
import_zip.close()
|
||||
else:
|
||||
recipe = self.get_recipe_from_file(f.file)
|
||||
recipe.keywords.add(self.keyword)
|
||||
except BadZipFile:
|
||||
messages.add_message(self.request, messages.ERROR, _('Importer expected a .zip file. Did you choose the correct importer type for your data ?'))
|
||||
|
||||
return HttpResponseRedirect(reverse('view_search') + '?keywords=' + str(self.keyword.pk))
|
||||
|
||||
@staticmethod
|
||||
def import_recipe_image(recipe, image_file):
|
||||
"""
|
||||
Adds an image to a recipe naming it correctly
|
||||
:param recipe: Recipe object
|
||||
:param image_file: ByteIO stream containing the image
|
||||
"""
|
||||
recipe.image = File(image_file, name=f'{uuid.uuid4()}_{recipe.pk}.png')
|
||||
recipe.save()
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
"""
|
||||
Takes any file like object and converts it into a recipe
|
||||
:param file: ByteIO or any file like object, depends on provider
|
||||
:return: Recipe object
|
||||
"""
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
"""
|
||||
Takes a recipe object and converts it to a string (depending on the format)
|
||||
returns both the filename of the exported file and the file contents
|
||||
:param recipe: Recipe object that should be converted
|
||||
:returns:
|
||||
- name - file name in export
|
||||
- data - string content for file to get created in export zip
|
||||
"""
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
52
cookbook/integration/mealie.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
|
||||
|
||||
class Mealie(Integration):
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
return re.match(r'^recipes/([A-Za-z\d-])+.json$', zip_info_object.filename)
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
recipe_json = json.loads(file.getvalue().decode("utf-8"))
|
||||
|
||||
recipe = Recipe.objects.create(
|
||||
name=recipe_json['name'].strip(), description=recipe_json['description'].strip(),
|
||||
created_by=self.request.user, internal=True)
|
||||
|
||||
# TODO parse times (given in PT2H3M )
|
||||
|
||||
ingredients_added = False
|
||||
for s in recipe_json['recipeInstructions']:
|
||||
step = Step.objects.create(
|
||||
instruction=s['text']
|
||||
)
|
||||
if not ingredients_added:
|
||||
ingredients_added = True
|
||||
|
||||
for ingredient in recipe_json['recipeIngredient']:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f, created = Food.objects.get_or_create(name=ingredient)
|
||||
u, created = Unit.objects.get_or_create(name=unit)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
for f in self.files:
|
||||
if '.zip' in f.name:
|
||||
import_zip = ZipFile(f.file)
|
||||
for z in import_zip.filelist:
|
||||
if re.match(f'^images/{recipe_json["slug"]}.jpg$', z.filename):
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)))
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
54
cookbook/integration/nextcloud_cookbook.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
|
||||
|
||||
class NextcloudCookbook(Integration):
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
return re.match(r'^Recipes/([A-Za-z\d\s])+/recipe.json$', zip_info_object.filename)
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
recipe_json = json.loads(file.getvalue().decode("utf-8"))
|
||||
|
||||
recipe = Recipe.objects.create(
|
||||
name=recipe_json['name'].strip(), description=recipe_json['description'].strip(),
|
||||
created_by=self.request.user, internal=True,
|
||||
servings=recipe_json['recipeYield'])
|
||||
|
||||
# TODO parse times (given in PT2H3M )
|
||||
# TODO parse keywords
|
||||
|
||||
ingredients_added = False
|
||||
for s in recipe_json['recipeInstructions']:
|
||||
step = Step.objects.create(
|
||||
instruction=s
|
||||
)
|
||||
if not ingredients_added:
|
||||
ingredients_added = True
|
||||
|
||||
for ingredient in recipe_json['recipeIngredient']:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f, created = Food.objects.get_or_create(name=ingredient)
|
||||
u, created = Unit.objects.get_or_create(name=unit)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
for f in self.files:
|
||||
if '.zip' in f.name:
|
||||
import_zip = ZipFile(f.file)
|
||||
for z in import_zip.filelist:
|
||||
if re.match(f'^Recipes/{recipe.name}/full.jpg$', z.filename):
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)))
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
56
cookbook/integration/paprika.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
import microdata
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from cookbook.helper.recipe_url_import import find_recipe_json
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Ingredient, Unit
|
||||
|
||||
|
||||
class Paprika(Integration):
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
print("testing", zip_info_object.filename)
|
||||
return re.match(r'^Recipes/([A-Za-z\s])+.html$', zip_info_object.filename)
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
html_text = file.getvalue().decode("utf-8")
|
||||
|
||||
items = microdata.get_items(html_text)
|
||||
for i in items:
|
||||
md_json = json.loads(i.json())
|
||||
if 'schema.org/Recipe' in str(md_json['type']):
|
||||
recipe_json = find_recipe_json(md_json['properties'], '')
|
||||
recipe = Recipe.objects.create(name=recipe_json['name'].strip(), created_by=self.request.user, internal=True)
|
||||
step = Step.objects.create(
|
||||
instruction=recipe_json['recipeInstructions']
|
||||
)
|
||||
|
||||
for ingredient in recipe_json['recipeIngredient']:
|
||||
f, created = Food.objects.get_or_create(name=ingredient['ingredient']['text'])
|
||||
u, created = Unit.objects.get_or_create(name=ingredient['unit']['text'])
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=ingredient['amount'], note=ingredient['note']
|
||||
))
|
||||
|
||||
recipe.steps.add(step)
|
||||
|
||||
soup = BeautifulSoup(html_text, "html.parser")
|
||||
image = soup.find('img')
|
||||
image_name = image.attrs['src'].strip().replace('Images/', '')
|
||||
|
||||
for f in self.files:
|
||||
if '.zip' in f.name:
|
||||
import_zip = ZipFile(f.file)
|
||||
for z in import_zip.filelist:
|
||||
if re.match(f'^Recipes/Images/{image_name}$', z.filename):
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)))
|
||||
|
||||
return recipe
|
||||
60
cookbook/integration/safron.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
|
||||
|
||||
class Safron(Integration):
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
ingredient_mode = False
|
||||
direction_mode = False
|
||||
|
||||
ingredients = []
|
||||
directions = []
|
||||
for fl in file.readlines():
|
||||
line = fl.decode("utf-8")
|
||||
if 'Title:' in line:
|
||||
title = line.replace('Title:', '').strip()
|
||||
if 'Description:' in line:
|
||||
description = line.replace('Description:', '').strip()
|
||||
if 'Yield:' in line:
|
||||
directions.append(_('Servings') + ' ' + line.replace('Yield:', '').strip() + '\n')
|
||||
if 'Cook:' in line:
|
||||
directions.append(_('Waiting time') + ' ' + line.replace('Cook:', '').strip() + '\n')
|
||||
if 'Prep:' in line:
|
||||
directions.append(_('Preparation Time') + ' ' + line.replace('Prep:', '').strip() + '\n')
|
||||
if 'Cookbook:' in line:
|
||||
directions.append(_('Cookbook') + ' ' + line.replace('Cookbook:', '').strip() + '\n')
|
||||
if 'Section:' in line:
|
||||
directions.append(_('Section') + ' ' + line.replace('Section:', '').strip() + '\n')
|
||||
if ingredient_mode:
|
||||
if len(line) > 2 and 'Instructions:' not in line:
|
||||
ingredients.append(line.strip())
|
||||
if direction_mode:
|
||||
if len(line) > 2:
|
||||
directions.append(line.strip())
|
||||
if 'Ingredients:' in line:
|
||||
ingredient_mode = True
|
||||
if 'Instructions:' in line:
|
||||
ingredient_mode = False
|
||||
direction_mode = True
|
||||
|
||||
recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, )
|
||||
|
||||
step = Step.objects.create(instruction='\n'.join(directions))
|
||||
|
||||
for ingredient in ingredients:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f, created = Food.objects.get_or_create(name=ingredient)
|
||||
u, created = Unit.objects.get_or_create(name=unit)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
BIN
cookbook/locale/ca/LC_MESSAGES/django.mo
Normal file
2135
cookbook/locale/ca/LC_MESSAGES/django.po
Normal file
BIN
cookbook/locale/cs/LC_MESSAGES/django.mo
Normal file
1871
cookbook/locale/cs/LC_MESSAGES/django.po
Normal file
BIN
cookbook/locale/es/LC_MESSAGES/django.mo
Normal file
2162
cookbook/locale/es/LC_MESSAGES/django.po
Normal file
BIN
cookbook/locale/hu_HU/LC_MESSAGES/django.mo
Normal file
1775
cookbook/locale/hu_HU/LC_MESSAGES/django.po
Normal file
BIN
cookbook/locale/it/LC_MESSAGES/django.mo
Normal file
2133
cookbook/locale/it/LC_MESSAGES/django.po
Normal file
BIN
cookbook/locale/lv/LC_MESSAGES/django.mo
Normal file
2049
cookbook/locale/lv/LC_MESSAGES/django.po
Normal file
BIN
cookbook/locale/zh_CN/LC_MESSAGES/django.mo
Normal file
1775
cookbook/locale/zh_CN/LC_MESSAGES/django.po
Normal file
36
cookbook/migrations/0089_auto_20201117_2222.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 3.1.1 on 2020-11-17 21:22
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0088_shoppinglist_finished'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NutritionInformation',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('fats', models.DecimalField(decimal_places=16, default=0, max_digits=32)),
|
||||
('carbohydrates', models.DecimalField(decimal_places=16, default=0, max_digits=32)),
|
||||
('proteins', models.DecimalField(decimal_places=16, default=0, max_digits=32)),
|
||||
('calories', models.DecimalField(decimal_places=16, default=0, max_digits=32)),
|
||||
('source', models.CharField(blank=True, default='', max_length=512, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invitelink',
|
||||
name='valid_until',
|
||||
field=models.DateField(default=datetime.date(2020, 12, 1)),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='recipe',
|
||||
name='nutrition',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.nutritioninformation'),
|
||||
),
|
||||
]
|
||||
24
cookbook/migrations/0090_auto_20201214_1359.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.1.3 on 2020-12-14 12:59
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0089_auto_20201117_2222'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='use_fractions',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invitelink',
|
||||
name='valid_until',
|
||||
field=models.DateField(default=datetime.date(2020, 12, 28)),
|
||||
),
|
||||
]
|
||||
26
cookbook/migrations/0091_auto_20201226_1551.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-26 14:51
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def migrate_empty_units(apps, schema_editor):
|
||||
Unit = apps.get_model('cookbook', 'Unit')
|
||||
Ingredient = apps.get_model('cookbook', 'Ingredient')
|
||||
|
||||
empty_units = Unit.objects.filter(name='').all()
|
||||
for x in empty_units:
|
||||
for i in Ingredient.objects.all():
|
||||
if i.unit == x:
|
||||
i.unit = None
|
||||
i.save()
|
||||
x.delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('cookbook', '0090_auto_20201214_1359'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_empty_units),
|
||||
]
|
||||
18
cookbook/migrations/0092_recipe_servings.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.7 on 2020-08-30 13:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0091_auto_20201226_1551'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='recipe',
|
||||
name='servings',
|
||||
field=models.IntegerField(default=1),
|
||||
),
|
||||
]
|
||||
30
cookbook/migrations/0093_auto_20201231_1236.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-31 11:36
|
||||
|
||||
import datetime
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0092_recipe_servings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='mealplan',
|
||||
old_name='recipe_multiplier',
|
||||
new_name='servings',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invitelink',
|
||||
name='valid_until',
|
||||
field=models.DateField(default=datetime.date(2021, 1, 14)),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='unit',
|
||||
name='name',
|
||||
field=models.CharField(max_length=128, unique=True, validators=[django.core.validators.MinLengthValidator(1)]),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0094_auto_20201231_1238.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.1.4 on 2020-12-31 11:38
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0093_auto_20201231_1236'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='shoppinglistrecipe',
|
||||
old_name='multiplier',
|
||||
new_name='servings',
|
||||
),
|
||||
]
|
||||
24
cookbook/migrations/0095_auto_20210107_1804.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.1.5 on 2021-01-07 17:04
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0094_auto_20201231_1238'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='sticky_navbar',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invitelink',
|
||||
name='valid_until',
|
||||
field=models.DateField(default=datetime.date(2021, 1, 21)),
|
||||
),
|
||||
]
|
||||
31
cookbook/migrations/0096_auto_20210109_2044.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 3.1.5 on 2021-01-09 19:44
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def delete_duplicate_bookmarks(apps, schema_editor):
|
||||
"""
|
||||
In this migration, a unique constraint is set on the fields `recipe` and `book`.
|
||||
If there are already duplicate entries, the migration will fail.
|
||||
Therefore all duplicate entries are deleted beforehand.
|
||||
"""
|
||||
RecipeBookEntry = apps.get_model('cookbook', 'RecipeBookEntry')
|
||||
|
||||
for row in RecipeBookEntry.objects.all():
|
||||
if RecipeBookEntry.objects.filter(recipe=row.recipe, book=row.book).count() > 1:
|
||||
row.delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('cookbook', '0095_auto_20210107_1804'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# run function to delete duplicated bookmarks
|
||||
migrations.RunPython(delete_duplicate_bookmarks),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='recipebookentry',
|
||||
unique_together={('recipe', 'book')},
|
||||
),
|
||||
]
|
||||
25
cookbook/migrations/0097_auto_20210113_1315.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 3.1.5 on 2021-01-13 12:15
|
||||
|
||||
import datetime
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0096_auto_20210109_2044'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='recipe',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=512, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='food',
|
||||
name='name',
|
||||
field=models.CharField(max_length=128, unique=True, validators=[django.core.validators.MinLengthValidator(1)]),
|
||||
),
|
||||
]
|
||||
19
cookbook/migrations/0098_auto_20210113_1320.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.1.5 on 2021-01-13 12:20
|
||||
|
||||
import cookbook.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0097_auto_20210113_1315'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='invitelink',
|
||||
name='valid_until',
|
||||
field=models.DateField(default=cookbook.models.default_valid_until),
|
||||
),
|
||||
]
|
||||
19
cookbook/migrations/0099_auto_20210113_1518.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.1.5 on 2021-01-13 14:18
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0098_auto_20210113_1320'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='cooklog',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0100_recipe_servings_text.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.1.5 on 2021-01-21 19:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0099_auto_20210113_1518'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='recipe',
|
||||
name='servings_text',
|
||||
field=models.CharField(blank=True, default='', max_length=32),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0101_storage_path.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.1.5 on 2021-01-22 18:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0100_recipe_servings_text'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='storage',
|
||||
name='path',
|
||||
field=models.CharField(blank=True, default='', max_length=256),
|
||||
),
|
||||
]
|
||||
46
cookbook/migrations/0102_auto_20210125_1147.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 3.1.5 on 2021-01-25 10:47
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0101_storage_path'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Supermarket',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=128, unique=True, validators=[django.core.validators.MinLengthValidator(1)])),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SupermarketCategory',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=128, unique=True, validators=[django.core.validators.MinLengthValidator(1)])),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, default=''),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='storage',
|
||||
name='method',
|
||||
field=models.CharField(choices=[('DB', 'Dropbox'), ('NEXTCLOUD', 'Nextcloud'), ('LOCAL', 'Local')], default='DB', max_length=128),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='supermarket_category',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.supermarketcategory'),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0103_food_ignore_shopping.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.1.5 on 2021-01-25 13:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0102_auto_20210125_1147'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='ignore_shopping',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
28
cookbook/migrations/0104_auto_20210125_2133.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 3.1.5 on 2021-01-25 20:33
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0103_food_ignore_shopping'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SupermarketCategoryRelation',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('order', models.IntegerField(default=0)),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.supermarketcategory')),
|
||||
('supermarket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.supermarket')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='supermarket',
|
||||
name='categories',
|
||||
field=models.ManyToManyField(through='cookbook.SupermarketCategoryRelation', to='cookbook.SupermarketCategory'),
|
||||
),
|
||||
]
|
||||
24
cookbook/migrations/0105_auto_20210126_1604.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.1.5 on 2021-01-26 15:04
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0104_auto_20210125_2133'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='supermarketcategoryrelation',
|
||||
name='category',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='category_to_supermarket', to='cookbook.supermarketcategory'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='supermarketcategoryrelation',
|
||||
name='supermarket',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='category_to_supermarket', to='cookbook.supermarket'),
|
||||
),
|
||||
]
|
||||
19
cookbook/migrations/0106_shoppinglist_supermarket.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.1.5 on 2021-01-26 15:21
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0105_auto_20210126_1604'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='shoppinglist',
|
||||
name='supermarket',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.supermarket'),
|
||||
),
|
||||
]
|
||||
17
cookbook/migrations/0107_auto_20210128_1535.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.1.5 on 2021-01-28 14:35
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0106_shoppinglist_supermarket'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='supermarketcategoryrelation',
|
||||
options={'ordering': ('order',)},
|
||||
),
|
||||
]
|
||||
@@ -4,12 +4,15 @@ from datetime import date, timedelta
|
||||
|
||||
from annoying.fields import AutoOneToOneField
|
||||
from django.contrib import auth
|
||||
from django.contrib.auth.models import User, Group
|
||||
from django.utils.translation import gettext as _
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django_random_queryset import RandomManager
|
||||
|
||||
from recipes.settings import COMMENT_PREF_DEFAULT
|
||||
from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT,
|
||||
STICKY_NAV_PREF_DEFAULT)
|
||||
|
||||
|
||||
def get_user_name(self):
|
||||
@@ -38,7 +41,12 @@ class UserPreference(models.Model):
|
||||
FLATLY = 'FLATLY'
|
||||
SUPERHERO = 'SUPERHERO'
|
||||
|
||||
THEMES = ((BOOTSTRAP, 'Bootstrap'), (DARKLY, 'Darkly'), (FLATLY, 'Flatly'), (SUPERHERO, 'Superhero'))
|
||||
THEMES = (
|
||||
(BOOTSTRAP, 'Bootstrap'),
|
||||
(DARKLY, 'Darkly'),
|
||||
(FLATLY, 'Flatly'),
|
||||
(SUPERHERO, 'Superhero')
|
||||
)
|
||||
|
||||
# Nav colors
|
||||
PRIMARY = 'PRIMARY'
|
||||
@@ -50,14 +58,26 @@ class UserPreference(models.Model):
|
||||
LIGHT = 'LIGHT'
|
||||
DARK = 'DARK'
|
||||
|
||||
COLORS = ((PRIMARY, 'Primary'), (SECONDARY, 'Secondary'), (SUCCESS, 'Success'), (INFO, 'Info'), (WARNING, 'Warning'), (DANGER, 'Danger'), (LIGHT, 'Light'), (DARK, 'Dark'))
|
||||
COLORS = (
|
||||
(PRIMARY, 'Primary'),
|
||||
(SECONDARY, 'Secondary'),
|
||||
(SUCCESS, 'Success'), (INFO, 'Info'),
|
||||
(WARNING, 'Warning'),
|
||||
(DANGER, 'Danger'),
|
||||
(LIGHT, 'Light'),
|
||||
(DARK, 'Dark')
|
||||
)
|
||||
|
||||
# Default Page
|
||||
SEARCH = 'SEARCH'
|
||||
PLAN = 'PLAN'
|
||||
BOOKS = 'BOOKS'
|
||||
|
||||
PAGES = ((SEARCH, _('Search')), (PLAN, _('Meal-Plan')), (BOOKS, _('Books')),)
|
||||
PAGES = (
|
||||
(SEARCH, _('Search')),
|
||||
(PLAN, _('Meal-Plan')),
|
||||
(BOOKS, _('Books')),
|
||||
)
|
||||
|
||||
# Search Style
|
||||
SMALL = 'SMALL'
|
||||
@@ -67,15 +87,25 @@ class UserPreference(models.Model):
|
||||
|
||||
user = AutoOneToOneField(User, on_delete=models.CASCADE, primary_key=True)
|
||||
theme = models.CharField(choices=THEMES, max_length=128, default=FLATLY)
|
||||
nav_color = models.CharField(choices=COLORS, max_length=128, default=PRIMARY)
|
||||
nav_color = models.CharField(
|
||||
choices=COLORS, max_length=128, default=PRIMARY
|
||||
)
|
||||
default_unit = models.CharField(max_length=32, default='g')
|
||||
default_page = models.CharField(choices=PAGES, max_length=64, default=SEARCH)
|
||||
search_style = models.CharField(choices=SEARCH_STYLE, max_length=64, default=LARGE)
|
||||
use_fractions = models.BooleanField(default=FRACTION_PREF_DEFAULT)
|
||||
default_page = models.CharField(
|
||||
choices=PAGES, max_length=64, default=SEARCH
|
||||
)
|
||||
search_style = models.CharField(
|
||||
choices=SEARCH_STYLE, max_length=64, default=LARGE
|
||||
)
|
||||
show_recent = models.BooleanField(default=True)
|
||||
plan_share = models.ManyToManyField(User, blank=True, related_name='plan_share_default')
|
||||
plan_share = models.ManyToManyField(
|
||||
User, blank=True, related_name='plan_share_default'
|
||||
)
|
||||
ingredient_decimals = models.IntegerField(default=2)
|
||||
comments = models.BooleanField(default=COMMENT_PREF_DEFAULT)
|
||||
shopping_auto_sync = models.IntegerField(default=5)
|
||||
sticky_navbar = models.BooleanField(default=STICKY_NAV_PREF_DEFAULT)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.user)
|
||||
@@ -84,14 +114,18 @@ class UserPreference(models.Model):
|
||||
class Storage(models.Model):
|
||||
DROPBOX = 'DB'
|
||||
NEXTCLOUD = 'NEXTCLOUD'
|
||||
STORAGE_TYPES = ((DROPBOX, 'Dropbox'), (NEXTCLOUD, 'Nextcloud'))
|
||||
LOCAL = 'LOCAL'
|
||||
STORAGE_TYPES = ((DROPBOX, 'Dropbox'), (NEXTCLOUD, 'Nextcloud'), (LOCAL, 'Local'))
|
||||
|
||||
name = models.CharField(max_length=128)
|
||||
method = models.CharField(choices=STORAGE_TYPES, max_length=128, default=DROPBOX)
|
||||
method = models.CharField(
|
||||
choices=STORAGE_TYPES, max_length=128, default=DROPBOX
|
||||
)
|
||||
username = models.CharField(max_length=128, blank=True, null=True)
|
||||
password = models.CharField(max_length=128, blank=True, null=True)
|
||||
token = models.CharField(max_length=512, blank=True, null=True)
|
||||
url = models.URLField(blank=True, null=True)
|
||||
path = models.CharField(blank=True, default='', max_length=256)
|
||||
created_by = models.ForeignKey(User, on_delete=models.PROTECT)
|
||||
|
||||
def __str__(self):
|
||||
@@ -110,6 +144,32 @@ class Sync(models.Model):
|
||||
return self.path
|
||||
|
||||
|
||||
class SupermarketCategory(models.Model):
|
||||
name = models.CharField(unique=True, max_length=128, validators=[MinLengthValidator(1)])
|
||||
description = models.TextField(blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Supermarket(models.Model):
|
||||
name = models.CharField(unique=True, max_length=128, validators=[MinLengthValidator(1)])
|
||||
description = models.TextField(blank=True, null=True)
|
||||
categories = models.ManyToManyField(SupermarketCategory, through='SupermarketCategoryRelation')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class SupermarketCategoryRelation(models.Model):
|
||||
supermarket = models.ForeignKey(Supermarket, on_delete=models.CASCADE, related_name='category_to_supermarket')
|
||||
category = models.ForeignKey(SupermarketCategory, on_delete=models.CASCADE, related_name='category_to_supermarket')
|
||||
order = models.IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ('order',)
|
||||
|
||||
|
||||
class SyncLog(models.Model):
|
||||
sync = models.ForeignKey(Sync, on_delete=models.CASCADE)
|
||||
status = models.CharField(max_length=32)
|
||||
@@ -135,7 +195,7 @@ class Keyword(models.Model):
|
||||
|
||||
|
||||
class Unit(models.Model):
|
||||
name = models.CharField(unique=True, max_length=128)
|
||||
name = models.CharField(unique=True, max_length=128, validators=[MinLengthValidator(1)])
|
||||
description = models.TextField(blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
@@ -143,16 +203,23 @@ class Unit(models.Model):
|
||||
|
||||
|
||||
class Food(models.Model):
|
||||
name = models.CharField(unique=True, max_length=128)
|
||||
name = models.CharField(unique=True, max_length=128, validators=[MinLengthValidator(1)])
|
||||
recipe = models.ForeignKey('Recipe', null=True, blank=True, on_delete=models.SET_NULL)
|
||||
supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL)
|
||||
ignore_shopping = models.BooleanField(default=False)
|
||||
description = models.TextField(default='', blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Ingredient(models.Model):
|
||||
food = models.ForeignKey(Food, on_delete=models.PROTECT, null=True, blank=True)
|
||||
unit = models.ForeignKey(Unit, on_delete=models.PROTECT, null=True, blank=True)
|
||||
food = models.ForeignKey(
|
||||
Food, on_delete=models.PROTECT, null=True, blank=True
|
||||
)
|
||||
unit = models.ForeignKey(
|
||||
Unit, on_delete=models.PROTECT, null=True, blank=True
|
||||
)
|
||||
amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||
note = models.CharField(max_length=256, null=True, blank=True)
|
||||
is_header = models.BooleanField(default=False)
|
||||
@@ -171,21 +238,49 @@ class Step(models.Model):
|
||||
TIME = 'TIME'
|
||||
|
||||
name = models.CharField(max_length=128, default='', blank=True)
|
||||
type = models.CharField(choices=((TEXT, _('Text')), (TIME, _('Time')),), default=TEXT, max_length=16)
|
||||
type = models.CharField(
|
||||
choices=((TEXT, _('Text')), (TIME, _('Time')),),
|
||||
default=TEXT,
|
||||
max_length=16
|
||||
)
|
||||
instruction = models.TextField(blank=True)
|
||||
ingredients = models.ManyToManyField(Ingredient, blank=True)
|
||||
time = models.IntegerField(default=0, blank=True)
|
||||
order = models.IntegerField(default=0)
|
||||
show_as_header = models.BooleanField(default=True)
|
||||
|
||||
def get_instruction_render(self):
|
||||
from cookbook.helper.template_helper import render_instructions
|
||||
return render_instructions(self)
|
||||
|
||||
class Meta:
|
||||
ordering = ['order', 'pk']
|
||||
|
||||
|
||||
class NutritionInformation(models.Model):
|
||||
fats = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||
carbohydrates = models.DecimalField(
|
||||
default=0, decimal_places=16, max_digits=32
|
||||
)
|
||||
proteins = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||
calories = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||
source = models.CharField(
|
||||
max_length=512, default="", null=True, blank=True
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return 'Nutrition'
|
||||
|
||||
|
||||
class Recipe(models.Model):
|
||||
name = models.CharField(max_length=128)
|
||||
description = models.CharField(max_length=512, blank=True, null=True)
|
||||
servings = models.IntegerField(default=1)
|
||||
servings_text = models.CharField(default='', blank=True, max_length=32)
|
||||
image = models.ImageField(upload_to='recipes/', blank=True, null=True)
|
||||
storage = models.ForeignKey(Storage, on_delete=models.PROTECT, blank=True, null=True)
|
||||
storage = models.ForeignKey(
|
||||
Storage, on_delete=models.PROTECT, blank=True, null=True
|
||||
)
|
||||
file_uid = models.CharField(max_length=256, default="", blank=True)
|
||||
file_path = models.CharField(max_length=512, default="", blank=True)
|
||||
link = models.CharField(max_length=512, null=True, blank=True)
|
||||
@@ -195,6 +290,9 @@ class Recipe(models.Model):
|
||||
working_time = models.IntegerField(default=0)
|
||||
waiting_time = models.IntegerField(default=0)
|
||||
internal = models.BooleanField(default=False)
|
||||
nutrition = models.ForeignKey(
|
||||
NutritionInformation, blank=True, null=True, on_delete=models.CASCADE
|
||||
)
|
||||
created_by = models.ForeignKey(User, on_delete=models.PROTECT)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
@@ -231,7 +329,9 @@ class RecipeBook(models.Model):
|
||||
name = models.CharField(max_length=128)
|
||||
description = models.TextField(blank=True)
|
||||
icon = models.CharField(max_length=16, blank=True, null=True)
|
||||
shared = models.ManyToManyField(User, blank=True, related_name='shared_with')
|
||||
shared = models.ManyToManyField(
|
||||
User, blank=True, related_name='shared_with'
|
||||
)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
@@ -245,6 +345,15 @@ class RecipeBookEntry(models.Model):
|
||||
def __str__(self):
|
||||
return self.recipe.name
|
||||
|
||||
def get_owner(self):
|
||||
try:
|
||||
return self.book.created_by
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
unique_together = (('recipe', 'book'),)
|
||||
|
||||
|
||||
class MealType(models.Model):
|
||||
name = models.CharField(max_length=128)
|
||||
@@ -256,11 +365,15 @@ class MealType(models.Model):
|
||||
|
||||
|
||||
class MealPlan(models.Model):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, blank=True, null=True)
|
||||
recipe_multiplier = models.DecimalField(default=1, max_digits=8, decimal_places=4)
|
||||
recipe = models.ForeignKey(
|
||||
Recipe, on_delete=models.CASCADE, blank=True, null=True
|
||||
)
|
||||
servings = models.DecimalField(default=1, max_digits=8, decimal_places=4)
|
||||
title = models.CharField(max_length=64, blank=True, default='')
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
shared = models.ManyToManyField(User, blank=True, related_name='plan_share')
|
||||
shared = models.ManyToManyField(
|
||||
User, blank=True, related_name='plan_share'
|
||||
)
|
||||
meal_type = models.ForeignKey(MealType, on_delete=models.CASCADE)
|
||||
note = models.TextField(blank=True)
|
||||
date = models.DateField()
|
||||
@@ -278,8 +391,10 @@ class MealPlan(models.Model):
|
||||
|
||||
|
||||
class ShoppingListRecipe(models.Model):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, null=True, blank=True)
|
||||
multiplier = models.DecimalField(default=1, max_digits=8, decimal_places=4)
|
||||
recipe = models.ForeignKey(
|
||||
Recipe, on_delete=models.CASCADE, null=True, blank=True
|
||||
)
|
||||
servings = models.DecimalField(default=1, max_digits=8, decimal_places=4)
|
||||
|
||||
def __str__(self):
|
||||
return f'Shopping list recipe {self.id} - {self.recipe}'
|
||||
@@ -315,6 +430,7 @@ class ShoppingList(models.Model):
|
||||
recipes = models.ManyToManyField(ShoppingListRecipe, blank=True)
|
||||
entries = models.ManyToManyField(ShoppingListEntry, blank=True)
|
||||
shared = models.ManyToManyField(User, blank=True, related_name='list_share')
|
||||
supermarket = models.ForeignKey(Supermarket, null=True, blank=True, on_delete=models.SET_NULL)
|
||||
finished = models.BooleanField(default=False)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -333,12 +449,18 @@ class ShareLink(models.Model):
|
||||
return f'{self.recipe} - {self.uuid}'
|
||||
|
||||
|
||||
def default_valid_until():
|
||||
return date.today() + timedelta(days=14)
|
||||
|
||||
|
||||
class InviteLink(models.Model):
|
||||
uuid = models.UUIDField(default=uuid.uuid4)
|
||||
username = models.CharField(blank=True, max_length=64)
|
||||
group = models.ForeignKey(Group, on_delete=models.CASCADE)
|
||||
valid_until = models.DateField(default=date.today() + timedelta(days=14))
|
||||
used_by = models.ForeignKey(User, null=True, on_delete=models.CASCADE, related_name='used_by')
|
||||
valid_until = models.DateField(default=default_valid_until)
|
||||
used_by = models.ForeignKey(
|
||||
User, null=True, on_delete=models.CASCADE, related_name='used_by'
|
||||
)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@@ -349,7 +471,7 @@ class InviteLink(models.Model):
|
||||
class CookLog(models.Model):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
created_at = models.DateTimeField(default=timezone.now)
|
||||
rating = models.IntegerField(null=True)
|
||||
servings = models.IntegerField(default=0)
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
import json
|
||||
|
||||
from cookbook.models import Recipe, RecipeImport, SyncLog
|
||||
from cookbook.provider.provider import Provider
|
||||
|
||||
@@ -34,16 +32,26 @@ class Dropbox(Provider):
|
||||
return r
|
||||
|
||||
import_count = 0
|
||||
for recipe in recipes['entries']: # TODO check if has_more is set and import that as well
|
||||
# TODO check if has_more is set and import that as well
|
||||
for recipe in recipes['entries']:
|
||||
path = recipe['path_lower']
|
||||
if not Recipe.objects.filter(file_path__iexact=path).exists() and not RecipeImport.objects.filter(
|
||||
file_path=path).exists():
|
||||
if not Recipe.objects.filter(file_path__iexact=path).exists() \
|
||||
and not RecipeImport.objects.filter(file_path=path).exists(): # noqa: E501
|
||||
name = os.path.splitext(recipe['name'])[0]
|
||||
new_recipe = RecipeImport(name=name, file_path=path, storage=monitor.storage, file_uid=recipe['id'])
|
||||
new_recipe = RecipeImport(
|
||||
name=name,
|
||||
file_path=path,
|
||||
storage=monitor.storage,
|
||||
file_uid=recipe['id']
|
||||
)
|
||||
new_recipe.save()
|
||||
import_count += 1
|
||||
|
||||
log_entry = SyncLog(status='SUCCESS', msg='Imported ' + str(import_count) + ' recipes', sync=monitor)
|
||||
log_entry = SyncLog(
|
||||
status='SUCCESS',
|
||||
msg='Imported ' + str(import_count) + ' recipes',
|
||||
sync=monitor
|
||||
)
|
||||
log_entry.save()
|
||||
|
||||
monitor.last_checked = datetime.now()
|
||||
@@ -53,7 +61,7 @@ class Dropbox(Provider):
|
||||
|
||||
@staticmethod
|
||||
def create_share_link(recipe):
|
||||
url = "https://api.dropboxapi.com/2/sharing/create_shared_link_with_settings"
|
||||
url = "https://api.dropboxapi.com/2/sharing/create_shared_link_with_settings" # noqa: E501
|
||||
|
||||
headers = {
|
||||
"Authorization": "Bearer " + recipe.storage.token,
|
||||
@@ -84,8 +92,8 @@ class Dropbox(Provider):
|
||||
r = requests.post(url, headers=headers, data=json.dumps(data))
|
||||
p = r.json()
|
||||
|
||||
for l in p['links']:
|
||||
return l['url']
|
||||
for link in p['links']:
|
||||
return link['url']
|
||||
|
||||
response = Dropbox.create_share_link(recipe)
|
||||
return response['url']
|
||||
@@ -96,7 +104,9 @@ class Dropbox(Provider):
|
||||
recipe.link = Dropbox.get_share_link(recipe)
|
||||
recipe.save()
|
||||
|
||||
response = requests.get(recipe.link.replace('www.dropbox.', 'dl.dropboxusercontent.'))
|
||||
response = requests.get(
|
||||
recipe.link.replace('www.dropbox.', 'dl.dropboxusercontent.')
|
||||
)
|
||||
|
||||
return io.BytesIO(response.content)
|
||||
|
||||
@@ -111,7 +121,11 @@ class Dropbox(Provider):
|
||||
|
||||
data = {
|
||||
"from_path": recipe.file_path,
|
||||
"to_path": os.path.dirname(recipe.file_path) + '/' + new_name + os.path.splitext(recipe.file_path)[1]
|
||||
"to_path": "%s/%s%s" % (
|
||||
os.path.dirname(recipe.file_path),
|
||||
new_name,
|
||||
os.path.splitext(recipe.file_path)[1]
|
||||
)
|
||||
}
|
||||
|
||||
r = requests.post(url, headers=headers, data=json.dumps(data))
|
||||
|
||||
59
cookbook/provider/local.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from os import listdir
|
||||
from os.path import isfile, join
|
||||
|
||||
from cookbook.models import Recipe, RecipeImport, SyncLog
|
||||
from cookbook.provider.provider import Provider
|
||||
|
||||
|
||||
class Local(Provider):
|
||||
|
||||
@staticmethod
|
||||
def import_all(monitor):
|
||||
files = [f for f in listdir(monitor.path) if isfile(join(monitor.path, f))]
|
||||
|
||||
import_count = 0
|
||||
for file in files:
|
||||
path = monitor.path + '/' + file
|
||||
if not Recipe.objects.filter(file_path__iexact=path).exists() \
|
||||
and not RecipeImport.objects.filter(file_path=path).exists(): # noqa: E501
|
||||
name = os.path.splitext(file)[0]
|
||||
new_recipe = RecipeImport(
|
||||
name=name,
|
||||
file_path=path,
|
||||
storage=monitor.storage
|
||||
)
|
||||
new_recipe.save()
|
||||
import_count += 1
|
||||
|
||||
log_entry = SyncLog(
|
||||
status='SUCCESS',
|
||||
msg='Imported ' + str(import_count) + ' recipes',
|
||||
sync=monitor
|
||||
)
|
||||
log_entry.save()
|
||||
|
||||
monitor.last_checked = datetime.now()
|
||||
monitor.save()
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_file(recipe):
|
||||
file = io.BytesIO(open(recipe.file_path, 'rb').read())
|
||||
|
||||
return file
|
||||
|
||||
@staticmethod
|
||||
def rename_file(recipe, new_name):
|
||||
os.rename(recipe.file_path, os.path.join(os.path.dirname(recipe.file_path), (new_name + os.path.splitext(recipe.file_path)[1])))
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def delete_file(recipe):
|
||||
os.remove(recipe.file_path)
|
||||
return True
|
||||
@@ -1,15 +1,13 @@
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
import webdav3.client as wc
|
||||
import requests
|
||||
from io import BytesIO
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
import requests
|
||||
import webdav3.client as wc
|
||||
from cookbook.models import Recipe, RecipeImport, SyncLog
|
||||
from cookbook.provider.provider import Provider
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
|
||||
class Nextcloud(Provider):
|
||||
@@ -22,6 +20,8 @@ class Nextcloud(Provider):
|
||||
'webdav_password': storage.password,
|
||||
'webdav_root': '/remote.php/dav/files/' + storage.username
|
||||
}
|
||||
if storage.path != '':
|
||||
options['webdav_root'] = storage.path
|
||||
return wc.Client(options)
|
||||
|
||||
@staticmethod
|
||||
@@ -34,14 +34,22 @@ class Nextcloud(Provider):
|
||||
import_count = 0
|
||||
for file in files:
|
||||
path = monitor.path + '/' + file
|
||||
if not Recipe.objects.filter(file_path__iexact=path).exists() and not RecipeImport.objects.filter(
|
||||
file_path=path).exists():
|
||||
if not Recipe.objects.filter(file_path__iexact=path).exists() \
|
||||
and not RecipeImport.objects.filter(file_path=path).exists(): # noqa: E501
|
||||
name = os.path.splitext(file)[0]
|
||||
new_recipe = RecipeImport(name=name, file_path=path, storage=monitor.storage)
|
||||
new_recipe = RecipeImport(
|
||||
name=name,
|
||||
file_path=path,
|
||||
storage=monitor.storage
|
||||
)
|
||||
new_recipe.save()
|
||||
import_count += 1
|
||||
|
||||
log_entry = SyncLog(status='SUCCESS', msg='Imported ' + str(import_count) + ' recipes', sync=monitor)
|
||||
log_entry = SyncLog(
|
||||
status='SUCCESS',
|
||||
msg='Imported ' + str(import_count) + ' recipes',
|
||||
sync=monitor
|
||||
)
|
||||
log_entry.save()
|
||||
|
||||
monitor.last_checked = datetime.now()
|
||||
@@ -51,7 +59,7 @@ class Nextcloud(Provider):
|
||||
|
||||
@staticmethod
|
||||
def create_share_link(recipe):
|
||||
url = recipe.storage.url + '/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json'
|
||||
url = recipe.storage.url + '/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json' # noqa: E501
|
||||
|
||||
headers = {
|
||||
"OCS-APIRequest": "true",
|
||||
@@ -60,8 +68,14 @@ class Nextcloud(Provider):
|
||||
|
||||
data = {'path': recipe.file_path, 'shareType': 3}
|
||||
|
||||
r = requests.post(url, headers=headers, auth=HTTPBasicAuth(recipe.storage.username, recipe.storage.password),
|
||||
data=data)
|
||||
r = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
auth=HTTPBasicAuth(
|
||||
recipe.storage.username, recipe.storage.password
|
||||
),
|
||||
data=data
|
||||
)
|
||||
|
||||
response_json = r.json()
|
||||
|
||||
@@ -69,14 +83,20 @@ class Nextcloud(Provider):
|
||||
|
||||
@staticmethod
|
||||
def get_share_link(recipe):
|
||||
url = recipe.storage.url + '/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json&path=' + recipe.file_path
|
||||
url = recipe.storage.url + '/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json&path=' + recipe.file_path # noqa: E501
|
||||
|
||||
headers = {
|
||||
"OCS-APIRequest": "true",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
r = requests.get(url, headers=headers, auth=HTTPBasicAuth(recipe.storage.username, recipe.storage.password))
|
||||
r = requests.get(
|
||||
url,
|
||||
headers=headers,
|
||||
auth=HTTPBasicAuth(
|
||||
recipe.storage.username, recipe.storage.password
|
||||
)
|
||||
)
|
||||
|
||||
response_json = r.json()
|
||||
for element in response_json['ocs']['data']:
|
||||
@@ -91,7 +111,10 @@ class Nextcloud(Provider):
|
||||
|
||||
tmp_file_path = tempfile.gettempdir() + '/' + recipe.name + '.pdf'
|
||||
|
||||
client.download_file(remote_path=recipe.file_path, local_path=tmp_file_path)
|
||||
client.download_file(
|
||||
remote_path=recipe.file_path,
|
||||
local_path=tmp_file_path
|
||||
)
|
||||
|
||||
file = io.BytesIO(open(tmp_file_path, 'rb').read())
|
||||
os.remove(tmp_file_path)
|
||||
@@ -102,8 +125,14 @@ class Nextcloud(Provider):
|
||||
def rename_file(recipe, new_name):
|
||||
client = Nextcloud.get_client(recipe.storage)
|
||||
|
||||
client.move(recipe.file_path,
|
||||
os.path.dirname(recipe.file_path) + '/' + new_name + os.path.splitext(recipe.file_path)[1])
|
||||
client.move(
|
||||
recipe.file_path,
|
||||
"%s/%s%s" % (
|
||||
os.path.dirname(recipe.file_path),
|
||||
new_name,
|
||||
os.path.splitext(recipe.file_path)[1]
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from drf_writable_nested import WritableNestedModelSerializer, UniqueFieldsMixin
|
||||
from drf_writable_nested import (UniqueFieldsMixin,
|
||||
WritableNestedModelSerializer)
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from cookbook.models import MealPlan, MealType, Recipe, ViewLog, UserPreference, Storage, Sync, SyncLog, Keyword, Unit, Ingredient, Comment, RecipeImport, RecipeBook, RecipeBookEntry, ShareLink, CookLog, Food, Step, ShoppingList, \
|
||||
ShoppingListEntry, ShoppingListRecipe
|
||||
from cookbook.models import (Comment, CookLog, Food, Ingredient, Keyword,
|
||||
MealPlan, MealType, NutritionInformation, Recipe,
|
||||
RecipeBook, RecipeBookEntry, RecipeImport,
|
||||
ShareLink, ShoppingList, ShoppingListEntry,
|
||||
ShoppingListRecipe, Step, Storage, Sync, SyncLog,
|
||||
Unit, UserPreference, ViewLog, SupermarketCategory, Supermarket, SupermarketCategoryRelation)
|
||||
from cookbook.templatetags.custom_tags import markdown
|
||||
|
||||
|
||||
class CustomDecimalField(serializers.Field):
|
||||
"""
|
||||
Custom decimal field to normalize useless decimal places and allow commas as decimal separators
|
||||
Custom decimal field to normalize useless decimal places
|
||||
and allow commas as decimal separators
|
||||
"""
|
||||
|
||||
def to_representation(self, value):
|
||||
@@ -47,15 +53,21 @@ class UserNameSerializer(WritableNestedModelSerializer):
|
||||
class UserPreferenceSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = UserPreference
|
||||
fields = ('user', 'theme', 'nav_color', 'default_unit', 'default_page', 'search_style', 'show_recent',
|
||||
'plan_share', 'ingredient_decimals', 'comments')
|
||||
fields = (
|
||||
'user', 'theme', 'nav_color', 'default_unit', 'default_page',
|
||||
'search_style', 'show_recent', 'plan_share', 'ingredient_decimals',
|
||||
'comments'
|
||||
)
|
||||
read_only_fields = ['user']
|
||||
|
||||
|
||||
class StorageSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Storage
|
||||
fields = ('id', 'name', 'method', 'username', 'password', 'token', 'created_by')
|
||||
fields = (
|
||||
'id', 'name', 'method', 'username', 'password',
|
||||
'token', 'created_by'
|
||||
)
|
||||
|
||||
extra_kwargs = {
|
||||
'password': {'write_only': True},
|
||||
@@ -66,7 +78,10 @@ class StorageSerializer(serializers.ModelSerializer):
|
||||
class SyncSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Sync
|
||||
fields = ('id', 'storage', 'path', 'active', 'last_checked', 'created_at', 'updated_at')
|
||||
fields = (
|
||||
'id', 'storage', 'path', 'active', 'last_checked',
|
||||
'created_at', 'updated_at'
|
||||
)
|
||||
|
||||
|
||||
class SyncLogSerializer(serializers.ModelSerializer):
|
||||
@@ -75,6 +90,20 @@ class SyncLogSerializer(serializers.ModelSerializer):
|
||||
fields = ('id', 'sync', 'status', 'msg', 'created_at')
|
||||
|
||||
|
||||
class KeywordLabelSerializer(serializers.ModelSerializer):
|
||||
label = serializers.SerializerMethodField('get_label')
|
||||
|
||||
def get_label(self, obj):
|
||||
return str(obj)
|
||||
|
||||
class Meta:
|
||||
model = Keyword
|
||||
fields = (
|
||||
'id', 'label',
|
||||
)
|
||||
read_only_fields = ('id', 'label')
|
||||
|
||||
|
||||
class KeywordSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
|
||||
label = serializers.SerializerMethodField('get_label')
|
||||
|
||||
@@ -82,13 +111,17 @@ class KeywordSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
|
||||
return str(obj)
|
||||
|
||||
def create(self, validated_data):
|
||||
# since multi select tags dont have id's duplicate names might be routed to create
|
||||
obj, created = Keyword.objects.get_or_create(**validated_data)
|
||||
# since multi select tags dont have id's
|
||||
# duplicate names might be routed to create
|
||||
obj, created = Keyword.objects.get_or_create(name=validated_data['name'])
|
||||
return obj
|
||||
|
||||
class Meta:
|
||||
model = Keyword
|
||||
fields = ('id', 'name', 'icon', 'label', 'description', 'created_at', 'updated_at')
|
||||
fields = (
|
||||
'id', 'name', 'icon', 'label', 'description',
|
||||
'created_at', 'updated_at'
|
||||
)
|
||||
|
||||
read_only_fields = ('id',)
|
||||
|
||||
@@ -96,8 +129,9 @@ class KeywordSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
|
||||
class UnitSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
# since multi select tags dont have id's duplicate names might be routed to create
|
||||
obj, created = Unit.objects.get_or_create(**validated_data)
|
||||
# since multi select tags dont have id's
|
||||
# duplicate names might be routed to create
|
||||
obj, created = Unit.objects.get_or_create(name=validated_data['name'])
|
||||
return obj
|
||||
|
||||
class Meta:
|
||||
@@ -106,11 +140,45 @@ class UnitSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
|
||||
read_only_fields = ('id',)
|
||||
|
||||
|
||||
class FoodSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
|
||||
class SupermarketCategorySerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
# since multi select tags dont have id's duplicate names might be routed to create
|
||||
obj, created = Food.objects.get_or_create(**validated_data)
|
||||
# since multi select tags dont have id's
|
||||
# duplicate names might be routed to create
|
||||
obj, created = SupermarketCategory.objects.get_or_create(name=validated_data['name'])
|
||||
return obj
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
return super(SupermarketCategorySerializer, self).update(instance, validated_data)
|
||||
|
||||
class Meta:
|
||||
model = SupermarketCategory
|
||||
fields = ('id', 'name')
|
||||
|
||||
|
||||
class SupermarketCategoryRelationSerializer(serializers.ModelSerializer):
|
||||
category = SupermarketCategorySerializer()
|
||||
|
||||
class Meta:
|
||||
model = SupermarketCategoryRelation
|
||||
fields = ('id', 'category', 'supermarket', 'order')
|
||||
|
||||
|
||||
class SupermarketSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
|
||||
category_to_supermarket = SupermarketCategoryRelationSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Supermarket
|
||||
fields = ('id', 'name', 'category_to_supermarket')
|
||||
|
||||
|
||||
class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
|
||||
supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False)
|
||||
|
||||
def create(self, validated_data):
|
||||
# since multi select tags dont have id's
|
||||
# duplicate names might be routed to create
|
||||
obj, created = Food.objects.get_or_create(name=validated_data['name'])
|
||||
return obj
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@@ -118,8 +186,7 @@ class FoodSerializer(UniqueFieldsMixin, serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = ('id', 'name', 'recipe')
|
||||
read_only_fields = ('id',)
|
||||
fields = ('id', 'name', 'recipe', 'ignore_shopping', 'supermarket_category')
|
||||
|
||||
|
||||
class IngredientSerializer(WritableNestedModelSerializer):
|
||||
@@ -129,26 +196,68 @@ class IngredientSerializer(WritableNestedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Ingredient
|
||||
fields = ('id', 'food', 'unit', 'amount', 'note', 'order', 'is_header', 'no_amount')
|
||||
fields = (
|
||||
'id', 'food', 'unit', 'amount', 'note', 'order',
|
||||
'is_header', 'no_amount'
|
||||
)
|
||||
|
||||
|
||||
class StepSerializer(WritableNestedModelSerializer):
|
||||
ingredients = IngredientSerializer(many=True)
|
||||
ingredients_markdown = serializers.SerializerMethodField('get_ingredients_markdown')
|
||||
ingredients_vue = serializers.SerializerMethodField('get_ingredients_vue')
|
||||
|
||||
def get_ingredients_vue(self, obj):
|
||||
return obj.get_instruction_render()
|
||||
|
||||
def get_ingredients_markdown(self, obj):
|
||||
return obj.get_instruction_render()
|
||||
|
||||
class Meta:
|
||||
model = Step
|
||||
fields = ('id', 'name', 'type', 'instruction', 'ingredients', 'time', 'order', 'show_as_header')
|
||||
fields = (
|
||||
'id', 'name', 'type', 'instruction', 'ingredients', 'ingredients_markdown',
|
||||
'ingredients_vue', 'time', 'order', 'show_as_header'
|
||||
)
|
||||
|
||||
|
||||
class NutritionInformationSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = NutritionInformation
|
||||
fields = ('id', 'carbohydrates', 'fats', 'proteins', 'calories', 'source')
|
||||
|
||||
|
||||
class RecipeOverviewSerializer(WritableNestedModelSerializer):
|
||||
keywords = KeywordLabelSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = (
|
||||
'id', 'name', 'description', 'image', 'keywords', 'working_time',
|
||||
'waiting_time', 'created_by', 'created_at', 'updated_at',
|
||||
'internal', 'servings', 'file_path'
|
||||
)
|
||||
read_only_fields = ['image', 'created_by', 'created_at']
|
||||
|
||||
|
||||
class RecipeSerializer(WritableNestedModelSerializer):
|
||||
nutrition = NutritionInformationSerializer(allow_null=True, required=False)
|
||||
steps = StepSerializer(many=True)
|
||||
keywords = KeywordSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ['id', 'name', 'image', 'keywords', 'steps', 'working_time', 'waiting_time', 'created_by', 'created_at', 'updated_at', 'internal']
|
||||
fields = (
|
||||
'id', 'name', 'description', 'image', 'keywords', 'steps', 'working_time',
|
||||
'waiting_time', 'created_by', 'created_at', 'updated_at',
|
||||
'internal', 'nutrition', 'servings', 'file_path', 'servings_text',
|
||||
)
|
||||
read_only_fields = ['image', 'created_by', 'created_at']
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class RecipeImageSerializer(WritableNestedModelSerializer):
|
||||
class Meta:
|
||||
@@ -191,35 +300,40 @@ class MealPlanSerializer(serializers.ModelSerializer):
|
||||
recipe_name = serializers.ReadOnlyField(source='recipe.name')
|
||||
meal_type_name = serializers.ReadOnlyField(source='meal_type.name')
|
||||
note_markdown = serializers.SerializerMethodField('get_note_markdown')
|
||||
recipe_multiplier = CustomDecimalField()
|
||||
servings = CustomDecimalField()
|
||||
|
||||
def get_note_markdown(self, obj):
|
||||
return markdown(obj.note)
|
||||
|
||||
class Meta:
|
||||
model = MealPlan
|
||||
fields = ('id', 'title', 'recipe', 'recipe_multiplier', 'note', 'note_markdown', 'date', 'meal_type', 'created_by', 'shared', 'recipe_name', 'meal_type_name')
|
||||
fields = (
|
||||
'id', 'title', 'recipe', 'servings', 'note', 'note_markdown',
|
||||
'date', 'meal_type', 'created_by', 'shared', 'recipe_name',
|
||||
'meal_type_name'
|
||||
)
|
||||
|
||||
|
||||
class ShoppingListRecipeSerializer(serializers.ModelSerializer):
|
||||
recipe_name = serializers.ReadOnlyField(source='recipe.name')
|
||||
multiplier = CustomDecimalField()
|
||||
servings = CustomDecimalField()
|
||||
|
||||
class Meta:
|
||||
model = ShoppingListRecipe
|
||||
fields = ('id', 'recipe', 'recipe_name', 'multiplier')
|
||||
fields = ('id', 'recipe', 'recipe_name', 'servings')
|
||||
read_only_fields = ('id',)
|
||||
|
||||
|
||||
class ShoppingListEntrySerializer(WritableNestedModelSerializer):
|
||||
food = FoodSerializer(allow_null=True)
|
||||
unit = UnitSerializer(allow_null=True)
|
||||
unit = UnitSerializer(allow_null=True, required=False)
|
||||
amount = CustomDecimalField()
|
||||
|
||||
class Meta:
|
||||
model = ShoppingListEntry
|
||||
fields = ('id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked')
|
||||
read_only_fields = ('id',)
|
||||
fields = (
|
||||
'id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked'
|
||||
)
|
||||
|
||||
|
||||
class ShoppingListEntryCheckedSerializer(serializers.ModelSerializer):
|
||||
@@ -232,10 +346,14 @@ class ShoppingListSerializer(WritableNestedModelSerializer):
|
||||
recipes = ShoppingListRecipeSerializer(many=True, allow_null=True)
|
||||
entries = ShoppingListEntrySerializer(many=True, allow_null=True)
|
||||
shared = UserNameSerializer(many=True)
|
||||
supermarket = SupermarketSerializer(allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = ShoppingList
|
||||
fields = ('id', 'uuid', 'note', 'recipes', 'entries', 'shared', 'finished', 'created_by', 'created_at',)
|
||||
fields = (
|
||||
'id', 'uuid', 'note', 'recipes', 'entries',
|
||||
'shared', 'finished', 'supermarket', 'created_by', 'created_at'
|
||||
)
|
||||
read_only_fields = ('id',)
|
||||
|
||||
|
||||
@@ -255,12 +373,86 @@ class ShareLinkSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class CookLogSerializer(serializers.ModelSerializer):
|
||||
def create(self, validated_data): # TODO make mixin
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = CookLog
|
||||
fields = '__all__'
|
||||
read_only_fields = ('id', 'created_by')
|
||||
|
||||
|
||||
class ViewLogSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ViewLog
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
# Export/Import Serializers
|
||||
|
||||
class KeywordExportSerializer(KeywordSerializer):
|
||||
class Meta:
|
||||
model = Keyword
|
||||
fields = ('name', 'icon', 'description', 'created_at', 'updated_at')
|
||||
|
||||
|
||||
class NutritionInformationExportSerializer(NutritionInformationSerializer):
|
||||
class Meta:
|
||||
model = NutritionInformation
|
||||
fields = ('carbohydrates', 'fats', 'proteins', 'calories', 'source')
|
||||
|
||||
|
||||
class SupermarketCategoryExportSerializer(SupermarketCategorySerializer):
|
||||
class Meta:
|
||||
model = SupermarketCategory
|
||||
fields = ('name',)
|
||||
|
||||
|
||||
class UnitExportSerializer(UnitSerializer):
|
||||
class Meta:
|
||||
model = Unit
|
||||
fields = ('name', 'description')
|
||||
|
||||
|
||||
class FoodExportSerializer(FoodSerializer):
|
||||
supermarket_category = SupermarketCategoryExportSerializer(allow_null=True, required=False)
|
||||
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = ('name', 'ignore_shopping', 'supermarket_category')
|
||||
|
||||
|
||||
class IngredientExportSerializer(WritableNestedModelSerializer):
|
||||
food = FoodExportSerializer(allow_null=True)
|
||||
unit = UnitExportSerializer(allow_null=True)
|
||||
amount = CustomDecimalField()
|
||||
|
||||
class Meta:
|
||||
model = Ingredient
|
||||
fields = ('food', 'unit', 'amount', 'note', 'order', 'is_header', 'no_amount')
|
||||
|
||||
|
||||
class StepExportSerializer(WritableNestedModelSerializer):
|
||||
ingredients = IngredientExportSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Step
|
||||
fields = ('name', 'type', 'instruction', 'ingredients', 'time', 'order', 'show_as_header')
|
||||
|
||||
|
||||
class RecipeExportSerializer(WritableNestedModelSerializer):
|
||||
nutrition = NutritionInformationSerializer(allow_null=True, required=False)
|
||||
steps = StepExportSerializer(many=True)
|
||||
keywords = KeywordExportSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = (
|
||||
'name', 'description', 'keywords', 'steps', 'working_time',
|
||||
'waiting_time', 'internal', 'nutrition', 'servings', 'servings_text',
|
||||
)
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
43
cookbook/static/assets/favicon.svg
Normal file
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g id="Logo" transform="matrix(0.637323,0,0,0.637323,-243.095,-716.725)">
|
||||
<g id="Kreis" transform="matrix(1.44936,0,0,1.50279,387.258,1039.34)">
|
||||
<ellipse cx="273.123" cy="324.015" rx="259.822" ry="250.584" style="fill:url(#_Linear1);"/>
|
||||
<clipPath id="_clip2">
|
||||
<ellipse cx="273.123" cy="324.015" rx="259.822" ry="250.584"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip2)">
|
||||
<g id="Shadow" transform="matrix(1.10322,0,0,1.064,-5.58287,50.5786)">
|
||||
<path d="M156.285,427.208L389.554,660.477L668.803,495.551L374.012,200.761L156.285,427.208Z" style="fill:rgb(22,22,22);"/>
|
||||
<g transform="matrix(1,0,0,1,-4.22105,0.775864)">
|
||||
<path d="M208.628,178.613L485.935,455.919L590.027,364.63L296.923,71.526L294.175,138.989L208.628,178.613Z" style="fill:rgb(22,22,22);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-85.3876,27.8512)">
|
||||
<path d="M310.385,145.641L587.692,422.948L590.392,361.357L297.288,68.253L294.175,138.989L310.385,145.641Z" style="fill:rgb(22,22,22);"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(1.471,0,0,1.471,406.537,1149.69)">
|
||||
<path d="M256.049,220C286.222,219.994 312.656,207.31 329.388,194.134C346.35,180.754 370.899,183.406 384.611,200.1C407.129,227.376 420.598,261.944 420.598,299.53C420.598,361.08 382.604,437.101 329.764,463.706C307.035,475.15 283.466,480.586 256.098,480.599L256.098,480.599L256.049,480.599L256,480.599L256,480.599C228.632,480.586 205.063,475.15 182.334,463.706C129.494,437.101 91.5,361.08 91.5,299.53C91.5,261.944 104.969,227.376 127.487,200.1C141.199,183.406 165.748,180.754 182.71,194.134C199.442,207.31 225.876,219.994 256.049,220Z" style="fill:rgb(255,203,118);"/>
|
||||
</g>
|
||||
<g id="Flame-2" serif:id="Flame 2" transform="matrix(0.965725,0,0,0.89175,164.497,436.391)">
|
||||
<path d="M604.408,844.314C601.981,840.845 601.962,836.056 604.362,832.565C606.763,829.074 611.005,827.721 614.769,829.246C633.87,836.869 658.833,848.629 678.207,864.452C718.526,897.381 729.55,919.407 738.552,942.091C749.208,968.943 750.785,996.68 748.515,1016.08C742.018,1071.61 700.355,1117.5 641.034,1117.5C581.713,1117.5 534.493,1072.05 533.553,1016.08C532.986,982.372 543.985,955.443 555.988,936.22C558.982,931.437 564.594,929.469 569.609,931.444C574.623,933.419 577.757,938.831 577.215,944.58C575.493,956.716 574.362,969.372 574.932,979.484C576.863,1013.7 597.171,1022.5 618.083,1022.29C640.371,1022.08 662.925,1003.17 654.797,954.895C647.69,912.681 622.362,870.194 604.408,844.314Z" style="fill:rgb(255,111,0);"/>
|
||||
<clipPath id="_clip3">
|
||||
<path d="M604.408,844.314C601.981,840.845 601.962,836.056 604.362,832.565C606.763,829.074 611.005,827.721 614.769,829.246C633.87,836.869 658.833,848.629 678.207,864.452C718.526,897.381 729.55,919.407 738.552,942.091C749.208,968.943 750.785,996.68 748.515,1016.08C742.018,1071.61 700.355,1117.5 641.034,1117.5C581.713,1117.5 534.493,1072.05 533.553,1016.08C532.986,982.372 543.985,955.443 555.988,936.22C558.982,931.437 564.594,929.469 569.609,931.444C574.623,933.419 577.757,938.831 577.215,944.58C575.493,956.716 574.362,969.372 574.932,979.484C576.863,1013.7 597.171,1022.5 618.083,1022.29C640.371,1022.08 662.925,1003.17 654.797,954.895C647.69,912.681 622.362,870.194 604.408,844.314Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip3)">
|
||||
<g transform="matrix(1.28784,-0.270602,0.285942,1.59598,247.349,825.209)">
|
||||
<path d="M255.004,46.957C279.547,58.545 306,85.447 313.307,120.161C325.437,177.791 291.571,193.789 262.496,192.403C215.889,190.181 200.194,153.246 231.326,108.9C250.631,81.401 232.663,36.408 255.004,46.957Z" style="fill:rgb(255,209,0);"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Hut" transform="matrix(1.521,0,0,1.521,393.566,1149.06)">
|
||||
<path d="M228.197,408.524C222.698,408.524 217.813,406.688 214.024,403.619C211.776,401.794 210.92,398.752 211.888,396.024C212.856,393.295 215.437,391.472 218.332,391.472C232.214,391.4 256.112,391.396 256.112,391.396C256.112,391.396 280.009,391.4 293.891,391.472C296.786,391.472 299.367,393.295 300.335,396.024C301.303,398.752 300.447,401.794 298.199,403.619C294.41,406.688 289.526,408.524 284.027,408.524L228.197,408.524ZM217.24,378.877C214.208,378.877 211.3,377.671 209.158,375.525C207.015,373.379 205.814,370.469 205.82,367.436C205.831,361.119 205.842,354.539 205.842,354.539C205.842,350.423 203.097,346.814 199.131,345.714C185.313,341.841 175.2,329.468 175.2,314.823C175.2,297.07 190.059,282.657 208.362,282.657C208.362,282.657 208.362,282.657 208.362,282.657C215.401,282.657 221.675,278.218 224.017,271.581C227.243,262.39 236.411,252.015 256,251.998L256,251.998L256.223,251.998L256.223,251.998C275.812,252.015 284.98,262.39 288.206,271.581C290.549,278.218 296.822,282.657 303.861,282.657C303.861,282.657 303.861,282.657 303.861,282.657C322.164,282.657 337.023,297.07 337.023,314.823C337.023,329.468 326.911,341.841 313.093,345.714C309.127,346.814 306.382,350.423 306.381,354.539C306.381,354.539 306.386,361.127 306.391,367.447C306.394,370.478 305.191,373.385 303.049,375.529C300.907,377.672 298.001,378.877 294.971,378.877C275.615,378.877 236.604,378.877 217.24,378.877Z" style="fill:rgb(22,22,22);"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2e-06,0,0,2e-06,3755.77,81.7179)"><stop offset="0" style="stop-color:rgb(39,39,39);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(108,108,108);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
43
cookbook/static/assets/logo_color.svg
Normal file
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g id="Logo" transform="matrix(0.637323,0,0,0.637323,-243.095,-716.725)">
|
||||
<g id="Kreis" transform="matrix(1.44936,0,0,1.50279,387.258,1039.34)">
|
||||
<ellipse cx="273.123" cy="324.015" rx="259.822" ry="250.584" style="fill:url(#_Linear1);"/>
|
||||
<clipPath id="_clip2">
|
||||
<ellipse cx="273.123" cy="324.015" rx="259.822" ry="250.584"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip2)">
|
||||
<g id="Shadow" transform="matrix(1.10322,0,0,1.064,-5.58287,50.5786)">
|
||||
<path d="M156.285,427.208L389.554,660.477L668.803,495.551L374.012,200.761L156.285,427.208Z" style="fill:rgb(22,22,22);"/>
|
||||
<g transform="matrix(1,0,0,1,-4.22105,0.775864)">
|
||||
<path d="M208.628,178.613L485.935,455.919L590.027,364.63L296.923,71.526L294.175,138.989L208.628,178.613Z" style="fill:rgb(22,22,22);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-85.3876,27.8512)">
|
||||
<path d="M310.385,145.641L587.692,422.948L590.392,361.357L297.288,68.253L294.175,138.989L310.385,145.641Z" style="fill:rgb(22,22,22);"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(1.471,0,0,1.471,406.537,1149.69)">
|
||||
<path d="M256.049,220C286.222,219.994 312.656,207.31 329.388,194.134C346.35,180.754 370.899,183.406 384.611,200.1C407.129,227.376 420.598,261.944 420.598,299.53C420.598,361.08 382.604,437.101 329.764,463.706C307.035,475.15 283.466,480.586 256.098,480.599L256.098,480.599L256.049,480.599L256,480.599L256,480.599C228.632,480.586 205.063,475.15 182.334,463.706C129.494,437.101 91.5,361.08 91.5,299.53C91.5,261.944 104.969,227.376 127.487,200.1C141.199,183.406 165.748,180.754 182.71,194.134C199.442,207.31 225.876,219.994 256.049,220Z" style="fill:rgb(255,203,118);"/>
|
||||
</g>
|
||||
<g id="Flame-2" serif:id="Flame 2" transform="matrix(0.965725,0,0,0.89175,164.497,436.391)">
|
||||
<path d="M604.408,844.314C601.981,840.845 601.962,836.056 604.362,832.565C606.763,829.074 611.005,827.721 614.769,829.246C633.87,836.869 658.833,848.629 678.207,864.452C718.526,897.381 729.55,919.407 738.552,942.091C749.208,968.943 750.785,996.68 748.515,1016.08C742.018,1071.61 700.355,1117.5 641.034,1117.5C581.713,1117.5 534.493,1072.05 533.553,1016.08C532.986,982.372 543.985,955.443 555.988,936.22C558.982,931.437 564.594,929.469 569.609,931.444C574.623,933.419 577.757,938.831 577.215,944.58C575.493,956.716 574.362,969.372 574.932,979.484C576.863,1013.7 597.171,1022.5 618.083,1022.29C640.371,1022.08 662.925,1003.17 654.797,954.895C647.69,912.681 622.362,870.194 604.408,844.314Z" style="fill:rgb(255,111,0);"/>
|
||||
<clipPath id="_clip3">
|
||||
<path d="M604.408,844.314C601.981,840.845 601.962,836.056 604.362,832.565C606.763,829.074 611.005,827.721 614.769,829.246C633.87,836.869 658.833,848.629 678.207,864.452C718.526,897.381 729.55,919.407 738.552,942.091C749.208,968.943 750.785,996.68 748.515,1016.08C742.018,1071.61 700.355,1117.5 641.034,1117.5C581.713,1117.5 534.493,1072.05 533.553,1016.08C532.986,982.372 543.985,955.443 555.988,936.22C558.982,931.437 564.594,929.469 569.609,931.444C574.623,933.419 577.757,938.831 577.215,944.58C575.493,956.716 574.362,969.372 574.932,979.484C576.863,1013.7 597.171,1022.5 618.083,1022.29C640.371,1022.08 662.925,1003.17 654.797,954.895C647.69,912.681 622.362,870.194 604.408,844.314Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip3)">
|
||||
<g transform="matrix(1.28784,-0.270602,0.285942,1.59598,247.349,825.209)">
|
||||
<path d="M255.004,46.957C279.547,58.545 306,85.447 313.307,120.161C325.437,177.791 291.571,193.789 262.496,192.403C215.889,190.181 200.194,153.246 231.326,108.9C250.631,81.401 232.663,36.408 255.004,46.957Z" style="fill:rgb(255,209,0);"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Hut" transform="matrix(1.521,0,0,1.521,393.566,1149.06)">
|
||||
<path d="M228.197,408.524C222.698,408.524 217.813,406.688 214.024,403.619C211.776,401.794 210.92,398.752 211.888,396.024C212.856,393.295 215.437,391.472 218.332,391.472C232.214,391.4 256.112,391.396 256.112,391.396C256.112,391.396 280.009,391.4 293.891,391.472C296.786,391.472 299.367,393.295 300.335,396.024C301.303,398.752 300.447,401.794 298.199,403.619C294.41,406.688 289.526,408.524 284.027,408.524L228.197,408.524ZM217.24,378.877C214.208,378.877 211.3,377.671 209.158,375.525C207.015,373.379 205.814,370.469 205.82,367.436C205.831,361.119 205.842,354.539 205.842,354.539C205.842,350.423 203.097,346.814 199.131,345.714C185.313,341.841 175.2,329.468 175.2,314.823C175.2,297.07 190.059,282.657 208.362,282.657C208.362,282.657 208.362,282.657 208.362,282.657C215.401,282.657 221.675,278.218 224.017,271.581C227.243,262.39 236.411,252.015 256,251.998L256,251.998L256.223,251.998L256.223,251.998C275.812,252.015 284.98,262.39 288.206,271.581C290.549,278.218 296.822,282.657 303.861,282.657C303.861,282.657 303.861,282.657 303.861,282.657C322.164,282.657 337.023,297.07 337.023,314.823C337.023,329.468 326.911,341.841 313.093,345.714C309.127,346.814 306.382,350.423 306.381,354.539C306.381,354.539 306.386,361.127 306.391,367.447C306.394,370.478 305.191,373.385 303.049,375.529C300.907,377.672 298.001,378.877 294.971,378.877C275.615,378.877 236.604,378.877 217.24,378.877Z" style="fill:rgb(22,22,22);"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2e-06,0,0,2e-06,3755.77,81.7179)"><stop offset="0" style="stop-color:rgb(39,39,39);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(108,108,108);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
BIN
cookbook/static/assets/logo_color144.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
cookbook/static/assets/logo_color512.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
4
cookbook/static/css/bootstrap-vue.min.css
vendored
14
cookbook/static/django_js_reverse/reverse.js
Normal file
|
Before Width: | Height: | Size: 5.9 KiB |
@@ -1 +0,0 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="book" class="svg-inline--fa fa-book fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M128 152v-32c0-4.4 3.6-8 8-8h208c4.4 0 8 3.6 8 8v32c0 4.4-3.6 8-8 8H136c-4.4 0-8-3.6-8-8zm8 88h208c4.4 0 8-3.6 8-8v-32c0-4.4-3.6-8-8-8H136c-4.4 0-8 3.6-8 8v32c0 4.4 3.6 8 8 8zm299.1 159.7c-4.2 13-4.2 51.6 0 64.6 7.3 1.4 12.9 7.9 12.9 15.7v16c0 8.8-7.2 16-16 16H80c-44.2 0-80-35.8-80-80V80C0 35.8 35.8 0 80 0h352c8.8 0 16 7.2 16 16v368c0 7.8-5.5 14.2-12.9 15.7zm-41.1.3H80c-17.6 0-32 14.4-32 32 0 17.7 14.3 32 32 32h314c-2.7-17.3-2.7-46.7 0-64zm6-352H80c-17.7 0-32 14.3-32 32v278.7c9.8-4.3 20.6-6.7 32-6.7h320V48z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 740 B |
43
cookbook/static/js/frac.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/* frac.js (C) 2012-present SheetJS -- http://sheetjs.com */
|
||||
/*https://developer.aliyun.com/mirror/npm/package/frac/v/0.3.0 Apache license*/
|
||||
var frac = function frac(x, D, mixed) {
|
||||
var n1 = Math.floor(x), d1 = 1;
|
||||
var n2 = n1+1, d2 = 1;
|
||||
if(x !== n1) while(d1 <= D && d2 <= D) {
|
||||
var m = (n1 + n2) / (d1 + d2);
|
||||
if(x === m) {
|
||||
if(d1 + d2 <= D) { d1+=d2; n1+=n2; d2=D+1; }
|
||||
else if(d1 > d2) d2=D+1;
|
||||
else d1=D+1;
|
||||
break;
|
||||
}
|
||||
else if(x < m) { n2 = n1+n2; d2 = d1+d2; }
|
||||
else { n1 = n1+n2; d1 = d1+d2; }
|
||||
}
|
||||
if(d1 > D) { d1 = d2; n1 = n2; }
|
||||
if(!mixed) return [0, n1, d1];
|
||||
var q = Math.floor(n1/d1);
|
||||
return [q, n1 - q*d1, d1];
|
||||
};
|
||||
frac.cont = function cont(x, D, mixed) {
|
||||
var sgn = x < 0 ? -1 : 1;
|
||||
var B = x * sgn;
|
||||
var P_2 = 0, P_1 = 1, P = 0;
|
||||
var Q_2 = 1, Q_1 = 0, Q = 0;
|
||||
var A = Math.floor(B);
|
||||
while(Q_1 < D) {
|
||||
A = Math.floor(B);
|
||||
P = A * P_1 + P_2;
|
||||
Q = A * Q_1 + Q_2;
|
||||
if((B - A) < 0.00000005) break;
|
||||
B = 1 / (B - A);
|
||||
P_2 = P_1; P_1 = P;
|
||||
Q_2 = Q_1; Q_1 = Q;
|
||||
}
|
||||
if(Q > D) { if(Q_1 > D) { Q = Q_2; P = P_2; } else { Q = Q_1; P = P_1; } }
|
||||
if(!mixed) return [0, sgn * P, Q];
|
||||
var q = Math.floor(sgn * P/Q);
|
||||
return [q, sgn*P - q*Q, Q];
|
||||
};
|
||||
// eslint-disable-next-line no-undef
|
||||
if(typeof module !== 'undefined' && typeof DO_NOT_EXPORT_FRAC === 'undefined') module.exports = frac;
|
||||
146
cookbook/static/js/vue-cookies.js
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Vue Cookies v1.7.4
|
||||
* https://github.com/cmp-cc/vue-cookies
|
||||
*
|
||||
* Copyright 2016, cmp-cc
|
||||
* Released under the MIT license
|
||||
*/
|
||||
|
||||
(function () {
|
||||
|
||||
var defaultConfig = {
|
||||
expires: '1d',
|
||||
path: '; path=/',
|
||||
domain: '',
|
||||
secure: '',
|
||||
sameSite: '; SameSite=Lax'
|
||||
};
|
||||
|
||||
var VueCookies = {
|
||||
// install of Vue
|
||||
install: function (Vue) {
|
||||
Vue.prototype.$cookies = this;
|
||||
Vue.$cookies = this;
|
||||
},
|
||||
config: function (expireTimes, path, domain, secure, sameSite) {
|
||||
defaultConfig.expires = expireTimes ? expireTimes : '1d';
|
||||
defaultConfig.path = path ? '; path=' + path : '; path=/';
|
||||
defaultConfig.domain = domain ? '; domain=' + domain : '';
|
||||
defaultConfig.secure = secure ? '; Secure' : '';
|
||||
defaultConfig.sameSite = sameSite ? '; SameSite=' + sameSite : '; SameSite=Lax';
|
||||
},
|
||||
get: function (key) {
|
||||
var value = decodeURIComponent(document.cookie.replace(new RegExp('(?:(?:^|.*;)\\s*' + encodeURIComponent(key).replace(/[\-\.\+\*]/g, '\\$&') + '\\s*\\=\\s*([^;]*).*$)|^.*$'), '$1')) || null;
|
||||
|
||||
if (value && value.substring(0, 1) === '{' && value.substring(value.length - 1, value.length) === '}') {
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (e) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
},
|
||||
set: function (key, value, expireTimes, path, domain, secure, sameSite) {
|
||||
if (!key) {
|
||||
throw new Error('Cookie name is not find in first argument.');
|
||||
} else if (/^(?:expires|max\-age|path|domain|secure|SameSite)$/i.test(key)) {
|
||||
throw new Error('Cookie key name illegality, Cannot be set to ["expires","max-age","path","domain","secure","SameSite"]\t current key name: ' + key);
|
||||
}
|
||||
// support json object
|
||||
if (value && value.constructor === Object) {
|
||||
value = JSON.stringify(value);
|
||||
}
|
||||
var _expires = '';
|
||||
expireTimes = expireTimes == undefined ? defaultConfig.expires : expireTimes;
|
||||
if (expireTimes && expireTimes != 0) {
|
||||
switch (expireTimes.constructor) {
|
||||
case Number:
|
||||
if (expireTimes === Infinity || expireTimes === -1) _expires = '; expires=Fri, 31 Dec 9999 23:59:59 GMT';
|
||||
else _expires = '; max-age=' + expireTimes;
|
||||
break;
|
||||
case String:
|
||||
if (/^(?:\d+(y|m|d|h|min|s))$/i.test(expireTimes)) {
|
||||
// get capture number group
|
||||
var _expireTime = expireTimes.replace(/^(\d+)(?:y|m|d|h|min|s)$/i, '$1');
|
||||
// get capture type group , to lower case
|
||||
switch (expireTimes.replace(/^(?:\d+)(y|m|d|h|min|s)$/i, '$1').toLowerCase()) {
|
||||
// Frequency sorting
|
||||
case 'm':
|
||||
_expires = '; max-age=' + +_expireTime * 2592000;
|
||||
break; // 60 * 60 * 24 * 30
|
||||
case 'd':
|
||||
_expires = '; max-age=' + +_expireTime * 86400;
|
||||
break; // 60 * 60 * 24
|
||||
case 'h':
|
||||
_expires = '; max-age=' + +_expireTime * 3600;
|
||||
break; // 60 * 60
|
||||
case 'min':
|
||||
_expires = '; max-age=' + +_expireTime * 60;
|
||||
break; // 60
|
||||
case 's':
|
||||
_expires = '; max-age=' + _expireTime;
|
||||
break;
|
||||
case 'y':
|
||||
_expires = '; max-age=' + +_expireTime * 31104000;
|
||||
break; // 60 * 60 * 24 * 30 * 12
|
||||
default:
|
||||
new Error('unknown exception of "set operation"');
|
||||
}
|
||||
} else {
|
||||
_expires = '; expires=' + expireTimes;
|
||||
}
|
||||
break;
|
||||
case Date:
|
||||
_expires = '; expires=' + expireTimes.toUTCString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
document.cookie =
|
||||
encodeURIComponent(key) + '=' + encodeURIComponent(value) +
|
||||
_expires +
|
||||
(domain ? '; domain=' + domain : defaultConfig.domain) +
|
||||
(path ? '; path=' + path : defaultConfig.path) +
|
||||
(secure == undefined ? defaultConfig.secure : secure ? '; Secure' : '') +
|
||||
(sameSite == undefined ? defaultConfig.sameSite : (sameSite ? '; SameSite=' + sameSite : ''));
|
||||
return this;
|
||||
},
|
||||
remove: function (key, path, domain) {
|
||||
if (!key || !this.isKey(key)) {
|
||||
return false;
|
||||
}
|
||||
document.cookie = encodeURIComponent(key) +
|
||||
'=; expires=Thu, 01 Jan 1970 00:00:00 GMT' +
|
||||
(domain ? '; domain=' + domain : defaultConfig.domain) +
|
||||
(path ? '; path=' + path : defaultConfig.path) +
|
||||
'; SameSite=Lax';
|
||||
return this;
|
||||
},
|
||||
isKey: function (key) {
|
||||
return (new RegExp('(?:^|;\\s*)' + encodeURIComponent(key).replace(/[\-\.\+\*]/g, '\\$&') + '\\s*\\=')).test(document.cookie);
|
||||
},
|
||||
keys: function () {
|
||||
if (!document.cookie) return [];
|
||||
var _keys = document.cookie.replace(/((?:^|\s*;)[^\=]+)(?=;|$)|^\s*|\s*(?:\=[^;]*)?(?:\1|$)/g, '').split(/\s*(?:\=[^;]*)?;\s*/);
|
||||
for (var _index = 0; _index < _keys.length; _index++) {
|
||||
_keys[_index] = decodeURIComponent(_keys[_index]);
|
||||
}
|
||||
return _keys;
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof exports == 'object') {
|
||||
module.exports = VueCookies;
|
||||
} else if (typeof define == 'function' && define.amd) {
|
||||
define([], function () {
|
||||
return VueCookies;
|
||||
});
|
||||
} else if (window.Vue) {
|
||||
Vue.use(VueCookies);
|
||||
}
|
||||
// vue-cookies can exist independently,no dependencies library
|
||||
if (typeof window !== 'undefined') {
|
||||
window.$cookies = VueCookies;
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -1,6 +1,6 @@
|
||||
/*!
|
||||
* Vue.js v2.6.11
|
||||
* (c) 2014-2019 Evan You
|
||||
* Vue.js v2.6.12
|
||||
* (c) 2014-2020 Evan You
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
(function (global, factory) {
|
||||
@@ -5443,7 +5443,7 @@
|
||||
value: FunctionalRenderContext
|
||||
});
|
||||
|
||||
Vue.version = '2.6.11';
|
||||
Vue.version = '2.6.12';
|
||||
|
||||
/* */
|
||||
|
||||
@@ -7649,7 +7649,7 @@
|
||||
// skip the update if old and new VDOM state is the same.
|
||||
// `value` is handled separately because the DOM value may be temporarily
|
||||
// out of sync with VDOM state due to focus, composition and modifiers.
|
||||
// This #4521 by skipping the unnecesarry `checked` update.
|
||||
// This #4521 by skipping the unnecessary `checked` update.
|
||||
cur !== oldProps[key]
|
||||
) {
|
||||
// some property updates can throw
|
||||
@@ -9894,7 +9894,7 @@
|
||||
}
|
||||
},
|
||||
comment: function comment (text, start, end) {
|
||||
// adding anyting as a sibling to the root node is forbidden
|
||||
// adding anything as a sibling to the root node is forbidden
|
||||
// comments should still be allowed, but ignored
|
||||
if (currentParent) {
|
||||
var child = {
|
||||
|
||||