mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-25 19:29:30 -05:00
Compare commits
522 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cabeba3cb | ||
|
|
90bb67ff89 | ||
|
|
69ed987db8 | ||
|
|
638904abc8 | ||
|
|
a07bd452a9 | ||
|
|
2398c00dfe | ||
|
|
7314da1a5f | ||
|
|
9c80a10652 | ||
|
|
30456c60e0 | ||
|
|
202ef9509d | ||
|
|
95b10bc01c | ||
|
|
289387f235 | ||
|
|
92c8afdf8f | ||
|
|
6e2374737e | ||
|
|
f0b05808b8 | ||
|
|
250c3ce5b2 | ||
|
|
7916635716 | ||
|
|
a30a27c755 | ||
|
|
f274f31e80 | ||
|
|
20adcc0e83 | ||
|
|
c5b70b94c7 | ||
|
|
c90e5d72af | ||
|
|
0cf0fcea0a | ||
|
|
ab5bff62e3 | ||
|
|
001edecdd3 | ||
|
|
d27b39f7de | ||
|
|
ddbbd53ace | ||
|
|
0360d443ea | ||
|
|
c20e982fb1 | ||
|
|
0f7dc096cb | ||
|
|
fc9eb249a8 | ||
|
|
4a9e027849 | ||
|
|
890817ef6d | ||
|
|
61a253675c | ||
|
|
530b1a8986 | ||
|
|
631d594f45 | ||
|
|
3fcea5af0a | ||
|
|
07195b74a3 | ||
|
|
9e9a61e94e | ||
|
|
18c45771e7 | ||
|
|
42aaed011c | ||
|
|
66d29d10bf | ||
|
|
dfa4f444ef | ||
|
|
12f2d3c7b3 | ||
|
|
f9c68e9fcc | ||
|
|
d65c881fde | ||
|
|
7bf9f18402 | ||
|
|
3ea96d4102 | ||
|
|
b3417be2ec | ||
|
|
8d24ae9008 | ||
|
|
a9d8080ec2 | ||
|
|
fe09278b0e | ||
|
|
2a13a341dd | ||
|
|
b382ab9024 | ||
|
|
7ff7d157dc | ||
|
|
24c476830d | ||
|
|
2d0a638c0a | ||
|
|
70b8a50d1d | ||
|
|
05df133960 | ||
|
|
426f4d3e77 | ||
|
|
6b2ac3f873 | ||
|
|
1986da7f6e | ||
|
|
cc7b9bba32 | ||
|
|
8e0c709427 | ||
|
|
1ed965adcd | ||
|
|
8ced587562 | ||
|
|
a0fd1f4104 | ||
|
|
7fbc1cd8d1 | ||
|
|
ba1f10cd3a | ||
|
|
4e0cc34d41 | ||
|
|
ef4ce62f5b | ||
|
|
b990462bdb | ||
|
|
5e34c6ddf0 | ||
|
|
d8d76ae9e0 | ||
|
|
c60141940d | ||
|
|
532d32c194 | ||
|
|
54721a0a62 | ||
|
|
c27933548d | ||
|
|
d04e9518cb | ||
|
|
b9065f7052 | ||
|
|
c8c29e1b5a | ||
|
|
5724ef9511 | ||
|
|
2595a26fb4 | ||
|
|
e1c7305c07 | ||
|
|
418c38423f | ||
|
|
cc5be844d5 | ||
|
|
90b6f9ad06 | ||
|
|
437296415e | ||
|
|
a8c885bd21 | ||
|
|
a539d14aad | ||
|
|
2b0541bd74 | ||
|
|
3f53a924e1 | ||
|
|
0ed9100fb1 | ||
|
|
d23158839b | ||
|
|
d2b796ddd2 | ||
|
|
8b1e80efeb | ||
|
|
85ecac3a17 | ||
|
|
e0b8d6fcc3 | ||
|
|
edd47873f7 | ||
|
|
c14dd04261 | ||
|
|
769365d624 | ||
|
|
ddb9e70d31 | ||
|
|
a376728120 | ||
|
|
306f90aa98 | ||
|
|
a19ad706ce | ||
|
|
4af6de7425 | ||
|
|
8f3044dbee | ||
|
|
7c5ffdaef4 | ||
|
|
30421d067e | ||
|
|
d3b71e40c7 | ||
|
|
1a84a8fe80 | ||
|
|
16cb99f915 | ||
|
|
a451f722a1 | ||
|
|
dde350c8af | ||
|
|
37971acb48 | ||
|
|
f12196d1c6 | ||
|
|
d4242a244d | ||
|
|
8a7c4e11c9 | ||
|
|
745bb58c7e | ||
|
|
b3e971fe09 | ||
|
|
0c603e3665 | ||
|
|
fed9cfeeb7 | ||
|
|
5a65fd2231 | ||
|
|
c2a763fa4c | ||
|
|
528767a835 | ||
|
|
9b182f6076 | ||
|
|
968b710b49 | ||
|
|
f11e07d347 | ||
|
|
24e42496a7 | ||
|
|
9da496cb6d | ||
|
|
99b3ed8464 | ||
|
|
281535e756 | ||
|
|
9221533ae7 | ||
|
|
f07690d7e3 | ||
|
|
8cebc98d3b | ||
|
|
965d2c05e7 | ||
|
|
17ad01ae8c | ||
|
|
51620a34d9 | ||
|
|
91fcb1b822 | ||
|
|
01d5ab92c5 | ||
|
|
79c8d26e8c | ||
|
|
9486b08e20 | ||
|
|
934eeee5c4 | ||
|
|
2927333bf1 | ||
|
|
0e1153ce3a | ||
|
|
b3f05b0bfd | ||
|
|
6d9a90c6ba | ||
|
|
6555df824d | ||
|
|
e313481fc8 | ||
|
|
d36033a8b5 | ||
|
|
d2d2765765 | ||
|
|
3aa7f6a367 | ||
|
|
ffa91863dd | ||
|
|
cf2d33daad | ||
|
|
506d7a8bb2 | ||
|
|
8b1233be62 | ||
|
|
9a3a4b9450 | ||
|
|
2db300a8a4 | ||
|
|
a2dc8d8988 | ||
|
|
798aa7f179 | ||
|
|
22953b0591 | ||
|
|
0b8881c511 | ||
|
|
dc10bf2c49 | ||
|
|
20d61160ba | ||
|
|
c4f40b9639 | ||
|
|
8f08ba7114 | ||
|
|
8a4f35e592 | ||
|
|
80de87d459 | ||
|
|
f9b04a3f1e | ||
|
|
f7cb067b52 | ||
|
|
25ccea90e0 | ||
|
|
93b868bc69 | ||
|
|
acfb02cc0e | ||
|
|
79c8edd354 | ||
|
|
e1e53d12f8 | ||
|
|
30683fe455 | ||
|
|
c20aae3efc | ||
|
|
5e2ca250b0 | ||
|
|
d506952602 | ||
|
|
0a6abf9688 | ||
|
|
6c4b1e76eb | ||
|
|
1f391b794b | ||
|
|
983d66c197 | ||
|
|
ab2098151b | ||
|
|
6053b1419c | ||
|
|
5c98f06208 | ||
|
|
c141dc850f | ||
|
|
0283835a96 | ||
|
|
724217f142 | ||
|
|
0094fd28e2 | ||
|
|
54b57a8bcb | ||
|
|
0778025a0c | ||
|
|
063a0dec24 | ||
|
|
b09acefa6a | ||
|
|
6a1fcabae0 | ||
|
|
13115a1e53 | ||
|
|
f65b5d0733 | ||
|
|
922eb7402b | ||
|
|
2c76fb7b69 | ||
|
|
7c89117e04 | ||
|
|
b919fb4ae8 | ||
|
|
29aa52aa3d | ||
|
|
214db80dac | ||
|
|
25c1689ca0 | ||
|
|
10001dde7b | ||
|
|
578154510b | ||
|
|
8a99907a51 | ||
|
|
636fa8f318 | ||
|
|
7efbc9c42e | ||
|
|
b05639110a | ||
|
|
1fe673ba1e | ||
|
|
0a89bf4a10 | ||
|
|
049d218f7b | ||
|
|
0030775e55 | ||
|
|
cd49311cba | ||
|
|
f7af4b9cd2 | ||
|
|
6c205e2fc6 | ||
|
|
938f5560fb | ||
|
|
6791de94d7 | ||
|
|
884dd6b8f8 | ||
|
|
d2bf0359c0 | ||
|
|
f418d74639 | ||
|
|
68260a2929 | ||
|
|
0f5feac067 | ||
|
|
fde892dd78 | ||
|
|
e54d477b12 | ||
|
|
29411b5a74 | ||
|
|
02fcf70ab2 | ||
|
|
b661ee2a23 | ||
|
|
b71c115194 | ||
|
|
fc0f92eecc | ||
|
|
555451f64e | ||
|
|
557c8ce3b9 | ||
|
|
b19190e9e2 | ||
|
|
c9a01a001e | ||
|
|
0a085bfafa | ||
|
|
84cd4671a2 | ||
|
|
c05e44fdce | ||
|
|
6478bb3bb8 | ||
|
|
e99c3af5d6 | ||
|
|
4047febec9 | ||
|
|
d1c8515b77 | ||
|
|
0aafd8d8b2 | ||
|
|
56ee5671ea | ||
|
|
ba032e9353 | ||
|
|
1c30e643c3 | ||
|
|
a5638ea8a1 | ||
|
|
5b462d81b4 | ||
|
|
e7acecb16b | ||
|
|
58a0d96fbd | ||
|
|
30b9ea7e9f | ||
|
|
d26a1b5698 | ||
|
|
795f3084d9 | ||
|
|
931eae4361 | ||
|
|
80fc50e09b | ||
|
|
045a0b7d4f | ||
|
|
957c659a62 | ||
|
|
b282c46c1a | ||
|
|
582e145a9f | ||
|
|
79b4bc387e | ||
|
|
af9a2a89ec | ||
|
|
c50a89c651 | ||
|
|
f21587605a | ||
|
|
2e69a00fce | ||
|
|
bddaa77f71 | ||
|
|
3743a08996 | ||
|
|
3fafd43e58 | ||
|
|
2787b64a96 | ||
|
|
52d1069353 | ||
|
|
c961909342 | ||
|
|
ccd0966d92 | ||
|
|
a4f2c994a0 | ||
|
|
c43b8e91da | ||
|
|
58d025f1a5 | ||
|
|
c20e036d90 | ||
|
|
2d0a7330f3 | ||
|
|
279faadf46 | ||
|
|
5b287ad484 | ||
|
|
e257a8d29b | ||
|
|
889fa7b8ea | ||
|
|
a3008a6091 | ||
|
|
24bef756e8 | ||
|
|
b4510a2cc1 | ||
|
|
63fe174070 | ||
|
|
0f4bd9972e | ||
|
|
9794d544cc | ||
|
|
e66897c1ea | ||
|
|
2d94cb70ab | ||
|
|
f5e4adba8b | ||
|
|
b0705da1fe | ||
|
|
a20a877dc7 | ||
|
|
ed50a27669 | ||
|
|
b3f4f2c895 | ||
|
|
682f4a4297 | ||
|
|
e33ca876a6 | ||
|
|
453b1eb5b9 | ||
|
|
ee4ab41c1c | ||
|
|
1364f75f21 | ||
|
|
3047c09e55 | ||
|
|
5bdcbb1d17 | ||
|
|
35e81f6247 | ||
|
|
a51eb7a2cb | ||
|
|
262387da3e | ||
|
|
ab968f225b | ||
|
|
0e6685882c | ||
|
|
8f0c5e21ad | ||
|
|
b5bf0a4584 | ||
|
|
c7ad9c8d15 | ||
|
|
729aa51901 | ||
|
|
2763eed5b2 | ||
|
|
2af7b64d4f | ||
|
|
24b0643765 | ||
|
|
df54b10610 | ||
|
|
7ad088d953 | ||
|
|
fdd86b0c2d | ||
|
|
8dcdf00dc7 | ||
|
|
0693d31550 | ||
|
|
cae3773d5a | ||
|
|
f2222fd7d5 | ||
|
|
b8dfc00106 | ||
|
|
1d224d8658 | ||
|
|
2b41fbc9f8 | ||
|
|
a24f09c419 | ||
|
|
450de740b6 | ||
|
|
b92c027919 | ||
|
|
6c0e979909 | ||
|
|
a035e02288 | ||
|
|
6eec3d18fe | ||
|
|
94b2e9b01c | ||
|
|
de7d2e27d9 | ||
|
|
dcfe4de61f | ||
|
|
f245aa8b4f | ||
|
|
a217db5822 | ||
|
|
6e9d609fe0 | ||
|
|
ecac3f3c2d | ||
|
|
6135a6f26d | ||
|
|
7a0b395107 | ||
|
|
1f41fa04a3 | ||
|
|
7c598720d0 | ||
|
|
5c9f5e0e1a | ||
|
|
f400c7cd7c | ||
|
|
2a138a852f | ||
|
|
fbe748db62 | ||
|
|
4377505b14 | ||
|
|
c5c76cadea | ||
|
|
fbd17b48fe | ||
|
|
6eea7ac99b | ||
|
|
f5f9380344 | ||
|
|
e243e089cc | ||
|
|
0b1d8bbd5f | ||
|
|
10a33add75 | ||
|
|
f16e457d14 | ||
|
|
64f2787943 | ||
|
|
3ff15b6766 | ||
|
|
d67c5fcf1b | ||
|
|
b8e0a7cf69 | ||
|
|
e2915dde55 | ||
|
|
05f2fdecb3 | ||
|
|
5d33d82d70 | ||
|
|
17efc388ca | ||
|
|
e3a3220f00 | ||
|
|
f15f34887a | ||
|
|
20984d3dd6 | ||
|
|
67e4c88be7 | ||
|
|
2d01a2af47 | ||
|
|
5272cf0a5c | ||
|
|
6b848e27a8 | ||
|
|
efec416604 | ||
|
|
e5a4f6b5bf | ||
|
|
a55f975068 | ||
|
|
421ade7ad0 | ||
|
|
c785b590a1 | ||
|
|
42132568c4 | ||
|
|
dfe414985b | ||
|
|
ee52092e24 | ||
|
|
75b45ba8eb | ||
|
|
bf9e59d64c | ||
|
|
132c48a490 | ||
|
|
e470a70321 | ||
|
|
1a99a2d6f1 | ||
|
|
cf3ddfc610 | ||
|
|
ecbd3edb97 | ||
|
|
7837467c30 | ||
|
|
84759383fa | ||
|
|
aaaae5b1ba | ||
|
|
ea62c10d9a | ||
|
|
3516505dd1 | ||
|
|
d4553c05c2 | ||
|
|
edc670e87d | ||
|
|
a313039b65 | ||
|
|
963dad39e8 | ||
|
|
8f19ab6e5e | ||
|
|
0e20f679b3 | ||
|
|
46b83c8205 | ||
|
|
8b28a47297 | ||
|
|
e7e3a3083d | ||
|
|
ea7d34c8d2 | ||
|
|
7e081d4389 | ||
|
|
2edb455bd6 | ||
|
|
c32a96fd6f | ||
|
|
6d1476b2d8 | ||
|
|
5d79e4d3be | ||
|
|
0866d21fa5 | ||
|
|
6448c062f9 | ||
|
|
b146e75daa | ||
|
|
68927d141e | ||
|
|
1e36e6cd5b | ||
|
|
4877d69947 | ||
|
|
f2f187a844 | ||
|
|
c2e84c1fa4 | ||
|
|
ca93920f04 | ||
|
|
903a721a1d | ||
|
|
44e513ff2d | ||
|
|
2d7d160d1b | ||
|
|
54ca8b2bd0 | ||
|
|
a972a757b2 | ||
|
|
7c0d1236c2 | ||
|
|
09b0dcb136 | ||
|
|
5b4867d172 | ||
|
|
d3d4c210c1 | ||
|
|
6cffee57fe | ||
|
|
286595e03d | ||
|
|
0d1c55d2e4 | ||
|
|
fd8ca2e9ac | ||
|
|
9ef4c88d02 | ||
|
|
08d3c40200 | ||
|
|
e229a70360 | ||
|
|
06b7ba809b | ||
|
|
099a5420d6 | ||
|
|
5a9543b4d8 | ||
|
|
60d7e63da8 | ||
|
|
867e2d4fbf | ||
|
|
757fa5e49c | ||
|
|
8b682c33f3 | ||
|
|
27f358dd03 | ||
|
|
7c6a7ef6a4 | ||
|
|
4c506750de | ||
|
|
b84d77be15 | ||
|
|
247dd30b20 | ||
|
|
5e4e203dfb | ||
|
|
79b6d4817e | ||
|
|
6075ce50e7 | ||
|
|
2ca7722afb | ||
|
|
7a9e5b1e3f | ||
|
|
7f87a9efed | ||
|
|
3d674cfca6 | ||
|
|
1642224205 | ||
|
|
3d359f844f | ||
|
|
94c69271d3 | ||
|
|
9827c3ffd5 | ||
|
|
4a747f5cd4 | ||
|
|
0623a8ebc7 | ||
|
|
5941022b5e | ||
|
|
2559905a78 | ||
|
|
edde015b71 | ||
|
|
9b7b8beea4 | ||
|
|
2eae8e5eeb | ||
|
|
6d8bc396f8 | ||
|
|
4118c8d9e3 | ||
|
|
78c2eacbd8 | ||
|
|
01510f39e5 | ||
|
|
09cc5aafe9 | ||
|
|
e8b2f57812 | ||
|
|
664e83143f | ||
|
|
f1309cc624 | ||
|
|
6fb7f6bd1f | ||
|
|
158bb1bf03 | ||
|
|
086e802873 | ||
|
|
c94c8d3559 | ||
|
|
f99010aa1d | ||
|
|
32e00999f3 | ||
|
|
e3196a79a8 | ||
|
|
e926b34bec | ||
|
|
a460123184 | ||
|
|
c89c88b981 | ||
|
|
cf6ea04f30 | ||
|
|
15c4609db3 | ||
|
|
053804f8cb | ||
|
|
da748995e7 | ||
|
|
1d80ba3a3b | ||
|
|
29fe6c7363 | ||
|
|
42d4a32ffc | ||
|
|
e8ae844fb0 | ||
|
|
c93f68804a | ||
|
|
b4ea236241 | ||
|
|
2bef5c3b51 | ||
|
|
52f2086616 | ||
|
|
03e1474113 | ||
|
|
9829ab68a6 | ||
|
|
7e07508a31 | ||
|
|
94b0438516 | ||
|
|
b97c90e22f | ||
|
|
f78264620f | ||
|
|
571a618818 | ||
|
|
6c97594591 | ||
|
|
5d353a0839 | ||
|
|
0be1f6a170 | ||
|
|
5cd042fa7c | ||
|
|
e02d2530aa | ||
|
|
b35f5047ab | ||
|
|
f10bec8ab4 | ||
|
|
3bc1daa72e | ||
|
|
5d6574b8cc | ||
|
|
adc65baf9c | ||
|
|
4d2e7eadb6 | ||
|
|
7c985cec23 | ||
|
|
2cd33ee40a | ||
|
|
f61146123e | ||
|
|
4806bd63b6 | ||
|
|
41242c8d09 | ||
|
|
57a967b91d | ||
|
|
fb931f4715 | ||
|
|
e86b476b3a | ||
|
|
7f22e0a275 | ||
|
|
1907223a8a | ||
|
|
9b5fe8f4e7 | ||
|
|
d76fdd090a | ||
|
|
55a0304700 | ||
|
|
5b6dd62f8e | ||
|
|
19f5684d26 | ||
|
|
d6ad1354db | ||
|
|
4626af3505 |
@@ -7,7 +7,9 @@ SQL_DEBUG=0
|
||||
ALLOWED_HOSTS=*
|
||||
|
||||
# random secret key, use for example `base64 /dev/urandom | head -c50` to generate one
|
||||
# ---------------------------- REQUIRED -------------------------
|
||||
SECRET_KEY=
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
# your default timezone See https://timezonedb.com/time-zones for a list of timezones
|
||||
TIMEZONE=Europe/Berlin
|
||||
@@ -18,7 +20,9 @@ DB_ENGINE=django.db.backends.postgresql
|
||||
POSTGRES_HOST=db_recipes
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=djangouser
|
||||
# ---------------------------- REQUIRED -------------------------
|
||||
POSTGRES_PASSWORD=
|
||||
# ---------------------------------------------------------------
|
||||
POSTGRES_DB=djangodb
|
||||
|
||||
# database connection string, when used overrides other database settings.
|
||||
@@ -41,7 +45,8 @@ SHOPPING_MIN_AUTOSYNC_INTERVAL=5
|
||||
# Default for user setting sticky navbar
|
||||
# STICKY_NAV_PREF_DEFAULT=1
|
||||
|
||||
# If base URL is something other than just / (you are serving a subfolder in your proxy for instance http://recipe_app/recipes/)
|
||||
# If base URL is something other than just / (you are serving a subfolder in your proxy for instance http://recipe_app/recipes/)
|
||||
# Be sure to not have a trailing slash: e.g. '/recipes' instead of '/recipes/'
|
||||
# SCRIPT_NAME=/recipes
|
||||
|
||||
# If staticfiles are stored at a different location uncomment and change accordingly, MUST END IN /
|
||||
@@ -141,3 +146,7 @@ REVERSE_PROXY_AUTH=0
|
||||
#AUTH_LDAP_BIND_DN=
|
||||
#AUTH_LDAP_BIND_PASSWORD=
|
||||
#AUTH_LDAP_USER_SEARCH_BASE_DN=
|
||||
|
||||
# Enables exporting PDF (see export docs)
|
||||
# Disabled by default, uncomment to enable
|
||||
# ENABLE_PDF_EXPORT=1
|
||||
|
||||
15
.github/ISSUE_TEMPLATE/bug_report.md
vendored
15
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,15 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
### Version
|
||||
Please provide your current version (can be found on the system page since v0.8.4)
|
||||
Version:
|
||||
|
||||
### Bug description
|
||||
A clear and concise description of what the bug is.
|
||||
81
.github/ISSUE_TEMPLATE/bug_report.md.bak
vendored
Normal file
81
.github/ISSUE_TEMPLATE/bug_report.md.bak
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Version
|
||||
<!-- Please provide your current version (can be found on the system page since v0.8.4). -->
|
||||
**Tandoor-Version:**
|
||||
|
||||
## Setup configuration
|
||||
<!--Please tick all boxes which apply to your configuration. Feel free to provide additional information below.
|
||||
To tick boxes here, simply put an X inside the brackets below -->
|
||||
|
||||
### Setup
|
||||
- [ ] Docker / Docker-Compose
|
||||
- [ ] Unraid
|
||||
- [ ] Synology
|
||||
- [ ] Kubernetes
|
||||
- [ ] Manual setup
|
||||
- [ ] Others (please state below)
|
||||
|
||||
### Reverse Proxy
|
||||
- [ ] No reverse proxy
|
||||
- [ ] jwilder's nginx proxy
|
||||
- [ ] Nginx proxy manager (NPM)
|
||||
- [ ] SWAG
|
||||
- [ ] Caddy
|
||||
- [ ] Traefik
|
||||
- [ ] Others (please state below)
|
||||
|
||||
<!-- Please provide additional information if possible -->
|
||||
**Additional information:**
|
||||
|
||||
## Bug description
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
|
||||
|
||||
## Logs
|
||||
<!-- *(Remove this section entirely if no logs are available or necessary for your issue)*
|
||||
To get the most information about your issue, set DEBUG=1 (e.g. in your `.env` file if using docker-compose) and try to reproduce the issue afterwards.
|
||||
|
||||
Please put your logs into the expandable section below and use code quotation for all logs! Usage: Put three backticks in front and after the log, like this:
|
||||
` ``` <Many lines of log messages ``` `
|
||||
|
||||
Feel free to remove parts if you don't fill them out.
|
||||
-->
|
||||
|
||||
<details>
|
||||
<summary>Web-Container-Logs</summary>
|
||||
|
||||
<!-- *Put your logs inside here (leave the code quotations in takt):* -->
|
||||
|
||||
```
|
||||
Replace me with logs
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>DB-Container-Logs</summary>
|
||||
|
||||
<!-- *Put your logs inside here (leave the code quotations in takt):* -->
|
||||
|
||||
```
|
||||
Replace me with logs
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Nginx-Container-Logs <!-- if you use one --></summary>
|
||||
|
||||
<!-- *Put your logs inside here (leave the code quotations in takt):* -->
|
||||
|
||||
```
|
||||
Replace me with logs
|
||||
```
|
||||
</details>
|
||||
64
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
64
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: Bug Report
|
||||
description: "Create a report to help us improve"
|
||||
#title: ""
|
||||
#labels: ["Bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Tandoor Version
|
||||
description: "What version of Tandoor are you using? (can be found on the system page since v0.8.4)"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: setup
|
||||
attributes:
|
||||
label: Setup
|
||||
description: "How is your Tandoor instance set up?"
|
||||
options:
|
||||
- Docker / Docker-Compose
|
||||
- Unraid
|
||||
- Synology
|
||||
- Kubernetes
|
||||
- Manual Setup
|
||||
- Others (please state below)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: reverse-proxy
|
||||
attributes:
|
||||
label: "Reverse Proxy"
|
||||
description: "What reverse proxy do you use with Tandoor?"
|
||||
options:
|
||||
- No reverse proxy
|
||||
- jwilder's nginx proxy
|
||||
- Nginx Proxy Manager (NPM)
|
||||
- SWAG
|
||||
- Caddy
|
||||
- Traefik
|
||||
- Apache2
|
||||
- Others (please state below)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: other
|
||||
attributes:
|
||||
label: Other
|
||||
description: "In case you chose 'Others' above, please provide more info here."
|
||||
- type: textarea
|
||||
id: bug-descr
|
||||
attributes:
|
||||
label: Bug description
|
||||
description: "Please accurately describe the bug you encountered."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs
|
||||
description: Please copy and paste any relevant logs. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: FAQs
|
||||
url: https://docs.tandoor.dev/faq/
|
||||
about: Please take a look at the FAQs before creating a bug ticket.
|
||||
40
.github/ISSUE_TEMPLATE/doc_issue.yml
vendored
Normal file
40
.github/ISSUE_TEMPLATE/doc_issue.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Documentation Issue
|
||||
description: "Create a report to help us improve"
|
||||
#title: ""
|
||||
labels: ["documentation"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this documentation issue report!
|
||||
- type: input
|
||||
id: docs-link
|
||||
attributes:
|
||||
label: Documentation link
|
||||
description: "Please provide a link to the corresponding documentation site on docs.tandoor.dev"
|
||||
- type: dropdown
|
||||
id: section
|
||||
attributes:
|
||||
label: Affected section
|
||||
description: "What part of the documentation is the issue about?"
|
||||
options:
|
||||
- Installation
|
||||
- Features
|
||||
- System
|
||||
- FAQ
|
||||
- Does not exist yet
|
||||
- Other (please state below)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: other
|
||||
attributes:
|
||||
label: Other
|
||||
description: "In case you chose 'Other' above, please provide more info here."
|
||||
- type: textarea
|
||||
id: descr
|
||||
attributes:
|
||||
label: Issue description
|
||||
description: "Please accurately describe the documentation issue you are seeing."
|
||||
validations:
|
||||
required: true
|
||||
39
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
39
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Feature Request
|
||||
description: "Suggest an idea for this project"
|
||||
#title: ""
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this feature request!
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: "Is your feature request related to a problem? Please describe."
|
||||
description: "A clear and concise description of what the problem is. Ex. I'm always frustrated when..."
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: "Describe the solution you'd like"
|
||||
description: "A clear and concise description of what you want to happen."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: "Describe alternatives you've considered"
|
||||
description: "A clear and concise description of any alternative solutions or features you've considered."
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: "Additional context"
|
||||
description: "Add any other context or screenshots about the feature request here."
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: "Contribute"
|
||||
description: "Are you willing and able to help develop this feature?"
|
||||
options:
|
||||
- label: "Yes"
|
||||
- label: "Partly"
|
||||
- label: "No"
|
||||
82
.github/ISSUE_TEMPLATE/help_request.yml
vendored
Normal file
82
.github/ISSUE_TEMPLATE/help_request.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
name: Help request
|
||||
description: "If there is anything wrong with your setup"
|
||||
#title: ""
|
||||
labels: ["setup issue"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this help request!
|
||||
- type: textarea
|
||||
id: issue
|
||||
attributes:
|
||||
label: Issue
|
||||
description: "Please describe your problem here."
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Tandoor Version
|
||||
description: "What version of Tandoor are you using? (can be found on the system page since v0.8.4)"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: OS Version
|
||||
description: "E.g. Ubuntu 20.02"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: setup
|
||||
attributes:
|
||||
label: Setup
|
||||
description: "How is your Tandoor instance set up?"
|
||||
options:
|
||||
- Docker / Docker-Compose
|
||||
- Unraid
|
||||
- Synology
|
||||
- Kubernetes
|
||||
- Manual Setup
|
||||
- Others (please state below)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: reverse-proxy
|
||||
attributes:
|
||||
label: "Reverse Proxy"
|
||||
description: "What reverse proxy do you use with Tandoor?"
|
||||
options:
|
||||
- No reverse proxy
|
||||
- jwilder's nginx proxy
|
||||
- Nginx Proxy Manager (NPM)
|
||||
- SWAG
|
||||
- Caddy
|
||||
- Traefik
|
||||
- Others (please state below)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: other
|
||||
attributes:
|
||||
label: Other
|
||||
description: "In case you chose 'Others' above or have more info, please provide additional details here."
|
||||
- type: textarea
|
||||
id: env
|
||||
attributes:
|
||||
label: Environment file
|
||||
description: "Please include your `.env` config file (**make sure to remove/replace all secrets**)"
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: docker-compose
|
||||
attributes:
|
||||
label: Docker-Compose file
|
||||
description: "When running with docker compose please provide your `docker-compose.yml`"
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs
|
||||
description: "If you feel like there is anything interesting please post the output of `docker-compose logs` at container startup and when the issue happens."
|
||||
render: shell
|
||||
36
.github/ISSUE_TEMPLATE/website_import.yml
vendored
Normal file
36
.github/ISSUE_TEMPLATE/website_import.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Website Import
|
||||
description: "Anything related to website imports"
|
||||
#title: ""
|
||||
#labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this website import form!
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Tandoor Version
|
||||
description: "What version of Tandoor are you using? (can be found on the system page since v0.8.4)"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: url
|
||||
attributes:
|
||||
label: Import URL
|
||||
description: "Exact URL you are trying to import from."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: bug-descr
|
||||
attributes:
|
||||
label: "When did the issue happen?"
|
||||
description: "When pressing the search button with the url / when importing after the page has loaded / ..."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Response / message shown
|
||||
description: Please copy and paste any relevant logs or responses / messages which are shown in Tandoor. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Continous Integration
|
||||
name: Continuous Integration
|
||||
|
||||
on: [push]
|
||||
|
||||
@@ -9,14 +9,14 @@ jobs:
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
python-version: [3.9]
|
||||
python-version: ['3.10']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Set up Python 3.9
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: '3.10'
|
||||
# Build Vue frontend
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
|
||||
3
.github/workflows/docker-publish-dev.yml
vendored
3
.github/workflows/docker-publish-dev.yml
vendored
@@ -24,6 +24,9 @@ jobs:
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '14'
|
||||
- name: Clear Cache
|
||||
working-directory: ./vue
|
||||
run: yarn cache clean --all
|
||||
- name: Install dependencies
|
||||
working-directory: ./vue
|
||||
run: yarn install
|
||||
|
||||
2
.github/workflows/docker-publish-release.yml
vendored
2
.github/workflows/docker-publish-release.yml
vendored
@@ -49,4 +49,4 @@ jobs:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@0.3.2
|
||||
with:
|
||||
args: '🚀 Version {{ EVENT_PAYLOAD.release.tag_name }} of tandoor has been released 🥳 \nCheck it out https://github.com/vabene1111/recipes/releases/tag/{{ EVENT_PAYLOAD.release.tag_name }}'
|
||||
args: '🚀 Version {{ EVENT_PAYLOAD.release.tag_name }} of tandoor has been released 🥳 Check it out https://github.com/vabene1111/recipes/releases/tag/{{ EVENT_PAYLOAD.release.tag_name }}'
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -79,8 +79,8 @@ postgresql/
|
||||
/docker-compose.override.yml
|
||||
vue/node_modules
|
||||
.vscode/
|
||||
vue/yarn.lock
|
||||
vetur.config.js
|
||||
cookbook/static/vue
|
||||
vue/webpack-stats.json
|
||||
cookbook/templates/sw.js
|
||||
.prettierignore
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<h4 align="center">The recipe manager that allows you to manage your ever growing collection of digital recipes.</h4>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/vabene1111/recipes/actions" target="_blank" rel="noopener noreferrer"><img src="https://github.com/vabene1111/recipes/workflows/Continous%20Integration/badge.svg?branch=master" ></a>
|
||||
<a href="https://github.com/vabene1111/recipes/actions" target="_blank" rel="noopener noreferrer"><img src="https://github.com/vabene1111/recipes/workflows/Continuous%20Integration/badge.svg?branch=master" ></a>
|
||||
<a href="https://github.com/vabene1111/recipes/stargazers" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/stars/vabene1111/recipes" ></a>
|
||||
<a href="https://github.com/vabene1111/recipes/network/members" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/forks/vabene1111/recipes" ></a>
|
||||
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer"><img src="https://badgen.net/badge/icon/discord?icon=discord&label" ></a>
|
||||
|
||||
@@ -7,4 +7,4 @@ Since this software is still considered beta/WIP support is always only given fo
|
||||
## 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 :/).
|
||||
public just open a generic issue and we will discuss further communication there (since GitHub does not allow everyone to create a security advisory :/).
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.contrib.postgres.search import SearchVector
|
||||
from django.utils import translation
|
||||
from django_scopes import scopes_disabled
|
||||
from treebeard.admin import TreeAdmin
|
||||
from treebeard.forms import movenodeform_factory
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.contrib.auth.models import User, Group
|
||||
from django_scopes import scopes_disabled
|
||||
from django.utils import translation
|
||||
|
||||
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,
|
||||
ImportLog, TelegramBot, BookmarkletImport, UserFile, SearchPreference)
|
||||
|
||||
from cookbook.managers import DICTIONARY
|
||||
|
||||
from .models import (BookmarkletImport, Comment, CookLog, Food, FoodInheritField, ImportLog,
|
||||
Ingredient, InviteLink, Keyword, MealPlan, MealType, NutritionInformation,
|
||||
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink,
|
||||
ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
||||
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
|
||||
TelegramBot, Unit, UserFile, UserPreference, ViewLog, Automation)
|
||||
|
||||
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
def has_add_permission(self, request, obj=None):
|
||||
@@ -30,11 +29,52 @@ admin.site.register(User, CustomUserAdmin)
|
||||
admin.site.unregister(Group)
|
||||
|
||||
|
||||
@admin.action(description='Delete all data from a space')
|
||||
def delete_space_action(modeladmin, request, queryset):
|
||||
for space in queryset:
|
||||
CookLog.objects.filter(space=space).delete()
|
||||
ViewLog.objects.filter(space=space).delete()
|
||||
ImportLog.objects.filter(space=space).delete()
|
||||
BookmarkletImport.objects.filter(space=space).delete()
|
||||
|
||||
Comment.objects.filter(recipe__space=space).delete()
|
||||
Keyword.objects.filter(space=space).delete()
|
||||
Ingredient.objects.filter(space=space).delete()
|
||||
Food.objects.filter(space=space).delete()
|
||||
Unit.objects.filter(space=space).delete()
|
||||
Step.objects.filter(space=space).delete()
|
||||
NutritionInformation.objects.filter(space=space).delete()
|
||||
RecipeBookEntry.objects.filter(book__space=space).delete()
|
||||
RecipeBook.objects.filter(space=space).delete()
|
||||
MealType.objects.filter(space=space).delete()
|
||||
MealPlan.objects.filter(space=space).delete()
|
||||
ShareLink.objects.filter(space=space).delete()
|
||||
Recipe.objects.filter(space=space).delete()
|
||||
|
||||
RecipeImport.objects.filter(space=space).delete()
|
||||
SyncLog.objects.filter(sync__space=space).delete()
|
||||
Sync.objects.filter(space=space).delete()
|
||||
Storage.objects.filter(space=space).delete()
|
||||
|
||||
ShoppingListEntry.objects.filter(shoppinglist__space=space).delete()
|
||||
ShoppingListRecipe.objects.filter(shoppinglist__space=space).delete()
|
||||
ShoppingList.objects.filter(space=space).delete()
|
||||
|
||||
SupermarketCategoryRelation.objects.filter(supermarket__space=space).delete()
|
||||
SupermarketCategory.objects.filter(space=space).delete()
|
||||
Supermarket.objects.filter(space=space).delete()
|
||||
|
||||
InviteLink.objects.filter(space=space).delete()
|
||||
UserFile.objects.filter(space=space).delete()
|
||||
Automation.objects.filter(space=space).delete()
|
||||
|
||||
|
||||
class SpaceAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'created_by', 'max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
|
||||
search_fields = ('name', 'created_by__username')
|
||||
list_filter = ('max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
|
||||
date_hierarchy = 'created_at'
|
||||
actions = [delete_space_action]
|
||||
|
||||
|
||||
admin.site.register(Space, SpaceAdmin)
|
||||
@@ -129,6 +169,7 @@ def sort_tree(modeladmin, request, queryset):
|
||||
class KeywordAdmin(TreeAdmin):
|
||||
form = movenodeform_factory(Keyword)
|
||||
ordering = ('space', 'path',)
|
||||
search_fields = ('name',)
|
||||
actions = [sort_tree, enable_tree_sorting, disable_tree_sorting]
|
||||
|
||||
|
||||
@@ -136,8 +177,8 @@ admin.site.register(Keyword, KeywordAdmin)
|
||||
|
||||
|
||||
class StepAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'type', 'order')
|
||||
search_fields = ('name', 'type')
|
||||
list_display = ('name', 'order',)
|
||||
search_fields = ('name',)
|
||||
|
||||
|
||||
admin.site.register(Step, StepAdmin)
|
||||
@@ -173,9 +214,13 @@ admin.site.register(Recipe, RecipeAdmin)
|
||||
admin.site.register(Unit)
|
||||
|
||||
|
||||
# admin.site.register(FoodInheritField)
|
||||
|
||||
|
||||
class FoodAdmin(TreeAdmin):
|
||||
form = movenodeform_factory(Keyword)
|
||||
ordering = ('space', 'path',)
|
||||
search_fields = ('name',)
|
||||
actions = [sort_tree, enable_tree_sorting, disable_tree_sorting]
|
||||
|
||||
|
||||
@@ -257,7 +302,7 @@ admin.site.register(ViewLog, ViewLogAdmin)
|
||||
|
||||
class InviteLinkAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
'group', 'valid_until',
|
||||
'group', 'valid_until', 'space',
|
||||
'created_by', 'created_at', 'used_by'
|
||||
)
|
||||
|
||||
@@ -280,7 +325,7 @@ admin.site.register(ShoppingListRecipe, ShoppingListRecipeAdmin)
|
||||
|
||||
|
||||
class ShoppingListEntryAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'food', 'unit', 'list_recipe', 'checked')
|
||||
list_display = ('id', 'food', 'unit', 'list_recipe', 'created_by', 'created_at', 'checked')
|
||||
|
||||
|
||||
admin.site.register(ShoppingListEntry, ShoppingListEntryAdmin)
|
||||
|
||||
@@ -12,29 +12,26 @@ class CookbookConfig(AppConfig):
|
||||
name = 'cookbook'
|
||||
|
||||
def ready(self):
|
||||
# post_save signal is only necessary if using full-text search on postgres
|
||||
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
|
||||
'django.db.backends.postgresql']:
|
||||
import cookbook.signals # noqa
|
||||
import cookbook.signals # noqa
|
||||
|
||||
if not settings.DISABLE_TREE_FIX_STARTUP:
|
||||
# when starting up run fix_tree to:
|
||||
# a) make sure that nodes are sorted when switching between sort modes
|
||||
# b) fix problems, if any, with tree consistency
|
||||
with scopes_disabled():
|
||||
try:
|
||||
from cookbook.models import Keyword, Food
|
||||
Keyword.fix_tree(fix_paths=True)
|
||||
Food.fix_tree(fix_paths=True)
|
||||
except OperationalError:
|
||||
if DEBUG:
|
||||
traceback.print_exc()
|
||||
pass # if model does not exist there is no need to fix it
|
||||
except ProgrammingError:
|
||||
if DEBUG:
|
||||
traceback.print_exc()
|
||||
pass # if migration has not been run database cannot be fixed yet
|
||||
except Exception:
|
||||
if DEBUG:
|
||||
traceback.print_exc()
|
||||
pass # dont break startup just because fix could not run, need to investigate cases when this happens
|
||||
# if not settings.DISABLE_TREE_FIX_STARTUP:
|
||||
# # when starting up run fix_tree to:
|
||||
# # a) make sure that nodes are sorted when switching between sort modes
|
||||
# # b) fix problems, if any, with tree consistency
|
||||
# with scopes_disabled():
|
||||
# try:
|
||||
# from cookbook.models import Food, Keyword
|
||||
# Keyword.fix_tree(fix_paths=True)
|
||||
# Food.fix_tree(fix_paths=True)
|
||||
# except OperationalError:
|
||||
# if DEBUG:
|
||||
# traceback.print_exc()
|
||||
# pass # if model does not exist there is no need to fix it
|
||||
# except ProgrammingError:
|
||||
# if DEBUG:
|
||||
# traceback.print_exc()
|
||||
# pass # if migration has not been run database cannot be fixed yet
|
||||
# except Exception:
|
||||
# if DEBUG:
|
||||
# traceback.print_exc()
|
||||
# pass # dont break startup just because fix could not run, need to investigate cases when this happens
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
from datetime import datetime
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.forms import widgets, NumberInput
|
||||
from django.forms import NumberInput, widgets
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import scopes_disabled
|
||||
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
|
||||
from hcaptcha.fields import hCaptchaField
|
||||
|
||||
from .models import (Comment, InviteLink, Keyword, MealPlan, Recipe,
|
||||
RecipeBook, RecipeBookEntry, Storage, Sync, User,
|
||||
UserPreference, MealType, Space,
|
||||
SearchPreference)
|
||||
from .models import (Comment, Food, InviteLink, Keyword, MealPlan, MealType, Recipe, RecipeBook,
|
||||
RecipeBookEntry, SearchPreference, Space, Storage, Sync, User, UserPreference)
|
||||
|
||||
|
||||
class SelectWidget(widgets.Select):
|
||||
@@ -37,7 +37,10 @@ class UserPreferenceForm(forms.ModelForm):
|
||||
prefix = 'preference'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
if x := kwargs.get('instance', None):
|
||||
space = x.space
|
||||
else:
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['plan_share'].queryset = User.objects.filter(userpreference__space=space).all()
|
||||
|
||||
@@ -46,8 +49,7 @@ class UserPreferenceForm(forms.ModelForm):
|
||||
fields = (
|
||||
'default_unit', 'use_fractions', 'use_kj', 'theme', 'nav_color',
|
||||
'sticky_navbar', 'default_page', 'show_recent', 'search_style',
|
||||
'plan_share', 'ingredient_decimals', 'shopping_auto_sync',
|
||||
'comments'
|
||||
'plan_share', 'ingredient_decimals', 'comments',
|
||||
)
|
||||
|
||||
labels = {
|
||||
@@ -74,8 +76,8 @@ class UserPreferenceForm(forms.ModelForm):
|
||||
'Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'),
|
||||
# noqa: E501
|
||||
'use_kj': _('Display nutritional energy amounts in joules instead of calories'), # noqa: E501
|
||||
'plan_share': _(
|
||||
'Users with whom newly created meal plan/shopping list entries should be shared by default.'),
|
||||
'plan_share': _('Users with whom newly created meal plans should be shared by default.'),
|
||||
'shopping_share': _('Users with whom to share shopping lists.'),
|
||||
# noqa: E501
|
||||
'show_recent': _('Show recently viewed recipes on search page.'), # noqa: E501
|
||||
'ingredient_decimals': _('Number of decimals to round ingredients.'), # noqa: E501
|
||||
@@ -84,11 +86,14 @@ class UserPreferenceForm(forms.ModelForm):
|
||||
'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
|
||||
'sticky_navbar': _('Makes the navbar stick to the top of the page.'), # noqa: E501
|
||||
'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'),
|
||||
'mealplan_autoexclude_onhand': _('Exclude ingredients that are on hand.'),
|
||||
}
|
||||
|
||||
widgets = {
|
||||
'plan_share': MultiSelectWidget
|
||||
'plan_share': MultiSelectWidget,
|
||||
'shopping_share': MultiSelectWidget,
|
||||
}
|
||||
|
||||
|
||||
@@ -140,7 +145,7 @@ class ImportExportBase(forms.Form):
|
||||
NEXTCLOUD = 'NEXTCLOUD'
|
||||
MEALIE = 'MEALIE'
|
||||
CHOWDOWN = 'CHOWDOWN'
|
||||
SAFRON = 'SAFRON'
|
||||
SAFFRON = 'SAFFRON'
|
||||
CHEFTAP = 'CHEFTAP'
|
||||
PEPPERPLATE = 'PEPPERPLATE'
|
||||
RECIPEKEEPER = 'RECIPEKEEPER'
|
||||
@@ -152,13 +157,15 @@ class ImportExportBase(forms.Form):
|
||||
OPENEATS = 'OPENEATS'
|
||||
PLANTOEAT = 'PLANTOEAT'
|
||||
COOKBOOKAPP = 'COOKBOOKAPP'
|
||||
COPYMETHAT = 'COPYMETHAT'
|
||||
PDF = 'PDF'
|
||||
|
||||
type = forms.ChoiceField(choices=(
|
||||
(DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'),
|
||||
(MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'), (SAFRON, 'Safron'), (CHEFTAP, 'ChefTap'),
|
||||
(MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'), (SAFFRON, 'Saffron'), (CHEFTAP, 'ChefTap'),
|
||||
(PEPPERPLATE, 'Pepperplate'), (RECETTETEK, 'RecetteTek'), (RECIPESAGE, 'Recipe Sage'), (DOMESTICA, 'Domestica'),
|
||||
(MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'), (OPENEATS, 'Openeats'), (RECIPEKEEPER, 'Recipe Keeper'),
|
||||
(PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'),
|
||||
(PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'), (COPYMETHAT, 'CopyMeThat'), (PDF, 'PDF'),
|
||||
))
|
||||
|
||||
|
||||
@@ -170,7 +177,7 @@ class ImportForm(ImportExportBase):
|
||||
|
||||
|
||||
class ExportForm(ImportExportBase):
|
||||
recipes = forms.ModelMultipleChoiceField(widget=MultiSelectWidget, queryset=Recipe.objects.none())
|
||||
recipes = forms.ModelMultipleChoiceField(widget=MultiSelectWidget, queryset=Recipe.objects.none(), required=False)
|
||||
all = forms.BooleanField(required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -222,6 +229,7 @@ class StorageForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
# TODO: Deprecate
|
||||
class RecipeBookEntryForm(forms.ModelForm):
|
||||
prefix = 'bookmark'
|
||||
|
||||
@@ -261,6 +269,7 @@ class SyncForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
# TODO deprecate
|
||||
class BatchEditForm(forms.Form):
|
||||
search = forms.CharField(label=_('Search String'))
|
||||
keywords = forms.ModelMultipleChoiceField(
|
||||
@@ -297,6 +306,7 @@ class ImportRecipeForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
# TODO deprecate
|
||||
class MealPlanForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
@@ -348,8 +358,8 @@ class InviteLinkForm(forms.ModelForm):
|
||||
|
||||
def clean(self):
|
||||
space = self.cleaned_data['space']
|
||||
if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() + InviteLink.objects.filter(
|
||||
space=space).count()) >= space.max_users:
|
||||
if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() +
|
||||
InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=space).count()) >= space.max_users:
|
||||
raise ValidationError(_('Maximum number of users for this space reached.'))
|
||||
|
||||
def clean_email(self):
|
||||
@@ -432,7 +442,7 @@ class SearchPreferenceForm(forms.ModelForm):
|
||||
|
||||
help_texts = {
|
||||
'search': _(
|
||||
'Select type method of search. Click <a href="/docs/search/">here</a> for full desciption of choices.'),
|
||||
'Select type method of search. Click <a href="/docs/search/">here</a> for full description of choices.'),
|
||||
'lookup': _('Use fuzzy matching on units, keywords and ingredients when editing and importing recipes.'),
|
||||
'unaccent': _(
|
||||
'Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'),
|
||||
@@ -451,7 +461,7 @@ class SearchPreferenceForm(forms.ModelForm):
|
||||
'lookup': _('Fuzzy Lookups'),
|
||||
'unaccent': _('Ignore Accent'),
|
||||
'icontains': _("Partial Match"),
|
||||
'istartswith': _("Starts Wtih"),
|
||||
'istartswith': _("Starts With"),
|
||||
'trigram': _("Fuzzy Search"),
|
||||
'fulltext': _("Full Text")
|
||||
}
|
||||
@@ -464,3 +474,73 @@ class SearchPreferenceForm(forms.ModelForm):
|
||||
'trigram': MultiSelectWidget,
|
||||
'fulltext': MultiSelectWidget,
|
||||
}
|
||||
|
||||
|
||||
class ShoppingPreferenceForm(forms.ModelForm):
|
||||
prefix = 'shopping'
|
||||
|
||||
class Meta:
|
||||
model = UserPreference
|
||||
|
||||
fields = (
|
||||
'shopping_share', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'mealplan_autoexclude_onhand',
|
||||
'mealplan_autoinclude_related', 'shopping_add_onhand', 'default_delay', 'filter_to_supermarket', 'shopping_recent_days', 'csv_delim', 'csv_prefix'
|
||||
)
|
||||
|
||||
help_texts = {
|
||||
'shopping_share': _('Users will see all items you add to your shopping list. They must add you to see items on their list.'),
|
||||
'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 ' # noqa: E501
|
||||
'of mobile data. If lower than instance limit it is reset when saving.' # noqa: E501
|
||||
),
|
||||
'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'),
|
||||
'mealplan_autoinclude_related': _('When adding a meal plan to the shopping list (manually or automatically), include all related recipes.'),
|
||||
'mealplan_autoexclude_onhand': _('When adding a meal plan to the shopping list (manually or automatically), exclude ingredients that are on hand.'),
|
||||
'default_delay': _('Default number of hours to delay a shopping list entry.'),
|
||||
'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'),
|
||||
'shopping_recent_days': _('Days of recent shopping list entries to display.'),
|
||||
'shopping_add_onhand': _("Mark food 'On Hand' when checked off shopping list."),
|
||||
'csv_delim': _('Delimiter to use for CSV exports.'),
|
||||
'csv_prefix': _('Prefix to add when copying list to the clipboard.'),
|
||||
|
||||
}
|
||||
labels = {
|
||||
'shopping_share': _('Share Shopping List'),
|
||||
'shopping_auto_sync': _('Autosync'),
|
||||
'mealplan_autoadd_shopping': _('Auto Add Meal Plan'),
|
||||
'mealplan_autoexclude_onhand': _('Exclude On Hand'),
|
||||
'mealplan_autoinclude_related': _('Include Related'),
|
||||
'default_delay': _('Default Delay Hours'),
|
||||
'filter_to_supermarket': _('Filter to Supermarket'),
|
||||
'shopping_recent_days': _('Recent Days'),
|
||||
'csv_delim': _('CSV Delimiter'),
|
||||
"csv_prefix_label": _("List Prefix"),
|
||||
'shopping_add_onhand': _("Auto On Hand"),
|
||||
}
|
||||
|
||||
widgets = {
|
||||
'shopping_share': MultiSelectWidget
|
||||
}
|
||||
|
||||
|
||||
class SpacePreferenceForm(forms.ModelForm):
|
||||
prefix = 'space'
|
||||
reset_food_inherit = forms.BooleanField(label=_("Reset Food Inheritance"), initial=False, required=False,
|
||||
help_text=_("Reset all food to inherit the fields configured."))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs) # populates the post
|
||||
self.fields['food_inherit'].queryset = Food.inheritable_fields
|
||||
|
||||
class Meta:
|
||||
model = Space
|
||||
|
||||
fields = ('food_inherit', 'reset_food_inherit', 'show_facet_count')
|
||||
|
||||
help_texts = {
|
||||
'food_inherit': _('Fields on food that should be inherited by default.'),
|
||||
'show_facet_count': _('Show recipe counts on search filters'), }
|
||||
|
||||
widgets = {
|
||||
'food_inherit': MultiSelectWidget
|
||||
}
|
||||
|
||||
13
cookbook/helper/HelperFunctions.py
Normal file
13
cookbook/helper/HelperFunctions.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.db.models import Func
|
||||
|
||||
|
||||
class Round(Func):
|
||||
function = 'ROUND'
|
||||
template = '%(function)s(%(expressions)s, 0)'
|
||||
|
||||
|
||||
def str2bool(v):
|
||||
if type(v) == bool or v is None:
|
||||
return v
|
||||
else:
|
||||
return v.lower() in ("yes", "true", "1")
|
||||
@@ -2,11 +2,9 @@
|
||||
Source: https://djangosnippets.org/snippets/1703/
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.core.cache import caches
|
||||
|
||||
from cookbook.models import ShareLink
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import user_passes_test
|
||||
from django.core.cache import caches
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse, reverse_lazy
|
||||
@@ -14,6 +12,8 @@ from django.utils.translation import gettext as _
|
||||
from rest_framework import permissions
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
|
||||
from cookbook.models import ShareLink
|
||||
|
||||
|
||||
def get_allowed_groups(groups_required):
|
||||
"""
|
||||
@@ -34,7 +34,7 @@ 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.
|
||||
Unauthenticated users can't 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
|
||||
@@ -205,6 +205,9 @@ class CustomIsShared(permissions.BasePermission):
|
||||
return request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
# temporary hack to make old shopping list work with new shopping list
|
||||
if obj.__class__.__name__ == 'ShoppingList':
|
||||
return is_object_shared(request.user, obj) or obj.created_by in list(request.user.get_shopping_share())
|
||||
return is_object_shared(request.user, obj)
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import json
|
||||
import re
|
||||
from json import JSONDecodeError
|
||||
from urllib.parse import unquote
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from bs4.element import Tag
|
||||
from recipe_scrapers._utils import get_host_name, normalize_string
|
||||
|
||||
from cookbook.helper import recipe_url_import as helper
|
||||
from cookbook.helper.scrapers.scrapers import text_scraper
|
||||
from json import JSONDecodeError
|
||||
from recipe_scrapers._utils import get_host_name, normalize_string
|
||||
from urllib.parse import unquote
|
||||
|
||||
|
||||
def get_recipe_from_source(text, url, request):
|
||||
@@ -58,7 +59,7 @@ def get_recipe_from_source(text, url, request):
|
||||
return kid_list
|
||||
|
||||
recipe_json = {
|
||||
'name': '',
|
||||
'name': '',
|
||||
'url': '',
|
||||
'description': '',
|
||||
'image': '',
|
||||
@@ -188,6 +189,6 @@ def remove_graph(el):
|
||||
for x in el['@graph']:
|
||||
if '@type' in x and x['@type'] == 'Recipe':
|
||||
el = x
|
||||
except TypeError:
|
||||
except (TypeError, JSONDecodeError):
|
||||
pass
|
||||
return el
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,15 @@
|
||||
import random
|
||||
import re
|
||||
from html import unescape
|
||||
|
||||
from django.utils.dateparse import parse_duration
|
||||
from isodate import parse_duration as iso_parse_duration
|
||||
from isodate.isoerror import ISO8601Error
|
||||
from recipe_scrapers._utils import get_minutes
|
||||
|
||||
from cookbook.helper import recipe_url_import as helper
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.models import Keyword
|
||||
from django.utils.dateparse import parse_duration
|
||||
from html import unescape
|
||||
from recipe_scrapers._utils import get_minutes
|
||||
|
||||
|
||||
def get_from_scraper(scrape, request):
|
||||
@@ -96,8 +98,9 @@ def get_from_scraper(scrape, request):
|
||||
recipe_json['keywords'] = keywords
|
||||
|
||||
ingredient_parser = IngredientParser(request, True)
|
||||
|
||||
ingredients = []
|
||||
try:
|
||||
ingredients = []
|
||||
for x in scrape.ingredients():
|
||||
try:
|
||||
amount, unit, ingredient, note = ingredient_parser.parse(x)
|
||||
|
||||
@@ -5,6 +5,7 @@ from rest_framework.authtoken.models import Token
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from cookbook.views import views
|
||||
from recipes import settings
|
||||
|
||||
|
||||
class ScopeMiddleware:
|
||||
@@ -12,16 +13,17 @@ class ScopeMiddleware:
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
prefix = settings.JS_REVERSE_SCRIPT_PREFIX or ''
|
||||
if request.user.is_authenticated:
|
||||
|
||||
if request.path.startswith('/admin/'):
|
||||
if request.path.startswith(prefix + '/admin/'):
|
||||
with scopes_disabled():
|
||||
return self.get_response(request)
|
||||
|
||||
if request.path.startswith('/signup/') or request.path.startswith('/invite/'):
|
||||
if request.path.startswith(prefix + '/signup/') or request.path.startswith(prefix + '/invite/'):
|
||||
return self.get_response(request)
|
||||
|
||||
if request.path.startswith('/accounts/'):
|
||||
if request.path.startswith(prefix + '/accounts/'):
|
||||
return self.get_response(request)
|
||||
|
||||
with scopes_disabled():
|
||||
@@ -36,7 +38,7 @@ class ScopeMiddleware:
|
||||
with scope(space=request.space):
|
||||
return self.get_response(request)
|
||||
else:
|
||||
if request.path.startswith('/api/'):
|
||||
if request.path.startswith(prefix + '/api/'):
|
||||
try:
|
||||
if auth := TokenAuthentication().authenticate(request):
|
||||
request.space = auth[0].userpreference.space
|
||||
|
||||
155
cookbook/helper/shopping_helper.py
Normal file
155
cookbook/helper/shopping_helper.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.db.models import F, OuterRef, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.HelperFunctions import Round, str2bool
|
||||
from cookbook.models import (Ingredient, ShoppingListEntry, ShoppingListRecipe,
|
||||
SupermarketCategoryRelation)
|
||||
from recipes import settings
|
||||
|
||||
|
||||
def shopping_helper(qs, request):
|
||||
supermarket = request.query_params.get('supermarket', None)
|
||||
checked = request.query_params.get('checked', 'recent')
|
||||
user = request.user
|
||||
supermarket_order = [F('food__supermarket_category__name').asc(nulls_first=True), 'food__name']
|
||||
|
||||
# TODO created either scheduled task or startup task to delete very old shopping list entries
|
||||
# TODO create user preference to define 'very old'
|
||||
if supermarket:
|
||||
supermarket_categories = SupermarketCategoryRelation.objects.filter(supermarket=supermarket, category=OuterRef('food__supermarket_category'))
|
||||
qs = qs.annotate(supermarket_order=Coalesce(Subquery(supermarket_categories.values('order')), Value(9999)))
|
||||
supermarket_order = ['supermarket_order'] + supermarket_order
|
||||
if checked in ['false', 0, '0']:
|
||||
qs = qs.filter(checked=False)
|
||||
elif checked in ['true', 1, '1']:
|
||||
qs = qs.filter(checked=True)
|
||||
elif checked in ['recent']:
|
||||
today_start = timezone.now().replace(hour=0, minute=0, second=0)
|
||||
week_ago = today_start - timedelta(days=user.userpreference.shopping_recent_days)
|
||||
qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago))
|
||||
supermarket_order = ['checked'] + supermarket_order
|
||||
|
||||
return qs.order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe')
|
||||
|
||||
|
||||
# TODO refactor as class
|
||||
def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False):
|
||||
"""
|
||||
Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe
|
||||
:param list_recipe: Modify an existing ShoppingListRecipe
|
||||
:param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required
|
||||
:param mealplan: alternatively use a mealplan recipe as source of ingredients
|
||||
:param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted
|
||||
:param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used
|
||||
:param append: If False will remove any entries not included with ingredients, when True will append ingredients to the shopping list
|
||||
"""
|
||||
r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None)
|
||||
if not r:
|
||||
raise ValueError(_("You must supply a recipe or mealplan"))
|
||||
|
||||
created_by = created_by or getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', None)
|
||||
if not created_by:
|
||||
raise ValueError(_("You must supply a created_by"))
|
||||
|
||||
try:
|
||||
servings = float(servings)
|
||||
except (ValueError, TypeError):
|
||||
servings = getattr(mealplan, 'servings', 1.0)
|
||||
|
||||
servings_factor = servings / r.servings
|
||||
|
||||
shared_users = list(created_by.get_shopping_share())
|
||||
shared_users.append(created_by)
|
||||
if list_recipe:
|
||||
created = False
|
||||
else:
|
||||
list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings)
|
||||
created = True
|
||||
|
||||
related_step_ing = []
|
||||
if servings == 0 and not created:
|
||||
list_recipe.delete()
|
||||
return []
|
||||
elif ingredients:
|
||||
ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
|
||||
else:
|
||||
ingredients = Ingredient.objects.filter(step__recipe=r, space=space)
|
||||
|
||||
if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand:
|
||||
ingredients = ingredients.exclude(food__onhand_users__id__in=[x.id for x in shared_users])
|
||||
|
||||
if related := created_by.userpreference.mealplan_autoinclude_related:
|
||||
# TODO: add levels of related recipes (related recipes of related recipes) to use when auto-adding mealplans
|
||||
related_recipes = r.get_related_recipes()
|
||||
|
||||
for x in related_recipes:
|
||||
# related recipe is a Step serving size is driven by recipe serving size
|
||||
# TODO once/if Steps can have a serving size this needs to be refactored
|
||||
if exclude_onhand:
|
||||
# if steps are used more than once in a recipe or subrecipe - I don' think this results in the desired behavior
|
||||
related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users]).values_list('id', flat=True)
|
||||
else:
|
||||
related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).values_list('id', flat=True)
|
||||
|
||||
x_ing = []
|
||||
if ingredients.filter(food__recipe=x).exists():
|
||||
for ing in ingredients.filter(food__recipe=x):
|
||||
if exclude_onhand:
|
||||
x_ing = Ingredient.objects.filter(step__recipe=x, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users])
|
||||
else:
|
||||
x_ing = Ingredient.objects.filter(step__recipe=x, space=space)
|
||||
for i in [x for x in x_ing]:
|
||||
ShoppingListEntry.objects.create(
|
||||
list_recipe=list_recipe,
|
||||
food=i.food,
|
||||
unit=i.unit,
|
||||
ingredient=i,
|
||||
amount=i.amount * Decimal(servings_factor),
|
||||
created_by=created_by,
|
||||
space=space,
|
||||
)
|
||||
# dont' add food to the shopping list that are actually recipes that will be added as ingredients
|
||||
ingredients = ingredients.exclude(food__recipe=x)
|
||||
|
||||
add_ingredients = list(ingredients.values_list('id', flat=True)) + related_step_ing
|
||||
if not append:
|
||||
existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe)
|
||||
# delete shopping list entries not included in ingredients
|
||||
existing_list.exclude(ingredient__in=ingredients).delete()
|
||||
# add shopping list entries that did not previously exist
|
||||
add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True))
|
||||
add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
|
||||
|
||||
# if servings have changed, update the ShoppingListRecipe and existing Entries
|
||||
if servings <= 0:
|
||||
servings = 1
|
||||
|
||||
if not created and list_recipe.servings != servings:
|
||||
update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True))
|
||||
list_recipe.servings = servings
|
||||
list_recipe.save()
|
||||
for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients):
|
||||
sle.amount = sle.ingredient.amount * Decimal(servings_factor)
|
||||
sle.save()
|
||||
|
||||
# add any missing Entries
|
||||
for i in [x for x in add_ingredients if x.food]:
|
||||
|
||||
ShoppingListEntry.objects.create(
|
||||
list_recipe=list_recipe,
|
||||
food=i.food,
|
||||
unit=i.unit,
|
||||
ingredient=i,
|
||||
amount=i.amount * Decimal(servings_factor),
|
||||
created_by=created_by,
|
||||
space=space,
|
||||
)
|
||||
|
||||
# return all shopping list items
|
||||
return list_recipe
|
||||
@@ -5,7 +5,7 @@ from cookbook.helper.mdx_attributes import MarkdownFormatExtension
|
||||
from cookbook.helper.mdx_urlize import UrlizeExtension
|
||||
from jinja2 import Template, TemplateSyntaxError, UndefinedError
|
||||
from gettext import gettext as _
|
||||
|
||||
from markdown.extensions.tables import TableExtension
|
||||
|
||||
class IngredientObject(object):
|
||||
amount = ""
|
||||
@@ -41,7 +41,7 @@ def render_instructions(step): # TODO deduplicate markdown cleanup code
|
||||
parsed_md = md.markdown(
|
||||
instructions,
|
||||
extensions=[
|
||||
'markdown.extensions.fenced_code', 'tables',
|
||||
'markdown.extensions.fenced_code', TableExtension(),
|
||||
UrlizeExtension(), MarkdownFormatExtension()
|
||||
]
|
||||
)
|
||||
|
||||
84
cookbook/integration/copymethat.py
Normal file
84
cookbook/integration/copymethat.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_html_import import get_recipe_from_source
|
||||
from cookbook.helper.recipe_url_import import iso_duration_to_minutes, parse_servings
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Ingredient, Keyword
|
||||
from recipes.settings import DEBUG
|
||||
|
||||
|
||||
class CopyMeThat(Integration):
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
if DEBUG:
|
||||
print("testing", zip_info_object.filename, zip_info_object.filename == 'recipes.html')
|
||||
return zip_info_object.filename == 'recipes.html'
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
# 'file' comes is as a beautifulsoup object
|
||||
recipe = Recipe.objects.create(name=file.find("div", {"id": "name"}).text.strip(), created_by=self.request.user, internal=True, space=self.request.space, )
|
||||
|
||||
for category in file.find_all("span", {"class": "recipeCategory"}):
|
||||
keyword, created = Keyword.objects.get_or_create(name=category.text, space=self.request.space)
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
try:
|
||||
recipe.servings = parse_servings(file.find("a", {"id": "recipeYield"}).text.strip())
|
||||
recipe.working_time = iso_duration_to_minutes(file.find("span", {"meta": "prepTime"}).text.strip())
|
||||
recipe.waiting_time = iso_duration_to_minutes(file.find("span", {"meta": "cookTime"}).text.strip())
|
||||
recipe.save()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
step = Step.objects.create(instruction='', space=self.request.space, )
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
for ingredient in file.find_all("li", {"class": "recipeIngredient"}):
|
||||
if ingredient.text == "":
|
||||
continue
|
||||
amount, unit, ingredient, note = ingredient_parser.parse(ingredient.text.strip())
|
||||
f = ingredient_parser.get_food(ingredient)
|
||||
u = ingredient_parser.get_unit(unit)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note, space=self.request.space,
|
||||
))
|
||||
|
||||
for s in file.find_all("li", {"class": "instruction"}):
|
||||
if s.text == "":
|
||||
continue
|
||||
step.instruction += s.text.strip() + ' \n\n'
|
||||
|
||||
for s in file.find_all("li", {"class": "recipeNote"}):
|
||||
if s.text == "":
|
||||
continue
|
||||
step.instruction += s.text.strip() + ' \n\n'
|
||||
|
||||
try:
|
||||
if file.find("a", {"id": "original_link"}).text != '':
|
||||
step.instruction += "\n\nImported from: " + file.find("a", {"id": "original_link"}).text
|
||||
step.save()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
recipe.steps.add(step)
|
||||
|
||||
# import the Primary recipe image that is stored in the Zip
|
||||
try:
|
||||
for f in self.files:
|
||||
if '.zip' in f['name']:
|
||||
import_zip = ZipFile(f['file'])
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(file.find("img", class_="recipeImage").get("src"))), filetype='.jpeg')
|
||||
except Exception as e:
|
||||
print(recipe.name, ': failed to import image ', str(e))
|
||||
|
||||
recipe.save()
|
||||
return recipe
|
||||
|
||||
def split_recipe_file(self, file):
|
||||
soup = BeautifulSoup(file, "html.parser")
|
||||
return soup.find_all("div", {"class": "recipe"})
|
||||
@@ -1,5 +1,5 @@
|
||||
import json
|
||||
from io import BytesIO
|
||||
from io import BytesIO, StringIO
|
||||
from re import match
|
||||
from zipfile import ZipFile
|
||||
|
||||
@@ -35,3 +35,28 @@ class Default(Integration):
|
||||
export = RecipeExportSerializer(recipe).data
|
||||
|
||||
return 'recipe.json', JSONRenderer().render(export).decode("utf-8")
|
||||
|
||||
def get_files_from_recipes(self, recipes, cookie):
|
||||
export_zip_stream = BytesIO()
|
||||
export_zip_obj = ZipFile(export_zip_stream, 'w')
|
||||
|
||||
for r in recipes:
|
||||
if r.internal and r.space == self.request.space:
|
||||
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.writestr(f'image{get_filetype(r.image.file.name)}', r.image.file.read())
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
recipe_zip_obj.close()
|
||||
export_zip_obj.writestr(str(r.pk) + '.zip', recipe_zip_stream.getvalue())
|
||||
export_zip_obj.close()
|
||||
|
||||
return [[ 'export.zip', export_zip_stream.getvalue() ]]
|
||||
@@ -3,8 +3,9 @@ import json
|
||||
import traceback
|
||||
import uuid
|
||||
from io import BytesIO, StringIO
|
||||
from zipfile import ZipFile, BadZipFile
|
||||
from zipfile import BadZipFile, ZipFile
|
||||
|
||||
from bs4 import Tag
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.files import File
|
||||
from django.db import IntegrityError
|
||||
@@ -16,7 +17,7 @@ from django_scopes import scope
|
||||
from cookbook.forms import ImportExportBase
|
||||
from cookbook.helper.image_processing import get_filetype, handle_image
|
||||
from cookbook.models import Keyword, Recipe
|
||||
from recipes.settings import DATABASES, DEBUG
|
||||
from recipes.settings import DEBUG
|
||||
|
||||
|
||||
class Integration:
|
||||
@@ -41,7 +42,7 @@ class Integration:
|
||||
try:
|
||||
last_kw = Keyword.objects.filter(name__regex=r'^(Import [0-9]+)', space=request.space).latest('created_at')
|
||||
name = f'Import {int(last_kw.name.replace("Import ", "")) + 1}'
|
||||
except ObjectDoesNotExist:
|
||||
except (ObjectDoesNotExist, ValueError):
|
||||
name = 'Import 1'
|
||||
|
||||
parent, created = Keyword.objects.get_or_create(name='Import', space=request.space)
|
||||
@@ -52,7 +53,7 @@ class Integration:
|
||||
icon=icon,
|
||||
space=request.space
|
||||
)
|
||||
except IntegrityError: # in case, for whatever reason, the name does exist append UUID to it. Not nice but works for now.
|
||||
except (IntegrityError, ValueError): # in case, for whatever reason, the name does exist append UUID to it. Not nice but works for now.
|
||||
self.keyword = parent.add_child(
|
||||
name=f'{name} {str(uuid.uuid4())[0:8]}',
|
||||
description=description,
|
||||
@@ -64,45 +65,30 @@ class Integration:
|
||||
"""
|
||||
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
|
||||
:return: HttpResponse with the file of the requested export format that is directly downloaded (When that format involve multiple files they are zipped together)
|
||||
"""
|
||||
|
||||
# TODO this is temporary, find a better solution for different export formats when doing other exporters
|
||||
if self.export_type != ImportExportBase.RECIPESAGE:
|
||||
export_zip_stream = BytesIO()
|
||||
export_zip_obj = ZipFile(export_zip_stream, 'w')
|
||||
files = self.get_files_from_recipes(recipes, self.request.COOKIES)
|
||||
|
||||
for r in recipes:
|
||||
if r.internal and r.space == self.request.space:
|
||||
recipe_zip_stream = BytesIO()
|
||||
recipe_zip_obj = ZipFile(recipe_zip_stream, 'w')
|
||||
if len(files) == 1:
|
||||
filename, file = files[0]
|
||||
export_filename = filename
|
||||
export_file = file
|
||||
|
||||
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.writestr(f'image{get_filetype(r.image.file.name)}', r.image.file.read())
|
||||
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
|
||||
else:
|
||||
json_list = []
|
||||
for r in recipes:
|
||||
json_list.append(self.get_file_from_recipe(r))
|
||||
export_filename = "export.zip"
|
||||
export_stream = BytesIO()
|
||||
export_obj = ZipFile(export_stream, 'w')
|
||||
|
||||
response = HttpResponse(json.dumps(json_list), content_type='application/force-download')
|
||||
response['Content-Disposition'] = 'attachment; filename="recipes.json"'
|
||||
return response
|
||||
for filename, file in files:
|
||||
export_obj.writestr(filename, file)
|
||||
|
||||
export_obj.close()
|
||||
export_file = export_stream.getvalue()
|
||||
|
||||
response = HttpResponse(export_file, content_type='application/force-download')
|
||||
response['Content-Disposition'] = 'attachment; filename="' + export_filename + '"'
|
||||
return response
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
"""
|
||||
@@ -153,9 +139,17 @@ class Integration:
|
||||
file_list.append(z)
|
||||
il.total_recipes += len(file_list)
|
||||
|
||||
import cookbook
|
||||
if isinstance(self, cookbook.integration.copymethat.CopyMeThat):
|
||||
file_list = self.split_recipe_file(BytesIO(import_zip.read('recipes.html')))
|
||||
il.total_recipes += len(file_list)
|
||||
|
||||
for z in file_list:
|
||||
try:
|
||||
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
|
||||
if isinstance(z, Tag):
|
||||
recipe = self.get_recipe_from_file(z)
|
||||
else:
|
||||
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += f'{recipe.pk} - {recipe.name} \n'
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
@@ -266,6 +260,16 @@ class Integration:
|
||||
"""
|
||||
raise NotImplementedError('Method not implemented in integration')
|
||||
|
||||
def get_files_from_recipes(self, recipes, cookie):
|
||||
"""
|
||||
Takes a list of recipe object and converts it to a array containing each file.
|
||||
Each file is represented as an array [filename, data] where data is a string of the content of the file.
|
||||
:param recipe: Recipe object that should be converted
|
||||
:returns:
|
||||
[[filename, data], ...]
|
||||
"""
|
||||
raise NotImplementedError('Method not implemented in integration')
|
||||
|
||||
@staticmethod
|
||||
def handle_exception(exception, log=None, message=''):
|
||||
if log:
|
||||
|
||||
55
cookbook/integration/pdfexport.py
Normal file
55
cookbook/integration/pdfexport.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import json
|
||||
from io import BytesIO
|
||||
from re import match
|
||||
from zipfile import ZipFile
|
||||
import asyncio
|
||||
from pyppeteer import launch
|
||||
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.serializer import RecipeExportSerializer
|
||||
|
||||
import django.core.management.commands.runserver as runserver
|
||||
|
||||
|
||||
class PDFexport(Integration):
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
|
||||
async def get_files_from_recipes_async(self, recipes, cookie):
|
||||
cmd = runserver.Command()
|
||||
|
||||
browser = await launch(
|
||||
handleSIGINT=False,
|
||||
handleSIGTERM=False,
|
||||
handleSIGHUP=False,
|
||||
ignoreHTTPSErrors=True
|
||||
)
|
||||
|
||||
cookies = {'domain': cmd.default_addr, 'name': 'sessionid', 'value': cookie['sessionid'], }
|
||||
options = {'format': 'letter',
|
||||
'margin': {
|
||||
'top': '0.75in',
|
||||
'bottom': '0.75in',
|
||||
'left': '0.75in',
|
||||
'right': '0.75in',
|
||||
}
|
||||
}
|
||||
|
||||
page = await browser.newPage()
|
||||
await page.emulateMedia('print')
|
||||
await page.setCookie(cookies)
|
||||
|
||||
files = []
|
||||
for recipe in recipes:
|
||||
await page.goto('http://' + cmd.default_addr + ':' + cmd.default_port + '/view/recipe/' + str(recipe.id), {'waitUntil': 'networkidle0', })
|
||||
files.append([recipe.name + '.pdf', await page.pdf(options)])
|
||||
|
||||
await browser.close()
|
||||
return files
|
||||
|
||||
def get_files_from_recipes(self, recipes, cookie):
|
||||
return asyncio.run(self.get_files_from_recipes_async(recipes, cookie))
|
||||
@@ -27,10 +27,10 @@ class RecetteTek(Integration):
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
|
||||
# Create initial recipe with just a title and a decription
|
||||
# Create initial recipe with just a title and a description
|
||||
recipe = Recipe.objects.create(name=file['title'], created_by=self.request.user, internal=True, space=self.request.space, )
|
||||
|
||||
# set the description as an empty string for later use for the source URL, incase there is no description text.
|
||||
# set the description as an empty string for later use for the source URL, in case there is no description text.
|
||||
recipe.description = ''
|
||||
|
||||
try:
|
||||
|
||||
@@ -88,5 +88,12 @@ class RecipeSage(Integration):
|
||||
|
||||
return data
|
||||
|
||||
def get_files_from_recipes(self, recipes, cookie):
|
||||
json_list = []
|
||||
for r in recipes:
|
||||
json_list.append(self.get_file_from_recipe(r))
|
||||
|
||||
return [['export.json', json.dumps(json_list)]]
|
||||
|
||||
def split_recipe_file(self, file):
|
||||
return json.loads(file.read().decode("utf-8"))
|
||||
|
||||
@@ -5,7 +5,7 @@ from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Ingredient
|
||||
|
||||
|
||||
class Safron(Integration):
|
||||
class Saffron(Integration):
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
ingredient_mode = False
|
||||
@@ -58,4 +58,39 @@ class Safron(Integration):
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
|
||||
data = "Title: "+recipe.name if recipe.name else ""+"\n"
|
||||
data += "Description: "+recipe.description if recipe.description else ""+"\n"
|
||||
data += "Source: \n"
|
||||
data += "Original URL: \n"
|
||||
data += "Yield: "+str(recipe.servings)+"\n"
|
||||
data += "Cookbook: \n"
|
||||
data += "Section: \n"
|
||||
data += "Image: \n"
|
||||
|
||||
recipeInstructions = []
|
||||
recipeIngredient = []
|
||||
for s in recipe.steps.all():
|
||||
if s.type != Step.TIME:
|
||||
recipeInstructions.append(s.instruction)
|
||||
|
||||
for i in s.ingredients.all():
|
||||
recipeIngredient.append(f'{float(i.amount)} {i.unit} {i.food}')
|
||||
|
||||
data += "Ingredients: \n"
|
||||
for ingredient in recipeIngredient:
|
||||
data += ingredient+"\n"
|
||||
|
||||
data += "Instructions: \n"
|
||||
for instruction in recipeInstructions:
|
||||
data += instruction+"\n"
|
||||
|
||||
return recipe.name+'.txt', data
|
||||
|
||||
def get_files_from_recipes(self, recipes, cookie):
|
||||
files = []
|
||||
for r in recipes:
|
||||
filename, data = self.get_file_from_recipe(r)
|
||||
files.append([ filename, data ])
|
||||
|
||||
return files
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if settings.DATABASES['default']['ENGINE'] not in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']:
|
||||
self.stdout.write(self.style.WARNING(_('Only Postgress databases use full text search, no index to rebuild')))
|
||||
self.stdout.write(self.style.WARNING(_('Only Postgresql databases use full text search, no index to rebuild')))
|
||||
|
||||
try:
|
||||
language = DICTIONARY.get(translation.get_language(), 'simple')
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
import annoying.fields
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.contrib.postgres.search import SearchVectorField, SearchVector
|
||||
from django.contrib.postgres.search import SearchVector, SearchVectorField
|
||||
from django.db import migrations, models
|
||||
from django.db.models import deletion
|
||||
from django_scopes import scopes_disabled
|
||||
from django.utils import translation
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.managers import DICTIONARY
|
||||
from cookbook.models import Recipe, Step, Index, PermissionModelMixin, nameSearchField, allSearchFields
|
||||
from cookbook.models import (Index, PermissionModelMixin, Recipe, Step, allSearchFields,
|
||||
nameSearchField)
|
||||
|
||||
|
||||
def set_default_search_vector(apps, schema_editor):
|
||||
@@ -16,8 +18,6 @@ def set_default_search_vector(apps, schema_editor):
|
||||
return
|
||||
language = DICTIONARY.get(translation.get_language(), 'simple')
|
||||
with scopes_disabled():
|
||||
# TODO this approach doesn't work terribly well if multiple languages are in use
|
||||
# I'm also uncertain about forcing unaccent here
|
||||
Recipe.objects.all().update(
|
||||
name_search_vector=SearchVector('name__unaccent', weight='A', config=language),
|
||||
desc_search_vector=SearchVector('description__unaccent', weight='B', config=language)
|
||||
|
||||
144
cookbook/migrations/0159_add_shoppinglistentry_fields.py
Normal file
144
cookbook/migrations/0159_add_shoppinglistentry_fields.py
Normal file
@@ -0,0 +1,144 @@
|
||||
# Generated by Django 3.2.7 on 2021-10-01 20:52
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import PermissionModelMixin, ShoppingListEntry
|
||||
|
||||
|
||||
def copy_values_to_sle(apps, schema_editor):
|
||||
with scopes_disabled():
|
||||
entries = ShoppingListEntry.objects.all()
|
||||
for entry in entries:
|
||||
if entry.shoppinglist_set.first():
|
||||
entry.created_by = entry.shoppinglist_set.first().created_by
|
||||
entry.space = entry.shoppinglist_set.first().space
|
||||
if entries:
|
||||
ShoppingListEntry.objects.bulk_update(entries, ["created_by", "space", ])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0158_userpreference_use_kj'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='shoppinglistentry',
|
||||
name='completed_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shoppinglistentry',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shoppinglistentry',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='auth.user'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='shopping_share',
|
||||
field=models.ManyToManyField(blank=True, related_name='shopping_share', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shoppinglistentry',
|
||||
name='space',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shoppinglistrecipe',
|
||||
name='mealplan',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.mealplan'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shoppinglistrecipe',
|
||||
name='name',
|
||||
field=models.CharField(blank=True, default='', max_length=32),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shoppinglistentry',
|
||||
name='ingredient',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.ingredient'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='shoppinglistentry',
|
||||
name='unit',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='cookbook.unit'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='mealplan_autoadd_shopping',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='mealplan_autoexclude_onhand',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='shoppinglistentry',
|
||||
name='list_recipe',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='entries', to='cookbook.shoppinglistrecipe'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FoodInheritField',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('field', models.CharField(max_length=32, unique=True)),
|
||||
('name', models.CharField(max_length=64, unique=True)),
|
||||
],
|
||||
bases=(models.Model, PermissionModelMixin),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='mealplan_autoinclude_related',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='inherit_fields',
|
||||
field=models.ManyToManyField(blank=True, to='cookbook.FoodInheritField'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='food_inherit',
|
||||
field=models.ManyToManyField(blank=True, to='cookbook.FoodInheritField'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shoppinglistentry',
|
||||
name='delay_until',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='default_delay',
|
||||
field=models.DecimalField(decimal_places=4, default=4, max_digits=8),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='filter_to_supermarket',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='shopping_recent_days',
|
||||
field=models.PositiveIntegerField(default=7),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='food',
|
||||
old_name='ignore_shopping',
|
||||
new_name='food_onhand',
|
||||
),
|
||||
migrations.RunPython(copy_values_to_sle),
|
||||
]
|
||||
50
cookbook/migrations/0160_delete_shoppinglist_orphans.py
Normal file
50
cookbook/migrations/0160_delete_shoppinglist_orphans.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 3.2.7 on 2021-10-01 22:34
|
||||
|
||||
import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import utc
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import FoodInheritField, ShoppingListEntry
|
||||
|
||||
|
||||
def delete_orphaned_sle(apps, schema_editor):
|
||||
with scopes_disabled():
|
||||
# shopping list entry is orphaned - delete it
|
||||
ShoppingListEntry.objects.filter(shoppinglist=None).delete()
|
||||
|
||||
|
||||
def create_inheritfields(apps, schema_editor):
|
||||
FoodInheritField.objects.create(name='Supermarket Category', field='supermarket_category')
|
||||
FoodInheritField.objects.create(name='On Hand', field='food_onhand')
|
||||
FoodInheritField.objects.create(name='Diet', field='diet')
|
||||
FoodInheritField.objects.create(name='Substitute', field='substitute')
|
||||
FoodInheritField.objects.create(name='Substitute Children', field='substitute_children')
|
||||
FoodInheritField.objects.create(name='Substitute Siblings', field='substitute_siblings')
|
||||
|
||||
|
||||
def set_completed_at(apps, schema_editor):
|
||||
today_start = timezone.now().replace(hour=0, minute=0, second=0)
|
||||
# arbitrary - keeping all of the closed shopping list items out of the 'recent' view
|
||||
month_ago = today_start - timedelta(days=30)
|
||||
with scopes_disabled():
|
||||
ShoppingListEntry.objects.filter(checked=True).update(completed_at=month_ago)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0159_add_shoppinglistentry_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(delete_orphaned_sle),
|
||||
migrations.RunPython(create_inheritfields),
|
||||
migrations.RunPython(set_completed_at),
|
||||
]
|
||||
19
cookbook/migrations/0161_alter_shoppinglistentry_food.py
Normal file
19
cookbook/migrations/0161_alter_shoppinglistentry_food.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.2.8 on 2021-11-03 23:19
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0160_delete_shoppinglist_orphans'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='shoppinglistentry',
|
||||
name='food',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shopping_entries', to='cookbook.food'),
|
||||
),
|
||||
]
|
||||
23
cookbook/migrations/0162_userpreference_csv_delim.py
Normal file
23
cookbook/migrations/0162_userpreference_csv_delim.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.9 on 2021-11-30 22:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0161_alter_shoppinglistentry_food'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='csv_delim',
|
||||
field=models.CharField(default=',', max_length=2),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='csv_prefix',
|
||||
field=models.CharField(blank=True, max_length=10),
|
||||
),
|
||||
]
|
||||
41
cookbook/migrations/0163_auto_20220105_0758.py
Normal file
41
cookbook/migrations/0163_auto_20220105_0758.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 3.2.10 on 2022-01-05 13:58
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
from cookbook.models import FoodInheritField
|
||||
|
||||
|
||||
def rename_inherit_field(apps, schema_editor):
|
||||
x = FoodInheritField.objects.filter(name='On Hand', field='food_onhand').first()
|
||||
if x:
|
||||
x.name = "Ignore Shopping"
|
||||
x.field = "ignore_shopping"
|
||||
x.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0162_userpreference_csv_delim'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='food',
|
||||
name='onhand_users',
|
||||
field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='shopping_add_onhand',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='food',
|
||||
old_name='food_onhand',
|
||||
new_name='ignore_shopping',
|
||||
),
|
||||
migrations.RunPython(rename_inherit_field),
|
||||
]
|
||||
18
cookbook/migrations/0164_space_show_facet_count.py
Normal file
18
cookbook/migrations/0164_space_show_facet_count.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.11 on 2022-01-17 22:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0163_auto_20220105_0758'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='show_facet_count',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
17
cookbook/migrations/0165_remove_step_type.py
Normal file
17
cookbook/migrations/0165_remove_step_type.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.2.11 on 2022-01-18 19:19
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0164_space_show_facet_count'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='step',
|
||||
name='type',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 3.2.11 on 2022-01-20 14:39
|
||||
|
||||
from django.db import migrations, models
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def add_default_trigram(apps, schema_editor):
|
||||
with scopes_disabled():
|
||||
UserPreference = apps.get_model('cookbook', 'UserPreference')
|
||||
|
||||
UserPreference.objects.all().update(shopping_add_onhand=False)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('cookbook', '0165_remove_step_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userpreference',
|
||||
name='shopping_add_onhand',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.RunPython(add_default_trigram),
|
||||
]
|
||||
18
cookbook/migrations/0167_userpreference_left_handed.py
Normal file
18
cookbook/migrations/0167_userpreference_left_handed.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.11 on 2022-01-20 22:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0166_alter_userpreference_shopping_add_onhand'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='left_handed',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -2,25 +2,30 @@ import operator
|
||||
import pathlib
|
||||
import re
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from annoying.fields import AutoOneToOneField
|
||||
from django.contrib import auth
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.contrib.postgres.search import SearchVectorField
|
||||
from django.core.files.uploadedfile import UploadedFile, InMemoryUploadedFile
|
||||
from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models, IntegrityError
|
||||
from django.db.models import Index, ProtectedError
|
||||
from django.db import IntegrityError, models
|
||||
from django.db.models import Index, ProtectedError, Q, Subquery
|
||||
from django.db.models.fields.related import ManyToManyField
|
||||
from django.db.models.functions import Substr
|
||||
from django.db.transaction import atomic
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from treebeard.mp_tree import MP_Node, MP_NodeManager
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from django_prometheus.models import ExportModelOperationsMixin
|
||||
from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT,
|
||||
KJ_PREF_DEFAULT, STICKY_NAV_PREF_DEFAULT,
|
||||
SORT_TREE_BY_NAME)
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
from treebeard.mp_tree import MP_Node, MP_NodeManager
|
||||
|
||||
from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT, KJ_PREF_DEFAULT,
|
||||
SORT_TREE_BY_NAME, STICKY_NAV_PREF_DEFAULT)
|
||||
|
||||
|
||||
def get_user_name(self):
|
||||
@@ -30,7 +35,20 @@ def get_user_name(self):
|
||||
return self.username
|
||||
|
||||
|
||||
def get_shopping_share(self):
|
||||
# get list of users that shared shopping list with user. Django ORM forbids this type of query, so raw is required
|
||||
return User.objects.raw(' '.join([
|
||||
'SELECT auth_user.id FROM auth_user',
|
||||
'INNER JOIN cookbook_userpreference',
|
||||
'ON (auth_user.id = cookbook_userpreference.user_id)',
|
||||
'INNER JOIN cookbook_userpreference_shopping_share',
|
||||
'ON (cookbook_userpreference.user_id = cookbook_userpreference_shopping_share.userpreference_id)',
|
||||
'WHERE cookbook_userpreference_shopping_share.user_id ={}'.format(self.id)
|
||||
]))
|
||||
|
||||
|
||||
auth.models.User.add_to_class('get_user_name', get_user_name)
|
||||
auth.models.User.add_to_class('get_shopping_share', get_shopping_share)
|
||||
|
||||
|
||||
def get_model_name(model):
|
||||
@@ -38,15 +56,32 @@ def get_model_name(model):
|
||||
|
||||
|
||||
class TreeManager(MP_NodeManager):
|
||||
def create(self, *args, **kwargs):
|
||||
return self.get_or_create(*args, **kwargs)[0]
|
||||
|
||||
# model.Manager get_or_create() is not compatible with MP_Tree
|
||||
def get_or_create(self, **kwargs):
|
||||
def get_or_create(self, *args, **kwargs):
|
||||
kwargs['name'] = kwargs['name'].strip()
|
||||
try:
|
||||
return self.get(name__exact=kwargs['name'], space=kwargs['space']), False
|
||||
except self.model.DoesNotExist:
|
||||
with scopes_disabled():
|
||||
try:
|
||||
return self.model.add_root(**kwargs), True
|
||||
defaults = kwargs.pop('defaults', None)
|
||||
if defaults:
|
||||
kwargs = {**kwargs, **defaults}
|
||||
# ManyToMany fields can't be set this way, so pop them out to save for later
|
||||
fields = [field.name for field in self.model._meta.get_fields() if issubclass(type(field), ManyToManyField)]
|
||||
many_to_many = {field: kwargs.pop(field) for field in list(kwargs) if field in fields}
|
||||
obj = self.model.add_root(**kwargs)
|
||||
for field in many_to_many:
|
||||
field_model = getattr(obj, field).model
|
||||
for related_obj in many_to_many[field]:
|
||||
if isinstance(related_obj, User):
|
||||
getattr(obj, field).add(field_model.objects.get(id=related_obj.id))
|
||||
else:
|
||||
getattr(obj, field).add(field_model.objects.get(**dict(related_obj)))
|
||||
return obj, True
|
||||
except IntegrityError as e:
|
||||
if 'Key (path)' in e.args[0]:
|
||||
self.model.fix_tree(fix_paths=True)
|
||||
@@ -62,6 +97,13 @@ class TreeModel(MP_Node):
|
||||
else:
|
||||
return f"{self.name}"
|
||||
|
||||
# MP_Tree move uses raw SQL to execute move, override behavior to force a save triggering post_save signal
|
||||
def move(self, *args, **kwargs):
|
||||
super().move(*args, **kwargs)
|
||||
# treebeard bypasses ORM, need to retrieve the object again to avoid writing previous state back to disk
|
||||
obj = self.__class__.objects.get(id=self.id)
|
||||
obj.save()
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
parent = self.get_parent()
|
||||
@@ -108,6 +150,48 @@ class TreeModel(MP_Node):
|
||||
with scopes_disabled():
|
||||
return super().add_root(**kwargs)
|
||||
|
||||
# i'm 99% sure there is a more idiomatic way to do this subclassing MP_NodeQuerySet
|
||||
@staticmethod
|
||||
def include_descendants(queryset=None, filter=None):
|
||||
"""
|
||||
:param queryset: Model Queryset to add descendants
|
||||
:param filter: Filter (exclude) the descendants nodes with the provided Q filter
|
||||
"""
|
||||
descendants = Q()
|
||||
# TODO filter the queryset nodes to exclude descendants of objects in the queryset
|
||||
nodes = queryset.values('path', 'depth')
|
||||
for node in nodes:
|
||||
descendants |= Q(path__startswith=node['path'], depth__gt=node['depth'])
|
||||
|
||||
return queryset.model.objects.filter(Q(id__in=queryset.values_list('id')) | descendants)
|
||||
|
||||
def exclude_descendants(queryset=None, filter=None):
|
||||
"""
|
||||
:param queryset: Model Queryset to add descendants
|
||||
:param filter: Filter (include) the descendants nodes with the provided Q filter
|
||||
"""
|
||||
descendants = Q()
|
||||
# TODO filter the queryset nodes to exclude descendants of objects in the queryset
|
||||
nodes = queryset.values('path', 'depth')
|
||||
for node in nodes:
|
||||
descendants |= Q(path__startswith=node['path'], depth__gt=node['depth'])
|
||||
|
||||
return queryset.model.objects.filter(id__in=queryset.values_list('id')).exclude(descendants)
|
||||
|
||||
def include_ancestors(queryset=None):
|
||||
"""
|
||||
:param queryset: Model Queryset to add ancestors
|
||||
:param filter: Filter (include) the ancestors nodes with the provided Q filter
|
||||
"""
|
||||
|
||||
queryset = queryset.annotate(root=Substr('path', 1, queryset.model.steplen))
|
||||
nodes = list(set(queryset.values_list('root', 'depth')))
|
||||
|
||||
ancestors = Q()
|
||||
for node in nodes:
|
||||
ancestors |= Q(path__startswith=node[0], depth__lt=node[1])
|
||||
return queryset.model.objects.filter(Q(id__in=queryset.values_list('id')) | ancestors)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@@ -141,6 +225,18 @@ class PermissionModelMixin:
|
||||
raise NotImplementedError('get space for method not implemented and standard fields not available')
|
||||
|
||||
|
||||
class FoodInheritField(models.Model, PermissionModelMixin):
|
||||
field = models.CharField(max_length=32, unique=True)
|
||||
name = models.CharField(max_length=64, unique=True)
|
||||
|
||||
def __str__(self):
|
||||
return _(self.name)
|
||||
|
||||
@staticmethod
|
||||
def get_name(self):
|
||||
return _(self.name)
|
||||
|
||||
|
||||
class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
name = models.CharField(max_length=128, default='Default')
|
||||
created_by = models.ForeignKey(User, on_delete=models.PROTECT, null=True)
|
||||
@@ -151,6 +247,8 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
max_users = models.IntegerField(default=0)
|
||||
allow_sharing = models.BooleanField(default=True)
|
||||
demo = models.BooleanField(default=False)
|
||||
food_inherit = models.ManyToManyField(FoodInheritField, blank=True)
|
||||
show_facet_count = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -229,10 +327,23 @@ class UserPreference(models.Model, PermissionModelMixin):
|
||||
plan_share = models.ManyToManyField(
|
||||
User, blank=True, related_name='plan_share_default'
|
||||
)
|
||||
shopping_share = models.ManyToManyField(
|
||||
User, blank=True, related_name='shopping_share'
|
||||
)
|
||||
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)
|
||||
mealplan_autoadd_shopping = models.BooleanField(default=False)
|
||||
mealplan_autoexclude_onhand = models.BooleanField(default=True)
|
||||
mealplan_autoinclude_related = models.BooleanField(default=True)
|
||||
shopping_add_onhand = models.BooleanField(default=False)
|
||||
filter_to_supermarket = models.BooleanField(default=False)
|
||||
left_handed = models.BooleanField(default=False)
|
||||
default_delay = models.DecimalField(default=4, max_digits=8, decimal_places=4)
|
||||
shopping_recent_days = models.PositiveIntegerField(default=7)
|
||||
csv_delim = models.CharField(max_length=2, default=",")
|
||||
csv_prefix = models.CharField(max_length=10, blank=True,)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True)
|
||||
@@ -347,8 +458,8 @@ class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelM
|
||||
name = models.CharField(max_length=64)
|
||||
icon = models.CharField(max_length=16, blank=True, null=True)
|
||||
description = models.TextField(default="", blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True) # TODO deprecate
|
||||
updated_at = models.DateTimeField(auto_now=True) # TODO deprecate
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space', _manager_class=TreeManager)
|
||||
@@ -377,13 +488,19 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
|
||||
|
||||
|
||||
class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||
# exclude fields not implemented yet
|
||||
inheritable_fields = FoodInheritField.objects.exclude(field__in=['diet', 'substitute', 'substitute_children', 'substitute_siblings'])
|
||||
|
||||
# WARNING: Food inheritance relies on post_save signals, avoid using UPDATE to update Food objects unless you intend to bypass those signals
|
||||
if SORT_TREE_BY_NAME:
|
||||
node_order_by = ['name']
|
||||
name = models.CharField(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)
|
||||
supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL) # inherited field
|
||||
ignore_shopping = models.BooleanField(default=False) # inherited field
|
||||
onhand_users = models.ManyToManyField(User, blank=True)
|
||||
description = models.TextField(default='', blank=True)
|
||||
inherit_fields = models.ManyToManyField(FoodInheritField, blank=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space', _manager_class=TreeManager)
|
||||
@@ -397,6 +514,35 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||
else:
|
||||
return super().delete()
|
||||
|
||||
@staticmethod
|
||||
def reset_inheritance(space=None):
|
||||
# resets inherited fields to the space defaults and updates all inherited fields to root object values
|
||||
inherit = space.food_inherit.all()
|
||||
|
||||
# remove all inherited fields from food
|
||||
Through = Food.objects.filter(space=space).first().inherit_fields.through
|
||||
Through.objects.all().delete()
|
||||
# food is going to inherit attributes
|
||||
if space.food_inherit.all().count() > 0:
|
||||
# ManyToMany cannot be updated through an UPDATE operation
|
||||
for i in inherit:
|
||||
Through.objects.bulk_create([
|
||||
Through(food_id=x, foodinheritfield_id=i.id)
|
||||
for x in Food.objects.filter(space=space).values_list('id', flat=True)
|
||||
])
|
||||
|
||||
inherit = inherit.values_list('field', flat=True)
|
||||
if 'ignore_shopping' in inherit:
|
||||
# get food at root that have children that need updated
|
||||
Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, space=space, ignore_shopping=True)).update(ignore_shopping=True)
|
||||
Food.include_descendants(queryset=Food.objects.filter(depth=1, numchild__gt=0, space=space, ignore_shopping=False)).update(ignore_shopping=False)
|
||||
if 'supermarket_category' in inherit:
|
||||
# when supermarket_category is null or blank assuming it is not set and not intended to be blank for all descedants
|
||||
# find top node that has category set
|
||||
category_roots = Food.exclude_descendants(queryset=Food.objects.filter(supermarket_category__isnull=False, numchild__gt=0, space=space))
|
||||
for root in category_roots:
|
||||
root.get_descendants().update(supermarket_category=root.supermarket_category)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='f_unique_name_per_space')
|
||||
@@ -431,17 +577,7 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
|
||||
|
||||
|
||||
class Step(ExportModelOperationsMixin('step'), models.Model, PermissionModelMixin):
|
||||
TEXT = 'TEXT'
|
||||
TIME = 'TIME'
|
||||
FILE = 'FILE'
|
||||
RECIPE = 'RECIPE'
|
||||
|
||||
name = models.CharField(max_length=128, default='', blank=True)
|
||||
type = models.CharField(
|
||||
choices=((TEXT, _('Text')), (TIME, _('Time')), (FILE, _('File')), (RECIPE, _('Recipe')),),
|
||||
default=TEXT,
|
||||
max_length=16
|
||||
)
|
||||
instruction = models.TextField(blank=True)
|
||||
ingredients = models.ManyToManyField(Ingredient, blank=True)
|
||||
time = models.IntegerField(default=0, blank=True)
|
||||
@@ -473,9 +609,7 @@ class NutritionInformation(models.Model, PermissionModelMixin):
|
||||
)
|
||||
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
|
||||
)
|
||||
source = models.CharField( max_length=512, default="", null=True, blank=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
@@ -484,6 +618,15 @@ class NutritionInformation(models.Model, PermissionModelMixin):
|
||||
return f'Nutrition {self.pk}'
|
||||
|
||||
|
||||
# class NutritionType(models.Model, PermissionModelMixin):
|
||||
# name = models.CharField(max_length=128)
|
||||
# icon = models.CharField(max_length=16, blank=True, null=True)
|
||||
# description = models.CharField(max_length=512, blank=True, null=True)
|
||||
#
|
||||
# space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
# objects = ScopedManager(space='space')
|
||||
|
||||
|
||||
class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128)
|
||||
description = models.CharField(max_length=512, blank=True, null=True)
|
||||
@@ -518,6 +661,21 @@ class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModel
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_related_recipes(self, levels=1):
|
||||
# recipes for step recipe
|
||||
step_recipes = Q(id__in=self.steps.exclude(step_recipe=None).values_list('step_recipe'))
|
||||
# recipes for foods
|
||||
food_recipes = Q(id__in=Food.objects.filter(ingredient__step__recipe=self).exclude(recipe=None).values_list('recipe'))
|
||||
related_recipes = Recipe.objects.filter(step_recipes | food_recipes)
|
||||
if levels == 1:
|
||||
return related_recipes
|
||||
|
||||
# this can loop over multiple levels if you update the value of related_recipes at each step (maybe an array?)
|
||||
# for now keeping it at 2 levels max, should be sufficient in 99.9% of scenarios
|
||||
sub_step_recipes = Q(id__in=Step.objects.filter(recipe__in=related_recipes.values_list('steps')).exclude(step_recipe=None).values_list('step_recipe'))
|
||||
sub_food_recipes = Q(id__in=Food.objects.filter(ingredient__step__recipe__in=related_recipes).exclude(recipe=None).values_list('recipe'))
|
||||
return Recipe.objects.filter(Q(id__in=related_recipes.values_list('id')) | sub_step_recipes | sub_food_recipes)
|
||||
|
||||
class Meta():
|
||||
indexes = (
|
||||
GinIndex(fields=["name_search_vector"]),
|
||||
@@ -644,8 +802,10 @@ class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, Permission
|
||||
|
||||
|
||||
class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, null=True, blank=True)
|
||||
name = models.CharField(max_length=32, blank=True, default='')
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, null=True, blank=True) # TODO make required after old shoppinglist deprecated
|
||||
servings = models.DecimalField(default=1, max_digits=8, decimal_places=4)
|
||||
mealplan = models.ForeignKey(MealPlan, on_delete=models.CASCADE, null=True, blank=True)
|
||||
|
||||
objects = ScopedManager(space='recipe__space')
|
||||
|
||||
@@ -661,20 +821,26 @@ class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), mod
|
||||
|
||||
def get_owner(self):
|
||||
try:
|
||||
return self.shoppinglist_set.first().created_by
|
||||
return getattr(self.entries.first(), 'created_by', None) or getattr(self.shoppinglist_set.first(), 'created_by', None)
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
|
||||
class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), models.Model, PermissionModelMixin):
|
||||
list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True)
|
||||
food = models.ForeignKey(Food, on_delete=models.CASCADE)
|
||||
unit = models.ForeignKey(Unit, on_delete=models.CASCADE, null=True, blank=True)
|
||||
list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True, related_name='entries')
|
||||
food = models.ForeignKey(Food, on_delete=models.CASCADE, related_name='shopping_entries')
|
||||
unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
ingredient = models.ForeignKey(Ingredient, on_delete=models.CASCADE, null=True, blank=True)
|
||||
amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||
order = models.IntegerField(default=0)
|
||||
checked = models.BooleanField(default=False)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
delay_until = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
objects = ScopedManager(space='shoppinglist__space')
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
@staticmethod
|
||||
def get_space_key():
|
||||
@@ -686,12 +852,14 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model
|
||||
def __str__(self):
|
||||
return f'Shopping list entry {self.id}'
|
||||
|
||||
# TODO deprecate
|
||||
def get_shared(self):
|
||||
return self.shoppinglist_set.first().shared.all()
|
||||
|
||||
# TODO deprecate
|
||||
def get_owner(self):
|
||||
try:
|
||||
return self.shoppinglist_set.first().created_by
|
||||
return self.created_by or self.shoppinglist_set.first().created_by
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
|
||||
@@ -29,7 +29,11 @@ class Nextcloud(Provider):
|
||||
client = Nextcloud.get_client(monitor.storage)
|
||||
|
||||
files = client.list(monitor.path)
|
||||
files.pop(0) # remove first element because its the folder itself
|
||||
|
||||
try:
|
||||
files.pop(0) # remove first element because its the folder itself
|
||||
except IndexError:
|
||||
pass # folder is empty, no recipes will be imported
|
||||
|
||||
import_count = 0
|
||||
for file in files:
|
||||
|
||||
@@ -2,78 +2,29 @@ from rest_framework.schemas.openapi import AutoSchema
|
||||
from rest_framework.schemas.utils import is_list_view
|
||||
|
||||
|
||||
# TODO move to separate class to cleanup
|
||||
class RecipeSchema(AutoSchema):
|
||||
class QueryParam(object):
|
||||
def __init__(self, name, description=None, qtype='string', required=False):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.qtype = qtype
|
||||
self.required = required
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name}, {self.qtype}, {self.description}'
|
||||
|
||||
|
||||
class QueryParamAutoSchema(AutoSchema):
|
||||
def get_path_parameters(self, path, method):
|
||||
if not is_list_view(path, method, self.view):
|
||||
return super(RecipeSchema, self).get_path_parameters(path, method)
|
||||
|
||||
return super().get_path_parameters(path, method)
|
||||
parameters = super().get_path_parameters(path, method)
|
||||
parameters.append({
|
||||
"name": 'query', "in": "query", "required": False,
|
||||
"description": 'Query string matched (fuzzy) against recipe name. In the future also fulltext search.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'keywords', "in": "query", "required": False,
|
||||
"description": 'Id of keyword a recipe should have. For multiple repeat parameter.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'foods', "in": "query", "required": False,
|
||||
"description": 'Id of food a recipe should have. For multiple repeat parameter.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'units', "in": "query", "required": False,
|
||||
"description": 'Id of unit a recipe should have.',
|
||||
'schema': {'type': 'int', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'rating', "in": "query", "required": False,
|
||||
"description": 'Id of unit a recipe should have.',
|
||||
'schema': {'type': 'int', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'books', "in": "query", "required": False,
|
||||
"description": 'Id of book a recipe should have. For multiple repeat parameter.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'steps', "in": "query", "required": False,
|
||||
"description": 'Id of a step a recipe should have. For multiple repeat parameter.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'keywords_or', "in": "query", "required": False,
|
||||
"description": 'If recipe should have all (AND) or any (OR) of the provided keywords.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'foods_or', "in": "query", "required": False,
|
||||
"description": 'If recipe should have all (AND) or any (OR) any of the provided foods.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'books_or', "in": "query", "required": False,
|
||||
"description": 'If recipe should be in all (AND) or any (OR) any of the provided books.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'internal', "in": "query", "required": False,
|
||||
"description": 'true or false. If only internal recipes should be returned or not.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'random', "in": "query", "required": False,
|
||||
"description": 'true or false. returns the results in randomized order.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'new', "in": "query", "required": False,
|
||||
"description": 'true or false. returns new results first in search results',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
for q in self.view.query_params:
|
||||
parameters.append({
|
||||
"name": q.name, "in": "query", "required": q.required,
|
||||
"description": q.description,
|
||||
'schema': {'type': q.qtype, },
|
||||
})
|
||||
|
||||
return parameters
|
||||
|
||||
|
||||
@@ -118,15 +69,15 @@ class FilterSchema(AutoSchema):
|
||||
return parameters
|
||||
|
||||
|
||||
class QueryOnlySchema(AutoSchema):
|
||||
def get_path_parameters(self, path, method):
|
||||
if not is_list_view(path, method, self.view):
|
||||
return super(QueryOnlySchema, self).get_path_parameters(path, method)
|
||||
# class QueryOnlySchema(AutoSchema):
|
||||
# def get_path_parameters(self, path, method):
|
||||
# if not is_list_view(path, method, self.view):
|
||||
# return super(QueryOnlySchema, self).get_path_parameters(path, method)
|
||||
|
||||
parameters = super().get_path_parameters(path, method)
|
||||
parameters.append({
|
||||
"name": 'query', "in": "query", "required": False,
|
||||
"description": 'Query string matched (fuzzy) against object name.',
|
||||
'schema': {'type': 'string', },
|
||||
})
|
||||
return parameters
|
||||
# parameters = super().get_path_parameters(path, method)
|
||||
# parameters.append({
|
||||
# "name": 'query', "in": "query", "required": False,
|
||||
# "description": 'Query string matched (fuzzy) against object name.',
|
||||
# 'schema': {'type': 'string', },
|
||||
# })
|
||||
# return parameters
|
||||
|
||||
@@ -10,21 +10,30 @@ from django.utils import timezone
|
||||
from drf_writable_nested import UniqueFieldsMixin, WritableNestedModelSerializer
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
from rest_framework.fields import empty
|
||||
|
||||
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food, ImportLog,
|
||||
Ingredient, Keyword, MealPlan, MealType, NutritionInformation, Recipe,
|
||||
RecipeBook, RecipeBookEntry, RecipeImport, ShareLink, ShoppingList,
|
||||
ShoppingListEntry, ShoppingListRecipe, Step, Storage, Supermarket,
|
||||
SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, Unit,
|
||||
UserFile, UserPreference, ViewLog)
|
||||
from cookbook.helper.HelperFunctions import str2bool
|
||||
from cookbook.helper.shopping_helper import list_from_recipe
|
||||
from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Food,
|
||||
FoodInheritField, ImportLog, Ingredient, Keyword, MealPlan, MealType,
|
||||
NutritionInformation, Recipe, RecipeBook, RecipeBookEntry,
|
||||
RecipeImport, ShareLink, ShoppingList, ShoppingListEntry,
|
||||
ShoppingListRecipe, Step, Storage, Supermarket, SupermarketCategory,
|
||||
SupermarketCategoryRelation, Sync, SyncLog, Unit, UserFile,
|
||||
UserPreference, ViewLog)
|
||||
from cookbook.templatetags.custom_tags import markdown
|
||||
from recipes.settings import MEDIA_URL
|
||||
|
||||
|
||||
class ExtendedRecipeMixin(serializers.ModelSerializer):
|
||||
# adds image and recipe count to serializer when query param extended=1
|
||||
image = serializers.SerializerMethodField('get_image')
|
||||
numrecipe = serializers.SerializerMethodField('count_recipes')
|
||||
# ORM path to this object from Recipe
|
||||
recipe_filter = None
|
||||
# list of ORM paths to any image
|
||||
images = None
|
||||
|
||||
image = serializers.SerializerMethodField('get_image')
|
||||
numrecipe = serializers.ReadOnlyField(source='count_recipes_test')
|
||||
|
||||
def get_fields(self, *args, **kwargs):
|
||||
fields = super().get_fields(*args, **kwargs)
|
||||
@@ -34,12 +43,9 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
|
||||
api_serializer = None
|
||||
# extended values are computationally expensive and not needed in normal circumstances
|
||||
try:
|
||||
if bool(int(
|
||||
self.context['request'].query_params.get('extended', False))) and self.__class__ == api_serializer:
|
||||
if str2bool(self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer:
|
||||
return fields
|
||||
except AttributeError:
|
||||
pass
|
||||
except KeyError:
|
||||
except (AttributeError, KeyError) as e:
|
||||
pass
|
||||
try:
|
||||
del fields['image']
|
||||
@@ -49,21 +55,8 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
|
||||
return fields
|
||||
|
||||
def get_image(self, obj):
|
||||
# TODO add caching
|
||||
recipes = Recipe.objects.filter(**{self.recipe_filter: obj}, space=obj.space).exclude(
|
||||
image__isnull=True).exclude(image__exact='')
|
||||
try:
|
||||
if recipes.count() == 0 and obj.has_children():
|
||||
obj__in = self.recipe_filter + '__in'
|
||||
recipes = Recipe.objects.filter(**{obj__in: obj.get_descendants()}, space=obj.space).exclude(
|
||||
image__isnull=True).exclude(image__exact='') # if no recipes found - check whole tree
|
||||
except AttributeError:
|
||||
# probably not a tree
|
||||
pass
|
||||
if recipes.count() != 0:
|
||||
return random.choice(recipes).image.url
|
||||
else:
|
||||
return None
|
||||
if obj.recipe_image:
|
||||
return MEDIA_URL + obj.recipe_image
|
||||
|
||||
def count_recipes(self, obj):
|
||||
return Recipe.objects.filter(**{self.recipe_filter: obj}, space=obj.space).count()
|
||||
@@ -92,9 +85,27 @@ class CustomDecimalField(serializers.Field):
|
||||
raise ValidationError('A valid number is required')
|
||||
|
||||
|
||||
class CustomOnHandField(serializers.Field):
|
||||
def get_attribute(self, instance):
|
||||
return instance
|
||||
|
||||
def to_representation(self, obj):
|
||||
shared_users = None
|
||||
if request := self.context.get('request', None):
|
||||
shared_users = getattr(request, '_shared_users', None)
|
||||
if shared_users is None:
|
||||
shared_users = [x.id for x in list(self.context['request'].user.get_shopping_share())] + [self.context['request'].user.id]
|
||||
return obj.onhand_users.filter(id__in=shared_users).exists()
|
||||
|
||||
def to_internal_value(self, data):
|
||||
return data
|
||||
|
||||
|
||||
class SpaceFilterSerializer(serializers.ListSerializer):
|
||||
|
||||
def to_representation(self, data):
|
||||
if self.context.get('request', None) is None:
|
||||
return
|
||||
if (type(data) == QuerySet and data.query.is_sliced):
|
||||
# if query is sliced it came from api request not nested serializer
|
||||
return super().to_representation(data)
|
||||
@@ -136,19 +147,43 @@ class UserNameSerializer(WritableNestedModelSerializer):
|
||||
fields = ('id', 'username')
|
||||
|
||||
|
||||
class UserPreferenceSerializer(serializers.ModelSerializer):
|
||||
class FoodInheritFieldSerializer(WritableNestedModelSerializer):
|
||||
name = serializers.CharField(allow_null=True, allow_blank=True, required=False)
|
||||
field = serializers.CharField(allow_null=True, allow_blank=True, required=False)
|
||||
|
||||
def create(self, validated_data):
|
||||
if validated_data['user'] != self.context['request'].user:
|
||||
# don't allow writing to FoodInheritField via API
|
||||
return FoodInheritField.objects.get(**validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# don't allow writing to FoodInheritField via API
|
||||
return FoodInheritField.objects.get(**validated_data)
|
||||
|
||||
class Meta:
|
||||
model = FoodInheritField
|
||||
fields = ('id', 'name', 'field', )
|
||||
read_only_fields = ['id']
|
||||
|
||||
|
||||
class UserPreferenceSerializer(WritableNestedModelSerializer):
|
||||
food_inherit_default = FoodInheritFieldSerializer(source='space.food_inherit', many=True, allow_null=True, required=False, read_only=True)
|
||||
plan_share = UserNameSerializer(many=True, allow_null=True, required=False, read_only=True)
|
||||
shopping_share = UserNameSerializer(many=True, allow_null=True, required=False)
|
||||
|
||||
def create(self, validated_data):
|
||||
if not validated_data.get('user', None):
|
||||
raise ValidationError(_('A user is required'))
|
||||
if (validated_data['user'] != self.context['request'].user):
|
||||
raise NotFound()
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = UserPreference
|
||||
fields = (
|
||||
'user', 'theme', 'nav_color', 'default_unit', 'default_page',
|
||||
'search_style', 'show_recent', 'plan_share', 'ingredient_decimals',
|
||||
'comments'
|
||||
'user', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_kj', 'search_style', 'show_recent', 'plan_share',
|
||||
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'food_inherit_default', 'default_delay',
|
||||
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days', 'csv_delim', 'csv_prefix',
|
||||
'filter_to_supermarket', 'shopping_add_onhand', 'left_handed'
|
||||
)
|
||||
|
||||
|
||||
@@ -254,25 +289,11 @@ class KeywordLabelSerializer(serializers.ModelSerializer):
|
||||
|
||||
class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
||||
label = serializers.SerializerMethodField('get_label')
|
||||
# image = serializers.SerializerMethodField('get_image')
|
||||
# numrecipe = serializers.SerializerMethodField('count_recipes')
|
||||
recipe_filter = 'keywords'
|
||||
|
||||
def get_label(self, obj):
|
||||
return str(obj)
|
||||
|
||||
# def get_image(self, obj):
|
||||
# recipes = obj.recipe_set.all().filter(space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
|
||||
# if recipes.count() == 0 and obj.has_children():
|
||||
# recipes = Recipe.objects.filter(keywords__in=obj.get_descendants(), space=obj.space).exclude(image__isnull=True).exclude(image__exact='') # if no recipes found - check whole tree
|
||||
# if recipes.count() != 0:
|
||||
# return random.choice(recipes).image.url
|
||||
# else:
|
||||
# return None
|
||||
|
||||
# def count_recipes(self, obj):
|
||||
# return obj.recipe_set.filter(space=self.context['request'].space).all().count()
|
||||
|
||||
def create(self, validated_data):
|
||||
# since multi select tags dont have id's
|
||||
# duplicate names might be routed to create
|
||||
@@ -285,26 +306,13 @@ class KeywordSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
||||
model = Keyword
|
||||
fields = (
|
||||
'id', 'name', 'icon', 'label', 'description', 'image', 'parent', 'numchild', 'numrecipe', 'created_at',
|
||||
'updated_at')
|
||||
read_only_fields = ('id', 'numchild', 'parent', 'image')
|
||||
'updated_at', 'full_name')
|
||||
read_only_fields = ('id', 'label', 'numchild', 'parent', 'image')
|
||||
|
||||
|
||||
class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin):
|
||||
# image = serializers.SerializerMethodField('get_image')
|
||||
# numrecipe = serializers.SerializerMethodField('count_recipes')
|
||||
recipe_filter = 'steps__ingredients__unit'
|
||||
|
||||
# def get_image(self, obj):
|
||||
# recipes = Recipe.objects.filter(steps__ingredients__unit=obj, space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
|
||||
|
||||
# if recipes.count() != 0:
|
||||
# return random.choice(recipes).image.url
|
||||
# else:
|
||||
# return None
|
||||
|
||||
# def count_recipes(self, obj):
|
||||
# return Recipe.objects.filter(steps__ingredients__unit=obj, space=obj.space).count()
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['name'] = validated_data['name'].strip()
|
||||
validated_data['space'] = self.context['request'].space
|
||||
@@ -368,27 +376,16 @@ class RecipeSimpleSerializer(serializers.ModelSerializer):
|
||||
class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedRecipeMixin):
|
||||
supermarket_category = SupermarketCategorySerializer(allow_null=True, required=False)
|
||||
recipe = RecipeSimpleSerializer(allow_null=True, required=False)
|
||||
# image = serializers.SerializerMethodField('get_image')
|
||||
# numrecipe = serializers.SerializerMethodField('count_recipes')
|
||||
# shopping = serializers.SerializerMethodField('get_shopping_status')
|
||||
shopping = serializers.ReadOnlyField(source='shopping_status')
|
||||
inherit_fields = FoodInheritFieldSerializer(many=True, allow_null=True, required=False)
|
||||
food_onhand = CustomOnHandField(required=False, allow_null=True)
|
||||
|
||||
recipe_filter = 'steps__ingredients__food'
|
||||
images = ['recipe__image']
|
||||
|
||||
# def get_image(self, obj):
|
||||
# if obj.recipe and obj.space == obj.recipe.space:
|
||||
# if obj.recipe.image and obj.recipe.image != '':
|
||||
# return obj.recipe.image.url
|
||||
# # if food is not also a recipe, look for recipe images that use the food
|
||||
# recipes = Recipe.objects.filter(steps__ingredients__food=obj, space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
|
||||
# # if no recipes found - check whole tree
|
||||
# if recipes.count() == 0 and obj.has_children():
|
||||
# recipes = Recipe.objects.filter(steps__ingredients__food__in=obj.get_descendants(), space=obj.space).exclude(image__isnull=True).exclude(image__exact='')
|
||||
|
||||
# if recipes.count() != 0:
|
||||
# return random.choice(recipes).image.url
|
||||
# else:
|
||||
# return None
|
||||
|
||||
# def count_recipes(self, obj):
|
||||
# return Recipe.objects.filter(steps__ingredients__food=obj, space=obj.space).count()
|
||||
# def get_shopping_status(self, obj):
|
||||
# return ShoppingListEntry.objects.filter(space=obj.space, food=obj, checked=False).count() > 0
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['name'] = validated_data['name'].strip()
|
||||
@@ -398,20 +395,43 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
||||
validated_data['supermarket_category'], sc_created = SupermarketCategory.objects.get_or_create(
|
||||
name=validated_data.pop('supermarket_category')['name'],
|
||||
space=self.context['request'].space)
|
||||
onhand = validated_data.pop('food_onhand', None)
|
||||
|
||||
# assuming if on hand for user also onhand for shopping_share users
|
||||
if not onhand is None:
|
||||
shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all())
|
||||
if self.instance:
|
||||
onhand_users = self.instance.onhand_users.all()
|
||||
else:
|
||||
onhand_users = []
|
||||
if onhand:
|
||||
validated_data['onhand_users'] = list(onhand_users) + shared_users
|
||||
else:
|
||||
validated_data['onhand_users'] = list(set(onhand_users) - set(shared_users))
|
||||
|
||||
obj, created = Food.objects.get_or_create(**validated_data)
|
||||
return obj
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
validated_data['name'] = validated_data['name'].strip()
|
||||
if name := validated_data.get('name', None):
|
||||
validated_data['name'] = name.strip()
|
||||
# assuming if on hand for user also onhand for shopping_share users
|
||||
onhand = validated_data.get('food_onhand', None)
|
||||
if not onhand is None:
|
||||
shared_users = [user := self.context['request'].user] + list(user.userpreference.shopping_share.all())
|
||||
if onhand:
|
||||
validated_data['onhand_users'] = list(self.instance.onhand_users.all()) + shared_users
|
||||
else:
|
||||
validated_data['onhand_users'] = list(set(self.instance.onhand_users.all()) - set(shared_users))
|
||||
return super(FoodSerializer, self).update(instance, validated_data)
|
||||
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = (
|
||||
'id', 'name', 'description', 'recipe', 'ignore_shopping', 'supermarket_category', 'image', 'parent',
|
||||
'numchild',
|
||||
'numrecipe')
|
||||
read_only_fields = ('id', 'numchild', 'parent', 'image')
|
||||
'id', 'name', 'description', 'shopping', 'recipe', 'food_onhand', 'supermarket_category',
|
||||
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name'
|
||||
)
|
||||
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
|
||||
|
||||
|
||||
class IngredientSerializer(WritableNestedModelSerializer):
|
||||
@@ -456,12 +476,12 @@ class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
|
||||
# check if root type is recipe to prevent infinite recursion
|
||||
# can be improved later to allow multi level embedding
|
||||
if obj.step_recipe and type(self.parent.root) == RecipeSerializer:
|
||||
return StepRecipeSerializer(obj.step_recipe).data
|
||||
return StepRecipeSerializer(obj.step_recipe, context={'request': self.context['request']}).data
|
||||
|
||||
class Meta:
|
||||
model = Step
|
||||
fields = (
|
||||
'id', 'name', 'type', 'instruction', 'ingredients', 'ingredients_markdown',
|
||||
'id', 'name', 'instruction', 'ingredients', 'ingredients_markdown',
|
||||
'ingredients_vue', 'time', 'order', 'show_as_header', 'file', 'step_recipe', 'step_recipe_data', 'numrecipe'
|
||||
)
|
||||
|
||||
@@ -477,6 +497,10 @@ class StepRecipeSerializer(WritableNestedModelSerializer):
|
||||
|
||||
|
||||
class NutritionInformationSerializer(serializers.ModelSerializer):
|
||||
carbohydrates = CustomDecimalField()
|
||||
fats = CustomDecimalField()
|
||||
proteins = CustomDecimalField()
|
||||
calories = CustomDecimalField()
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['space'] = self.context['request'].space
|
||||
@@ -500,7 +524,7 @@ class RecipeBaseSerializer(WritableNestedModelSerializer):
|
||||
|
||||
def get_recipe_last_cooked(self, obj):
|
||||
try:
|
||||
last = obj.cooklog_set.filter(created_by=self.context['request'].user).last()
|
||||
last = obj.cooklog_set.filter(created_by=self.context['request'].user).order_by('created_at').last()
|
||||
if last:
|
||||
return last.created_at
|
||||
except TypeError:
|
||||
@@ -509,7 +533,7 @@ class RecipeBaseSerializer(WritableNestedModelSerializer):
|
||||
|
||||
# TODO make days of new recipe a setting
|
||||
def is_recipe_new(self, obj):
|
||||
if obj.created_at > (timezone.now() - timedelta(days=7)):
|
||||
if getattr(obj, 'new_recipe', None) or obj.created_at > (timezone.now() - timedelta(days=7)):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
@@ -520,6 +544,7 @@ class RecipeOverviewSerializer(RecipeBaseSerializer):
|
||||
rating = serializers.SerializerMethodField('get_recipe_rating')
|
||||
last_cooked = serializers.SerializerMethodField('get_recipe_last_cooked')
|
||||
new = serializers.SerializerMethodField('is_recipe_new')
|
||||
recent = serializers.ReadOnlyField()
|
||||
|
||||
def create(self, validated_data):
|
||||
pass
|
||||
@@ -532,7 +557,7 @@ class RecipeOverviewSerializer(RecipeBaseSerializer):
|
||||
fields = (
|
||||
'id', 'name', 'description', 'image', 'keywords', 'working_time',
|
||||
'waiting_time', 'created_by', 'created_at', 'updated_at',
|
||||
'internal', 'servings', 'servings_text', 'rating', 'last_cooked', 'new'
|
||||
'internal', 'servings', 'servings_text', 'rating', 'last_cooked', 'new', 'recent'
|
||||
)
|
||||
read_only_fields = ['image', 'created_by', 'created_at']
|
||||
|
||||
@@ -620,52 +645,136 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
||||
meal_type_name = serializers.ReadOnlyField(source='meal_type.name') # TODO deprecate once old meal plan was removed
|
||||
note_markdown = serializers.SerializerMethodField('get_note_markdown')
|
||||
servings = CustomDecimalField()
|
||||
shared = UserNameSerializer(many=True, required=False, allow_null=True)
|
||||
shopping = serializers.SerializerMethodField('in_shopping')
|
||||
|
||||
def get_note_markdown(self, obj):
|
||||
return markdown(obj.note)
|
||||
|
||||
def in_shopping(self, obj):
|
||||
return ShoppingListRecipe.objects.filter(mealplan=obj.id).exists()
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
mealplan = super().create(validated_data)
|
||||
if self.context['request'].data.get('addshopping', False):
|
||||
list_from_recipe(mealplan=mealplan, servings=validated_data['servings'], created_by=validated_data['created_by'], space=validated_data['space'])
|
||||
return mealplan
|
||||
|
||||
class Meta:
|
||||
model = MealPlan
|
||||
fields = (
|
||||
'id', 'title', 'recipe', 'servings', 'note', 'note_markdown',
|
||||
'date', 'meal_type', 'created_by', 'shared', 'recipe_name',
|
||||
'meal_type_name'
|
||||
'meal_type_name', 'shopping'
|
||||
)
|
||||
read_only_fields = ('created_by',)
|
||||
|
||||
|
||||
# TODO deprecate
|
||||
class ShoppingListRecipeSerializer(serializers.ModelSerializer):
|
||||
name = serializers.SerializerMethodField('get_name') # should this be done at the front end?
|
||||
recipe_name = serializers.ReadOnlyField(source='recipe.name')
|
||||
mealplan_note = serializers.ReadOnlyField(source='mealplan.note')
|
||||
servings = CustomDecimalField()
|
||||
|
||||
def get_name(self, obj):
|
||||
if not isinstance(value := obj.servings, Decimal):
|
||||
value = Decimal(value)
|
||||
value = value.quantize(Decimal(1)) if value == value.to_integral() else value.normalize() # strips trailing zero
|
||||
return (
|
||||
obj.name
|
||||
or getattr(obj.mealplan, 'title', None)
|
||||
or (d := getattr(obj.mealplan, 'date', None)) and ': '.join([obj.mealplan.recipe.name, str(d)])
|
||||
or obj.recipe.name
|
||||
) + f' ({value:.2g})'
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if 'servings' in validated_data:
|
||||
list_from_recipe(
|
||||
list_recipe=instance,
|
||||
servings=validated_data['servings'],
|
||||
created_by=self.context['request'].user,
|
||||
space=self.context['request'].space
|
||||
)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
class Meta:
|
||||
model = ShoppingListRecipe
|
||||
fields = ('id', 'recipe', 'recipe_name', 'servings')
|
||||
fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note')
|
||||
read_only_fields = ('id',)
|
||||
|
||||
|
||||
class ShoppingListEntrySerializer(WritableNestedModelSerializer):
|
||||
food = FoodSerializer(allow_null=True)
|
||||
unit = UnitSerializer(allow_null=True, required=False)
|
||||
ingredient_note = serializers.ReadOnlyField(source='ingredient.note')
|
||||
recipe_mealplan = ShoppingListRecipeSerializer(source='list_recipe', read_only=True)
|
||||
amount = CustomDecimalField()
|
||||
created_by = UserNameSerializer(read_only=True)
|
||||
completed_at = serializers.DateTimeField(allow_null=True, required=False)
|
||||
|
||||
def get_fields(self, *args, **kwargs):
|
||||
fields = super().get_fields(*args, **kwargs)
|
||||
|
||||
# autosync values are only needed for frequent 'checked' value updating
|
||||
if self.context['request'] and bool(int(self.context['request'].query_params.get('autosync', False))):
|
||||
for f in list(set(fields) - set(['id', 'checked'])):
|
||||
del fields[f]
|
||||
return fields
|
||||
|
||||
def run_validation(self, data):
|
||||
if self.root.instance.__class__.__name__ == 'ShoppingListEntry':
|
||||
if (
|
||||
data.get('checked', False)
|
||||
and self.root.instance
|
||||
and not self.root.instance.checked
|
||||
):
|
||||
# if checked flips from false to true set completed datetime
|
||||
data['completed_at'] = timezone.now()
|
||||
|
||||
elif not data.get('checked', False):
|
||||
# if not checked set completed to None
|
||||
data['completed_at'] = None
|
||||
else:
|
||||
# otherwise don't write anything
|
||||
if 'completed_at' in data:
|
||||
del data['completed_at']
|
||||
|
||||
return super().run_validation(data)
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['space'] = self.context['request'].space
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
user = self.context['request'].user
|
||||
# update the onhand for food if shopping_add_onhand is True
|
||||
if user.userpreference.shopping_add_onhand:
|
||||
if checked := validated_data.get('checked', None):
|
||||
instance.food.onhand_users.add(*user.userpreference.shopping_share.all(), user)
|
||||
elif checked == False:
|
||||
instance.food.onhand_users.remove(*user.userpreference.shopping_share.all(), user)
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
class Meta:
|
||||
model = ShoppingListEntry
|
||||
fields = (
|
||||
'id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked'
|
||||
'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked', 'recipe_mealplan',
|
||||
'created_by', 'created_at', 'completed_at', 'delay_until'
|
||||
)
|
||||
read_only_fields = ('id', 'created_by', 'created_at',)
|
||||
|
||||
|
||||
# TODO deprecate
|
||||
class ShoppingListEntryCheckedSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ShoppingListEntry
|
||||
fields = ('id', 'checked')
|
||||
|
||||
|
||||
# TODO deprecate
|
||||
class ShoppingListSerializer(WritableNestedModelSerializer):
|
||||
recipes = ShoppingListRecipeSerializer(many=True, allow_null=True)
|
||||
entries = ShoppingListEntrySerializer(many=True, allow_null=True)
|
||||
@@ -686,6 +795,7 @@ class ShoppingListSerializer(WritableNestedModelSerializer):
|
||||
read_only_fields = ('id', 'created_by',)
|
||||
|
||||
|
||||
# TODO deprecate
|
||||
class ShoppingListAutoSyncSerializer(WritableNestedModelSerializer):
|
||||
entries = ShoppingListEntryCheckedSerializer(many=True, allow_null=True)
|
||||
|
||||
@@ -755,7 +865,7 @@ class AutomationSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
# CORS, REST and Scopes aren't currently working
|
||||
# Scopes are evaluating before REST has authenticated the user assiging a None space
|
||||
# Scopes are evaluating before REST has authenticated the user assigning a None space
|
||||
# I've made the change below to fix the bookmarklet, other serializers likely need a similar/better fix
|
||||
class BookmarkletImportSerializer(serializers.ModelSerializer):
|
||||
def create(self, validated_data):
|
||||
@@ -800,7 +910,7 @@ class FoodExportSerializer(FoodSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = ('name', 'ignore_shopping', 'supermarket_category')
|
||||
fields = ('name', 'ignore_shopping', 'supermarket_category',)
|
||||
|
||||
|
||||
class IngredientExportSerializer(WritableNestedModelSerializer):
|
||||
@@ -826,7 +936,7 @@ class StepExportSerializer(WritableNestedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Step
|
||||
fields = ('name', 'type', 'instruction', 'ingredients', 'time', 'order', 'show_as_header')
|
||||
fields = ('name', 'instruction', 'ingredients', 'time', 'order', 'show_as_header')
|
||||
|
||||
|
||||
class RecipeExportSerializer(WritableNestedModelSerializer):
|
||||
@@ -845,3 +955,24 @@ class RecipeExportSerializer(WritableNestedModelSerializer):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
validated_data['space'] = self.context['request'].space
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class RecipeShoppingUpdateSerializer(serializers.ModelSerializer):
|
||||
list_recipe = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("Existing shopping list to update"))
|
||||
ingredients = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_(
|
||||
"List of ingredient IDs from the recipe to add, if not provided all ingredients will be added."))
|
||||
servings = serializers.IntegerField(default=1, write_only=True, allow_null=True, required=False, help_text=_("Providing a list_recipe ID and servings of 0 will delete that shopping list."))
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ['id', 'list_recipe', 'ingredients', 'servings', ]
|
||||
|
||||
|
||||
class FoodShoppingUpdateSerializer(serializers.ModelSerializer):
|
||||
amount = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("Amount of food to add to the shopping list"))
|
||||
unit = serializers.IntegerField(write_only=True, allow_null=True, required=False, help_text=_("ID of unit to use for the shopping list"))
|
||||
delete = serializers.ChoiceField(choices=['true'], write_only=True, allow_null=True, allow_blank=True, help_text=_("When set to true will delete all food from active shopping lists."))
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ['id', 'amount', 'unit', 'delete', ]
|
||||
|
||||
@@ -1,47 +1,131 @@
|
||||
from decimal import Decimal
|
||||
from functools import wraps
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.search import SearchVector
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import translation
|
||||
|
||||
from cookbook.models import Recipe, Step
|
||||
from cookbook.helper.shopping_helper import list_from_recipe
|
||||
from cookbook.managers import DICTIONARY
|
||||
from cookbook.models import (Food, FoodInheritField, Ingredient, MealPlan, Recipe,
|
||||
ShoppingListEntry, Step)
|
||||
|
||||
SQLITE = True
|
||||
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
|
||||
'django.db.backends.postgresql']:
|
||||
SQLITE = False
|
||||
|
||||
# wraps a signal with the ability to set 'skip_signal' to avoid creating recursive signals
|
||||
|
||||
|
||||
def skip_signal(signal_func):
|
||||
@wraps(signal_func)
|
||||
def _decorator(sender, instance, **kwargs):
|
||||
if not instance:
|
||||
return None
|
||||
if hasattr(instance, 'skip_signal'):
|
||||
return None
|
||||
return signal_func(sender, instance, **kwargs)
|
||||
return _decorator
|
||||
|
||||
|
||||
# TODO there is probably a way to generalize this
|
||||
@receiver(post_save, sender=Recipe)
|
||||
@skip_signal
|
||||
def update_recipe_search_vector(sender, instance=None, created=False, **kwargs):
|
||||
if not instance:
|
||||
if SQLITE:
|
||||
return
|
||||
|
||||
# needed to ensure search vector update doesn't trigger recursion
|
||||
if hasattr(instance, '_dirty'):
|
||||
return
|
||||
|
||||
language = DICTIONARY.get(translation.get_language(), 'simple')
|
||||
instance.name_search_vector = SearchVector('name__unaccent', weight='A', config=language)
|
||||
instance.desc_search_vector = SearchVector('description__unaccent', weight='C', config=language)
|
||||
|
||||
try:
|
||||
instance._dirty = True
|
||||
instance.skip_signal = True
|
||||
instance.save()
|
||||
finally:
|
||||
del instance._dirty
|
||||
del instance.skip_signal
|
||||
|
||||
|
||||
@receiver(post_save, sender=Step)
|
||||
@skip_signal
|
||||
def update_step_search_vector(sender, instance=None, created=False, **kwargs):
|
||||
if SQLITE:
|
||||
return
|
||||
language = DICTIONARY.get(translation.get_language(), 'simple')
|
||||
instance.search_vector = SearchVector('instruction__unaccent', weight='B', config=language)
|
||||
try:
|
||||
instance.skip_signal = True
|
||||
instance.save()
|
||||
finally:
|
||||
del instance.skip_signal
|
||||
|
||||
|
||||
@receiver(post_save, sender=Food)
|
||||
@skip_signal
|
||||
def update_food_inheritance(sender, instance=None, created=False, **kwargs):
|
||||
if not instance:
|
||||
return
|
||||
|
||||
# needed to ensure search vector update doesn't trigger recursion
|
||||
if hasattr(instance, '_dirty'):
|
||||
inherit = instance.inherit_fields.all()
|
||||
# nothing to apply from parent and nothing to apply to children
|
||||
if (not instance.parent or inherit.count() == 0) and instance.numchild == 0:
|
||||
return
|
||||
|
||||
language = DICTIONARY.get(translation.get_language(), 'simple')
|
||||
instance.search_vector = SearchVector('instruction__unaccent', weight='B', config=language)
|
||||
inherit = inherit.values_list('field', flat=True)
|
||||
# apply changes from parent to instance for each inherited field
|
||||
if instance.parent and inherit.count() > 0:
|
||||
parent = instance.get_parent()
|
||||
if 'ignore_shopping' in inherit:
|
||||
instance.ignore_shopping = parent.ignore_shopping
|
||||
# if supermarket_category is not set, do not cascade - if this becomes non-intuitive can change
|
||||
if 'supermarket_category' in inherit and parent.supermarket_category:
|
||||
instance.supermarket_category = parent.supermarket_category
|
||||
try:
|
||||
instance.skip_signal = True
|
||||
instance.save()
|
||||
finally:
|
||||
del instance.skip_signal
|
||||
|
||||
try:
|
||||
instance._dirty = True
|
||||
instance.save()
|
||||
finally:
|
||||
del instance._dirty
|
||||
# TODO figure out how to generalize this
|
||||
# apply changes to direct children - depend on save signals for those objects to cascade inheritance down
|
||||
_save = []
|
||||
for child in instance.get_children().filter(inherit_fields__field='ignore_shopping'):
|
||||
child.ignore_shopping = instance.ignore_shopping
|
||||
_save.append(child)
|
||||
# don't cascade empty supermarket category
|
||||
if instance.supermarket_category:
|
||||
# apply changes to direct children - depend on save signals for those objects to cascade inheritance down
|
||||
for child in instance.get_children().filter(inherit_fields__field='supermarket_category'):
|
||||
child.supermarket_category = instance.supermarket_category
|
||||
_save.append(child)
|
||||
for child in set(_save):
|
||||
child.save()
|
||||
|
||||
|
||||
@receiver(post_save, sender=MealPlan)
|
||||
def auto_add_shopping(sender, instance=None, created=False, weak=False, **kwargs):
|
||||
user = instance.get_owner()
|
||||
if not user.userpreference.mealplan_autoadd_shopping:
|
||||
return
|
||||
|
||||
if not created and instance.shoppinglistrecipe_set.exists():
|
||||
for x in instance.shoppinglistrecipe_set.all():
|
||||
if instance.servings != x.servings:
|
||||
list_recipe = list_from_recipe(list_recipe=x, servings=instance.servings, space=instance.space)
|
||||
elif created:
|
||||
# if creating a mealplan - perform shopping list activities
|
||||
kwargs = {
|
||||
'mealplan': instance,
|
||||
'space': instance.space,
|
||||
'created_by': user,
|
||||
'servings': instance.servings
|
||||
}
|
||||
list_recipe = list_from_recipe(**kwargs)
|
||||
|
||||
|
||||
# user = self.context['request'].user
|
||||
# if user.userpreference.shopping_add_onhand:
|
||||
# if checked := validated_data.get('checked', None):
|
||||
# instance.food.onhand_users.add(*user.userpreference.shopping_share.all(), user)
|
||||
# elif checked == False:
|
||||
# instance.food.onhand_users.remove(*user.userpreference.shopping_share.all(), user)
|
||||
|
||||
File diff suppressed because one or more lines are too long
5
cookbook/static/themes/tandoor.min.css
vendored
5
cookbook/static/themes/tandoor.min.css
vendored
@@ -10461,4 +10461,9 @@ textarea, input:not([type="submit"]):not([class="multiselect__input"]):not([clas
|
||||
|
||||
.form-control-search {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
opacity: 0.5 !important;
|
||||
background: #b98766 !important;
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3">
|
||||
<hr>
|
||||
<hr>
|
||||
<form class="login" method="POST" action="{% url 'account_login' %}">
|
||||
{% csrf_token %}
|
||||
{{ form | crispy }}
|
||||
@@ -29,12 +29,16 @@
|
||||
{% endif %}
|
||||
|
||||
<button class="btn btn-success" type="submit">{% trans "Sign In" %}</button>
|
||||
<a class="btn btn-secondary" href="{% url 'account_signup' %}">{% trans "Sign Up" %}</a>
|
||||
|
||||
{% if SIGNUP_ENABLED %}
|
||||
<a class="btn btn-secondary" href="{% url 'account_signup' %}">{% trans "Sign Up" %}</a>
|
||||
{% endif %}
|
||||
|
||||
{% if EMAIL_ENABLED %}
|
||||
<a class="btn btn-warning float-right d-none d-xl-block d-lg-block"
|
||||
href="{% url 'account_reset_password' %}">{% trans "Reset My Password" %}</a>
|
||||
<p class="d-xl-none d-lg-none">{% trans 'Lost your password?' %} <a href="{% url 'account_reset_password' %}">{% trans "Reset My Password" %}</a></p>
|
||||
<p class="d-xl-none d-lg-none">{% trans 'Lost your password?' %} <a
|
||||
href="{% url 'account_reset_password' %}">{% trans "Reset My Password" %}</a></p>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
@@ -44,7 +48,7 @@
|
||||
|
||||
{% if socialaccount_providers %}
|
||||
<div class="row" style="margin-top: 2vh">
|
||||
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3">
|
||||
<div class="col-sm-12 col-lg-6 col-md-6 offset-lg-3 offset-md-3">
|
||||
<h5>{% trans "Social Login" %}</h5>
|
||||
<span>{% trans 'You can use any of the following providers to sign in.' %}</span>
|
||||
|
||||
@@ -62,5 +66,8 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
$('#id_login').focus()
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -71,4 +71,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
$('#id_username').focus()
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -67,7 +67,7 @@
|
||||
</button>
|
||||
|
||||
{% if not request.user.is_authenticated or request.user.userpreference.theme == request.user.userpreference.TANDOOR %}
|
||||
<a class="navbar-brand p-0 me-2 justify-content-center" href="/" aria-label="Tandoor">
|
||||
<a class="navbar-brand p-0 me-2 justify-content-center" href="{% base_path request 'base' %}" aria-label="Tandoor">
|
||||
<img class="brand-icon" src="{% static 'assets/brand_logo.png' %}" alt="" style="height: 5vh;">
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -122,7 +122,7 @@
|
||||
<i class="fas fa-leaf fa-2x"></i>
|
||||
</div>
|
||||
<div class="card-body text-break text-center p-0 no-gutters text-muted">
|
||||
{% trans 'Ingredients' %}
|
||||
{% trans 'Foods' %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -336,6 +336,9 @@
|
||||
{% block content_fluid %}
|
||||
{% endblock %}
|
||||
|
||||
{% user_prefs request as prefs%}
|
||||
{{ prefs|json_script:'user_preference' }}
|
||||
|
||||
</div>
|
||||
|
||||
{% block script %}
|
||||
@@ -345,6 +348,7 @@
|
||||
localStorage.setItem('SCRIPT_NAME', "{% base_path request 'script' %}")
|
||||
localStorage.setItem('BASE_PATH', "{% base_path request 'base' %}")
|
||||
localStorage.setItem('STATIC_URL', "{% base_path request 'static_base' %}")
|
||||
localStorage.setItem('DEBUG', "{% is_debug %}")
|
||||
window.addEventListener("load", () => {
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("{% url 'service_worker' %}", {scope: "{% base_path request 'base' %}" + '/'}).then(function (reg) {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% load render_bundle from webpack_loader %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% comment %} {% load l10n %} {% endcomment %}
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content_fluid %}
|
||||
|
||||
<div id="app" >
|
||||
<checklist-view></checklist-view>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block script %}
|
||||
{{ config | json_script:"model_config" }}
|
||||
|
||||
{% if debug %}
|
||||
<script src="{% url 'js_reverse' %}"></script>
|
||||
{% else %}
|
||||
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
|
||||
{% endif %}
|
||||
|
||||
<script type="application/javascript">
|
||||
window.IMAGE_PLACEHOLDER = "{% static 'assets/recipe_no_image.svg' %}"
|
||||
</script>
|
||||
|
||||
{% render_bundle 'checklist_view' %}
|
||||
{% endblock %}
|
||||
@@ -18,12 +18,23 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="table-container">
|
||||
<h3 style="margin-bottom: 2vh">{{ title }} {% trans 'List' %}
|
||||
{% if create_url %}
|
||||
<a href="{% url create_url %}"> <i class="fas fa-plus-circle"></i>
|
||||
<span class="col col-md-9">
|
||||
<h3 style="margin-bottom: 2vh">{{ title }} {% trans 'List' %}
|
||||
{% if create_url %}
|
||||
<a href="{% url create_url %}"> <i class="fas fa-plus-circle"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</h3>
|
||||
</span>
|
||||
{% if request.resolver_match.url_name in 'list_shopping_list' %}
|
||||
<span class="col-md-3">
|
||||
<a href="{% url 'view_shopping_new' %}" class="float-right">
|
||||
<button class="btn btn-outline-secondary shadow-none">
|
||||
<i class="fas fa-star"></i> {% trans 'Try the new shopping list' %}
|
||||
</button>
|
||||
</a>
|
||||
{% endif %}
|
||||
</h3>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if filter %}
|
||||
<br/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% load i18n %}
|
||||
{% comment %} TODO: Deprecate {% endcomment %}
|
||||
|
||||
<div class="modal" tabindex="-1" role="dialog" id="id_modal_cook_log">
|
||||
<div class="modal-dialog" role="document">
|
||||
@@ -77,4 +78,4 @@
|
||||
$('#id_rating_show').html(rating.val() + '/5')
|
||||
});
|
||||
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
{
|
||||
"name": "Tandoor Recipes",
|
||||
"short_name" : "Tandoor",
|
||||
"description": "Application to manage, tag and search recipes.",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/assets/logo_color144.png",
|
||||
"type": "image/png",
|
||||
"sizes": "144x144"
|
||||
},
|
||||
{
|
||||
"src": "/static/assets/logo_color512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": "/search",
|
||||
"background_color": "#ffcb76",
|
||||
"display": "standalone",
|
||||
"scope": "/",
|
||||
"theme_color": "#ffcb76",
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Plan",
|
||||
"short_name": "Plan",
|
||||
"description": "View your meal Plan",
|
||||
"url": "/plan"
|
||||
},
|
||||
{
|
||||
"name": "Books",
|
||||
"short_name": "Cookbooks",
|
||||
"description": "View your cookbooks",
|
||||
"url": "/books"
|
||||
},
|
||||
{
|
||||
"name": "Shopping",
|
||||
"short_name": "Shopping",
|
||||
"description": "View your shopping lists",
|
||||
"url": "/list/shopping-list/"
|
||||
},
|
||||
{
|
||||
"name": "Latest Shopping List",
|
||||
"short_name": "Shopping List",
|
||||
"description": "View the latest shopping list",
|
||||
"url": "/shopping/latest/"
|
||||
}
|
||||
]
|
||||
"name": "Tandoor Recipes",
|
||||
"short_name": "Tandoor",
|
||||
"description": "Application to manage, tag and search recipes.",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/assets/logo_color144.png",
|
||||
"type": "image/png",
|
||||
"sizes": "144x144"
|
||||
},
|
||||
{
|
||||
"src": "/static/assets/logo_color512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": "./search",
|
||||
"background_color": "#ffcb76",
|
||||
"display": "standalone",
|
||||
"scope": ".",
|
||||
"theme_color": "#ffcb76",
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Plan",
|
||||
"short_name": "Plan",
|
||||
"description": "View your meal Plan",
|
||||
"url": "./plan"
|
||||
},
|
||||
{
|
||||
"name": "Books",
|
||||
"short_name": "Cookbooks",
|
||||
"description": "View your cookbooks",
|
||||
"url": "./books"
|
||||
},
|
||||
{
|
||||
"name": "Shopping",
|
||||
"short_name": "Shopping",
|
||||
"description": "View your shopping lists",
|
||||
"url": "./list/shopping-list/"
|
||||
},
|
||||
{
|
||||
"name": "Latest Shopping List",
|
||||
"short_name": "Shopping List",
|
||||
"description": "View the latest shopping list",
|
||||
"url": "./shopping/latest/"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
<h2>{% trans 'Formatting' %}</h2>
|
||||
<pre class="intro-code code-block"><code>
|
||||
{% trans 'Line breaks are inserted by adding two spaces after the end of a line' %}
|
||||
{% trans 'or by leaving a blank line inbetween.' %}
|
||||
{% trans 'or by leaving a blank line in between.' %}
|
||||
|
||||
**{% trans 'This text is bold' %}**
|
||||
*{% trans 'This text is italic' %}*
|
||||
@@ -70,7 +70,7 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% trans 'Line breaks are inserted by adding two spaces after the end of a line' %}<br/>
|
||||
{% trans 'or by leaving a blank line inbetween.' %}<br/><br/>
|
||||
{% trans 'or by leaving a blank line in between.' %}<br/><br/>
|
||||
<b>{% trans 'This text is bold' %}</b><br/>
|
||||
<i>{% trans 'This text is italic' %}</i>
|
||||
<blockquote>
|
||||
@@ -82,7 +82,7 @@
|
||||
|
||||
<br/>
|
||||
<h2>{% trans 'Lists' %}</h2>
|
||||
{% trans 'Lists can ordered or unorderd. It is <b>important to leave a blank line before the list!</b>' %}
|
||||
{% trans 'Lists can ordered or unordered. It is <b>important to leave a blank line before the list!</b>' %}
|
||||
<pre class="intro-code code-block"><code>
|
||||
{% trans 'Ordered List' %}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
{% endblocktrans %}</p>
|
||||
<h4>{% trans 'Simple' %}</h4>
|
||||
<p> {% blocktrans %}
|
||||
Simple searches ignore punctuation and common words such as 'the', 'a', 'and'. And will treat seperate words as required.
|
||||
Simple searches ignore punctuation and common words such as 'the', 'a', 'and'. And will treat separate words as required.
|
||||
Searching for 'apple or flour' will return any recipe that includes both 'apple' and 'flour' anywhere in the fields that have been selected for a full text search.
|
||||
{% endblocktrans %}</p>
|
||||
<h4>{% trans 'Phrase' %}</h4>
|
||||
@@ -39,7 +39,7 @@
|
||||
<p> {% blocktrans %}
|
||||
Web searches simulate functionality found on many web search sites supporting special syntax.
|
||||
Placing quotes around several words will convert those words into a phrase.
|
||||
'or' is recongized as searching for the word (or phrase) immediately before 'or' OR the word (or phrase) directly after.
|
||||
'or' is recognized as searching for the word (or phrase) immediately before 'or' OR the word (or phrase) directly after.
|
||||
'-' is recognized as searching for recipes that do not include the word (or phrase) that comes immediately after.
|
||||
For example searching for 'apple pie' or cherry -butter will return any recipe that includes the phrase 'apple pie' or the word 'cherry'
|
||||
in any field included in the full text search but exclude any recipe that has the word 'butter' in any field included.
|
||||
@@ -59,7 +59,7 @@
|
||||
{% blocktrans %}
|
||||
Another approach to searching that also requires Postgresql is fuzzy search or trigram similarity. A trigram is a group of three consecutive characters.
|
||||
For example searching for 'apple' will create x trigrams 'app', 'ppl', 'ple' and will create a score of how closely words match the generated trigrams.
|
||||
One benefit of searching trigams is that a search for 'sandwich' will find mispelled words such as 'sandwhich' that would be missed by other methods.
|
||||
One benefit of searching trigams is that a search for 'sandwich' will find misspelled words such as 'sandwhich' that would be missed by other methods.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -28,10 +28,10 @@
|
||||
{% trans 'Account' %}</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link {% if active_tab == 'prefernces' %} active {% endif %}" id="preferences-tab"
|
||||
<a class="nav-link {% if active_tab == 'preferences' %} active {% endif %}" id="preferences-tab"
|
||||
data-toggle="tab" href="#preferences" role="tab"
|
||||
aria-controls="preferences"
|
||||
aria-selected="{% if active_tab == 'prefernces' %} 'true' {% else %} 'false' {% endif %}">
|
||||
aria-selected="{% if active_tab == 'preferences' %} 'true' {% else %} 'false' {% endif %}">
|
||||
{% trans 'Preferences' %}</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
@@ -48,6 +48,13 @@
|
||||
aria-selected="{% if active_tab == 'search' %} 'true' {% else %} 'false' {% endif %}">
|
||||
{% trans 'Search-Settings' %}</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link {% if active_tab == 'shopping' %} active {% endif %}" id="shopping-tab" data-toggle="tab"
|
||||
href="#shopping" role="tab"
|
||||
aria-controls="search"
|
||||
aria-selected="{% if active_tab == 'shopping' %} 'true' {% else %} 'false' {% endif %}">
|
||||
{% trans 'Shopping-Settings' %}</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
|
||||
@@ -195,6 +202,17 @@
|
||||
class="fas fa-save"></i> {% trans 'Save' %}</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="tab-pane {% if active_tab == 'shopping' %} active {% endif %}" id="shopping" role="tabpanel"
|
||||
aria-labelledby="shopping-tab">
|
||||
<h4>{% trans 'Shopping Settings' %}</h4>
|
||||
|
||||
<form action="./#shopping" method="post" id="id_shopping_form">
|
||||
{% csrf_token %}
|
||||
{{ shopping_form|crispy }}
|
||||
<button class="btn btn-success" type="submit" name="shopping_form" id="shopping_form_button"><i
|
||||
class="fas fa-save"></i> {% trans 'Save' %}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -224,5 +242,26 @@
|
||||
$('.nav-tabs a').on('shown.bs.tab', function (e) {
|
||||
window.location.hash = e.target.hash;
|
||||
})
|
||||
// listen for events
|
||||
{% comment %} $(document).ready(function(){
|
||||
hideShow()
|
||||
// call hideShow when the user clicks on the mealplan_autoadd checkbox
|
||||
$("#id_shopping-mealplan_autoadd_shopping").click(function(event){
|
||||
hideShow()
|
||||
});
|
||||
})
|
||||
|
||||
function hideShow(){
|
||||
if(document.getElementById('id_shopping-mealplan_autoadd_shopping').checked == true)
|
||||
{
|
||||
$('#div_id_shopping-mealplan_autoexclude_onhand').show();
|
||||
$('#div_id_shopping-mealplan_autoinclude_related').show();
|
||||
}
|
||||
else
|
||||
{
|
||||
$('#div_id_shopping-mealplan_autoexclude_onhand').hide();
|
||||
$('#div_id_shopping-mealplan_autoinclude_related').hide();
|
||||
} {% endcomment %}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
17
cookbook/templates/shoppinglist_template.html
Normal file
17
cookbook/templates/shoppinglist_template.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %} {% load render_bundle from webpack_loader %} {% load static %} {% load i18n %} {% block title %} {{ title }} {% endblock %} {% block content_fluid %}
|
||||
|
||||
<div id="app">
|
||||
<shopping-list-view></shopping-list-view>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block script %} {% if debug %}
|
||||
<script src="{% url 'js_reverse' %}"></script>
|
||||
{% else %}
|
||||
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
|
||||
{% endif %}
|
||||
|
||||
<script type="application/javascript">
|
||||
window.IMAGE_PLACEHOLDER = "{% static 'assets/recipe_no_image.svg' %}"
|
||||
</script>
|
||||
|
||||
{% render_bundle 'shopping_list_view' %} {% endblock %}
|
||||
@@ -1,165 +1,188 @@
|
||||
{% extends "base.html" %}
|
||||
{% load django_tables2 %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load crispy_forms_filters %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Space Settings" %}{% endblock %}
|
||||
{%block title %} {% trans "Space Settings" %} {% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{ form.media }}
|
||||
|
||||
{{ space_form.media }}
|
||||
{% include 'include/vue_base.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'view_space' %}">{% trans 'Space Settings' %}</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'view_space' %}">{% trans 'Space Settings' %}</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h3><span class="text-muted">{% trans 'Space:' %}</span> {{ request.space.name }} <small>{% if HOSTED %}
|
||||
<a href="https://tandoor.dev/manage">{% trans 'Manage Subscription' %}</a>{% endif %}</small></h3>
|
||||
<h3>
|
||||
<span class="text-muted">{% trans 'Space:' %}</span> {{ request.space.name }}
|
||||
<small>{% if HOSTED %} <a href="https://tandoor.dev/manage">{% trans 'Manage Subscription' %}</a>{% endif %}</small>
|
||||
</h3>
|
||||
|
||||
<br/>
|
||||
<br />
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
{% trans 'Number of objects' %}
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">{% trans 'Recipes' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.recipes }} /
|
||||
{% if request.space.max_recipes > 0 %}
|
||||
{{ request.space.max_recipes }}{% else %}∞{% endif %}</span></li>
|
||||
<li class="list-group-item">{% trans 'Keywords' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.keywords }}</span></li>
|
||||
<li class="list-group-item">{% trans 'Units' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.units }}</span></li>
|
||||
<li class="list-group-item">{% trans 'Ingredients' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.ingredients }}</span></li>
|
||||
<li class="list-group-item">{% trans 'Recipe Imports' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.recipe_import }}</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
{% trans 'Objects stats' %}
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">{% trans 'Recipes without Keywords' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.recipes_no_keyword }}</span></li>
|
||||
<li class="list-group-item">{% trans 'External Recipes' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.recipes_external }}</span></li>
|
||||
<li class="list-group-item">{% trans 'Internal Recipes' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.recipes_internal }}</span></li>
|
||||
<li class="list-group-item">{% trans 'Comments' %} : <span
|
||||
class="badge badge-pill badge-info">{{ counts.comments }}</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">{% trans 'Number of objects' %}</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
{% trans 'Recipes' %} :
|
||||
<span class="badge badge-pill badge-info"
|
||||
>{{ counts.recipes }} / {% if request.space.max_recipes > 0 %} {{ request.space.max_recipes }}{%
|
||||
else %}∞{% endif %}</span
|
||||
>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'Keywords' %} : <span class="badge badge-pill badge-info">{{ counts.keywords }}</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'Units' %} : <span class="badge badge-pill badge-info">{{ counts.units }}</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'Ingredients' %} :
|
||||
<span class="badge badge-pill badge-info">{{ counts.ingredients }}</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'Recipe Imports' %} :
|
||||
<span class="badge badge-pill badge-info">{{ counts.recipe_import }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<br/>
|
||||
<br/>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
|
||||
<h4>{% trans 'Members' %} <small class="text-muted">{{ space_users|length }}/
|
||||
{% if request.space.max_users > 0 %}
|
||||
{{ request.space.max_users }}{% else %}∞{% endif %}</small>
|
||||
<a class="btn btn-success float-right" href="{% url 'new_invite_link' %}"><i
|
||||
class="fas fa-plus-circle"></i> {% trans 'Invite User' %}</a>
|
||||
</h4>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">{% trans 'Objects stats' %}</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
{% trans 'Recipes without Keywords' %} :
|
||||
<span class="badge badge-pill badge-info">{{ counts.recipes_no_keyword }}</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'External Recipes' %} :
|
||||
<span class="badge badge-pill badge-info">{{ counts.recipes_external }}</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'Internal Recipes' %} :
|
||||
<span class="badge badge-pill badge-info">{{ counts.recipes_internal }}</span>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
{% trans 'Comments' %} : <span class="badge badge-pill badge-info">{{ counts.comments }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<br>
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% if space_users %}
|
||||
<table class="table table-bordered">
|
||||
<tr>
|
||||
<th>{% trans 'User' %}</th>
|
||||
<th>{% trans 'Groups' %}</th>
|
||||
<th>{% trans 'Edit' %}</th>
|
||||
</tr>
|
||||
{% for u in space_users %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ u.user.username }}
|
||||
</td>
|
||||
<td>
|
||||
{{ u.user.groups.all |join:", " }}
|
||||
</td>
|
||||
<td>
|
||||
{% if u.user != request.user %}
|
||||
<div class="input-group mb-3">
|
||||
<select v-model="users['{{ u.pk }}']" class="custom-select form-control"
|
||||
style="height: 44px">
|
||||
<option value="admin">{% trans 'admin' %}</option>
|
||||
<option value="user">{% trans 'user' %}</option>
|
||||
<option value="guest">{% trans 'guest' %}</option>
|
||||
<option value="remove">{% trans 'remove' %}</option>
|
||||
</select>
|
||||
<span class="input-group-append">
|
||||
<a class="btn btn-warning"
|
||||
:href="editUserUrl({{ u.pk }}, {{ u.space.pk }})">{% trans 'Update' %}</a>
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
{% trans 'You cannot edit yourself.' %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p>{% trans 'There are no members in your space yet!' %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<form action="." method="post">{% csrf_token %} {{ user_name_form|crispy }}</form>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<h4>
|
||||
{% trans 'Members' %}
|
||||
<small class="text-muted"
|
||||
>{{ space_users|length }}/ {% if request.space.max_users > 0 %} {{ request.space.max_users }}{% else
|
||||
%}∞{% endif %}</small
|
||||
>
|
||||
<a class="btn btn-success float-right" href="{% url 'new_invite_link' %}"
|
||||
><i class="fas fa-plus-circle"></i> {% trans 'Invite User' %}</a
|
||||
>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<h4>{% trans 'Invite Links' %}</h4>
|
||||
{% render_table invite_links %}
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
{% if space_users %}
|
||||
<table class="table table-bordered">
|
||||
<tr>
|
||||
<th>{% trans 'User' %}</th>
|
||||
<th>{% trans 'Groups' %}</th>
|
||||
<th>{% trans 'Edit' %}</th>
|
||||
</tr>
|
||||
{% for u in space_users %}
|
||||
<tr>
|
||||
<td>{{ u.user.username }}</td>
|
||||
<td>{{ u.user.groups.all |join:", " }}</td>
|
||||
<td>
|
||||
{% if u.user != request.user %}
|
||||
<div class="input-group mb-3">
|
||||
<select v-model="users['{{ u.pk }}']" class="custom-select form-control" style="height: 44px">
|
||||
<option value="admin">{% trans 'admin' %}</option>
|
||||
<option value="user">{% trans 'user' %}</option>
|
||||
<option value="guest">{% trans 'guest' %}</option>
|
||||
<option value="remove">{% trans 'remove' %}</option>
|
||||
</select>
|
||||
<span class="input-group-append">
|
||||
<a class="btn btn-warning" :href="editUserUrl({{ u.pk }}, {{ u.space.pk }})"
|
||||
>{% trans 'Update' %}</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
{% else %} {% trans 'You cannot edit yourself.' %} {% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<p>{% trans 'There are no members in your space yet!' %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<h4>{% trans 'Invite Links' %}</h4>
|
||||
{% render_table invite_links %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
<div class="col col-md-12">
|
||||
<h4>{% trans 'Space Settings' %}</h4>
|
||||
<form action="." method="post">
|
||||
{% csrf_token %}
|
||||
{{ space_form|crispy }}
|
||||
<button class="btn btn-success" type="submit" name="space_form"><i
|
||||
class="fas fa-save"></i> {% trans 'Save' %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
|
||||
{% endblock %} {% block script %}
|
||||
|
||||
<script type="application/javascript">
|
||||
let app = new Vue({
|
||||
delimiters: ['[[', ']]'],
|
||||
el: '#id_base_container',
|
||||
data: {
|
||||
users: {
|
||||
{% for u in space_users %}
|
||||
'{{ u.pk }}': 'none',
|
||||
{% endfor %}
|
||||
}
|
||||
},
|
||||
mounted: function () {
|
||||
|
||||
},
|
||||
methods: {
|
||||
editUserUrl: function (user_id, space_id) {
|
||||
return '{% url 'change_space_member' 1234 5678 'role' %}'.replace('1234', user_id).replace('5678', space_id).replace('role', this.users[user_id])
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
|
||||
<script type="application/javascript">
|
||||
let app = new Vue({
|
||||
delimiters: ['[[', ']]'],
|
||||
el: '#id_base_container',
|
||||
data: {
|
||||
users: {
|
||||
{% for u in space_users %}
|
||||
'{{ u.pk }}': 'none',
|
||||
{% endfor %}
|
||||
}
|
||||
},
|
||||
mounted: function () {
|
||||
|
||||
},
|
||||
methods: {
|
||||
editUserUrl: function (user_id, space_id) {
|
||||
return '{% url 'change_space_member' 1234 5678 'role' %}'.replace('1234', user_id).replace('5678', space_id).replace('role', this.users[user_id])
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %} TODO: refactor to be Vue app {% endcomment %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load custom_tags %}
|
||||
@@ -75,6 +76,7 @@
|
||||
<option value="CHEFTAP">Cheftap</option>
|
||||
<option value="CHOWDOWN">Chowdown</option>
|
||||
<option value="COOKBOOKAPP">CookBookApp</option>
|
||||
<option value="COPYMETHAT">CopyMeThat</option>
|
||||
<option value="DOMESTICA">Domestica</option>
|
||||
<option value="MEALIE">Mealie</option>
|
||||
<option value="MEALMASTER">Mealmaster</option>
|
||||
@@ -496,6 +498,8 @@
|
||||
:clear-on-select="true"
|
||||
:allow-empty="true"
|
||||
:preserve-search="true"
|
||||
:internal-search="false"
|
||||
:limit="options_limit"
|
||||
placeholder="{% trans 'Select one' %}"
|
||||
tag-placeholder="{% trans 'Select' %}"
|
||||
label="text"
|
||||
@@ -534,6 +538,8 @@
|
||||
:clear-on-select="true"
|
||||
:allow-empty="false"
|
||||
:preserve-search="true"
|
||||
:internal-search="false"
|
||||
:limit="options_limit"
|
||||
label="text"
|
||||
track-by="id"
|
||||
:multiple="false"
|
||||
@@ -584,6 +590,8 @@
|
||||
:clear-on-select="true"
|
||||
:hide-selected="true"
|
||||
:preserve-search="true"
|
||||
:internal-search="false"
|
||||
:limit="options_limit"
|
||||
placeholder="{% trans 'Select one' %}"
|
||||
tag-placeholder="{% trans 'Add Keyword' %}"
|
||||
:taggable="true"
|
||||
@@ -652,7 +660,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="{% url 'javascript-catalog' %}"></script>
|
||||
<script src="{% url 'javascript-catalog' %}">
|
||||
</script>
|
||||
<script type="application/javascript">
|
||||
let csrftoken = Cookies.get('csrftoken');
|
||||
Vue.http.headers.common['X-CSRFToken'] = csrftoken;
|
||||
@@ -691,7 +700,8 @@
|
||||
import_duplicates: false,
|
||||
recipe_files: [],
|
||||
images: [],
|
||||
mode: 'url'
|
||||
mode: 'url',
|
||||
options_limit:25
|
||||
},
|
||||
directives: {
|
||||
tabindex: {
|
||||
@@ -701,9 +711,9 @@
|
||||
}
|
||||
},
|
||||
mounted: function () {
|
||||
this.searchKeywords('')
|
||||
this.searchUnits('')
|
||||
this.searchIngredients('')
|
||||
// this.searchKeywords('')
|
||||
// this.searchUnits('')
|
||||
// this.searchIngredients('')
|
||||
let uri = window.location.search.substring(1);
|
||||
let params = new URLSearchParams(uri);
|
||||
q = params.get("id")
|
||||
@@ -713,7 +723,6 @@
|
||||
},
|
||||
methods: {
|
||||
makeToast: function (title, message, variant = null) {
|
||||
//TODO remove duplicate function in favor of central one
|
||||
this.$bvToast.toast(message, {
|
||||
title: title,
|
||||
variant: variant,
|
||||
@@ -883,7 +892,20 @@
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
})
|
||||
// let apiFactory = new ApiApiFactory()
|
||||
|
||||
// this.keywords_loading = true
|
||||
// apiFactory
|
||||
// .listKeywords(query, undefined, undefined, 1, this.options_limit)
|
||||
// .then((response) => {
|
||||
// this.keywords = response.data.results
|
||||
// this.keywords_loading = false
|
||||
// })
|
||||
// .catch((err) => {
|
||||
// console.log(err)
|
||||
// StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
|
||||
// })
|
||||
},
|
||||
searchUnits: function (query) {
|
||||
this.units_loading = true
|
||||
@@ -921,6 +943,29 @@
|
||||
console.log(err)
|
||||
this.makeToast(gettext('Error'), gettext('There was an error loading a resource!') + err.bodyText, 'danger')
|
||||
})
|
||||
// let apiFactory = new ApiApiFactory()
|
||||
|
||||
// this.foods_loading = true
|
||||
// apiFactory
|
||||
// .listFoods(query, undefined, undefined, 1, this.options_limit)
|
||||
// .then((response) => {
|
||||
// this.foods = response.data.results
|
||||
|
||||
// if (this.recipe !== undefined) {
|
||||
// for (let s of this.recipe.steps) {
|
||||
// for (let i of s.ingredients) {
|
||||
// if (i.food !== null && i.food.id === undefined) {
|
||||
// this.foods.push(i.food)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// this.foods_loading = false
|
||||
// })
|
||||
// .catch((err) => {
|
||||
// StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
|
||||
// })
|
||||
},
|
||||
deleteNode: function (node, item, e) {
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import re
|
||||
from gettext import gettext as _
|
||||
|
||||
import bleach
|
||||
import markdown as md
|
||||
import re
|
||||
from markdown.extensions.tables import TableExtension
|
||||
from bleach_allowlist import markdown_attrs, markdown_tags
|
||||
from cookbook.helper.mdx_attributes import MarkdownFormatExtension
|
||||
from cookbook.helper.mdx_urlize import UrlizeExtension
|
||||
from cookbook.models import Space, get_model_name
|
||||
from django import template
|
||||
from django.db.models import Avg
|
||||
from django.templatetags.static import static
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from recipes import settings
|
||||
from rest_framework.authtoken.models import Token
|
||||
from gettext import gettext as _
|
||||
|
||||
from cookbook.helper.mdx_attributes import MarkdownFormatExtension
|
||||
from cookbook.helper.mdx_urlize import UrlizeExtension
|
||||
from cookbook.models import Space, get_model_name
|
||||
from recipes import settings
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@@ -47,7 +50,7 @@ def markdown(value):
|
||||
parsed_md = md.markdown(
|
||||
value,
|
||||
extensions=[
|
||||
'markdown.extensions.fenced_code', 'tables',
|
||||
'markdown.extensions.fenced_code', TableExtension(),
|
||||
UrlizeExtension(), MarkdownFormatExtension()
|
||||
]
|
||||
)
|
||||
@@ -124,10 +127,10 @@ def markdown_link():
|
||||
@register.simple_tag
|
||||
def bookmarklet(request):
|
||||
if request.is_secure():
|
||||
prefix = "https://"
|
||||
protocol = "https://"
|
||||
else:
|
||||
prefix = "http://"
|
||||
server = prefix + request.get_host()
|
||||
protocol = "http://"
|
||||
server = protocol + request.get_host()
|
||||
prefix = settings.JS_REVERSE_SCRIPT_PREFIX
|
||||
# TODO is it safe to store the token in clear text in a bookmark?
|
||||
if (api_token := Token.objects.filter(user=request.user).first()) is None:
|
||||
@@ -155,3 +158,13 @@ def base_path(request, path_type):
|
||||
return request.META.get('HTTP_X_SCRIPT_NAME', '')
|
||||
elif path_type == 'static_base':
|
||||
return static('vue/manifest.json').replace('vue/manifest.json', '')
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def user_prefs(request):
|
||||
from cookbook.serializer import \
|
||||
UserPreferenceSerializer # putting it with imports caused circular execution
|
||||
try:
|
||||
return UserPreferenceSerializer(request.user.userpreference, context={'request': request}).data
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from django.contrib import auth
|
||||
from django_scopes import scopes_disabled
|
||||
from django.urls import reverse
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from pytest_factoryboy import LazyFixture, register
|
||||
|
||||
|
||||
from cookbook.models import Food, Ingredient, ShoppingList, ShoppingListEntry
|
||||
from cookbook.models import Food, FoodInheritField, Ingredient, ShoppingList, ShoppingListEntry
|
||||
from cookbook.tests.factories import (FoodFactory, IngredientFactory, ShoppingListEntryFactory,
|
||||
SupermarketCategoryFactory)
|
||||
|
||||
# ------------------ IMPORTANT -------------------
|
||||
#
|
||||
@@ -27,78 +29,50 @@ else:
|
||||
node_location = 'last-child'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def obj_1(space_1):
|
||||
return Food.objects.get_or_create(name='test_1', space=space_1)[0]
|
||||
register(FoodFactory, 'obj_1', space=LazyFixture('space_1'))
|
||||
register(FoodFactory, 'obj_2', space=LazyFixture('space_1'))
|
||||
register(FoodFactory, 'obj_3', space=LazyFixture('space_2'))
|
||||
register(SupermarketCategoryFactory, 'cat_1', space=LazyFixture('space_1'))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def obj_1_1(obj_1, space_1):
|
||||
return obj_1.add_child(name='test_1_1', space=space_1)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def obj_1_1_1(obj_1_1, space_1):
|
||||
return obj_1_1.add_child(name='test_1_1_1', space=space_1)
|
||||
# @pytest.fixture
|
||||
# def true():
|
||||
# return True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def obj_2(space_1):
|
||||
return Food.objects.get_or_create(name='test_2', space=space_1)[0]
|
||||
def false():
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def non_exist():
|
||||
return {}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def obj_3(space_2):
|
||||
return Food.objects.get_or_create(name='test_3', space=space_2)[0]
|
||||
def obj_tree_1(request, space_1):
|
||||
try:
|
||||
params = request.param # request.param is a magic variable
|
||||
except AttributeError:
|
||||
params = {}
|
||||
objs = []
|
||||
inherit = params.pop('inherit', False)
|
||||
objs.extend(FoodFactory.create_batch(3, space=space_1, **params))
|
||||
|
||||
# set all foods to inherit everything
|
||||
if inherit:
|
||||
inherit = Food.inheritable_fields
|
||||
Through = Food.objects.filter(space=space_1).first().inherit_fields.through
|
||||
for i in inherit:
|
||||
Through.objects.bulk_create([
|
||||
Through(food_id=x, foodinheritfield_id=i.id)
|
||||
for x in Food.objects.filter(space=space_1).values_list('id', flat=True)
|
||||
])
|
||||
|
||||
@pytest.fixture()
|
||||
def ing_1_s1(obj_1, space_1):
|
||||
return Ingredient.objects.create(food=obj_1, space=space_1)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ing_2_s1(obj_2, space_1):
|
||||
return Ingredient.objects.create(food=obj_2, space=space_1)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ing_3_s2(obj_3, space_2):
|
||||
return Ingredient.objects.create(food=obj_3, space=space_2)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ing_1_1_s1(obj_1_1, space_1):
|
||||
return Ingredient.objects.create(food=obj_1_1, space=space_1)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sle_1_s1(obj_1, u1_s1, space_1):
|
||||
e = ShoppingListEntry.objects.create(food=obj_1)
|
||||
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
|
||||
s.entries.add(e)
|
||||
return e
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sle_2_s1(obj_2, u1_s1, space_1):
|
||||
return ShoppingListEntry.objects.create(food=obj_2)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sle_3_s2(obj_3, u1_s2, space_2):
|
||||
e = ShoppingListEntry.objects.create(food=obj_3)
|
||||
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s2), space=space_2, )
|
||||
s.entries.add(e)
|
||||
return e
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sle_1_1_s1(obj_1_1, u1_s1, space_1):
|
||||
e = ShoppingListEntry.objects.create(food=obj_1_1)
|
||||
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
|
||||
s.entries.add(e)
|
||||
return e
|
||||
objs[0].move(objs[1], node_location)
|
||||
objs[1].move(objs[2], node_location)
|
||||
return Food.objects.get(id=objs[1].id) # whenever you move/merge a tree it's safest to re-get the object
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
@@ -128,7 +102,10 @@ def test_list_filter(obj_1, obj_2, u1_s1):
|
||||
assert r.status_code == 200
|
||||
response = json.loads(r.content)
|
||||
assert response['count'] == 2
|
||||
assert response['results'][0]['name'] == obj_1.name
|
||||
|
||||
assert obj_1.name in [x['name'] for x in response['results']]
|
||||
assert obj_2.name in [x['name'] for x in response['results']]
|
||||
assert response['results'][0]['name'] < response['results'][1]['name']
|
||||
|
||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?page_size=1').content)
|
||||
assert len(response['results']) == 1
|
||||
@@ -142,7 +119,7 @@ def test_list_filter(obj_1, obj_2, u1_s1):
|
||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?query=chicken').content)
|
||||
assert response['count'] == 0
|
||||
|
||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?query={obj_1.name[4:]}').content)
|
||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?query={obj_1.name[:-4]}').content)
|
||||
assert response['count'] == 1
|
||||
|
||||
|
||||
@@ -194,7 +171,6 @@ def test_add(arg, request, u1_s2):
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_add_duplicate(u1_s1, u1_s2, obj_1, obj_3):
|
||||
assert json.loads(u1_s1.get(reverse(LIST_URL)).content)['count'] == 1
|
||||
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 1
|
||||
@@ -220,9 +196,9 @@ def test_add_duplicate(u1_s1, u1_s2, obj_1, obj_3):
|
||||
assert json.loads(u1_s2.get(reverse(LIST_URL)).content)['count'] == 2
|
||||
|
||||
|
||||
def test_delete(u1_s1, u1_s2, obj_1, obj_1_1, obj_1_1_1):
|
||||
def test_delete(u1_s1, u1_s2, obj_1, obj_tree_1):
|
||||
with scopes_disabled():
|
||||
assert Food.objects.count() == 3
|
||||
assert Food.objects.count() == 4
|
||||
|
||||
r = u1_s2.delete(
|
||||
reverse(
|
||||
@@ -232,18 +208,19 @@ def test_delete(u1_s1, u1_s2, obj_1, obj_1_1, obj_1_1_1):
|
||||
)
|
||||
assert r.status_code == 404
|
||||
with scopes_disabled():
|
||||
assert Food.objects.count() == 3
|
||||
assert Food.objects.count() == 4
|
||||
|
||||
# should delete self and child, leaving parent
|
||||
r = u1_s1.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1_1.id}
|
||||
args={obj_tree_1.id}
|
||||
)
|
||||
)
|
||||
|
||||
assert r.status_code == 204
|
||||
with scopes_disabled():
|
||||
assert Food.objects.count() == 1
|
||||
assert Food.objects.count() == 2
|
||||
assert Food.find_problems() == ([], [], [], [], [])
|
||||
|
||||
|
||||
@@ -283,13 +260,16 @@ def test_integrity(u1_s1, recipe_1_s1):
|
||||
assert Ingredient.objects.count() == 9
|
||||
|
||||
|
||||
def test_move(u1_s1, obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, space_1):
|
||||
url = reverse(MOVE_URL, args=[obj_1_1.id, obj_2.id])
|
||||
with scopes_disabled():
|
||||
assert obj_1.get_num_children() == 1
|
||||
assert obj_1.get_descendant_count() == 2
|
||||
def test_move(u1_s1, obj_tree_1, obj_2, obj_3, space_1):
|
||||
with scope(space=space_1):
|
||||
parent = obj_tree_1.get_parent()
|
||||
child = obj_tree_1.get_descendants()[0]
|
||||
assert parent.get_num_children() == 1
|
||||
assert parent.get_descendant_count() == 2
|
||||
assert Food.get_root_nodes().filter(space=space_1).count() == 2
|
||||
|
||||
url = reverse(MOVE_URL, args=[obj_tree_1.id, obj_2.id])
|
||||
|
||||
# move child to new parent, only HTTP put method should work
|
||||
r = u1_s1.get(url)
|
||||
assert r.status_code == 405
|
||||
@@ -301,61 +281,107 @@ def test_move(u1_s1, obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, space_1):
|
||||
assert r.status_code == 200
|
||||
with scopes_disabled():
|
||||
# django-treebeard bypasses django ORM so object needs retrieved again
|
||||
obj_1 = Food.objects.get(pk=obj_1.id)
|
||||
parent = Food.objects.get(pk=parent.id)
|
||||
obj_2 = Food.objects.get(pk=obj_2.id)
|
||||
assert obj_1.get_num_children() == 0
|
||||
assert obj_1.get_descendant_count() == 0
|
||||
assert parent.get_num_children() == 0
|
||||
assert parent.get_descendant_count() == 0
|
||||
assert obj_2.get_num_children() == 1
|
||||
assert obj_2.get_descendant_count() == 2
|
||||
|
||||
# move child to root
|
||||
r = u1_s1.put(reverse(MOVE_URL, args=[obj_1_1.id, 0]))
|
||||
assert r.status_code == 200
|
||||
with scopes_disabled():
|
||||
assert Food.get_root_nodes().filter(space=space_1).count() == 3
|
||||
|
||||
# attempt to move to non-existent parent
|
||||
r = u1_s1.put(
|
||||
reverse(MOVE_URL, args=[obj_1.id, 9999])
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
# attempt to move to wrong space
|
||||
r = u1_s1.put(
|
||||
reverse(MOVE_URL, args=[obj_1_1.id, obj_3.id])
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
# run diagnostic to find problems - none should be found
|
||||
with scopes_disabled():
|
||||
assert Food.find_problems() == ([], [], [], [], [])
|
||||
|
||||
|
||||
def test_merge(
|
||||
u1_s1,
|
||||
obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3,
|
||||
ing_1_s1, ing_2_s1, ing_3_s2, ing_1_1_s1,
|
||||
sle_1_s1, sle_2_s1, sle_3_s2, sle_1_1_s1,
|
||||
space_1
|
||||
):
|
||||
def test_move_errors(u1_s1, obj_tree_1, obj_3, space_1):
|
||||
with scope(space=space_1):
|
||||
parent = obj_tree_1.get_parent()
|
||||
child = obj_tree_1.get_descendants()[0]
|
||||
# move child to root
|
||||
r = u1_s1.put(reverse(MOVE_URL, args=[obj_tree_1.id, 0]))
|
||||
assert r.status_code == 200
|
||||
with scopes_disabled():
|
||||
assert obj_1.get_num_children() == 1
|
||||
assert obj_1.get_descendant_count() == 2
|
||||
assert Food.get_root_nodes().filter(space=space_1).count() == 2
|
||||
assert Food.objects.filter(space=space_1).count() == 4
|
||||
assert obj_1.ingredient_set.count() == 1
|
||||
assert obj_2.ingredient_set.count() == 1
|
||||
assert obj_3.ingredient_set.count() == 1
|
||||
assert obj_1_1.ingredient_set.count() == 1
|
||||
assert obj_1_1_1.ingredient_set.count() == 0
|
||||
assert obj_1.shoppinglistentry_set.count() == 1
|
||||
assert obj_2.shoppinglistentry_set.count() == 1
|
||||
assert obj_3.shoppinglistentry_set.count() == 1
|
||||
assert obj_1_1.shoppinglistentry_set.count() == 1
|
||||
assert obj_1_1_1.shoppinglistentry_set.count() == 0
|
||||
|
||||
# merge food with no children and no ingredient/shopping list entry with another food, only HTTP put method should work
|
||||
url = reverse(MERGE_URL, args=[obj_1_1_1.id, obj_2.id])
|
||||
# attempt to move to non-existent parent
|
||||
r = u1_s1.put(
|
||||
reverse(MOVE_URL, args=[parent.id, 9999])
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
# attempt to move non-existent mode to parent
|
||||
r = u1_s1.put(
|
||||
reverse(MOVE_URL, args=[9999, parent.id])
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
# attempt to move to wrong space
|
||||
r = u1_s1.put(
|
||||
reverse(MOVE_URL, args=[obj_tree_1.id, obj_3.id])
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# TODO: figure out how to generalize this to be all related objects
|
||||
def test_merge_ingredients(obj_tree_1, u1_s1, space_1):
|
||||
with scope(space=space_1):
|
||||
parent = obj_tree_1.get_parent()
|
||||
child = obj_tree_1.get_descendants()[0]
|
||||
IngredientFactory.create(food=parent, space=space_1)
|
||||
IngredientFactory.create(food=child, space=space_1)
|
||||
assert parent.get_num_children() == 1
|
||||
assert parent.get_descendant_count() == 2
|
||||
assert Ingredient.objects.count() == 2
|
||||
assert parent.ingredient_set.count() == 1
|
||||
assert obj_tree_1.ingredient_set.count() == 0
|
||||
assert child.ingredient_set.count() == 1
|
||||
|
||||
# merge food (with connected ingredient) with children to another food
|
||||
r = u1_s1.put(reverse(MERGE_URL, args=[child.id, obj_tree_1.id]))
|
||||
assert r.status_code == 200
|
||||
with scope(space=space_1):
|
||||
# django-treebeard bypasses django ORM so object needs retrieved again
|
||||
with pytest.raises(Food.DoesNotExist):
|
||||
Food.objects.get(pk=child.id)
|
||||
obj_tree_1 = Food.objects.get(pk=obj_tree_1.id)
|
||||
assert obj_tree_1.ingredient_set.count() == 1 # now has child's ingredient
|
||||
|
||||
|
||||
def test_merge_shopping_entries(obj_tree_1, u1_s1, space_1):
|
||||
with scope(space=space_1):
|
||||
parent = obj_tree_1.get_parent()
|
||||
child = obj_tree_1.get_descendants()[0]
|
||||
ShoppingListEntryFactory.create(food=parent, space=space_1)
|
||||
ShoppingListEntryFactory.create(food=child, space=space_1)
|
||||
assert parent.get_num_children() == 1
|
||||
assert parent.get_descendant_count() == 2
|
||||
assert ShoppingListEntry.objects.count() == 2
|
||||
assert parent.shopping_entries.count() == 1
|
||||
assert obj_tree_1.shopping_entries.count() == 0
|
||||
assert child.shopping_entries.count() == 1
|
||||
|
||||
# merge food (with connected shoppinglistentry) with children to another food
|
||||
r = u1_s1.put(reverse(MERGE_URL, args=[child.id, obj_tree_1.id]))
|
||||
assert r.status_code == 200
|
||||
with scope(space=space_1):
|
||||
# django-treebeard bypasses django ORM so object needs retrieved again
|
||||
with pytest.raises(Food.DoesNotExist):
|
||||
Food.objects.get(pk=child.id)
|
||||
obj_tree_1 = Food.objects.get(pk=obj_tree_1.id)
|
||||
assert obj_tree_1.shopping_entries.count() == 1 # now has child's ingredient
|
||||
|
||||
|
||||
def test_merge(u1_s1, obj_tree_1, obj_1, obj_3, space_1):
|
||||
with scope(space=space_1):
|
||||
parent = obj_tree_1.get_parent()
|
||||
child = obj_tree_1.get_descendants()[0]
|
||||
assert parent.get_num_children() == 1
|
||||
assert parent.get_descendant_count() == 2
|
||||
assert Food.get_root_nodes().filter(space=space_1).count() == 2
|
||||
assert Food.objects.count() == 4
|
||||
|
||||
# merge food with no children with another food, only HTTP put method should work
|
||||
url = reverse(MERGE_URL, args=[child.id, obj_tree_1.id])
|
||||
r = u1_s1.get(url)
|
||||
assert r.status_code == 405
|
||||
r = u1_s1.post(url)
|
||||
@@ -364,88 +390,163 @@ def test_merge(
|
||||
assert r.status_code == 405
|
||||
r = u1_s1.put(url)
|
||||
assert r.status_code == 200
|
||||
with scopes_disabled():
|
||||
with scope(space=space_1):
|
||||
# django-treebeard bypasses django ORM so object needs retrieved again
|
||||
with pytest.raises(Food.DoesNotExist):
|
||||
Food.objects.get(pk=child.id)
|
||||
obj_tree_1 = Food.objects.get(pk=obj_tree_1.id)
|
||||
assert parent.get_num_children() == 1
|
||||
assert parent.get_descendant_count() == 1
|
||||
|
||||
# merge food with children with another food
|
||||
r = u1_s1.put(reverse(MERGE_URL, args=[parent.id, obj_1.id]))
|
||||
assert r.status_code == 200
|
||||
with scope(space=space_1):
|
||||
# django-treebeard bypasses django ORM so object needs retrieved again
|
||||
with pytest.raises(Food.DoesNotExist):
|
||||
Food.objects.get(pk=parent.id)
|
||||
obj_1 = Food.objects.get(pk=obj_1.id)
|
||||
obj_2 = Food.objects.get(pk=obj_2.id)
|
||||
assert Food.objects.filter(pk=obj_1_1_1.id).count() == 0
|
||||
assert obj_1.get_num_children() == 1
|
||||
assert obj_1.get_descendant_count() == 1
|
||||
assert obj_2.get_num_children() == 0
|
||||
assert obj_2.get_descendant_count() == 0
|
||||
assert obj_1.ingredient_set.count() == 1
|
||||
assert obj_2.ingredient_set.count() == 1
|
||||
assert obj_3.ingredient_set.count() == 1
|
||||
assert obj_1_1.ingredient_set.count() == 1
|
||||
assert obj_1.shoppinglistentry_set.count() == 1
|
||||
assert obj_2.shoppinglistentry_set.count() == 1
|
||||
assert obj_3.shoppinglistentry_set.count() == 1
|
||||
assert obj_1_1.shoppinglistentry_set.count() == 1
|
||||
|
||||
# merge food (with connected ingredient/shoppinglistentry) with children to another food
|
||||
r = u1_s1.put(reverse(MERGE_URL, args=[obj_1.id, obj_2.id]))
|
||||
assert r.status_code == 200
|
||||
with scopes_disabled():
|
||||
# django-treebeard bypasses django ORM so object needs retrieved again
|
||||
obj_2 = Food.objects.get(pk=obj_2.id)
|
||||
assert Food.objects.filter(pk=obj_1.id).count() == 0
|
||||
assert obj_2.get_num_children() == 1
|
||||
assert obj_2.get_descendant_count() == 1
|
||||
assert obj_2.ingredient_set.count() == 2
|
||||
assert obj_3.ingredient_set.count() == 1
|
||||
assert obj_1_1.ingredient_set.count() == 1
|
||||
assert obj_2.shoppinglistentry_set.count() == 2
|
||||
assert obj_3.shoppinglistentry_set.count() == 1
|
||||
assert obj_1_1.shoppinglistentry_set.count() == 1
|
||||
|
||||
# attempt to merge with non-existent parent
|
||||
r = u1_s1.put(
|
||||
reverse(MERGE_URL, args=[obj_1_1.id, 9999])
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
# attempt to move to wrong space
|
||||
r = u1_s1.put(
|
||||
reverse(MERGE_URL, args=[obj_2.id, obj_3.id])
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
# attempt to merge with child
|
||||
r = u1_s1.put(
|
||||
reverse(MERGE_URL, args=[obj_2.id, obj_1_1.id])
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
# attempt to merge with self
|
||||
r = u1_s1.put(
|
||||
reverse(MERGE_URL, args=[obj_2.id, obj_2.id])
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
# run diagnostic to find problems - none should be found
|
||||
with scopes_disabled():
|
||||
assert Food.find_problems() == ([], [], [], [], [])
|
||||
|
||||
|
||||
def test_root_filter(obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, u1_s1):
|
||||
def test_merge_errors(u1_s1, obj_tree_1, obj_3, space_1):
|
||||
with scope(space=space_1):
|
||||
parent = obj_tree_1.get_parent()
|
||||
child = obj_tree_1.get_descendants()[0]
|
||||
|
||||
# attempt to merge with non-existent parent
|
||||
r = u1_s1.put(
|
||||
reverse(MERGE_URL, args=[obj_tree_1.id, 9999])
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
# attempt to merge non-existent node to parent
|
||||
r = u1_s1.put(
|
||||
reverse(MERGE_URL, args=[9999, obj_tree_1.id])
|
||||
)
|
||||
assert r.status_code == 404
|
||||
# attempt to move to wrong space
|
||||
r = u1_s1.put(
|
||||
reverse(MERGE_URL, args=[obj_tree_1.id, obj_3.id])
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
# attempt to merge with child
|
||||
r = u1_s1.put(
|
||||
reverse(MERGE_URL, args=[parent.id, obj_tree_1.id])
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
# attempt to merge with self
|
||||
r = u1_s1.put(
|
||||
reverse(MERGE_URL, args=[obj_tree_1.id, obj_tree_1.id])
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_root_filter(obj_tree_1, obj_2, obj_3, u1_s1):
|
||||
with scope(space=obj_tree_1.space):
|
||||
parent = obj_tree_1.get_parent()
|
||||
child = obj_tree_1.get_descendants()[0]
|
||||
|
||||
# should return root objects in the space (obj_1, obj_2), ignoring query filters
|
||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root=0').content)
|
||||
assert len(response['results']) == 2
|
||||
|
||||
with scopes_disabled():
|
||||
obj_2.move(obj_1, node_location)
|
||||
# should return direct children of obj_1 (obj_1_1, obj_2), ignoring query filters
|
||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root={obj_1.id}').content)
|
||||
obj_2.move(parent, node_location)
|
||||
# should return direct children of parent (obj_tree_1, obj_2), ignoring query filters
|
||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root={parent.id}').content)
|
||||
assert response['count'] == 2
|
||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root={obj_1.id}&query={obj_2.name[4:]}').content)
|
||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?root={parent.id}&query={obj_2.name[4:]}').content)
|
||||
assert response['count'] == 2
|
||||
|
||||
|
||||
def test_tree_filter(obj_1, obj_1_1, obj_1_1_1, obj_2, obj_3, u1_s1):
|
||||
with scopes_disabled():
|
||||
obj_2.move(obj_1, node_location)
|
||||
# should return full tree starting at obj_1 (obj_1_1_1, obj_2), ignoring query filters
|
||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={obj_1.id}').content)
|
||||
def test_tree_filter(obj_tree_1, obj_2, obj_3, u1_s1):
|
||||
with scope(space=obj_tree_1.space):
|
||||
parent = obj_tree_1.get_parent()
|
||||
child = obj_tree_1.get_descendants()[0]
|
||||
obj_2.move(parent, node_location)
|
||||
# should return full tree starting at parent (obj_tree_1, obj_2), ignoring query filters
|
||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={parent.id}').content)
|
||||
assert response['count'] == 4
|
||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={obj_1.id}&query={obj_2.name[4:]}').content)
|
||||
response = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?tree={parent.id}&query={obj_2.name[4:]}').content)
|
||||
assert response['count'] == 4
|
||||
|
||||
|
||||
# This is more about the model than the API - should this be moved to a different test?
|
||||
@pytest.mark.parametrize("obj_tree_1, field, inherit, new_val", [
|
||||
({'has_category': True, 'inherit': True}, 'supermarket_category', True, 'cat_1'),
|
||||
({'has_category': True, 'inherit': False}, 'supermarket_category', False, 'cat_1'),
|
||||
({'ignore_shopping': True, 'inherit': True}, 'ignore_shopping', True, 'false'),
|
||||
({'ignore_shopping': True, 'inherit': False}, 'ignore_shopping', False, 'false'),
|
||||
], indirect=['obj_tree_1']) # indirect=True populates magic variable request.param of obj_tree_1 with the parameter
|
||||
def test_inherit(request, obj_tree_1, field, inherit, new_val, u1_s1):
|
||||
with scope(space=obj_tree_1.space):
|
||||
parent = obj_tree_1.get_parent()
|
||||
child = obj_tree_1.get_descendants()[0]
|
||||
|
||||
new_val = request.getfixturevalue(new_val)
|
||||
# if this test passes it demonstrates that inheritance works
|
||||
# when moving to a parent as each food is created with a different category
|
||||
assert (getattr(parent, field) == getattr(obj_tree_1, field)) in [inherit, True]
|
||||
assert (getattr(obj_tree_1, field) == getattr(child, field)) in [inherit, True]
|
||||
# change parent to a new value
|
||||
setattr(parent, field, new_val)
|
||||
with scope(space=parent.space):
|
||||
parent.save() # trigger post-save signal
|
||||
# get the objects again because values are cached
|
||||
obj_tree_1 = Food.objects.get(id=obj_tree_1.id)
|
||||
child = Food.objects.get(id=child.id)
|
||||
# when changing parent value the obj value should be same if inherited
|
||||
assert (getattr(obj_tree_1, field) == new_val) == inherit
|
||||
assert (getattr(child, field) == new_val) == inherit
|
||||
|
||||
|
||||
@pytest.mark.parametrize("obj_tree_1", [
|
||||
({'has_category': True, 'inherit': False, 'ignore_shopping': True}),
|
||||
], indirect=['obj_tree_1'])
|
||||
def test_reset_inherit(obj_tree_1, space_1):
|
||||
with scope(space=space_1):
|
||||
space_1.food_inherit.add(*Food.inheritable_fields.values_list('id', flat=True)) # set default inherit fields
|
||||
parent = obj_tree_1.get_parent()
|
||||
child = obj_tree_1.get_descendants()[0]
|
||||
obj_tree_1.ignore_shopping = False
|
||||
assert parent.ignore_shopping == child.ignore_shopping
|
||||
assert parent.ignore_shopping != obj_tree_1.ignore_shopping
|
||||
assert parent.supermarket_category != child.supermarket_category
|
||||
assert parent.supermarket_category != obj_tree_1.supermarket_category
|
||||
|
||||
parent.reset_inheritance(space=space_1)
|
||||
# djangotree bypasses ORM and need to be retrieved again
|
||||
obj_tree_1 = Food.objects.get(id=obj_tree_1.id)
|
||||
parent = obj_tree_1.get_parent()
|
||||
child = obj_tree_1.get_descendants()[0]
|
||||
assert parent.ignore_shopping == obj_tree_1.ignore_shopping == child.ignore_shopping
|
||||
assert parent.supermarket_category == obj_tree_1.supermarket_category == child.supermarket_category
|
||||
|
||||
|
||||
def test_onhand(obj_1, u1_s1, u2_s1):
|
||||
assert json.loads(u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == False
|
||||
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == False
|
||||
|
||||
u1_s1.patch(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={obj_1.id}
|
||||
),
|
||||
{'food_onhand': True},
|
||||
content_type='application/json'
|
||||
)
|
||||
assert json.loads(u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == True
|
||||
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == False
|
||||
|
||||
user1 = auth.get_user(u1_s1)
|
||||
user2 = auth.get_user(u2_s1)
|
||||
user1.userpreference.shopping_share.add(user2)
|
||||
assert json.loads(u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).content)['food_onhand'] == True
|
||||
|
||||
96
cookbook/tests/api/test_api_food_shopping.py
Normal file
96
cookbook/tests/api/test_api_food_shopping.py
Normal file
@@ -0,0 +1,96 @@
|
||||
# test create
|
||||
# test create units
|
||||
# test amounts
|
||||
# test create wrong space
|
||||
# test sharing
|
||||
# test delete
|
||||
# test delete checked (nothing should happen)
|
||||
# test delete not shared (nothing happens)
|
||||
# test delete shared
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
from django_scopes import scope, scopes_disabled
|
||||
|
||||
from cookbook.models import Food, ShoppingListEntry
|
||||
from cookbook.tests.factories import FoodFactory
|
||||
|
||||
SHOPPING_LIST_URL = 'api:shoppinglistentry-list'
|
||||
SHOPPING_FOOD_URL = 'api:food-shopping'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def food(request, space_1, u1_s1):
|
||||
return FoodFactory(space=space_1)
|
||||
|
||||
|
||||
def test_shopping_forbidden_methods(food, u1_s1):
|
||||
r = u1_s1.post(
|
||||
reverse(SHOPPING_FOOD_URL, args={food.id}))
|
||||
assert r.status_code == 405
|
||||
|
||||
r = u1_s1.delete(
|
||||
reverse(SHOPPING_FOOD_URL, args={food.id}))
|
||||
assert r.status_code == 405
|
||||
|
||||
r = u1_s1.get(
|
||||
reverse(SHOPPING_FOOD_URL, args={food.id}))
|
||||
assert r.status_code == 405
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 204],
|
||||
['u1_s2', 404],
|
||||
['a1_s1', 204],
|
||||
])
|
||||
def test_shopping_food_create(request, arg, food):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.put(reverse(SHOPPING_FOOD_URL, args={food.id}))
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 204:
|
||||
assert len(json.loads(c.get(reverse(SHOPPING_LIST_URL)).content)) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 403],
|
||||
['u1_s1', 204],
|
||||
['u1_s2', 404],
|
||||
['a1_s1', 204],
|
||||
])
|
||||
def test_shopping_food_delete(request, arg, food):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.put(
|
||||
reverse(SHOPPING_FOOD_URL, args={food.id}),
|
||||
{'_delete': "true"},
|
||||
content_type='application/json'
|
||||
)
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 204:
|
||||
assert len(json.loads(c.get(reverse(SHOPPING_LIST_URL)).content)) == 0
|
||||
|
||||
|
||||
def test_shopping_food_share(u1_s1, u2_s1, food, space_1):
|
||||
with scope(space=space_1):
|
||||
user1 = auth.get_user(u1_s1)
|
||||
user2 = auth.get_user(u2_s1)
|
||||
food2 = FoodFactory(space=space_1)
|
||||
r = u1_s1.put(reverse(SHOPPING_FOOD_URL, args={food.id}))
|
||||
r = u2_s1.put(reverse(SHOPPING_FOOD_URL, args={food2.id}))
|
||||
sl_1 = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||
sl_2 = json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||
assert len(sl_1) == 1
|
||||
assert len(sl_2) == 1
|
||||
sl_1[0]['created_by']['id'] == user1.id
|
||||
sl_2[0]['created_by']['id'] == user2.id
|
||||
|
||||
with scopes_disabled():
|
||||
user1.userpreference.shopping_share.add(user2)
|
||||
user1.userpreference.save()
|
||||
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 1
|
||||
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 2
|
||||
@@ -4,13 +4,16 @@ from datetime import datetime, timedelta
|
||||
import pytest
|
||||
from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
from django_scopes import scope, scopes_disabled
|
||||
|
||||
from cookbook.models import Food, MealPlan, MealType
|
||||
from cookbook.tests.factories import RecipeFactory
|
||||
|
||||
LIST_URL = 'api:mealplan-list'
|
||||
DETAIL_URL = 'api:mealplan-detail'
|
||||
|
||||
# NOTE: auto adding shopping list from meal plan is tested in test_shopping_recipe as tests are identical
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def meal_type(space_1, u1_s1):
|
||||
@@ -106,7 +109,7 @@ def test_add(arg, request, u1_s2, recipe_1_s1, meal_type):
|
||||
r = c.post(
|
||||
reverse(LIST_URL),
|
||||
{'recipe': {'id': recipe_1_s1.id, 'name': recipe_1_s1.name, 'keywords': []}, 'meal_type': {'id': meal_type.id, 'name': meal_type.name},
|
||||
'date': (datetime.now()).strftime("%Y-%m-%d"), 'servings': 1, 'title': 'test'},
|
||||
'date': (datetime.now()).strftime("%Y-%m-%d"), 'servings': 1, 'title': 'test', 'shared': []},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = json.loads(r.content)
|
||||
@@ -139,3 +142,17 @@ def test_delete(u1_s1, u1_s2, obj_1):
|
||||
assert r.status_code == 204
|
||||
with scopes_disabled():
|
||||
assert MealPlan.objects.count() == 0
|
||||
|
||||
|
||||
def test_add_with_shopping(u1_s1, meal_type):
|
||||
space = meal_type.space
|
||||
with scope(space=space):
|
||||
recipe = RecipeFactory.create(space=space)
|
||||
r = u1_s1.post(
|
||||
reverse(LIST_URL),
|
||||
{'recipe': {'id': recipe.id, 'name': recipe.name, 'keywords': []}, 'meal_type': {'id': meal_type.id, 'name': meal_type.name},
|
||||
'date': (datetime.now()).strftime("%Y-%m-%d"), 'servings': 1, 'title': 'test', 'shared': [], 'addshopping': True},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert len(json.loads(u1_s1.get(reverse('api:shoppinglistentry-list')).content)) == 10
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
import pytest
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
73
cookbook/tests/api/test_api_related_recipe.py
Normal file
73
cookbook/tests/api/test_api_related_recipe.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import json
|
||||
|
||||
import factory
|
||||
import pytest
|
||||
from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
from pytest_factoryboy import LazyFixture, register
|
||||
|
||||
from cookbook.tests.factories import RecipeFactory
|
||||
|
||||
RELATED_URL = 'api:recipe-related'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def recipe(request, space_1, u1_s1):
|
||||
try:
|
||||
params = request.param # request.param is a magic variable
|
||||
except AttributeError:
|
||||
params = {}
|
||||
step_recipe = params.get('steps__count', 1)
|
||||
steps__recipe_count = params.get('steps__recipe_count', 0)
|
||||
steps__food_recipe_count = params.get('steps__food_recipe_count', {})
|
||||
created_by = params.get('created_by', auth.get_user(u1_s1))
|
||||
|
||||
return RecipeFactory.create(
|
||||
steps__recipe_count=steps__recipe_count,
|
||||
steps__food_recipe_count=steps__food_recipe_count,
|
||||
created_by=created_by,
|
||||
space=space_1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['g1_s1', 200],
|
||||
['u1_s1', 200],
|
||||
['u1_s2', 404],
|
||||
['a1_s1', 200],
|
||||
])
|
||||
@pytest.mark.parametrize("recipe, related_count", [
|
||||
({}, 0),
|
||||
({'steps__recipe_count': 1}, 1), # shopping list from recipe with StepRecipe
|
||||
({'steps__food_recipe_count': {'step': 0, 'count': 1}}, 1), # shopping list from recipe with food recipe
|
||||
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}, 2), # shopping list from recipe with StepRecipe and food recipe
|
||||
], indirect=['recipe'])
|
||||
def test_get_related_recipes(request, arg, recipe, related_count, u1_s1, space_2):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.get(reverse(RELATED_URL, args={recipe.id}))
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 200:
|
||||
assert len(json.loads(r.content)) == related_count
|
||||
|
||||
|
||||
@pytest.mark.parametrize("recipe", [
|
||||
({'steps__recipe_count': 1}), # shopping list from recipe with StepRecipe
|
||||
({'steps__food_recipe_count': {'step': 0, 'count': 1}}), # shopping list from recipe with food recipe
|
||||
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}), # shopping list from recipe with StepRecipe and food recipe
|
||||
], indirect=['recipe'])
|
||||
def test_related_mixed_space(request, recipe, u1_s2):
|
||||
with scopes_disabled():
|
||||
recipe.space = auth.get_user(u1_s2).userpreference.space
|
||||
recipe.save()
|
||||
assert len(json.loads(
|
||||
u1_s2.get(
|
||||
reverse(RELATED_URL, args={recipe.id})).content)) == 0
|
||||
|
||||
|
||||
# TODO if/when related recipes includes multiple levels (related recipes of related recipes) add the following tests
|
||||
# -- step recipes included in step recipes
|
||||
# -- step recipes included in food recipes
|
||||
# -- food recipes included in step recipes
|
||||
# -- food recipes included in food recipes
|
||||
# -- -- included recipes in the wrong space
|
||||
@@ -5,7 +5,7 @@ from django.contrib import auth
|
||||
from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import RecipeBook, Storage, Sync, SyncLog, ShoppingList
|
||||
from cookbook.models import RecipeBook, ShoppingList, Storage, Sync, SyncLog
|
||||
|
||||
LIST_URL = 'api:shoppinglist-list'
|
||||
DETAIL_URL = 'api:shoppinglist-detail'
|
||||
@@ -56,6 +56,21 @@ def test_share(obj_1, u1_s1, u2_s1, u1_s2):
|
||||
assert u1_s2.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 404
|
||||
|
||||
|
||||
def test_new_share(request, obj_1, u1_s1, u2_s1, u1_s2):
|
||||
assert u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 200
|
||||
assert u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 404
|
||||
assert u1_s2.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 404
|
||||
|
||||
with scopes_disabled():
|
||||
user = auth.get_user(u1_s1)
|
||||
user.userpreference.shopping_share.add(auth.get_user(u2_s1))
|
||||
user.userpreference.save()
|
||||
|
||||
assert u1_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 200
|
||||
assert u2_s1.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 200
|
||||
assert u1_s2.get(reverse(DETAIL_URL, args={obj_1.id})).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 404],
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.forms import model_to_dict
|
||||
from django.urls import reverse
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import ShoppingList, ShoppingListEntry, Food
|
||||
from cookbook.models import Food, ShoppingList, ShoppingListEntry
|
||||
|
||||
LIST_URL = 'api:shoppinglistentry-list'
|
||||
DETAIL_URL = 'api:shoppinglistentry-detail'
|
||||
@@ -14,7 +14,7 @@ DETAIL_URL = 'api:shoppinglistentry-detail'
|
||||
|
||||
@pytest.fixture()
|
||||
def obj_1(space_1, u1_s1):
|
||||
e = ShoppingListEntry.objects.create(food=Food.objects.get_or_create(name='test 1', space=space_1)[0])
|
||||
e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 1', space=space_1)[0], space=space_1)
|
||||
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
|
||||
s.entries.add(e)
|
||||
return e
|
||||
@@ -22,7 +22,7 @@ def obj_1(space_1, u1_s1):
|
||||
|
||||
@pytest.fixture
|
||||
def obj_2(space_1, u1_s1):
|
||||
e = ShoppingListEntry.objects.create(food=Food.objects.get_or_create(name='test 2', space=space_1)[0])
|
||||
e = ShoppingListEntry.objects.create(created_by=auth.get_user(u1_s1), food=Food.objects.get_or_create(name='test 2', space=space_1)[0], space=space_1)
|
||||
s = ShoppingList.objects.create(created_by=auth.get_user(u1_s1), space=space_1, )
|
||||
s.entries.add(e)
|
||||
return e
|
||||
@@ -45,8 +45,11 @@ def test_list_space(obj_1, obj_2, u1_s1, u1_s2, space_2):
|
||||
|
||||
with scopes_disabled():
|
||||
s = ShoppingList.objects.first()
|
||||
e = ShoppingListEntry.objects.first()
|
||||
s.space = space_2
|
||||
e.space = space_2
|
||||
s.save()
|
||||
e.save()
|
||||
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 1
|
||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
|
||||
|
||||
222
cookbook/tests/api/test_api_shopping_list_entryv2.py
Normal file
222
cookbook/tests/api/test_api_shopping_list_entryv2.py
Normal file
@@ -0,0 +1,222 @@
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
import factory
|
||||
import pytest
|
||||
from django.contrib import auth
|
||||
from django.forms import model_to_dict
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django_scopes import scopes_disabled
|
||||
from pytest_factoryboy import LazyFixture, register
|
||||
|
||||
from cookbook.models import ShoppingListEntry
|
||||
from cookbook.tests.factories import ShoppingListEntryFactory
|
||||
|
||||
LIST_URL = 'api:shoppinglistentry-list'
|
||||
DETAIL_URL = 'api:shoppinglistentry-detail'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sle(space_1, u1_s1):
|
||||
user = auth.get_user(u1_s1)
|
||||
return ShoppingListEntryFactory.create_batch(10, space=space_1, created_by=user)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sle_2(request):
|
||||
try:
|
||||
params = request.param # request.param is a magic variable
|
||||
except AttributeError:
|
||||
params = {}
|
||||
u = request.getfixturevalue(params.get('user', 'u1_s1'))
|
||||
user = auth.get_user(u)
|
||||
count = params.get('count', 10)
|
||||
return ShoppingListEntryFactory.create_batch(count, space=user.userpreference.space, created_by=user)
|
||||
|
||||
|
||||
@ pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 200],
|
||||
['u1_s1', 200],
|
||||
['a1_s1', 200],
|
||||
])
|
||||
def test_list_permission(arg, request):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
assert c.get(reverse(LIST_URL)).status_code == arg[1]
|
||||
|
||||
|
||||
def test_list_space(sle, u1_s1, u1_s2, space_2):
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 10
|
||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
|
||||
|
||||
with scopes_disabled():
|
||||
e = ShoppingListEntry.objects.first()
|
||||
e.space = space_2
|
||||
e.save()
|
||||
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 9
|
||||
assert len(json.loads(u1_s2.get(reverse(LIST_URL)).content)) == 0
|
||||
|
||||
|
||||
def test_get_detail(u1_s1, sle):
|
||||
r = u1_s1.get(reverse(
|
||||
DETAIL_URL,
|
||||
args={sle[0].id}
|
||||
))
|
||||
assert json.loads(r.content)['id'] == sle[0].id
|
||||
|
||||
|
||||
@ pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 404],
|
||||
['u1_s1', 200],
|
||||
['a1_s1', 404],
|
||||
['g1_s2', 404],
|
||||
['u1_s2', 404],
|
||||
['a1_s2', 404],
|
||||
])
|
||||
def test_update(arg, request, sle):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
new_val = float(sle[0].amount + 1)
|
||||
r = c.patch(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={sle[0].id}
|
||||
),
|
||||
{'amount': new_val},
|
||||
content_type='application/json'
|
||||
)
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 200:
|
||||
response = json.loads(r.content)
|
||||
assert response['amount'] == new_val
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['a_u', 403],
|
||||
['g1_s1', 201],
|
||||
['u1_s1', 201],
|
||||
['a1_s1', 201],
|
||||
])
|
||||
def test_add(arg, request, sle):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
r = c.post(
|
||||
reverse(LIST_URL),
|
||||
{'food': model_to_dict(sle[0].food), 'amount': 1},
|
||||
content_type='application/json'
|
||||
)
|
||||
response = json.loads(r.content)
|
||||
print(r.content)
|
||||
assert r.status_code == arg[1]
|
||||
if r.status_code == 201:
|
||||
assert response['food']['id'] == sle[0].food.pk
|
||||
|
||||
|
||||
def test_delete(u1_s1, u1_s2, sle):
|
||||
r = u1_s2.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={sle[0].id}
|
||||
)
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
r = u1_s1.delete(
|
||||
reverse(
|
||||
DETAIL_URL,
|
||||
args={sle[0].id}
|
||||
)
|
||||
)
|
||||
|
||||
assert r.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shared, count, sle_2", [
|
||||
('g1_s1', 20, {'user': 'g1_s1'}),
|
||||
('g1_s2', 10, {'user': 'g1_s2'}),
|
||||
('u2_s1', 20, {'user': 'u2_s1'}),
|
||||
('u1_s2', 10, {'user': 'u1_s2'}),
|
||||
('a1_s1', 20, {'user': 'a1_s1'}),
|
||||
('a1_s2', 10, {'user': 'a1_s2'}),
|
||||
], indirect=['sle_2'])
|
||||
def test_sharing(request, shared, count, sle_2, sle, u1_s1):
|
||||
user = auth.get_user(u1_s1)
|
||||
shared_client = request.getfixturevalue(shared)
|
||||
shared_user = auth.get_user(shared_client)
|
||||
|
||||
# confirm shared user can't access shopping list items created by u1_s1
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 10
|
||||
assert len(json.loads(shared_client.get(reverse(LIST_URL)).content)) == 10
|
||||
|
||||
user.userpreference.shopping_share.add(shared_user)
|
||||
# confirm sharing user only sees their shopping list
|
||||
assert len(json.loads(u1_s1.get(reverse(LIST_URL)).content)) == 10
|
||||
r = shared_client.get(reverse(LIST_URL))
|
||||
# confirm shared user sees their list and the list that's shared with them
|
||||
assert len(json.loads(r.content)) == count
|
||||
|
||||
|
||||
def test_completed(sle, u1_s1):
|
||||
# check 1 entry
|
||||
#
|
||||
u1_s1.patch(
|
||||
reverse(DETAIL_URL, args={sle[0].id}),
|
||||
{'checked': True},
|
||||
content_type='application/json'
|
||||
)
|
||||
r = json.loads(u1_s1.get(reverse(LIST_URL)).content)
|
||||
assert len(r) == 10
|
||||
# count unchecked entries
|
||||
assert [x['checked'] for x in r].count(False) == 9
|
||||
# confirm completed_at is populated
|
||||
assert [(x['completed_at'] != None) for x in r if x['checked']].count(True) == 1
|
||||
|
||||
assert len(json.loads(u1_s1.get(f'{reverse(LIST_URL)}?checked=0').content)) == 9
|
||||
assert len(json.loads(u1_s1.get(f'{reverse(LIST_URL)}?checked=1').content)) == 1
|
||||
|
||||
# uncheck entry
|
||||
u1_s1.patch(
|
||||
reverse(DETAIL_URL, args={sle[0].id}),
|
||||
{'checked': False},
|
||||
content_type='application/json'
|
||||
)
|
||||
r = json.loads(u1_s1.get(reverse(LIST_URL)).content)
|
||||
assert [x['checked'] for x in r].count(False) == 10
|
||||
# confirm completed_at value cleared
|
||||
assert [(x['completed_at'] != None) for x in r if x['checked']].count(True) == 0
|
||||
|
||||
|
||||
def test_recent(sle, u1_s1):
|
||||
user = auth.get_user(u1_s1)
|
||||
today_start = timezone.now().replace(hour=0, minute=0, second=0)
|
||||
|
||||
# past_date within recent_days threshold
|
||||
past_date = today_start - timedelta(days=user.userpreference.shopping_recent_days - 1)
|
||||
sle[0].checked = True
|
||||
sle[0].completed_at = past_date
|
||||
sle[0].save()
|
||||
|
||||
r = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?recent=1').content)
|
||||
assert len(r) == 10
|
||||
assert [x['checked'] for x in r].count(False) == 9
|
||||
|
||||
# past_date outside recent_days threshold
|
||||
past_date = today_start - timedelta(days=user.userpreference.shopping_recent_days + 2)
|
||||
sle[0].completed_at = past_date
|
||||
sle[0].save()
|
||||
|
||||
r = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?recent=1').content)
|
||||
assert len(r) == 9
|
||||
assert [x['checked'] for x in r].count(False) == 9
|
||||
|
||||
# user preference moved to include entry again
|
||||
user.userpreference.shopping_recent_days = user.userpreference.shopping_recent_days + 4
|
||||
user.userpreference.save()
|
||||
|
||||
r = json.loads(u1_s1.get(f'{reverse(LIST_URL)}?recent=1').content)
|
||||
assert len(r) == 10
|
||||
assert [x['checked'] for x in r].count(False) == 9
|
||||
|
||||
|
||||
# TODO test auto onhand
|
||||
242
cookbook/tests/api/test_api_shopping_recipe.py
Normal file
242
cookbook/tests/api/test_api_shopping_recipe.py
Normal file
@@ -0,0 +1,242 @@
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
import factory
|
||||
import pytest
|
||||
# work around for bug described here https://stackoverflow.com/a/70312265/15762829
|
||||
from django.conf import settings
|
||||
from django.contrib import auth
|
||||
from django.forms import model_to_dict
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from pytest_factoryboy import LazyFixture, register
|
||||
|
||||
from cookbook.models import Food, Ingredient, ShoppingListEntry, Step
|
||||
from cookbook.tests.factories import (IngredientFactory, MealPlanFactory, RecipeFactory,
|
||||
StepFactory, UserFactory)
|
||||
|
||||
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
|
||||
'django.db.backends.postgresql']:
|
||||
from django.db.backends.postgresql.features import DatabaseFeatures
|
||||
DatabaseFeatures.can_defer_constraint_checks = False
|
||||
|
||||
SHOPPING_LIST_URL = 'api:shoppinglistentry-list'
|
||||
SHOPPING_RECIPE_URL = 'api:recipe-shopping'
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def user2(request, u1_s1):
|
||||
try:
|
||||
params = request.param # request.param is a magic variable
|
||||
except AttributeError:
|
||||
params = {}
|
||||
user = auth.get_user(u1_s1)
|
||||
user.userpreference.mealplan_autoadd_shopping = params.get('mealplan_autoadd_shopping', True)
|
||||
user.userpreference.mealplan_autoinclude_related = params.get('mealplan_autoinclude_related', True)
|
||||
user.userpreference.mealplan_autoexclude_onhand = params.get('mealplan_autoexclude_onhand', True)
|
||||
user.userpreference.save()
|
||||
return u1_s1
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def recipe(request, space_1, u1_s1):
|
||||
try:
|
||||
params = request.param # request.param is a magic variable
|
||||
except AttributeError:
|
||||
params = {}
|
||||
# step_recipe = params.get('steps__count', 1)
|
||||
# steps__recipe_count = params.get('steps__recipe_count', 0)
|
||||
# steps__food_recipe_count = params.get('steps__food_recipe_count', {})
|
||||
params['created_by'] = params.get('created_by', auth.get_user(u1_s1))
|
||||
params['space'] = space_1
|
||||
return RecipeFactory(**params)
|
||||
|
||||
# return RecipeFactory.create(
|
||||
# steps__recipe_count=steps__recipe_count,
|
||||
# steps__food_recipe_count=steps__food_recipe_count,
|
||||
# created_by=created_by,
|
||||
# space=space_1,
|
||||
# )
|
||||
|
||||
|
||||
@pytest.mark.parametrize("arg", [
|
||||
['g1_s1', 204],
|
||||
['u1_s1', 204],
|
||||
['u1_s2', 404],
|
||||
['a1_s1', 204],
|
||||
])
|
||||
@pytest.mark.parametrize("recipe, sle_count", [
|
||||
({}, 10),
|
||||
({'steps__recipe_count': 1}, 20), # shopping list from recipe with StepRecipe
|
||||
({'steps__food_recipe_count': {'step': 0, 'count': 1}}, 19), # shopping list from recipe with food recipe
|
||||
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}, 29), # shopping list from recipe with StepRecipe and food recipe
|
||||
], indirect=['recipe'])
|
||||
def test_shopping_recipe_method(request, arg, recipe, sle_count, u1_s1, u2_s1):
|
||||
c = request.getfixturevalue(arg[0])
|
||||
user = auth.get_user(c)
|
||||
user.userpreference.mealplan_autoadd_shopping = True
|
||||
user.userpreference.save()
|
||||
|
||||
assert len(json.loads(c.get(reverse(SHOPPING_LIST_URL)).content)) == 0
|
||||
|
||||
url = reverse(SHOPPING_RECIPE_URL, args={recipe.id})
|
||||
r = c.put(url)
|
||||
assert r.status_code == arg[1]
|
||||
# only PUT method should work
|
||||
if r.status_code == 204: # skip anonymous user
|
||||
|
||||
r = json.loads(c.get(reverse(SHOPPING_LIST_URL)).content)
|
||||
assert len(r) == sle_count # recipe factory creates 10 ingredients by default
|
||||
assert [x['created_by']['id'] for x in r].count(user.id) == sle_count
|
||||
# user in space can't see shopping list
|
||||
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0
|
||||
user.userpreference.shopping_share.add(auth.get_user(u2_s1))
|
||||
# after share, user in space can see shopping list
|
||||
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
|
||||
# confirm that the author of the recipe doesn't have access to shopping list
|
||||
if c != u1_s1:
|
||||
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0
|
||||
|
||||
r = c.get(url)
|
||||
assert r.status_code == 405
|
||||
r = c.post(url)
|
||||
assert r.status_code == 405
|
||||
r = c.delete(url)
|
||||
assert r.status_code == 405
|
||||
|
||||
|
||||
@pytest.mark.parametrize("recipe, sle_count", [
|
||||
({}, 10),
|
||||
({'steps__recipe_count': 1}, 20), # shopping list from recipe with StepRecipe
|
||||
({'steps__food_recipe_count': {'step': 0, 'count': 1}}, 19), # shopping list from recipe with food recipe
|
||||
({'steps__food_recipe_count': {'step': 0, 'count': 1}, 'steps__recipe_count': 1}, 29), # shopping list from recipe with StepRecipe and food recipe
|
||||
], indirect=['recipe'])
|
||||
@pytest.mark.parametrize("use_mealplan", [(False), (True), ])
|
||||
def test_shopping_recipe_edit(request, recipe, sle_count, use_mealplan, u1_s1, u2_s1):
|
||||
# tests editing shopping list via recipe or mealplan
|
||||
with scopes_disabled():
|
||||
user = auth.get_user(u1_s1)
|
||||
user2 = auth.get_user(u2_s1)
|
||||
user.userpreference.mealplan_autoinclude_related = True
|
||||
user.userpreference.mealplan_autoadd_shopping = True
|
||||
user.userpreference.shopping_share.add(user2)
|
||||
user.userpreference.save()
|
||||
|
||||
if use_mealplan:
|
||||
mealplan = MealPlanFactory(space=recipe.space, created_by=user, servings=recipe.servings, recipe=recipe)
|
||||
else:
|
||||
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}))
|
||||
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||
assert [x['created_by']['id'] for x in r].count(user.id) == sle_count
|
||||
all_ing = [x['ingredient'] for x in r]
|
||||
keep_ing = all_ing[1:-1] # remove first and last element
|
||||
del keep_ing[int(len(keep_ing)/2)] # remove a middle element
|
||||
list_recipe = r[0]['list_recipe']
|
||||
amount_sum = sum([x['amount'] for x in r])
|
||||
|
||||
# test modifying shopping list as different user
|
||||
# test increasing servings size of recipe shopping list
|
||||
if use_mealplan:
|
||||
mealplan.servings = 2*recipe.servings
|
||||
mealplan.save()
|
||||
else:
|
||||
u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
|
||||
{'list_recipe': list_recipe, 'servings': 2*recipe.servings},
|
||||
content_type='application/json'
|
||||
)
|
||||
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||
assert sum([x['amount'] for x in r]) == amount_sum * 2
|
||||
assert len(r) == sle_count
|
||||
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
|
||||
|
||||
# testing decreasing servings size of recipe shopping list
|
||||
if use_mealplan:
|
||||
mealplan.servings = .5 * recipe.servings
|
||||
mealplan.save()
|
||||
else:
|
||||
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
|
||||
{'list_recipe': list_recipe, 'servings': .5 * recipe.servings},
|
||||
content_type='application/json'
|
||||
)
|
||||
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||
assert sum([x['amount'] for x in r]) == amount_sum * .5
|
||||
assert len(r) == sle_count
|
||||
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
|
||||
|
||||
# test removing 2 items from shopping list
|
||||
u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
|
||||
{'list_recipe': list_recipe, 'ingredients': keep_ing},
|
||||
content_type='application/json'
|
||||
)
|
||||
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||
assert len(r) == sle_count - 3
|
||||
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count - 3
|
||||
|
||||
# add all ingredients to existing shopping list - don't change serving size
|
||||
u2_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}),
|
||||
{'list_recipe': list_recipe, 'ingredients': all_ing},
|
||||
content_type='application/json'
|
||||
)
|
||||
r = json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)
|
||||
assert sum([x['amount'] for x in r]) == amount_sum * .5
|
||||
assert len(r) == sle_count
|
||||
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count
|
||||
|
||||
|
||||
@pytest.mark.parametrize("user2, sle_count", [
|
||||
({'mealplan_autoadd_shopping': False}, (0, 18)),
|
||||
({'mealplan_autoinclude_related': False}, (9, 9)),
|
||||
({'mealplan_autoexclude_onhand': False}, (20, 20)),
|
||||
({'mealplan_autoexclude_onhand': False, 'mealplan_autoinclude_related': False}, (10, 10)),
|
||||
], indirect=['user2'])
|
||||
@pytest.mark.parametrize("use_mealplan", [(False), (True), ])
|
||||
@pytest.mark.parametrize("recipe", [({'steps__recipe_count': 1})], indirect=['recipe'])
|
||||
def test_shopping_recipe_userpreference(recipe, sle_count, use_mealplan, user2):
|
||||
with scopes_disabled():
|
||||
user = auth.get_user(user2)
|
||||
# setup recipe with 10 ingredients, 1 step recipe with 10 ingredients, 2 food onhand(from recipe and step_recipe)
|
||||
ingredients = Ingredient.objects.filter(step__recipe=recipe)
|
||||
food = Food.objects.get(id=ingredients[2].food.id)
|
||||
food.onhand_users.add(user)
|
||||
food.save()
|
||||
food = recipe.steps.exclude(step_recipe=None).first().step_recipe.steps.first().ingredients.first().food
|
||||
food = Food.objects.get(id=food.id)
|
||||
food.onhand_users.add(user)
|
||||
food.save()
|
||||
|
||||
if use_mealplan:
|
||||
mealplan = MealPlanFactory(space=recipe.space, created_by=user, servings=recipe.servings, recipe=recipe)
|
||||
assert len(json.loads(user2.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count[0]
|
||||
else:
|
||||
user2.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}))
|
||||
assert len(json.loads(user2.get(reverse(SHOPPING_LIST_URL)).content)) == sle_count[1]
|
||||
|
||||
|
||||
def test_shopping_recipe_mixed_authors(u1_s1, u2_s1):
|
||||
with scopes_disabled():
|
||||
user1 = auth.get_user(u1_s1)
|
||||
user2 = auth.get_user(u2_s1)
|
||||
space = user1.userpreference.space
|
||||
user3 = UserFactory(space=space)
|
||||
recipe1 = RecipeFactory(created_by=user1, space=space)
|
||||
recipe2 = RecipeFactory(created_by=user2, space=space)
|
||||
recipe3 = RecipeFactory(created_by=user3, space=space)
|
||||
food = Food.objects.get(id=recipe1.steps.first().ingredients.first().food.id)
|
||||
food.recipe = recipe2
|
||||
food.save()
|
||||
recipe1.steps.add(StepFactory(step_recipe=recipe3, ingredients__count=0, space=space))
|
||||
recipe1.save()
|
||||
|
||||
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe1.id}))
|
||||
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 29
|
||||
assert len(json.loads(u2_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("recipe", [{'steps__ingredients__header': 1}], indirect=['recipe'])
|
||||
def test_shopping_with_header_ingredient(u1_s1, recipe):
|
||||
# with scope(space=recipe.space):
|
||||
# recipe.step_set.first().ingredient_set.add(IngredientFactory(ingredients__header=1))
|
||||
u1_s1.put(reverse(SHOPPING_RECIPE_URL, args={recipe.id}))
|
||||
assert len(json.loads(u1_s1.get(reverse(SHOPPING_LIST_URL)).content)) == 10
|
||||
assert len(json.loads(u1_s1.get(reverse('api:ingredient-list')).content)) == 11
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user