mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-27 04:00:48 -05:00
Compare commits
806 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
625c04257e | ||
|
|
5521c29d43 | ||
|
|
dc3a530928 | ||
|
|
48f63b4d9f | ||
|
|
1604ae51de | ||
|
|
a1502bffd1 | ||
|
|
756e5ec668 | ||
|
|
b9cf0a7136 | ||
|
|
6bf72c4043 | ||
|
|
f2f8342b49 | ||
|
|
7d82393789 | ||
|
|
2254d6f072 | ||
|
|
8d02cad7d9 | ||
|
|
5c12d00f49 | ||
|
|
b37fc4e24f | ||
|
|
35a7f62837 | ||
|
|
ffc1c5a99c | ||
|
|
fc2a60a4ba | ||
|
|
fb0f424d82 | ||
|
|
ffb5291f4b | ||
|
|
77ba482b79 | ||
|
|
abb0be69d8 | ||
|
|
7bb23e8362 | ||
|
|
cbb659da41 | ||
|
|
52b0382243 | ||
|
|
3371dc949a | ||
|
|
49f026f2bd | ||
|
|
cce373a522 | ||
|
|
b4b3e659de | ||
|
|
f81500ec99 | ||
|
|
ecba13e97f | ||
|
|
191a6c0d9b | ||
|
|
52ba2be586 | ||
|
|
36129b29b4 | ||
|
|
99e3690a42 | ||
|
|
c780f81dd8 | ||
|
|
27f5e85e11 | ||
|
|
e6d9ffbb9c | ||
|
|
3b188c3c55 | ||
|
|
161e70dc9c | ||
|
|
1f80df262b | ||
|
|
9928675f48 | ||
|
|
3d925b29c2 | ||
|
|
4825317a58 | ||
|
|
9a5408e996 | ||
|
|
f13c1a2605 | ||
|
|
bc8aadbe4e | ||
|
|
e60843b54c | ||
|
|
46dce472db | ||
|
|
556ca1bcb1 | ||
|
|
d9b2fcaa87 | ||
|
|
3e950602a7 | ||
|
|
1fe0757f6c | ||
|
|
b6cf1cf5e6 | ||
|
|
ca7d487789 | ||
|
|
cb44136b2e | ||
|
|
c92331a79c | ||
|
|
82e7118757 | ||
|
|
6d9817183e | ||
|
|
0e05d5b18d | ||
|
|
da1d88314b | ||
|
|
c46f22d71e | ||
|
|
f21de5eddc | ||
|
|
e88fb88d8a | ||
|
|
6eb14daf4d | ||
|
|
fad40dab6c | ||
|
|
015e01afb9 | ||
|
|
2acdd16d9e | ||
|
|
d15162337f | ||
|
|
0862c0f0bc | ||
|
|
a811d4a55c | ||
|
|
01a53ad8ec | ||
|
|
c7e20716f5 | ||
|
|
7adc4ad50a | ||
|
|
61ded5094f | ||
|
|
7aff5dc44a | ||
|
|
b5e5ff9bf8 | ||
|
|
a8434ce745 | ||
|
|
cf926295cf | ||
|
|
dbd7ae4adc | ||
|
|
ba20fa1ff5 | ||
|
|
a5b92f5672 | ||
|
|
ed412d11b7 | ||
|
|
b36d0620e0 | ||
|
|
d16d77f640 | ||
|
|
d72f90b90e | ||
|
|
9c78bcd662 | ||
|
|
b8c8fc3b58 | ||
|
|
e1110000ab | ||
|
|
00194f68bf | ||
|
|
4f4c324c30 | ||
|
|
106129e779 | ||
|
|
c5d509bf9e | ||
|
|
8b0f9bc2e7 | ||
|
|
4db17874c4 | ||
|
|
f6c491a8e6 | ||
|
|
e4a9f56352 | ||
|
|
c1287407a3 | ||
|
|
ba1e18410a | ||
|
|
7f8e29f1bc | ||
|
|
6334bee608 | ||
|
|
681c57201a | ||
|
|
738f0781b2 | ||
|
|
6143e31e1a | ||
|
|
d6e6ab24c2 | ||
|
|
0492182803 | ||
|
|
22713d884e | ||
|
|
bd14b77f62 | ||
|
|
6a89ed1bb6 | ||
|
|
25717f6a79 | ||
|
|
d3ecd52fd2 | ||
|
|
1830eb4edc | ||
|
|
81a8734fac | ||
|
|
abcef54e72 | ||
|
|
e15c92cda5 | ||
|
|
45dba6fad2 | ||
|
|
f67bb3cb98 | ||
|
|
53b584da56 | ||
|
|
58fc26904b | ||
|
|
7527646319 | ||
|
|
d3b1139a22 | ||
|
|
f04a51c1ad | ||
|
|
7fbff9f3b5 | ||
|
|
bb0f3e1778 | ||
|
|
6ab8d6bd0d | ||
|
|
69a51e0640 | ||
|
|
1425e795ff | ||
|
|
afadc61d5d | ||
|
|
ce8524b247 | ||
|
|
fd09ae1510 | ||
|
|
9137fbfb97 | ||
|
|
f0ac55c20e | ||
|
|
249663bd91 | ||
|
|
55920501b8 | ||
|
|
cc3e00e75f | ||
|
|
661f7ae789 | ||
|
|
8bfbd96398 | ||
|
|
8a051b531d | ||
|
|
de9456e3d7 | ||
|
|
950315936e | ||
|
|
af1bc19fd8 | ||
|
|
906da25301 | ||
|
|
fe1ddf1237 | ||
|
|
48c90c483a | ||
|
|
3c1b6a5f3a | ||
|
|
780c929162 | ||
|
|
e00b6b9293 | ||
|
|
8c9ee37c46 | ||
|
|
45a0bda758 | ||
|
|
e71417e77f | ||
|
|
29e6eda9cd | ||
|
|
32c984c7f0 | ||
|
|
b68d2ba384 | ||
|
|
daf0be37fd | ||
|
|
2e625715cc | ||
|
|
6a25428b3c | ||
|
|
cb78f75f19 | ||
|
|
77cfcb4602 | ||
|
|
d2a4a9d953 | ||
|
|
9280540927 | ||
|
|
2f77532111 | ||
|
|
2149f4034b | ||
|
|
76eeed1a77 | ||
|
|
ad0d802e41 | ||
|
|
e41464cb31 | ||
|
|
ab3f7bf671 | ||
|
|
e968a57c06 | ||
|
|
641feede74 | ||
|
|
b48708652f | ||
|
|
c2addc1121 | ||
|
|
a25109e16c | ||
|
|
ae81b10dbd | ||
|
|
4d6d84bf5b | ||
|
|
a8a132e2a1 | ||
|
|
3b0413c30e | ||
|
|
d767743b64 | ||
|
|
e8f7caebd1 | ||
|
|
a7b7272bec | ||
|
|
d7402f60c5 | ||
|
|
d049cf6d3d | ||
|
|
fdcdf6a026 | ||
|
|
7e38e946a5 | ||
|
|
b552badff7 | ||
|
|
d10c84b66e | ||
|
|
e7f8d58a7d | ||
|
|
528f329ebb | ||
|
|
b9b7a125f0 | ||
|
|
4742056223 | ||
|
|
ba4c3b95e5 | ||
|
|
4c03520371 | ||
|
|
1f5ddd9af7 | ||
|
|
d3e6b34a63 | ||
|
|
323f424630 | ||
|
|
eed6e9d3a5 | ||
|
|
f4af7ffb0b | ||
|
|
3b072d5dd9 | ||
|
|
11240dcf48 | ||
|
|
0b5fc1a9f4 | ||
|
|
049449bda3 | ||
|
|
e5a19302f0 | ||
|
|
e1fa939757 | ||
|
|
3da33e364e | ||
|
|
bfaed434cc | ||
|
|
b6acc17e5a | ||
|
|
5fd03e7cdc | ||
|
|
54e71f2910 | ||
|
|
c99c944130 | ||
|
|
94c9185bcf | ||
|
|
74e731e334 | ||
|
|
d01f7409bf | ||
|
|
29ab6cfb2d | ||
|
|
59b2da933d | ||
|
|
99b5f9a3ec | ||
|
|
9057eac42c | ||
|
|
24b5cdff85 | ||
|
|
f2630c3ba0 | ||
|
|
21740522bc | ||
|
|
47090ce863 | ||
|
|
3ac22c08ff | ||
|
|
cc62b088fd | ||
|
|
2c34425135 | ||
|
|
205f76d128 | ||
|
|
4147bc61c7 | ||
|
|
dfae453925 | ||
|
|
7507cae44c | ||
|
|
28312774bd | ||
|
|
058723d583 | ||
|
|
db4abdd31d | ||
|
|
727b0e9e61 | ||
|
|
aa41146735 | ||
|
|
670fc9bf35 | ||
|
|
d9a5649adc | ||
|
|
5ed300a3ea | ||
|
|
59cc22a877 | ||
|
|
8dffc07072 | ||
|
|
76c7ad1ff5 | ||
|
|
7f391c25a4 | ||
|
|
bccc41d177 | ||
|
|
cc882082d2 | ||
|
|
689918c1ac | ||
|
|
1c43be3899 | ||
|
|
40387428e7 | ||
|
|
46fb02376e | ||
|
|
24e43e3e2e | ||
|
|
846c660811 | ||
|
|
beb4aa634f | ||
|
|
58c6077925 | ||
|
|
d7675d4b80 | ||
|
|
e2b1115b3b | ||
|
|
96c963795e | ||
|
|
804adde964 | ||
|
|
5aa918f478 | ||
|
|
a44f72a030 | ||
|
|
ad163509b4 | ||
|
|
fb58d35029 | ||
|
|
79f823cd62 | ||
|
|
c42266b82c | ||
|
|
c60c3f1876 | ||
|
|
fc5455a0f2 | ||
|
|
28d8f62af7 | ||
|
|
b6b505c361 | ||
|
|
97cef449c9 | ||
|
|
fef6f695ce | ||
|
|
73a24a8ef0 | ||
|
|
e727cae020 | ||
|
|
c6dd55df4e | ||
|
|
6962b0e218 | ||
|
|
894d2d2e6b | ||
|
|
8bf4a32dfd | ||
|
|
505650518e | ||
|
|
35f3ecc7eb | ||
|
|
543e52d596 | ||
|
|
f39433142d | ||
|
|
f2765c75c6 | ||
|
|
47049808b7 | ||
|
|
150d4c7309 | ||
|
|
d116d08adf | ||
|
|
82d2e479b2 | ||
|
|
df81aec02e | ||
|
|
74779fc488 | ||
|
|
ac9922ff61 | ||
|
|
ff0cd6fa93 | ||
|
|
777f4518be | ||
|
|
84591fd17a | ||
|
|
7536425e39 | ||
|
|
9d28ce48fe | ||
|
|
d6b438b5f4 | ||
|
|
87d6ca0200 | ||
|
|
bcda57a4fa | ||
|
|
3e55207a8d | ||
|
|
80eee945a0 | ||
|
|
3436ef4877 | ||
|
|
20e9d4a990 | ||
|
|
3a1c9aa462 | ||
|
|
de9f0ad8f8 | ||
|
|
61f43f78ec | ||
|
|
a3f2c1bed2 | ||
|
|
e0a0eeeecc | ||
|
|
4a4dafd69c | ||
|
|
6781128c1b | ||
|
|
73b7f60222 | ||
|
|
46a9d19374 | ||
|
|
6ba1ff4505 | ||
|
|
58c5b2c301 | ||
|
|
5d1d6d4248 | ||
|
|
0f251bee9b | ||
|
|
149c5b5f5e | ||
|
|
7d051336d3 | ||
|
|
79da8db889 | ||
|
|
ec842aa657 | ||
|
|
61c2d5eb61 | ||
|
|
41e3ec41e9 | ||
|
|
086570ce90 | ||
|
|
d2783429a1 | ||
|
|
de19a10cba | ||
|
|
f312631676 | ||
|
|
6c52b7bbd9 | ||
|
|
900f1a6f7a | ||
|
|
ff0a7c5262 | ||
|
|
e0acd1de83 | ||
|
|
585c31490a | ||
|
|
3e7f96c0b8 | ||
|
|
d45adc1688 | ||
|
|
c857d092b1 | ||
|
|
b0fe98c091 | ||
|
|
103878e107 | ||
|
|
175fca2b39 | ||
|
|
4600aab13a | ||
|
|
966a107414 | ||
|
|
69674e2648 | ||
|
|
bcd2e44493 | ||
|
|
e745e4be0c | ||
|
|
3afd18ccdc | ||
|
|
24f1fb228e | ||
|
|
431e213514 | ||
|
|
1e00fa16db | ||
|
|
ac1c283efb | ||
|
|
0a6a8b760f | ||
|
|
4cdd784259 | ||
|
|
461cb20a4f | ||
|
|
7ebf4d5e2a | ||
|
|
e50d3233fd | ||
|
|
cc980b2e8a | ||
|
|
93e965697a | ||
|
|
8d65d20d1f | ||
|
|
a112824578 | ||
|
|
6192277778 | ||
|
|
148324b37f | ||
|
|
c30ce471c2 | ||
|
|
63cfa14a21 | ||
|
|
53c715b6f6 | ||
|
|
96146a388a | ||
|
|
d2a0bb1ec1 | ||
|
|
d826b9f38a | ||
|
|
e8d9cc6ad9 | ||
|
|
e9689d347c | ||
|
|
b275fdcf62 | ||
|
|
a0ebc47ade | ||
|
|
b698fad83a | ||
|
|
5e53c66eaa | ||
|
|
37008ef290 | ||
|
|
35ee5847ca | ||
|
|
935dee853e | ||
|
|
7b75e279b0 | ||
|
|
15c758b24a | ||
|
|
26ec1724a5 | ||
|
|
96c4823664 | ||
|
|
5ab19b7958 | ||
|
|
09716f2b00 | ||
|
|
138a29770a | ||
|
|
36584346cb | ||
|
|
c7dd5dd8bb | ||
|
|
a16ad2c887 | ||
|
|
ca728b45ca | ||
|
|
9fd87dbf23 | ||
|
|
384a49b1c6 | ||
|
|
477236009c | ||
|
|
93cff8873e | ||
|
|
d9feb61e85 | ||
|
|
00875c0d8e | ||
|
|
f1b7ed7d7a | ||
|
|
fce293e722 | ||
|
|
09062cb12c | ||
|
|
098f88e0b8 | ||
|
|
6992bf83aa | ||
|
|
32044907bf | ||
|
|
3fcd613ca8 | ||
|
|
5b8a22762b | ||
|
|
c41c319d25 | ||
|
|
6690c3b206 | ||
|
|
56bcd4f887 | ||
|
|
47c690526e | ||
|
|
ec14338159 | ||
|
|
bf3fe1c716 | ||
|
|
fede79fc04 | ||
|
|
9251613cd6 | ||
|
|
0bd6df9d57 | ||
|
|
24e660156c | ||
|
|
345ffe4d6d | ||
|
|
e5b7cf5f30 | ||
|
|
b563447674 | ||
|
|
523a2b41d1 | ||
|
|
a0741f6ad3 | ||
|
|
b52c3d6bd4 | ||
|
|
803369a7a6 | ||
|
|
2e3e629406 | ||
|
|
d7894e07e9 | ||
|
|
63dbdfa4a6 | ||
|
|
961b3f07b5 | ||
|
|
7a1ee9a9b2 | ||
|
|
f18980a9e2 | ||
|
|
08733751aa | ||
|
|
b271f81af2 | ||
|
|
0900e4c57d | ||
|
|
d99e523608 | ||
|
|
50829dce47 | ||
|
|
910dc29f06 | ||
|
|
486c871cb5 | ||
|
|
2b94500ffe | ||
|
|
d25ea34512 | ||
|
|
fbc3dcdfef | ||
|
|
642015b368 | ||
|
|
99f06955dc | ||
|
|
9e5a7b2cc0 | ||
|
|
948eb9be3e | ||
|
|
ec778edb93 | ||
|
|
a431031c04 | ||
|
|
082a656210 | ||
|
|
9f51b9fd16 | ||
|
|
98aadf2869 | ||
|
|
54107000af | ||
|
|
999fe2bc61 | ||
|
|
23bd0a7d90 | ||
|
|
f9059f636c | ||
|
|
95aff5c998 | ||
|
|
bf9b8a0230 | ||
|
|
c79432567c | ||
|
|
ddc484562b | ||
|
|
97b5f64718 | ||
|
|
b042ab72cd | ||
|
|
a59ac44f3b | ||
|
|
acafcbc077 | ||
|
|
6ff0e3b7b3 | ||
|
|
bb43ed203a | ||
|
|
1bb412e007 | ||
|
|
e69d1c3408 | ||
|
|
cd51d12618 | ||
|
|
ee130f9077 | ||
|
|
0eebd438ca | ||
|
|
983d40f2c1 | ||
|
|
bbd01fdb04 | ||
|
|
ea2f493e01 | ||
|
|
9b364d57c7 | ||
|
|
068a09e28e | ||
|
|
83c7e318ea | ||
|
|
24ed6a1cd2 | ||
|
|
816ced83b5 | ||
|
|
b93fb99e1b | ||
|
|
9203b8e985 | ||
|
|
7b936ec4fd | ||
|
|
99f0ab830b | ||
|
|
34028587fc | ||
|
|
df0cfc3677 | ||
|
|
16e2af8c5d | ||
|
|
02aec7d6d6 | ||
|
|
cb913f6cea | ||
|
|
5bb20bd479 | ||
|
|
c561ddc08c | ||
|
|
fd3743377b | ||
|
|
381c3bf3fa | ||
|
|
5d37a1dc0b | ||
|
|
cf07040ece | ||
|
|
f2028ee928 | ||
|
|
0c39ddcf66 | ||
|
|
8ddbc34017 | ||
|
|
6ef06b2650 | ||
|
|
67581c7fa4 | ||
|
|
bc1f28eda6 | ||
|
|
61daf9d5c9 | ||
|
|
a37c77bb84 | ||
|
|
6d2c48a1c8 | ||
|
|
bed5b72864 | ||
|
|
325d6e4326 | ||
|
|
f7ff700c7a | ||
|
|
41c8e53569 | ||
|
|
ff8e431630 | ||
|
|
eb9b2ac6fe | ||
|
|
1ad468e652 | ||
|
|
986bda0c81 | ||
|
|
bb361001b9 | ||
|
|
26aa0207aa | ||
|
|
8b1bd3c555 | ||
|
|
b59c7288b1 | ||
|
|
f5b456018d | ||
|
|
4dad26102a | ||
|
|
afc7718c95 | ||
|
|
e5ef19ffe4 | ||
|
|
ecf065db2b | ||
|
|
4c03d1eb87 | ||
|
|
b71e9fe57d | ||
|
|
7ab6276397 | ||
|
|
c7da37e7e7 | ||
|
|
00f9bc087c | ||
|
|
f2c658cb2d | ||
|
|
c35f71370e | ||
|
|
edb9c883f7 | ||
|
|
0405c123f4 | ||
|
|
b84a330883 | ||
|
|
2d8c6ef44a | ||
|
|
6af5f59c80 | ||
|
|
3c4384e2f6 | ||
|
|
e50239f067 | ||
|
|
4fa6919ca0 | ||
|
|
db6fe4256f | ||
|
|
8a73f018f0 | ||
|
|
b93b16d6eb | ||
|
|
6acf4bb831 | ||
|
|
86134eecb4 | ||
|
|
3716e2bb0f | ||
|
|
b5e08a4828 | ||
|
|
976cedd536 | ||
|
|
4af5a4e96e | ||
|
|
9044f9e1ff | ||
|
|
4b719af4e1 | ||
|
|
78fa5338d3 | ||
|
|
e9f2b875b9 | ||
|
|
fe3f817bc5 | ||
|
|
32f5cf64e5 | ||
|
|
5aadb66013 | ||
|
|
6225648e3a | ||
|
|
29903af085 | ||
|
|
8ed2562454 | ||
|
|
fd255fd6ad | ||
|
|
8931fa8557 | ||
|
|
251bd88f70 | ||
|
|
2ac076afa5 | ||
|
|
2d82fc1ddd | ||
|
|
ba9d85dfc9 | ||
|
|
c752b2e81b | ||
|
|
19df1cf65d | ||
|
|
ebdd8fc053 | ||
|
|
924576c8ba | ||
|
|
f4fa28bfbc | ||
|
|
0c2cb599ba | ||
|
|
54f85196e7 | ||
|
|
a1093ed918 | ||
|
|
caa33810c4 | ||
|
|
fd8229684c | ||
|
|
320d94a223 | ||
|
|
43ccc351c7 | ||
|
|
d36e4c6e0a | ||
|
|
fdeede5717 | ||
|
|
738b601462 | ||
|
|
2c93a2f177 | ||
|
|
6b2d164585 | ||
|
|
ee707eba5c | ||
|
|
f26b09cc0a | ||
|
|
4e88f846af | ||
|
|
6a1d892e8b | ||
|
|
b90c70b2a3 | ||
|
|
bcf50f30bc | ||
|
|
065ed6c437 | ||
|
|
285e09f40a | ||
|
|
0398f36949 | ||
|
|
ea30eb96cd | ||
|
|
b787ae49bb | ||
|
|
f8e2283a69 | ||
|
|
13d51a7b46 | ||
|
|
e74ae06b64 | ||
|
|
aa495250c9 | ||
|
|
f8ee48c23b | ||
|
|
320246b18b | ||
|
|
00992da998 | ||
|
|
2b9ad2feed | ||
|
|
257127bd4e | ||
|
|
b1df118140 | ||
|
|
da6b437b20 | ||
|
|
6fe4c79b0d | ||
|
|
1793753cb4 | ||
|
|
9ed1aff0d2 | ||
|
|
51c3ec5762 | ||
|
|
5feeabb498 | ||
|
|
c4aa3eb019 | ||
|
|
e8b9f473a6 | ||
|
|
279b4dc025 | ||
|
|
6a1226ca26 | ||
|
|
b9ee7d53fa | ||
|
|
ace7ee4274 | ||
|
|
16968db1cf | ||
|
|
2b24155dd2 | ||
|
|
5a7c914fe7 | ||
|
|
f822e03be0 | ||
|
|
1bdf14dbf9 | ||
|
|
6ef173d82d | ||
|
|
1e471ad40d | ||
|
|
4ff1a6bc93 | ||
|
|
7d1f47edc5 | ||
|
|
f69d7898d5 | ||
|
|
9692e2386b | ||
|
|
93b2e2d7e4 | ||
|
|
8b2833f353 | ||
|
|
643dbbc294 | ||
|
|
c4273a4c3f | ||
|
|
95461316a5 | ||
|
|
1775b64ba4 | ||
|
|
5a9270373f | ||
|
|
37f98ce9fe | ||
|
|
fa556c9a7f | ||
|
|
29e1d1286c | ||
|
|
f489043077 | ||
|
|
bdd004518c | ||
|
|
840f5ec60d | ||
|
|
566eea1d75 | ||
|
|
bb48655acb | ||
|
|
d723165b1c | ||
|
|
592bd4f11e | ||
|
|
0aec23fcdd | ||
|
|
a23dc717aa | ||
|
|
d364994ed7 | ||
|
|
a38ed28512 | ||
|
|
1f5c02bcc3 | ||
|
|
f4afdfbc07 | ||
|
|
f753b63b13 | ||
|
|
6f3068a28c | ||
|
|
aa57b47d18 | ||
|
|
113e9ef1e3 | ||
|
|
5899527621 | ||
|
|
0a40de0f14 | ||
|
|
bc31f013c0 | ||
|
|
e7fc15dc72 | ||
|
|
79396cec9e | ||
|
|
5e07c6130f | ||
|
|
94e1fdfbff | ||
|
|
a0d414c83f | ||
|
|
1441368465 | ||
|
|
6f301c4771 | ||
|
|
ec31d251ea | ||
|
|
289625923f | ||
|
|
a42a76a2cf | ||
|
|
fd1216cd22 | ||
|
|
3f6a342026 | ||
|
|
f72fc699f8 | ||
|
|
cdcca80196 | ||
|
|
400cd2f6a0 | ||
|
|
37a4821d01 | ||
|
|
d165075a96 | ||
|
|
a062173ebd | ||
|
|
806963c396 | ||
|
|
851853740d | ||
|
|
7ca88f3c0a | ||
|
|
ac2e9dd6cb | ||
|
|
b2a34ce59a | ||
|
|
6124501f5a | ||
|
|
4ec313f752 | ||
|
|
dd07c56ede | ||
|
|
77fae46aee | ||
|
|
ff573b0358 | ||
|
|
247eab2a4f | ||
|
|
dc46502667 | ||
|
|
ac58f1959d | ||
|
|
f4543f8d65 | ||
|
|
d0ef5e27df | ||
|
|
9ea90f1c87 | ||
|
|
e7922a7e47 | ||
|
|
d3bc440c83 | ||
|
|
910b28fe2d | ||
|
|
89cd8bc2d2 | ||
|
|
d2e9ad2ae6 | ||
|
|
56c9edd328 | ||
|
|
7732aa7646 | ||
|
|
9863447bac | ||
|
|
53b00cc4c8 | ||
|
|
4f34ec1be8 | ||
|
|
e444ba91f0 | ||
|
|
fa3513eb65 | ||
|
|
d323778f1d | ||
|
|
53cb5afef6 | ||
|
|
0349301919 | ||
|
|
a5d2bd75d6 | ||
|
|
26499ad431 | ||
|
|
2eb72953f0 | ||
|
|
6fd9cf0d8c | ||
|
|
1e800889e4 | ||
|
|
422113a745 | ||
|
|
e687d0e569 | ||
|
|
76c1529ec1 | ||
|
|
d30b2b7ec8 | ||
|
|
c4fbad614e | ||
|
|
4c92a4b39c | ||
|
|
3dad5132bb | ||
|
|
7d7890445e | ||
|
|
cea015f23d | ||
|
|
3e8610912e | ||
|
|
ba80ca42e6 | ||
|
|
c413db5460 | ||
|
|
0e319ff293 | ||
|
|
88e3b22dcd | ||
|
|
19e2094ecd | ||
|
|
215989682b | ||
|
|
2f038edf8c | ||
|
|
a754002f4e | ||
|
|
1af2211010 | ||
|
|
724d57ecd7 | ||
|
|
4b04fada51 | ||
|
|
4ad7043f91 | ||
|
|
4dfda4439c | ||
|
|
591d185b9d | ||
|
|
8d582548bd | ||
|
|
209924e5b3 | ||
|
|
7e3e2aadaf | ||
|
|
0930e615f0 | ||
|
|
21c759b127 | ||
|
|
7d1a83440d | ||
|
|
2d75b303fd | ||
|
|
a1b15d46b8 | ||
|
|
69a6edee99 | ||
|
|
0ac23b4e3a | ||
|
|
085e777ee0 | ||
|
|
c31df3f7a6 | ||
|
|
98e2c0acaf | ||
|
|
1509b8243b | ||
|
|
e427d8b714 | ||
|
|
89b8dbe57f | ||
|
|
f2a17fe3bb | ||
|
|
14e0dae6e3 | ||
|
|
733c281dc8 | ||
|
|
c542f3154e | ||
|
|
c6f40db7e3 | ||
|
|
31dabd4757 | ||
|
|
7a89015ac5 | ||
|
|
2b1cde2efc | ||
|
|
cb3b8c931e | ||
|
|
72bea14c3a | ||
|
|
cd46203d55 | ||
|
|
368d631602 | ||
|
|
5c1cecb7e7 | ||
|
|
526cf13b8d | ||
|
|
3c21baf876 | ||
|
|
2d2c38517c | ||
|
|
163b259bd1 | ||
|
|
24ced66c69 | ||
|
|
6c1982cccb | ||
|
|
3bae7283d1 | ||
|
|
0b458f7565 | ||
|
|
675f30126c | ||
|
|
25b051323c | ||
|
|
697de3d9fc | ||
|
|
7bc09dfe89 | ||
|
|
711dfbe55f | ||
|
|
76108c66c6 | ||
|
|
db3c390d03 | ||
|
|
138fb14107 | ||
|
|
17ebdd7711 | ||
|
|
fc9a42029a | ||
|
|
7d942d551a | ||
|
|
78c94f2b64 | ||
|
|
b317d7ba29 | ||
|
|
71b8ddd1bf | ||
|
|
23de4d4239 | ||
|
|
4641b81f70 | ||
|
|
a9bad5e5f9 | ||
|
|
9f7106a325 | ||
|
|
73f13f56e1 | ||
|
|
312c364797 | ||
|
|
ad9b10c9c1 | ||
|
|
678cfaca12 | ||
|
|
9b36f51d16 | ||
|
|
fa8389d783 | ||
|
|
30d766be77 | ||
|
|
5e2dba7b04 | ||
|
|
70df7c5307 | ||
|
|
f91d9fcfe2 | ||
|
|
086a4aea47 | ||
|
|
148ce2faef | ||
|
|
4827364e37 | ||
|
|
da958faf33 | ||
|
|
f5117abcfb | ||
|
|
df79c8f889 | ||
|
|
0ff65d35dc | ||
|
|
8239dc3604 | ||
|
|
4a4d4b4486 | ||
|
|
34733a427f | ||
|
|
7f68bbd25d | ||
|
|
392ee73719 | ||
|
|
2a0a85018a | ||
|
|
62868cd2b2 | ||
|
|
4e92be3bbc | ||
|
|
14c94bf7ab | ||
|
|
ce3148ac89 | ||
|
|
652b4bf2af | ||
|
|
bc39b53aad | ||
|
|
984192e479 | ||
|
|
3c73b084cf | ||
|
|
fc073124d4 | ||
|
|
f6fb07926e | ||
|
|
90dddd34f3 | ||
|
|
0b948618f3 | ||
|
|
78be002134 | ||
|
|
7acd72ff3a | ||
|
|
c5edeb7e8f | ||
|
|
5d5c5a8597 | ||
|
|
03bdcdf9b4 | ||
|
|
16d755fd76 | ||
|
|
587426e3d3 | ||
|
|
be55e034bf | ||
|
|
8055754455 |
@@ -14,5 +14,4 @@ LICENSE
|
||||
.idea
|
||||
LICENSE.md
|
||||
docs
|
||||
nginx
|
||||
update.sh
|
||||
@@ -5,31 +5,63 @@ DEBUG=0
|
||||
# hosts the application can run under e.g. recipes.mydomain.com,cooking.mydomain.com,...
|
||||
ALLOWED_HOSTS=*
|
||||
|
||||
# random secret key, use for example base64 /dev/urandom | head -c50 to generate one
|
||||
# random secret key, use for example `base64 /dev/urandom | head -c50` to generate one
|
||||
SECRET_KEY=
|
||||
|
||||
# your default timezone See https://timezonedb.com/time-zones for a list of timezones
|
||||
TIMEZONE=Europe/Berlin
|
||||
|
||||
# add only a database password if you want to run with the default postgres, otherwise change settings accordingly
|
||||
DB_ENGINE=django.db.backends.postgresql_psycopg2
|
||||
DB_ENGINE=django.db.backends.postgresql
|
||||
# DB_OPTIONS= {} # e.g. {"sslmode":"require"} to enable ssl
|
||||
POSTGRES_HOST=db_recipes
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=djangodb
|
||||
POSTGRES_USER=djangouser
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_DB=djangodb
|
||||
|
||||
# the default value for the user preference 'fractions' (enable/disable fraction support)
|
||||
# default: disabled=0
|
||||
FRACTION_PREF_DEFAULT=0
|
||||
|
||||
# the default value for the user preference 'comments' (enable/disable commenting system)
|
||||
# default comments enabled=1
|
||||
COMMENT_PREF_DEFAULT=1
|
||||
|
||||
# Users can set a amount of time after which the shopping list is refreshed when they are in viewing mode
|
||||
# This is the minimum interval users can set. Setting this to low will allow users to refresh very frequently which
|
||||
# might cause high load on the server. (Technically they can obviously refresh as often as they want with their own scripts)
|
||||
SHOPPING_MIN_AUTOSYNC_INTERVAL=5
|
||||
|
||||
# Default for user setting sticky navbar
|
||||
#STICKY_NAV_PREF_DEFAULT=1
|
||||
|
||||
# If staticfiles are stored at a different location uncomment and change accordingly
|
||||
# STATIC_URL=/static/
|
||||
|
||||
# If mediafiles are stored at a different location uncomment and change accordingly
|
||||
# MEDIA_URL=/media/
|
||||
|
||||
# Serve mediafiles directly using gunicorn. Basically everyone recommends not doing this. Please use any of the examples
|
||||
# provided that include an additional nxginx container to handle media file serving.
|
||||
# If you know what you are doing turn this back on (1) to serve media files using djangos serve() method.
|
||||
# when unset: 1 (true) - this is temporary until an appropriate amount of time has passed for everyone to migrate
|
||||
GUNICORN_MEDIA=0
|
||||
|
||||
|
||||
# allow authentication via reverse proxy (e.g. authelia), leave of if you dont know what you are doing
|
||||
# docs: https://github.com/vabene1111/recipes/tree/develop/docs/docker/nginx-proxy%20with%20proxy%20authentication
|
||||
# allow authentication via reverse proxy (e.g. authelia), leave off if you dont know what you are doing
|
||||
# see docs for more information https://vabene1111.github.io/recipes/features/authentication/
|
||||
# when unset: 0 (false)
|
||||
REVERSE_PROXY_AUTH=0
|
||||
|
||||
|
||||
# the default value for the user preference 'comments' (enable/disable commenting system)
|
||||
# when unset: 1 (true)
|
||||
COMMENT_PREF_DEFAULT=1
|
||||
# allows you to setup OAuth providers
|
||||
# see docs for more information https://vabene1111.github.io/recipes/features/authentication/
|
||||
# SOCIAL_PROVIDERS = allauth.socialaccount.providers.github, allauth.socialaccount.providers.nextcloud,
|
||||
|
||||
# Should a newly created user from a social provider get assigned to the default space and given permission by default ?
|
||||
# ATTENTION: This feature might be deprecated in favor of a space join and public viewing system in the future
|
||||
# default 0 (false), when 1 (true) users will be assigned space and group
|
||||
# SOCIAL_DEFAULT_ACCESS = 1
|
||||
|
||||
# if SOCIAL_DEFAULT_ACCESS is used, which group should be added
|
||||
# SOCIAL_DEFAULT_GROUP=guest
|
||||
18
.github/ISSUE_TEMPLATE/help-request.md
vendored
18
.github/ISSUE_TEMPLATE/help-request.md
vendored
@@ -6,14 +6,16 @@ labels: setup issue
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
### Version
|
||||
Please provide your current version (can be found on the system page since v0.8.4)
|
||||
Version:
|
||||
|
||||
### Issue
|
||||
## Issue
|
||||
Please describe your problem here
|
||||
|
||||
|
||||
## Setup Info
|
||||
Version: (can be found on the system page since v0.8.4)
|
||||
OS: e.g. Ubuntu 20.02
|
||||
|
||||
Other relevant information regarding your problem (proxies, firewalls, etc.)
|
||||
|
||||
### `.env`
|
||||
Please include your `.env` config file (**make sure to remove/replace all secrets**)
|
||||
```
|
||||
@@ -25,3 +27,7 @@ When running with docker compose please provide your `docker-compose.yml`
|
||||
```
|
||||
docker-compose.yml content
|
||||
```
|
||||
|
||||
### Logs
|
||||
If you feel like there is anything interesting please post the output of `docker-compose logs` at
|
||||
container startup and when the issue happens.
|
||||
22
.github/ISSUE_TEMPLATE/url_import.md
vendored
Normal file
22
.github/ISSUE_TEMPLATE/url_import.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: Website Import
|
||||
about: Anything related to website imports
|
||||
title: ''
|
||||
labels: enhancement, url_import
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
### Version
|
||||
Please provide your current version (can be found on the system page since v0.8.4)
|
||||
Version:
|
||||
|
||||
### Information
|
||||
Exact URL you are trying to import from:
|
||||
|
||||
When did the issue happen: When pressing the search button with the url / when importing after the page has loaded
|
||||
|
||||
Response/Message shown
|
||||
```
|
||||
Message
|
||||
```
|
||||
11
.github/dependabot.yml
vendored
Normal file
11
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "pip" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "daily"
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -22,6 +22,7 @@ jobs:
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
python3 manage.py collectstatic --noinput
|
||||
python3 manage.py collectstatic_js_reverse
|
||||
- name: Django Testing project
|
||||
run: |
|
||||
python3 manage.py test
|
||||
pytest
|
||||
|
||||
64
.github/workflows/codeql-analysis.yml
vendored
64
.github/workflows/codeql-analysis.yml
vendored
@@ -12,40 +12,42 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
fetch-depth: 2
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
fetch-depth: 2
|
||||
|
||||
# If this run was triggered by a pull request event, then checkout
|
||||
# the head of the pull request instead of the merge commit.
|
||||
- run: git checkout HEAD^2
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
# Override language selection by uncommenting this and choosing your languages
|
||||
# with:
|
||||
# languages: go, javascript, csharp, python, cpp, java
|
||||
# If this run was triggered by a pull request event, then checkout
|
||||
# the head of the pull request instead of the merge commit.
|
||||
- run: git checkout HEAD^2
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
# - name: Autobuild
|
||||
# uses: github/codeql-action/autobuild@v1
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
# Override language selection by uncommenting this and choosing your languages
|
||||
with:
|
||||
languages: python, javascript
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
# - name: Autobuild
|
||||
# uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
with:
|
||||
languages: javascript, python
|
||||
|
||||
26
.github/workflows/docker-publish-beta.yml
vendored
Normal file
26
.github/workflows/docker-publish-beta.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: publish beta image docker
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'beta'
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- name: Update version file
|
||||
uses: DamianReeves/write-file-action@v1.0
|
||||
with:
|
||||
path: recipes/version.py
|
||||
contents: |
|
||||
VERSION_NUMBER = 'beta'
|
||||
BUILD_REF = '${{ github.sha }}'
|
||||
write-mode: overwrite
|
||||
- name: Build and publish image
|
||||
uses: ilteoood/docker_buildx@master
|
||||
with:
|
||||
publish: true
|
||||
imageName: vabene1111/recipes
|
||||
tag: beta
|
||||
dockerHubUser: ${{ secrets.DOCKER_USERNAME }}
|
||||
dockerHubPassword: ${{ secrets.DOCKER_PASSWORD }}
|
||||
2
.github/workflows/docker-publish-release.yml
vendored
2
.github/workflows/docker-publish-release.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
name: Build image job
|
||||
steps:
|
||||
- name: Checkout master
|
||||
uses: actions/checkout@master#
|
||||
uses: actions/checkout@master
|
||||
- name: Get version number
|
||||
id: get_version
|
||||
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}
|
||||
|
||||
17
.github/workflows/docs.yml
vendored
Normal file
17
.github/workflows/docs.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Make Docs
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.x
|
||||
- run: pip install mkdocs-material
|
||||
- run: mkdocs gh-deploy --force
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -77,3 +77,5 @@ postgresql/
|
||||
|
||||
|
||||
/docker-compose.override.yml
|
||||
vue/node_modules
|
||||
.vscode/
|
||||
|
||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
5
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
</state>
|
||||
</component>
|
||||
5
.idea/dictionaries/vabene1111_PC.xml
generated
5
.idea/dictionaries/vabene1111_PC.xml
generated
@@ -1,9 +1,14 @@
|
||||
<component name="ProjectDictionaryState">
|
||||
<dictionary name="vabene1111-PC">
|
||||
<words>
|
||||
<w>autosync</w>
|
||||
<w>chowdown</w>
|
||||
<w>csrftoken</w>
|
||||
<w>gunicorn</w>
|
||||
<w>ical</w>
|
||||
<w>mealie</w>
|
||||
<w>pepperplate</w>
|
||||
<w>safron</w>
|
||||
<w>traefik</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
|
||||
9
.idea/recipes.iml
generated
9
.idea/recipes.iml
generated
@@ -6,7 +6,7 @@
|
||||
<option name="rootFolder" value="$MODULE_DIR$" />
|
||||
<option name="settingsModule" value="recipes/settings.py" />
|
||||
<option name="manageScript" value="$MODULE_DIR$/manage.py" />
|
||||
<option name="environment" value="<map/>" />
|
||||
<option name="environment" value="<map> <entry> <string>POSTGRES_USER</string> <string>postgres</string> </entry> <entry> <string>POSTGRES_HOST</string> <string>localhost</string> </entry> <entry> <string>DB_ENGINE</string> <string>django.db.backends.postgresql_psycopg2</string> </entry> <entry> <string>POSTGRES_PORT</string> <string>5432</string> </entry> <entry> <string>POSTGRES_PASSWORD</string> <string>Computer1234</string> </entry> <entry> <string>POSTGRES_DB</string> <string>recipes_db</string> </entry> </map>" />
|
||||
<option name="doNotUseTestRunner" value="false" />
|
||||
<option name="trackFilePattern" value="migrations" />
|
||||
</configuration>
|
||||
@@ -20,10 +20,6 @@
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.8 (recipes)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
<orderEntry type="library" name="jquery-3.4.1" level="application" />
|
||||
<orderEntry type="library" name="pretty-checkbox" level="application" />
|
||||
<orderEntry type="library" name="pdf" level="application" />
|
||||
<orderEntry type="library" name="pdf_viewer" level="application" />
|
||||
</component>
|
||||
<component name="TemplatesService">
|
||||
<option name="TEMPLATE_CONFIGURATION" value="Django" />
|
||||
@@ -33,4 +29,7 @@
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="TestRunnerService">
|
||||
<option name="PROJECT_TEST_RUNNER" value="pytest" />
|
||||
</component>
|
||||
</module>
|
||||
31
.pre-commit-config.yaml
Normal file
31
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
# See https://pre-commit.com for more information
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pre-commit-yarn-build
|
||||
name: Build javascript files
|
||||
entry: yarn --cwd ./vue build
|
||||
always_run: true
|
||||
language: system
|
||||
types: [ python ]
|
||||
pass_filenames: false
|
||||
|
||||
#- id: pre-commit-django-migrations
|
||||
# name: Check django migrations
|
||||
# entry: bash -c './venv/bin/activate && ./manage.py makemigrations --check'
|
||||
# language: system
|
||||
# types: [ python ]
|
||||
# pass_filenames: false
|
||||
# - id: pre-commit-django-make-messages
|
||||
# name: Make messages if necessary
|
||||
# entry: ./manage.py makemessages -i venv -a
|
||||
# language: system
|
||||
# types: [ python ]
|
||||
# pass_filenames: false
|
||||
# - id: pre-commit-django-compile-messages
|
||||
# name: Compile messages if necessary
|
||||
# entry: ./manage.py compilemessages -i venv
|
||||
# language: system
|
||||
# types: [ python ]
|
||||
# pass_filenames: false
|
||||
@@ -1,11 +1,63 @@
|
||||
Many thanks to everyone who contributed to this project!
|
||||
Many thanks to everyone who contributed to this project! If you add something or help out feel free to add yourself
|
||||
to this list.
|
||||
|
||||
## Code/Features
|
||||
Please have a look at the [list of pull requests](https://github.com/vabene1111/recipes/pulls) for
|
||||
a complete list of contributions.
|
||||
Below are some of the larger contributions made yet.
|
||||
|
||||
|
||||
- @tourn provided the serving feature and **several** other improvements!
|
||||
- @l0c4lh057 provided a much improved ingredient text parser in [#277](https://github.com/vabene1111/recipes/pull/277)
|
||||
- @sebimarkgraf added nutritional information [#199](https://github.com/vabene1111/recipes/pull/199)
|
||||
- @cazier added reverse proxy authentication [#88](https://github.com/vabene1111/recipes/pull/88)
|
||||
|
||||
## Translations
|
||||
|
||||
### Catalan
|
||||
[Rubenix](https://www.transifex.com/user/profile/rubenix/)
|
||||
|
||||
### Dutch
|
||||
[D0T1X](https://www.transifex.com/user/profile/D0T1X/)
|
||||
[D0T1X](https://www.transifex.com/user/profile/D0T1X/)
|
||||
[ikbenfrank](https://www.transifex.com/user/profile/ikbenfrank/)
|
||||
[kampsj](https://www.transifex.com/user/profile/kampsj/)
|
||||
|
||||
### French
|
||||
[jt117](https://www.transifex.com/user/profile/jt117/)
|
||||
[nerdinator](https://www.transifex.com/user/profile/nerdinator/)
|
||||
[nerdinator](https://www.transifex.com/user/profile/nerdinator/)
|
||||
[agaume](https://www.transifex.com/user/profile/agaume/)
|
||||
|
||||
### German
|
||||
[eTaurus](https://www.transifex.com/user/profile/eTaurus/)
|
||||
[l0c4lh057](https://www.transifex.com/user/profile/l0c4lh057/)
|
||||
|
||||
### Hungarian
|
||||
[igazka](https://www.transifex.com/user/profile/igazka/)
|
||||
|
||||
### Italian
|
||||
[SK3LA](https://www.transifex.com/user/profile/SK3LA/)
|
||||
[auanasgheps](https://www.transifex.com/user/profile/auanasgheps/)
|
||||
|
||||
### Latvian
|
||||
[melkypie](https://github.com/melkypie)
|
||||
|
||||
### Portuguese
|
||||
|
||||
[hds](https://www.transifex.com/user/profile/hds/)
|
||||
[mlopezifu](https://www.transifex.com/user/profile/mlopezifu/)
|
||||
[stormsz](https://www.transifex.com/user/profile/stormsz/)
|
||||
|
||||
### Spanish
|
||||
|
||||
[albertocp](https://www.transifex.com/user/profile/albertocp/)
|
||||
[alfa5](https://www.transifex.com/user/profile/alfa5/)
|
||||
[mlopezifu](https://www.transifex.com/user/profile/mlopezifu/)
|
||||
[sergio.laya](https://www.transifex.com/user/profile/sergio.laya/)
|
||||
|
||||
### Turkish
|
||||
|
||||
[batmanisnaked](https://www.transifex.com/user/profile/batmanisnaked/)
|
||||
|
||||
### Vietnamese
|
||||
|
||||
[vuongtrunghieu](https://www.transifex.com/user/profile/vuongtrunghieu/)
|
||||
22
Dockerfile
22
Dockerfile
@@ -1,18 +1,28 @@
|
||||
FROM python:3.8-alpine
|
||||
FROM python:3.9-alpine3.12
|
||||
|
||||
RUN apk add --no-cache postgresql-libs gettext zlib libjpeg libxml2-dev libxslt-dev
|
||||
#Install all dependencies.
|
||||
RUN apk add --no-cache postgresql-libs gettext zlib libjpeg libxml2-dev libxslt-dev py-cryptography
|
||||
|
||||
#Print all logs without buffering it.
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
#This port will be used by gunicorn.
|
||||
EXPOSE 8080
|
||||
|
||||
#Create app dir and install requirements.
|
||||
RUN mkdir /opt/recipes
|
||||
WORKDIR /opt/recipes
|
||||
COPY . ./
|
||||
RUN chmod +x boot.sh setup.sh
|
||||
RUN ln -s /opt/recipes/setup.sh /usr/local/bin/createsuperuser
|
||||
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev && \
|
||||
COPY requirements.txt ./
|
||||
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libressl-dev libffi-dev cargo && \
|
||||
python -m venv venv && \
|
||||
/opt/recipes/venv/bin/python -m pip install --upgrade pip && \
|
||||
venv/bin/pip install wheel==0.36.2 && \
|
||||
venv/bin/pip install -r requirements.txt --no-cache-dir &&\
|
||||
apk --purge del .build-deps
|
||||
|
||||
#Copy project and execute it.
|
||||
COPY . ./
|
||||
RUN chmod +x boot.sh
|
||||
ENTRYPOINT ["/opt/recipes/boot.sh"]
|
||||
121
README.md
121
README.md
@@ -1,81 +1,70 @@
|
||||
# Recipes 
|
||||
Recipes is a Django application to manage, tag and search recipes using either built in models or external storage providers hosting PDF's, Images or other files.
|
||||
<h1 align="center">
|
||||
<br>
|
||||
<a href="https://app.tandoor.dev"><img src="https://github.com/vabene1111/recipes/raw/develop/docs/logo_color.svg" height="256px" width="256px"></a>
|
||||
<br>
|
||||
Tandoor Recipes
|
||||
<br>
|
||||
</h1>
|
||||
|
||||
<h4 align="center">The recipe manager that allows you to manage your ever growing collection of digital recipes.</h4>
|
||||
|
||||
<p align="center">
|
||||
|
||||
<img src="https://github.com/vabene1111/recipes/workflows/Continous%20Integration/badge.svg?branch=develop" >
|
||||
<img src="https://img.shields.io/github/stars/vabene1111/recipes" >
|
||||
<img src="https://img.shields.io/github/forks/vabene1111/recipes" >
|
||||
<img src="https://img.shields.io/docker/pulls/vabene1111/recipes" >
|
||||
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://docs.tandoor.dev/install/docker/" target="_blank" rel="noopener noreferrer">Installation</a> •
|
||||
<a href="https://docs.tandoor.dev/" target="_blank" rel="noopener noreferrer">Documentation</a> •
|
||||
<a href="https://app.tandoor.dev/" target="_blank" rel="noopener noreferrer">Demo</a>
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
[More Screenshots](https://imgur.com/a/V01151p)
|
||||
## Features
|
||||
|
||||
### Features
|
||||
- 📦 **Sync** files with Dropbox and Nextcloud (more can easily be added)
|
||||
- 🔍 Powerful **search** with Djangos [TrigramSimilarity](https://docs.djangoproject.com/en/3.0/ref/contrib/postgres/search/#trigram-similarity)
|
||||
- 🏷️ Create and search for **tags**, assign them in batch to all files matching certain filters
|
||||
- 📄 **Create recipes** locally within a nice, standardized web interface
|
||||
- ⬇️ **Import recipes** from thousands of websites supporting [ld+json or microdata](https://schema.org/Recipe)
|
||||
- 📱 Optimized for use on **mobile** devices like phones and tablets
|
||||
- 🛒 Generate **shopping** lists from recipes
|
||||
- 📆 Create a **Plan** on what to eat when
|
||||
- 👪 **Share** recipes with friends and comment on them to suggest or remember changes you made
|
||||
- ➗ automatically convert decimal units to **fractions** for those who like this
|
||||
- 🐳 Easy setup with **Docker** and included examples for Kubernetes, Unraid and Synology
|
||||
- 🎨 Customize your interface with **themes**
|
||||
- ✉️ Export and import recipes from other users
|
||||
- 🌍 localized in many languages thanks to the awesome community
|
||||
- ➕ Many more like recipe scaling, image compression, cookbooks, printing views, ...
|
||||
|
||||
- :package: **Sync** files with Dropbox and Nextcloud (more can easily be added)
|
||||
- :mag: Powerful **search** with Djangos [TrigramSimilarity](https://docs.djangoproject.com/en/3.0/ref/contrib/postgres/search/#trigram-similarity)
|
||||
- :label: Create and search for **tags**, assign them in batch to all files matching certain filters
|
||||
- :page_facing_up: **Create recipes** locally within a nice, standardized web interface
|
||||
- :arrow_down: **Import recipes** from thousands of websites supporting [ld+json or microdata](https://schema.org/Recipe)
|
||||
- :iphone: Optimized for use on **mobile** devices like phones and tablets
|
||||
- :shopping_cart: Generate **shopping** lists from recipes
|
||||
- :calendar: Create a **Plan** on what to eat when
|
||||
- :family: **Share** recipes with friends and comment on them to suggest or remember changes you made
|
||||
- :whale: Easy setup with **Docker**
|
||||
- :art: Customize your interface with **themes**
|
||||
- :envelope: Export and import recipes from other users
|
||||
- :heavy_plus_sign: Many more like recipe scaling, image compression, cookbooks, printing views, ...
|
||||
|
||||
This application is meant for people with a collection of recipes they want to share with family and friends or simply
|
||||
store them in a nicely organized way. A basic permission system exists but this application is not meant to be run as a public page.
|
||||
Some Documentation can be found [here](https://github.com/vabene1111/recipes/wiki)
|
||||
# Installation
|
||||
|
||||
The docker image (`vabene1111/recipes`) simply exposes the application on port `8080`. You may choose any preferred installation method, the following are just examples to make it easier.
|
||||
|
||||
### Docker-Compose
|
||||
|
||||
2. Choose one of the included configurations [here](docs/docker).
|
||||
2. Download the environment (config) file template and fill it out `wget https://raw.githubusercontent.com/vabene1111/recipes/develop/.env.template -O .env `
|
||||
3. Start the container `docker-compose up -d`
|
||||
4. Open the page to create the first user. Alternatively use `docker-compose exec web_recipes createsuperuser`
|
||||
|
||||
### Manual
|
||||
**Python >= 3.8** is required to run this!
|
||||
|
||||
Copy `.env.template` to `.env` and fill in the missing values accordingly.
|
||||
Make sure all variables are available to whatever serves your application.
|
||||
|
||||
Otherwise simply follow the instructions for any django based deployment
|
||||
(for example [this one](http://uwsgi-docs.readthedocs.io/en/latest/tutorials/Django_and_nginx.html)).
|
||||
|
||||
## Updating
|
||||
While intermediate updates can be skipped when updating please make sure to **read the release notes** in case some special action is required to update.
|
||||
|
||||
0. Before updating it is recommended to **create a backup!**
|
||||
1. Stop the container using `docker-compose down`
|
||||
2. Pull the latest image using `docker-compose pull`
|
||||
3. Start the container again using `docker-compose up -d`
|
||||
|
||||
## Kubernetes
|
||||
|
||||
You can find a basic kubernetes setup [here](docs/k8s/). Please see the README in the folder for more detail.
|
||||
|
||||
## Contributing
|
||||
Pull Requests and ideas are welcome, feel free to contribute in any way.
|
||||
For any questions on how to work with django please refer to their excellent [documentation](https://www.djangoproject.com/start/).
|
||||
|
||||
### Translating
|
||||
There is a [transifex project](https://www.transifex.com/django-recipes/django-cookbook/) project to enable community driven translations. If you want to contribute a new language or help maintain an already existing one feel free to create a transifex account (using the link above) and request to join the project.
|
||||
|
||||
It is also possible to provide the translations directly by creating a new language using `manage.py makemessages -l <language_code> -i venv`. Once finished simply open a PR with the changed files.
|
||||
This application is meant for people with a collection of recipes they want to share with family and friends or simply
|
||||
store them in a nicely organized way. A basic permission system exists but this application is not meant to be run as
|
||||
a public page.
|
||||
Documentation can be found [here](https://docs.tandoor.dev/).
|
||||
|
||||
While this application has been around for a while and is actively used by many (including myself), it is still considered
|
||||
**beta** software that has a lot of rough edges and unpolished parts.
|
||||
## License
|
||||
|
||||
Beginning with version 0.10.0 the code in this repository is licensed under the [GNU AGPL v3](https://www.gnu.org/licenses/agpl-3.0.de.html) license with an
|
||||
[common clause](https://commonsclause.com/) selling exception. See [LICENSE.md](https://github.com/vabene1111/recipes/blob/develop/LICENSE.md) for details.
|
||||
|
||||
**Reasoning**
|
||||
> NOTE: There appears to be a whole range of legal issues with licensing anything else then the standard completely open licenses.
|
||||
> I am in the process of getting some professional legal advice to sort out these issues.
|
||||
> Please also see [Issue 238](https://github.com/vabene1111/recipes/issues/238) for some discussion and **reasoning** regarding the topic.
|
||||
|
||||
#### This software and **all** its features are and will always be free for everyone to use and enjoy.
|
||||
**Reasoning**
|
||||
**This software and *all* its features are and will always be free for everyone to use and enjoy.**
|
||||
|
||||
The reason for the selling exception is that a significant amount of time was spend over multiple years to develop this software.
|
||||
The reason for the selling exception is that a significant amount of time was spend over multiple years to develop this software.
|
||||
A payed hosted version which will be identical in features and code base to the software offered in this repository will
|
||||
likely be released in the future (including all features needed to sell a hosted version as they might also be useful for personal use).
|
||||
This will not only benefit me personally but also everyone who self-hosts this software as any profits made trough selling the hosted option
|
||||
This will not only benefit me personally but also everyone who self-hosts this software as any profits made trough selling the hosted option
|
||||
allow me to spend more time developing and improving the software for everyone. Selling exceptions are [approved by Richard Stallman](http://www.gnu.org/philosophy/selling-exceptions.en.html) and the
|
||||
common clause license is very permissive (see the [FAQ](https://commonsclause.com/)).
|
||||
common clause license is very permissive (see the [FAQ](https://commonsclause.com/)).
|
||||
|
||||
10
SECURITY.md
Normal file
10
SECURITY.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Since this software is still considered beta/WIP support is always only given for the latest version. There are no backports of security or any other fixes.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please open a normal public issue if you have any security related concerns. If you feel like the issue should not be discussed in
|
||||
public just open a generic issue and we will discuss further communitcation there (since GitHub does not allow everyone to create a security advisory :/).
|
||||
1
boot.sh
1
boot.sh
@@ -3,6 +3,7 @@ source venv/bin/activate
|
||||
|
||||
echo "Updating database"
|
||||
python manage.py migrate
|
||||
python manage.py collectstatic_js_reverse
|
||||
python manage.py collectstatic --noinput
|
||||
echo "Done"
|
||||
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
from django.contrib import admin
|
||||
from .models import *
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.contrib.auth.models import User, Group
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
admin.site.unregister(User)
|
||||
admin.site.register(User, CustomUserAdmin)
|
||||
|
||||
admin.site.unregister(Group)
|
||||
|
||||
|
||||
class SpaceAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'created_by', 'message')
|
||||
|
||||
|
||||
admin.site.register(Space, SpaceAdmin)
|
||||
|
||||
|
||||
class UserPreferenceAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'theme', 'nav_color', 'default_page', 'search_style', 'comments')
|
||||
list_display = ('name', 'space', 'theme', 'nav_color', 'default_page', 'search_style',)
|
||||
|
||||
@staticmethod
|
||||
def name(obj):
|
||||
@@ -27,6 +53,18 @@ class SyncAdmin(admin.ModelAdmin):
|
||||
admin.site.register(Sync, SyncAdmin)
|
||||
|
||||
|
||||
class SupermarketCategoryInline(admin.TabularInline):
|
||||
model = SupermarketCategoryRelation
|
||||
|
||||
|
||||
class SupermarketAdmin(admin.ModelAdmin):
|
||||
inlines = (SupermarketCategoryInline,)
|
||||
|
||||
|
||||
admin.site.register(Supermarket, SupermarketAdmin)
|
||||
admin.site.register(SupermarketCategory)
|
||||
|
||||
|
||||
class SyncLogAdmin(admin.ModelAdmin):
|
||||
list_display = ('sync', 'status', 'msg', 'created_at')
|
||||
|
||||
@@ -125,6 +163,16 @@ class ViewLogAdmin(admin.ModelAdmin):
|
||||
admin.site.register(ViewLog, ViewLogAdmin)
|
||||
|
||||
|
||||
class InviteLinkAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
'username', 'group', 'valid_until',
|
||||
'created_by', 'created_at', 'used_by'
|
||||
)
|
||||
|
||||
|
||||
admin.site.register(InviteLink, InviteLinkAdmin)
|
||||
|
||||
|
||||
class CookLogAdmin(admin.ModelAdmin):
|
||||
list_display = ('recipe', 'created_by', 'created_at', 'rating', 'servings')
|
||||
|
||||
@@ -132,8 +180,50 @@ class CookLogAdmin(admin.ModelAdmin):
|
||||
admin.site.register(CookLog, CookLogAdmin)
|
||||
|
||||
|
||||
class ShoppingListRecipeAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'recipe', 'servings')
|
||||
|
||||
|
||||
admin.site.register(ShoppingListRecipe, ShoppingListRecipeAdmin)
|
||||
|
||||
|
||||
class ShoppingListEntryAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'food', 'unit', 'list_recipe', 'checked')
|
||||
|
||||
|
||||
admin.site.register(ShoppingListEntry, ShoppingListEntryAdmin)
|
||||
|
||||
|
||||
class ShoppingListAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'created_by', 'created_at')
|
||||
|
||||
|
||||
admin.site.register(ShoppingList, ShoppingListAdmin)
|
||||
|
||||
|
||||
class ShareLinkAdmin(admin.ModelAdmin):
|
||||
list_display = ('recipe', 'created_by', 'uuid', 'created_at',)
|
||||
|
||||
|
||||
admin.site.register(ShareLink, ShareLinkAdmin)
|
||||
|
||||
|
||||
class NutritionInformationAdmin(admin.ModelAdmin):
|
||||
list_display = ('id',)
|
||||
|
||||
|
||||
admin.site.register(NutritionInformation, NutritionInformationAdmin)
|
||||
|
||||
|
||||
class ImportLogAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'type', 'running', 'created_by', 'created_at',)
|
||||
|
||||
|
||||
admin.site.register(ImportLog, ImportLogAdmin)
|
||||
|
||||
|
||||
class TelegramBotAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'name', 'created_by',)
|
||||
|
||||
|
||||
admin.site.register(TelegramBot, TelegramBotAdmin)
|
||||
|
||||
@@ -1,54 +1,83 @@
|
||||
import django_filters
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.db.models import Q
|
||||
from cookbook.forms import MultiSelectWidget
|
||||
from cookbook.models import Recipe, Keyword, Food
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.forms import MultiSelectWidget
|
||||
from cookbook.models import Food, Keyword, Recipe, ShoppingList
|
||||
|
||||
class RecipeFilter(django_filters.FilterSet):
|
||||
name = django_filters.CharFilter(method='filter_name')
|
||||
keywords = django_filters.ModelMultipleChoiceFilter(queryset=Keyword.objects.all(), widget=MultiSelectWidget,
|
||||
method='filter_keywords')
|
||||
foods = django_filters.ModelMultipleChoiceFilter(queryset=Food.objects.all(), widget=MultiSelectWidget,
|
||||
method='filter_foods', label=_('Ingredients'))
|
||||
with scopes_disabled():
|
||||
class RecipeFilter(django_filters.FilterSet):
|
||||
name = django_filters.CharFilter(method='filter_name')
|
||||
keywords = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Keyword.objects.none(),
|
||||
widget=MultiSelectWidget,
|
||||
method='filter_keywords'
|
||||
)
|
||||
foods = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Food.objects.none(),
|
||||
widget=MultiSelectWidget,
|
||||
method='filter_foods',
|
||||
label=_('Ingredients')
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def filter_keywords(queryset, name, value):
|
||||
if not name == 'keywords':
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(data, *args, **kwargs)
|
||||
self.filters['foods'].queryset = Food.objects.filter(space=space).all()
|
||||
self.filters['keywords'].queryset = Keyword.objects.filter(space=space).all()
|
||||
|
||||
@staticmethod
|
||||
def filter_keywords(queryset, name, value):
|
||||
if not name == 'keywords':
|
||||
return queryset
|
||||
for x in value:
|
||||
queryset = queryset.filter(keywords=x)
|
||||
return queryset
|
||||
for x in value:
|
||||
queryset = queryset.filter(keywords=x)
|
||||
return queryset
|
||||
|
||||
@staticmethod
|
||||
def filter_foods(queryset, name, value):
|
||||
if not name == 'foods':
|
||||
@staticmethod
|
||||
def filter_foods(queryset, name, value):
|
||||
if not name == 'foods':
|
||||
return queryset
|
||||
for x in value:
|
||||
queryset = queryset.filter(steps__ingredients__food__name=x).distinct()
|
||||
return queryset
|
||||
for x in value:
|
||||
queryset = queryset.filter(steps__ingredients__food__name=x).distinct()
|
||||
return queryset
|
||||
|
||||
@staticmethod
|
||||
def filter_name(queryset, name, value):
|
||||
if not name == 'name':
|
||||
@staticmethod
|
||||
def filter_name(queryset, name, value):
|
||||
if not name == 'name':
|
||||
return queryset
|
||||
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
|
||||
'django.db.backends.postgresql']:
|
||||
queryset = queryset.annotate(similarity=TrigramSimilarity('name', value), ).filter(
|
||||
Q(similarity__gt=0.1) | Q(name__unaccent__icontains=value)).order_by('-similarity')
|
||||
else:
|
||||
queryset = queryset.filter(name__icontains=value)
|
||||
return queryset
|
||||
if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql_psycopg2':
|
||||
queryset = queryset.annotate(similarity=TrigramSimilarity('name', value), ).filter(
|
||||
Q(similarity__gt=0.1) | Q(name__icontains=value)).order_by('-similarity')
|
||||
else:
|
||||
queryset = queryset.filter(name__icontains=value)
|
||||
return queryset
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ['name', 'keywords', 'foods', 'internal']
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ['name', 'keywords', 'foods', 'internal']
|
||||
|
||||
|
||||
class IngredientFilter(django_filters.FilterSet):
|
||||
name = django_filters.CharFilter(lookup_expr='icontains')
|
||||
class FoodFilter(django_filters.FilterSet):
|
||||
name = django_filters.CharFilter(lookup_expr='icontains')
|
||||
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = ['name']
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = ['name']
|
||||
|
||||
|
||||
class ShoppingListFilter(django_filters.FilterSet):
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
if data is not None:
|
||||
data = data.copy()
|
||||
data.setdefault("finished", False)
|
||||
super().__init__(data, *args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
model = ShoppingList
|
||||
fields = ['finished']
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from django import forms
|
||||
from django.forms import widgets
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
|
||||
from emoji_picker.widgets import EmojiPickerTextInput
|
||||
|
||||
from .models import *
|
||||
from .models import (Comment, Food, InviteLink, Keyword, MealPlan, Recipe,
|
||||
RecipeBook, RecipeBookEntry, Storage, Sync, Unit, User,
|
||||
UserPreference, SupermarketCategory, MealType, Space)
|
||||
|
||||
|
||||
class SelectWidget(widgets.Select):
|
||||
@@ -17,7 +19,8 @@ class MultiSelectWidget(widgets.SelectMultiple):
|
||||
js = ('custom/js/form_multiselect.js',)
|
||||
|
||||
|
||||
# yes there are some stupid browsers that still dont support this but i dont support people using these browsers
|
||||
# Yes there are some stupid browsers that still dont support this but
|
||||
# I dont support people using these browsers.
|
||||
class DateWidget(forms.DateInput):
|
||||
input_type = 'date'
|
||||
|
||||
@@ -31,15 +34,26 @@ class UserPreferenceForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = UserPreference
|
||||
fields = ('default_unit', 'theme', 'nav_color', 'default_page', 'show_recent', 'search_style', 'plan_share', 'ingredient_decimals', 'comments')
|
||||
fields = (
|
||||
'default_unit', 'use_fractions', 'theme', 'nav_color',
|
||||
'sticky_navbar', 'default_page', 'show_recent', 'search_style',
|
||||
'plan_share', 'ingredient_decimals', 'shopping_auto_sync',
|
||||
'comments'
|
||||
)
|
||||
|
||||
help_texts = {
|
||||
'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!'),
|
||||
'default_unit': _('Default Unit to be used when inserting a new ingredient into a recipe.'),
|
||||
'plan_share': _('Default user to share newly created meal plan entries with.'),
|
||||
'show_recent': _('Show recently viewed recipes on search page.'),
|
||||
'ingredient_decimals': _('Number of decimals to round ingredients.'),
|
||||
'comments': _('If you want to be able to create and see comments underneath recipes.')
|
||||
'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!'), # noqa: E501
|
||||
'default_unit': _('Default Unit to be used when inserting a new ingredient into a recipe.'), # noqa: E501
|
||||
'use_fractions': _('Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'), # noqa: E501
|
||||
'plan_share': _('Users with whom newly created meal plan/shopping list entries should be shared by default.'), # noqa: E501
|
||||
'show_recent': _('Show recently viewed recipes on search page.'), # noqa: E501
|
||||
'ingredient_decimals': _('Number of decimals to round ingredients.'), # noqa: E501
|
||||
'comments': _('If you want to be able to create and see comments underneath recipes.'), # noqa: E501
|
||||
'shopping_auto_sync': _(
|
||||
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit ' # noqa: E501
|
||||
'of mobile data. If lower than instance limit it is reset when saving.' # noqa: E501
|
||||
),
|
||||
'sticky_navbar': _('Makes the navbar stick to the top of the page.') # noqa: E501
|
||||
}
|
||||
|
||||
widgets = {
|
||||
@@ -55,18 +69,25 @@ class UserNameForm(forms.ModelForm):
|
||||
fields = ('first_name', 'last_name')
|
||||
|
||||
help_texts = {
|
||||
'first_name': _('Both fields are optional. If none are given the username will be displayed instead')
|
||||
'first_name': _('Both fields are optional. If none are given the username will be displayed instead') # noqa: E501
|
||||
}
|
||||
|
||||
|
||||
class ExternalRecipeForm(forms.ModelForm):
|
||||
file_path = forms.CharField(disabled=True, required=False)
|
||||
storage = forms.ModelChoiceField(queryset=Storage.objects.all(), disabled=True, required=False)
|
||||
file_uid = forms.CharField(disabled=True, required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['keywords'].queryset = Keyword.objects.filter(space=space).all()
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ('name', 'keywords', 'working_time', 'waiting_time', 'file_path', 'storage', 'file_uid')
|
||||
fields = (
|
||||
'name', 'description', 'servings', 'working_time', 'waiting_time',
|
||||
'file_path', 'file_uid', 'keywords'
|
||||
)
|
||||
|
||||
labels = {
|
||||
'name': _('Name'),
|
||||
@@ -77,88 +98,93 @@ class ExternalRecipeForm(forms.ModelForm):
|
||||
'file_uid': _('Storage UID'),
|
||||
}
|
||||
widgets = {'keywords': MultiSelectWidget}
|
||||
|
||||
|
||||
class InternalRecipeForm(forms.ModelForm):
|
||||
ingredients = forms.CharField(widget=forms.HiddenInput(), required=False)
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ('name', 'image', 'working_time', 'waiting_time', 'keywords')
|
||||
|
||||
labels = {
|
||||
'name': _('Name'),
|
||||
'keywords': _('Keywords'),
|
||||
'working_time': _('Preparation time in minutes'),
|
||||
'waiting_time': _('Waiting time (cooking/baking) in minutes'),
|
||||
field_classes = {
|
||||
'keywords': SafeModelMultipleChoiceField,
|
||||
}
|
||||
widgets = {'keywords': MultiSelectWidget}
|
||||
|
||||
|
||||
class ShoppingForm(forms.Form):
|
||||
recipe = forms.ModelMultipleChoiceField(
|
||||
queryset=Recipe.objects.filter(internal=True).all(),
|
||||
widget=MultiSelectWidget
|
||||
)
|
||||
markdown_format = forms.BooleanField(
|
||||
help_text=_('Include <code>- [ ]</code> in list for easier usage in markdown based documents.'),
|
||||
required=False,
|
||||
initial=False
|
||||
)
|
||||
class ImportExportBase(forms.Form):
|
||||
DEFAULT = 'DEFAULT'
|
||||
PAPRIKA = 'PAPRIKA'
|
||||
NEXTCLOUD = 'NEXTCLOUD'
|
||||
MEALIE = 'MEALIE'
|
||||
CHOWDOWN = 'CHOWDOWN'
|
||||
SAFRON = 'SAFRON'
|
||||
CHEFTAP = 'CHEFTAP'
|
||||
PEPPERPLATE = 'PEPPERPLATE'
|
||||
RECIPESAGE = 'RECIPESAGE'
|
||||
DOMESTICA = 'DOMESTICA'
|
||||
MEALMASTER = 'MEALMASTER'
|
||||
REZKONV = 'REZKONV'
|
||||
|
||||
type = forms.ChoiceField(choices=(
|
||||
(DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'),
|
||||
(MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'), (SAFRON, 'Safron'), (CHEFTAP, 'ChefTap'),
|
||||
(PEPPERPLATE, 'Pepperplate'), (RECIPESAGE, 'Recipe Sage'), (DOMESTICA, 'Domestica'),
|
||||
(MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'),
|
||||
))
|
||||
|
||||
|
||||
class ExportForm(forms.Form):
|
||||
recipe = forms.ModelChoiceField(
|
||||
queryset=Recipe.objects.filter(internal=True).all(),
|
||||
widget=SelectWidget
|
||||
)
|
||||
image = forms.BooleanField(
|
||||
help_text=_('Export Base64 encoded image?'),
|
||||
required=False
|
||||
)
|
||||
download = forms.BooleanField(
|
||||
help_text=_('Download export directly or show on page?'),
|
||||
required=False
|
||||
)
|
||||
class ImportForm(ImportExportBase):
|
||||
files = forms.FileField(required=True, widget=forms.ClearableFileInput(attrs={'multiple': True}))
|
||||
duplicates = forms.BooleanField(help_text=_('To prevent duplicates recipes with the same name as existing ones are ignored. Check this box to import everything.'), required=False)
|
||||
|
||||
|
||||
class ImportForm(forms.Form):
|
||||
recipe = forms.CharField(widget=forms.Textarea, help_text=_('Simply paste a JSON export into this textarea and click import.'))
|
||||
class ExportForm(ImportExportBase):
|
||||
recipes = forms.ModelMultipleChoiceField(widget=MultiSelectWidget, queryset=Recipe.objects.none())
|
||||
all = forms.BooleanField(required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['recipes'].queryset = Recipe.objects.filter(space=space).all()
|
||||
|
||||
|
||||
class UnitMergeForm(forms.Form):
|
||||
prefix = 'unit'
|
||||
|
||||
new_unit = forms.ModelChoiceField(
|
||||
queryset=Unit.objects.all(),
|
||||
new_unit = SafeModelChoiceField(
|
||||
queryset=Unit.objects.none(),
|
||||
widget=SelectWidget,
|
||||
label=_('New Unit'),
|
||||
help_text=_('New unit that other gets replaced by.'),
|
||||
)
|
||||
old_unit = forms.ModelChoiceField(
|
||||
queryset=Unit.objects.all(),
|
||||
old_unit = SafeModelChoiceField(
|
||||
queryset=Unit.objects.none(),
|
||||
widget=SelectWidget,
|
||||
label=_('Old Unit'),
|
||||
help_text=_('Unit that should be replaced.'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['new_unit'].queryset = Unit.objects.filter(space=space).all()
|
||||
self.fields['old_unit'].queryset = Unit.objects.filter(space=space).all()
|
||||
|
||||
|
||||
class FoodMergeForm(forms.Form):
|
||||
prefix = 'food'
|
||||
|
||||
new_food = forms.ModelChoiceField(
|
||||
queryset=Food.objects.all(),
|
||||
new_food = SafeModelChoiceField(
|
||||
queryset=Food.objects.none(),
|
||||
widget=SelectWidget,
|
||||
label=_('New Food'),
|
||||
help_text=_('New food that other gets replaced by.'),
|
||||
)
|
||||
old_food = forms.ModelChoiceField(
|
||||
queryset=Food.objects.all(),
|
||||
old_food = SafeModelChoiceField(
|
||||
queryset=Food.objects.none(),
|
||||
widget=SelectWidget,
|
||||
label=_('Old Food'),
|
||||
help_text=_('Food that should be replaced.'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['new_food'].queryset = Food.objects.filter(space=space).all()
|
||||
self.fields['old_food'].queryset = Food.objects.filter(space=space).all()
|
||||
|
||||
|
||||
class CommentForm(forms.ModelForm):
|
||||
prefix = 'comment'
|
||||
@@ -183,52 +209,104 @@ class KeywordForm(forms.ModelForm):
|
||||
|
||||
|
||||
class FoodForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['recipe'].queryset = Recipe.objects.filter(space=space).all()
|
||||
self.fields['supermarket_category'].queryset = SupermarketCategory.objects.filter(space=space).all()
|
||||
|
||||
class Meta:
|
||||
model = Food
|
||||
fields = ('name', 'recipe')
|
||||
fields = ('name', 'description', 'ignore_shopping', 'recipe', 'supermarket_category')
|
||||
widgets = {'recipe': SelectWidget}
|
||||
|
||||
field_classes = {
|
||||
'recipe': SafeModelChoiceField,
|
||||
'supermarket_category': SafeModelChoiceField,
|
||||
}
|
||||
|
||||
|
||||
class StorageForm(forms.ModelForm):
|
||||
username = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password'}), required=False)
|
||||
password = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}),
|
||||
required=False,
|
||||
help_text=_('Leave empty for dropbox and enter app password for nextcloud.'))
|
||||
token = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}),
|
||||
required=False,
|
||||
help_text=_('Leave empty for nextcloud and enter api token for dropbox.'))
|
||||
username = forms.CharField(
|
||||
widget=forms.TextInput(attrs={'autocomplete': 'new-password'}),
|
||||
required=False
|
||||
)
|
||||
password = forms.CharField(
|
||||
widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}),
|
||||
required=False,
|
||||
help_text=_('Leave empty for dropbox and enter app password for nextcloud.')
|
||||
)
|
||||
token = forms.CharField(
|
||||
widget=forms.TextInput(
|
||||
attrs={'autocomplete': 'new-password', 'type': 'password'}
|
||||
),
|
||||
required=False,
|
||||
help_text=_('Leave empty for nextcloud and enter api token for dropbox.')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Storage
|
||||
fields = ('name', 'method', 'username', 'password', 'token', 'url')
|
||||
fields = ('name', 'method', 'username', 'password', 'token', 'url', 'path')
|
||||
|
||||
help_texts = {
|
||||
'url': _(
|
||||
'Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'),
|
||||
'url': _('Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'),
|
||||
}
|
||||
|
||||
|
||||
class RecipeBookEntryForm(forms.ModelForm):
|
||||
prefix = 'bookmark'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['book'].queryset = RecipeBook.objects.filter(space=space).all()
|
||||
|
||||
class Meta:
|
||||
model = RecipeBookEntry
|
||||
fields = ('book',)
|
||||
|
||||
field_classes = {
|
||||
'book': SafeModelChoiceField,
|
||||
}
|
||||
|
||||
|
||||
class SyncForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['storage'].queryset = Storage.objects.filter(space=space).all()
|
||||
|
||||
class Meta:
|
||||
model = Sync
|
||||
fields = ('storage', 'path', 'active')
|
||||
|
||||
field_classes = {
|
||||
'storage': SafeModelChoiceField,
|
||||
}
|
||||
|
||||
|
||||
class BatchEditForm(forms.Form):
|
||||
search = forms.CharField(label=_('Search String'))
|
||||
keywords = forms.ModelMultipleChoiceField(queryset=Keyword.objects.all().order_by('id'), required=False,
|
||||
widget=MultiSelectWidget)
|
||||
keywords = forms.ModelMultipleChoiceField(
|
||||
queryset=Keyword.objects.none(),
|
||||
required=False,
|
||||
widget=MultiSelectWidget
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['keywords'].queryset = Keyword.objects.filter(space=space).all().order_by('id')
|
||||
|
||||
|
||||
class ImportRecipeForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['keywords'].queryset = Keyword.objects.filter(space=space).all()
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ('name', 'keywords', 'file_path', 'file_uid')
|
||||
@@ -240,38 +318,94 @@ class ImportRecipeForm(forms.ModelForm):
|
||||
'file_uid': _('File ID'),
|
||||
}
|
||||
widgets = {'keywords': MultiSelectWidget}
|
||||
field_classes = {
|
||||
'keywords': SafeModelChoiceField,
|
||||
}
|
||||
|
||||
|
||||
class RecipeBookForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['shared'].queryset = User.objects.filter(userpreference__space=space).all()
|
||||
|
||||
class Meta:
|
||||
model = RecipeBook
|
||||
fields = ('name', 'icon', 'description', 'shared')
|
||||
widgets = {'icon': EmojiPickerTextInput, 'shared': MultiSelectWidget}
|
||||
field_classes = {
|
||||
'shared': SafeModelMultipleChoiceField,
|
||||
}
|
||||
|
||||
|
||||
class MealPlanForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['recipe'].queryset = Recipe.objects.filter(space=space).all()
|
||||
self.fields['meal_type'].queryset = MealType.objects.filter(space=space).all()
|
||||
self.fields['shared'].queryset = User.objects.filter(userpreference__space=space).all()
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super(MealPlanForm, self).clean()
|
||||
|
||||
if cleaned_data['title'] == '' and cleaned_data['recipe'] is None:
|
||||
raise forms.ValidationError(_('You must provide at least a recipe or a title.'))
|
||||
raise forms.ValidationError(
|
||||
_('You must provide at least a recipe or a title.')
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
class Meta:
|
||||
model = MealPlan
|
||||
fields = ('recipe', 'title', 'meal_type', 'note', 'date', 'shared')
|
||||
fields = (
|
||||
'recipe', 'title', 'meal_type', 'note',
|
||||
'servings', 'date', 'shared'
|
||||
)
|
||||
|
||||
help_texts = {
|
||||
'shared': _('You can list default users to share recipes with in the settings.'),
|
||||
'note': _('You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>')
|
||||
'shared': _('You can list default users to share recipes with in the settings.'), # noqa: E501
|
||||
'note': _('You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>') # noqa: E501
|
||||
}
|
||||
|
||||
widgets = {'recipe': SelectWidget, 'date': DateWidget, 'shared': MultiSelectWidget}
|
||||
widgets = {
|
||||
'recipe': SelectWidget,
|
||||
'date': DateWidget,
|
||||
'shared': MultiSelectWidget
|
||||
}
|
||||
field_classes = {
|
||||
'recipe': SafeModelChoiceField,
|
||||
'meal_type': SafeModelChoiceField,
|
||||
'shared': SafeModelMultipleChoiceField,
|
||||
}
|
||||
|
||||
|
||||
class SuperUserForm(forms.Form):
|
||||
name = forms.CharField()
|
||||
password = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}))
|
||||
password_confirm = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}))
|
||||
class InviteLinkForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['space'].queryset = Space.objects.filter(created_by=user).all()
|
||||
|
||||
class Meta:
|
||||
model = InviteLink
|
||||
fields = ('username', 'group', 'valid_until', 'space')
|
||||
help_texts = {
|
||||
'username': _('A username is not required, if left blank the new user can choose one.') # noqa: E501
|
||||
}
|
||||
field_classes = {
|
||||
'space': SafeModelChoiceField,
|
||||
}
|
||||
|
||||
|
||||
class UserCreateForm(forms.Form):
|
||||
name = forms.CharField(label='Username')
|
||||
password = forms.CharField(
|
||||
widget=forms.TextInput(
|
||||
attrs={'autocomplete': 'new-password', 'type': 'password'}
|
||||
)
|
||||
)
|
||||
password_confirm = forms.CharField(
|
||||
widget=forms.TextInput(
|
||||
attrs={'autocomplete': 'new-password', 'type': 'password'}
|
||||
)
|
||||
)
|
||||
|
||||
19
cookbook/helper/AllAuthCustomAdapter.py
Normal file
19
cookbook/helper/AllAuthCustomAdapter.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django.conf import settings
|
||||
|
||||
from allauth.account.adapter import DefaultAccountAdapter
|
||||
|
||||
|
||||
class AllAuthCustomAdapter(DefaultAccountAdapter):
|
||||
|
||||
def is_open_for_signup(self, request):
|
||||
"""
|
||||
Whether to allow sign ups.
|
||||
"""
|
||||
if request.resolver_match.view_name == 'account_signup':
|
||||
return False
|
||||
else:
|
||||
return super(AllAuthCustomAdapter, self).is_open_for_signup(request)
|
||||
|
||||
# disable password reset for now
|
||||
def send_mail(self, template_prefix, email, context):
|
||||
pass
|
||||
8
cookbook/helper/CustomTestRunner.py
Normal file
8
cookbook/helper/CustomTestRunner.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.test.runner import DiscoverRunner
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
class CustomTestRunner(DiscoverRunner):
|
||||
def run_tests(self, *args, **kwargs):
|
||||
with scopes_disabled():
|
||||
return super().run_tests(*args, **kwargs)
|
||||
@@ -1 +1,6 @@
|
||||
from cookbook.helper.dal import *
|
||||
import cookbook.helper.dal
|
||||
from cookbook.helper.AllAuthCustomAdapter import AllAuthCustomAdapter
|
||||
|
||||
__all__ = [
|
||||
'dal',
|
||||
]
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
from cookbook.models import Food, Keyword, Recipe, Unit
|
||||
|
||||
from dal import autocomplete
|
||||
|
||||
from cookbook.models import Keyword, Recipe, Unit, Food
|
||||
|
||||
class BaseAutocomplete(autocomplete.Select2QuerySetView):
|
||||
model = None
|
||||
|
||||
class KeywordAutocomplete(autocomplete.Select2QuerySetView):
|
||||
def get_queryset(self):
|
||||
if not self.request.user.is_authenticated:
|
||||
return Keyword.objects.none()
|
||||
return self.model.objects.none()
|
||||
|
||||
qs = Keyword.objects.all()
|
||||
|
||||
if self.q:
|
||||
qs = qs.filter(name__istartswith=self.q)
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
class IngredientsAutocomplete(autocomplete.Select2QuerySetView):
|
||||
def get_queryset(self):
|
||||
if not self.request.user.is_authenticated:
|
||||
return Food.objects.none()
|
||||
|
||||
qs = Food.objects.all()
|
||||
qs = self.model.objects.filter(space=self.request.space).all()
|
||||
|
||||
if self.q:
|
||||
qs = qs.filter(name__icontains=self.q)
|
||||
@@ -29,27 +18,17 @@ class IngredientsAutocomplete(autocomplete.Select2QuerySetView):
|
||||
return qs
|
||||
|
||||
|
||||
class RecipeAutocomplete(autocomplete.Select2QuerySetView):
|
||||
def get_queryset(self):
|
||||
if not self.request.user.is_authenticated:
|
||||
return Recipe.objects.none()
|
||||
|
||||
qs = Recipe.objects.all()
|
||||
|
||||
if self.q:
|
||||
qs = qs.filter(name__icontains=self.q)
|
||||
|
||||
return qs
|
||||
class KeywordAutocomplete(BaseAutocomplete):
|
||||
model = Keyword
|
||||
|
||||
|
||||
class UnitAutocomplete(autocomplete.Select2QuerySetView):
|
||||
def get_queryset(self):
|
||||
if not self.request.user.is_authenticated:
|
||||
return Unit.objects.none()
|
||||
class IngredientsAutocomplete(BaseAutocomplete):
|
||||
model = Food
|
||||
|
||||
qs = Unit.objects.all()
|
||||
|
||||
if self.q:
|
||||
qs = qs.filter(name__icontains=self.q)
|
||||
class RecipeAutocomplete(BaseAutocomplete):
|
||||
model = Recipe
|
||||
|
||||
return qs
|
||||
|
||||
class UnitAutocomplete(BaseAutocomplete):
|
||||
model = Unit
|
||||
|
||||
176
cookbook/helper/ingredient_parser.py
Normal file
176
cookbook/helper/ingredient_parser.py
Normal file
@@ -0,0 +1,176 @@
|
||||
import string
|
||||
import unicodedata
|
||||
|
||||
from cookbook.models import Unit, Food
|
||||
|
||||
|
||||
def parse_fraction(x):
|
||||
if len(x) == 1 and 'fraction' in unicodedata.decomposition(x):
|
||||
frac_split = unicodedata.decomposition(x[-1:]).split()
|
||||
return (float((frac_split[1]).replace('003', ''))
|
||||
/ float((frac_split[3]).replace('003', '')))
|
||||
else:
|
||||
frac_split = x.split('/')
|
||||
if not len(frac_split) == 2:
|
||||
raise ValueError
|
||||
try:
|
||||
return int(frac_split[0]) / int(frac_split[1])
|
||||
except ZeroDivisionError:
|
||||
raise ValueError
|
||||
|
||||
|
||||
def parse_amount(x):
|
||||
amount = 0
|
||||
unit = ''
|
||||
|
||||
did_check_frac = False
|
||||
end = 0
|
||||
while (
|
||||
end < len(x)
|
||||
and (
|
||||
x[end] in string.digits
|
||||
or (
|
||||
(x[end] == '.' or x[end] == ',' or x[end] == '/')
|
||||
and end + 1 < len(x)
|
||||
and x[end + 1] in string.digits
|
||||
)
|
||||
)
|
||||
):
|
||||
end += 1
|
||||
if end > 0:
|
||||
if "/" in x[:end]:
|
||||
amount = parse_fraction(x[:end])
|
||||
else:
|
||||
amount = float(x[:end].replace(',', '.'))
|
||||
else:
|
||||
amount = parse_fraction(x[0])
|
||||
end += 1
|
||||
did_check_frac = True
|
||||
if end < len(x):
|
||||
if did_check_frac:
|
||||
unit = x[end:]
|
||||
else:
|
||||
try:
|
||||
amount += parse_fraction(x[end])
|
||||
unit = x[end + 1:]
|
||||
except ValueError:
|
||||
unit = x[end:]
|
||||
return amount, unit
|
||||
|
||||
|
||||
def parse_ingredient_with_comma(tokens):
|
||||
ingredient = ''
|
||||
note = ''
|
||||
start = 0
|
||||
# search for first occurrence of an argument ending in a comma
|
||||
while start < len(tokens) and not tokens[start].endswith(','):
|
||||
start += 1
|
||||
if start == len(tokens):
|
||||
# no token ending in a comma found -> use everything as ingredient
|
||||
ingredient = ' '.join(tokens)
|
||||
else:
|
||||
ingredient = ' '.join(tokens[:start + 1])[:-1]
|
||||
note = ' '.join(tokens[start + 1:])
|
||||
return ingredient, note
|
||||
|
||||
|
||||
def parse_ingredient(tokens):
|
||||
ingredient = ''
|
||||
note = ''
|
||||
if tokens[-1].endswith(')'):
|
||||
# Check if the matching opening bracket is in the same token
|
||||
if (not tokens[-1].startswith('(')) and ('(' in tokens[-1]):
|
||||
return parse_ingredient_with_comma(tokens)
|
||||
# last argument ends with closing bracket -> look for opening bracket
|
||||
start = len(tokens) - 1
|
||||
while not tokens[start].startswith('(') and not start == 0:
|
||||
start -= 1
|
||||
if start == 0:
|
||||
# the whole list is wrapped in brackets -> assume it is an error (e.g. assumed first argument was the unit) # noqa: E501
|
||||
raise ValueError
|
||||
elif start < 0:
|
||||
# no opening bracket anywhere -> just ignore the last bracket
|
||||
ingredient, note = parse_ingredient_with_comma(tokens)
|
||||
else:
|
||||
# opening bracket found -> split in ingredient and note, remove brackets from note # noqa: E501
|
||||
note = ' '.join(tokens[start:])[1:-1]
|
||||
ingredient = ' '.join(tokens[:start])
|
||||
else:
|
||||
ingredient, note = parse_ingredient_with_comma(tokens)
|
||||
return ingredient, note
|
||||
|
||||
|
||||
def parse(x):
|
||||
# initialize default values
|
||||
amount = 0
|
||||
unit = ''
|
||||
ingredient = ''
|
||||
note = ''
|
||||
|
||||
tokens = x.split()
|
||||
if len(tokens) == 1:
|
||||
# there only is one argument, that must be the ingredient
|
||||
ingredient = tokens[0]
|
||||
else:
|
||||
try:
|
||||
# try to parse first argument as amount
|
||||
amount, unit = parse_amount(tokens[0])
|
||||
# only try to parse second argument as amount if there are at least
|
||||
# three arguments if it already has a unit there can't be
|
||||
# a fraction for the amount
|
||||
if len(tokens) > 2:
|
||||
try:
|
||||
if not unit == '':
|
||||
# a unit is already found, no need to try the second argument for a fraction # noqa: E501
|
||||
# probably not the best method to do it, but I didn't want to make an if check and paste the exact same thing in the else as already is in the except # noqa: E501
|
||||
raise ValueError
|
||||
# try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½' # noqa: E501
|
||||
amount += parse_fraction(tokens[1])
|
||||
# assume that units can't end with a comma
|
||||
if len(tokens) > 3 and not tokens[2].endswith(','):
|
||||
# try to use third argument as unit and everything else as ingredient, use everything as ingredient if it fails # noqa: E501
|
||||
try:
|
||||
ingredient, note = parse_ingredient(tokens[3:])
|
||||
unit = tokens[2]
|
||||
except ValueError:
|
||||
ingredient, note = parse_ingredient(tokens[2:])
|
||||
else:
|
||||
ingredient, note = parse_ingredient(tokens[2:])
|
||||
except ValueError:
|
||||
# assume that units can't end with a comma
|
||||
if not tokens[1].endswith(','):
|
||||
# try to use second argument as unit and everything else as ingredient, use everything as ingredient if it fails # noqa: E501
|
||||
try:
|
||||
ingredient, note = parse_ingredient(tokens[2:])
|
||||
unit = tokens[1]
|
||||
except ValueError:
|
||||
ingredient, note = parse_ingredient(tokens[1:])
|
||||
else:
|
||||
ingredient, note = parse_ingredient(tokens[1:])
|
||||
else:
|
||||
# only two arguments, first one is the amount
|
||||
# which means this is the ingredient
|
||||
ingredient = tokens[1]
|
||||
except ValueError:
|
||||
try:
|
||||
# can't parse first argument as amount
|
||||
# -> no unit -> parse everything as ingredient
|
||||
ingredient, note = parse_ingredient(tokens)
|
||||
except ValueError:
|
||||
ingredient = ' '.join(tokens[1:])
|
||||
return amount, unit.strip(), ingredient.strip(), note.strip()
|
||||
|
||||
|
||||
# small utility functions to prevent emtpy unit/food creation
|
||||
def get_unit(unit, space):
|
||||
if len(unit) > 0:
|
||||
u, created = Unit.objects.get_or_create(name=unit, space=space)
|
||||
return u
|
||||
return None
|
||||
|
||||
|
||||
def get_food(food, space):
|
||||
if len(food) > 0:
|
||||
f, created = Food.objects.get_or_create(name=food, space=space)
|
||||
return f
|
||||
return None
|
||||
@@ -1,5 +1,4 @@
|
||||
import markdown
|
||||
|
||||
from markdown.treeprocessors import Treeprocessor
|
||||
|
||||
|
||||
@@ -20,5 +19,10 @@ class StyleTreeprocessor(Treeprocessor):
|
||||
|
||||
|
||||
class MarkdownFormatExtension(markdown.Extension):
|
||||
def extendMarkdown(self, md, md_globals):
|
||||
md.treeprocessors.register(StyleTreeprocessor(), 'StyleTreeprocessor', 10)
|
||||
# md_ globals deprecated - see here:
|
||||
def extendMarkdown(self, md):
|
||||
md.treeprocessors.register(
|
||||
StyleTreeprocessor(),
|
||||
'StyleTreeprocessor',
|
||||
10
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""A more liberal autolinker
|
||||
"""
|
||||
A more liberal autolinker
|
||||
|
||||
Inspired by Django's urlize function.
|
||||
|
||||
@@ -45,27 +46,30 @@ URLIZE_RE = '(%s)' % '|'.join([
|
||||
r'[^(<\s]+\.(?:com|net|org)\b',
|
||||
])
|
||||
|
||||
|
||||
class UrlizePattern(markdown.inlinepatterns.Pattern):
|
||||
""" Return a link Element given an autolink (`http://example/com`). """
|
||||
|
||||
def handleMatch(self, m):
|
||||
url = m.group(2)
|
||||
|
||||
|
||||
if url.startswith('<'):
|
||||
url = url[1:-1]
|
||||
|
||||
|
||||
text = url
|
||||
|
||||
if not url.split('://')[0] in ('http','https','ftp'):
|
||||
if '@' in url and not '/' in url:
|
||||
|
||||
if not url.split('://')[0] in ('http', 'https', 'ftp'):
|
||||
if '@' in url and '/' not in url:
|
||||
url = 'mailto:' + url
|
||||
else:
|
||||
url = 'http://' + url
|
||||
|
||||
|
||||
el = markdown.util.etree.Element("a")
|
||||
el.set('href', url)
|
||||
el.text = markdown.util.AtomicString(text)
|
||||
return el
|
||||
|
||||
|
||||
class UrlizeExtension(markdown.Extension):
|
||||
""" Urlize Extension for Python-Markdown. """
|
||||
|
||||
@@ -73,9 +77,12 @@ class UrlizeExtension(markdown.Extension):
|
||||
""" Replace autolink with UrlizePattern """
|
||||
md.inlinePatterns['autolink'] = UrlizePattern(URLIZE_RE, md)
|
||||
|
||||
|
||||
def makeExtension(*args, **kwargs):
|
||||
return UrlizeExtension(*args, **kwargs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import doctest
|
||||
|
||||
doctest.testmod()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# Permission Config
|
||||
from cookbook.helper.permission_helper import CustomIsUser, CustomIsOwner, CustomIsAdmin, CustomIsGuest
|
||||
from cookbook.helper.permission_helper import CustomIsUser
|
||||
|
||||
|
||||
class PermissionConfig:
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
"""
|
||||
Source: https://djangosnippets.org/snippets/1703/
|
||||
"""
|
||||
from django.views.generic.detail import SingleObjectTemplateResponseMixin
|
||||
from django.views.generic.edit import ModelFormMixin
|
||||
|
||||
from cookbook.models import ShareLink
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import user_passes_test
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse_lazy, reverse
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework import permissions
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
|
||||
from cookbook.models import ShareLink
|
||||
|
||||
|
||||
# Helper Functions
|
||||
|
||||
def get_allowed_groups(groups_required):
|
||||
"""
|
||||
@@ -34,8 +33,8 @@ def get_allowed_groups(groups_required):
|
||||
def has_group_permission(user, groups):
|
||||
"""
|
||||
Tests if a given user is member of a certain group (or any higher group)
|
||||
Superusers always bypass permission checks. Unauthenticated users cant be member of any
|
||||
group thus always return false.
|
||||
Superusers always bypass permission checks.
|
||||
Unauthenticated users cant be member of any group thus always return false.
|
||||
:param user: django auth user object
|
||||
:param groups: list or tuple of groups the user should be checked for
|
||||
:return: True if user is in allowed groups, false otherwise
|
||||
@@ -44,7 +43,7 @@ def has_group_permission(user, groups):
|
||||
return False
|
||||
groups_allowed = get_allowed_groups(groups)
|
||||
if user.is_authenticated:
|
||||
if user.is_superuser | bool(user.groups.filter(name__in=groups_allowed)):
|
||||
if bool(user.groups.filter(name__in=groups_allowed)):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -52,22 +51,35 @@ def has_group_permission(user, groups):
|
||||
def is_object_owner(user, obj):
|
||||
"""
|
||||
Tests if a given user is the owner of a given object
|
||||
test performed by checking user against the objects user and create_by field (if exists)
|
||||
test performed by checking user against the objects user
|
||||
and create_by field (if exists)
|
||||
superusers bypass all checks, unauthenticated users cannot own anything
|
||||
:param user django auth user object
|
||||
:param obj any object that should be tested
|
||||
:return: true if user is owner of object, false otherwise
|
||||
"""
|
||||
# TODO this could be improved/cleaned up by adding get_owner methods to all models that allow owner checks
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
if user.is_superuser:
|
||||
return True
|
||||
if owner := getattr(obj, 'created_by', None):
|
||||
return owner == user
|
||||
if owner := getattr(obj, 'user', None):
|
||||
return owner == user
|
||||
return False
|
||||
try:
|
||||
return obj.get_owner() == user
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
def is_object_shared(user, obj):
|
||||
"""
|
||||
Tests if a given user is shared for a given object
|
||||
test performed by checking user against the objects shared table
|
||||
superusers bypass all checks, unauthenticated users cannot own anything
|
||||
:param user django auth user object
|
||||
:param obj any object that should be tested
|
||||
:return: true if user is shared for object, false otherwise
|
||||
"""
|
||||
# TODO this could be improved/cleaned up by adding
|
||||
# share checks for relevant objects
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
return user in obj.get_shared()
|
||||
|
||||
|
||||
def share_link_valid(recipe, share):
|
||||
@@ -75,7 +87,7 @@ def share_link_valid(recipe, share):
|
||||
Verifies the validity of a share uuid
|
||||
:param recipe: recipe object
|
||||
:param share: share uuid
|
||||
:return: true if a share link with the given recipe and uuid exists, false otherwise
|
||||
:return: true if a share link with the given recipe and uuid exists
|
||||
"""
|
||||
try:
|
||||
return True if ShareLink.objects.filter(recipe=recipe, uuid=share).exists() else False
|
||||
@@ -87,8 +99,8 @@ def share_link_valid(recipe, share):
|
||||
|
||||
def group_required(*groups_required):
|
||||
"""
|
||||
Decorator that tests the requesting user to be member of at least one of the provided groups
|
||||
or higher level groups
|
||||
Decorator that tests the requesting user to be member
|
||||
of at least one of the provided groups or higher level groups
|
||||
:param groups_required: list of required groups
|
||||
:return: true if member of group, false otherwise
|
||||
"""
|
||||
@@ -96,7 +108,7 @@ def group_required(*groups_required):
|
||||
def in_groups(u):
|
||||
return has_group_permission(u, groups_required)
|
||||
|
||||
return user_passes_test(in_groups, login_url='index')
|
||||
return user_passes_test(in_groups, login_url='view_no_perm')
|
||||
|
||||
|
||||
class GroupRequiredMixin(object):
|
||||
@@ -111,6 +123,14 @@ class GroupRequiredMixin(object):
|
||||
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
|
||||
return HttpResponseRedirect(reverse_lazy('index'))
|
||||
|
||||
try:
|
||||
obj = self.get_object()
|
||||
if obj.get_space() != request.space:
|
||||
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
|
||||
return HttpResponseRedirect(reverse_lazy('index'))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return super(GroupRequiredMixin, self).dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
@@ -119,12 +139,20 @@ class OwnerRequiredMixin(object):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
messages.add_message(request, messages.ERROR, _('You are not logged in and therefore cannot view this page!'))
|
||||
return HttpResponseRedirect(reverse_lazy('login'))
|
||||
return HttpResponseRedirect(reverse_lazy('account_login') + '?next=' + request.path)
|
||||
else:
|
||||
if not is_object_owner(request.user, self.get_object()):
|
||||
messages.add_message(request, messages.ERROR, _('You cannot interact with this object as its not owned by you!'))
|
||||
messages.add_message(request, messages.ERROR, _('You cannot interact with this object as it is not owned by you!'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
try:
|
||||
obj = self.get_object()
|
||||
if obj.get_space() != request.space:
|
||||
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
|
||||
return HttpResponseRedirect(reverse_lazy('index'))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
return super(OwnerRequiredMixin, self).dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
@@ -136,7 +164,7 @@ class CustomIsOwner(permissions.BasePermission):
|
||||
verifies user has ownership over object
|
||||
(either user or created_by or user is request user)
|
||||
"""
|
||||
message = _('You cannot interact with this object as its not owned by you!')
|
||||
message = _('You cannot interact with this object as it is not owned by you!') # noqa: E501
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.user.is_authenticated
|
||||
@@ -145,6 +173,21 @@ class CustomIsOwner(permissions.BasePermission):
|
||||
return is_object_owner(request.user, obj)
|
||||
|
||||
|
||||
# TODO function duplicate/too similar name
|
||||
class CustomIsShared(permissions.BasePermission):
|
||||
"""
|
||||
Custom permission class for django rest framework views
|
||||
verifies user is shared for the object he is trying to access
|
||||
"""
|
||||
message = _('You cannot interact with this object as it is not owned by you!') # noqa: E501
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
return is_object_shared(request.user, obj)
|
||||
|
||||
|
||||
class CustomIsGuest(permissions.BasePermission):
|
||||
"""
|
||||
Custom permission class for django rest framework views
|
||||
|
||||
@@ -5,20 +5,21 @@ from json import JSONDecodeError
|
||||
|
||||
import microdata
|
||||
from bs4 import BeautifulSoup
|
||||
from cookbook.helper.ingredient_parser import parse as parse_ingredient
|
||||
from cookbook.models import Keyword
|
||||
from django.http import JsonResponse
|
||||
from django.utils.dateparse import parse_duration
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.models import Keyword
|
||||
from recipe_scrapers import _utils
|
||||
|
||||
|
||||
def get_from_html(html_text, url):
|
||||
def get_from_html(html_text, url, space):
|
||||
soup = BeautifulSoup(html_text, "html.parser")
|
||||
|
||||
# first try finding ld+json as its most common
|
||||
for ld in soup.find_all('script', type='application/ld+json'):
|
||||
try:
|
||||
ld_json = json.loads(ld.string)
|
||||
ld_json = json.loads(ld.string.replace('\n', ''))
|
||||
if type(ld_json) != list:
|
||||
ld_json = [ld_json]
|
||||
|
||||
@@ -29,26 +30,37 @@ def get_from_html(html_text, url):
|
||||
if '@type' in x and x['@type'] == 'Recipe':
|
||||
ld_json_item = x
|
||||
|
||||
if '@type' in ld_json_item and ld_json_item['@type'] == 'Recipe':
|
||||
return find_recipe_json(ld_json_item, url)
|
||||
if ('@type' in ld_json_item
|
||||
and ld_json_item['@type'] == 'Recipe'):
|
||||
return JsonResponse(find_recipe_json(ld_json_item, url, space))
|
||||
except JSONDecodeError:
|
||||
JsonResponse({'error': True, 'msg': _('The requested site provided malformed data and cannot be read.')}, status=400)
|
||||
return JsonResponse(
|
||||
{
|
||||
'error': True,
|
||||
'msg': _('The requested site provided malformed data and cannot be read.') # noqa: E501
|
||||
},
|
||||
status=400)
|
||||
|
||||
# now try to find microdata
|
||||
items = microdata.get_items(html_text)
|
||||
for i in items:
|
||||
md_json = json.loads(i.json())
|
||||
if 'schema.org/Recipe' in str(md_json['type']):
|
||||
return find_recipe_json(md_json['properties'], url)
|
||||
return JsonResponse(find_recipe_json(md_json['properties'], url, space))
|
||||
|
||||
return JsonResponse({'error': True, 'msg': _('The requested site does not provide any recognized data format to import the recipe from.')}, status=400)
|
||||
return JsonResponse(
|
||||
{
|
||||
'error': True,
|
||||
'msg': _('The requested site does not provide any recognized data format to import the recipe from.') # noqa: E501
|
||||
},
|
||||
status=400)
|
||||
|
||||
|
||||
def find_recipe_json(ld_json, url):
|
||||
def find_recipe_json(ld_json, url, space):
|
||||
if type(ld_json['name']) == list:
|
||||
try:
|
||||
ld_json['name'] = ld_json['name'][0]
|
||||
except:
|
||||
except Exception:
|
||||
ld_json['name'] = 'ERROR'
|
||||
|
||||
# some sites use ingredients instead of recipeIngredients
|
||||
@@ -57,8 +69,11 @@ def find_recipe_json(ld_json, url):
|
||||
|
||||
if 'recipeIngredient' in ld_json:
|
||||
# some pages have comma separated ingredients in a single array entry
|
||||
if len(ld_json['recipeIngredient']) == 1 and len(ld_json['recipeIngredient'][0]) > 30:
|
||||
ld_json['recipeIngredient'] = ld_json['recipeIngredient'][0].split(',')
|
||||
if (len(ld_json['recipeIngredient']) == 1
|
||||
and type(ld_json['recipeIngredient']) == list):
|
||||
ld_json['recipeIngredient'] = ld_json['recipeIngredient'][0].split(',') # noqa: E501
|
||||
elif type(ld_json['recipeIngredient']) == str:
|
||||
ld_json['recipeIngredient'] = ld_json['recipeIngredient'].split(',')
|
||||
|
||||
for x in ld_json['recipeIngredient']:
|
||||
if '\n' in x:
|
||||
@@ -69,57 +84,49 @@ def find_recipe_json(ld_json, url):
|
||||
ingredients = []
|
||||
|
||||
for x in ld_json['recipeIngredient']:
|
||||
ingredient_split = x.split()
|
||||
ingredient = None
|
||||
amount = 0
|
||||
unit = ''
|
||||
if len(ingredient_split) > 2:
|
||||
ingredient = " ".join(ingredient_split[2:])
|
||||
unit = ingredient_split[1]
|
||||
if x.replace(' ', '') != '':
|
||||
x = x.replace('½', "0.5").replace('¼', "0.25").replace('¾', "0.75")
|
||||
try:
|
||||
amount = float(ingredient_split[0].replace(',', '.'))
|
||||
except ValueError:
|
||||
amount = 0
|
||||
ingredient = " ".join(ingredient_split)
|
||||
if len(ingredient_split) == 2:
|
||||
ingredient = " ".join(ingredient_split[1:])
|
||||
unit = ''
|
||||
try:
|
||||
amount = float(ingredient_split[0].replace(',', '.'))
|
||||
except ValueError:
|
||||
amount = 0
|
||||
ingredient = " ".join(ingredient_split)
|
||||
if len(ingredient_split) == 1:
|
||||
ingredient = " ".join(ingredient_split)
|
||||
|
||||
if ingredient:
|
||||
ingredients.append({'amount': amount, 'unit': {'text': unit, 'id': random.randrange(10000, 99999)}, 'ingredient': {'text': ingredient, 'id': random.randrange(10000, 99999)}, 'original': x})
|
||||
amount, unit, ingredient, note = parse_ingredient(x)
|
||||
if ingredient:
|
||||
ingredients.append(
|
||||
{
|
||||
'amount': amount,
|
||||
'unit': {
|
||||
'text': unit,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'ingredient': {
|
||||
'text': ingredient,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'note': note,
|
||||
'original': x
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
ingredients.append(
|
||||
{
|
||||
'amount': 0,
|
||||
'unit': {
|
||||
'text': '',
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'ingredient': {
|
||||
'text': x,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'note': '',
|
||||
'original': x
|
||||
}
|
||||
)
|
||||
|
||||
ld_json['recipeIngredient'] = ingredients
|
||||
else:
|
||||
ld_json['recipeIngredient'] = []
|
||||
|
||||
if 'keywords' in ld_json:
|
||||
keywords = []
|
||||
|
||||
# keywords as string
|
||||
if type(ld_json['keywords']) == str:
|
||||
ld_json['keywords'] = ld_json['keywords'].split(',')
|
||||
|
||||
# keywords as string in list
|
||||
if type(ld_json['keywords']) == list and len(ld_json['keywords']) == 1 and ',' in ld_json['keywords'][0]:
|
||||
ld_json['keywords'] = ld_json['keywords'][0].split(',')
|
||||
|
||||
# keywords as list
|
||||
for kw in ld_json['keywords']:
|
||||
if k := Keyword.objects.filter(name=kw).first():
|
||||
keywords.append({'id': str(k.id), 'text': str(k).strip()})
|
||||
else:
|
||||
keywords.append({'id': "null", 'text': kw.strip()})
|
||||
|
||||
ld_json['keywords'] = keywords
|
||||
else:
|
||||
ld_json['keywords'] = []
|
||||
ld_json['keywords'] = parse_keywords(listify_keywords(ld_json['keywords']), space)
|
||||
|
||||
if 'recipeInstructions' in ld_json:
|
||||
instructions = ''
|
||||
@@ -142,14 +149,15 @@ def find_recipe_json(ld_json, url):
|
||||
instructions += str(i)
|
||||
ld_json['recipeInstructions'] = instructions
|
||||
|
||||
ld_json['recipeInstructions'] = re.sub(r'\n\s*\n', '\n\n', ld_json['recipeInstructions'])
|
||||
ld_json['recipeInstructions'] = re.sub(' +', ' ', ld_json['recipeInstructions'])
|
||||
ld_json['recipeInstructions'] = ld_json['recipeInstructions'].replace('<p>', '')
|
||||
ld_json['recipeInstructions'] = ld_json['recipeInstructions'].replace('</p>', '')
|
||||
ld_json['recipeInstructions'] = re.sub(r'\n\s*\n', '\n\n', ld_json['recipeInstructions']) # noqa: E501
|
||||
ld_json['recipeInstructions'] = re.sub(' +', ' ', ld_json['recipeInstructions']) # noqa: E501
|
||||
ld_json['recipeInstructions'] = ld_json['recipeInstructions'].replace('<p>', '') # noqa: E501
|
||||
ld_json['recipeInstructions'] = ld_json['recipeInstructions'].replace('</p>', '') # noqa: E501
|
||||
else:
|
||||
ld_json['recipeInstructions'] = ''
|
||||
|
||||
ld_json['recipeInstructions'] += '\n\n' + _('Imported from ') + url
|
||||
if url != '':
|
||||
ld_json['recipeInstructions'] += '\n\n' + _('Imported from') + ' ' + url
|
||||
|
||||
if 'image' in ld_json:
|
||||
# check if list of images is returned, take first if so
|
||||
@@ -165,9 +173,14 @@ def find_recipe_json(ld_json, url):
|
||||
|
||||
if 'cookTime' in ld_json:
|
||||
try:
|
||||
if type(ld_json['cookTime']) == list and len(ld_json['cookTime']) > 0:
|
||||
if (type(ld_json['cookTime']) == list
|
||||
and len(ld_json['cookTime']) > 0):
|
||||
ld_json['cookTime'] = ld_json['cookTime'][0]
|
||||
ld_json['cookTime'] = round(parse_duration(ld_json['cookTime']).seconds / 60)
|
||||
ld_json['cookTime'] = round(
|
||||
parse_duration(
|
||||
ld_json['cookTime']
|
||||
).seconds / 60
|
||||
)
|
||||
except TypeError:
|
||||
ld_json['cookTime'] = 0
|
||||
else:
|
||||
@@ -175,16 +188,156 @@ def find_recipe_json(ld_json, url):
|
||||
|
||||
if 'prepTime' in ld_json:
|
||||
try:
|
||||
if type(ld_json['prepTime']) == list and len(ld_json['prepTime']) > 0:
|
||||
if (type(ld_json['prepTime']) == list
|
||||
and len(ld_json['prepTime']) > 0):
|
||||
ld_json['prepTime'] = ld_json['prepTime'][0]
|
||||
ld_json['prepTime'] = round(parse_duration(ld_json['prepTime']).seconds / 60)
|
||||
ld_json['prepTime'] = round(
|
||||
parse_duration(
|
||||
ld_json['prepTime']
|
||||
).seconds / 60
|
||||
)
|
||||
except TypeError:
|
||||
ld_json['prepTime'] = 0
|
||||
else:
|
||||
ld_json['prepTime'] = 0
|
||||
|
||||
ld_json['servings'] = 1
|
||||
try:
|
||||
if 'recipeYield' in ld_json:
|
||||
if type(ld_json['recipeYield']) == str:
|
||||
ld_json['servings'] = int(re.findall(r'\b\d+\b', ld_json['recipeYield'])[0])
|
||||
elif type(ld_json['recipeYield']) == list:
|
||||
ld_json['servings'] = int(re.findall(r'\b\d+\b', ld_json['recipeYield'][0])[0])
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
for key in list(ld_json):
|
||||
if key not in ['prepTime', 'cookTime', 'image', 'recipeInstructions', 'keywords', 'name', 'recipeIngredient']:
|
||||
if key not in [
|
||||
'prepTime', 'cookTime', 'image', 'recipeInstructions',
|
||||
'keywords', 'name', 'recipeIngredient', 'servings', 'description'
|
||||
]:
|
||||
ld_json.pop(key, None)
|
||||
|
||||
return JsonResponse(ld_json)
|
||||
return ld_json
|
||||
|
||||
|
||||
def get_from_scraper(scrape, space):
|
||||
# converting the scrape_me object to the existing json format based on ld+json
|
||||
|
||||
recipe_json = {}
|
||||
recipe_json['name'] = scrape.title()
|
||||
|
||||
try:
|
||||
description = scrape.schema.data.get("description") or ''
|
||||
recipe_json['prepTime'] = _utils.get_minutes(scrape.schema.data.get("prepTime")) or 0
|
||||
recipe_json['cookTime'] = _utils.get_minutes(scrape.schema.data.get("cookTime")) or 0
|
||||
except AttributeError:
|
||||
description = ''
|
||||
recipe_json['prepTime'] = 0
|
||||
recipe_json['cookTime'] = 0
|
||||
|
||||
recipe_json['description'] = description
|
||||
|
||||
try:
|
||||
servings = scrape.yields()
|
||||
servings = int(re.findall(r'\b\d+\b', servings)[0])
|
||||
except (AttributeError, ValueError, IndexError):
|
||||
servings = 1
|
||||
recipe_json['servings'] = servings
|
||||
|
||||
if recipe_json['cookTime'] + recipe_json['prepTime'] == 0:
|
||||
try:
|
||||
recipe_json['prepTime'] = scrape.total_time()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
recipe_json['image'] = scrape.image()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
keywords = []
|
||||
try:
|
||||
if scrape.schema.data.get("keywords"):
|
||||
keywords += listify_keywords(scrape.schema.data.get("keywords"))
|
||||
if scrape.schema.data.get('recipeCategory'):
|
||||
keywords += listify_keywords(scrape.schema.data.get("recipeCategory"))
|
||||
if scrape.schema.data.get('recipeCuisine'):
|
||||
keywords += listify_keywords(scrape.schema.data.get("recipeCuisine"))
|
||||
recipe_json['keywords'] = parse_keywords(list(set(map(str.casefold, keywords))), space)
|
||||
except AttributeError:
|
||||
recipe_json['keywords'] = keywords
|
||||
|
||||
try:
|
||||
ingredients = []
|
||||
for x in scrape.ingredients():
|
||||
try:
|
||||
amount, unit, ingredient, note = parse_ingredient(x)
|
||||
if ingredient:
|
||||
ingredients.append(
|
||||
{
|
||||
'amount': amount,
|
||||
'unit': {
|
||||
'text': unit,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'ingredient': {
|
||||
'text': ingredient,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'note': note,
|
||||
'original': x
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
ingredients.append(
|
||||
{
|
||||
'amount': 0,
|
||||
'unit': {
|
||||
'text': '',
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'ingredient': {
|
||||
'text': x,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'note': '',
|
||||
'original': x
|
||||
}
|
||||
)
|
||||
recipe_json['recipeIngredient'] = ingredients
|
||||
except AttributeError:
|
||||
recipe_json['recipeIngredient'] = ingredients
|
||||
|
||||
try:
|
||||
recipe_json['recipeInstructions'] = scrape.instructions()
|
||||
except AttributeError:
|
||||
recipe_json['recipeInstructions'] = ""
|
||||
|
||||
recipe_json['recipeInstructions'] += "\n\nImported from " + scrape.url
|
||||
return recipe_json
|
||||
|
||||
|
||||
def parse_keywords(keyword_json, space):
|
||||
keywords = []
|
||||
# keywords as list
|
||||
for kw in keyword_json:
|
||||
if k := Keyword.objects.filter(name=kw, space=space).first():
|
||||
keywords.append({'id': str(k.id), 'text': str(k)})
|
||||
else:
|
||||
keywords.append({'id': random.randrange(1111111, 9999999, 1), 'text': kw})
|
||||
|
||||
return keywords
|
||||
|
||||
|
||||
def listify_keywords(keyword_list):
|
||||
# keywords as string
|
||||
if type(keyword_list) == str:
|
||||
keyword_list = keyword_list.split(',')
|
||||
|
||||
# keywords as string in list
|
||||
if (type(keyword_list) == list
|
||||
and len(keyword_list) == 1
|
||||
and ',' in keyword_list[0]):
|
||||
keyword_list = keyword_list[0].split(',')
|
||||
return [x.strip() for x in keyword_list]
|
||||
|
||||
33
cookbook/helper/scope_middleware.py
Normal file
33
cookbook/helper/scope_middleware.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django_scopes import scope, scopes_disabled
|
||||
|
||||
from cookbook.views import views
|
||||
|
||||
|
||||
class ScopeMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if request.user.is_authenticated:
|
||||
|
||||
if request.path.startswith('/admin/'):
|
||||
with scopes_disabled():
|
||||
return self.get_response(request)
|
||||
|
||||
with scopes_disabled():
|
||||
if request.user.userpreference.space is None and not reverse('account_logout') in request.path:
|
||||
return views.no_space(request)
|
||||
|
||||
if request.user.groups.count() == 0 and not reverse('account_logout') in request.path:
|
||||
return views.no_groups(request)
|
||||
|
||||
request.space = request.user.userpreference.space
|
||||
# with scopes_disabled():
|
||||
with scope(space=request.space):
|
||||
return self.get_response(request)
|
||||
else:
|
||||
with scopes_disabled():
|
||||
request.space = None
|
||||
return self.get_response(request)
|
||||
64
cookbook/helper/template_helper.py
Normal file
64
cookbook/helper/template_helper.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import bleach
|
||||
import markdown as md
|
||||
from bleach_allowlist import markdown_attrs, markdown_tags
|
||||
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 _
|
||||
|
||||
class IngredientObject(object):
|
||||
amount = ""
|
||||
unit = ""
|
||||
food = ""
|
||||
note = ""
|
||||
|
||||
def __init__(self, ingredient):
|
||||
if ingredient.no_amount:
|
||||
self.amount = ""
|
||||
else:
|
||||
self.amount = f"<scalable-number v-bind:number='{bleach.clean(str(ingredient.amount))}' v-bind:factor='ingredient_factor'></scalable-number>"
|
||||
if ingredient.unit:
|
||||
self.unit = bleach.clean(str(ingredient.unit))
|
||||
else:
|
||||
self.unit = ""
|
||||
self.food = bleach.clean(str(ingredient.food))
|
||||
self.note = bleach.clean(str(ingredient.note))
|
||||
|
||||
def __str__(self):
|
||||
ingredient = self.amount
|
||||
if self.unit != "":
|
||||
ingredient += f' {self.unit}'
|
||||
return f'{ingredient} {self.food}'
|
||||
|
||||
|
||||
def render_instructions(step): # TODO deduplicate markdown cleanup code
|
||||
instructions = step.instruction
|
||||
|
||||
tags = markdown_tags + [
|
||||
'pre', 'table', 'td', 'tr', 'th', 'tbody', 'style', 'thead'
|
||||
]
|
||||
parsed_md = md.markdown(
|
||||
instructions,
|
||||
extensions=[
|
||||
'markdown.extensions.fenced_code', 'tables',
|
||||
UrlizeExtension(), MarkdownFormatExtension()
|
||||
]
|
||||
)
|
||||
markdown_attrs['*'] = markdown_attrs['*'] + ['class']
|
||||
|
||||
instructions = bleach.clean(parsed_md, tags, markdown_attrs)
|
||||
|
||||
ingredients = []
|
||||
|
||||
for i in step.ingredients.all():
|
||||
ingredients.append(IngredientObject(i))
|
||||
|
||||
try:
|
||||
template = Template(instructions)
|
||||
instructions = template.render(ingredients=ingredients)
|
||||
except TemplateSyntaxError:
|
||||
return _('Could not parse template code.') + ' Error: Template Syntax broken'
|
||||
except UndefinedError:
|
||||
return _('Could not parse template code.') + ' Error: Undefined Error'
|
||||
|
||||
return instructions
|
||||
59
cookbook/integration/Pepperplate.py
Normal file
59
cookbook/integration/Pepperplate.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient, Keyword
|
||||
|
||||
|
||||
class Pepperplate(Integration):
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
ingredient_mode = False
|
||||
direction_mode = False
|
||||
|
||||
ingredients = []
|
||||
directions = []
|
||||
for fl in file.readlines():
|
||||
line = fl.decode("utf-8")
|
||||
if 'Title:' in line:
|
||||
title = line.replace('Title:', '').replace('"', '').strip()
|
||||
if 'Description:' in line:
|
||||
description = line.replace('Description:', '').strip()
|
||||
if 'Original URL:' in line or 'Source:' in line or 'Yield:' in line or 'Total:' in line:
|
||||
if len(line.strip().split(':')[1]) > 0:
|
||||
directions.append(line.strip() + '\n')
|
||||
if ingredient_mode:
|
||||
if len(line) > 2 and 'Instructions:' not in line:
|
||||
ingredients.append(line.strip())
|
||||
if direction_mode:
|
||||
if len(line) > 2:
|
||||
directions.append(line.strip() + '\n')
|
||||
if 'Ingredients:' in line:
|
||||
ingredient_mode = True
|
||||
if 'Instructions:' in line:
|
||||
ingredient_mode = False
|
||||
direction_mode = True
|
||||
|
||||
recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space)
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction='\n'.join(directions) + '\n\n'
|
||||
)
|
||||
|
||||
for ingredient in ingredients:
|
||||
if len(ingredient.strip()) > 0:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
60
cookbook/integration/cheftap.py
Normal file
60
cookbook/integration/cheftap.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import re
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
|
||||
|
||||
class ChefTap(Integration):
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
print("testing", zip_info_object.filename)
|
||||
return re.match(r'^cheftap_export/([A-Za-z\d\w\s-])+.txt$', zip_info_object.filename) or re.match(r'^([A-Za-z\d\w\s-])+.txt$', zip_info_object.filename)
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
source_url = ''
|
||||
|
||||
ingredient_mode = 0
|
||||
|
||||
ingredients = []
|
||||
directions = []
|
||||
for i, fl in enumerate(file.readlines(), start=0):
|
||||
line = fl.decode("utf-8")
|
||||
if i == 0:
|
||||
title = line.strip()
|
||||
else:
|
||||
if line.startswith('https:') or line.startswith('http:'):
|
||||
source_url = line.strip()
|
||||
else:
|
||||
if ingredient_mode == 1 and len(line.strip()) == 0:
|
||||
ingredient_mode = 2
|
||||
if re.match(r'^([0-9])[^.](.)*$', line) and ingredient_mode < 2:
|
||||
ingredient_mode = 1
|
||||
ingredients.append(line.strip())
|
||||
else:
|
||||
directions.append(line.strip())
|
||||
|
||||
recipe = Recipe.objects.create(name=title, created_by=self.request.user, internal=True, space=self.request.space, )
|
||||
|
||||
step = Step.objects.create(instruction='\n'.join(directions))
|
||||
|
||||
if source_url != '':
|
||||
step.instruction += '\n' + source_url
|
||||
step.save()
|
||||
|
||||
for ingredient in ingredients:
|
||||
if len(ingredient.strip()) > 0:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
79
cookbook/integration/chowdown.py
Normal file
79
cookbook/integration/chowdown.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient, Keyword
|
||||
|
||||
|
||||
class Chowdown(Integration):
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
print("testing", zip_info_object.filename)
|
||||
return re.match(r'^(_)*recipes/([A-Za-z\d\s-])+.md$', zip_info_object.filename)
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
ingredient_mode = False
|
||||
direction_mode = False
|
||||
description_mode = False
|
||||
|
||||
ingredients = []
|
||||
directions = []
|
||||
descriptions = []
|
||||
for fl in file.readlines():
|
||||
line = fl.decode("utf-8")
|
||||
if 'title:' in line:
|
||||
title = line.replace('title:', '').replace('"', '').strip()
|
||||
if 'image:' in line:
|
||||
image = line.replace('image:', '').strip()
|
||||
if 'tags:' in line:
|
||||
tags = line.replace('tags:', '').strip()
|
||||
if ingredient_mode:
|
||||
if len(line) > 2 and 'directions:' not in line:
|
||||
ingredients.append(line[2:])
|
||||
if '---' in line and direction_mode:
|
||||
direction_mode = False
|
||||
description_mode = True
|
||||
if direction_mode:
|
||||
if len(line) > 2:
|
||||
directions.append(line[2:])
|
||||
if 'ingredients:' in line:
|
||||
ingredient_mode = True
|
||||
if 'directions:' in line:
|
||||
ingredient_mode = False
|
||||
direction_mode = True
|
||||
if description_mode and len(line) > 3 and '---' not in line:
|
||||
descriptions.append(line)
|
||||
|
||||
recipe = Recipe.objects.create(name=title, created_by=self.request.user, internal=True, space=self.request.space)
|
||||
|
||||
for k in tags.split(','):
|
||||
keyword, created = Keyword.objects.get_or_create(name=k.strip(), space=self.request.space)
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction='\n'.join(directions) + '\n\n' + '\n'.join(descriptions)
|
||||
)
|
||||
|
||||
for ingredient in ingredients:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
for f in self.files:
|
||||
if '.zip' in f['name']:
|
||||
import_zip = ZipFile(f['file'])
|
||||
for z in import_zip.filelist:
|
||||
if re.match(f'^images/{image}$', z.filename):
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)))
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
34
cookbook/integration/default.py
Normal file
34
cookbook/integration/default.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import json
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.serializer import RecipeExportSerializer
|
||||
|
||||
|
||||
class Default(Integration):
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
recipe_zip = ZipFile(file)
|
||||
|
||||
recipe_string = recipe_zip.read('recipe.json').decode("utf-8")
|
||||
recipe = self.decode_recipe(recipe_string)
|
||||
if 'image.png' in recipe_zip.namelist():
|
||||
self.import_recipe_image(recipe, BytesIO(recipe_zip.read('image.png')))
|
||||
return recipe
|
||||
|
||||
def decode_recipe(self, string):
|
||||
data = json.loads(string)
|
||||
serialized_recipe = RecipeExportSerializer(data=data, context={'request': self.request})
|
||||
if serialized_recipe.is_valid():
|
||||
recipe = serialized_recipe.save()
|
||||
return recipe
|
||||
|
||||
return None
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
export = RecipeExportSerializer(recipe).data
|
||||
|
||||
return 'recipe.json', JSONRenderer().render(export).decode("utf-8")
|
||||
56
cookbook/integration/domestica.py
Normal file
56
cookbook/integration/domestica.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import base64
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Ingredient
|
||||
|
||||
|
||||
class Domestica(Integration):
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
|
||||
recipe = Recipe.objects.create(
|
||||
name=file['name'].strip(),
|
||||
created_by=self.request.user, internal=True,
|
||||
space=self.request.space)
|
||||
|
||||
if file['servings'] != '':
|
||||
recipe.servings = file['servings']
|
||||
|
||||
if file['timeCook'] != '':
|
||||
recipe.waiting_time = file['timeCook']
|
||||
|
||||
if file['timePrep'] != '':
|
||||
recipe.working_time = file['timePrep']
|
||||
|
||||
recipe.save()
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction=file['directions']
|
||||
)
|
||||
|
||||
if file['source'] != '':
|
||||
step.instruction += '\n' + file['source']
|
||||
|
||||
for ingredient in file['ingredients'].split('\n'):
|
||||
if len(ingredient.strip()) > 0:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
if file['image'] != '':
|
||||
self.import_recipe_image(recipe, BytesIO(base64.b64decode(file['image'].replace('data:image/jpeg;base64,', ''))))
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
|
||||
def split_recipe_file(self, file):
|
||||
return json.loads(file.read().decode("utf-8"))
|
||||
186
cookbook/integration/integration.py
Normal file
186
cookbook/integration/integration.py
Normal file
@@ -0,0 +1,186 @@
|
||||
import datetime
|
||||
import json
|
||||
import uuid
|
||||
from io import BytesIO, StringIO
|
||||
from zipfile import ZipFile, BadZipFile
|
||||
|
||||
from django.core.files import File
|
||||
from django.http import HttpResponse
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scope
|
||||
|
||||
from cookbook.forms import ImportExportBase
|
||||
from cookbook.models import Keyword, Recipe
|
||||
|
||||
|
||||
class Integration:
|
||||
request = None
|
||||
keyword = None
|
||||
files = None
|
||||
export_type = None
|
||||
ignored_recipes = []
|
||||
|
||||
def __init__(self, request, export_type):
|
||||
"""
|
||||
Integration for importing and exporting recipes
|
||||
:param request: request context of import session (used to link user to created objects)
|
||||
"""
|
||||
self.request = request
|
||||
self.export_type = export_type
|
||||
self.keyword = Keyword.objects.create(
|
||||
name=f'Import {export_type} {date_format(datetime.datetime.now(), "DATETIME_FORMAT")}.{datetime.datetime.now().strftime("%S")}',
|
||||
description=f'Imported by {request.user.get_user_name()} at {date_format(datetime.datetime.now(), "DATETIME_FORMAT")}. Type: {export_type}',
|
||||
icon='📥',
|
||||
space=request.space
|
||||
)
|
||||
|
||||
def do_export(self, recipes):
|
||||
"""
|
||||
Perform the export based on a list of recipes
|
||||
:param recipes: list of recipe objects
|
||||
:return: HttpResponse with a ZIP file that is directly downloaded
|
||||
"""
|
||||
|
||||
# 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')
|
||||
|
||||
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.write(r.image.path, 'image.png')
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
recipe_zip_obj.close()
|
||||
export_zip_obj.writestr(str(r.pk) + '.zip', recipe_zip_stream.getvalue())
|
||||
|
||||
export_zip_obj.close()
|
||||
|
||||
response = HttpResponse(export_zip_stream.getvalue(), content_type='application/force-download')
|
||||
response['Content-Disposition'] = 'attachment; filename="export.zip"'
|
||||
return response
|
||||
else:
|
||||
json_list = []
|
||||
for r in recipes:
|
||||
json_list.append(self.get_file_from_recipe(r))
|
||||
|
||||
response = HttpResponse(json.dumps(json_list), content_type='application/force-download')
|
||||
response['Content-Disposition'] = 'attachment; filename="recipes.json"'
|
||||
return response
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
"""
|
||||
Since zipfile.namelist() returns all files in all subdirectories this function allows filtering of files
|
||||
If false is returned the file will be ignored
|
||||
By default all files are included
|
||||
:param zip_info_object: ZipInfo object
|
||||
:return: Boolean if object should be included
|
||||
"""
|
||||
return True
|
||||
|
||||
def do_import(self, files, il, import_duplicates):
|
||||
"""
|
||||
Imports given files
|
||||
:param import_duplicates: if true duplicates are imported as well
|
||||
:param files: List of in memory files
|
||||
:param il: Import Log object to refresh while running
|
||||
:return: HttpResponseRedirect to the recipe search showing all imported recipes
|
||||
"""
|
||||
with scope(space=self.request.space):
|
||||
self.keyword.name = _('Import') + ' ' + str(il.pk)
|
||||
self.keyword.save()
|
||||
|
||||
try:
|
||||
self.files = files
|
||||
for f in files:
|
||||
if '.zip' in f['name'] or '.paprikarecipes' in f['name']:
|
||||
import_zip = ZipFile(f['file'])
|
||||
for z in import_zip.filelist:
|
||||
if self.import_file_name_filter(z):
|
||||
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += f'{recipe.pk} - {recipe.name} \n'
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
|
||||
import_zip.close()
|
||||
elif '.json' in f['name'] or '.txt' in f['name']:
|
||||
data_list = self.split_recipe_file(f['file'])
|
||||
for d in data_list:
|
||||
recipe = self.get_recipe_from_file(d)
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += f'{recipe.pk} - {recipe.name} \n'
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
else:
|
||||
recipe = self.get_recipe_from_file(f['file'])
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += f'{recipe.pk} - {recipe.name} \n'
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
except BadZipFile:
|
||||
il.msg += 'ERROR ' + _('Importer expected a .zip file. Did you choose the correct importer type for your data ?') + '\n'
|
||||
|
||||
if len(self.ignored_recipes) > 0:
|
||||
il.msg += '\n' + _('The following recipes were ignored because they already existed:') + ' ' + ', '.join(self.ignored_recipes) + '\n\n'
|
||||
|
||||
il.keyword = self.keyword
|
||||
il.msg += (_('Imported %s recipes.') % Recipe.objects.filter(keywords=self.keyword).count()) + '\n'
|
||||
il.running = False
|
||||
il.save()
|
||||
|
||||
def handle_duplicates(self, recipe, import_duplicates):
|
||||
"""
|
||||
Checks if a recipe is already present, if so deletes it
|
||||
:param recipe: Recipe object
|
||||
:param import_duplicates: if duplicates should be imported
|
||||
"""
|
||||
if Recipe.objects.filter(space=self.request.space, name=recipe.name).count() > 1 and not import_duplicates:
|
||||
recipe.delete()
|
||||
self.ignored_recipes.append(recipe.name)
|
||||
|
||||
@staticmethod
|
||||
def import_recipe_image(recipe, image_file):
|
||||
"""
|
||||
Adds an image to a recipe naming it correctly
|
||||
:param recipe: Recipe object
|
||||
:param image_file: ByteIO stream containing the image
|
||||
"""
|
||||
recipe.image = File(image_file, name=f'{uuid.uuid4()}_{recipe.pk}.png')
|
||||
recipe.save()
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
"""
|
||||
Takes any file like object and converts it into a recipe
|
||||
:param file: ByteIO or any file like object, depends on provider
|
||||
:return: Recipe object
|
||||
"""
|
||||
raise NotImplementedError('Method not implemented in integration')
|
||||
|
||||
def split_recipe_file(self, file):
|
||||
"""
|
||||
Takes a file that contains multiple recipes and splits it into a list of strings of various formats (e.g. json, text, ..)
|
||||
:param file: ByteIO or any file like object, depends on provider
|
||||
:return: list of strings
|
||||
"""
|
||||
raise NotImplementedError('Method not implemented in integration')
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
"""
|
||||
Takes a recipe object and converts it to a string (depending on the format)
|
||||
returns both the filename of the exported file and the file contents
|
||||
:param recipe: Recipe object that should be converted
|
||||
:returns:
|
||||
- name - file name in export
|
||||
- data - string content for file to get created in export zip
|
||||
"""
|
||||
raise NotImplementedError('Method not implemented in integration')
|
||||
52
cookbook/integration/mealie.py
Normal file
52
cookbook/integration/mealie.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
|
||||
|
||||
class Mealie(Integration):
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
return re.match(r'^recipes/([A-Za-z\d-])+.json$', zip_info_object.filename)
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
recipe_json = json.loads(file.getvalue().decode("utf-8"))
|
||||
|
||||
recipe = Recipe.objects.create(
|
||||
name=recipe_json['name'].strip(), description=recipe_json['description'].strip(),
|
||||
created_by=self.request.user, internal=True, space=self.request.space)
|
||||
|
||||
# TODO parse times (given in PT2H3M )
|
||||
|
||||
ingredients_added = False
|
||||
for s in recipe_json['recipeInstructions']:
|
||||
step = Step.objects.create(
|
||||
instruction=s['text']
|
||||
)
|
||||
if not ingredients_added:
|
||||
ingredients_added = True
|
||||
|
||||
for ingredient in recipe_json['recipeIngredient']:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
for f in self.files:
|
||||
if '.zip' in f['name']:
|
||||
import_zip = ZipFile(f['file'])
|
||||
for z in import_zip.filelist:
|
||||
if re.match(f'^images/{recipe_json["slug"]}.jpg$', z.filename):
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)))
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
83
cookbook/integration/mealmaster.py
Normal file
83
cookbook/integration/mealmaster.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient, Keyword
|
||||
|
||||
|
||||
class MealMaster(Integration):
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
print('------------ getting recipe')
|
||||
servings = 1
|
||||
ingredients = []
|
||||
directions = []
|
||||
for line in file.replace('\r', '').split('\n'):
|
||||
print('testing line')
|
||||
if not line.startswith('MMMMM') and line.strip != '':
|
||||
if 'Title:' in line:
|
||||
title = line.replace('Title:', '').strip()
|
||||
else:
|
||||
if 'Categories:' in line:
|
||||
tags = line.replace('Categories:', '').strip()
|
||||
else:
|
||||
if 'Yield:' in line:
|
||||
servings_text = line.replace('Yield:', '').strip()
|
||||
else:
|
||||
if re.match('\s{2,}([0-9])+', line):
|
||||
ingredients.append(line.strip())
|
||||
else:
|
||||
directions.append(line.strip())
|
||||
|
||||
try:
|
||||
servings = re.findall('([0-9])+', servings_text)[0]
|
||||
except Exception as e:
|
||||
print('failed parsing servings ', e)
|
||||
|
||||
recipe = Recipe.objects.create(name=title, servings=servings, created_by=self.request.user, internal=True, space=self.request.space)
|
||||
|
||||
for k in tags.split(','):
|
||||
keyword, created = Keyword.objects.get_or_create(name=k.strip(), space=self.request.space)
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction='\n'.join(directions) + '\n\n'
|
||||
)
|
||||
|
||||
for ingredient in ingredients:
|
||||
if len(ingredient.strip()) > 0:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
|
||||
def split_recipe_file(self, file):
|
||||
recipe_list = []
|
||||
current_recipe = ''
|
||||
|
||||
for fl in file.readlines():
|
||||
line = fl.decode("ANSI")
|
||||
if (line.startswith('MMMMM') or line.startswith('-----')) and 'meal-master' in line.lower():
|
||||
if current_recipe != '':
|
||||
recipe_list.append(current_recipe)
|
||||
current_recipe = ''
|
||||
else:
|
||||
current_recipe = ''
|
||||
else:
|
||||
current_recipe += line + '\n'
|
||||
|
||||
if current_recipe != '':
|
||||
recipe_list.append(current_recipe)
|
||||
|
||||
return recipe_list
|
||||
54
cookbook/integration/nextcloud_cookbook.py
Normal file
54
cookbook/integration/nextcloud_cookbook.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
|
||||
|
||||
class NextcloudCookbook(Integration):
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
return re.match(r'^Recipes/([A-Za-z\d\s])+/recipe.json$', zip_info_object.filename)
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
recipe_json = json.loads(file.getvalue().decode("utf-8"))
|
||||
|
||||
recipe = Recipe.objects.create(
|
||||
name=recipe_json['name'].strip(), description=recipe_json['description'].strip(),
|
||||
created_by=self.request.user, internal=True,
|
||||
servings=recipe_json['recipeYield'], space=self.request.space)
|
||||
|
||||
# TODO parse times (given in PT2H3M )
|
||||
# TODO parse keywords
|
||||
|
||||
ingredients_added = False
|
||||
for s in recipe_json['recipeInstructions']:
|
||||
step = Step.objects.create(
|
||||
instruction=s
|
||||
)
|
||||
if not ingredients_added:
|
||||
ingredients_added = True
|
||||
|
||||
for ingredient in recipe_json['recipeIngredient']:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
for f in self.files:
|
||||
if '.zip' in f['name']:
|
||||
import_zip = ZipFile(f['file'])
|
||||
for z in import_zip.filelist:
|
||||
if re.match(f'^Recipes/{recipe.name}/full.jpg$', z.filename):
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)))
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
73
cookbook/integration/paprika.py
Normal file
73
cookbook/integration/paprika.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Ingredient, Keyword
|
||||
from gettext import gettext as _
|
||||
|
||||
|
||||
class Paprika(Integration):
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
with gzip.open(file, 'r') as recipe_zip:
|
||||
recipe_json = json.loads(recipe_zip.read().decode("utf-8"))
|
||||
|
||||
recipe = Recipe.objects.create(
|
||||
name=recipe_json['name'].strip(), description=recipe_json['description'].strip(),
|
||||
created_by=self.request.user, internal=True, space=self.request.space)
|
||||
|
||||
try:
|
||||
if re.match(r'([0-9])+\s(.)*', recipe_json['servings'] ):
|
||||
s = recipe_json['servings'].split(' ')
|
||||
recipe.servings = s[0]
|
||||
recipe.servings_text = s[1]
|
||||
|
||||
if len(recipe_json['cook_time'].strip()) > 0:
|
||||
recipe.waiting_time = re.findall(r'\d+', recipe_json['cook_time'])[0]
|
||||
|
||||
if len(recipe_json['prep_time'].strip()) > 0:
|
||||
recipe.working_time = re.findall(r'\d+', recipe_json['prep_time'])[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
recipe.save()
|
||||
|
||||
instructions = recipe_json['directions']
|
||||
if len(recipe_json['notes'].strip()) > 0:
|
||||
instructions += '\n\n### ' + _('Notes') + ' \n' + recipe_json['notes']
|
||||
|
||||
if len(recipe_json['nutritional_info'].strip()) > 0:
|
||||
instructions += '\n\n### ' + _('Nutritional Information') + ' \n' + recipe_json['nutritional_info']
|
||||
|
||||
if len(recipe_json['source'].strip()) > 0 or len(recipe_json['source_url'].strip()) > 0:
|
||||
instructions += '\n\n### ' + _('Source') + ' \n' + recipe_json['source'].strip() + ' \n' + recipe_json['source_url'].strip()
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction=instructions
|
||||
)
|
||||
|
||||
if 'categories' in recipe_json:
|
||||
for c in recipe_json['categories']:
|
||||
keyword, created = Keyword.objects.get_or_create(name=c.strip(), space=self.request.space)
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
for ingredient in recipe_json['ingredients'].split('\n'):
|
||||
if len(ingredient.strip()) > 0:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
|
||||
recipe.steps.add(step)
|
||||
|
||||
self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_json['photo_data'])))
|
||||
return recipe
|
||||
93
cookbook/integration/recipesage.py
Normal file
93
cookbook/integration/recipesage.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import base64
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Ingredient
|
||||
|
||||
|
||||
class RecipeSage(Integration):
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
|
||||
recipe = Recipe.objects.create(
|
||||
name=file['name'].strip(),
|
||||
created_by=self.request.user, internal=True,
|
||||
space=self.request.space)
|
||||
|
||||
try:
|
||||
if file['recipeYield'] != '':
|
||||
recipe.servings = int(file['recipeYield'])
|
||||
|
||||
if file['totalTime'] != '':
|
||||
recipe.waiting_time = int(file['totalTime']) - int(file['timePrep'])
|
||||
|
||||
if file['prepTime'] != '':
|
||||
recipe.working_time = int(file['timePrep'])
|
||||
|
||||
recipe.save()
|
||||
except Exception as e:
|
||||
print('failed to parse yield or time ', str(e))
|
||||
|
||||
ingredients_added = False
|
||||
for s in file['recipeInstructions']:
|
||||
step = Step.objects.create(
|
||||
instruction=s['text']
|
||||
)
|
||||
if not ingredients_added:
|
||||
ingredients_added = True
|
||||
|
||||
for ingredient in file['recipeIngredient']:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
if len(file['image']) > 0:
|
||||
try:
|
||||
response = requests.get(file['image'][0])
|
||||
self.import_recipe_image(recipe, BytesIO(response.content))
|
||||
except Exception as e:
|
||||
print('failed to import image ', str(e))
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
data = {
|
||||
'@context': 'http://schema.org',
|
||||
'@type': 'Recipe',
|
||||
'creditText': '',
|
||||
'isBasedOn': '',
|
||||
'name': recipe.name,
|
||||
'description': recipe.description,
|
||||
'prepTime': str(recipe.working_time),
|
||||
'totalTime': str(recipe.waiting_time + recipe.working_time),
|
||||
'recipeYield': str(recipe.servings),
|
||||
'image': [],
|
||||
'recipeCategory': [],
|
||||
'comment': [],
|
||||
'recipeIngredient': [],
|
||||
'recipeInstructions': [],
|
||||
}
|
||||
|
||||
for s in recipe.steps.all():
|
||||
if s.type != Step.TIME:
|
||||
data['recipeInstructions'].append({
|
||||
'@type': 'HowToStep',
|
||||
'text': s.instruction
|
||||
})
|
||||
|
||||
for i in s.ingredients.all():
|
||||
data['recipeIngredient'].append(f'{float(i.amount)} {i.unit} {i.food}')
|
||||
|
||||
return data
|
||||
|
||||
def split_recipe_file(self, file):
|
||||
return json.loads(file.read().decode("utf-8"))
|
||||
82
cookbook/integration/rezkonv.py
Normal file
82
cookbook/integration/rezkonv.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient, Keyword
|
||||
|
||||
|
||||
class RezKonv(Integration):
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
|
||||
ingredient_mode = False
|
||||
direction_mode = False
|
||||
|
||||
ingredients = []
|
||||
directions = []
|
||||
for line in file.replace('\r', '').split('\n'):
|
||||
if 'Titel:' in line:
|
||||
title = line.replace('Titel:', '').strip()
|
||||
if 'Kategorien:' in line:
|
||||
tags = line.replace('Kategorien:', '').strip()
|
||||
if ingredient_mode and ('quelle' in line.lower() or 'source' in line.lower()):
|
||||
ingredient_mode = False
|
||||
if ingredient_mode:
|
||||
if line != '' and '===' not in line and 'Zubereitung' not in line:
|
||||
ingredients.append(line.strip())
|
||||
if direction_mode:
|
||||
if line.strip() != '' and line.strip() != '=====':
|
||||
directions.append(line.strip())
|
||||
if 'Zutaten:' in line:
|
||||
ingredient_mode = True
|
||||
if 'Zubereitung:' in line:
|
||||
ingredient_mode = False
|
||||
direction_mode = True
|
||||
|
||||
recipe = Recipe.objects.create(name=title, created_by=self.request.user, internal=True, space=self.request.space)
|
||||
|
||||
for k in tags.split(','):
|
||||
keyword, created = Keyword.objects.get_or_create(name=k.strip(), space=self.request.space)
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction='\n'.join(directions) + '\n\n'
|
||||
)
|
||||
|
||||
for ingredient in ingredients:
|
||||
if len(ingredient.strip()) > 0:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
|
||||
def split_recipe_file(self, file):
|
||||
recipe_list = []
|
||||
current_recipe = ''
|
||||
|
||||
for fl in file.readlines():
|
||||
line = fl.decode("ANSI")
|
||||
if line.startswith('=====') and 'rezkonv' in line.lower():
|
||||
if current_recipe != '':
|
||||
recipe_list.append(current_recipe)
|
||||
current_recipe = ''
|
||||
else:
|
||||
current_recipe = ''
|
||||
else:
|
||||
current_recipe += line + '\n'
|
||||
|
||||
if current_recipe != '':
|
||||
recipe_list.append(current_recipe)
|
||||
|
||||
return recipe_list
|
||||
60
cookbook/integration/safron.py
Normal file
60
cookbook/integration/safron.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
|
||||
|
||||
class Safron(Integration):
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
ingredient_mode = False
|
||||
direction_mode = False
|
||||
|
||||
ingredients = []
|
||||
directions = []
|
||||
for fl in file.readlines():
|
||||
line = fl.decode("utf-8")
|
||||
if 'Title:' in line:
|
||||
title = line.replace('Title:', '').strip()
|
||||
if 'Description:' in line:
|
||||
description = line.replace('Description:', '').strip()
|
||||
if 'Yield:' in line:
|
||||
directions.append(_('Servings') + ' ' + line.replace('Yield:', '').strip() + '\n')
|
||||
if 'Cook:' in line:
|
||||
directions.append(_('Waiting time') + ' ' + line.replace('Cook:', '').strip() + '\n')
|
||||
if 'Prep:' in line:
|
||||
directions.append(_('Preparation Time') + ' ' + line.replace('Prep:', '').strip() + '\n')
|
||||
if 'Cookbook:' in line:
|
||||
directions.append(_('Cookbook') + ' ' + line.replace('Cookbook:', '').strip() + '\n')
|
||||
if 'Section:' in line:
|
||||
directions.append(_('Section') + ' ' + line.replace('Section:', '').strip() + '\n')
|
||||
if ingredient_mode:
|
||||
if len(line) > 2 and 'Instructions:' not in line:
|
||||
ingredients.append(line.strip())
|
||||
if direction_mode:
|
||||
if len(line) > 2:
|
||||
directions.append(line.strip())
|
||||
if 'Ingredients:' in line:
|
||||
ingredient_mode = True
|
||||
if 'Instructions:' in line:
|
||||
ingredient_mode = False
|
||||
direction_mode = True
|
||||
|
||||
recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space, )
|
||||
|
||||
step = Step.objects.create(instruction='\n'.join(directions))
|
||||
|
||||
for ingredient in ingredients:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
BIN
cookbook/locale/ca/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/ca/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1984
cookbook/locale/ca/LC_MESSAGES/django.po
Normal file
1984
cookbook/locale/ca/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/cs/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/cs/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1871
cookbook/locale/cs/LC_MESSAGES/django.po
Normal file
1871
cookbook/locale/cs/LC_MESSAGES/django.po
Normal file
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
BIN
cookbook/locale/es/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/es/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
2053
cookbook/locale/es/LC_MESSAGES/django.po
Normal file
2053
cookbook/locale/es/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/hu_HU/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/hu_HU/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1835
cookbook/locale/hu_HU/LC_MESSAGES/django.po
Normal file
1835
cookbook/locale/hu_HU/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/hy/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/hy/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1881
cookbook/locale/hy/LC_MESSAGES/django.po
Normal file
1881
cookbook/locale/hy/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/it/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/it/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
2006
cookbook/locale/it/LC_MESSAGES/django.po
Normal file
2006
cookbook/locale/it/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/lv/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/lv/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1986
cookbook/locale/lv/LC_MESSAGES/django.po
Normal file
1986
cookbook/locale/lv/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
1832
cookbook/locale/nb_NO/LC_MESSAGES/django.po
Normal file
1832
cookbook/locale/nb_NO/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/pl/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/pl/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1797
cookbook/locale/pl/LC_MESSAGES/django.po
Normal file
1797
cookbook/locale/pl/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/pt/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/pt/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1877
cookbook/locale/pt/LC_MESSAGES/django.po
Normal file
1877
cookbook/locale/pt/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/rn/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/rn/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1824
cookbook/locale/rn/LC_MESSAGES/django.po
Normal file
1824
cookbook/locale/rn/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
1853
cookbook/locale/sv/LC_MESSAGES/django.po
Normal file
1853
cookbook/locale/sv/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/tr/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/tr/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1835
cookbook/locale/tr/LC_MESSAGES/django.po
Normal file
1835
cookbook/locale/tr/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/vi/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/vi/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1787
cookbook/locale/vi/LC_MESSAGES/django.po
Normal file
1787
cookbook/locale/vi/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/zh_CN/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/zh_CN/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1824
cookbook/locale/zh_CN/LC_MESSAGES/django.po
Normal file
1824
cookbook/locale/zh_CN/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,22 @@
|
||||
# Generated by Django 3.0.2 on 2020-01-30 09:59
|
||||
|
||||
from django.db import migrations
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def migrate_ingredient_units(apps, schema_editor):
|
||||
Unit = apps.get_model('cookbook', 'Unit')
|
||||
RecipeIngredients = apps.get_model('cookbook', 'RecipeIngredients')
|
||||
with scopes_disabled():
|
||||
Unit = apps.get_model('cookbook', 'Unit')
|
||||
RecipeIngredients = apps.get_model('cookbook', 'RecipeIngredients')
|
||||
|
||||
for u in RecipeIngredients.objects.values('unit').distinct():
|
||||
unit = Unit()
|
||||
unit.name = u['unit']
|
||||
unit.save()
|
||||
for u in RecipeIngredients.objects.values('unit').distinct():
|
||||
unit = Unit()
|
||||
unit.name = u['unit']
|
||||
unit.save()
|
||||
|
||||
for i in RecipeIngredients.objects.all():
|
||||
i.unit_key = Unit.objects.get(name=i.unit)
|
||||
i.save()
|
||||
for i in RecipeIngredients.objects.all():
|
||||
i.unit_key = Unit.objects.get(name=i.unit)
|
||||
i.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
# Generated by Django 3.0.2 on 2020-02-16 22:09
|
||||
from django.db import migrations
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def migrate_ingredients(apps, schema_editor):
|
||||
Ingredient = apps.get_model('cookbook', 'Ingredient')
|
||||
RecipeIngredient = apps.get_model('cookbook', 'RecipeIngredient')
|
||||
with scopes_disabled():
|
||||
Ingredient = apps.get_model('cookbook', 'Ingredient')
|
||||
RecipeIngredient = apps.get_model('cookbook', 'RecipeIngredient')
|
||||
|
||||
for u in RecipeIngredient.objects.values('name').distinct():
|
||||
ingredient = Ingredient()
|
||||
ingredient.name = u['name']
|
||||
ingredient.save()
|
||||
for u in RecipeIngredient.objects.values('name').distinct():
|
||||
ingredient = Ingredient()
|
||||
ingredient.name = u['name']
|
||||
ingredient.save()
|
||||
|
||||
for i in RecipeIngredient.objects.all():
|
||||
i.ingredient = Ingredient.objects.get(name=i.name)
|
||||
i.save()
|
||||
for i in RecipeIngredient.objects.all():
|
||||
i.ingredient = Ingredient.objects.get(name=i.name)
|
||||
i.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
# Generated by Django 3.0.5 on 2020-04-26 14:14
|
||||
|
||||
from django.db import migrations
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def apply_migration(apps, schema_editor):
|
||||
Group = apps.get_model('auth', 'Group')
|
||||
Group.objects.bulk_create([
|
||||
Group(name=u'guest'),
|
||||
Group(name=u'user'),
|
||||
Group(name=u'admin'),
|
||||
])
|
||||
with scopes_disabled():
|
||||
Group = apps.get_model('auth', 'Group')
|
||||
Group.objects.bulk_create([
|
||||
Group(name=u'guest'),
|
||||
Group(name=u'user'),
|
||||
Group(name=u'admin'),
|
||||
])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
# Generated by Django 3.0.5 on 2020-04-27 16:00
|
||||
|
||||
from django.db import migrations
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def apply_migration(apps, schema_editor):
|
||||
Group = apps.get_model('auth', 'Group')
|
||||
User = apps.get_model('auth', 'User')
|
||||
for u in User.objects.all():
|
||||
if u.groups.count() < 1:
|
||||
u.groups.add(Group.objects.get(name='admin'))
|
||||
u.save()
|
||||
with scopes_disabled():
|
||||
Group = apps.get_model('auth', 'Group')
|
||||
User = apps.get_model('auth', 'User')
|
||||
for u in User.objects.all():
|
||||
if u.groups.count() < 1:
|
||||
u.groups.add(Group.objects.get(name='admin'))
|
||||
u.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -2,43 +2,45 @@
|
||||
|
||||
from django.db import migrations
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def migrate_meal_types(apps, schema_editor):
|
||||
MealPlan = apps.get_model('cookbook', 'MealPlan')
|
||||
MealType = apps.get_model('cookbook', 'MealType')
|
||||
with scopes_disabled():
|
||||
MealPlan = apps.get_model('cookbook', 'MealPlan')
|
||||
MealType = apps.get_model('cookbook', 'MealType')
|
||||
|
||||
breakfast = MealType.objects.create(
|
||||
name=_('Breakfast'),
|
||||
order=0,
|
||||
)
|
||||
breakfast = MealType.objects.create(
|
||||
name=_('Breakfast'),
|
||||
order=0,
|
||||
)
|
||||
|
||||
lunch = MealType.objects.create(
|
||||
name=_('Lunch'),
|
||||
order=0,
|
||||
)
|
||||
lunch = MealType.objects.create(
|
||||
name=_('Lunch'),
|
||||
order=0,
|
||||
)
|
||||
|
||||
dinner = MealType.objects.create(
|
||||
name=_('Dinner'),
|
||||
order=0,
|
||||
)
|
||||
dinner = MealType.objects.create(
|
||||
name=_('Dinner'),
|
||||
order=0,
|
||||
)
|
||||
|
||||
other = MealType.objects.create(
|
||||
name=_('Other'),
|
||||
order=0,
|
||||
)
|
||||
other = MealType.objects.create(
|
||||
name=_('Other'),
|
||||
order=0,
|
||||
)
|
||||
|
||||
for m in MealPlan.objects.all():
|
||||
if m.meal == 'BREAKFAST':
|
||||
m.meal_type = breakfast
|
||||
if m.meal == 'LUNCH':
|
||||
m.meal_type = lunch
|
||||
if m.meal == 'DINNER':
|
||||
m.meal_type = dinner
|
||||
if m.meal == 'OTHER':
|
||||
m.meal_type = other
|
||||
for m in MealPlan.objects.all():
|
||||
if m.meal == 'BREAKFAST':
|
||||
m.meal_type = breakfast
|
||||
if m.meal == 'LUNCH':
|
||||
m.meal_type = lunch
|
||||
if m.meal == 'DINNER':
|
||||
m.meal_type = dinner
|
||||
if m.meal == 'OTHER':
|
||||
m.meal_type = other
|
||||
|
||||
m.save()
|
||||
m.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -2,22 +2,24 @@
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.models import Q
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def migrate_meal_types(apps, schema_editor):
|
||||
MealPlan = apps.get_model('cookbook', 'MealPlan')
|
||||
MealType = apps.get_model('cookbook', 'MealType')
|
||||
User = apps.get_model('auth', 'User')
|
||||
with scopes_disabled():
|
||||
MealPlan = apps.get_model('cookbook', 'MealPlan')
|
||||
MealType = apps.get_model('cookbook', 'MealType')
|
||||
User = apps.get_model('auth', 'User')
|
||||
|
||||
for u in User.objects.all():
|
||||
for t in MealType.objects.filter(created_by=None).all():
|
||||
user_type = MealType.objects.create(
|
||||
name=t.name,
|
||||
created_by=u,
|
||||
)
|
||||
MealPlan.objects.filter(Q(created_by=u) and Q(meal_type=t)).update(meal_type=user_type)
|
||||
for u in User.objects.all():
|
||||
for t in MealType.objects.filter(created_by=None).all():
|
||||
user_type = MealType.objects.create(
|
||||
name=t.name,
|
||||
created_by=u,
|
||||
)
|
||||
MealPlan.objects.filter(Q(created_by=u) and Q(meal_type=t)).update(meal_type=user_type)
|
||||
|
||||
MealType.objects.filter(created_by=None).delete()
|
||||
MealType.objects.filter(created_by=None).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def invalidate_shares(apps, schema_editor):
|
||||
ShareLink = apps.get_model('cookbook', 'ShareLink')
|
||||
with scopes_disabled():
|
||||
ShareLink = apps.get_model('cookbook', 'ShareLink')
|
||||
|
||||
ShareLink.objects.all().delete()
|
||||
ShareLink.objects.all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
# Generated by Django 3.0.7 on 2020-06-25 19:37
|
||||
|
||||
from django.db import migrations
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def migrate_ingredients(apps, schema_editor):
|
||||
Recipe = apps.get_model('cookbook', 'Recipe')
|
||||
Ingredient = apps.get_model('cookbook', 'Ingredient')
|
||||
with scopes_disabled():
|
||||
Recipe = apps.get_model('cookbook', 'Recipe')
|
||||
Ingredient = apps.get_model('cookbook', 'Ingredient')
|
||||
|
||||
for r in Recipe.objects.all():
|
||||
for i in Ingredient.objects.filter(recipe=r).all():
|
||||
r.ingredients.add(i)
|
||||
r.save()
|
||||
for r in Recipe.objects.all():
|
||||
for i in Ingredient.objects.filter(recipe=r).all():
|
||||
r.ingredients.add(i)
|
||||
r.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
# Generated by Django 3.0.7 on 2020-06-25 20:19
|
||||
|
||||
from django.db import migrations, models
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def create_default_step(apps, schema_editor):
|
||||
Recipe = apps.get_model('cookbook', 'Recipe')
|
||||
Step = apps.get_model('cookbook', 'Step')
|
||||
with scopes_disabled():
|
||||
Recipe = apps.get_model('cookbook', 'Recipe')
|
||||
Step = apps.get_model('cookbook', 'Step')
|
||||
|
||||
for r in Recipe.objects.filter(internal=True).all():
|
||||
s = Step.objects.create(
|
||||
instruction=r.instructions
|
||||
)
|
||||
for i in r.ingredients.all():
|
||||
s.ingredients.add(i)
|
||||
s.save()
|
||||
r.steps.add(s)
|
||||
r.save()
|
||||
for r in Recipe.objects.filter(internal=True).all():
|
||||
s = Step.objects.create(
|
||||
instruction=r.instructions
|
||||
)
|
||||
for i in r.ingredients.all():
|
||||
s.ingredients.add(i)
|
||||
s.save()
|
||||
r.steps.add(s)
|
||||
r.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -2,27 +2,29 @@
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def convert_old_specials(apps, schema_editor):
|
||||
Ingredient = apps.get_model('cookbook', 'Ingredient')
|
||||
Food = apps.get_model('cookbook', 'Food')
|
||||
Unit = apps.get_model('cookbook', 'Unit')
|
||||
with scopes_disabled():
|
||||
Ingredient = apps.get_model('cookbook', 'Ingredient')
|
||||
Food = apps.get_model('cookbook', 'Food')
|
||||
Unit = apps.get_model('cookbook', 'Unit')
|
||||
|
||||
for i in Ingredient.objects.all():
|
||||
if i.amount == 0:
|
||||
i.no_amount = True
|
||||
if i.unit.name == 'Special:Header':
|
||||
i.header = True
|
||||
i.unit = None
|
||||
i.food = None
|
||||
i.save()
|
||||
for i in Ingredient.objects.all():
|
||||
if i.amount == 0:
|
||||
i.no_amount = True
|
||||
if i.unit.name == 'Special:Header':
|
||||
i.header = True
|
||||
i.unit = None
|
||||
i.food = None
|
||||
i.save()
|
||||
|
||||
try:
|
||||
Unit.objects.filter(name='Special:Header').delete()
|
||||
Food.objects.filter(name='Header').delete()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
Unit.objects.filter(name='Special:Header').delete()
|
||||
Food.objects.filter(name='Header').delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Generated by Django 3.0.7 on 2020-08-11 10:14
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0074_remove_keyword_created_by'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ShoppingListRecipe',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('multiplier', models.IntegerField(default=1)),
|
||||
('recipe', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.Recipe')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ShoppingListEntry',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('amount', models.IntegerField(default=1)),
|
||||
('order', models.IntegerField(default=0)),
|
||||
('checked', models.BooleanField(default=False)),
|
||||
('food', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.Food')),
|
||||
('list_recipe', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.ShoppingListRecipe')),
|
||||
('unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.Unit')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ShoppingList',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4)),
|
||||
('note', models.TextField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('recipes', models.ManyToManyField(blank=True, to='cookbook.ShoppingListRecipe')),
|
||||
('shared', models.ManyToManyField(blank=True, related_name='list_share', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0076_shoppinglist_entries.py
Normal file
18
cookbook/migrations/0076_shoppinglist_entries.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.7 on 2020-08-26 18:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0075_shoppinglist_shoppinglistentry_shoppinglistrecipe'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='shoppinglist',
|
||||
name='entries',
|
||||
field=models.ManyToManyField(blank=True, to='cookbook.ShoppingListEntry'),
|
||||
),
|
||||
]
|
||||
29
cookbook/migrations/0077_invitelink.py
Normal file
29
cookbook/migrations/0077_invitelink.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 3.0.7 on 2020-09-01 11:31
|
||||
|
||||
import datetime
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0076_shoppinglist_entries'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='InviteLink',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4)),
|
||||
('username', models.CharField(blank=True, max_length=64)),
|
||||
('valid_until', models.DateField(default=datetime.date(2020, 9, 15))),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
21
cookbook/migrations/0078_invitelink_used_by.py
Normal file
21
cookbook/migrations/0078_invitelink_used_by.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 3.0.7 on 2020-09-01 11:39
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0077_invitelink'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invitelink',
|
||||
name='used_by',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='used_by', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
21
cookbook/migrations/0079_invitelink_group.py
Normal file
21
cookbook/migrations/0079_invitelink_group.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 3.0.7 on 2020-09-01 12:54
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('auth', '0011_update_proxy_permissions'),
|
||||
('cookbook', '0078_invitelink_used_by'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invitelink',
|
||||
name='group',
|
||||
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='auth.Group'),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
24
cookbook/migrations/0080_auto_20200921_2331.py
Normal file
24
cookbook/migrations/0080_auto_20200921_2331.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.0.7 on 2020-09-21 21:31
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0079_invitelink_group'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='shopping_auto_sync',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invitelink',
|
||||
name='valid_until',
|
||||
field=models.DateField(default=datetime.date(2020, 10, 5)),
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user