mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-30 13:40:01 -05:00
Compare commits
1112 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a626bda1ab | ||
|
|
d104974ca8 | ||
|
|
b1a7212fce | ||
|
|
eaee474cb7 | ||
|
|
64f5b9ad1f | ||
|
|
21f1700d6d | ||
|
|
c23df3d474 | ||
|
|
0f06506f18 | ||
|
|
56223df80b | ||
|
|
fe581e538f | ||
|
|
88efe7ac8e | ||
|
|
be999c726b | ||
|
|
857d287233 | ||
|
|
02ceacd232 | ||
|
|
d42308281c | ||
|
|
1f09f778c7 | ||
|
|
2304ec0633 | ||
|
|
fe358eab16 | ||
|
|
e656a2da8c | ||
|
|
2f3beb4f13 | ||
|
|
641e65c7ab | ||
|
|
d04075732e | ||
|
|
678d0dff3a | ||
|
|
ca068a3ae0 | ||
|
|
c597aed956 | ||
|
|
7e6f3ad92b | ||
|
|
b2c5e3d5e7 | ||
|
|
9344bf80da | ||
|
|
b78597fa52 | ||
|
|
75b7397343 | ||
|
|
7b506ff903 | ||
|
|
23a6efbb05 | ||
|
|
701f631c5f | ||
|
|
a829cdd85d | ||
|
|
27b2743e82 | ||
|
|
095b70d446 | ||
|
|
da313916db | ||
|
|
f4ea70081c | ||
|
|
203e0795ad | ||
|
|
ecdeb6b66b | ||
|
|
003e4a8b37 | ||
|
|
107984de11 | ||
|
|
70ea97a551 | ||
|
|
1965a7213a | ||
|
|
9f95f9eb14 | ||
|
|
fd1bdef440 | ||
|
|
60ac24acb0 | ||
|
|
f147f51ba2 | ||
|
|
b36483410b | ||
|
|
98aa1297b1 | ||
|
|
fa273cd4fe | ||
|
|
5da8569b8e | ||
|
|
46430b81a0 | ||
|
|
7e3b74b926 | ||
|
|
7cf629d8ee | ||
|
|
20653618bb | ||
|
|
7ffe0efc07 | ||
|
|
5447b2bce4 | ||
|
|
0cfa61692c | ||
|
|
006a83132b | ||
|
|
ba9f816513 | ||
|
|
498cbe0191 | ||
|
|
49d3d6cbc2 | ||
|
|
0d56d8a836 | ||
|
|
6548f7f4d8 | ||
|
|
e639ff9d77 | ||
|
|
23c58868de | ||
|
|
3936e4c66f | ||
|
|
ea0675b35e | ||
|
|
f855ba1c0f | ||
|
|
59f645d8c9 | ||
|
|
061f874f45 | ||
|
|
5ccc8d12f0 | ||
|
|
42e841d1e6 | ||
|
|
ba6cac803c | ||
|
|
0b51d87a06 | ||
|
|
77d2e29fe4 | ||
|
|
54c5655b85 | ||
|
|
a02e9e806c | ||
|
|
66a904568b | ||
|
|
7f6f579757 | ||
|
|
6c993aabad | ||
|
|
1a31847223 | ||
|
|
9996d521f5 | ||
|
|
7ebbad3827 | ||
|
|
1e1399cfe9 | ||
|
|
5b32160051 | ||
|
|
d4ba2b9dd2 | ||
|
|
961201412e | ||
|
|
67b294c141 | ||
|
|
d71557dcec | ||
|
|
c5d39b1c99 | ||
|
|
c9e0f40e88 | ||
|
|
dc5de6f0a2 | ||
|
|
4a7eb91e67 | ||
|
|
c15bd663cb | ||
|
|
95fdf893f4 | ||
|
|
63f9d5c181 | ||
|
|
86b80a78d6 | ||
|
|
59ecc40dc6 | ||
|
|
ae70064c06 | ||
|
|
6de68707ed | ||
|
|
6224e38138 | ||
|
|
0e8cac7ab9 | ||
|
|
64ab768add | ||
|
|
f6607aa2e3 | ||
|
|
dccdb7cc2f | ||
|
|
b1f418622f | ||
|
|
23a7dc0ebf | ||
|
|
70e754eb37 | ||
|
|
ae9a78f2e1 | ||
|
|
a4849adb4c | ||
|
|
da9a2b4dc2 | ||
|
|
8f65ecfc18 | ||
|
|
c8c8792ea8 | ||
|
|
4e43a7a325 | ||
|
|
8f3effe194 | ||
|
|
5e508944a3 | ||
|
|
6ce95fb393 | ||
|
|
3e641e4d28 | ||
|
|
de80702e3f | ||
|
|
565a732ff0 | ||
|
|
e6fce0b4a7 | ||
|
|
04e0a6df4a | ||
|
|
b8cadf1faa | ||
|
|
2b15a5e6be | ||
|
|
21094eecc6 | ||
|
|
fece7e9bd6 | ||
|
|
b109e28b0c | ||
|
|
e766df947e | ||
|
|
0725fb0f2b | ||
|
|
33ac00e294 | ||
|
|
6b6556d532 | ||
|
|
47c508831c | ||
|
|
3a349b1bd2 | ||
|
|
f085e7ff2f | ||
|
|
ac68fd30ae | ||
|
|
b0439cc13b | ||
|
|
7c1de82c8a | ||
|
|
7bc567ab95 | ||
|
|
596ec9134e | ||
|
|
bfb8c31329 | ||
|
|
55b8b78a16 | ||
|
|
9e63c5321e | ||
|
|
28479e96e9 | ||
|
|
717d4d2346 | ||
|
|
f57d2ca832 | ||
|
|
03ccc8e044 | ||
|
|
d62ba2f5e8 | ||
|
|
d75c4ffaed | ||
|
|
4869f9596a | ||
|
|
0989a58af5 | ||
|
|
278258946f | ||
|
|
2f0b32f3c2 | ||
|
|
d78d23b096 | ||
|
|
b6a99e2494 | ||
|
|
896ab1636f | ||
|
|
9cf13e898c | ||
|
|
705fa18dd6 | ||
|
|
b3a283c1a4 | ||
|
|
e409fc03e9 | ||
|
|
0a154ec847 | ||
|
|
53e27d62e6 | ||
|
|
48447f1c8a | ||
|
|
ce32c20f67 | ||
|
|
3fd3c8ec12 | ||
|
|
f50bf39e60 | ||
|
|
beb860acc6 | ||
|
|
778f40eac4 | ||
|
|
1883da5e49 | ||
|
|
dd0ae9b1b3 | ||
|
|
db484f8adb | ||
|
|
c94f9291ce | ||
|
|
dfbab2c57a | ||
|
|
631af65cf3 | ||
|
|
9b84b82b58 | ||
|
|
144f72fc79 | ||
|
|
a9e9f8345a | ||
|
|
b7c95a1d11 | ||
|
|
047aace9c2 | ||
|
|
b64319a232 | ||
|
|
b99443cb30 | ||
|
|
9506f2889a | ||
|
|
f34dc4d242 | ||
|
|
5bbcdd2551 | ||
|
|
43443305e6 | ||
|
|
038d03783f | ||
|
|
80c594d486 | ||
|
|
b5a204265a | ||
|
|
8e72108290 | ||
|
|
d1042835a5 | ||
|
|
57d7bda803 | ||
|
|
a088697812 | ||
|
|
cf190734b2 | ||
|
|
a9a3dd6e51 | ||
|
|
00ff13ae08 | ||
|
|
d7d34b2326 | ||
|
|
250d58f8a1 | ||
|
|
0499745772 | ||
|
|
1b2c4a3062 | ||
|
|
9232465e21 | ||
|
|
30e853088c | ||
|
|
c45a88d048 | ||
|
|
8f272eba3b | ||
|
|
ad5562d850 | ||
|
|
ca35052ac0 | ||
|
|
3f19d94d2f | ||
|
|
b5c3ef72ef | ||
|
|
cca7b7f558 | ||
|
|
3f962345f7 | ||
|
|
d92211485d | ||
|
|
0827cb387f | ||
|
|
b2a4b084d7 | ||
|
|
8e4e785179 | ||
|
|
ef0f181268 | ||
|
|
f3e42f13b1 | ||
|
|
fb8e04fee5 | ||
|
|
03c775d1cc | ||
|
|
6aa2aa42c7 | ||
|
|
20e1435abf | ||
|
|
0ee5164aac | ||
|
|
1819ff2bbd | ||
|
|
14c2be9277 | ||
|
|
6377d55ea5 | ||
|
|
768e9f8801 | ||
|
|
e8db2b6401 | ||
|
|
13f532a67b | ||
|
|
61ccc6061c | ||
|
|
dc7db75963 | ||
|
|
98b06b6f3c | ||
|
|
dffb2d4eae | ||
|
|
08a2b4d0b2 | ||
|
|
5211fbe6da | ||
|
|
a2cb1ccf3a | ||
|
|
29438109a6 | ||
|
|
4bb4c3e9a4 | ||
|
|
46dc0f1f03 | ||
|
|
1dc9244ac2 | ||
|
|
962d617839 | ||
|
|
65a7c82af9 | ||
|
|
2bfc8b0717 | ||
|
|
16e8c1e8e3 | ||
|
|
2a6c13fc5c | ||
|
|
408c2271a6 | ||
|
|
0e945f4bd7 | ||
|
|
0279013f72 | ||
|
|
074244ee12 | ||
|
|
247907ef25 | ||
|
|
c88dda90d4 | ||
|
|
59e949067c | ||
|
|
3e97fa9633 | ||
|
|
513082255b | ||
|
|
1a5881bb4b | ||
|
|
b6cbb28a87 | ||
|
|
36c0fbffbe | ||
|
|
febf3a3d86 | ||
|
|
3f859e5227 | ||
|
|
7e39e6fea5 | ||
|
|
2fd72fe985 | ||
|
|
ce0ee8caaa | ||
|
|
75c0ca8a9e | ||
|
|
4eafbddfdb | ||
|
|
010fb0112f | ||
|
|
2a15d19551 | ||
|
|
ece7ca7e82 | ||
|
|
bb1b1a40b6 | ||
|
|
f7b25c9b31 | ||
|
|
7f6cd16a77 | ||
|
|
e5303967df | ||
|
|
199caff2a2 | ||
|
|
c3eb12160a | ||
|
|
f017c6ae68 | ||
|
|
b2f270a829 | ||
|
|
f87fe43796 | ||
|
|
b46809155a | ||
|
|
30d69432ff | ||
|
|
6ccd74edab | ||
|
|
8b5b063da6 | ||
|
|
a8983a4b8a | ||
|
|
1ad5f4843f | ||
|
|
971b58ccb0 | ||
|
|
1d236998d2 | ||
|
|
77f81523d0 | ||
|
|
aac729a3a0 | ||
|
|
ee7cdacc40 | ||
|
|
0f1a3ba5d8 | ||
|
|
4c6410d7ae | ||
|
|
502a606534 | ||
|
|
fff2ecd58d | ||
|
|
f54a6480f7 | ||
|
|
1f5b5df6a5 | ||
|
|
eaff34a3de | ||
|
|
4724104f50 | ||
|
|
e2ebccda85 | ||
|
|
79b57eab13 | ||
|
|
d4fbc266a1 | ||
|
|
558a2d2a30 | ||
|
|
55877d69a0 | ||
|
|
3c93e760d2 | ||
|
|
3fbfcb9939 | ||
|
|
44f6a581c7 | ||
|
|
5853ca9243 | ||
|
|
09c1446c06 | ||
|
|
f0853ee11c | ||
|
|
6e6af47d8c | ||
|
|
3e51bdc7f0 | ||
|
|
5001d05df2 | ||
|
|
58989a96e3 | ||
|
|
25c8f18b79 | ||
|
|
255ecd4a88 | ||
|
|
6b4b2a8f87 | ||
|
|
67f6e04680 | ||
|
|
fb8ca52280 | ||
|
|
126e8a5a53 | ||
|
|
d10c70b797 | ||
|
|
ba169ba38d | ||
|
|
578bb2af25 | ||
|
|
8e4c8821dc | ||
|
|
7673e794bf | ||
|
|
64e54ceaec | ||
|
|
f431e18336 | ||
|
|
5842022d0a | ||
|
|
c118dca2c0 | ||
|
|
b0ca2ff228 | ||
|
|
43aac60e9c | ||
|
|
cca56cd6db | ||
|
|
dd9e93498d | ||
|
|
1eecb098a6 | ||
|
|
85eefb4852 | ||
|
|
5243eaf63a | ||
|
|
a1da77fe31 | ||
|
|
9450d75ca4 | ||
|
|
8b11b31f8c | ||
|
|
8490cdaf78 | ||
|
|
30019ec946 | ||
|
|
48d75a6c7e | ||
|
|
6e86680eca | ||
|
|
a557e4628e | ||
|
|
22dec90921 | ||
|
|
da3dea60c0 | ||
|
|
9d1abe3419 | ||
|
|
570aefc9fa | ||
|
|
321dbc10d3 | ||
|
|
d94de6abd1 | ||
|
|
2b9c294c2a | ||
|
|
7d74979859 | ||
|
|
7de9758ee1 | ||
|
|
20027cf127 | ||
|
|
71ea67dc30 | ||
|
|
a661eaf221 | ||
|
|
ff77aa7268 | ||
|
|
e321c80dd6 | ||
|
|
12db67bd96 | ||
|
|
57100baf7c | ||
|
|
05cf7cc081 | ||
|
|
c8a070f473 | ||
|
|
eae409da67 | ||
|
|
62e1d860a9 | ||
|
|
5129ef77c9 | ||
|
|
deba961085 | ||
|
|
92cfd09155 | ||
|
|
14b7a8fd85 | ||
|
|
ab4bac0d21 | ||
|
|
838edc5b97 | ||
|
|
8910784cfd | ||
|
|
e951490825 | ||
|
|
0d9e2a8f5b | ||
|
|
e3445c4c71 | ||
|
|
1aa68f14e1 | ||
|
|
ccdd678582 | ||
|
|
a3a2812e3f | ||
|
|
8ef343c41d | ||
|
|
e38b7841f1 | ||
|
|
90eba291a5 | ||
|
|
1a262ef56f | ||
|
|
06ce3606cf | ||
|
|
0584fc3305 | ||
|
|
b430476a82 | ||
|
|
72d4404665 | ||
|
|
0c94cf1c2e | ||
|
|
1673254934 | ||
|
|
0493ef7e3a | ||
|
|
1fd6a47e9c | ||
|
|
5f9d59317b | ||
|
|
409c0295ec | ||
|
|
764cd7dba0 | ||
|
|
d6d806791d | ||
|
|
dc81ca19b9 | ||
|
|
2697e42af7 | ||
|
|
2b1eda12d1 | ||
|
|
36fd097ec5 | ||
|
|
40da2cee19 | ||
|
|
31de43196a | ||
|
|
bb52f8902d | ||
|
|
8ec8d98be6 | ||
|
|
245787b89e | ||
|
|
4a7bd6a885 | ||
|
|
35eff630ff | ||
|
|
8d90fada1d | ||
|
|
d1865b57f1 | ||
|
|
1692230f01 | ||
|
|
5a0ca3f4e5 | ||
|
|
37c7a62853 | ||
|
|
2ba2b97f9c | ||
|
|
fb65100b14 | ||
|
|
17163b0dba | ||
|
|
362c0340fc | ||
|
|
3d6d560c5d | ||
|
|
fd821c30c7 | ||
|
|
995d423a6f | ||
|
|
65dd82e292 | ||
|
|
87ede4b9cc | ||
|
|
c7dd61e239 | ||
|
|
48ac70de95 | ||
|
|
8302521427 | ||
|
|
26408c33f4 | ||
|
|
e045849e89 | ||
|
|
50eb232fff | ||
|
|
1a37961ceb | ||
|
|
72b0bd7f1e | ||
|
|
022439e017 | ||
|
|
9c804863a8 | ||
|
|
9cf3bdd5f2 | ||
|
|
445e64c71e | ||
|
|
d576394c99 | ||
|
|
a61f79507b | ||
|
|
f1b41461db | ||
|
|
6a393acd26 | ||
|
|
bf0462cd74 | ||
|
|
e5f0c19cdc | ||
|
|
45f0413fb9 | ||
|
|
38c464ebae | ||
|
|
b4f158b913 | ||
|
|
da49b6bda0 | ||
|
|
66a07ab39d | ||
|
|
6d4f094455 | ||
|
|
e7d9d7b7b3 | ||
|
|
5f7a57a258 | ||
|
|
4b1a80a0ed | ||
|
|
69b24db442 | ||
|
|
a1ff54bf3f | ||
|
|
748935d0b8 | ||
|
|
8efc3de11f | ||
|
|
1f3cd11964 | ||
|
|
94cfc36ed5 | ||
|
|
d493ba72a1 | ||
|
|
a5135de50b | ||
|
|
71e5484f0c | ||
|
|
761e423bde | ||
|
|
c8e674da16 | ||
|
|
6f3d4491ed | ||
|
|
54e2615c86 | ||
|
|
77942a7144 | ||
|
|
5a5ce4d736 | ||
|
|
0d966b5e59 | ||
|
|
1dda4126c1 | ||
|
|
5ffe821407 | ||
|
|
f9bfb8e258 | ||
|
|
8e0bc3acc7 | ||
|
|
c6fa635af2 | ||
|
|
50e1eaf645 | ||
|
|
953dc75a8d | ||
|
|
ac5333d0e7 | ||
|
|
ecf985f5e3 | ||
|
|
44ac3cf51e | ||
|
|
3cab0ab52e | ||
|
|
d131278aa5 | ||
|
|
d0cbe350a7 | ||
|
|
b6d4c4c3b8 | ||
|
|
30f3a697f0 | ||
|
|
964afd5f73 | ||
|
|
1fa2186dd0 | ||
|
|
146e97c8ec | ||
|
|
42ced25e10 | ||
|
|
6011cf359f | ||
|
|
f57acc412b | ||
|
|
200cacb9ac | ||
|
|
5c89173373 | ||
|
|
61b67cd37a | ||
|
|
12c2f2f7aa | ||
|
|
3d8b1d6ccb | ||
|
|
aa0d6b5a6b | ||
|
|
64ed75156c | ||
|
|
c6d41e8810 | ||
|
|
2a10843101 | ||
|
|
f861b39d05 | ||
|
|
5c18c09944 | ||
|
|
1bd5f96029 | ||
|
|
988df4eb00 | ||
|
|
bf61b6474e | ||
|
|
c76f5d9482 | ||
|
|
bace2f7ba4 | ||
|
|
0576337e9c | ||
|
|
be177cf258 | ||
|
|
5059abc232 | ||
|
|
475ce44df9 | ||
|
|
492d266fbe | ||
|
|
c695f0dacb | ||
|
|
cb63bb2615 | ||
|
|
7ca5a34b28 | ||
|
|
57d87c899c | ||
|
|
e37f8b3a51 | ||
|
|
df03818f45 | ||
|
|
063c64d078 | ||
|
|
2c3e0b547b | ||
|
|
999e3794f5 | ||
|
|
5e5caf201c | ||
|
|
0ce4d45eeb | ||
|
|
0dacdcc72f | ||
|
|
629dfd5d52 | ||
|
|
20bc1c5c2a | ||
|
|
d3376b33d8 | ||
|
|
a7ea7a8987 | ||
|
|
80c0c71b13 | ||
|
|
42839a5886 | ||
|
|
b0c561661b | ||
|
|
ae3818611d | ||
|
|
d1c4e51842 | ||
|
|
e6f7f07220 | ||
|
|
245e8311ba | ||
|
|
3b916cc6a4 | ||
|
|
a70ebd5130 | ||
|
|
ddf9ef11a0 | ||
|
|
f65597c391 | ||
|
|
8d7b4f614c | ||
|
|
df67d3ce7b | ||
|
|
54119ed1ec | ||
|
|
c1d77a8fe3 | ||
|
|
26f694576a | ||
|
|
7a5b744ff0 | ||
|
|
4058c997de | ||
|
|
089677d799 | ||
|
|
4de9be5c89 | ||
|
|
34ee03b720 | ||
|
|
48dacf46c3 | ||
|
|
181c270b34 | ||
|
|
e89c3887ec | ||
|
|
99cd9bfb5b | ||
|
|
8bbccad7a9 | ||
|
|
a59a78f44c | ||
|
|
205bf5253d | ||
|
|
45c14f6a12 | ||
|
|
0fed6b9fb3 | ||
|
|
dd3e91e10d | ||
|
|
76b84898f6 | ||
|
|
05d971835f | ||
|
|
0a814fa896 | ||
|
|
05ba11a48e | ||
|
|
6a7a22626e | ||
|
|
1635a3a335 | ||
|
|
1d84e7851b | ||
|
|
44d1cc3a30 | ||
|
|
04b4f552f8 | ||
|
|
6214176fe5 | ||
|
|
205dc11125 | ||
|
|
e423fc1df4 | ||
|
|
3e0b0a87e9 | ||
|
|
ba5112e138 | ||
|
|
0e34cc72d5 | ||
|
|
31c6defc93 | ||
|
|
d4c544bb4b | ||
|
|
2b05efeff6 | ||
|
|
d7ddcd3214 | ||
|
|
29133f4236 | ||
|
|
b440b09be5 | ||
|
|
ed1f656167 | ||
|
|
4f3e6d3765 | ||
|
|
46a50d7835 | ||
|
|
65513a8f60 | ||
|
|
044ed1ec18 | ||
|
|
8f53b399c6 | ||
|
|
702c1d67d3 | ||
|
|
3adb4c5233 | ||
|
|
c654cc469a | ||
|
|
8df846c9c2 | ||
|
|
7070f6c964 | ||
|
|
b454960676 | ||
|
|
abf8f79136 | ||
|
|
fd028047d6 | ||
|
|
bd0e1bcefe | ||
|
|
a2aa0dc3b9 | ||
|
|
1758aebb73 | ||
|
|
ecffe30062 | ||
|
|
21653465e0 | ||
|
|
f3e11e6358 | ||
|
|
0c381ed46c | ||
|
|
fd978f9c19 | ||
|
|
b069a49954 | ||
|
|
11c8422fbb | ||
|
|
2cb010c8b4 | ||
|
|
8f96c7f0a3 | ||
|
|
3054297357 | ||
|
|
3e083e2168 | ||
|
|
d1174ea50d | ||
|
|
fe11b88fd0 | ||
|
|
a3a2433d2a | ||
|
|
92be2db9fd | ||
|
|
be2f759048 | ||
|
|
52e88ddfd3 | ||
|
|
fe208e9844 | ||
|
|
15e7f32001 | ||
|
|
8c87e0aced | ||
|
|
3140480f36 | ||
|
|
52cd588b45 | ||
|
|
517e465d2f | ||
|
|
745c045f06 | ||
|
|
4b5abec458 | ||
|
|
f6ed49b5c4 | ||
|
|
0a0e3a48c3 | ||
|
|
cce2407bc0 | ||
|
|
9b18cab145 | ||
|
|
8b9a09b268 | ||
|
|
db1709cef7 | ||
|
|
4844e5cbc8 | ||
|
|
e90781983f | ||
|
|
86496069b3 | ||
|
|
e1aee23c54 | ||
|
|
1000badd2f | ||
|
|
9ae0b50558 | ||
|
|
f69813f729 | ||
|
|
fcb2c07acd | ||
|
|
a076d20cba | ||
|
|
bae777bc69 | ||
|
|
49781bfa7f | ||
|
|
72d3ace0f9 | ||
|
|
7f44a6f187 | ||
|
|
92b8799d26 | ||
|
|
7f1eecddc4 | ||
|
|
4723a7ecbd | ||
|
|
6af28e6fe5 | ||
|
|
ad1e64fb9a | ||
|
|
add600f3ca | ||
|
|
ad036d7e6c | ||
|
|
d7017902ab | ||
|
|
35743e8be9 | ||
|
|
75523c06f6 | ||
|
|
7e2aee53db | ||
|
|
9c74730461 | ||
|
|
977d2822bc | ||
|
|
31f93285d8 | ||
|
|
da9002a7fd | ||
|
|
899a9955fb | ||
|
|
1bf7af7027 | ||
|
|
1145a8cf26 | ||
|
|
5e918297f8 | ||
|
|
69adad70c8 | ||
|
|
d3905f1e80 | ||
|
|
1a1ff52725 | ||
|
|
731958fdaa | ||
|
|
8a19c8eeb0 | ||
|
|
4e7368f7b6 | ||
|
|
b00f1009a6 | ||
|
|
2d3ecaaf3c | ||
|
|
339049c785 | ||
|
|
d0481ed18c | ||
|
|
bcee66c7a4 | ||
|
|
7e993ca50e | ||
|
|
638dc845c0 | ||
|
|
4aea0fea8c | ||
|
|
2ba94df9a8 | ||
|
|
5723d87768 | ||
|
|
24b1f4028f | ||
|
|
984c863ff6 | ||
|
|
0f207c2fa7 | ||
|
|
58e70c982e | ||
|
|
4cb94a1759 | ||
|
|
3e568f7bb5 | ||
|
|
b69c6bc97a | ||
|
|
9132ab8f33 | ||
|
|
45858d5107 | ||
|
|
1e332977c5 | ||
|
|
7b70ffab5f | ||
|
|
9b367e5d08 | ||
|
|
3c08e3a3f1 | ||
|
|
243cac0389 | ||
|
|
8205812c84 | ||
|
|
94279b74c9 | ||
|
|
5a6a1787cf | ||
|
|
8a588db429 | ||
|
|
5150807ab7 | ||
|
|
8d4cb4f08d | ||
|
|
225ddc8eeb | ||
|
|
537276c62f | ||
|
|
f168fb825f | ||
|
|
a6965fb3c4 | ||
|
|
90354305c4 | ||
|
|
220d98a85c | ||
|
|
7fb4155ebe | ||
|
|
a39e6e8a6a | ||
|
|
97fc15ded3 | ||
|
|
8bee2e3976 | ||
|
|
e89c1742fb | ||
|
|
6680fbb644 | ||
|
|
4cec643e08 | ||
|
|
03bd51893e | ||
|
|
66b0e381ec | ||
|
|
fd70adf19d | ||
|
|
eb8422cb51 | ||
|
|
1008d880c9 | ||
|
|
9cb1c21cd8 | ||
|
|
5149cb0609 | ||
|
|
08adf4eb6f | ||
|
|
62f38d00f3 | ||
|
|
43a55c8c82 | ||
|
|
d09eb64a41 | ||
|
|
8bbbc1b9ef | ||
|
|
cc367bfed2 | ||
|
|
b18aa831ac | ||
|
|
6205fbe1c4 | ||
|
|
879a54524c | ||
|
|
36678692be | ||
|
|
de6285e5f8 | ||
|
|
7ff7409f56 | ||
|
|
50b3636c86 | ||
|
|
1f72a3f62f | ||
|
|
aea796bd6d | ||
|
|
edcddc3183 | ||
|
|
45a24a4720 | ||
|
|
bed95105f3 | ||
|
|
a39fdb4226 | ||
|
|
9d629b03b3 | ||
|
|
4eeb87cb95 | ||
|
|
3ce7e43b46 | ||
|
|
a3a995ef77 | ||
|
|
a386b45a03 | ||
|
|
74bd2ba2c0 | ||
|
|
695f467126 | ||
|
|
6270b46951 | ||
|
|
a3ad131e6a | ||
|
|
e2d5287cc6 | ||
|
|
1c39d8089c | ||
|
|
2230b9e9ab | ||
|
|
339d7b1c96 | ||
|
|
4e8c955555 | ||
|
|
221c466c18 | ||
|
|
2c8e029811 | ||
|
|
019825bfcb | ||
|
|
8e5ea47d5e | ||
|
|
425ac7f379 | ||
|
|
f0caef4759 | ||
|
|
3cabe85091 | ||
|
|
c56a76f264 | ||
|
|
429886e6a6 | ||
|
|
339ab57df7 | ||
|
|
cb6d98a357 | ||
|
|
e746b44f3b | ||
|
|
bc63ba6713 | ||
|
|
ea8661ab03 | ||
|
|
bc9a5c9435 | ||
|
|
365ffa29fa | ||
|
|
b3aeee6a63 | ||
|
|
d503dc77c3 | ||
|
|
fee364ee4a | ||
|
|
680a8d0fce | ||
|
|
0944d72e32 | ||
|
|
6809ded468 | ||
|
|
64d07a65dc | ||
|
|
745abb57a8 | ||
|
|
dfe5083451 | ||
|
|
4e05bc2f6a | ||
|
|
8f8147fda4 | ||
|
|
9377e208e8 | ||
|
|
40f38e6c6d | ||
|
|
3ee0717d84 | ||
|
|
47155ce338 | ||
|
|
611080b739 | ||
|
|
416d1badda | ||
|
|
0ef5d3ad92 | ||
|
|
efb8784b91 | ||
|
|
a1a6f476e0 | ||
|
|
ccd0667f04 | ||
|
|
38cf825816 | ||
|
|
a8dc8e7190 | ||
|
|
76aca6cf38 | ||
|
|
89c31a018f | ||
|
|
e54f55b6d0 | ||
|
|
fff7cb607c | ||
|
|
54c2478869 | ||
|
|
a7795092b3 | ||
|
|
538fb8b42e | ||
|
|
1f0cd58d7d | ||
|
|
78b1386a1c | ||
|
|
aba7f8db5c | ||
|
|
d7fadffbfd | ||
|
|
22c7f5d85d | ||
|
|
c18d8daece | ||
|
|
d91c4b33f3 | ||
|
|
2ad6f21b9c | ||
|
|
554170a84e | ||
|
|
d43a6e551d | ||
|
|
02cb6d1be7 | ||
|
|
45b1eca48b | ||
|
|
6dacd44f1f | ||
|
|
1b97472368 | ||
|
|
d467352029 | ||
|
|
a0256b607e | ||
|
|
847fceaf10 | ||
|
|
9e831a22df | ||
|
|
768a5ea237 | ||
|
|
36403ecbae | ||
|
|
4620ebaf30 | ||
|
|
c907da84c1 | ||
|
|
9b5e39415e | ||
|
|
2679a22464 | ||
|
|
8bae21025b | ||
|
|
4120adc546 | ||
|
|
30c891abfc | ||
|
|
b8317c2c29 | ||
|
|
39253cfd02 | ||
|
|
7c0b8b151c | ||
|
|
8e1b8923af | ||
|
|
52eb876a08 | ||
|
|
a820b9c09e | ||
|
|
bcfe6ca707 | ||
|
|
4aa3e04df0 | ||
|
|
f8f08ae337 | ||
|
|
c8fc6b5237 | ||
|
|
52909e8117 | ||
|
|
c72bf57ccb | ||
|
|
d3c21cf97f | ||
|
|
942edd9336 | ||
|
|
8fa6c98254 | ||
|
|
73c6bfce44 | ||
|
|
c105909933 | ||
|
|
13baf4f30a | ||
|
|
da5fd16338 | ||
|
|
83a52bd204 | ||
|
|
fe4bd6a127 | ||
|
|
d193d91e6a | ||
|
|
a2f9ef2e74 | ||
|
|
3f63eab68c | ||
|
|
9b6ed7a63a | ||
|
|
e2f8efb521 | ||
|
|
ce29283a52 | ||
|
|
dcf9d59b06 | ||
|
|
794f9cf5b9 | ||
|
|
9954bb9410 | ||
|
|
e57be4a704 | ||
|
|
ffaecc066f | ||
|
|
94f398a7f6 | ||
|
|
65d670a995 | ||
|
|
15ed040533 | ||
|
|
5d3f44ffee | ||
|
|
9ee4be621b | ||
|
|
d33b0d2254 | ||
|
|
1a20c4bef5 | ||
|
|
b350ab1b59 | ||
|
|
687e8a1f6a | ||
|
|
64b9605871 | ||
|
|
8320473606 | ||
|
|
88228ab853 | ||
|
|
dcfb269909 | ||
|
|
4a1ec5adf7 | ||
|
|
56cdc14cc1 | ||
|
|
b8959036bf | ||
|
|
ab24177c89 | ||
|
|
4ffc9cc72f | ||
|
|
7f62ec28e3 | ||
|
|
d42d784aeb | ||
|
|
ce84b3b385 | ||
|
|
74fbcb03a1 | ||
|
|
b1aa70787c | ||
|
|
8675143cc1 | ||
|
|
75e23106fc | ||
|
|
2ad89b5b22 | ||
|
|
36074c9c35 | ||
|
|
05560c5730 | ||
|
|
6ba4db6ff9 | ||
|
|
6353885f9c | ||
|
|
833ebf8c0c | ||
|
|
0662255b27 | ||
|
|
fde4ea8c4c | ||
|
|
132815496c | ||
|
|
a7a6abe3d2 | ||
|
|
2f617aa40f | ||
|
|
9b50ea4c22 | ||
|
|
cde8dd8b53 | ||
|
|
8411537f87 | ||
|
|
479cf1a042 | ||
|
|
8fa00972bd | ||
|
|
5d5eb45b5a | ||
|
|
87beed48c9 | ||
|
|
cf7cc6c637 | ||
|
|
3d45a068e4 | ||
|
|
01ce658883 | ||
|
|
92d648c3a3 | ||
|
|
17fa3c8d7c | ||
|
|
c1ae4e3905 | ||
|
|
d819cbc20e | ||
|
|
f255397bbd | ||
|
|
2f0929e90e | ||
|
|
6785033a21 | ||
|
|
0345b7720c | ||
|
|
7163c33b2a | ||
|
|
934df3c5f7 | ||
|
|
2888b18819 | ||
|
|
c01081255b | ||
|
|
2e606dc166 | ||
|
|
835c5a1d3a | ||
|
|
8580aea43f | ||
|
|
db4f2db236 | ||
|
|
7e9cef6075 | ||
|
|
75612781da | ||
|
|
f5fb4e563d | ||
|
|
1ecb57e795 | ||
|
|
c4a0df26fc | ||
|
|
8ff5142149 | ||
|
|
716976453a | ||
|
|
f07dec6062 | ||
|
|
ffc96890ac | ||
|
|
a8fd703d1d | ||
|
|
4592cc85a5 | ||
|
|
4a835c38d8 | ||
|
|
ef72a07acb | ||
|
|
246b9c4a02 | ||
|
|
c18a77bc9b | ||
|
|
3d7e2b1aa5 | ||
|
|
28f18fbc42 | ||
|
|
ba361a8a27 | ||
|
|
fc2ce6e488 | ||
|
|
d7f77a572a | ||
|
|
64e28fd01a | ||
|
|
714d5e5184 | ||
|
|
640500c82d | ||
|
|
8bf661c1ab | ||
|
|
1d29e435d5 | ||
|
|
6eac48633b | ||
|
|
743fae1ba7 | ||
|
|
b3565451ff | ||
|
|
4a93681870 | ||
|
|
d83b0484d8 | ||
|
|
c0d67dbc58 | ||
|
|
3a8ea4b4c9 | ||
|
|
4b14a099df | ||
|
|
dae7cbfb85 | ||
|
|
0c62b80e3a | ||
|
|
678963e6dd | ||
|
|
6d84c718fd | ||
|
|
b8e1ed8967 | ||
|
|
d87633433a | ||
|
|
fe33adbba0 | ||
|
|
baa84cf481 | ||
|
|
ecd828008e | ||
|
|
2b8c607b78 | ||
|
|
df684f591a | ||
|
|
cb5b51bde3 | ||
|
|
7f27419215 | ||
|
|
312cd077d0 | ||
|
|
eac059ca85 | ||
|
|
782dd4cb17 | ||
|
|
f7b60f2c52 | ||
|
|
ca28e52698 | ||
|
|
0c2c12d536 | ||
|
|
113c40c243 | ||
|
|
0688f46d8b | ||
|
|
2fdcdba889 | ||
|
|
6a39148e5f | ||
|
|
22dfb40fd5 | ||
|
|
2b5a86ce53 | ||
|
|
e77016ea9b | ||
|
|
9988a61da7 | ||
|
|
f34fb8eec3 | ||
|
|
7853357065 | ||
|
|
6f1befc43c | ||
|
|
530d6b0cb6 | ||
|
|
c18386b9b5 | ||
|
|
d5ba2e6716 | ||
|
|
b30f8c245e | ||
|
|
74c86f1b6b | ||
|
|
cf9d599536 | ||
|
|
14a67fd6c2 | ||
|
|
19f1225249 | ||
|
|
7f33f82b60 | ||
|
|
6880c0a967 | ||
|
|
814f4157db | ||
|
|
0f5e53526e | ||
|
|
413da01c5c | ||
|
|
a73d231bd4 | ||
|
|
4f2392faac | ||
|
|
2321dcec6c | ||
|
|
c2cf7ba758 | ||
|
|
239dd4aa60 | ||
|
|
a653b2e777 | ||
|
|
d8faee7e93 | ||
|
|
69417425e9 | ||
|
|
e8574a49a7 | ||
|
|
fe624cd218 | ||
|
|
1f10a66c74 | ||
|
|
a8f1cd26cd | ||
|
|
a497a6b7f5 | ||
|
|
9dc144f2b5 | ||
|
|
7d50f3cf21 | ||
|
|
315af4911c | ||
|
|
35704c69c7 | ||
|
|
a24628c771 | ||
|
|
e9748a160a | ||
|
|
7bc78e104f | ||
|
|
6f0dccfec9 | ||
|
|
76d6981dab | ||
|
|
5df37c52dd | ||
|
|
c78b7a6928 | ||
|
|
7a2ccc075c | ||
|
|
237054c23e | ||
|
|
ac1d641bd5 | ||
|
|
3545b6e98a | ||
|
|
d3a56e00ea | ||
|
|
e9f8578c25 | ||
|
|
dccfc436be | ||
|
|
1e85c8587b | ||
|
|
b8f92ab054 | ||
|
|
766ed31f8e | ||
|
|
cad78e115d | ||
|
|
c2def3eb9d | ||
|
|
2397c66218 | ||
|
|
f826f93b03 | ||
|
|
ad7ebf1cd5 | ||
|
|
b599c4f6a9 | ||
|
|
439539f56d | ||
|
|
9da66c9f6c | ||
|
|
237bcb92c9 | ||
|
|
ce02a23dbb | ||
|
|
8e81512735 | ||
|
|
c69f0394a8 | ||
|
|
d7ca9e05de | ||
|
|
64534ff810 | ||
|
|
124211a2f4 | ||
|
|
d0164a6c28 | ||
|
|
0f898ddf4a | ||
|
|
e903382034 | ||
|
|
0d225450da | ||
|
|
71555fee28 | ||
|
|
c077a64484 | ||
|
|
6c16094b42 | ||
|
|
5aa80746f9 | ||
|
|
cc64717818 | ||
|
|
6acd892116 | ||
|
|
3955408aa4 | ||
|
|
3de2468df3 | ||
|
|
b1d983fbc3 | ||
|
|
5f443d2593 | ||
|
|
436158f596 | ||
|
|
dcc56fc138 | ||
|
|
0eef10079b | ||
|
|
2b839dfb19 | ||
|
|
491b678d6e | ||
|
|
151dce006d | ||
|
|
d4f538b4aa | ||
|
|
a727439c57 | ||
|
|
f779107749 | ||
|
|
4a5c8f41fa | ||
|
|
bf458e22e8 | ||
|
|
9b8088fca2 | ||
|
|
68435aa335 | ||
|
|
afe5465044 | ||
|
|
9decf3cf14 | ||
|
|
b31c3cfd2f | ||
|
|
1306c7381c | ||
|
|
dbd2025e71 | ||
|
|
ac17b84a7a | ||
|
|
9756b7b653 | ||
|
|
ee38d93e3b | ||
|
|
ee5c7d0ef4 | ||
|
|
f19f4abe0c | ||
|
|
7c4a854bfd | ||
|
|
04322b56a4 | ||
|
|
45b4ac3e9e | ||
|
|
362ed9b088 | ||
|
|
8bf347dd09 | ||
|
|
d449f0c2fc | ||
|
|
6dab514817 | ||
|
|
8ce0d416c2 | ||
|
|
dd88641763 | ||
|
|
fb52f34ef9 | ||
|
|
05a99c9b64 | ||
|
|
32690f04b2 | ||
|
|
29b74557a6 | ||
|
|
991a51d55e | ||
|
|
c43e7e0331 | ||
|
|
fe7fd7700d | ||
|
|
c6ef0e0087 | ||
|
|
6c9227faac | ||
|
|
693b43af2e | ||
|
|
4fb5ce550e | ||
|
|
6149f693ab | ||
|
|
497321799c | ||
|
|
78f1ee175b | ||
|
|
daef57823f | ||
|
|
332c4bd94a | ||
|
|
5c7b9a93ae | ||
|
|
b681364f95 | ||
|
|
40d14eeb9f | ||
|
|
46b09f11b6 | ||
|
|
900291dc5f | ||
|
|
e9f9134e2e | ||
|
|
8fe11b12f8 | ||
|
|
a1cfb7ad9f | ||
|
|
2bddf21175 | ||
|
|
4a390b5824 | ||
|
|
aa5490adb3 | ||
|
|
785dc15cd9 | ||
|
|
31f3425354 | ||
|
|
bea089dd5e | ||
|
|
2c7237adaa | ||
|
|
98af1e1e4c | ||
|
|
689eb426ea | ||
|
|
4a1aee38a3 | ||
|
|
92c21bc382 | ||
|
|
ba748cc5fe | ||
|
|
22b1a9634a | ||
|
|
eeb5395efc | ||
|
|
6ea259596a | ||
|
|
49275a96fe |
11
.coveragerc
Normal file
11
.coveragerc
Normal file
@@ -0,0 +1,11 @@
|
||||
[run]
|
||||
omit =
|
||||
*/apps.py,
|
||||
*/migrations/*,
|
||||
*/settings*,
|
||||
*/test*,
|
||||
*/tests/*,
|
||||
*urls.py,
|
||||
*/wsgi*,
|
||||
manage.py,
|
||||
*__init__*
|
||||
26
.devcontainer/Dockerfile
Normal file
26
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM python:3.10-alpine3.18
|
||||
|
||||
#Install all dependencies.
|
||||
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git yarn
|
||||
|
||||
#Print all logs without buffering it.
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
#This port will be used by gunicorn.
|
||||
EXPOSE 8000
|
||||
|
||||
#This port will be used by vue
|
||||
EXPOSE 8080
|
||||
|
||||
#Install all python dependencies to the image
|
||||
COPY requirements.txt /tmp/pip-tmp/
|
||||
|
||||
RUN \
|
||||
if [ `apk --print-arch` = "armv7" ]; then \
|
||||
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
|
||||
fi
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev && \
|
||||
echo -n "INPUT ( libldap.so )" > /usr/lib/libldap_r.so && \
|
||||
pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt && \
|
||||
rm -rf /tmp/pip-tmp && \
|
||||
apk --purge del .build-deps
|
||||
27
.devcontainer/devcontainer.json
Normal file
27
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,27 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json.
|
||||
{
|
||||
"name": "Tandoor Dev Container",
|
||||
"build": { "context": "..", "dockerfile": "Dockerfile" },
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [8000, 8080],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// "postCreateCommand": "pip3 install --user -r requirements.txt"
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-python.debugpy",
|
||||
"ms-python.python"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
@@ -3,7 +3,6 @@ npm-debug.log
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
LICENSE
|
||||
|
||||
167
.env.template
167
.env.template
@@ -1,172 +1,15 @@
|
||||
# only set this to true when testing/debugging
|
||||
# when unset: 1 (true) - dont unset this, just for development
|
||||
DEBUG=0
|
||||
SQL_DEBUG=0
|
||||
DEBUG_TOOLBAR=0
|
||||
# Gunicorn log level for debugging (default value is "info" when unset)
|
||||
# (see https://docs.gunicorn.org/en/stable/settings.html#loglevel for available settings)
|
||||
# GUNICORN_LOG_LEVEL="debug"
|
||||
|
||||
# HTTP port to bind to
|
||||
# TANDOOR_PORT=8080
|
||||
|
||||
# hosts the application can run under e.g. recipes.mydomain.com,cooking.mydomain.com,...
|
||||
ALLOWED_HOSTS=*
|
||||
# ---------------------------------------------------------------------------
|
||||
# This template contains only required options.
|
||||
# Visit the docs to find more https://docs.tandoor.dev/system/configuration/
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# random secret key, use for example `base64 /dev/urandom | head -c50` to generate one
|
||||
# ---------------------------- REQUIRED -------------------------
|
||||
SECRET_KEY=
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
# your default timezone See https://timezonedb.com/time-zones for a list of timezones
|
||||
TIMEZONE=Europe/Berlin
|
||||
|
||||
# add only a database password if you want to run with the default postgres, otherwise change settings accordingly
|
||||
DB_ENGINE=django.db.backends.postgresql
|
||||
# DB_OPTIONS= {} # e.g. {"sslmode":"require"} to enable ssl
|
||||
POSTGRES_HOST=db_recipes
|
||||
POSTGRES_DB=djangodb
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=djangouser
|
||||
# ---------------------------- REQUIRED -------------------------
|
||||
POSTGRES_PASSWORD=
|
||||
# ---------------------------------------------------------------
|
||||
POSTGRES_DB=djangodb
|
||||
|
||||
# database connection string, when used overrides other database settings.
|
||||
# format might vary depending on backend
|
||||
# DATABASE_URL = engine://username:password@host:port/dbname
|
||||
|
||||
# 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 base URL is something other than just / (you are serving a subfolder in your proxy for instance http://recipe_app/recipes/)
|
||||
# Be sure to not have a trailing slash: e.g. '/recipes' instead of '/recipes/'
|
||||
# SCRIPT_NAME=/recipes
|
||||
|
||||
# If staticfiles are stored at a different location uncomment and change accordingly, MUST END IN /
|
||||
# this is not required if you are just using a subfolder
|
||||
# This can either be a relative path from the applications base path or the url of an external host
|
||||
# STATIC_URL=/static/
|
||||
|
||||
# If mediafiles are stored at a different location uncomment and change accordingly, MUST END IN /
|
||||
# this is not required if you are just using a subfolder
|
||||
# This can either be a relative path from the applications base path or the url of an external host
|
||||
# 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
|
||||
|
||||
# GUNICORN SERVER RELATED SETTINGS (see https://docs.gunicorn.org/en/stable/design.html#how-many-workers for recommended settings)
|
||||
# GUNICORN_WORKERS=1
|
||||
# GUNICORN_THREADS=1
|
||||
|
||||
# S3 Media settings: store mediafiles in s3 or any compatible storage backend (e.g. minio)
|
||||
# as long as S3_ACCESS_KEY is not set S3 features are disabled
|
||||
# S3_ACCESS_KEY=
|
||||
# S3_SECRET_ACCESS_KEY=
|
||||
# S3_BUCKET_NAME=
|
||||
# S3_REGION_NAME= # default none, set your region might be required
|
||||
# S3_QUERYSTRING_AUTH=1 # default true, set to 0 to serve media from a public bucket without signed urls
|
||||
# S3_QUERYSTRING_EXPIRE=3600 # number of seconds querystring are valid for
|
||||
# S3_ENDPOINT_URL= # when using a custom endpoint like minio
|
||||
# S3_CUSTOM_DOMAIN= # when using a CDN/proxy to S3 (see https://github.com/TandoorRecipes/recipes/issues/1943)
|
||||
|
||||
# Email Settings, see https://docs.djangoproject.com/en/3.2/ref/settings/#email-host
|
||||
# Required for email confirmation and password reset (automatically activates if host is set)
|
||||
# EMAIL_HOST=
|
||||
# EMAIL_PORT=
|
||||
# EMAIL_HOST_USER=
|
||||
# EMAIL_HOST_PASSWORD=
|
||||
# EMAIL_USE_TLS=0
|
||||
# EMAIL_USE_SSL=0
|
||||
# email sender address (default 'webmaster@localhost')
|
||||
# DEFAULT_FROM_EMAIL=
|
||||
# prefix used for account related emails (default "[Tandoor Recipes] ")
|
||||
# ACCOUNT_EMAIL_SUBJECT_PREFIX=
|
||||
|
||||
# allow authentication via reverse proxy (e.g. authelia), leave off if you dont know what you are doing
|
||||
# see docs for more information https://docs.tandoor.dev/features/authentication/
|
||||
# when unset: 0 (false)
|
||||
REVERSE_PROXY_AUTH=0
|
||||
|
||||
# Default settings for spaces, apply per space and can be changed in the admin view
|
||||
# SPACE_DEFAULT_MAX_RECIPES=0 # 0=unlimited recipes
|
||||
# SPACE_DEFAULT_MAX_USERS=0 # 0=unlimited users per space
|
||||
# SPACE_DEFAULT_MAX_FILES=0 # Maximum file storage for space in MB. 0 for unlimited, -1 to disable file upload.
|
||||
# SPACE_DEFAULT_ALLOW_SHARING=1 # Allow users to share recipes with public links
|
||||
|
||||
# allow people to create accounts on your application instance (without an invite link)
|
||||
# when unset: 0 (false)
|
||||
# ENABLE_SIGNUP=0
|
||||
|
||||
# If signup is enabled you might want to add a captcha to it to prevent spam
|
||||
# HCAPTCHA_SITEKEY=
|
||||
# HCAPTCHA_SECRET=
|
||||
|
||||
# if signup is enabled you might want to provide urls to data protection policies or terms and conditions
|
||||
# TERMS_URL=
|
||||
# PRIVACY_URL=
|
||||
# IMPRINT_URL=
|
||||
|
||||
# enable serving of prometheus metrics under the /metrics path
|
||||
# ATTENTION: view is not secured (as per the prometheus default way) so make sure to secure it
|
||||
# trough your web server (or leave it open of you dont care if the stats are exposed)
|
||||
# ENABLE_METRICS=0
|
||||
|
||||
# allows you to setup OAuth providers
|
||||
# see docs for more information https://docs.tandoor.dev/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
|
||||
|
||||
# Django session cookie settings. Can be changed to allow a single django application to authenticate several applications
|
||||
# when running under the same database
|
||||
# SESSION_COOKIE_DOMAIN=.example.com
|
||||
# SESSION_COOKIE_NAME=sessionid # use this only to not interfere with non unified django applications under the same top level domain
|
||||
|
||||
# by default SORT_TREE_BY_NAME is disabled this will store all Keywords and Food in the order they are created
|
||||
# enabling this setting makes saving new keywords and foods very slow, which doesn't matter in most usecases.
|
||||
# however, when doing large imports of recipes that will create new objects, can increase total run time by 10-15x
|
||||
# Keywords and Food can be manually sorted by name in Admin
|
||||
# This value can also be temporarily changed in Admin, it will revert the next time the application is started
|
||||
# This will be fixed/changed in the future by changing the implementation or finding a better workaround for sorting
|
||||
# SORT_TREE_BY_NAME=0
|
||||
# LDAP authentication
|
||||
# default 0 (false), when 1 (true) list of allowed users will be fetched from LDAP server
|
||||
#LDAP_AUTH=
|
||||
#AUTH_LDAP_SERVER_URI=
|
||||
#AUTH_LDAP_BIND_DN=
|
||||
#AUTH_LDAP_BIND_PASSWORD=
|
||||
#AUTH_LDAP_USER_SEARCH_BASE_DN=
|
||||
#AUTH_LDAP_TLS_CACERTFILE=
|
||||
#AUTH_LDAP_START_TLS=
|
||||
|
||||
# Enables exporting PDF (see export docs)
|
||||
# Disabled by default, uncomment to enable
|
||||
# ENABLE_PDF_EXPORT=1
|
||||
|
||||
# Recipe exports are cached for a certain time by default, adjust time if needed
|
||||
# EXPORT_FILE_CACHE_DURATION=600
|
||||
|
||||
|
||||
7
.github/dependabot.yml
vendored
7
.github/dependabot.yml
vendored
@@ -1,10 +1,15 @@
|
||||
# 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
|
||||
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "devcontainers"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/"
|
||||
schedule:
|
||||
|
||||
28
.github/workflows/build-docker-open-data.yml
vendored
28
.github/workflows/build-docker-open-data.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
suffix: ""
|
||||
continue-on-error: false
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get version number
|
||||
id: get_version
|
||||
@@ -34,16 +34,6 @@ jobs:
|
||||
echo VERSION=develop >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Update Version number
|
||||
- name: Update version file
|
||||
uses: DamianReeves/write-file-action@v1.2
|
||||
with:
|
||||
path: recipes/version.py
|
||||
contents: |
|
||||
VERSION_NUMBER = '${{ steps.get_version.outputs.VERSION }}-open-data'
|
||||
BUILD_REF = '${{ github.sha }}'
|
||||
write-mode: overwrite
|
||||
|
||||
# clone open data plugin
|
||||
- name: clone open data plugin repo
|
||||
uses: actions/checkout@master
|
||||
@@ -53,9 +43,9 @@ jobs:
|
||||
path: ./recipes/plugins/open_data_plugin
|
||||
|
||||
# Build Vue frontend
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '14'
|
||||
node-version: '18'
|
||||
cache: yarn
|
||||
cache-dependency-path: vue/yarn.lock
|
||||
- name: Install dependencies
|
||||
@@ -74,17 +64,17 @@ jobs:
|
||||
run: yarn build
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
if: github.secret_source == 'Actions'
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
if: github.secret_source == 'Actions'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -92,7 +82,7 @@ jobs:
|
||||
password: ${{ github.token }}
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
vabene1111/recipes
|
||||
@@ -107,7 +97,7 @@ jobs:
|
||||
type=semver,suffix=-open-data-plugin,pattern={{major}}
|
||||
type=ref,suffix=-open-data-plugin,event=branch
|
||||
- name: Build and Push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
|
||||
36
.github/workflows/build-docker.yml
vendored
36
.github/workflows/build-docker.yml
vendored
@@ -17,17 +17,11 @@ jobs:
|
||||
# Standard build config
|
||||
- name: Standard
|
||||
dockerfile: Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
suffix: ""
|
||||
continue-on-error: false
|
||||
# Raspi build config
|
||||
- name: Raspi
|
||||
dockerfile: Dockerfile-raspi
|
||||
platforms: linux/arm/v7
|
||||
suffix: "-raspi"
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get version number
|
||||
id: get_version
|
||||
@@ -40,20 +34,10 @@ jobs:
|
||||
echo VERSION=develop >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Update Version number
|
||||
- name: Update version file
|
||||
uses: DamianReeves/write-file-action@v1.2
|
||||
with:
|
||||
path: recipes/version.py
|
||||
contents: |
|
||||
VERSION_NUMBER = '${{ steps.get_version.outputs.VERSION }}'
|
||||
BUILD_REF = '${{ github.sha }}'
|
||||
write-mode: overwrite
|
||||
|
||||
# Build Vue frontend
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '14'
|
||||
node-version: '18'
|
||||
cache: yarn
|
||||
cache-dependency-path: vue/yarn.lock
|
||||
- name: Install dependencies
|
||||
@@ -64,17 +48,17 @@ jobs:
|
||||
run: yarn build
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
if: github.secret_source == 'Actions'
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
if: github.secret_source == 'Actions'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -82,7 +66,7 @@ jobs:
|
||||
password: ${{ github.token }}
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
vabene1111/recipes
|
||||
@@ -97,7 +81,7 @@ jobs:
|
||||
type=semver,pattern={{major}}
|
||||
type=ref,event=branch
|
||||
- name: Build and Push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
|
||||
109
.github/workflows/ci.yml
vendored
109
.github/workflows/ci.yml
vendored
@@ -3,38 +3,79 @@ name: Continuous Integration
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.repository_owner == 'TandoorRecipes'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
python-version: ['3.10']
|
||||
build:
|
||||
if: github.repository_owner == 'TandoorRecipes'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
node-version: ["18"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
# Build Vue frontend
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '16'
|
||||
- name: Install Vue dependencies
|
||||
working-directory: ./vue
|
||||
run: yarn install
|
||||
- name: Build Vue dependencies
|
||||
working-directory: ./vue
|
||||
run: yarn build
|
||||
- name: Install Django dependencies
|
||||
run: |
|
||||
sudo apt-get -y update
|
||||
sudo apt-get install -y libsasl2-dev python3-dev libldap2-dev libssl-dev
|
||||
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: |
|
||||
pytest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: awalsh128/cache-apt-pkgs-action@v1.4.1
|
||||
with:
|
||||
packages: libsasl2-dev python3-dev libldap2-dev libssl-dev
|
||||
version: 1.0
|
||||
|
||||
# Setup python & dependencies
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "pip"
|
||||
|
||||
- name: Install Python Dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Cache StaticFiles
|
||||
uses: actions/cache@v4
|
||||
id: django_cache
|
||||
with:
|
||||
path: |
|
||||
./cookbook/static
|
||||
./vue/webpack-stats.json
|
||||
./staticfiles
|
||||
key: |
|
||||
${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.node-version }}-collectstatic-${{ hashFiles('**/*.css', '**/*.js', 'vue/src/*') }}
|
||||
|
||||
# Build Vue frontend & Dependencies
|
||||
- name: Set up Node ${{ matrix.node-version }}
|
||||
if: steps.django_cache.outputs.cache-hit != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "yarn"
|
||||
cache-dependency-path: ./vue/yarn.lock
|
||||
|
||||
- name: Install Vue dependencies
|
||||
if: steps.django_cache.outputs.cache-hit != 'true'
|
||||
working-directory: ./vue
|
||||
run: yarn install
|
||||
|
||||
- name: Build Vue dependencies
|
||||
if: steps.django_cache.outputs.cache-hit != 'true'
|
||||
working-directory: ./vue
|
||||
run: yarn build
|
||||
|
||||
- name: Compile Django StaticFiles
|
||||
if: steps.django_cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python3 manage.py collectstatic --noinput
|
||||
python3 manage.py collectstatic_js_reverse
|
||||
|
||||
- uses: actions/cache/save@v4
|
||||
if: steps.django_cache.outputs.cache-hit != 'true'
|
||||
with:
|
||||
path: |
|
||||
./cookbook/static
|
||||
./vue/webpack-stats.json
|
||||
./staticfiles
|
||||
key: |
|
||||
${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.node-version }}-collectstatic-${{ hashFiles('**/*.css', '**/*.js', 'vue/src/*') }}
|
||||
|
||||
- name: Django Testing project
|
||||
run: pytest --junitxml=junit/test-results-${{ matrix.python-version }}.xml
|
||||
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v3
|
||||
# Override language selection by uncommenting this and choosing your languages
|
||||
with:
|
||||
languages: python, javascript
|
||||
@@ -47,6 +47,6 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
languages: javascript, python
|
||||
|
||||
17
.github/workflows/docs.yml
vendored
17
.github/workflows/docs.yml
vendored
@@ -1,17 +1,20 @@
|
||||
name: Make Docs
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
# the 1st condition
|
||||
workflow_run:
|
||||
workflows: ["Continuous Integration"]
|
||||
branches: [master]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository_owner == 'TandoorRecipes'
|
||||
if: github.repository_owner == 'TandoorRecipes' && ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-python@v4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.x
|
||||
- run: pip install mkdocs-material mkdocs-include-markdown-plugin
|
||||
- run: mkdocs gh-deploy --force
|
||||
- run: mkdocs gh-deploy --force
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -43,6 +43,7 @@ htmlcov/
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*,cover
|
||||
docs/reports/**
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
@@ -54,7 +55,6 @@ docs/_build/
|
||||
target/
|
||||
|
||||
\.idea/dataSources/
|
||||
|
||||
\.idea/dataSources\.xml
|
||||
|
||||
\.idea/dataSources\.local\.xml
|
||||
@@ -74,15 +74,17 @@ mediafiles/
|
||||
\.env
|
||||
staticfiles/
|
||||
postgresql/
|
||||
data/
|
||||
|
||||
|
||||
/docker-compose.override.yml
|
||||
vue/node_modules
|
||||
plugins
|
||||
.vscode/
|
||||
vetur.config.js
|
||||
cookbook/static/vue
|
||||
vue/webpack-stats.json
|
||||
cookbook/templates/sw.js
|
||||
.prettierignore
|
||||
vue/.yarn
|
||||
vue3/.vite
|
||||
vue3/node_modules
|
||||
|
||||
2
.idea/dictionaries/vaben.xml
generated
2
.idea/dictionaries/vaben.xml
generated
@@ -1,8 +1,10 @@
|
||||
<component name="ProjectDictionaryState">
|
||||
<dictionary name="vaben">
|
||||
<words>
|
||||
<w>mealplan</w>
|
||||
<w>pinia</w>
|
||||
<w>selfhosted</w>
|
||||
<w>unapplied</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
</component>
|
||||
1
.idea/inspectionProfiles/profiles_settings.xml
generated
1
.idea/inspectionProfiles/profiles_settings.xml
generated
@@ -1,5 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="PROJECT_PROFILE" value="Default" />
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
|
||||
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
18
.vscode/launch.json
vendored
Normal file
18
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
|
||||
{
|
||||
"name": "Python Debugger: Django",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/manage.py",
|
||||
"args": ["runserver"],
|
||||
"django": true,
|
||||
"justMyCode": true
|
||||
}
|
||||
]
|
||||
}
|
||||
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"python.testing.pytestArgs": [
|
||||
"cookbook/tests"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true
|
||||
}
|
||||
75
.vscode/tasks.json
vendored
Normal file
75
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Run Migrations",
|
||||
"type": "shell",
|
||||
"command": "python3 manage.py migrate",
|
||||
},
|
||||
{
|
||||
"label": "Collect Static Files",
|
||||
"type": "shell",
|
||||
"command": "python3 manage.py collectstatic",
|
||||
"dependsOn": ["Yarn Build"],
|
||||
},
|
||||
{
|
||||
"label": "Setup Dev Server",
|
||||
"dependsOn": ["Run Migrations", "Yarn Build"],
|
||||
},
|
||||
{
|
||||
"label": "Run Dev Server",
|
||||
"type": "shell",
|
||||
"dependsOn": ["Setup Dev Server"],
|
||||
"command": "python3 manage.py runserver",
|
||||
},
|
||||
{
|
||||
"label": "Yarn Install",
|
||||
"type": "shell",
|
||||
"command": "yarn install",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Yarn Serve",
|
||||
"type": "shell",
|
||||
"command": "yarn serve",
|
||||
"dependsOn": ["Yarn Install"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Yarn Build",
|
||||
"type": "shell",
|
||||
"command": "yarn build",
|
||||
"dependsOn": ["Yarn Install"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue"
|
||||
},
|
||||
"group": "build",
|
||||
},
|
||||
{
|
||||
"label": "Setup Tests",
|
||||
"dependsOn": ["Run Migrations", "Collect Static Files"],
|
||||
},
|
||||
{
|
||||
"label": "Run all pytests",
|
||||
"type": "shell",
|
||||
"command": "python3 -m pytest cookbook/tests",
|
||||
"dependsOn": ["Setup Tests"],
|
||||
"group": "test",
|
||||
},
|
||||
{
|
||||
"label": "Setup Documentation Dependencies",
|
||||
"type": "shell",
|
||||
"command": "pip install mkdocs-material mkdocs-include-markdown-plugin",
|
||||
},
|
||||
{
|
||||
"label": "Serve Documentation",
|
||||
"type": "shell",
|
||||
"command": "mkdocs serve",
|
||||
"dependsOn": ["Setup Documentation Dependencies"],
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,6 +20,7 @@ Below are some of the larger contributions made yet.
|
||||
- [murphy83] added support for IPv6 #1490
|
||||
- [TheHaf] added custom serving size component #1411
|
||||
- [lostlont] added LDAP support #960
|
||||
- [c0mputerguru] added devcontainers for ease of development
|
||||
|
||||
## Translations
|
||||
|
||||
|
||||
22
Dockerfile
22
Dockerfile
@@ -1,7 +1,7 @@
|
||||
FROM python:3.10-alpine3.15
|
||||
FROM python:3.12-alpine3.19
|
||||
|
||||
#Install all dependencies.
|
||||
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev py-cryptography openldap
|
||||
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git
|
||||
|
||||
#Print all logs without buffering it.
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
@@ -15,16 +15,28 @@ WORKDIR /opt/recipes
|
||||
|
||||
COPY requirements.txt ./
|
||||
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev git && \
|
||||
RUN \
|
||||
if [ `apk --print-arch` = "armv7" ]; then \
|
||||
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
|
||||
fi
|
||||
# remove Development dependencies from requirements.txt
|
||||
RUN sed -i '/# Development/,$d' requirements.txt
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev && \
|
||||
echo -n "INPUT ( libldap.so )" > /usr/lib/libldap_r.so && \
|
||||
python -m venv venv && \
|
||||
/opt/recipes/venv/bin/python -m pip install --upgrade pip && \
|
||||
venv/bin/pip install wheel==0.37.1 && \
|
||||
venv/bin/pip install setuptools_rust==1.1.2 && \
|
||||
venv/bin/pip install wheel==0.42.0 && \
|
||||
venv/bin/pip install setuptools_rust==1.9.0 && \
|
||||
venv/bin/pip install -r requirements.txt --no-cache-dir &&\
|
||||
apk --purge del .build-deps
|
||||
|
||||
#Copy project and execute it.
|
||||
COPY . ./
|
||||
|
||||
# collect information from git repositories
|
||||
RUN /opt/recipes/venv/bin/python version.py
|
||||
# delete git repositories to reduce image size
|
||||
RUN find . -type d -name ".git" | xargs rm -rf
|
||||
|
||||
RUN chmod +x boot.sh
|
||||
ENTRYPOINT ["/opt/recipes/boot.sh"]
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# builds of cryptography for raspberry pi (or better arm v7) fail for some
|
||||
FROM python:3.9-alpine3.15
|
||||
|
||||
#Install all dependencies.
|
||||
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev py-cryptography openldap gcompat
|
||||
|
||||
#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 requirements.txt ./
|
||||
RUN \
|
||||
if [ `apk --print-arch` = "armv7" ]; then \
|
||||
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
|
||||
fi
|
||||
RUN apk add --no-cache --virtual .build-deps gcc musl-dev zlib-dev jpeg-dev libwebp-dev python3-dev git && \
|
||||
echo -n "INPUT ( libldap.so )" > /usr/lib/libldap_r.so && \
|
||||
python -m venv venv && \
|
||||
/opt/recipes/venv/bin/python -m pip install --upgrade pip && \
|
||||
venv/bin/pip install wheel==0.37.1 && \
|
||||
venv/bin/pip install -r requirements.txt --no-cache-dir --no-binary=Pillow && \
|
||||
apk --purge del .build-deps
|
||||
|
||||
#Copy project and execute it.
|
||||
COPY . ./
|
||||
RUN chmod +x boot.sh
|
||||
ENTRYPOINT ["/opt/recipes/boot.sh"]
|
||||
14
README.md
14
README.md
@@ -39,13 +39,13 @@
|
||||
|
||||
- 🔍 Powerful & customizable **search** with fulltext support and [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
|
||||
- ↔️ Quickly merge and rename ingredients, tags and units
|
||||
- ↔️ Quickly merge and rename ingredients, tags and units
|
||||
- 📥️ **Import recipes** from thousands of websites supporting [ld+json or microdata](https://schema.org/Recipe)
|
||||
- ➗ Support for **fractions** or decimals
|
||||
- 🐳 Easy setup with **Docker** and included examples for **Kubernetes**, **Unraid** and **Synology**
|
||||
- 🎨 Customize your interface with **themes**
|
||||
- 📦 **Sync** files with Dropbox and Nextcloud
|
||||
|
||||
|
||||
## All the must haves
|
||||
|
||||
- 📱Optimized for use on **mobile** devices
|
||||
@@ -54,7 +54,7 @@
|
||||
- ➕ Many more like recipe scaling, image compression, printing views and supermarkets
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
## Docs
|
||||
@@ -62,13 +62,13 @@ a public page.
|
||||
Documentation can be found [here](https://docs.tandoor.dev/).
|
||||
|
||||
## Support our work
|
||||
Tandoor is developed by volunteers in their free time just because its fun. That said earning
|
||||
Tandoor is developed by volunteers in their free time just because its fun. That said earning
|
||||
some money with the project allows us to spend more time on it and thus make improvements we otherwise couldn't.
|
||||
Because of that there are several ways you can support us
|
||||
|
||||
- **GitHub Sponsors** You can sponsor contributors of this project on GitHub: [vabene1111](https://github.com/sponsors/vabene1111)
|
||||
- **Host at Hetzner** We have been very happy customers of Hetzner for multiple years for all of our projects. If you want to get into self-hosting or are tired of the expensive big providers, their cloud servers are a great place to get started. When you sign up via our [referral link](https://hetzner.cloud/?ref=ISdlrLmr9kGj) you will get 20€ worth of cloud credits and we get a small kickback too.
|
||||
- **Let us host for you** We are offering a [hosted version](https://app.tandoor.dev) where all profits support us and the development of tandoor (currently only available in germany).
|
||||
- **Let us host for you** We are offering a [hosted version](https://app.tandoor.dev) where all profits support us and the development of tandoor (currently only available in germany).
|
||||
|
||||
## Contributing
|
||||
Contributions are welcome but please read [this](https://docs.tandoor.dev/contribute/#contributing-code) **BEFORE** contributing anything!
|
||||
@@ -96,11 +96,11 @@ Share some information on how you use Tandoor to help me improve the application
|
||||
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 a
|
||||
[common clause](https://commonsclause.com/) selling exception. See [LICENSE.md](https://github.com/vabene1111/recipes/blob/develop/LICENSE.md) for details.
|
||||
|
||||
> NOTE: There appears to be a whole range of legal issues with licensing anything else then the standard completely open licenses.
|
||||
> NOTE: There appears to be a whole range of legal issues with licensing anything other than the standard completely open licenses.
|
||||
> I am in the process of getting some professional legal advice to sort out these issues.
|
||||
> Please also see [Issue 238](https://github.com/vabene1111/recipes/issues/238) for some discussion and **reasoning** regarding the topic.
|
||||
|
||||
**Reasoning**
|
||||
**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.
|
||||
|
||||
22
boot.sh
22
boot.sh
@@ -19,9 +19,14 @@ if [ ! -f "$NGINX_CONF_FILE" ] && [ $GUNICORN_MEDIA -eq 0 ]; then
|
||||
display_warning "Nginx configuration file could not be found at the default location!\nPath: ${NGINX_CONF_FILE}"
|
||||
fi
|
||||
|
||||
# SECRET_KEY must be set in .env file
|
||||
# SECRET_KEY (or a valid file at SECRET_KEY_FILE) must be set in .env file
|
||||
|
||||
if [ -f "${SECRET_KEY_FILE}" ]; then
|
||||
export SECRET_KEY=$(cat "$SECRET_KEY_FILE")
|
||||
fi
|
||||
|
||||
if [ -z "${SECRET_KEY}" ]; then
|
||||
display_warning "The environment variable 'SECRET_KEY' is not set but REQUIRED for running Tandoor!"
|
||||
display_warning "The environment variable 'SECRET_KEY' (or 'SECRET_KEY_FILE' that points to an existing file) is not set but REQUIRED for running Tandoor!"
|
||||
fi
|
||||
|
||||
|
||||
@@ -30,11 +35,16 @@ echo "Waiting for database to be ready..."
|
||||
attempt=0
|
||||
max_attempts=20
|
||||
|
||||
if [ "${DB_ENGINE}" != 'django.db.backends.sqlite3' ]; then
|
||||
if [ "${DB_ENGINE}" == 'django.db.backends.postgresql' ] || [ "${DATABASE_URL}" == 'postgres'* ]; then
|
||||
|
||||
# POSTGRES_PASSWORD (or a valid file at POSTGRES_PASSWORD_FILE) must be set in .env file
|
||||
|
||||
if [ -f "${POSTGRES_PASSWORD_FILE}" ]; then
|
||||
export POSTGRES_PASSWORD=$(cat "$POSTGRES_PASSWORD_FILE")
|
||||
fi
|
||||
|
||||
# POSTGRES_PASSWORD must be set in .env file
|
||||
if [ -z "${POSTGRES_PASSWORD}" ]; then
|
||||
display_warning "The environment variable 'POSTGRES_PASSWORD' is not set but REQUIRED for running Tandoor!"
|
||||
display_warning "The environment variable 'POSTGRES_PASSWORD' (or 'POSTGRES_PASSWORD_FILE' that points to an existing file) is not set but REQUIRED for running Tandoor!"
|
||||
fi
|
||||
|
||||
while pg_isready --host=${POSTGRES_HOST} --port=${POSTGRES_PORT} --user=${POSTGRES_USER} -q; status=$?; attempt=$((attempt+1)); [ $status -ne 0 ] && [ $attempt -le $max_attempts ]; do
|
||||
@@ -66,4 +76,4 @@ echo "Done"
|
||||
|
||||
chmod -R 755 /opt/recipes/mediafiles
|
||||
|
||||
exec gunicorn -b :$TANDOOR_PORT --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
|
||||
exec gunicorn -b "[::]:$TANDOOR_PORT" --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
|
||||
|
||||
@@ -10,13 +10,13 @@ from treebeard.forms import movenodeform_factory
|
||||
|
||||
from cookbook.managers import DICTIONARY
|
||||
|
||||
from .models import (Automation, BookmarkletImport, Comment, CookLog, Food, FoodInheritField,
|
||||
ImportLog, Ingredient, InviteLink, Keyword, MealPlan, MealType,
|
||||
NutritionInformation, Property, PropertyType, Recipe, RecipeBook,
|
||||
RecipeBookEntry, RecipeImport, SearchPreference, ShareLink, ShoppingList,
|
||||
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage, Supermarket,
|
||||
SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog, TelegramBot,
|
||||
Unit, UnitConversion, UserFile, UserPreference, UserSpace, ViewLog)
|
||||
from .models import (BookmarkletImport, Comment, CookLog, Food, ImportLog, Ingredient, InviteLink,
|
||||
Keyword, MealPlan, MealType, NutritionInformation, Property, PropertyType,
|
||||
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink,
|
||||
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
||||
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
|
||||
TelegramBot, Unit, UnitConversion, UserFile, UserPreference, UserSpace,
|
||||
ViewLog, ConnectorConfig)
|
||||
|
||||
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
@@ -39,6 +39,8 @@ def delete_space_action(modeladmin, request, queryset):
|
||||
class SpaceAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'created_by', 'max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
|
||||
search_fields = ('name', 'created_by__username')
|
||||
autocomplete_fields = ('created_by',)
|
||||
filter_horizontal = ('food_inherit',)
|
||||
list_filter = ('max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
|
||||
date_hierarchy = 'created_at'
|
||||
actions = [delete_space_action]
|
||||
@@ -50,16 +52,19 @@ admin.site.register(Space, SpaceAdmin)
|
||||
class UserSpaceAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'space',)
|
||||
search_fields = ('user__username', 'space__name',)
|
||||
filter_horizontal = ('groups',)
|
||||
autocomplete_fields = ('user', 'space',)
|
||||
|
||||
|
||||
admin.site.register(UserSpace, UserSpaceAdmin)
|
||||
|
||||
|
||||
class UserPreferenceAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'theme', 'nav_color', 'default_page',)
|
||||
list_display = ('name', 'theme', 'default_page')
|
||||
search_fields = ('user__username',)
|
||||
list_filter = ('theme', 'nav_color', 'default_page',)
|
||||
list_filter = ('theme', 'default_page',)
|
||||
date_hierarchy = 'created_at'
|
||||
filter_horizontal = ('plan_share', 'shopping_share',)
|
||||
|
||||
@staticmethod
|
||||
def name(obj):
|
||||
@@ -90,6 +95,14 @@ class StorageAdmin(admin.ModelAdmin):
|
||||
admin.site.register(Storage, StorageAdmin)
|
||||
|
||||
|
||||
class ConnectorConfigAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'name', 'type', 'enabled', 'url')
|
||||
search_fields = ('name', 'url')
|
||||
|
||||
|
||||
admin.site.register(ConnectorConfig, ConnectorConfigAdmin)
|
||||
|
||||
|
||||
class SyncAdmin(admin.ModelAdmin):
|
||||
list_display = ('storage', 'path', 'active', 'last_checked')
|
||||
search_fields = ('storage__name', 'path')
|
||||
@@ -103,11 +116,16 @@ class SupermarketCategoryInline(admin.TabularInline):
|
||||
|
||||
|
||||
class SupermarketAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'space',)
|
||||
inlines = (SupermarketCategoryInline,)
|
||||
|
||||
|
||||
class SupermarketCategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'space',)
|
||||
|
||||
|
||||
admin.site.register(Supermarket, SupermarketAdmin)
|
||||
admin.site.register(SupermarketCategory)
|
||||
admin.site.register(SupermarketCategory, SupermarketCategoryAdmin)
|
||||
|
||||
|
||||
class SyncLogAdmin(admin.ModelAdmin):
|
||||
@@ -158,10 +176,18 @@ def delete_unattached_steps(modeladmin, request, queryset):
|
||||
|
||||
|
||||
class StepAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'order',)
|
||||
search_fields = ('name',)
|
||||
list_display = ('recipe_and_name', 'order', 'space')
|
||||
ordering = ('recipe__name', 'name', 'space',)
|
||||
search_fields = ('name', 'recipe__name')
|
||||
actions = [delete_unattached_steps]
|
||||
|
||||
@staticmethod
|
||||
@admin.display(description="Name")
|
||||
def recipe_and_name(obj):
|
||||
if not obj.recipe_set.exists():
|
||||
return f"Orphaned Step{'':s if not obj.name else f': {obj.name}'}"
|
||||
return f"{obj.recipe_set.first().name}: {obj.name}" if obj.name else obj.recipe_set.first().name
|
||||
|
||||
|
||||
admin.site.register(Step, StepAdmin)
|
||||
|
||||
@@ -178,8 +204,9 @@ def rebuild_index(modeladmin, request, queryset):
|
||||
|
||||
|
||||
class RecipeAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'internal', 'created_by', 'storage')
|
||||
list_display = ('name', 'internal', 'created_by', 'storage', 'space')
|
||||
search_fields = ('name', 'created_by__username')
|
||||
ordering = ('name', 'created_by__username',)
|
||||
list_filter = ('internal',)
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
@@ -187,13 +214,20 @@ class RecipeAdmin(admin.ModelAdmin):
|
||||
def created_by(obj):
|
||||
return obj.created_by.get_user_display_name()
|
||||
|
||||
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']:
|
||||
if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql':
|
||||
actions = [rebuild_index]
|
||||
|
||||
|
||||
admin.site.register(Recipe, RecipeAdmin)
|
||||
|
||||
admin.site.register(Unit)
|
||||
|
||||
class UnitAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'space')
|
||||
ordering = ('name', 'space',)
|
||||
search_fields = ('name',)
|
||||
|
||||
|
||||
admin.site.register(Unit, UnitAdmin)
|
||||
|
||||
|
||||
# admin.site.register(FoodInheritField)
|
||||
@@ -224,10 +258,16 @@ def delete_unattached_ingredients(modeladmin, request, queryset):
|
||||
|
||||
|
||||
class IngredientAdmin(admin.ModelAdmin):
|
||||
list_display = ('food', 'amount', 'unit')
|
||||
search_fields = ('food__name', 'unit__name')
|
||||
list_display = ('recipe_name', 'amount', 'unit', 'food', 'space')
|
||||
search_fields = ('food__name', 'unit__name', 'step__recipe__name')
|
||||
actions = [delete_unattached_ingredients]
|
||||
|
||||
@staticmethod
|
||||
@admin.display(description="Recipe")
|
||||
def recipe_name(obj):
|
||||
recipes = obj.step_set.first().recipe_set.all() if obj.step_set.exists() else None
|
||||
return recipes.first().name if recipes else 'Orphaned Ingredient'
|
||||
|
||||
|
||||
admin.site.register(Ingredient, IngredientAdmin)
|
||||
|
||||
@@ -253,7 +293,7 @@ admin.site.register(RecipeImport, RecipeImportAdmin)
|
||||
|
||||
|
||||
class RecipeBookAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'user_name')
|
||||
list_display = ('name', 'user_name', 'space')
|
||||
search_fields = ('name', 'created_by__username')
|
||||
|
||||
@staticmethod
|
||||
@@ -272,7 +312,7 @@ admin.site.register(RecipeBookEntry, RecipeBookEntryAdmin)
|
||||
|
||||
|
||||
class MealPlanAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'recipe', 'meal_type', 'date')
|
||||
list_display = ('user', 'recipe', 'meal_type', 'from_date', 'to_date')
|
||||
|
||||
@staticmethod
|
||||
def user(obj):
|
||||
@@ -283,8 +323,8 @@ admin.site.register(MealPlan, MealPlanAdmin)
|
||||
|
||||
|
||||
class MealTypeAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'created_by', 'order')
|
||||
search_fields = ('name', 'created_by__username')
|
||||
list_display = ('name', 'space', 'created_by', 'order')
|
||||
search_fields = ('name', 'space', 'created_by__username')
|
||||
|
||||
|
||||
admin.site.register(MealType, MealTypeAdmin)
|
||||
@@ -309,6 +349,7 @@ admin.site.register(InviteLink, InviteLinkAdmin)
|
||||
|
||||
class CookLogAdmin(admin.ModelAdmin):
|
||||
list_display = ('recipe', 'created_by', 'created_at', 'rating', 'servings')
|
||||
search_fields = ('recipe__name', 'space__name',)
|
||||
|
||||
|
||||
admin.site.register(CookLog, CookLogAdmin)
|
||||
@@ -328,13 +369,6 @@ class ShoppingListEntryAdmin(admin.ModelAdmin):
|
||||
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',)
|
||||
|
||||
@@ -343,7 +377,9 @@ admin.site.register(ShareLink, ShareLinkAdmin)
|
||||
|
||||
|
||||
class PropertyTypeAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'name')
|
||||
search_fields = ('space',)
|
||||
|
||||
list_display = ('id', 'space', 'name', 'fdc_id')
|
||||
|
||||
|
||||
admin.site.register(PropertyType, PropertyTypeAdmin)
|
||||
|
||||
@@ -3,6 +3,7 @@ import traceback
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.db import OperationalError, ProgrammingError
|
||||
from django.db.models.signals import post_save, post_delete
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from recipes.settings import DEBUG
|
||||
@@ -14,6 +15,16 @@ class CookbookConfig(AppConfig):
|
||||
def ready(self):
|
||||
import cookbook.signals # noqa
|
||||
|
||||
if not settings.DISABLE_EXTERNAL_CONNECTORS:
|
||||
try:
|
||||
from cookbook.connectors.connector_manager import ConnectorManager # Needs to be here to prevent loading race condition of oauth2 modules in models.py
|
||||
handler = ConnectorManager()
|
||||
post_save.connect(handler, dispatch_uid="connector_manager")
|
||||
post_delete.connect(handler, dispatch_uid="connector_manager")
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
print('Failed to initialize connectors')
|
||||
pass
|
||||
# if not settings.DISABLE_TREE_FIX_STARTUP:
|
||||
# # when starting up run fix_tree to:
|
||||
# # a) make sure that nodes are sorted when switching between sort modes
|
||||
|
||||
0
cookbook/connectors/__init__.py
Normal file
0
cookbook/connectors/__init__.py
Normal file
29
cookbook/connectors/connector.py
Normal file
29
cookbook/connectors/connector.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from cookbook.models import ShoppingListEntry, Space, ConnectorConfig
|
||||
|
||||
|
||||
# A Connector is 'destroyed' & recreated each time 'any' ConnectorConfig in a space changes.
|
||||
class Connector(ABC):
|
||||
@abstractmethod
|
||||
def __init__(self, config: ConnectorConfig):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def on_shopping_list_entry_created(self, space: Space, instance: ShoppingListEntry) -> None:
|
||||
pass
|
||||
|
||||
# This method might not trigger on 'direct' entry updates: https://stackoverflow.com/a/35238823
|
||||
@abstractmethod
|
||||
async def on_shopping_list_entry_updated(self, space: Space, instance: ShoppingListEntry) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def on_shopping_list_entry_deleted(self, space: Space, instance: ShoppingListEntry) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
# TODO: Add Recipes & possibly Meal Place listeners/hooks (And maybe more?)
|
||||
179
cookbook/connectors/connector_manager.py
Normal file
179
cookbook/connectors/connector_manager.py
Normal file
@@ -0,0 +1,179 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
from asyncio import Task
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from types import UnionType
|
||||
from typing import List, Any, Dict, Optional, Type
|
||||
|
||||
from django.conf import settings
|
||||
from django_scopes import scope
|
||||
|
||||
from cookbook.connectors.connector import Connector
|
||||
from cookbook.connectors.homeassistant import HomeAssistant
|
||||
from cookbook.models import ShoppingListEntry, Space, ConnectorConfig
|
||||
|
||||
REGISTERED_CLASSES: UnionType | Type = ShoppingListEntry
|
||||
|
||||
|
||||
class ActionType(Enum):
|
||||
CREATED = 1
|
||||
UPDATED = 2
|
||||
DELETED = 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class Work:
|
||||
instance: REGISTERED_CLASSES | ConnectorConfig
|
||||
actionType: ActionType
|
||||
|
||||
|
||||
# The way ConnectionManager works is as follows:
|
||||
# 1. On init, it starts a worker & creates a queue for 'Work'
|
||||
# 2. Then any time its called, it verifies the type of action (create/update/delete) and if the item is of interest, pushes the Work (non-blocking) to the queue.
|
||||
# 3. The worker consumes said work from the queue.
|
||||
# 3.1 If the work is of type ConnectorConfig, it flushes its cache of known connectors (per space.id)
|
||||
# 3.2 If work is of type REGISTERED_CLASSES, it asynchronously fires of all connectors and wait for them to finish (runtime should depend on the 'slowest' connector)
|
||||
# 4. Work is marked as consumed, and next entry of the queue is consumed.
|
||||
# Each 'Work' is processed in sequential by the worker, so the throughput is about [workers * the slowest connector]
|
||||
class ConnectorManager:
|
||||
_queue: queue.Queue
|
||||
_listening_to_classes = REGISTERED_CLASSES | ConnectorConfig
|
||||
|
||||
def __init__(self):
|
||||
self._queue = queue.Queue(maxsize=settings.EXTERNAL_CONNECTORS_QUEUE_SIZE)
|
||||
self._worker = threading.Thread(target=self.worker, args=(0, self._queue,), daemon=True)
|
||||
self._worker.start()
|
||||
|
||||
# Called by post save & post delete signals
|
||||
def __call__(self, instance: Any, **kwargs) -> None:
|
||||
if not isinstance(instance, self._listening_to_classes) or not hasattr(instance, "space"):
|
||||
return
|
||||
|
||||
action_type: ActionType
|
||||
if "created" in kwargs and kwargs["created"]:
|
||||
action_type = ActionType.CREATED
|
||||
elif "created" in kwargs and not kwargs["created"]:
|
||||
action_type = ActionType.UPDATED
|
||||
elif "origin" in kwargs:
|
||||
action_type = ActionType.DELETED
|
||||
else:
|
||||
return
|
||||
|
||||
try:
|
||||
self._queue.put_nowait(Work(instance, action_type))
|
||||
except queue.Full:
|
||||
logging.info(f"queue was full, so skipping {action_type} of type {type(instance)}")
|
||||
return
|
||||
|
||||
def stop(self):
|
||||
self._queue.join()
|
||||
self._worker.join()
|
||||
|
||||
@staticmethod
|
||||
def worker(worker_id: int, worker_queue: queue.Queue):
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
logging.info(f"started ConnectionManager worker {worker_id}")
|
||||
|
||||
# When multiple workers are used, please make sure the cache is shared across all threads, otherwise it might lead to un-expected behavior.
|
||||
_connectors_cache: Dict[int, List[Connector]] = dict()
|
||||
|
||||
while True:
|
||||
try:
|
||||
item: Optional[Work] = worker_queue.get()
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
|
||||
if item is None:
|
||||
break
|
||||
|
||||
# If a Connector was changed/updated, refresh connector from the database for said space
|
||||
refresh_connector_cache = isinstance(item.instance, ConnectorConfig)
|
||||
|
||||
space: Space = item.instance.space
|
||||
connectors: Optional[List[Connector]] = _connectors_cache.get(space.id)
|
||||
|
||||
if connectors is None or refresh_connector_cache:
|
||||
if connectors is not None:
|
||||
loop.run_until_complete(close_connectors(connectors))
|
||||
|
||||
with scope(space=space):
|
||||
connectors: List[Connector] = list()
|
||||
for config in space.connectorconfig_set.all():
|
||||
config: ConnectorConfig = config
|
||||
if not config.enabled:
|
||||
continue
|
||||
|
||||
try:
|
||||
connector: Optional[Connector] = ConnectorManager.get_connected_for_config(config)
|
||||
except BaseException:
|
||||
logging.exception(f"failed to initialize {config.name}")
|
||||
continue
|
||||
|
||||
if connector is not None:
|
||||
connectors.append(connector)
|
||||
|
||||
_connectors_cache[space.id] = connectors
|
||||
|
||||
if len(connectors) == 0 or refresh_connector_cache:
|
||||
worker_queue.task_done()
|
||||
continue
|
||||
|
||||
loop.run_until_complete(run_connectors(connectors, space, item.instance, item.actionType))
|
||||
worker_queue.task_done()
|
||||
|
||||
logging.info(f"terminating ConnectionManager worker {worker_id}")
|
||||
|
||||
asyncio.set_event_loop(None)
|
||||
loop.close()
|
||||
|
||||
@staticmethod
|
||||
def get_connected_for_config(config: ConnectorConfig) -> Optional[Connector]:
|
||||
match config.type:
|
||||
case ConnectorConfig.HOMEASSISTANT:
|
||||
return HomeAssistant(config)
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
async def close_connectors(connectors: List[Connector]):
|
||||
tasks: List[Task] = [asyncio.create_task(connector.close()) for connector in connectors]
|
||||
|
||||
if len(tasks) == 0:
|
||||
return
|
||||
|
||||
try:
|
||||
await asyncio.gather(*tasks, return_exceptions=False)
|
||||
except BaseException:
|
||||
logging.exception("received an exception while closing one of the connectors")
|
||||
|
||||
|
||||
async def run_connectors(connectors: List[Connector], space: Space, instance: REGISTERED_CLASSES, action_type: ActionType):
|
||||
tasks: List[Task] = list()
|
||||
|
||||
if isinstance(instance, ShoppingListEntry):
|
||||
shopping_list_entry: ShoppingListEntry = instance
|
||||
|
||||
match action_type:
|
||||
case ActionType.CREATED:
|
||||
for connector in connectors:
|
||||
tasks.append(asyncio.create_task(connector.on_shopping_list_entry_created(space, shopping_list_entry)))
|
||||
case ActionType.UPDATED:
|
||||
for connector in connectors:
|
||||
tasks.append(asyncio.create_task(connector.on_shopping_list_entry_updated(space, shopping_list_entry)))
|
||||
case ActionType.DELETED:
|
||||
for connector in connectors:
|
||||
tasks.append(asyncio.create_task(connector.on_shopping_list_entry_deleted(space, shopping_list_entry)))
|
||||
|
||||
if len(tasks) == 0:
|
||||
return
|
||||
|
||||
try:
|
||||
# Wait for all async tasks to finish, if one fails, the others still continue.
|
||||
await asyncio.gather(*tasks, return_exceptions=False)
|
||||
except BaseException:
|
||||
logging.exception("received an exception from one of the connectors")
|
||||
85
cookbook/connectors/homeassistant.py
Normal file
85
cookbook/connectors/homeassistant.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import logging
|
||||
from logging import Logger
|
||||
|
||||
from homeassistant_api import Client, HomeassistantAPIError, Domain
|
||||
|
||||
from cookbook.connectors.connector import Connector
|
||||
from cookbook.models import ShoppingListEntry, ConnectorConfig, Space
|
||||
|
||||
|
||||
class HomeAssistant(Connector):
|
||||
_domains_cache: dict[str, Domain]
|
||||
_config: ConnectorConfig
|
||||
_logger: Logger
|
||||
_client: Client
|
||||
|
||||
def __init__(self, config: ConnectorConfig):
|
||||
if not config.token or not config.url or not config.todo_entity:
|
||||
raise ValueError("config for HomeAssistantConnector in incomplete")
|
||||
|
||||
self._domains_cache = dict()
|
||||
self._config = config
|
||||
self._logger = logging.getLogger("connector.HomeAssistant")
|
||||
self._client = Client(self._config.url, self._config.token, async_cache_session=False, use_async=True)
|
||||
|
||||
async def on_shopping_list_entry_created(self, space: Space, shopping_list_entry: ShoppingListEntry) -> None:
|
||||
if not self._config.on_shopping_list_entry_created_enabled:
|
||||
return
|
||||
|
||||
item, description = _format_shopping_list_entry(shopping_list_entry)
|
||||
|
||||
todo_domain = self._domains_cache.get('todo')
|
||||
try:
|
||||
if todo_domain is None:
|
||||
todo_domain = await self._client.async_get_domain('todo')
|
||||
self._domains_cache['todo'] = todo_domain
|
||||
|
||||
logging.debug(f"pushing {item} to {self._config.name}")
|
||||
await todo_domain.add_item(entity_id=self._config.todo_entity, item=item)
|
||||
except HomeassistantAPIError as err:
|
||||
self._logger.warning(f"[HomeAssistant {self._config.name}] Received an exception from the api: {err=}, {type(err)=}")
|
||||
|
||||
async def on_shopping_list_entry_updated(self, space: Space, shopping_list_entry: ShoppingListEntry) -> None:
|
||||
if not self._config.on_shopping_list_entry_updated_enabled:
|
||||
return
|
||||
pass
|
||||
|
||||
async def on_shopping_list_entry_deleted(self, space: Space, shopping_list_entry: ShoppingListEntry) -> None:
|
||||
if not self._config.on_shopping_list_entry_deleted_enabled:
|
||||
return
|
||||
|
||||
item, description = _format_shopping_list_entry(shopping_list_entry)
|
||||
|
||||
todo_domain = self._domains_cache.get('todo')
|
||||
try:
|
||||
if todo_domain is None:
|
||||
todo_domain = await self._client.async_get_domain('todo')
|
||||
self._domains_cache['todo'] = todo_domain
|
||||
|
||||
logging.debug(f"deleting {item} from {self._config.name}")
|
||||
await todo_domain.remove_item(entity_id=self._config.todo_entity, item=item)
|
||||
except HomeassistantAPIError as err:
|
||||
self._logger.warning(f"[HomeAssistant {self._config.name}] Received an exception from the api: {err=}, {type(err)=}")
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.async_cache_session.close()
|
||||
|
||||
|
||||
def _format_shopping_list_entry(shopping_list_entry: ShoppingListEntry):
|
||||
item = shopping_list_entry.food.name
|
||||
if shopping_list_entry.amount > 0:
|
||||
item += f" ({shopping_list_entry.amount:.2f}".rstrip('0').rstrip('.')
|
||||
if shopping_list_entry.unit and shopping_list_entry.unit.base_unit and len(shopping_list_entry.unit.base_unit) > 0:
|
||||
item += f" {shopping_list_entry.unit.base_unit})"
|
||||
elif shopping_list_entry.unit and shopping_list_entry.unit.name and len(shopping_list_entry.unit.name) > 0:
|
||||
item += f" {shopping_list_entry.unit.name})"
|
||||
else:
|
||||
item += ")"
|
||||
|
||||
description = "Imported by TandoorRecipes"
|
||||
if shopping_list_entry.created_by.first_name and len(shopping_list_entry.created_by.first_name) > 0:
|
||||
description += f", created by {shopping_list_entry.created_by.first_name}"
|
||||
else:
|
||||
description += f", created by {shopping_list_entry.created_by.username}"
|
||||
|
||||
return item, description
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime
|
||||
|
||||
from allauth.account.forms import ResetPasswordForm, SignupForm
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -9,18 +10,19 @@ from django_scopes import scopes_disabled
|
||||
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
|
||||
from hcaptcha.fields import hCaptchaField
|
||||
|
||||
from .models import (Comment, Food, InviteLink, Keyword, MealPlan, MealType, Recipe, RecipeBook,
|
||||
RecipeBookEntry, SearchPreference, Space, Storage, Sync, User, UserPreference)
|
||||
from .models import Comment, InviteLink, Keyword, Recipe, SearchPreference, Space, Storage, Sync, User, UserPreference, ConnectorConfig
|
||||
|
||||
|
||||
class SelectWidget(widgets.Select):
|
||||
|
||||
class Media:
|
||||
js = ('custom/js/form_select.js',)
|
||||
js = ('custom/js/form_select.js', )
|
||||
|
||||
|
||||
class MultiSelectWidget(widgets.SelectMultiple):
|
||||
|
||||
class Media:
|
||||
js = ('custom/js/form_multiselect.js',)
|
||||
js = ('custom/js/form_multiselect.js', )
|
||||
|
||||
|
||||
# Yes there are some stupid browsers that still dont support this but
|
||||
@@ -33,64 +35,6 @@ class DateWidget(forms.DateInput):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
||||
class UserPreferenceForm(forms.ModelForm):
|
||||
prefix = 'preference'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['plan_share'].queryset = User.objects.filter(userspace__space=space).all()
|
||||
|
||||
class Meta:
|
||||
model = UserPreference
|
||||
fields = (
|
||||
'default_unit', 'use_fractions', 'use_kj', 'theme', 'nav_color',
|
||||
'sticky_navbar', 'default_page', 'plan_share', 'ingredient_decimals', 'comments', 'left_handed',
|
||||
)
|
||||
|
||||
labels = {
|
||||
'default_unit': _('Default unit'),
|
||||
'use_fractions': _('Use fractions'),
|
||||
'use_kj': _('Use KJ'),
|
||||
'theme': _('Theme'),
|
||||
'nav_color': _('Navbar color'),
|
||||
'sticky_navbar': _('Sticky navbar'),
|
||||
'default_page': _('Default page'),
|
||||
'plan_share': _('Plan sharing'),
|
||||
'ingredient_decimals': _('Ingredient decimal places'),
|
||||
'shopping_auto_sync': _('Shopping list auto sync period'),
|
||||
'comments': _('Comments'),
|
||||
'left_handed': _('Left-handed mode')
|
||||
}
|
||||
|
||||
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.'),
|
||||
'use_fractions': _(
|
||||
'Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'),
|
||||
|
||||
'use_kj': _('Display nutritional energy amounts in joules instead of calories'),
|
||||
'plan_share': _('Users with whom newly created meal plans should be shared by default.'),
|
||||
'shopping_share': _('Users with whom to share shopping lists.'),
|
||||
'ingredient_decimals': _('Number of decimals to round ingredients.'),
|
||||
'comments': _('If you want to be able to create and see comments underneath recipes.'),
|
||||
'shopping_auto_sync': _(
|
||||
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit '
|
||||
'of mobile data. If lower than instance limit it is reset when saving.'
|
||||
),
|
||||
'sticky_navbar': _('Makes the navbar stick to the top of the page.'),
|
||||
'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'),
|
||||
'mealplan_autoexclude_onhand': _('Exclude ingredients that are on hand.'),
|
||||
'left_handed': _('Will optimize the UI for use with your left hand.')
|
||||
}
|
||||
|
||||
widgets = {
|
||||
'plan_share': MultiSelectWidget,
|
||||
'shopping_share': MultiSelectWidget,
|
||||
}
|
||||
|
||||
|
||||
class UserNameForm(forms.ModelForm):
|
||||
prefix = 'name'
|
||||
|
||||
@@ -98,9 +42,7 @@ class UserNameForm(forms.ModelForm):
|
||||
model = User
|
||||
fields = ('first_name', 'last_name')
|
||||
|
||||
help_texts = {
|
||||
'first_name': _('Both fields are optional. If none are given the username will be displayed instead')
|
||||
}
|
||||
help_texts = {'first_name': _('Both fields are optional. If none are given the username will be displayed instead')}
|
||||
|
||||
|
||||
class ExternalRecipeForm(forms.ModelForm):
|
||||
@@ -114,23 +56,14 @@ class ExternalRecipeForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = (
|
||||
'name', 'description', 'servings', 'working_time', 'waiting_time',
|
||||
'file_path', 'file_uid', 'keywords'
|
||||
)
|
||||
fields = ('name', 'description', 'servings', 'working_time', 'waiting_time', 'file_path', 'file_uid', 'keywords')
|
||||
|
||||
labels = {
|
||||
'name': _('Name'),
|
||||
'keywords': _('Keywords'),
|
||||
'working_time': _('Preparation time in minutes'),
|
||||
'waiting_time': _('Waiting time (cooking/baking) in minutes'),
|
||||
'file_path': _('Path'),
|
||||
'file_uid': _('Storage UID'),
|
||||
'name': _('Name'), 'keywords': _('Keywords'), 'working_time': _('Preparation time in minutes'), 'waiting_time': _('Waiting time (cooking/baking) in minutes'),
|
||||
'file_path': _('Path'), 'file_uid': _('Storage UID'),
|
||||
}
|
||||
widgets = {'keywords': MultiSelectWidget}
|
||||
field_classes = {
|
||||
'keywords': SafeModelMultipleChoiceField,
|
||||
}
|
||||
field_classes = {'keywords': SafeModelMultipleChoiceField, }
|
||||
|
||||
|
||||
class ImportExportBase(forms.Form):
|
||||
@@ -157,14 +90,11 @@ class ImportExportBase(forms.Form):
|
||||
REZEPTSUITEDE = 'REZEPTSUITEDE'
|
||||
PDF = 'PDF'
|
||||
|
||||
type = forms.ChoiceField(choices=(
|
||||
(DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'),
|
||||
(MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'), (SAFFRON, 'Saffron'), (CHEFTAP, 'ChefTap'),
|
||||
(PEPPERPLATE, 'Pepperplate'), (RECETTETEK, 'RecetteTek'), (RECIPESAGE, 'Recipe Sage'), (DOMESTICA, 'Domestica'),
|
||||
(MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'), (OPENEATS, 'Openeats'), (RECIPEKEEPER, 'Recipe Keeper'),
|
||||
(PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'), (COPYMETHAT, 'CopyMeThat'), (PDF, 'PDF'), (MELARECIPES, 'Melarecipes'),
|
||||
(COOKMATE, 'Cookmate'), (REZEPTSUITEDE, 'Recipesuite.de')
|
||||
))
|
||||
type = forms.ChoiceField(choices=((DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'), (MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'),
|
||||
(SAFFRON, 'Saffron'), (CHEFTAP, 'ChefTap'), (PEPPERPLATE, 'Pepperplate'), (RECETTETEK, 'RecetteTek'), (RECIPESAGE, 'Recipe Sage'),
|
||||
(DOMESTICA, 'Domestica'), (MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'), (OPENEATS, 'Openeats'), (RECIPEKEEPER, 'Recipe Keeper'),
|
||||
(PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'), (COPYMETHAT, 'CopyMeThat'), (PDF, 'PDF'), (MELARECIPES, 'Melarecipes'),
|
||||
(COOKMATE, 'Cookmate'), (REZEPTSUITEDE, 'Recipesuite.de')))
|
||||
|
||||
|
||||
class MultipleFileInput(forms.ClearableFileInput):
|
||||
@@ -172,6 +102,7 @@ class MultipleFileInput(forms.ClearableFileInput):
|
||||
|
||||
|
||||
class MultipleFileField(forms.FileField):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("widget", MultipleFileInput())
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -184,11 +115,11 @@ class MultipleFileField(forms.FileField):
|
||||
result = single_file_clean(data, initial)
|
||||
return result
|
||||
|
||||
|
||||
class ImportForm(ImportExportBase):
|
||||
files = MultipleFileField(required=True)
|
||||
duplicates = forms.BooleanField(help_text=_(
|
||||
'To prevent duplicates recipes with the same name as existing ones are ignored. Check this box to import everything.'),
|
||||
required=False)
|
||||
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 ExportForm(ImportExportBase):
|
||||
@@ -207,59 +138,71 @@ class CommentForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Comment
|
||||
fields = ('text',)
|
||||
fields = ('text', )
|
||||
|
||||
labels = {
|
||||
'text': _('Add your comment: '),
|
||||
}
|
||||
widgets = {
|
||||
'text': forms.Textarea(attrs={'rows': 2, 'cols': 15}),
|
||||
}
|
||||
labels = {'text': _('Add your comment: '), }
|
||||
widgets = {'text': forms.Textarea(attrs={'rows': 2, 'cols': 15}), }
|
||||
|
||||
|
||||
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', 'path')
|
||||
|
||||
help_texts = {
|
||||
'url': _(
|
||||
'Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'),
|
||||
}
|
||||
help_texts = {'url': _('Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'), }
|
||||
|
||||
|
||||
# TODO: Deprecate
|
||||
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 ConnectorConfigForm(forms.ModelForm):
|
||||
enabled = forms.BooleanField(
|
||||
help_text="Is the connector enabled",
|
||||
required=False,
|
||||
)
|
||||
|
||||
on_shopping_list_entry_created_enabled = forms.BooleanField(
|
||||
help_text="Enable action for ShoppingListEntry created events",
|
||||
required=False,
|
||||
)
|
||||
|
||||
on_shopping_list_entry_updated_enabled = forms.BooleanField(
|
||||
help_text="Enable action for ShoppingListEntry updated events",
|
||||
required=False,
|
||||
)
|
||||
|
||||
on_shopping_list_entry_deleted_enabled = forms.BooleanField(
|
||||
help_text="Enable action for ShoppingListEntry deleted events",
|
||||
required=False,
|
||||
)
|
||||
|
||||
update_token = forms.CharField(
|
||||
widget=forms.TextInput(attrs={'autocomplete': 'update-token', 'type': 'password'}),
|
||||
required=False,
|
||||
help_text=_('<a href="https://www.home-assistant.io/docs/authentication/#your-account-profile">Long Lived Access Token</a> for your HomeAssistant instance')
|
||||
)
|
||||
|
||||
url = forms.URLField(
|
||||
required=False,
|
||||
help_text=_('Something like http://homeassistant.local:8123/api'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RecipeBookEntry
|
||||
fields = ('book',)
|
||||
model = ConnectorConfig
|
||||
|
||||
field_classes = {
|
||||
'book': SafeModelChoiceField,
|
||||
fields = (
|
||||
'name', 'type', 'enabled', 'on_shopping_list_entry_created_enabled', 'on_shopping_list_entry_updated_enabled',
|
||||
'on_shopping_list_entry_deleted_enabled', 'url', 'todo_entity',
|
||||
)
|
||||
|
||||
help_texts = {
|
||||
'url': _('http://homeassistant.local:8123/api for example'),
|
||||
}
|
||||
|
||||
|
||||
@@ -274,25 +217,14 @@ class SyncForm(forms.ModelForm):
|
||||
model = Sync
|
||||
fields = ('storage', 'path', 'active')
|
||||
|
||||
field_classes = {
|
||||
'storage': SafeModelChoiceField,
|
||||
}
|
||||
field_classes = {'storage': SafeModelChoiceField, }
|
||||
|
||||
labels = {
|
||||
'storage': _('Storage'),
|
||||
'path': _('Path'),
|
||||
'active': _('Active')
|
||||
}
|
||||
labels = {'storage': _('Storage'), 'path': _('Path'), 'active': _('Active')}
|
||||
|
||||
|
||||
# TODO deprecate
|
||||
class BatchEditForm(forms.Form):
|
||||
search = forms.CharField(label=_('Search String'))
|
||||
keywords = forms.ModelMultipleChoiceField(
|
||||
queryset=Keyword.objects.none(),
|
||||
required=False,
|
||||
widget=MultiSelectWidget
|
||||
)
|
||||
keywords = forms.ModelMultipleChoiceField(queryset=Keyword.objects.none(), required=False, widget=MultiSelectWidget)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
@@ -301,6 +233,7 @@ class BatchEditForm(forms.Form):
|
||||
|
||||
|
||||
class ImportRecipeForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -310,63 +243,13 @@ class ImportRecipeForm(forms.ModelForm):
|
||||
model = Recipe
|
||||
fields = ('name', 'keywords', 'file_path', 'file_uid')
|
||||
|
||||
labels = {
|
||||
'name': _('Name'),
|
||||
'keywords': _('Keywords'),
|
||||
'file_path': _('Path'),
|
||||
'file_uid': _('File ID'),
|
||||
}
|
||||
labels = {'name': _('Name'), 'keywords': _('Keywords'), 'file_path': _('Path'), 'file_uid': _('File ID'), }
|
||||
widgets = {'keywords': MultiSelectWidget}
|
||||
field_classes = {
|
||||
'keywords': SafeModelChoiceField,
|
||||
}
|
||||
|
||||
|
||||
# TODO deprecate
|
||||
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.')
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
class Meta:
|
||||
model = MealPlan
|
||||
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>')
|
||||
|
||||
}
|
||||
|
||||
widgets = {
|
||||
'recipe': SelectWidget,
|
||||
'date': DateWidget,
|
||||
'shared': MultiSelectWidget
|
||||
}
|
||||
field_classes = {
|
||||
'recipe': SafeModelChoiceField,
|
||||
'meal_type': SafeModelChoiceField,
|
||||
'shared': SafeModelMultipleChoiceField,
|
||||
}
|
||||
field_classes = {'keywords': SafeModelChoiceField, }
|
||||
|
||||
|
||||
class InviteLinkForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -374,8 +257,8 @@ class InviteLinkForm(forms.ModelForm):
|
||||
|
||||
def clean(self):
|
||||
space = self.cleaned_data['space']
|
||||
if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() +
|
||||
InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=space).count()) >= space.max_users:
|
||||
if space.max_users != 0 and (UserPreference.objects.filter(space=space).count()
|
||||
+ InviteLink.objects.filter(valid_until__gte=datetime.today(), used_by=None, space=space).count()) >= space.max_users:
|
||||
raise ValidationError(_('Maximum number of users for this space reached.'))
|
||||
|
||||
def clean_email(self):
|
||||
@@ -389,12 +272,8 @@ class InviteLinkForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = InviteLink
|
||||
fields = ('email', 'group', 'valid_until', 'space')
|
||||
help_texts = {
|
||||
'email': _('An email address is not required but if present the invite link will be sent to the user.'),
|
||||
}
|
||||
field_classes = {
|
||||
'space': SafeModelChoiceField,
|
||||
}
|
||||
help_texts = {'email': _('An email address is not required but if present the invite link will be sent to the user.'), }
|
||||
field_classes = {'space': SafeModelChoiceField, }
|
||||
|
||||
|
||||
class SpaceCreateForm(forms.Form):
|
||||
@@ -414,12 +293,12 @@ class SpaceJoinForm(forms.Form):
|
||||
token = forms.CharField()
|
||||
|
||||
|
||||
class AllAuthSignupForm(forms.Form):
|
||||
class AllAuthSignupForm(SignupForm):
|
||||
captcha = hCaptchaField()
|
||||
terms = forms.BooleanField(label=_('Accept Terms and Privacy'))
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(AllAuthSignupForm, self).__init__(**kwargs)
|
||||
super().__init__(**kwargs)
|
||||
if settings.PRIVACY_URL == '' and settings.TERMS_URL == '':
|
||||
self.fields.pop('terms')
|
||||
if settings.HCAPTCHA_SECRET == '':
|
||||
@@ -429,136 +308,50 @@ class AllAuthSignupForm(forms.Form):
|
||||
pass
|
||||
|
||||
|
||||
class CustomPasswordResetForm(ResetPasswordForm):
|
||||
captcha = hCaptchaField()
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(CustomPasswordResetForm, self).__init__(**kwargs)
|
||||
if settings.HCAPTCHA_SECRET == '':
|
||||
self.fields.pop('captcha')
|
||||
|
||||
|
||||
class UserCreateForm(forms.Form):
|
||||
name = forms.CharField(label='Username')
|
||||
password = forms.CharField(
|
||||
widget=forms.TextInput(
|
||||
attrs={'autocomplete': 'new-password', 'type': 'password'}
|
||||
)
|
||||
)
|
||||
password_confirm = forms.CharField(
|
||||
widget=forms.TextInput(
|
||||
attrs={'autocomplete': 'new-password', 'type': 'password'}
|
||||
)
|
||||
)
|
||||
password = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}))
|
||||
password_confirm = forms.CharField(widget=forms.TextInput(attrs={'autocomplete': 'new-password', 'type': 'password'}))
|
||||
|
||||
|
||||
class SearchPreferenceForm(forms.ModelForm):
|
||||
prefix = 'search'
|
||||
trigram_threshold = forms.DecimalField(min_value=0.01, max_value=1, decimal_places=2,
|
||||
trigram_threshold = forms.DecimalField(min_value=0.01,
|
||||
max_value=1,
|
||||
decimal_places=2,
|
||||
widget=NumberInput(attrs={'class': "form-control-range", 'type': 'range'}),
|
||||
help_text=_(
|
||||
'Determines how fuzzy a search is if it uses trigram similarity matching (e.g. low values mean more typos are ignored).'))
|
||||
help_text=_('Determines how fuzzy a search is if it uses trigram similarity matching (e.g. low values mean more typos are ignored).'))
|
||||
preset = forms.CharField(widget=forms.HiddenInput(), required=False)
|
||||
|
||||
class Meta:
|
||||
model = SearchPreference
|
||||
fields = (
|
||||
'search', 'lookup', 'unaccent', 'icontains', 'istartswith', 'trigram', 'fulltext', 'trigram_threshold')
|
||||
fields = ('search', 'lookup', 'unaccent', 'icontains', 'istartswith', 'trigram', 'fulltext', 'trigram_threshold')
|
||||
|
||||
help_texts = {
|
||||
'search': _(
|
||||
'Select type method of search. Click <a href="/docs/search/">here</a> for full description of choices.'),
|
||||
'lookup': _('Use fuzzy matching on units, keywords and ingredients when editing and importing recipes.'),
|
||||
'unaccent': _(
|
||||
'Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'),
|
||||
'icontains': _(
|
||||
"Fields to search for partial matches. (e.g. searching for 'Pie' will return 'pie' and 'piece' and 'soapie')"),
|
||||
'istartswith': _(
|
||||
"Fields to search for beginning of word matches. (e.g. searching for 'sa' will return 'salad' and 'sandwich')"),
|
||||
'trigram': _(
|
||||
"Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) Note: this option will conflict with 'web' and 'raw' methods of search."),
|
||||
'fulltext': _(
|
||||
"Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods only function with fulltext fields."),
|
||||
'search': _('Select type method of search. Click <a href="/docs/search/">here</a> for full description of choices.'), 'lookup':
|
||||
_('Use fuzzy matching on units, keywords and ingredients when editing and importing recipes.'), 'unaccent':
|
||||
_('Fields to search ignoring accents. Selecting this option can improve or degrade search quality depending on language'), 'icontains':
|
||||
_("Fields to search for partial matches. (e.g. searching for 'Pie' will return 'pie' and 'piece' and 'soapie')"), 'istartswith':
|
||||
_("Fields to search for beginning of word matches. (e.g. searching for 'sa' will return 'salad' and 'sandwich')"), 'trigram':
|
||||
_("Fields to 'fuzzy' search. (e.g. searching for 'recpie' will find 'recipe'.) Note: this option will conflict with 'web' and 'raw' methods of search."), 'fulltext':
|
||||
_("Fields to full text search. Note: 'web', 'phrase', and 'raw' search methods only function with fulltext fields."),
|
||||
}
|
||||
|
||||
labels = {
|
||||
'search': _('Search Method'),
|
||||
'lookup': _('Fuzzy Lookups'),
|
||||
'unaccent': _('Ignore Accent'),
|
||||
'icontains': _("Partial Match"),
|
||||
'istartswith': _("Starts With"),
|
||||
'trigram': _("Fuzzy Search"),
|
||||
'fulltext': _("Full Text")
|
||||
'search': _('Search Method'), 'lookup': _('Fuzzy Lookups'), 'unaccent': _('Ignore Accent'), 'icontains': _("Partial Match"), 'istartswith': _("Starts With"),
|
||||
'trigram': _("Fuzzy Search"), 'fulltext': _("Full Text")
|
||||
}
|
||||
|
||||
widgets = {
|
||||
'search': SelectWidget,
|
||||
'unaccent': MultiSelectWidget,
|
||||
'icontains': MultiSelectWidget,
|
||||
'istartswith': MultiSelectWidget,
|
||||
'trigram': MultiSelectWidget,
|
||||
'fulltext': MultiSelectWidget,
|
||||
}
|
||||
|
||||
|
||||
class ShoppingPreferenceForm(forms.ModelForm):
|
||||
prefix = 'shopping'
|
||||
|
||||
class Meta:
|
||||
model = UserPreference
|
||||
|
||||
fields = (
|
||||
'shopping_share', 'shopping_auto_sync', 'mealplan_autoadd_shopping', 'mealplan_autoexclude_onhand',
|
||||
'mealplan_autoinclude_related', 'shopping_add_onhand', 'default_delay', 'filter_to_supermarket', 'shopping_recent_days', 'csv_delim', 'csv_prefix'
|
||||
)
|
||||
|
||||
help_texts = {
|
||||
'shopping_share': _('Users will see all items you add to your shopping list. They must add you to see items on their list.'),
|
||||
'shopping_auto_sync': _(
|
||||
'Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but might use a little bit '
|
||||
'of mobile data. If lower than instance limit it is reset when saving.'
|
||||
),
|
||||
'mealplan_autoadd_shopping': _('Automatically add meal plan ingredients to shopping list.'),
|
||||
'mealplan_autoinclude_related': _('When adding a meal plan to the shopping list (manually or automatically), include all related recipes.'),
|
||||
'mealplan_autoexclude_onhand': _('When adding a meal plan to the shopping list (manually or automatically), exclude ingredients that are on hand.'),
|
||||
'default_delay': _('Default number of hours to delay a shopping list entry.'),
|
||||
'filter_to_supermarket': _('Filter shopping list to only include supermarket categories.'),
|
||||
'shopping_recent_days': _('Days of recent shopping list entries to display.'),
|
||||
'shopping_add_onhand': _("Mark food 'On Hand' when checked off shopping list."),
|
||||
'csv_delim': _('Delimiter to use for CSV exports.'),
|
||||
'csv_prefix': _('Prefix to add when copying list to the clipboard.'),
|
||||
|
||||
}
|
||||
labels = {
|
||||
'shopping_share': _('Share Shopping List'),
|
||||
'shopping_auto_sync': _('Autosync'),
|
||||
'mealplan_autoadd_shopping': _('Auto Add Meal Plan'),
|
||||
'mealplan_autoexclude_onhand': _('Exclude On Hand'),
|
||||
'mealplan_autoinclude_related': _('Include Related'),
|
||||
'default_delay': _('Default Delay Hours'),
|
||||
'filter_to_supermarket': _('Filter to Supermarket'),
|
||||
'shopping_recent_days': _('Recent Days'),
|
||||
'csv_delim': _('CSV Delimiter'),
|
||||
"csv_prefix_label": _("List Prefix"),
|
||||
'shopping_add_onhand': _("Auto On Hand"),
|
||||
}
|
||||
|
||||
widgets = {
|
||||
'shopping_share': MultiSelectWidget
|
||||
}
|
||||
|
||||
|
||||
class SpacePreferenceForm(forms.ModelForm):
|
||||
prefix = 'space'
|
||||
reset_food_inherit = forms.BooleanField(label=_("Reset Food Inheritance"), initial=False, required=False,
|
||||
help_text=_("Reset all food to inherit the fields configured."))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs) # populates the post
|
||||
self.fields['food_inherit'].queryset = Food.inheritable_fields
|
||||
|
||||
class Meta:
|
||||
model = Space
|
||||
|
||||
fields = ('food_inherit', 'reset_food_inherit', 'show_facet_count', 'use_plural')
|
||||
|
||||
help_texts = {
|
||||
'food_inherit': _('Fields on food that should be inherited by default.'),
|
||||
'show_facet_count': _('Show recipe counts on search filters'),
|
||||
'use_plural': _('Use the plural form for units and food inside this space.'),
|
||||
}
|
||||
|
||||
widgets = {
|
||||
'food_inherit': MultiSelectWidget
|
||||
'search': SelectWidget, 'unaccent': MultiSelectWidget, 'icontains': MultiSelectWidget, 'istartswith': MultiSelectWidget, 'trigram': MultiSelectWidget, 'fulltext':
|
||||
MultiSelectWidget,
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from gettext import gettext as _
|
||||
|
||||
from allauth.account.adapter import DefaultAccountAdapter
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.cache import caches
|
||||
from gettext import gettext as _
|
||||
|
||||
from cookbook.models import InviteLink
|
||||
|
||||
@@ -17,10 +16,13 @@ class AllAuthCustomAdapter(DefaultAccountAdapter):
|
||||
Whether to allow sign-ups.
|
||||
"""
|
||||
signup_token = False
|
||||
if 'signup_token' in request.session and InviteLink.objects.filter(valid_until__gte=datetime.datetime.today(), used_by=None, uuid=request.session['signup_token']).exists():
|
||||
if 'signup_token' in request.session and InviteLink.objects.filter(
|
||||
valid_until__gte=datetime.datetime.today(), used_by=None, uuid=request.session['signup_token']).exists():
|
||||
signup_token = True
|
||||
|
||||
if (request.resolver_match.view_name == 'account_signup' or request.resolver_match.view_name == 'socialaccount_signup') and not settings.ENABLE_SIGNUP and not signup_token:
|
||||
if request.resolver_match.view_name == 'account_signup' and not settings.ENABLE_SIGNUP and not signup_token:
|
||||
return False
|
||||
elif request.resolver_match.view_name == 'socialaccount_signup' and len(settings.SOCIAL_PROVIDERS) < 1:
|
||||
return False
|
||||
else:
|
||||
return super(AllAuthCustomAdapter, self).is_open_for_signup(request)
|
||||
@@ -33,7 +35,7 @@ class AllAuthCustomAdapter(DefaultAccountAdapter):
|
||||
if c == default:
|
||||
try:
|
||||
super(AllAuthCustomAdapter, self).send_mail(template_prefix, email, context)
|
||||
except Exception: # dont fail signup just because confirmation mail could not be send
|
||||
except Exception: # dont fail signup just because confirmation mail could not be send
|
||||
pass
|
||||
else:
|
||||
messages.add_message(self.request, messages.ERROR, _('In order to prevent spam, the requested email was not send. Please wait a few minutes and try again.'))
|
||||
|
||||
@@ -7,7 +7,7 @@ class Round(Func):
|
||||
|
||||
|
||||
def str2bool(v):
|
||||
if type(v) == bool or v is None:
|
||||
if isinstance(v, bool) or v is None:
|
||||
return v
|
||||
else:
|
||||
return v.lower() in ("yes", "true", "1")
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import cookbook.helper.dal
|
||||
from cookbook.helper.AllAuthCustomAdapter import AllAuthCustomAdapter
|
||||
|
||||
__all__ = [
|
||||
'dal',
|
||||
]
|
||||
|
||||
227
cookbook/helper/automation_helper.py
Normal file
227
cookbook/helper/automation_helper.py
Normal file
@@ -0,0 +1,227 @@
|
||||
import re
|
||||
|
||||
from django.core.cache import caches
|
||||
from django.db.models.functions import Lower
|
||||
|
||||
from cookbook.models import Automation
|
||||
|
||||
|
||||
class AutomationEngine:
|
||||
request = None
|
||||
source = None
|
||||
use_cache = None
|
||||
food_aliases = None
|
||||
keyword_aliases = None
|
||||
unit_aliases = None
|
||||
never_unit = None
|
||||
transpose_words = None
|
||||
regex_replace = {
|
||||
Automation.DESCRIPTION_REPLACE: None,
|
||||
Automation.INSTRUCTION_REPLACE: None,
|
||||
Automation.FOOD_REPLACE: None,
|
||||
Automation.UNIT_REPLACE: None,
|
||||
Automation.NAME_REPLACE: None,
|
||||
}
|
||||
|
||||
def __init__(self, request, use_cache=True, source=None):
|
||||
self.request = request
|
||||
self.use_cache = use_cache
|
||||
if not source:
|
||||
self.source = "default_string_to_avoid_false_regex_match"
|
||||
else:
|
||||
self.source = source
|
||||
|
||||
def apply_keyword_automation(self, keyword):
|
||||
keyword = keyword.strip()
|
||||
if self.use_cache and self.keyword_aliases is None:
|
||||
self.keyword_aliases = {}
|
||||
KEYWORD_CACHE_KEY = f'automation_keyword_alias_{self.request.space.pk}'
|
||||
if c := caches['default'].get(KEYWORD_CACHE_KEY, None):
|
||||
self.keyword_aliases = c
|
||||
caches['default'].touch(KEYWORD_CACHE_KEY, 30)
|
||||
else:
|
||||
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.KEYWORD_ALIAS).only('param_1', 'param_2').order_by('order').all():
|
||||
self.keyword_aliases[a.param_1.lower()] = a.param_2
|
||||
caches['default'].set(KEYWORD_CACHE_KEY, self.keyword_aliases, 30)
|
||||
else:
|
||||
self.keyword_aliases = {}
|
||||
if self.keyword_aliases:
|
||||
try:
|
||||
keyword = self.keyword_aliases[keyword.lower()]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
if automation := Automation.objects.filter(space=self.request.space, type=Automation.KEYWORD_ALIAS, param_1__iexact=keyword, disabled=False).order_by('order').first():
|
||||
return automation.param_2
|
||||
return keyword
|
||||
|
||||
def apply_unit_automation(self, unit):
|
||||
unit = unit.strip()
|
||||
if self.use_cache and self.unit_aliases is None:
|
||||
self.unit_aliases = {}
|
||||
UNIT_CACHE_KEY = f'automation_unit_alias_{self.request.space.pk}'
|
||||
if c := caches['default'].get(UNIT_CACHE_KEY, None):
|
||||
self.unit_aliases = c
|
||||
caches['default'].touch(UNIT_CACHE_KEY, 30)
|
||||
else:
|
||||
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.UNIT_ALIAS).only('param_1', 'param_2').order_by('order').all():
|
||||
self.unit_aliases[a.param_1.lower()] = a.param_2
|
||||
caches['default'].set(UNIT_CACHE_KEY, self.unit_aliases, 30)
|
||||
else:
|
||||
self.unit_aliases = {}
|
||||
if self.unit_aliases:
|
||||
try:
|
||||
unit = self.unit_aliases[unit.lower()]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
if automation := Automation.objects.filter(space=self.request.space, type=Automation.UNIT_ALIAS, param_1__iexact=unit, disabled=False).order_by('order').first():
|
||||
return automation.param_2
|
||||
return self.apply_regex_replace_automation(unit, Automation.UNIT_REPLACE)
|
||||
|
||||
def apply_food_automation(self, food):
|
||||
food = food.strip()
|
||||
if self.use_cache and self.food_aliases is None:
|
||||
self.food_aliases = {}
|
||||
FOOD_CACHE_KEY = f'automation_food_alias_{self.request.space.pk}'
|
||||
if c := caches['default'].get(FOOD_CACHE_KEY, None):
|
||||
self.food_aliases = c
|
||||
caches['default'].touch(FOOD_CACHE_KEY, 30)
|
||||
else:
|
||||
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.FOOD_ALIAS).only('param_1', 'param_2').order_by('order').all():
|
||||
self.food_aliases[a.param_1.lower()] = a.param_2
|
||||
caches['default'].set(FOOD_CACHE_KEY, self.food_aliases, 30)
|
||||
else:
|
||||
self.food_aliases = {}
|
||||
|
||||
if self.food_aliases:
|
||||
try:
|
||||
return self.food_aliases[food.lower()]
|
||||
except KeyError:
|
||||
return self.apply_regex_replace_automation(food, Automation.FOOD_REPLACE)
|
||||
else:
|
||||
if automation := Automation.objects.filter(space=self.request.space, type=Automation.FOOD_ALIAS, param_1__iexact=food, disabled=False).order_by('order').first():
|
||||
return automation.param_2
|
||||
return self.apply_regex_replace_automation(food, Automation.FOOD_REPLACE)
|
||||
|
||||
def apply_never_unit_automation(self, tokens):
|
||||
"""
|
||||
Moves a string that should never be treated as a unit to next token and optionally replaced with default unit
|
||||
e.g. NEVER_UNIT: param1: egg, param2: None would modify ['1', 'egg', 'white'] to ['1', '', 'egg', 'white']
|
||||
or NEVER_UNIT: param1: egg, param2: pcs would modify ['1', 'egg', 'yolk'] to ['1', 'pcs', 'egg', 'yolk']
|
||||
:param1 string: string that should never be considered a unit, will be moved to token[2]
|
||||
:param2 (optional) unit as string: will insert unit string into token[1]
|
||||
:return: unit as string (possibly changed by automation)
|
||||
"""
|
||||
|
||||
if self.use_cache and self.never_unit is None:
|
||||
self.never_unit = {}
|
||||
NEVER_UNIT_CACHE_KEY = f'automation_never_unit_{self.request.space.pk}'
|
||||
if c := caches['default'].get(NEVER_UNIT_CACHE_KEY, None):
|
||||
self.never_unit = c
|
||||
caches['default'].touch(NEVER_UNIT_CACHE_KEY, 30)
|
||||
else:
|
||||
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.NEVER_UNIT).only('param_1', 'param_2').order_by('order').all():
|
||||
self.never_unit[a.param_1.lower()] = a.param_2
|
||||
caches['default'].set(NEVER_UNIT_CACHE_KEY, self.never_unit, 30)
|
||||
else:
|
||||
self.never_unit = {}
|
||||
|
||||
new_unit = None
|
||||
alt_unit = self.apply_unit_automation(tokens[1])
|
||||
never_unit = False
|
||||
if self.never_unit:
|
||||
try:
|
||||
new_unit = self.never_unit[tokens[1].lower()]
|
||||
never_unit = True
|
||||
except KeyError:
|
||||
return tokens
|
||||
else:
|
||||
if a := Automation.objects.annotate(param_1_lower=Lower('param_1')).filter(space=self.request.space, type=Automation.NEVER_UNIT, param_1_lower__in=[
|
||||
tokens[1].lower(), alt_unit.lower()], disabled=False).order_by('order').first():
|
||||
new_unit = a.param_2
|
||||
never_unit = True
|
||||
|
||||
if never_unit:
|
||||
tokens.insert(1, new_unit)
|
||||
return tokens
|
||||
|
||||
def apply_transpose_automation(self, string):
|
||||
"""
|
||||
If two words (param_1 & param_2) are detected in sequence, swap their position in the ingredient string
|
||||
:param 1: first word to detect
|
||||
:param 2: second word to detect
|
||||
return: new ingredient string
|
||||
"""
|
||||
if self.use_cache and self.transpose_words is None:
|
||||
self.transpose_words = {}
|
||||
TRANSPOSE_WORDS_CACHE_KEY = f'automation_transpose_words_{self.request.space.pk}'
|
||||
if c := caches['default'].get(TRANSPOSE_WORDS_CACHE_KEY, None):
|
||||
self.transpose_words = c
|
||||
caches['default'].touch(TRANSPOSE_WORDS_CACHE_KEY, 30)
|
||||
else:
|
||||
i = 0
|
||||
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.TRANSPOSE_WORDS).only(
|
||||
'param_1', 'param_2').order_by('order').all()[:512]:
|
||||
self.transpose_words[i] = [a.param_1.lower(), a.param_2.lower()]
|
||||
i += 1
|
||||
caches['default'].set(TRANSPOSE_WORDS_CACHE_KEY, self.transpose_words, 30)
|
||||
else:
|
||||
self.transpose_words = {}
|
||||
|
||||
tokens = [x.lower() for x in string.replace(',', ' ').split()]
|
||||
if self.transpose_words:
|
||||
for key, value in self.transpose_words.items():
|
||||
if value[0] in tokens and value[1] in tokens:
|
||||
string = re.sub(rf"\b({value[0]})\W*({value[1]})\b", r"\2 \1", string, flags=re.IGNORECASE)
|
||||
else:
|
||||
for rule in Automation.objects.filter(space=self.request.space, type=Automation.TRANSPOSE_WORDS, disabled=False) \
|
||||
.annotate(param_1_lower=Lower('param_1'), param_2_lower=Lower('param_2')) \
|
||||
.filter(param_1_lower__in=tokens, param_2_lower__in=tokens).order_by('order')[:512]:
|
||||
if rule.param_1 in tokens and rule.param_2 in tokens:
|
||||
string = re.sub(rf"\b({rule.param_1})\W*({rule.param_2})\b", r"\2 \1", string, flags=re.IGNORECASE)
|
||||
return string
|
||||
|
||||
def apply_regex_replace_automation(self, string, automation_type):
|
||||
# TODO add warning - maybe on SPACE page? when a max of 512 automations of a specific type is exceeded (ALIAS types excluded?)
|
||||
"""
|
||||
Replaces strings in a recipe field that are from a matched source
|
||||
field_type are Automation.type that apply regex replacements
|
||||
Automation.DESCRIPTION_REPLACE
|
||||
Automation.INSTRUCTION_REPLACE
|
||||
Automation.FOOD_REPLACE
|
||||
Automation.UNIT_REPLACE
|
||||
Automation.NAME_REPLACE
|
||||
|
||||
regex replacment utilized the following fields from the Automation model
|
||||
:param 1: source that should apply the automation in regex format ('.*' for all)
|
||||
:param 2: regex pattern to match ()
|
||||
:param 3: replacement string (leave blank to delete)
|
||||
return: new string
|
||||
"""
|
||||
if self.use_cache and self.regex_replace[automation_type] is None:
|
||||
self.regex_replace[automation_type] = {}
|
||||
REGEX_REPLACE_CACHE_KEY = f'automation_regex_replace_{self.request.space.pk}'
|
||||
if c := caches['default'].get(REGEX_REPLACE_CACHE_KEY, None):
|
||||
self.regex_replace[automation_type] = c[automation_type]
|
||||
caches['default'].touch(REGEX_REPLACE_CACHE_KEY, 30)
|
||||
else:
|
||||
i = 0
|
||||
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=automation_type).only(
|
||||
'param_1', 'param_2', 'param_3').order_by('order').all()[:512]:
|
||||
self.regex_replace[automation_type][i] = [a.param_1, a.param_2, a.param_3]
|
||||
i += 1
|
||||
caches['default'].set(REGEX_REPLACE_CACHE_KEY, self.regex_replace, 30)
|
||||
else:
|
||||
self.regex_replace[automation_type] = {}
|
||||
|
||||
if self.regex_replace[automation_type]:
|
||||
for rule in self.regex_replace[automation_type].values():
|
||||
if re.match(rule[0], (self.source)[:512]):
|
||||
string = re.sub(rule[1], rule[2], string, flags=re.IGNORECASE)
|
||||
else:
|
||||
for rule in Automation.objects.filter(space=self.request.space, disabled=False, type=automation_type).only(
|
||||
'param_1', 'param_2', 'param_3').order_by('order').all()[:512]:
|
||||
if re.match(rule.param_1, (self.source)[:512]):
|
||||
string = re.sub(rule.param_2, rule.param_3, string, flags=re.IGNORECASE)
|
||||
return string
|
||||
@@ -11,4 +11,5 @@ def context_settings(request):
|
||||
'PRIVACY_URL': settings.PRIVACY_URL,
|
||||
'IMPRINT_URL': settings.IMPRINT_URL,
|
||||
'SHOPPING_MIN_AUTOSYNC_INTERVAL': settings.SHOPPING_MIN_AUTOSYNC_INTERVAL,
|
||||
'DISABLE_EXTERNAL_CONNECTORS': settings.DISABLE_EXTERNAL_CONNECTORS,
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
from cookbook.models import Food, Keyword, Recipe, Unit
|
||||
|
||||
from dal import autocomplete
|
||||
|
||||
|
||||
class BaseAutocomplete(autocomplete.Select2QuerySetView):
|
||||
model = None
|
||||
|
||||
def get_queryset(self):
|
||||
if not self.request.user.is_authenticated:
|
||||
return self.model.objects.none()
|
||||
|
||||
qs = self.model.objects.filter(space=self.request.space).all()
|
||||
|
||||
if self.q:
|
||||
qs = qs.filter(name__icontains=self.q)
|
||||
|
||||
return qs
|
||||
|
||||
|
||||
class KeywordAutocomplete(BaseAutocomplete):
|
||||
model = Keyword
|
||||
|
||||
|
||||
class IngredientsAutocomplete(BaseAutocomplete):
|
||||
model = Food
|
||||
|
||||
|
||||
class RecipeAutocomplete(BaseAutocomplete):
|
||||
model = Recipe
|
||||
|
||||
|
||||
class UnitAutocomplete(BaseAutocomplete):
|
||||
model = Unit
|
||||
19
cookbook/helper/fdc_helper.py
Normal file
19
cookbook/helper/fdc_helper.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import json
|
||||
|
||||
|
||||
def get_all_nutrient_types():
|
||||
f = open('') # <--- download the foundation food or any other dataset and retrieve all nutrition ID's from it https://fdc.nal.usda.gov/download-datasets.html
|
||||
json_data = json.loads(f.read())
|
||||
|
||||
nutrients = {}
|
||||
for food in json_data['FoundationFoods']:
|
||||
for entry in food['foodNutrients']:
|
||||
nutrients[entry['nutrient']['id']] = {'name': entry['nutrient']['name'], 'unit': entry['nutrient']['unitName']}
|
||||
|
||||
nutrient_ids = list(nutrients.keys())
|
||||
nutrient_ids.sort()
|
||||
for nid in nutrient_ids:
|
||||
print('{', f'value: {nid}, text: "{nutrients[nid]["name"]} [{nutrients[nid]["unit"]}] ({nid})"', '},')
|
||||
|
||||
|
||||
get_all_nutrient_types()
|
||||
@@ -1,8 +1,7 @@
|
||||
import os
|
||||
import sys
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
def rescale_image_jpeg(image_object, base_width=1020):
|
||||
@@ -11,7 +10,7 @@ def rescale_image_jpeg(image_object, base_width=1020):
|
||||
width_percent = (base_width / float(img.size[0]))
|
||||
height = int((float(img.size[1]) * float(width_percent)))
|
||||
|
||||
img = img.resize((base_width, height), Image.ANTIALIAS)
|
||||
img = img.resize((base_width, height), Image.LANCZOS)
|
||||
img_bytes = BytesIO()
|
||||
img.save(img_bytes, 'JPEG', quality=90, optimize=True, icc_profile=icc_profile)
|
||||
|
||||
@@ -22,7 +21,7 @@ def rescale_image_png(image_object, base_width=1020):
|
||||
image_object = Image.open(image_object)
|
||||
wpercent = (base_width / float(image_object.size[0]))
|
||||
hsize = int((float(image_object.size[1]) * float(wpercent)))
|
||||
img = image_object.resize((base_width, hsize), Image.ANTIALIAS)
|
||||
img = image_object.resize((base_width, hsize), Image.LANCZOS)
|
||||
|
||||
im_io = BytesIO()
|
||||
img.save(im_io, 'PNG', quality=90)
|
||||
|
||||
@@ -2,18 +2,16 @@ import re
|
||||
import string
|
||||
import unicodedata
|
||||
|
||||
from django.core.cache import caches
|
||||
|
||||
from cookbook.models import Unit, Food, Automation, Ingredient
|
||||
from cookbook.helper.automation_helper import AutomationEngine
|
||||
from cookbook.models import Food, Ingredient, Unit
|
||||
|
||||
|
||||
class IngredientParser:
|
||||
request = None
|
||||
ignore_rules = False
|
||||
food_aliases = {}
|
||||
unit_aliases = {}
|
||||
automation = None
|
||||
|
||||
def __init__(self, request, cache_mode, ignore_automations=False):
|
||||
def __init__(self, request, cache_mode=True, ignore_automations=False):
|
||||
"""
|
||||
Initialize ingredient parser
|
||||
:param request: request context (to control caching, rule ownership, etc.)
|
||||
@@ -22,65 +20,8 @@ class IngredientParser:
|
||||
"""
|
||||
self.request = request
|
||||
self.ignore_rules = ignore_automations
|
||||
if cache_mode:
|
||||
FOOD_CACHE_KEY = f'automation_food_alias_{self.request.space.pk}'
|
||||
if c := caches['default'].get(FOOD_CACHE_KEY, None):
|
||||
self.food_aliases = c
|
||||
caches['default'].touch(FOOD_CACHE_KEY, 30)
|
||||
else:
|
||||
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.FOOD_ALIAS).only('param_1', 'param_2').order_by('order').all():
|
||||
self.food_aliases[a.param_1] = a.param_2
|
||||
caches['default'].set(FOOD_CACHE_KEY, self.food_aliases, 30)
|
||||
|
||||
UNIT_CACHE_KEY = f'automation_unit_alias_{self.request.space.pk}'
|
||||
if c := caches['default'].get(UNIT_CACHE_KEY, None):
|
||||
self.unit_aliases = c
|
||||
caches['default'].touch(UNIT_CACHE_KEY, 30)
|
||||
else:
|
||||
for a in Automation.objects.filter(space=self.request.space, disabled=False, type=Automation.UNIT_ALIAS).only('param_1', 'param_2').order_by('order').all():
|
||||
self.unit_aliases[a.param_1] = a.param_2
|
||||
caches['default'].set(UNIT_CACHE_KEY, self.unit_aliases, 30)
|
||||
else:
|
||||
self.food_aliases = {}
|
||||
self.unit_aliases = {}
|
||||
|
||||
def apply_food_automation(self, food):
|
||||
"""
|
||||
Apply food alias automations to passed food
|
||||
:param food: unit as string
|
||||
:return: food as string (possibly changed by automation)
|
||||
"""
|
||||
if self.ignore_rules:
|
||||
return food
|
||||
else:
|
||||
if self.food_aliases:
|
||||
try:
|
||||
return self.food_aliases[food]
|
||||
except KeyError:
|
||||
return food
|
||||
else:
|
||||
if automation := Automation.objects.filter(space=self.request.space, type=Automation.FOOD_ALIAS, param_1=food, disabled=False).order_by('order').first():
|
||||
return automation.param_2
|
||||
return food
|
||||
|
||||
def apply_unit_automation(self, unit):
|
||||
"""
|
||||
Apply unit alias automations to passed unit
|
||||
:param unit: unit as string
|
||||
:return: unit as string (possibly changed by automation)
|
||||
"""
|
||||
if self.ignore_rules:
|
||||
return unit
|
||||
else:
|
||||
if self.unit_aliases:
|
||||
try:
|
||||
return self.unit_aliases[unit]
|
||||
except KeyError:
|
||||
return unit
|
||||
else:
|
||||
if automation := Automation.objects.filter(space=self.request.space, type=Automation.UNIT_ALIAS, param_1=unit, disabled=False).order_by('order').first():
|
||||
return automation.param_2
|
||||
return unit
|
||||
if not self.ignore_rules:
|
||||
self.automation = AutomationEngine(self.request, use_cache=cache_mode)
|
||||
|
||||
def get_unit(self, unit):
|
||||
"""
|
||||
@@ -91,7 +32,10 @@ class IngredientParser:
|
||||
if not unit:
|
||||
return None
|
||||
if len(unit) > 0:
|
||||
u, created = Unit.objects.get_or_create(name=self.apply_unit_automation(unit), space=self.request.space)
|
||||
if self.ignore_rules:
|
||||
u, created = Unit.objects.get_or_create(name=unit.strip(), space=self.request.space)
|
||||
else:
|
||||
u, created = Unit.objects.get_or_create(name=self.automation.apply_unit_automation(unit), space=self.request.space)
|
||||
return u
|
||||
return None
|
||||
|
||||
@@ -104,7 +48,10 @@ class IngredientParser:
|
||||
if not food:
|
||||
return None
|
||||
if len(food) > 0:
|
||||
f, created = Food.objects.get_or_create(name=self.apply_food_automation(food), space=self.request.space)
|
||||
if self.ignore_rules:
|
||||
f, created = Food.objects.get_or_create(name=food.strip(), space=self.request.space)
|
||||
else:
|
||||
f, created = Food.objects.get_or_create(name=self.automation.apply_food_automation(food), space=self.request.space)
|
||||
return f
|
||||
return None
|
||||
|
||||
@@ -133,10 +80,10 @@ class IngredientParser:
|
||||
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
|
||||
))):
|
||||
(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]:
|
||||
@@ -160,7 +107,8 @@ class IngredientParser:
|
||||
if unit is not None and unit.strip() == '':
|
||||
unit = None
|
||||
|
||||
if unit is not None and (unit.startswith('(') or unit.startswith('-')): # i dont know any unit that starts with ( or - so its likely an alternative like 1L (500ml) Water or 2-3
|
||||
if unit is not None and (unit.startswith('(') or unit.startswith(
|
||||
'-')): # i dont know any unit that starts with ( or - so its likely an alternative like 1L (500ml) Water or 2-3
|
||||
unit = None
|
||||
note = x
|
||||
return amount, unit, note
|
||||
@@ -221,6 +169,9 @@ class IngredientParser:
|
||||
if len(ingredient) == 0:
|
||||
raise ValueError('string to parse cannot be empty')
|
||||
|
||||
if len(ingredient) > 512:
|
||||
raise ValueError('cannot parse ingredients with more than 512 characters')
|
||||
|
||||
# some people/languages put amount and unit at the end of the ingredient string
|
||||
# if something like this is detected move it to the beginning so the parser can handle it
|
||||
if len(ingredient) < 1000 and re.search(r'^([^\W\d_])+(.)*[1-9](\d)*\s*([^\W\d_])+', ingredient):
|
||||
@@ -230,8 +181,8 @@ class IngredientParser:
|
||||
|
||||
# if the string contains parenthesis early on remove it and place it at the end
|
||||
# because its likely some kind of note
|
||||
if re.match('(.){1,6}\s\((.[^\(\)])+\)\s', ingredient):
|
||||
match = re.search('\((.[^\(])+\)', ingredient)
|
||||
if re.match('(.){1,6}\\s\\((.[^\\(\\)])+\\)\\s', ingredient):
|
||||
match = re.search('\\((.[^\\(])+\\)', ingredient)
|
||||
ingredient = ingredient[:match.start()] + ingredient[match.end():] + ' ' + ingredient[match.start():match.end()]
|
||||
|
||||
# leading spaces before commas result in extra tokens, clean them out
|
||||
@@ -239,12 +190,15 @@ class IngredientParser:
|
||||
|
||||
# handle "(from) - (to)" amounts by using the minimum amount and adding the range to the description
|
||||
# "10.5 - 200 g XYZ" => "100 g XYZ (10.5 - 200)"
|
||||
ingredient = re.sub("^(\d+|\d+[\\.,]\d+) - (\d+|\d+[\\.,]\d+) (.*)", "\\1 \\3 (\\1 - \\2)", ingredient)
|
||||
ingredient = re.sub("^(\\d+|\\d+[\\.,]\\d+) - (\\d+|\\d+[\\.,]\\d+) (.*)", "\\1 \\3 (\\1 - \\2)", ingredient)
|
||||
|
||||
# if amount and unit are connected add space in between
|
||||
if re.match('([0-9])+([A-z])+\s', ingredient):
|
||||
if re.match('([0-9])+([A-z])+\\s', ingredient):
|
||||
ingredient = re.sub(r'(?<=([a-z])|\d)(?=(?(1)\d|[a-z]))', ' ', ingredient)
|
||||
|
||||
if not self.ignore_rules:
|
||||
ingredient = self.automation.apply_transpose_automation(ingredient)
|
||||
|
||||
tokens = ingredient.split() # split at each space into tokens
|
||||
if len(tokens) == 1:
|
||||
# there only is one argument, that must be the food
|
||||
@@ -257,6 +211,8 @@ class IngredientParser:
|
||||
# three arguments if it already has a unit there can't be
|
||||
# a fraction for the amount
|
||||
if len(tokens) > 2:
|
||||
if not self.ignore_rules:
|
||||
tokens = self.automation.apply_never_unit_automation(tokens)
|
||||
try:
|
||||
if unit is not None:
|
||||
# a unit is already found, no need to try the second argument for a fraction
|
||||
@@ -303,10 +259,11 @@ class IngredientParser:
|
||||
if unit_note not in note:
|
||||
note += ' ' + unit_note
|
||||
|
||||
if unit:
|
||||
unit = self.apply_unit_automation(unit.strip())
|
||||
if unit and not self.ignore_rules:
|
||||
unit = self.automation.apply_unit_automation(unit)
|
||||
|
||||
food = self.apply_food_automation(food.strip())
|
||||
if food and not self.ignore_rules:
|
||||
food = self.automation.apply_food_automation(food)
|
||||
if len(food) > Food._meta.get_field('name').max_length: # test if food name is to long
|
||||
# try splitting it at a space and taking only the first arg
|
||||
if len(food.split()) > 1 and len(food.split()[0]) < Food._meta.get_field('name').max_length:
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
from django.db.models import Q
|
||||
import traceback
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
|
||||
from cookbook.models import Unit, SupermarketCategory, Property, PropertyType, Supermarket, SupermarketCategoryRelation, Food, Automation, UnitConversion, FoodProperty
|
||||
from cookbook.models import (Food, FoodProperty, Property, PropertyType, Supermarket,
|
||||
SupermarketCategory, SupermarketCategoryRelation, Unit, UnitConversion)
|
||||
import re
|
||||
|
||||
|
||||
class OpenDataImportResponse:
|
||||
total_created = 0
|
||||
total_updated = 0
|
||||
total_untouched = 0
|
||||
total_errored = 0
|
||||
|
||||
def to_dict(self):
|
||||
return {'total_created': self.total_created, 'total_updated': self.total_updated, 'total_untouched': self.total_untouched, 'total_errored': self.total_errored}
|
||||
|
||||
|
||||
class OpenDataImporter:
|
||||
@@ -19,68 +33,269 @@ class OpenDataImporter:
|
||||
def _update_slug_cache(self, object_class, datatype):
|
||||
self.slug_id_cache[datatype] = dict(object_class.objects.filter(space=self.request.space, open_data_slug__isnull=False).values_list('open_data_slug', 'id', ))
|
||||
|
||||
def import_units(self):
|
||||
datatype = 'unit'
|
||||
@staticmethod
|
||||
def _is_obj_identical(field_list, obj, existing_obj):
|
||||
"""
|
||||
checks if the obj meant for import is identical to an already existing one
|
||||
:param field_list: list of field names to check
|
||||
:type field_list: list[str]
|
||||
:param obj: object meant for import
|
||||
:type obj: Object
|
||||
:param existing_obj: object already in DB
|
||||
:type existing_obj: Object
|
||||
:return: if objects are identical
|
||||
:rtype: bool
|
||||
"""
|
||||
for field in field_list:
|
||||
if isinstance(getattr(obj, field), float) or isinstance(getattr(obj, field), Decimal):
|
||||
if abs(float(getattr(obj, field)) - float(existing_obj[field])) > 0.001: # convert both to float and check if basically equal
|
||||
print(f'comparing FLOAT {obj} failed because field {field} is not equal ({getattr(obj, field)} != {existing_obj[field]})')
|
||||
return False
|
||||
elif getattr(obj, field) != existing_obj[field]:
|
||||
print(f'comparing {obj} failed because field {field} is not equal ({getattr(obj, field)} != {existing_obj[field]})')
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _merge_if_conflicting(model_type, obj, existing_data_slugs, existing_data_names):
|
||||
"""
|
||||
sometimes there might be two objects conflicting for open data import (one has the slug, the other the name)
|
||||
this function checks if that is the case and merges the two objects if possible
|
||||
:param model_type: type of model to check/merge
|
||||
:type model_type: Model
|
||||
:param obj: object that should be created/updated
|
||||
:type obj: Model
|
||||
:param existing_data_slugs: dict of open data slugs mapped to objects
|
||||
:type existing_data_slugs: dict
|
||||
:param existing_data_names: dict of names mapped to objects
|
||||
:type existing_data_names: dict
|
||||
:return: true if merge was successful or not necessary else false
|
||||
:rtype: bool
|
||||
"""
|
||||
if obj.open_data_slug in existing_data_slugs and obj.name in existing_data_names and existing_data_slugs[obj.open_data_slug]['pk'] != existing_data_names[obj.name]['pk']:
|
||||
try:
|
||||
source_obj = model_type.objects.get(pk=existing_data_slugs[obj.open_data_slug]['pk'])
|
||||
del existing_data_slugs[obj.open_data_slug]
|
||||
source_obj.merge_into(model_type.objects.get(pk=existing_data_names[obj.name]['pk']))
|
||||
return True
|
||||
except RuntimeError:
|
||||
return False # in the edge case (e.g. parent/child) that an object cannot be merged don't update it for now
|
||||
else:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _get_existing_obj(obj, existing_data_slugs, existing_data_names):
|
||||
"""
|
||||
gets the existing object from slug or name cache
|
||||
:param obj: object that should be found
|
||||
:type obj: Model
|
||||
:param existing_data_slugs: dict of open data slugs mapped to objects
|
||||
:type existing_data_slugs: dict
|
||||
:param existing_data_names: dict of names mapped to objects
|
||||
:type existing_data_names: dict
|
||||
:return: existing object
|
||||
:rtype: dict
|
||||
"""
|
||||
existing_obj = None
|
||||
if obj.open_data_slug in existing_data_slugs:
|
||||
existing_obj = existing_data_slugs[obj.open_data_slug]
|
||||
elif obj.name in existing_data_names:
|
||||
existing_obj = existing_data_names[obj.name]
|
||||
|
||||
return existing_obj
|
||||
|
||||
def import_units(self):
|
||||
od_response = OpenDataImportResponse()
|
||||
datatype = 'unit'
|
||||
model_type = Unit
|
||||
field_list = ['name', 'plural_name', 'base_unit', 'open_data_slug']
|
||||
|
||||
existing_data_slugs = {}
|
||||
existing_data_names = {}
|
||||
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
|
||||
existing_data_slugs[obj['open_data_slug']] = obj
|
||||
existing_data_names[obj['name']] = obj
|
||||
|
||||
update_list = []
|
||||
create_list = []
|
||||
|
||||
insert_list = []
|
||||
for u in list(self.data[datatype].keys()):
|
||||
insert_list.append(Unit(
|
||||
obj = model_type(
|
||||
name=self.data[datatype][u]['name'],
|
||||
plural_name=self.data[datatype][u]['plural_name'],
|
||||
base_unit=self.data[datatype][u]['base_unit'] if self.data[datatype][u]['base_unit'] != '' else None,
|
||||
base_unit=self.data[datatype][u]['base_unit'].lower() if self.data[datatype][u]['base_unit'] != '' else None,
|
||||
open_data_slug=u,
|
||||
space=self.request.space
|
||||
))
|
||||
)
|
||||
if obj.open_data_slug in existing_data_slugs or obj.name in existing_data_names:
|
||||
if not self._merge_if_conflicting(model_type, obj, existing_data_slugs, existing_data_names):
|
||||
od_response.total_errored += 1
|
||||
continue # if conflicting objects exist and cannot be merged skip object
|
||||
|
||||
if self.update_existing:
|
||||
return Unit.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('name', 'plural_name', 'base_unit', 'open_data_slug'), unique_fields=('space', 'name',))
|
||||
else:
|
||||
return Unit.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',))
|
||||
existing_obj = self._get_existing_obj(obj, existing_data_slugs, existing_data_names)
|
||||
|
||||
if not self._is_obj_identical(field_list, obj, existing_obj):
|
||||
obj.pk = existing_obj['pk']
|
||||
update_list.append(obj)
|
||||
else:
|
||||
od_response.total_untouched += 1
|
||||
else:
|
||||
create_list.append(obj)
|
||||
|
||||
if self.update_existing and len(update_list) > 0:
|
||||
model_type.objects.bulk_update(update_list, field_list)
|
||||
od_response.total_updated += len(update_list)
|
||||
|
||||
if len(create_list) > 0:
|
||||
model_type.objects.bulk_create(create_list, update_conflicts=True, update_fields=field_list, unique_fields=('space', 'name',))
|
||||
od_response.total_created += len(create_list)
|
||||
|
||||
return od_response
|
||||
|
||||
def import_category(self):
|
||||
od_response = OpenDataImportResponse()
|
||||
datatype = 'category'
|
||||
model_type = SupermarketCategory
|
||||
field_list = ['name', 'open_data_slug']
|
||||
|
||||
existing_data_slugs = {}
|
||||
existing_data_names = {}
|
||||
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
|
||||
existing_data_slugs[obj['open_data_slug']] = obj
|
||||
existing_data_names[obj['name']] = obj
|
||||
|
||||
update_list = []
|
||||
create_list = []
|
||||
|
||||
insert_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
insert_list.append(SupermarketCategory(
|
||||
obj = model_type(
|
||||
name=self.data[datatype][k]['name'],
|
||||
open_data_slug=k,
|
||||
space=self.request.space
|
||||
))
|
||||
)
|
||||
|
||||
return SupermarketCategory.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',))
|
||||
if obj.open_data_slug in existing_data_slugs or obj.name in existing_data_names:
|
||||
if not self._merge_if_conflicting(model_type, obj, existing_data_slugs, existing_data_names):
|
||||
od_response.total_errored += 1
|
||||
continue # if conflicting objects exist and cannot be merged skip object
|
||||
|
||||
existing_obj = self._get_existing_obj(obj, existing_data_slugs, existing_data_names)
|
||||
|
||||
if not self._is_obj_identical(field_list, obj, existing_obj):
|
||||
obj.pk = existing_obj['pk']
|
||||
update_list.append(obj)
|
||||
else:
|
||||
od_response.total_untouched += 1
|
||||
else:
|
||||
create_list.append(obj)
|
||||
|
||||
if self.update_existing and len(update_list) > 0:
|
||||
model_type.objects.bulk_update(update_list, field_list)
|
||||
od_response.total_updated += len(update_list)
|
||||
|
||||
if len(create_list) > 0:
|
||||
model_type.objects.bulk_create(create_list, update_conflicts=True, update_fields=field_list, unique_fields=('space', 'name',))
|
||||
od_response.total_created += len(create_list)
|
||||
|
||||
return od_response
|
||||
|
||||
def import_property(self):
|
||||
od_response = OpenDataImportResponse()
|
||||
datatype = 'property'
|
||||
model_type = PropertyType
|
||||
field_list = ['name', 'unit', 'fdc_id', 'open_data_slug']
|
||||
|
||||
existing_data_slugs = {}
|
||||
existing_data_names = {}
|
||||
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
|
||||
existing_data_slugs[obj['open_data_slug']] = obj
|
||||
existing_data_names[obj['name']] = obj
|
||||
|
||||
update_list = []
|
||||
create_list = []
|
||||
|
||||
insert_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
insert_list.append(PropertyType(
|
||||
obj = model_type(
|
||||
name=self.data[datatype][k]['name'],
|
||||
unit=self.data[datatype][k]['unit'],
|
||||
fdc_id=self.data[datatype][k]['fdc_id'],
|
||||
open_data_slug=k,
|
||||
space=self.request.space
|
||||
))
|
||||
)
|
||||
|
||||
return PropertyType.objects.bulk_create(insert_list, update_conflicts=True, update_fields=('open_data_slug',), unique_fields=('space', 'name',))
|
||||
if obj.open_data_slug in existing_data_slugs or obj.name in existing_data_names:
|
||||
if not self._merge_if_conflicting(model_type, obj, existing_data_slugs, existing_data_names):
|
||||
od_response.total_errored += 1
|
||||
continue # if conflicting objects exist and cannot be merged skip object
|
||||
|
||||
existing_obj = self._get_existing_obj(obj, existing_data_slugs, existing_data_names)
|
||||
|
||||
if not self._is_obj_identical(field_list, obj, existing_obj):
|
||||
obj.pk = existing_obj['pk']
|
||||
update_list.append(obj)
|
||||
else:
|
||||
od_response.total_untouched += 1
|
||||
else:
|
||||
create_list.append(obj)
|
||||
|
||||
if self.update_existing and len(update_list) > 0:
|
||||
model_type.objects.bulk_update(update_list, field_list)
|
||||
od_response.total_updated += len(update_list)
|
||||
|
||||
if len(create_list) > 0:
|
||||
model_type.objects.bulk_create(create_list, update_conflicts=True, update_fields=field_list, unique_fields=('space', 'name',))
|
||||
od_response.total_created += len(create_list)
|
||||
|
||||
return od_response
|
||||
|
||||
def import_supermarket(self):
|
||||
od_response = OpenDataImportResponse()
|
||||
datatype = 'store'
|
||||
model_type = Supermarket
|
||||
field_list = ['name', 'open_data_slug']
|
||||
|
||||
existing_data_slugs = {}
|
||||
existing_data_names = {}
|
||||
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
|
||||
existing_data_slugs[obj['open_data_slug']] = obj
|
||||
existing_data_names[obj['name']] = obj
|
||||
|
||||
update_list = []
|
||||
create_list = []
|
||||
|
||||
self._update_slug_cache(SupermarketCategory, 'category')
|
||||
insert_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
insert_list.append(Supermarket(
|
||||
obj = model_type(
|
||||
name=self.data[datatype][k]['name'],
|
||||
open_data_slug=k,
|
||||
space=self.request.space
|
||||
))
|
||||
)
|
||||
if obj.open_data_slug in existing_data_slugs or obj.name in existing_data_names:
|
||||
if not self._merge_if_conflicting(model_type, obj, existing_data_slugs, existing_data_names):
|
||||
od_response.total_errored += 1
|
||||
continue # if conflicting objects exist and cannot be merged skip object
|
||||
|
||||
existing_obj = self._get_existing_obj(obj, existing_data_slugs, existing_data_names)
|
||||
|
||||
if not self._is_obj_identical(field_list, obj, existing_obj):
|
||||
obj.pk = existing_obj['pk']
|
||||
update_list.append(obj)
|
||||
else:
|
||||
od_response.total_untouched += 1
|
||||
else:
|
||||
create_list.append(obj)
|
||||
|
||||
if self.update_existing and len(update_list) > 0:
|
||||
model_type.objects.bulk_update(update_list, field_list)
|
||||
od_response.total_updated += len(update_list)
|
||||
|
||||
if len(create_list) > 0:
|
||||
model_type.objects.bulk_create(create_list, update_conflicts=True, update_fields=field_list, unique_fields=('space', 'name',))
|
||||
od_response.total_created += len(create_list)
|
||||
|
||||
# always add open data slug if matching supermarket is found, otherwise relation might fail
|
||||
supermarkets = Supermarket.objects.bulk_create(insert_list, unique_fields=('space', 'name',), update_conflicts=True, update_fields=('open_data_slug',))
|
||||
self._update_slug_cache(Supermarket, 'store')
|
||||
|
||||
insert_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
relations = []
|
||||
order = 0
|
||||
@@ -96,119 +311,186 @@ class OpenDataImporter:
|
||||
|
||||
SupermarketCategoryRelation.objects.bulk_create(relations, ignore_conflicts=True, unique_fields=('supermarket', 'category',))
|
||||
|
||||
return supermarkets
|
||||
return od_response
|
||||
|
||||
def import_food(self):
|
||||
identifier_list = []
|
||||
od_response = OpenDataImportResponse()
|
||||
datatype = 'food'
|
||||
for k in list(self.data[datatype].keys()):
|
||||
identifier_list.append(self.data[datatype][k]['name'])
|
||||
identifier_list.append(self.data[datatype][k]['plural_name'])
|
||||
model_type = Food
|
||||
field_list = ['name', 'open_data_slug']
|
||||
|
||||
existing_objects_flat = []
|
||||
existing_objects = {}
|
||||
for f in Food.objects.filter(space=self.request.space).filter(name__in=identifier_list).values_list('id', 'name', 'plural_name'):
|
||||
existing_objects_flat.append(f[1])
|
||||
existing_objects_flat.append(f[2])
|
||||
existing_objects[f[1]] = f
|
||||
existing_objects[f[2]] = f
|
||||
existing_data_slugs = {}
|
||||
existing_data_names = {}
|
||||
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
|
||||
existing_data_slugs[obj['open_data_slug']] = obj
|
||||
existing_data_names[obj['name']] = obj
|
||||
|
||||
update_list = []
|
||||
create_list = []
|
||||
|
||||
self._update_slug_cache(Unit, 'unit')
|
||||
self._update_slug_cache(PropertyType, 'property')
|
||||
self._update_slug_cache(SupermarketCategory, 'category')
|
||||
|
||||
# pref_unit_key = 'preferred_unit_metric'
|
||||
# pref_shopping_unit_key = 'preferred_packaging_unit_metric'
|
||||
# if not self.use_metric:
|
||||
# pref_unit_key = 'preferred_unit_imperial'
|
||||
# pref_shopping_unit_key = 'preferred_packaging_unit_imperial'
|
||||
unit_g = Unit.objects.filter(space=self.request.space, base_unit__iexact='g').first()
|
||||
|
||||
insert_list = []
|
||||
update_list = []
|
||||
update_field_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
if not (self.data[datatype][k]['name'] in existing_objects_flat or self.data[datatype][k]['plural_name'] in existing_objects_flat):
|
||||
insert_list.append({'data': {
|
||||
'name': self.data[datatype][k]['name'],
|
||||
'plural_name': self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None,
|
||||
# 'preferred_unit_id': self.slug_id_cache['unit'][self.data[datatype][k][pref_unit_key]],
|
||||
# 'preferred_shopping_unit_id': self.slug_id_cache['unit'][self.data[datatype][k][pref_shopping_unit_key]],
|
||||
'supermarket_category_id': self.slug_id_cache['category'][self.data[datatype][k]['store_category']],
|
||||
'fdc_id': self.data[datatype][k]['fdc_id'] if self.data[datatype][k]['fdc_id'] != '' else None,
|
||||
'open_data_slug': k,
|
||||
'space': self.request.space.id,
|
||||
}})
|
||||
|
||||
obj_dict = {
|
||||
'name': self.data[datatype][k]['name'],
|
||||
'plural_name': self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None,
|
||||
'supermarket_category_id': self.slug_id_cache['category'][self.data[datatype][k]['store_category']],
|
||||
'fdc_id': re.sub(r'\D', '', self.data[datatype][k]['fdc_id']) if self.data[datatype][k]['fdc_id'] != '' else None,
|
||||
'open_data_slug': k,
|
||||
'properties_food_unit_id': None,
|
||||
'space_id': self.request.space.id,
|
||||
}
|
||||
|
||||
if unit_g:
|
||||
obj_dict['properties_food_unit_id'] = unit_g.id
|
||||
|
||||
obj = model_type(**obj_dict)
|
||||
|
||||
if obj.open_data_slug in existing_data_slugs or obj.name in existing_data_names:
|
||||
if not self._merge_if_conflicting(model_type, obj, existing_data_slugs, existing_data_names):
|
||||
od_response.total_errored += 1
|
||||
continue # if conflicting objects exist and cannot be merged skip object
|
||||
|
||||
existing_obj = self._get_existing_obj(obj, existing_data_slugs, existing_data_names)
|
||||
|
||||
if not self._is_obj_identical(field_list, obj, existing_obj):
|
||||
obj.pk = existing_obj['pk']
|
||||
update_list.append(obj)
|
||||
else:
|
||||
od_response.total_untouched += 1
|
||||
else:
|
||||
if self.data[datatype][k]['name'] in existing_objects:
|
||||
existing_food_id = existing_objects[self.data[datatype][k]['name']][0]
|
||||
else:
|
||||
existing_food_id = existing_objects[self.data[datatype][k]['plural_name']][0]
|
||||
create_list.append({'data': obj_dict})
|
||||
|
||||
if self.update_existing:
|
||||
update_field_list = ['name', 'plural_name', 'preferred_unit_id', 'preferred_shopping_unit_id', 'supermarket_category_id', 'fdc_id', 'open_data_slug', ]
|
||||
update_list.append(Food(
|
||||
id=existing_food_id,
|
||||
name=self.data[datatype][k]['name'],
|
||||
plural_name=self.data[datatype][k]['plural_name'] if self.data[datatype][k]['plural_name'] != '' else None,
|
||||
# preferred_unit_id=self.slug_id_cache['unit'][self.data[datatype][k][pref_unit_key]],
|
||||
# preferred_shopping_unit_id=self.slug_id_cache['unit'][self.data[datatype][k][pref_shopping_unit_key]],
|
||||
supermarket_category_id=self.slug_id_cache['category'][self.data[datatype][k]['store_category']],
|
||||
fdc_id=self.data[datatype][k]['fdc_id'] if self.data[datatype][k]['fdc_id'] != '' else None,
|
||||
open_data_slug=k,
|
||||
))
|
||||
else:
|
||||
update_field_list = ['open_data_slug', ]
|
||||
update_list.append(Food(id=existing_food_id, open_data_slug=k, ))
|
||||
if self.update_existing and len(update_list) > 0:
|
||||
model_type.objects.bulk_update(update_list, field_list)
|
||||
od_response.total_updated += len(update_list)
|
||||
|
||||
Food.load_bulk(insert_list, None)
|
||||
if len(update_list) > 0:
|
||||
Food.objects.bulk_update(update_list, update_field_list)
|
||||
if len(create_list) > 0:
|
||||
Food.load_bulk(create_list, None)
|
||||
od_response.total_created += len(create_list)
|
||||
|
||||
# --------------- PROPERTY STUFF -----------------------
|
||||
model_type = Property
|
||||
field_list = ['property_type_id', 'property_amount', 'open_data_food_slug']
|
||||
|
||||
existing_data_slugs = {}
|
||||
existing_data_property_types = {}
|
||||
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
|
||||
existing_data_slugs[obj['open_data_food_slug']] = obj
|
||||
existing_data_property_types[obj['property_type_id']] = obj
|
||||
|
||||
update_list = []
|
||||
create_list = []
|
||||
|
||||
self._update_slug_cache(Food, 'food')
|
||||
|
||||
food_property_list = []
|
||||
alias_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
for fp in self.data[datatype][k]['properties']['type_values']:
|
||||
food_property_list.append(Property(
|
||||
for k in list(self.data['food'].keys()):
|
||||
for fp in self.data['food'][k]['properties']['type_values']:
|
||||
obj = model_type(
|
||||
property_type_id=self.slug_id_cache['property'][fp['property_type']],
|
||||
property_amount=fp['property_value'],
|
||||
import_food_id=self.slug_id_cache['food'][k],
|
||||
open_data_food_slug=k,
|
||||
space=self.request.space,
|
||||
))
|
||||
)
|
||||
|
||||
# for a in self.data[datatype][k]['alias']:
|
||||
# alias_list.append(Automation(
|
||||
# param_1=a,
|
||||
# param_2=self.data[datatype][k]['name'],
|
||||
# space=self.request.space,
|
||||
# created_by=self.request.user,
|
||||
# ))
|
||||
if obj.open_data_food_slug in existing_data_slugs and obj.property_type_id in existing_data_property_types and existing_data_slugs[obj.open_data_food_slug] == existing_data_property_types[obj.property_type_id]:
|
||||
existing_obj = existing_data_slugs[obj.open_data_food_slug]
|
||||
|
||||
Property.objects.bulk_create(food_property_list, ignore_conflicts=True, unique_fields=('space', 'import_food_id', 'property_type',))
|
||||
if not self._is_obj_identical(field_list, obj, existing_obj):
|
||||
obj.pk = existing_obj['pk']
|
||||
update_list.append(obj)
|
||||
else:
|
||||
create_list.append(obj)
|
||||
|
||||
if self.update_existing and len(update_list) > 0:
|
||||
model_type.objects.bulk_update(update_list, field_list)
|
||||
|
||||
if len(create_list) > 0:
|
||||
model_type.objects.bulk_create(create_list, ignore_conflicts=True, unique_fields=('space', 'open_data_food_slug', 'property_type',))
|
||||
|
||||
linked_properties = list(FoodProperty.objects.filter(food__space=self.request.space).values_list('property_id', flat=True).all())
|
||||
|
||||
property_food_relation_list = []
|
||||
for p in Property.objects.filter(space=self.request.space, import_food_id__isnull=False).values_list('import_food_id', 'id', ):
|
||||
property_food_relation_list.append(Food.properties.through(food_id=p[0], property_id=p[1]))
|
||||
for p in model_type.objects.filter(space=self.request.space, open_data_food_slug__isnull=False).values_list('open_data_food_slug', 'id', ):
|
||||
if p[1] == 147:
|
||||
pass
|
||||
# slug_id_cache should always exist, don't create relations for already linked properties (ignore_conflicts would do that as well but this is more performant)
|
||||
if p[0] in self.slug_id_cache['food'] and p[1] not in linked_properties:
|
||||
property_food_relation_list.append(Food.properties.through(food_id=self.slug_id_cache['food'][p[0]], property_id=p[1]))
|
||||
|
||||
FoodProperty.objects.bulk_create(property_food_relation_list, ignore_conflicts=True, unique_fields=('food_id', 'property_id',))
|
||||
FoodProperty.objects.bulk_create(property_food_relation_list, unique_fields=('food_id', 'property_id',))
|
||||
|
||||
# Automation.objects.bulk_create(alias_list, ignore_conflicts=True, unique_fields=('space', 'param_1', 'param_2',))
|
||||
return insert_list + update_list
|
||||
return od_response
|
||||
|
||||
def import_conversion(self):
|
||||
od_response = OpenDataImportResponse()
|
||||
datatype = 'conversion'
|
||||
model_type = UnitConversion
|
||||
field_list = ['base_amount', 'base_unit_id', 'converted_amount', 'converted_unit_id', 'food_id', 'open_data_slug']
|
||||
|
||||
self._update_slug_cache(Food, 'food')
|
||||
self._update_slug_cache(Unit, 'unit')
|
||||
|
||||
existing_data_slugs = {}
|
||||
existing_data_foods = defaultdict(list)
|
||||
for obj in model_type.objects.filter(space=self.request.space).values('pk', *field_list):
|
||||
existing_data_slugs[obj['open_data_slug']] = obj
|
||||
existing_data_foods[obj['food_id']].append(obj)
|
||||
|
||||
update_list = []
|
||||
create_list = []
|
||||
|
||||
insert_list = []
|
||||
for k in list(self.data[datatype].keys()):
|
||||
insert_list.append(UnitConversion(
|
||||
base_amount=self.data[datatype][k]['base_amount'],
|
||||
base_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['base_unit']],
|
||||
converted_amount=self.data[datatype][k]['converted_amount'],
|
||||
converted_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['converted_unit']],
|
||||
food_id=self.slug_id_cache['food'][self.data[datatype][k]['food']],
|
||||
open_data_slug=k,
|
||||
space=self.request.space,
|
||||
created_by=self.request.user,
|
||||
))
|
||||
# try catch here because sometimes key "k" is not set for the food cache
|
||||
try:
|
||||
obj = model_type(
|
||||
base_amount=Decimal(self.data[datatype][k]['base_amount']),
|
||||
base_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['base_unit']],
|
||||
converted_amount=Decimal(self.data[datatype][k]['converted_amount']),
|
||||
converted_unit_id=self.slug_id_cache['unit'][self.data[datatype][k]['converted_unit']],
|
||||
food_id=self.slug_id_cache['food'][self.data[datatype][k]['food']],
|
||||
open_data_slug=k,
|
||||
space=self.request.space,
|
||||
created_by_id=self.request.user.id,
|
||||
)
|
||||
|
||||
return UnitConversion.objects.bulk_create(insert_list, ignore_conflicts=True, unique_fields=('space', 'base_unit', 'converted_unit', 'food', 'open_data_slug'))
|
||||
if obj.open_data_slug in existing_data_slugs:
|
||||
existing_obj = existing_data_slugs[obj.open_data_slug]
|
||||
if not self._is_obj_identical(field_list, obj, existing_obj):
|
||||
obj.pk = existing_obj['pk']
|
||||
update_list.append(obj)
|
||||
else:
|
||||
od_response.total_untouched += 1
|
||||
else:
|
||||
matching_existing_found = False
|
||||
if obj.food_id in existing_data_foods:
|
||||
for edf in existing_data_foods[obj.food_id]:
|
||||
if obj.base_unit_id == edf['base_unit_id'] and obj.converted_unit_id == edf['converted_unit_id']:
|
||||
matching_existing_found = True
|
||||
if not self._is_obj_identical(field_list, obj, edf):
|
||||
obj.pk = edf['pk']
|
||||
update_list.append(obj)
|
||||
else:
|
||||
od_response.total_untouched += 1
|
||||
if not matching_existing_found:
|
||||
create_list.append(obj)
|
||||
except KeyError as e:
|
||||
traceback.print_exc()
|
||||
od_response.total_errored += 1
|
||||
print(self.data[datatype][k]['food'] + ' is not in self.slug_id_cache["food"]')
|
||||
|
||||
if self.update_existing and len(update_list) > 0:
|
||||
od_response.total_updated = model_type.objects.bulk_update(update_list, field_list)
|
||||
od_response.total_errored += len(update_list) - od_response.total_updated
|
||||
|
||||
if len(create_list) > 0:
|
||||
objs_created = model_type.objects.bulk_create(create_list, ignore_conflicts=True, unique_fields=('space', 'base_unit', 'converted_unit', 'food', 'open_data_slug'))
|
||||
od_response.total_created = len(objs_created)
|
||||
od_response.total_errored += len(create_list) - od_response.total_created
|
||||
|
||||
return od_response
|
||||
|
||||
@@ -4,16 +4,16 @@ from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import user_passes_test
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError, ObjectDoesNotExist
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from oauth2_provider.contrib.rest_framework import TokenHasScope, TokenHasReadWriteScope
|
||||
from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope, TokenHasScope
|
||||
from oauth2_provider.models import AccessToken
|
||||
from rest_framework import permissions
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
|
||||
from cookbook.models import ShareLink, Recipe, UserSpace
|
||||
from cookbook.models import Recipe, ShareLink, UserSpace
|
||||
|
||||
|
||||
def get_allowed_groups(groups_required):
|
||||
@@ -75,7 +75,7 @@ def is_object_owner(user, obj):
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
try:
|
||||
return obj.get_owner() == user
|
||||
return obj.get_owner() == 'orphan' or obj.get_owner() == user
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -255,9 +255,6 @@ class CustomIsShared(permissions.BasePermission):
|
||||
return request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
# # temporary hack to make old shopping list work with new shopping list
|
||||
# if obj.__class__.__name__ in ['ShoppingList', 'ShoppingListEntry']:
|
||||
# return is_object_shared(request.user, obj) or obj.created_by in list(request.user.get_shopping_share())
|
||||
return is_object_shared(request.user, obj)
|
||||
|
||||
|
||||
@@ -322,7 +319,8 @@ class CustomRecipePermission(permissions.BasePermission):
|
||||
|
||||
def has_permission(self, request, view): # user is either at least a guest or a share link is given and the request is safe
|
||||
share = request.query_params.get('share', None)
|
||||
return has_group_permission(request.user, ['guest']) or (share and request.method in SAFE_METHODS and 'pk' in view.kwargs)
|
||||
return ((has_group_permission(request.user, ['guest']) and request.method in SAFE_METHODS) or has_group_permission(
|
||||
request.user, ['user'])) or (share and request.method in SAFE_METHODS and 'pk' in view.kwargs)
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
share = request.query_params.get('share', None)
|
||||
@@ -332,7 +330,8 @@ class CustomRecipePermission(permissions.BasePermission):
|
||||
if obj.private:
|
||||
return ((obj.created_by == request.user) or (request.user in obj.shared.all())) and obj.space == request.space
|
||||
else:
|
||||
return has_group_permission(request.user, ['guest']) and obj.space == request.space
|
||||
return ((has_group_permission(request.user, ['guest']) and request.method in SAFE_METHODS)
|
||||
or has_group_permission(request.user, ['user'])) and obj.space == request.space
|
||||
|
||||
|
||||
class CustomUserPermission(permissions.BasePermission):
|
||||
@@ -361,7 +360,7 @@ class CustomTokenHasScope(TokenHasScope):
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if type(request.auth) == AccessToken:
|
||||
if isinstance(request.auth, AccessToken):
|
||||
return super().has_permission(request, view)
|
||||
else:
|
||||
return request.user.is_authenticated
|
||||
@@ -375,7 +374,7 @@ class CustomTokenHasReadWriteScope(TokenHasReadWriteScope):
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if type(request.auth) == AccessToken:
|
||||
if isinstance(request.auth, AccessToken):
|
||||
return super().has_permission(request, view)
|
||||
else:
|
||||
return True
|
||||
@@ -434,3 +433,10 @@ def switch_user_active_space(user, space):
|
||||
return us
|
||||
except ObjectDoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
class IsReadOnlyDRF(permissions.BasePermission):
|
||||
message = 'You cannot interact with this object as it is not owned by you!'
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.method in SAFE_METHODS
|
||||
|
||||
@@ -2,7 +2,7 @@ from django.core.cache import caches
|
||||
|
||||
from cookbook.helper.cache_helper import CacheHelper
|
||||
from cookbook.helper.unit_conversion_helper import UnitConversionHelper
|
||||
from cookbook.models import PropertyType, Unit, Food, Property, Recipe, Step
|
||||
from cookbook.models import PropertyType
|
||||
|
||||
|
||||
class FoodPropertyHelper:
|
||||
@@ -31,10 +31,12 @@ class FoodPropertyHelper:
|
||||
|
||||
if not property_types:
|
||||
property_types = PropertyType.objects.filter(space=self.space).all()
|
||||
caches['default'].set(CacheHelper(self.space).PROPERTY_TYPE_CACHE_KEY, property_types, 60 * 60) # cache is cleared on property type save signal so long duration is fine
|
||||
# cache is cleared on property type save signal so long duration is fine
|
||||
caches['default'].set(CacheHelper(self.space).PROPERTY_TYPE_CACHE_KEY, property_types, 60 * 60)
|
||||
|
||||
for fpt in property_types:
|
||||
computed_properties[fpt.id] = {'id': fpt.id, 'name': fpt.name, 'icon': fpt.icon, 'description': fpt.description, 'unit': fpt.unit, 'food_values': {}, 'total_value': 0, 'missing_value': False}
|
||||
computed_properties[fpt.id] = {'id': fpt.id, 'name': fpt.name, 'description': fpt.description,
|
||||
'unit': fpt.unit, 'order': fpt.order, 'food_values': {}, 'total_value': 0, 'missing_value': False}
|
||||
|
||||
uch = UnitConversionHelper(self.space)
|
||||
|
||||
@@ -43,25 +45,30 @@ class FoodPropertyHelper:
|
||||
conversions = uch.get_conversions(i)
|
||||
for pt in property_types:
|
||||
found_property = False
|
||||
if i.food.properties_food_amount == 0 or i.food.properties_food_unit is None:
|
||||
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': 0}
|
||||
computed_properties[pt.id]['missing_value'] = i.food.properties_food_unit is None
|
||||
if i.food.properties_food_amount == 0 or i.food.properties_food_unit is None: # if food is configured incorrectly
|
||||
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': None}
|
||||
computed_properties[pt.id]['missing_value'] = True
|
||||
else:
|
||||
for p in i.food.properties.all():
|
||||
if p.property_type == pt:
|
||||
if p.property_type == pt and p.property_amount is not None:
|
||||
for c in conversions:
|
||||
if c.unit == i.food.properties_food_unit:
|
||||
found_property = True
|
||||
computed_properties[pt.id]['total_value'] += (c.amount / i.food.properties_food_amount) * p.property_amount
|
||||
computed_properties[pt.id]['food_values'] = self.add_or_create(computed_properties[p.property_type.id]['food_values'], c.food.id, (c.amount / i.food.properties_food_amount) * p.property_amount, c.food)
|
||||
computed_properties[pt.id]['food_values'] = self.add_or_create(
|
||||
computed_properties[p.property_type.id]['food_values'], c.food.id, (c.amount / i.food.properties_food_amount) * p.property_amount, c.food)
|
||||
if not found_property:
|
||||
computed_properties[pt.id]['missing_value'] = True
|
||||
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': 0}
|
||||
if i.amount == 0: # don't count ingredients without an amount as missing
|
||||
computed_properties[pt.id]['missing_value'] = computed_properties[pt.id]['missing_value'] or False # don't override if another food was already missing
|
||||
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': 0}
|
||||
else:
|
||||
computed_properties[pt.id]['missing_value'] = True
|
||||
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': i.food.name, 'value': None}
|
||||
|
||||
return computed_properties
|
||||
|
||||
# small dict helper to add to existing key or create new, probably a better way of doing this
|
||||
# TODO move to central helper ?
|
||||
# TODO move to central helper ? --> use defaultdict
|
||||
@staticmethod
|
||||
def add_or_create(d, key, value, food):
|
||||
if key in d:
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
# import json
|
||||
# import re
|
||||
# from json import JSONDecodeError
|
||||
# from urllib.parse import unquote
|
||||
|
||||
# from bs4 import BeautifulSoup
|
||||
# from bs4.element import Tag
|
||||
# from recipe_scrapers import scrape_html, scrape_me
|
||||
# from recipe_scrapers._exceptions import NoSchemaFoundInWildMode
|
||||
# from recipe_scrapers._utils import get_host_name, normalize_string
|
||||
|
||||
# from cookbook.helper import recipe_url_import as helper
|
||||
# from cookbook.helper.scrapers.scrapers import text_scraper
|
||||
|
||||
|
||||
# def get_recipe_from_source(text, url, request):
|
||||
# def build_node(k, v):
|
||||
# if isinstance(v, dict):
|
||||
# node = {
|
||||
# 'name': k,
|
||||
# 'value': k,
|
||||
# 'children': get_children_dict(v)
|
||||
# }
|
||||
# elif isinstance(v, list):
|
||||
# node = {
|
||||
# 'name': k,
|
||||
# 'value': k,
|
||||
# 'children': get_children_list(v)
|
||||
# }
|
||||
# else:
|
||||
# node = {
|
||||
# 'name': k + ": " + normalize_string(str(v)),
|
||||
# 'value': normalize_string(str(v))
|
||||
# }
|
||||
# return node
|
||||
|
||||
# def get_children_dict(children):
|
||||
# kid_list = []
|
||||
# for k, v in children.items():
|
||||
# kid_list.append(build_node(k, v))
|
||||
# return kid_list
|
||||
|
||||
# def get_children_list(children):
|
||||
# kid_list = []
|
||||
# for kid in children:
|
||||
# if type(kid) == list:
|
||||
# node = {
|
||||
# 'name': "unknown list",
|
||||
# 'value': "unknown list",
|
||||
# 'children': get_children_list(kid)
|
||||
# }
|
||||
# kid_list.append(node)
|
||||
# elif type(kid) == dict:
|
||||
# for k, v in kid.items():
|
||||
# kid_list.append(build_node(k, v))
|
||||
# else:
|
||||
# kid_list.append({
|
||||
# 'name': normalize_string(str(kid)),
|
||||
# 'value': normalize_string(str(kid))
|
||||
# })
|
||||
# return kid_list
|
||||
|
||||
# recipe_tree = []
|
||||
# parse_list = []
|
||||
# soup = BeautifulSoup(text, "html.parser")
|
||||
# html_data = get_from_html(soup)
|
||||
# images = get_images_from_source(soup, url)
|
||||
# text = unquote(text)
|
||||
# scrape = None
|
||||
|
||||
# if url and not text:
|
||||
# try:
|
||||
# scrape = scrape_me(url_path=url, wild_mode=True)
|
||||
# except(NoSchemaFoundInWildMode):
|
||||
# pass
|
||||
|
||||
# if not scrape:
|
||||
# try:
|
||||
# parse_list.append(remove_graph(json.loads(text)))
|
||||
# if not url and 'url' in parse_list[0]:
|
||||
# url = parse_list[0]['url']
|
||||
# scrape = text_scraper("<script type='application/ld+json'>" + text + "</script>", url=url)
|
||||
|
||||
# except JSONDecodeError:
|
||||
# for el in soup.find_all('script', type='application/ld+json'):
|
||||
# el = remove_graph(el)
|
||||
# if not url and 'url' in el:
|
||||
# url = el['url']
|
||||
# if type(el) == list:
|
||||
# for le in el:
|
||||
# parse_list.append(le)
|
||||
# elif type(el) == dict:
|
||||
# parse_list.append(el)
|
||||
# for el in soup.find_all(type='application/json'):
|
||||
# el = remove_graph(el)
|
||||
# if type(el) == list:
|
||||
# for le in el:
|
||||
# parse_list.append(le)
|
||||
# elif type(el) == dict:
|
||||
# parse_list.append(el)
|
||||
# scrape = text_scraper(text, url=url)
|
||||
|
||||
# recipe_json = helper.get_from_scraper(scrape, request)
|
||||
|
||||
# # TODO: DEPRECATE recipe_tree & html_data. first validate it isn't used anywhere
|
||||
# for el in parse_list:
|
||||
# temp_tree = []
|
||||
# if isinstance(el, Tag):
|
||||
# try:
|
||||
# el = json.loads(el.string)
|
||||
# except TypeError:
|
||||
# continue
|
||||
|
||||
# for k, v in el.items():
|
||||
# if isinstance(v, dict):
|
||||
# node = {
|
||||
# 'name': k,
|
||||
# 'value': k,
|
||||
# 'children': get_children_dict(v)
|
||||
# }
|
||||
# elif isinstance(v, list):
|
||||
# node = {
|
||||
# 'name': k,
|
||||
# 'value': k,
|
||||
# 'children': get_children_list(v)
|
||||
# }
|
||||
# else:
|
||||
# node = {
|
||||
# 'name': k + ": " + normalize_string(str(v)),
|
||||
# 'value': normalize_string(str(v))
|
||||
# }
|
||||
# temp_tree.append(node)
|
||||
|
||||
# if '@type' in el and el['@type'] == 'Recipe':
|
||||
# recipe_tree += [{'name': 'ld+json', 'children': temp_tree}]
|
||||
# else:
|
||||
# recipe_tree += [{'name': 'json', 'children': temp_tree}]
|
||||
|
||||
# return recipe_json, recipe_tree, html_data, images
|
||||
|
||||
|
||||
# def get_from_html(soup):
|
||||
# INVISIBLE_ELEMS = ('style', 'script', 'head', 'title')
|
||||
# html = []
|
||||
# for s in soup.strings:
|
||||
# if ((s.parent.name not in INVISIBLE_ELEMS) and (len(s.strip()) > 0)):
|
||||
# html.append(s)
|
||||
# return html
|
||||
|
||||
|
||||
# def get_images_from_source(soup, url):
|
||||
# sources = ['src', 'srcset', 'data-src']
|
||||
# images = []
|
||||
# img_tags = soup.find_all('img')
|
||||
# if url:
|
||||
# site = get_host_name(url)
|
||||
# prot = url.split(':')[0]
|
||||
|
||||
# urls = []
|
||||
# for img in img_tags:
|
||||
# for src in sources:
|
||||
# try:
|
||||
# urls.append(img[src])
|
||||
# except KeyError:
|
||||
# pass
|
||||
|
||||
# for u in urls:
|
||||
# u = u.split('?')[0]
|
||||
# filename = re.search(r'/([\w_-]+[.](jpg|jpeg|gif|png))$', u)
|
||||
# if filename:
|
||||
# if (('http' not in u) and (url)):
|
||||
# # sometimes an image source can be relative
|
||||
# # if it is provide the base url
|
||||
# u = '{}://{}{}'.format(prot, site, u)
|
||||
# if 'http' in u:
|
||||
# images.append(u)
|
||||
# return images
|
||||
|
||||
|
||||
# def remove_graph(el):
|
||||
# # recipes type might be wrapped in @graph type
|
||||
# if isinstance(el, Tag):
|
||||
# try:
|
||||
# el = json.loads(el.string)
|
||||
# if '@graph' in el:
|
||||
# for x in el['@graph']:
|
||||
# if '@type' in x and x['@type'] == 'Recipe':
|
||||
# el = x
|
||||
# except (TypeError, JSONDecodeError):
|
||||
# pass
|
||||
# return el
|
||||
@@ -1,14 +1,11 @@
|
||||
import json
|
||||
from collections import Counter
|
||||
from datetime import date, timedelta
|
||||
|
||||
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector, TrigramSimilarity
|
||||
from django.core.cache import cache, caches
|
||||
from django.db.models import (Avg, Case, Count, Exists, F, Func, Max, OuterRef, Q, Subquery, Value,
|
||||
When)
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Avg, Case, Count, Exists, F, Max, OuterRef, Q, Subquery, Value, When
|
||||
from django.db.models.functions import Coalesce, Lower, Substr
|
||||
from django.utils import timezone, translation
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.HelperFunctions import Round, str2bool
|
||||
from cookbook.managers import DICTIONARY
|
||||
@@ -17,21 +14,25 @@ from cookbook.models import (CookLog, CustomFilter, Food, Keyword, Recipe, Searc
|
||||
from recipes import settings
|
||||
|
||||
|
||||
# TODO create extensive tests to make sure ORs ANDs and various filters, sorting, etc work as expected
|
||||
# TODO consider creating a simpleListRecipe API that only includes minimum of recipe info and minimal filtering
|
||||
class RecipeSearch():
|
||||
_postgres = settings.DATABASES['default']['ENGINE'] in [
|
||||
'django.db.backends.postgresql_psycopg2', 'django.db.backends.postgresql']
|
||||
_postgres = settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql'
|
||||
|
||||
def __init__(self, request, **params):
|
||||
self._request = request
|
||||
self._queryset = None
|
||||
if f := params.get('filter', None):
|
||||
custom_filter = CustomFilter.objects.filter(id=f, space=self._request.space).filter(Q(created_by=self._request.user) |
|
||||
Q(shared=self._request.user) | Q(recipebook__shared=self._request.user)).first()
|
||||
custom_filter = (
|
||||
CustomFilter.objects.filter(id=f, space=self._request.space)
|
||||
.filter(Q(created_by=self._request.user) | Q(shared=self._request.user) | Q(recipebook__shared=self._request.user))
|
||||
.first()
|
||||
)
|
||||
if custom_filter:
|
||||
self._params = {**json.loads(custom_filter.search)}
|
||||
self._original_params = {**(params or {})}
|
||||
# json.loads casts rating as an integer, expecting string
|
||||
if isinstance(self._params.get('rating', None), int):
|
||||
self._params['rating'] = str(self._params['rating'])
|
||||
else:
|
||||
self._params = {**(params or {})}
|
||||
else:
|
||||
@@ -85,9 +86,9 @@ class RecipeSearch():
|
||||
self._viewedon = self._params.get('viewedon', None)
|
||||
self._makenow = self._params.get('makenow', None)
|
||||
# this supports hidden feature to find recipes missing X ingredients
|
||||
if type(self._makenow) == bool and self._makenow == True:
|
||||
if isinstance(self._makenow, bool) and self._makenow == True:
|
||||
self._makenow = 0
|
||||
elif type(self._makenow) == str and self._makenow in ["yes", "true"]:
|
||||
elif isinstance(self._makenow, str) and self._makenow in ["yes", "true"]:
|
||||
self._makenow = 0
|
||||
else:
|
||||
try:
|
||||
@@ -98,24 +99,18 @@ class RecipeSearch():
|
||||
self._search_type = self._search_prefs.search or 'plain'
|
||||
if self._string:
|
||||
if self._postgres:
|
||||
self._unaccent_include = self._search_prefs.unaccent.values_list(
|
||||
'field', flat=True)
|
||||
self._unaccent_include = self._search_prefs.unaccent.values_list('field', flat=True)
|
||||
else:
|
||||
self._unaccent_include = []
|
||||
self._icontains_include = [
|
||||
x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.icontains.values_list('field', flat=True)]
|
||||
self._istartswith_include = [
|
||||
x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.istartswith.values_list('field', flat=True)]
|
||||
self._icontains_include = [x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.icontains.values_list('field', flat=True)]
|
||||
self._istartswith_include = [x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.istartswith.values_list('field', flat=True)]
|
||||
self._trigram_include = None
|
||||
self._fulltext_include = None
|
||||
self._trigram = False
|
||||
if self._postgres and self._string:
|
||||
self._language = DICTIONARY.get(
|
||||
translation.get_language(), 'simple')
|
||||
self._trigram_include = [
|
||||
x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.trigram.values_list('field', flat=True)]
|
||||
self._fulltext_include = self._search_prefs.fulltext.values_list(
|
||||
'field', flat=True) or None
|
||||
self._language = DICTIONARY.get(translation.get_language(), 'simple')
|
||||
self._trigram_include = [x + '__unaccent' if x in self._unaccent_include else x for x in self._search_prefs.trigram.values_list('field', flat=True)]
|
||||
self._fulltext_include = self._search_prefs.fulltext.values_list('field', flat=True) or None
|
||||
|
||||
if self._search_type not in ['websearch', 'raw'] and self._trigram_include:
|
||||
self._trigram = True
|
||||
@@ -150,7 +145,7 @@ class RecipeSearch():
|
||||
self.unit_filters(units=self._units)
|
||||
self._makenow_filter(missing=self._makenow)
|
||||
self.string_filters(string=self._string)
|
||||
return self._queryset.filter(space=self._request.space).distinct().order_by(*self.orderby)
|
||||
return self._queryset.filter(space=self._request.space).order_by(*self.orderby)
|
||||
|
||||
def _sort_includes(self, *args):
|
||||
for x in args:
|
||||
@@ -166,7 +161,7 @@ class RecipeSearch():
|
||||
else:
|
||||
order = []
|
||||
# TODO add userpreference for default sort order and replace '-favorite'
|
||||
default_order = ['-name']
|
||||
default_order = ['name']
|
||||
# recent and new_recipe are always first; they float a few recipes to the top
|
||||
if self._num_recent:
|
||||
order += ['-recent']
|
||||
@@ -175,7 +170,6 @@ class RecipeSearch():
|
||||
|
||||
# if a sort order is provided by user - use that order
|
||||
if self._sort_order:
|
||||
|
||||
if not isinstance(self._sort_order, list):
|
||||
order += [self._sort_order]
|
||||
else:
|
||||
@@ -215,24 +209,18 @@ class RecipeSearch():
|
||||
self._queryset = self._queryset.filter(query_filter).distinct()
|
||||
if self._fulltext_include:
|
||||
if self._fuzzy_match is None:
|
||||
self._queryset = self._queryset.annotate(
|
||||
score=Coalesce(Max(self.search_rank), 0.0))
|
||||
self._queryset = self._queryset.annotate(score=Coalesce(Max(self.search_rank), 0.0))
|
||||
else:
|
||||
self._queryset = self._queryset.annotate(
|
||||
rank=Coalesce(Max(self.search_rank), 0.0))
|
||||
self._queryset = self._queryset.annotate(rank=Coalesce(Max(self.search_rank), 0.0))
|
||||
|
||||
if self._fuzzy_match is not None:
|
||||
simularity = self._fuzzy_match.filter(
|
||||
pk=OuterRef('pk')).values('simularity')
|
||||
simularity = self._fuzzy_match.filter(pk=OuterRef('pk')).values('simularity')
|
||||
if not self._fulltext_include:
|
||||
self._queryset = self._queryset.annotate(
|
||||
score=Coalesce(Subquery(simularity), 0.0))
|
||||
self._queryset = self._queryset.annotate(score=Coalesce(Subquery(simularity), 0.0))
|
||||
else:
|
||||
self._queryset = self._queryset.annotate(
|
||||
simularity=Coalesce(Subquery(simularity), 0.0))
|
||||
self._queryset = self._queryset.annotate(simularity=Coalesce(Subquery(simularity), 0.0))
|
||||
if self._sort_includes('score') and self._fulltext_include and self._fuzzy_match is not None:
|
||||
self._queryset = self._queryset.annotate(
|
||||
score=F('rank') + F('simularity'))
|
||||
self._queryset = self._queryset.annotate(score=F('rank') + F('simularity'))
|
||||
else:
|
||||
query_filter = Q()
|
||||
for f in [x + '__unaccent__iexact' if x in self._unaccent_include else x + '__iexact' for x in SearchFields.objects.all().values_list('field', flat=True)]:
|
||||
@@ -241,78 +229,69 @@ class RecipeSearch():
|
||||
|
||||
def _cooked_on_filter(self, cooked_date=None):
|
||||
if self._sort_includes('lastcooked') or cooked_date:
|
||||
lessthan = self._sort_includes(
|
||||
'-lastcooked') or '-' in (cooked_date or [])[:1]
|
||||
lessthan = self._sort_includes('-lastcooked') or '-' in (cooked_date or [])[:1]
|
||||
if lessthan:
|
||||
default = timezone.now() - timedelta(days=100000)
|
||||
else:
|
||||
default = timezone.now()
|
||||
self._queryset = self._queryset.annotate(lastcooked=Coalesce(
|
||||
Max(Case(When(cooklog__created_by=self._request.user, cooklog__space=self._request.space, then='cooklog__created_at'))), Value(default)))
|
||||
self._queryset = self._queryset.annotate(
|
||||
lastcooked=Coalesce(Max(Case(When(cooklog__created_by=self._request.user, cooklog__space=self._request.space, then='cooklog__created_at'))), Value(default))
|
||||
)
|
||||
if cooked_date is None:
|
||||
return
|
||||
|
||||
cooked_date = date(*[int(x)
|
||||
for x in cooked_date.split('-') if x != ''])
|
||||
cooked_date = date(*[int(x)for x in cooked_date.split('-') if x != ''])
|
||||
|
||||
if lessthan:
|
||||
self._queryset = self._queryset.filter(
|
||||
lastcooked__date__lte=cooked_date).exclude(lastcooked=default)
|
||||
self._queryset = self._queryset.filter(lastcooked__date__lte=cooked_date).exclude(lastcooked=default)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(
|
||||
lastcooked__date__gte=cooked_date).exclude(lastcooked=default)
|
||||
self._queryset = self._queryset.filter(lastcooked__date__gte=cooked_date).exclude(lastcooked=default)
|
||||
|
||||
def _created_on_filter(self, created_date=None):
|
||||
if created_date is None:
|
||||
return
|
||||
lessthan = '-' in created_date[:1]
|
||||
created_date = date(*[int(x)
|
||||
for x in created_date.split('-') if x != ''])
|
||||
created_date = date(*[int(x) for x in created_date.split('-') if x != ''])
|
||||
if lessthan:
|
||||
self._queryset = self._queryset.filter(
|
||||
created_at__date__lte=created_date)
|
||||
self._queryset = self._queryset.filter(created_at__date__lte=created_date)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(
|
||||
created_at__date__gte=created_date)
|
||||
self._queryset = self._queryset.filter(created_at__date__gte=created_date)
|
||||
|
||||
def _updated_on_filter(self, updated_date=None):
|
||||
if updated_date is None:
|
||||
return
|
||||
lessthan = '-' in updated_date[:1]
|
||||
updated_date = date(*[int(x)
|
||||
for x in updated_date.split('-') if x != ''])
|
||||
updated_date = date(*[int(x)for x in updated_date.split('-') if x != ''])
|
||||
if lessthan:
|
||||
self._queryset = self._queryset.filter(
|
||||
updated_at__date__lte=updated_date)
|
||||
self._queryset = self._queryset.filter(updated_at__date__lte=updated_date)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(
|
||||
updated_at__date__gte=updated_date)
|
||||
self._queryset = self._queryset.filter(updated_at__date__gte=updated_date)
|
||||
|
||||
def _viewed_on_filter(self, viewed_date=None):
|
||||
if self._sort_includes('lastviewed') or viewed_date:
|
||||
longTimeAgo = timezone.now() - timedelta(days=100000)
|
||||
self._queryset = self._queryset.annotate(lastviewed=Coalesce(
|
||||
Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then='viewlog__created_at'))), Value(longTimeAgo)))
|
||||
self._queryset = self._queryset.annotate(
|
||||
lastviewed=Coalesce(Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then='viewlog__created_at'))), Value(longTimeAgo))
|
||||
)
|
||||
if viewed_date is None:
|
||||
return
|
||||
lessthan = '-' in viewed_date[:1]
|
||||
viewed_date = date(*[int(x)
|
||||
for x in viewed_date.split('-') if x != ''])
|
||||
viewed_date = date(*[int(x)for x in viewed_date.split('-') if x != ''])
|
||||
|
||||
if lessthan:
|
||||
self._queryset = self._queryset.filter(
|
||||
lastviewed__date__lte=viewed_date).exclude(lastviewed=longTimeAgo)
|
||||
self._queryset = self._queryset.filter(lastviewed__date__lte=viewed_date).exclude(lastviewed=longTimeAgo)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(
|
||||
lastviewed__date__gte=viewed_date).exclude(lastviewed=longTimeAgo)
|
||||
self._queryset = self._queryset.filter(lastviewed__date__gte=viewed_date).exclude(lastviewed=longTimeAgo)
|
||||
|
||||
def _new_recipes(self, new_days=7):
|
||||
# TODO make new days a user-setting
|
||||
if not self._new:
|
||||
return
|
||||
self._queryset = (
|
||||
self._queryset.annotate(new_recipe=Case(
|
||||
When(created_at__gte=(timezone.now() - timedelta(days=new_days)), then=('pk')), default=Value(0), ))
|
||||
self._queryset = self._queryset.annotate(
|
||||
new_recipe=Case(
|
||||
When(created_at__gte=(timezone.now() - timedelta(days=new_days)), then=('pk')),
|
||||
default=Value(0),
|
||||
)
|
||||
)
|
||||
|
||||
def _recently_viewed(self, num_recent=None):
|
||||
@@ -322,34 +301,35 @@ class RecipeSearch():
|
||||
Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then='viewlog__pk'))), Value(0)))
|
||||
return
|
||||
|
||||
num_recent_recipes = ViewLog.objects.filter(created_by=self._request.user, space=self._request.space).values(
|
||||
'recipe').annotate(recent=Max('created_at')).order_by('-recent')[:num_recent]
|
||||
self._queryset = self._queryset.annotate(recent=Coalesce(Max(Case(When(
|
||||
pk__in=num_recent_recipes.values('recipe'), then='viewlog__pk'))), Value(0)))
|
||||
num_recent_recipes = (
|
||||
ViewLog.objects.filter(created_by=self._request.user, space=self._request.space)
|
||||
.values('recipe').annotate(recent=Max('created_at')).order_by('-recent')[:num_recent]
|
||||
)
|
||||
self._queryset = self._queryset.annotate(recent=Coalesce(Max(Case(When(pk__in=num_recent_recipes.values('recipe'), then='viewlog__pk'))), Value(0)))
|
||||
|
||||
def _favorite_recipes(self, times_cooked=None):
|
||||
if self._sort_includes('favorite') or times_cooked:
|
||||
less_than = '-' in (times_cooked or []
|
||||
) and not self._sort_includes('-favorite')
|
||||
less_than = '-' in (str(times_cooked) or []) and not self._sort_includes('-favorite')
|
||||
if less_than:
|
||||
default = 1000
|
||||
else:
|
||||
default = 0
|
||||
favorite_recipes = CookLog.objects.filter(created_by=self._request.user, space=self._request.space, recipe=OuterRef('pk')
|
||||
).values('recipe').annotate(count=Count('pk', distinct=True)).values('count')
|
||||
self._queryset = self._queryset.annotate(
|
||||
favorite=Coalesce(Subquery(favorite_recipes), default))
|
||||
favorite_recipes = (
|
||||
CookLog.objects.filter(created_by=self._request.user, space=self._request.space, recipe=OuterRef('pk'))
|
||||
.values('recipe')
|
||||
.annotate(count=Count('pk', distinct=True))
|
||||
.values('count')
|
||||
)
|
||||
self._queryset = self._queryset.annotate(favorite=Coalesce(Subquery(favorite_recipes), default))
|
||||
if times_cooked is None:
|
||||
return
|
||||
|
||||
if times_cooked == '0':
|
||||
self._queryset = self._queryset.filter(favorite=0)
|
||||
elif less_than:
|
||||
self._queryset = self._queryset.filter(favorite__lte=int(
|
||||
times_cooked.replace('-', ''))).exclude(favorite=0)
|
||||
self._queryset = self._queryset.filter(favorite__lte=int(times_cooked.replace('-', ''))).exclude(favorite=0)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(
|
||||
favorite__gte=int(times_cooked))
|
||||
self._queryset = self._queryset.filter(favorite__gte=int(times_cooked))
|
||||
|
||||
def keyword_filters(self, **kwargs):
|
||||
if all([kwargs[x] is None for x in kwargs]):
|
||||
@@ -382,8 +362,7 @@ class RecipeSearch():
|
||||
else:
|
||||
self._queryset = self._queryset.filter(f_and)
|
||||
if 'not' in kw_filter:
|
||||
self._queryset = self._queryset.exclude(
|
||||
id__in=recipes.values('id'))
|
||||
self._queryset = self._queryset.exclude(id__in=recipes.values('id'))
|
||||
|
||||
def food_filters(self, **kwargs):
|
||||
if all([kwargs[x] is None for x in kwargs]):
|
||||
@@ -397,8 +376,7 @@ class RecipeSearch():
|
||||
foods = Food.objects.filter(pk__in=kwargs[fd_filter])
|
||||
if 'or' in fd_filter:
|
||||
if self._include_children:
|
||||
f_or = Q(
|
||||
steps__ingredients__food__in=Food.include_descendants(foods))
|
||||
f_or = Q(steps__ingredients__food__in=Food.include_descendants(foods))
|
||||
else:
|
||||
f_or = Q(steps__ingredients__food__in=foods)
|
||||
|
||||
@@ -410,8 +388,7 @@ class RecipeSearch():
|
||||
recipes = Recipe.objects.all()
|
||||
for food in foods:
|
||||
if self._include_children:
|
||||
f_and = Q(
|
||||
steps__ingredients__food__in=food.get_descendants_and_self())
|
||||
f_and = Q(steps__ingredients__food__in=food.get_descendants_and_self())
|
||||
else:
|
||||
f_and = Q(steps__ingredients__food=food)
|
||||
if 'not' in fd_filter:
|
||||
@@ -419,8 +396,7 @@ class RecipeSearch():
|
||||
else:
|
||||
self._queryset = self._queryset.filter(f_and)
|
||||
if 'not' in fd_filter:
|
||||
self._queryset = self._queryset.exclude(
|
||||
id__in=recipes.values('id'))
|
||||
self._queryset = self._queryset.exclude(id__in=recipes.values('id'))
|
||||
|
||||
def unit_filters(self, units=None, operator=True):
|
||||
if operator != True:
|
||||
@@ -429,27 +405,25 @@ class RecipeSearch():
|
||||
return
|
||||
if not isinstance(units, list):
|
||||
units = [units]
|
||||
self._queryset = self._queryset.filter(
|
||||
steps__ingredients__unit__in=units)
|
||||
self._queryset = self._queryset.filter(steps__ingredients__unit__in=units)
|
||||
|
||||
def rating_filter(self, rating=None):
|
||||
if rating or self._sort_includes('rating'):
|
||||
lessthan = self._sort_includes('-rating') or '-' in (rating or [])
|
||||
if lessthan:
|
||||
lessthan = '-' in (rating or [])
|
||||
reverse = 'rating' in (self._sort_order or []) and '-rating' not in (self._sort_order or [])
|
||||
if lessthan or reverse:
|
||||
default = 100
|
||||
else:
|
||||
default = 0
|
||||
# TODO make ratings a settings user-only vs all-users
|
||||
self._queryset = self._queryset.annotate(rating=Round(Avg(Case(When(
|
||||
cooklog__created_by=self._request.user, then='cooklog__rating'), default=default))))
|
||||
self._queryset = self._queryset.annotate(rating=Round(Avg(Case(When(cooklog__created_by=self._request.user, then='cooklog__rating'), default=default))))
|
||||
if rating is None:
|
||||
return
|
||||
|
||||
if rating == '0':
|
||||
self._queryset = self._queryset.filter(rating=0)
|
||||
elif lessthan:
|
||||
self._queryset = self._queryset.filter(
|
||||
rating__lte=int(rating[1:])).exclude(rating=0)
|
||||
self._queryset = self._queryset.filter(rating__lte=int(rating[1:])).exclude(rating=0)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(rating__gte=int(rating))
|
||||
|
||||
@@ -477,14 +451,11 @@ class RecipeSearch():
|
||||
recipes = Recipe.objects.all()
|
||||
for book in kwargs[bk_filter]:
|
||||
if 'not' in bk_filter:
|
||||
recipes = recipes.filter(
|
||||
recipebookentry__book__id=book)
|
||||
recipes = recipes.filter(recipebookentry__book__id=book)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(
|
||||
recipebookentry__book__id=book)
|
||||
self._queryset = self._queryset.filter(recipebookentry__book__id=book)
|
||||
if 'not' in bk_filter:
|
||||
self._queryset = self._queryset.exclude(
|
||||
id__in=recipes.values('id'))
|
||||
self._queryset = self._queryset.exclude(id__in=recipes.values('id'))
|
||||
|
||||
def step_filters(self, steps=None, operator=True):
|
||||
if operator != True:
|
||||
@@ -503,25 +474,20 @@ class RecipeSearch():
|
||||
rank = []
|
||||
if 'name' in self._fulltext_include:
|
||||
vectors.append('name_search_vector')
|
||||
rank.append(SearchRank('name_search_vector',
|
||||
self.search_query, cover_density=True))
|
||||
rank.append(SearchRank('name_search_vector', self.search_query, cover_density=True))
|
||||
if 'description' in self._fulltext_include:
|
||||
vectors.append('desc_search_vector')
|
||||
rank.append(SearchRank('desc_search_vector',
|
||||
self.search_query, cover_density=True))
|
||||
rank.append(SearchRank('desc_search_vector', self.search_query, cover_density=True))
|
||||
if 'steps__instruction' in self._fulltext_include:
|
||||
vectors.append('steps__search_vector')
|
||||
rank.append(SearchRank('steps__search_vector',
|
||||
self.search_query, cover_density=True))
|
||||
rank.append(SearchRank('steps__search_vector', self.search_query, cover_density=True))
|
||||
if 'keywords__name' in self._fulltext_include:
|
||||
# explicitly settings unaccent on keywords and foods so that they behave the same as search_vector fields
|
||||
vectors.append('keywords__name__unaccent')
|
||||
rank.append(SearchRank('keywords__name__unaccent',
|
||||
self.search_query, cover_density=True))
|
||||
rank.append(SearchRank('keywords__name__unaccent', self.search_query, cover_density=True))
|
||||
if 'steps__ingredients__food__name' in self._fulltext_include:
|
||||
vectors.append('steps__ingredients__food__name__unaccent')
|
||||
rank.append(SearchRank('steps__ingredients__food__name',
|
||||
self.search_query, cover_density=True))
|
||||
rank.append(SearchRank('steps__ingredients__food__name', self.search_query, cover_density=True))
|
||||
|
||||
for r in rank:
|
||||
if self.search_rank is None:
|
||||
@@ -529,8 +495,7 @@ class RecipeSearch():
|
||||
else:
|
||||
self.search_rank += r
|
||||
# modifying queryset will annotation creates duplicate results
|
||||
self._filters.append(Q(id__in=Recipe.objects.annotate(
|
||||
vector=SearchVector(*vectors)).filter(Q(vector=self.search_query))))
|
||||
self._filters.append(Q(id__in=Recipe.objects.annotate(vector=SearchVector(*vectors)).filter(Q(vector=self.search_query))))
|
||||
|
||||
def build_text_filters(self, string=None):
|
||||
if not string:
|
||||
@@ -555,15 +520,19 @@ class RecipeSearch():
|
||||
trigram += TrigramSimilarity(f, self._string)
|
||||
else:
|
||||
trigram = TrigramSimilarity(f, self._string)
|
||||
self._fuzzy_match = Recipe.objects.annotate(trigram=trigram).distinct(
|
||||
).annotate(simularity=Max('trigram')).values('id', 'simularity').filter(simularity__gt=self._search_prefs.trigram_threshold)
|
||||
self._fuzzy_match = (
|
||||
Recipe.objects.annotate(trigram=trigram)
|
||||
.distinct()
|
||||
.annotate(simularity=Max('trigram'))
|
||||
.values('id', 'simularity')
|
||||
.filter(simularity__gt=self._search_prefs.trigram_threshold)
|
||||
)
|
||||
self._filters += [Q(pk__in=self._fuzzy_match.values('pk'))]
|
||||
|
||||
def _makenow_filter(self, missing=None):
|
||||
if missing is None or (type(missing) == bool and missing == False):
|
||||
if missing is None or (isinstance(missing, bool) and missing == False):
|
||||
return
|
||||
shopping_users = [
|
||||
*self._request.user.get_shopping_share(), self._request.user]
|
||||
shopping_users = [*self._request.user.get_shopping_share(), self._request.user]
|
||||
|
||||
onhand_filter = (
|
||||
Q(steps__ingredients__food__onhand_users__in=shopping_users) # food onhand
|
||||
@@ -573,264 +542,40 @@ class RecipeSearch():
|
||||
| Q(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users))
|
||||
)
|
||||
makenow_recipes = Recipe.objects.annotate(
|
||||
count_food=Count('steps__ingredients__food__pk', filter=Q(
|
||||
steps__ingredients__food__isnull=False), distinct=True),
|
||||
count_onhand=Count('steps__ingredients__food__pk',
|
||||
filter=onhand_filter, distinct=True),
|
||||
count_ignore_shopping=Count('steps__ingredients__food__pk', filter=Q(steps__ingredients__food__ignore_shopping=True,
|
||||
steps__ingredients__food__recipe__isnull=True), distinct=True),
|
||||
has_child_sub=Case(When(steps__ingredients__food__in=self.__children_substitute_filter(
|
||||
shopping_users), then=Value(1)), default=Value(0)),
|
||||
has_sibling_sub=Case(When(steps__ingredients__food__in=self.__sibling_substitute_filter(
|
||||
shopping_users), then=Value(1)), default=Value(0))
|
||||
).annotate(missingfood=F('count_food') - F('count_onhand') - F('count_ignore_shopping')).filter(missingfood=missing)
|
||||
self._queryset = self._queryset.distinct().filter(
|
||||
id__in=makenow_recipes.values('id'))
|
||||
count_food=Count('steps__ingredients__food__pk', filter=Q(steps__ingredients__food__isnull=False), distinct=True),
|
||||
count_onhand=Count('steps__ingredients__food__pk', filter=onhand_filter, distinct=True),
|
||||
count_ignore_shopping=Count(
|
||||
'steps__ingredients__food__pk', filter=Q(steps__ingredients__food__ignore_shopping=True, steps__ingredients__food__recipe__isnull=True), distinct=True
|
||||
),
|
||||
has_child_sub=Case(When(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users), then=Value(1)), default=Value(0)),
|
||||
has_sibling_sub=Case(When(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users), then=Value(1)), default=Value(0))
|
||||
).annotate(missingfood=F('count_food') - F('count_onhand') - F('count_ignore_shopping')).filter(missingfood__lte=missing)
|
||||
self._queryset = self._queryset.distinct().filter(id__in=makenow_recipes.values('id'))
|
||||
|
||||
@staticmethod
|
||||
def __children_substitute_filter(shopping_users=None):
|
||||
children_onhand_subquery = Food.objects.filter(
|
||||
path__startswith=OuterRef('path'),
|
||||
depth__gt=OuterRef('depth'),
|
||||
onhand_users__in=shopping_users
|
||||
children_onhand_subquery = Food.objects.filter(path__startswith=OuterRef('path'), depth__gt=OuterRef('depth'), onhand_users__in=shopping_users)
|
||||
return (
|
||||
Food.objects.exclude( # list of foods that are onhand and children of: foods that are not onhand and are set to use children as substitutes
|
||||
Q(onhand_users__in=shopping_users) | Q(ignore_shopping=True, recipe__isnull=True) | Q(substitute__onhand_users__in=shopping_users)
|
||||
)
|
||||
.exclude(depth=1, numchild=0)
|
||||
.filter(substitute_children=True)
|
||||
.annotate(child_onhand_count=Exists(children_onhand_subquery))
|
||||
.filter(child_onhand_count=True)
|
||||
)
|
||||
return Food.objects.exclude( # list of foods that are onhand and children of: foods that are not onhand and are set to use children as substitutes
|
||||
Q(onhand_users__in=shopping_users)
|
||||
| Q(ignore_shopping=True, recipe__isnull=True)
|
||||
| Q(substitute__onhand_users__in=shopping_users)
|
||||
).exclude(depth=1, numchild=0
|
||||
).filter(substitute_children=True
|
||||
).annotate(child_onhand_count=Exists(children_onhand_subquery)
|
||||
).filter(child_onhand_count=True)
|
||||
|
||||
@staticmethod
|
||||
def __sibling_substitute_filter(shopping_users=None):
|
||||
sibling_onhand_subquery = Food.objects.filter(
|
||||
path__startswith=Substr(
|
||||
OuterRef('path'), 1, Food.steplen * (OuterRef('depth') - 1)),
|
||||
depth=OuterRef('depth'),
|
||||
onhand_users__in=shopping_users
|
||||
path__startswith=Substr(OuterRef('path'), 1, Food.steplen * (OuterRef('depth') - 1)), depth=OuterRef('depth'), onhand_users__in=shopping_users
|
||||
)
|
||||
return Food.objects.exclude( # list of foods that are onhand and siblings of: foods that are not onhand and are set to use siblings as substitutes
|
||||
Q(onhand_users__in=shopping_users)
|
||||
| Q(ignore_shopping=True, recipe__isnull=True)
|
||||
| Q(substitute__onhand_users__in=shopping_users)
|
||||
).exclude(depth=1, numchild=0
|
||||
).filter(substitute_siblings=True
|
||||
).annotate(sibling_onhand=Exists(sibling_onhand_subquery)
|
||||
).filter(sibling_onhand=True)
|
||||
|
||||
|
||||
class RecipeFacet():
|
||||
class CacheEmpty(Exception):
|
||||
pass
|
||||
|
||||
def __init__(self, request, queryset=None, hash_key=None, cache_timeout=3600):
|
||||
if hash_key is None and queryset is None:
|
||||
raise ValueError(_("One of queryset or hash_key must be provided"))
|
||||
|
||||
self._request = request
|
||||
self._queryset = queryset
|
||||
self.hash_key = hash_key or str(hash(self._queryset.query))
|
||||
self._SEARCH_CACHE_KEY = f"recipes_filter_{self.hash_key}"
|
||||
self._cache_timeout = cache_timeout
|
||||
self._cache = caches['default'].get(self._SEARCH_CACHE_KEY, {})
|
||||
if self._cache is None and self._queryset is None:
|
||||
raise self.CacheEmpty("No queryset provided and cache empty")
|
||||
|
||||
self.Keywords = self._cache.get('Keywords', None)
|
||||
self.Foods = self._cache.get('Foods', None)
|
||||
self.Books = self._cache.get('Books', None)
|
||||
self.Ratings = self._cache.get('Ratings', None)
|
||||
# TODO Move Recent to recipe annotation/serializer: requrires change in RecipeSearch(), RecipeSearchView.vue and serializer
|
||||
self.Recent = self._cache.get('Recent', None)
|
||||
|
||||
if self._queryset is not None:
|
||||
self._recipe_list = list(
|
||||
self._queryset.values_list('id', flat=True))
|
||||
self._search_params = {
|
||||
'keyword_list': self._request.query_params.getlist('keywords', []),
|
||||
'food_list': self._request.query_params.getlist('foods', []),
|
||||
'book_list': self._request.query_params.getlist('book', []),
|
||||
'search_keywords_or': str2bool(self._request.query_params.get('keywords_or', True)),
|
||||
'search_foods_or': str2bool(self._request.query_params.get('foods_or', True)),
|
||||
'search_books_or': str2bool(self._request.query_params.get('books_or', True)),
|
||||
'space': self._request.space,
|
||||
}
|
||||
elif self.hash_key is not None:
|
||||
self._recipe_list = self._cache.get('recipe_list', [])
|
||||
self._search_params = {
|
||||
'keyword_list': self._cache.get('keyword_list', None),
|
||||
'food_list': self._cache.get('food_list', None),
|
||||
'book_list': self._cache.get('book_list', None),
|
||||
'search_keywords_or': self._cache.get('search_keywords_or', None),
|
||||
'search_foods_or': self._cache.get('search_foods_or', None),
|
||||
'search_books_or': self._cache.get('search_books_or', None),
|
||||
'space': self._cache.get('space', None),
|
||||
}
|
||||
|
||||
self._cache = {
|
||||
**self._search_params,
|
||||
'recipe_list': self._recipe_list,
|
||||
'Ratings': self.Ratings,
|
||||
'Recent': self.Recent,
|
||||
'Keywords': self.Keywords,
|
||||
'Foods': self.Foods,
|
||||
'Books': self.Books
|
||||
|
||||
}
|
||||
caches['default'].set(self._SEARCH_CACHE_KEY,
|
||||
self._cache, self._cache_timeout)
|
||||
|
||||
def get_facets(self, from_cache=False):
|
||||
if from_cache:
|
||||
return {
|
||||
'cache_key': self.hash_key or '',
|
||||
'Ratings': self.Ratings or {},
|
||||
'Recent': self.Recent or [],
|
||||
'Keywords': self.Keywords or [],
|
||||
'Foods': self.Foods or [],
|
||||
'Books': self.Books or []
|
||||
}
|
||||
return {
|
||||
'cache_key': self.hash_key,
|
||||
'Ratings': self.get_ratings(),
|
||||
'Recent': self.get_recent(),
|
||||
'Keywords': self.get_keywords(),
|
||||
'Foods': self.get_foods(),
|
||||
'Books': self.get_books()
|
||||
}
|
||||
|
||||
def set_cache(self, key, value):
|
||||
self._cache = {**self._cache, key: value}
|
||||
caches['default'].set(
|
||||
self._SEARCH_CACHE_KEY,
|
||||
self._cache,
|
||||
self._cache_timeout
|
||||
return (
|
||||
Food.objects.exclude( # list of foods that are onhand and siblings of: foods that are not onhand and are set to use siblings as substitutes
|
||||
Q(onhand_users__in=shopping_users) | Q(ignore_shopping=True, recipe__isnull=True) | Q(substitute__onhand_users__in=shopping_users)
|
||||
)
|
||||
.exclude(depth=1, numchild=0)
|
||||
.filter(substitute_siblings=True)
|
||||
.annotate(sibling_onhand=Exists(sibling_onhand_subquery))
|
||||
.filter(sibling_onhand=True)
|
||||
)
|
||||
|
||||
def get_books(self):
|
||||
if self.Books is None:
|
||||
self.Books = []
|
||||
return self.Books
|
||||
|
||||
def get_keywords(self):
|
||||
if self.Keywords is None:
|
||||
if self._search_params['search_keywords_or']:
|
||||
keywords = Keyword.objects.filter(
|
||||
space=self._request.space).distinct()
|
||||
else:
|
||||
keywords = Keyword.objects.filter(Q(recipe__in=self._recipe_list) | Q(
|
||||
depth=1)).filter(space=self._request.space).distinct()
|
||||
|
||||
# set keywords to root objects only
|
||||
keywords = self._keyword_queryset(keywords)
|
||||
self.Keywords = [{**x, 'children': None}
|
||||
if x['numchild'] > 0 else x for x in list(keywords)]
|
||||
self.set_cache('Keywords', self.Keywords)
|
||||
return self.Keywords
|
||||
|
||||
def get_foods(self):
|
||||
if self.Foods is None:
|
||||
# # if using an OR search, will annotate all keywords, otherwise, just those that appear in results
|
||||
if self._search_params['search_foods_or']:
|
||||
foods = Food.objects.filter(
|
||||
space=self._request.space).distinct()
|
||||
else:
|
||||
foods = Food.objects.filter(Q(ingredient__step__recipe__in=self._recipe_list) | Q(
|
||||
depth=1)).filter(space=self._request.space).distinct()
|
||||
|
||||
# set keywords to root objects only
|
||||
foods = self._food_queryset(foods)
|
||||
|
||||
self.Foods = [{**x, 'children': None}
|
||||
if x['numchild'] > 0 else x for x in list(foods)]
|
||||
self.set_cache('Foods', self.Foods)
|
||||
return self.Foods
|
||||
|
||||
def get_ratings(self):
|
||||
if self.Ratings is None:
|
||||
if not self._request.space.demo and self._request.space.show_facet_count:
|
||||
if self._queryset is None:
|
||||
self._queryset = Recipe.objects.filter(
|
||||
id__in=self._recipe_list)
|
||||
rating_qs = self._queryset.annotate(rating=Round(Avg(Case(When(
|
||||
cooklog__created_by=self._request.user, then='cooklog__rating'), default=Value(0)))))
|
||||
self.Ratings = dict(Counter(r.rating for r in rating_qs))
|
||||
else:
|
||||
self.Rating = {}
|
||||
self.set_cache('Ratings', self.Ratings)
|
||||
return self.Ratings
|
||||
|
||||
def get_recent(self):
|
||||
if self.Recent is None:
|
||||
# TODO make days of recent recipe a setting
|
||||
recent_recipes = ViewLog.objects.filter(created_by=self._request.user, space=self._request.space, created_at__gte=timezone.now() - timedelta(days=14)
|
||||
).values_list('recipe__pk', flat=True)
|
||||
self.Recent = list(recent_recipes)
|
||||
self.set_cache('Recent', self.Recent)
|
||||
return self.Recent
|
||||
|
||||
def add_food_children(self, id):
|
||||
try:
|
||||
food = Food.objects.get(id=id)
|
||||
nodes = food.get_ancestors()
|
||||
except Food.DoesNotExist:
|
||||
return self.get_facets()
|
||||
foods = self._food_queryset(food.get_children(), food)
|
||||
deep_search = self.Foods
|
||||
for node in nodes:
|
||||
index = next((i for i, x in enumerate(
|
||||
deep_search) if x["id"] == node.id), None)
|
||||
deep_search = deep_search[index]['children']
|
||||
index = next((i for i, x in enumerate(
|
||||
deep_search) if x["id"] == food.id), None)
|
||||
deep_search[index]['children'] = [
|
||||
{**x, 'children': None} if x['numchild'] > 0 else x for x in list(foods)]
|
||||
self.set_cache('Foods', self.Foods)
|
||||
return self.get_facets()
|
||||
|
||||
def add_keyword_children(self, id):
|
||||
try:
|
||||
keyword = Keyword.objects.get(id=id)
|
||||
nodes = keyword.get_ancestors()
|
||||
except Keyword.DoesNotExist:
|
||||
return self.get_facets()
|
||||
keywords = self._keyword_queryset(keyword.get_children(), keyword)
|
||||
deep_search = self.Keywords
|
||||
for node in nodes:
|
||||
index = next((i for i, x in enumerate(
|
||||
deep_search) if x["id"] == node.id), None)
|
||||
deep_search = deep_search[index]['children']
|
||||
index = next((i for i, x in enumerate(deep_search)
|
||||
if x["id"] == keyword.id), None)
|
||||
deep_search[index]['children'] = [
|
||||
{**x, 'children': None} if x['numchild'] > 0 else x for x in list(keywords)]
|
||||
self.set_cache('Keywords', self.Keywords)
|
||||
return self.get_facets()
|
||||
|
||||
def _recipe_count_queryset(self, field, depth=1, steplen=4):
|
||||
return Recipe.objects.filter(**{f'{field}__path__startswith': OuterRef('path'), f'{field}__depth__gte': depth}, id__in=self._recipe_list, space=self._request.space
|
||||
).annotate(count=Coalesce(Func('pk', function='Count'), 0)).values('count')
|
||||
|
||||
def _keyword_queryset(self, queryset, keyword=None):
|
||||
depth = getattr(keyword, 'depth', 0) + 1
|
||||
steplen = depth * Keyword.steplen
|
||||
|
||||
if not self._request.space.demo and self._request.space.show_facet_count:
|
||||
return queryset.annotate(count=Coalesce(Subquery(self._recipe_count_queryset('keywords', depth, steplen)), 0)
|
||||
).filter(depth=depth, count__gt=0
|
||||
).values('id', 'name', 'count', 'numchild').order_by(Lower('name').asc())[:200]
|
||||
else:
|
||||
return queryset.filter(depth=depth).values('id', 'name', 'numchild').order_by(Lower('name').asc())
|
||||
|
||||
def _food_queryset(self, queryset, food=None):
|
||||
depth = getattr(food, 'depth', 0) + 1
|
||||
steplen = depth * Food.steplen
|
||||
|
||||
if not self._request.space.demo and self._request.space.show_facet_count:
|
||||
return queryset.annotate(count=Coalesce(Subquery(self._recipe_count_queryset('steps__ingredients__food', depth, steplen)), 0)
|
||||
).filter(depth__lte=depth, count__gt=0
|
||||
).values('id', 'name', 'count', 'numchild').order_by(Lower('name').asc())[:200]
|
||||
else:
|
||||
return queryset.filter(depth__lte=depth).values('id', 'name', 'numchild').order_by(Lower('name').asc())
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
# import random
|
||||
import re
|
||||
import traceback
|
||||
from html import unescape
|
||||
|
||||
from django.core.cache import caches
|
||||
from django.utils.dateparse import parse_duration
|
||||
from django.utils.translation import gettext as _
|
||||
from isodate import parse_duration as iso_parse_duration
|
||||
@@ -11,20 +9,37 @@ from isodate.isoerror import ISO8601Error
|
||||
from pytube import YouTube
|
||||
from recipe_scrapers._utils import get_host_name, get_minutes
|
||||
|
||||
# from cookbook.helper import recipe_url_import as helper
|
||||
from cookbook.helper.automation_helper import AutomationEngine
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.models import Automation, Keyword, PropertyType
|
||||
|
||||
|
||||
# from unicodedata import decomposition
|
||||
|
||||
|
||||
# from recipe_scrapers._utils import get_minutes ## temporary until/unless upstream incorporates get_minutes() PR
|
||||
|
||||
|
||||
def get_from_scraper(scrape, request):
|
||||
# converting the scrape_me object to the existing json format based on ld+json
|
||||
recipe_json = {}
|
||||
|
||||
recipe_json = {
|
||||
'steps': [],
|
||||
'internal': True
|
||||
}
|
||||
keywords = []
|
||||
|
||||
# assign source URL
|
||||
try:
|
||||
source_url = scrape.canonical_url()
|
||||
except Exception:
|
||||
try:
|
||||
source_url = scrape.url
|
||||
except Exception:
|
||||
pass
|
||||
if source_url:
|
||||
recipe_json['source_url'] = source_url
|
||||
try:
|
||||
keywords.append(source_url.replace('http://', '').replace('https://', '').split('/')[0])
|
||||
except Exception:
|
||||
recipe_json['source_url'] = ''
|
||||
|
||||
automation_engine = AutomationEngine(request, source=recipe_json.get('source_url'))
|
||||
# assign recipe name
|
||||
try:
|
||||
recipe_json['name'] = parse_name(scrape.title()[:128] or None)
|
||||
except Exception:
|
||||
@@ -38,6 +53,10 @@ def get_from_scraper(scrape, request):
|
||||
if isinstance(recipe_json['name'], list) and len(recipe_json['name']) > 0:
|
||||
recipe_json['name'] = recipe_json['name'][0]
|
||||
|
||||
recipe_json['name'] = automation_engine.apply_regex_replace_automation(recipe_json['name'], Automation.NAME_REPLACE)
|
||||
|
||||
# assign recipe description
|
||||
# TODO notify user about limit if reached - >256 description will be truncated
|
||||
try:
|
||||
description = scrape.description() or None
|
||||
except Exception:
|
||||
@@ -48,16 +67,20 @@ def get_from_scraper(scrape, request):
|
||||
except Exception:
|
||||
description = ''
|
||||
|
||||
recipe_json['internal'] = True
|
||||
recipe_json['description'] = parse_description(description)
|
||||
recipe_json['description'] = automation_engine.apply_regex_replace_automation(recipe_json['description'], Automation.DESCRIPTION_REPLACE)
|
||||
|
||||
# assign servings attributes
|
||||
try:
|
||||
servings = scrape.schema.data.get('recipeYield') or 1 # dont use scrape.yields() as this will always return "x servings" or "x items", should be improved in scrapers directly
|
||||
# dont use scrape.yields() as this will always return "x servings" or "x items", should be improved in scrapers directly
|
||||
servings = scrape.schema.data.get('recipeYield') or 1
|
||||
except Exception:
|
||||
servings = 1
|
||||
|
||||
recipe_json['servings'] = parse_servings(servings)
|
||||
recipe_json['servings_text'] = parse_servings_text(servings)
|
||||
|
||||
# assign time attributes
|
||||
try:
|
||||
recipe_json['working_time'] = get_minutes(scrape.prep_time()) or 0
|
||||
except Exception:
|
||||
@@ -82,6 +105,7 @@ def get_from_scraper(scrape, request):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# assign image
|
||||
try:
|
||||
recipe_json['image'] = parse_image(scrape.image()) or None
|
||||
except Exception:
|
||||
@@ -92,7 +116,7 @@ def get_from_scraper(scrape, request):
|
||||
except Exception:
|
||||
recipe_json['image'] = ''
|
||||
|
||||
keywords = []
|
||||
# assign keywords
|
||||
try:
|
||||
if scrape.schema.data.get("keywords"):
|
||||
keywords += listify_keywords(scrape.schema.data.get("keywords"))
|
||||
@@ -117,20 +141,6 @@ def get_from_scraper(scrape, request):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
source_url = scrape.canonical_url()
|
||||
except Exception:
|
||||
try:
|
||||
source_url = scrape.url
|
||||
except Exception:
|
||||
pass
|
||||
if source_url:
|
||||
recipe_json['source_url'] = source_url
|
||||
try:
|
||||
keywords.append(source_url.replace('http://', '').replace('https://', '').split('/')[0])
|
||||
except Exception:
|
||||
recipe_json['source_url'] = ''
|
||||
|
||||
try:
|
||||
if scrape.author():
|
||||
keywords.append(scrape.author())
|
||||
@@ -138,33 +148,24 @@ def get_from_scraper(scrape, request):
|
||||
pass
|
||||
|
||||
try:
|
||||
recipe_json['keywords'] = parse_keywords(list(set(map(str.casefold, keywords))), request.space)
|
||||
recipe_json['keywords'] = parse_keywords(list(set(map(str.casefold, keywords))), request)
|
||||
except AttributeError:
|
||||
recipe_json['keywords'] = keywords
|
||||
|
||||
ingredient_parser = IngredientParser(request, True)
|
||||
|
||||
recipe_json['steps'] = []
|
||||
# assign steps
|
||||
try:
|
||||
for i in parse_instructions(scrape.instructions()):
|
||||
recipe_json['steps'].append({'instruction': i, 'ingredients': [], })
|
||||
recipe_json['steps'].append({'instruction': i, 'ingredients': [], 'show_ingredients_table': request.user.userpreference.show_step_ingredients, })
|
||||
except Exception:
|
||||
pass
|
||||
if len(recipe_json['steps']) == 0:
|
||||
recipe_json['steps'].append({'instruction': '', 'ingredients': [], })
|
||||
|
||||
parsed_description = parse_description(description)
|
||||
# TODO notify user about limit if reached
|
||||
# limits exist to limit the attack surface for dos style attacks
|
||||
automations = Automation.objects.filter(type=Automation.DESCRIPTION_REPLACE, space=request.space, disabled=False).only('param_1', 'param_2', 'param_3').all().order_by('order')[:512]
|
||||
for a in automations:
|
||||
if re.match(a.param_1, (recipe_json['source_url'])[:512]):
|
||||
parsed_description = re.sub(a.param_2, a.param_3, parsed_description, count=1)
|
||||
|
||||
if len(parsed_description) > 256: # split at 256 as long descriptions don't look good on recipe cards
|
||||
recipe_json['steps'][0]['instruction'] = f'*{parsed_description}* \n\n' + recipe_json['steps'][0]['instruction']
|
||||
else:
|
||||
recipe_json['description'] = parsed_description[:512]
|
||||
recipe_json['description'] = recipe_json['description'][:512]
|
||||
if len(recipe_json['description']) > 256: # split at 256 as long descriptions don't look good on recipe cards
|
||||
recipe_json['steps'][0]['instruction'] = f"*{recipe_json['description']}* \n\n" + recipe_json['steps'][0]['instruction']
|
||||
|
||||
try:
|
||||
for x in scrape.ingredients():
|
||||
@@ -205,12 +206,9 @@ def get_from_scraper(scrape, request):
|
||||
traceback.print_exc()
|
||||
pass
|
||||
|
||||
if 'source_url' in recipe_json and recipe_json['source_url']:
|
||||
automations = Automation.objects.filter(type=Automation.INSTRUCTION_REPLACE, space=request.space, disabled=False).only('param_1', 'param_2', 'param_3').order_by('order').all()[:512]
|
||||
for a in automations:
|
||||
if re.match(a.param_1, (recipe_json['source_url'])[:512]):
|
||||
for s in recipe_json['steps']:
|
||||
s['instruction'] = re.sub(a.param_2, a.param_3, s['instruction'])
|
||||
for s in recipe_json['steps']:
|
||||
s['instruction'] = automation_engine.apply_regex_replace_automation(s['instruction'], Automation.INSTRUCTION_REPLACE)
|
||||
# re.sub(a.param_2, a.param_3, s['instruction'])
|
||||
|
||||
return recipe_json
|
||||
|
||||
@@ -233,7 +231,7 @@ def get_recipe_properties(space, property_data):
|
||||
'id': pt.id,
|
||||
'name': pt.name,
|
||||
},
|
||||
'property_amount': parse_servings(property_data[properties[p]]) / float(property_data['servingSize']),
|
||||
'property_amount': parse_servings(property_data[properties[p]]) / parse_servings(property_data['servingSize']),
|
||||
})
|
||||
|
||||
return recipe_properties
|
||||
@@ -261,10 +259,14 @@ def get_from_youtube_scraper(url, request):
|
||||
}
|
||||
|
||||
try:
|
||||
video = YouTube(url=url)
|
||||
default_recipe_json['name'] = video.title
|
||||
automation_engine = AutomationEngine(request, source=url)
|
||||
video = YouTube(url)
|
||||
video.streams.first() # this is required to execute some kind of generator/web request that fetches the description
|
||||
default_recipe_json['name'] = automation_engine.apply_regex_replace_automation(video.title, Automation.NAME_REPLACE)
|
||||
default_recipe_json['image'] = video.thumbnail_url
|
||||
default_recipe_json['steps'][0]['instruction'] = video.description
|
||||
if video.description:
|
||||
default_recipe_json['steps'][0]['instruction'] = automation_engine.apply_regex_replace_automation(video.description, Automation.INSTRUCTION_REPLACE)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -272,7 +274,7 @@ def get_from_youtube_scraper(url, request):
|
||||
|
||||
|
||||
def parse_name(name):
|
||||
if type(name) == list:
|
||||
if isinstance(name, list):
|
||||
try:
|
||||
name = name[0]
|
||||
except Exception:
|
||||
@@ -316,16 +318,16 @@ def parse_instructions(instructions):
|
||||
"""
|
||||
instruction_list = []
|
||||
|
||||
if type(instructions) == list:
|
||||
if isinstance(instructions, list):
|
||||
for i in instructions:
|
||||
if type(i) == str:
|
||||
if isinstance(i, str):
|
||||
instruction_list.append(clean_instruction_string(i))
|
||||
else:
|
||||
if 'text' in i:
|
||||
instruction_list.append(clean_instruction_string(i['text']))
|
||||
elif 'itemListElement' in i:
|
||||
for ile in i['itemListElement']:
|
||||
if type(ile) == str:
|
||||
if isinstance(ile, str):
|
||||
instruction_list.append(clean_instruction_string(ile))
|
||||
elif 'text' in ile:
|
||||
instruction_list.append(clean_instruction_string(ile['text']))
|
||||
@@ -341,13 +343,13 @@ def parse_image(image):
|
||||
# check if list of images is returned, take first if so
|
||||
if not image:
|
||||
return None
|
||||
if type(image) == list:
|
||||
if isinstance(image, list):
|
||||
for pic in image:
|
||||
if (type(pic) == str) and (pic[:4] == 'http'):
|
||||
if (isinstance(pic, str)) and (pic[:4] == 'http'):
|
||||
image = pic
|
||||
elif 'url' in pic:
|
||||
image = pic['url']
|
||||
elif type(image) == dict:
|
||||
elif isinstance(image, dict):
|
||||
if 'url' in image:
|
||||
image = image['url']
|
||||
|
||||
@@ -358,12 +360,12 @@ def parse_image(image):
|
||||
|
||||
|
||||
def parse_servings(servings):
|
||||
if type(servings) == str:
|
||||
if isinstance(servings, str):
|
||||
try:
|
||||
servings = int(re.search(r'\d+', servings).group())
|
||||
except AttributeError:
|
||||
servings = 1
|
||||
elif type(servings) == list:
|
||||
elif isinstance(servings, list):
|
||||
try:
|
||||
servings = int(re.findall(r'\b\d+\b', servings[0])[0])
|
||||
except KeyError:
|
||||
@@ -372,12 +374,12 @@ def parse_servings(servings):
|
||||
|
||||
|
||||
def parse_servings_text(servings):
|
||||
if type(servings) == str:
|
||||
if isinstance(servings, str):
|
||||
try:
|
||||
servings = re.sub("\d+", '', servings).strip()
|
||||
servings = re.sub("\\d+", '', servings).strip()
|
||||
except Exception:
|
||||
servings = ''
|
||||
if type(servings) == list:
|
||||
if isinstance(servings, list):
|
||||
try:
|
||||
servings = parse_servings_text(servings[1])
|
||||
except Exception:
|
||||
@@ -394,7 +396,7 @@ def parse_time(recipe_time):
|
||||
recipe_time = round(iso_parse_duration(recipe_time).seconds / 60)
|
||||
except ISO8601Error:
|
||||
try:
|
||||
if (type(recipe_time) == list and len(recipe_time) > 0):
|
||||
if (isinstance(recipe_time, list) and len(recipe_time) > 0):
|
||||
recipe_time = recipe_time[0]
|
||||
recipe_time = round(parse_duration(recipe_time).seconds / 60)
|
||||
except AttributeError:
|
||||
@@ -403,18 +405,9 @@ def parse_time(recipe_time):
|
||||
return recipe_time
|
||||
|
||||
|
||||
def parse_keywords(keyword_json, space):
|
||||
def parse_keywords(keyword_json, request):
|
||||
keywords = []
|
||||
keyword_aliases = {}
|
||||
# retrieve keyword automation cache if it exists, otherwise build from database
|
||||
KEYWORD_CACHE_KEY = f'automation_keyword_alias_{space.pk}'
|
||||
if c := caches['default'].get(KEYWORD_CACHE_KEY, None):
|
||||
self.food_aliases = c
|
||||
caches['default'].touch(KEYWORD_CACHE_KEY, 30)
|
||||
else:
|
||||
for a in Automation.objects.filter(space=space, disabled=False, type=Automation.KEYWORD_ALIAS).only('param_1', 'param_2').order_by('order').all():
|
||||
keyword_aliases[a.param_1] = a.param_2
|
||||
caches['default'].set(KEYWORD_CACHE_KEY, keyword_aliases, 30)
|
||||
automation_engine = AutomationEngine(request)
|
||||
|
||||
# keywords as list
|
||||
for kw in keyword_json:
|
||||
@@ -422,12 +415,8 @@ def parse_keywords(keyword_json, space):
|
||||
# if alias exists use that instead
|
||||
|
||||
if len(kw) != 0:
|
||||
if keyword_aliases:
|
||||
try:
|
||||
kw = keyword_aliases[kw]
|
||||
except KeyError:
|
||||
pass
|
||||
if k := Keyword.objects.filter(name=kw, space=space).first():
|
||||
kw = automation_engine.apply_keyword_automation(kw)
|
||||
if k := Keyword.objects.filter(name__iexact=kw, space=request.space).first():
|
||||
keywords.append({'label': str(k), 'name': k.name, 'id': k.id})
|
||||
else:
|
||||
keywords.append({'label': kw, 'name': kw})
|
||||
@@ -438,15 +427,15 @@ def parse_keywords(keyword_json, space):
|
||||
def listify_keywords(keyword_list):
|
||||
# keywords as string
|
||||
try:
|
||||
if type(keyword_list[0]) == dict:
|
||||
if isinstance(keyword_list[0], dict):
|
||||
return keyword_list
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
if type(keyword_list) == str:
|
||||
if isinstance(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]):
|
||||
if (isinstance(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]
|
||||
|
||||
@@ -500,13 +489,13 @@ def get_images_from_soup(soup, url):
|
||||
|
||||
|
||||
def clean_dict(input_dict, key):
|
||||
if type(input_dict) == dict:
|
||||
if isinstance(input_dict, dict):
|
||||
for x in list(input_dict):
|
||||
if x == key:
|
||||
del input_dict[x]
|
||||
elif type(input_dict[x]) == dict:
|
||||
elif isinstance(input_dict[x], dict):
|
||||
input_dict[x] = clean_dict(input_dict[x], key)
|
||||
elif type(input_dict[x]) == list:
|
||||
elif isinstance(input_dict[x], list):
|
||||
temp_list = []
|
||||
for e in input_dict[x]:
|
||||
temp_list.append(clean_dict(e, key))
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from django.urls import reverse
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
|
||||
from rest_framework.authentication import TokenAuthentication
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from cookbook.views import views
|
||||
@@ -50,7 +48,6 @@ class ScopeMiddleware:
|
||||
return views.no_groups(request)
|
||||
|
||||
request.space = user_space.space
|
||||
# with scopes_disabled():
|
||||
with scope(space=request.space):
|
||||
return self.get_response(request)
|
||||
else:
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.db.models import F, OuterRef, Q, Subquery, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.HelperFunctions import Round, str2bool
|
||||
from cookbook.models import (Ingredient, MealPlan, Recipe, ShoppingListEntry, ShoppingListRecipe,
|
||||
SupermarketCategoryRelation)
|
||||
from recipes import settings
|
||||
|
||||
|
||||
def shopping_helper(qs, request):
|
||||
@@ -30,9 +26,6 @@ def shopping_helper(qs, request):
|
||||
elif checked in ['true', 1, '1']:
|
||||
qs = qs.filter(checked=True)
|
||||
elif checked in ['recent']:
|
||||
today_start = timezone.now().replace(hour=0, minute=0, second=0)
|
||||
week_ago = today_start - timedelta(days=user.userpreference.shopping_recent_days)
|
||||
qs = qs.filter(Q(checked=False) | Q(completed_at__gte=week_ago))
|
||||
supermarket_order = ['checked'] + supermarket_order
|
||||
|
||||
return qs.distinct().order_by(*supermarket_order).select_related('unit', 'food', 'ingredient', 'created_by', 'list_recipe', 'list_recipe__mealplan', 'list_recipe__recipe')
|
||||
@@ -47,7 +40,7 @@ class RecipeShoppingEditor():
|
||||
self.mealplan = self._kwargs.get('mealplan', None)
|
||||
if type(self.mealplan) in [int, float]:
|
||||
self.mealplan = MealPlan.objects.filter(id=self.mealplan, space=self.space)
|
||||
if type(self.mealplan) == dict:
|
||||
if isinstance(self.mealplan, dict):
|
||||
self.mealplan = MealPlan.objects.filter(id=self.mealplan['id'], space=self.space).first()
|
||||
self.id = self._kwargs.get('id', None)
|
||||
|
||||
@@ -69,11 +62,12 @@ class RecipeShoppingEditor():
|
||||
|
||||
@property
|
||||
def _recipe_servings(self):
|
||||
return getattr(self.recipe, 'servings', None) or getattr(getattr(self.mealplan, 'recipe', None), 'servings', None) or getattr(getattr(self._shopping_list_recipe, 'recipe', None), 'servings', None)
|
||||
return getattr(self.recipe, 'servings', None) or getattr(getattr(self.mealplan, 'recipe', None), 'servings',
|
||||
None) or getattr(getattr(self._shopping_list_recipe, 'recipe', None), 'servings', None)
|
||||
|
||||
@property
|
||||
def _servings_factor(self):
|
||||
return Decimal(self.servings)/Decimal(self._recipe_servings)
|
||||
return Decimal(self.servings) / Decimal(self._recipe_servings)
|
||||
|
||||
@property
|
||||
def _shared_users(self):
|
||||
@@ -81,18 +75,17 @@ class RecipeShoppingEditor():
|
||||
|
||||
@staticmethod
|
||||
def get_shopping_list_recipe(id, user, space):
|
||||
return ShoppingListRecipe.objects.filter(id=id).filter(Q(shoppinglist__space=space) | Q(entries__space=space)).filter(
|
||||
Q(shoppinglist__created_by=user)
|
||||
| Q(shoppinglist__shared=user)
|
||||
| Q(entries__created_by=user)
|
||||
return ShoppingListRecipe.objects.filter(id=id).filter(entries__space=space).filter(
|
||||
Q(entries__created_by=user)
|
||||
| Q(entries__created_by__in=list(user.get_shopping_share()))
|
||||
).prefetch_related('entries').first()
|
||||
|
||||
def get_recipe_ingredients(self, id, exclude_onhand=False):
|
||||
if exclude_onhand:
|
||||
return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space).exclude(food__onhand_users__id__in=[x.id for x in self._shared_users])
|
||||
return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space).exclude(
|
||||
food__onhand_users__id__in=[x.id for x in self._shared_users])
|
||||
else:
|
||||
return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space)
|
||||
return Ingredient.objects.filter(step__recipe__id=id, food__ignore_shopping=False, space=self.space)
|
||||
|
||||
@property
|
||||
def _include_related(self):
|
||||
@@ -109,7 +102,7 @@ class RecipeShoppingEditor():
|
||||
self.servings = float(servings)
|
||||
|
||||
if mealplan := kwargs.get('mealplan', None):
|
||||
if type(mealplan) == dict:
|
||||
if isinstance(mealplan, dict):
|
||||
self.mealplan = MealPlan.objects.filter(id=mealplan['id'], space=self.space).first()
|
||||
else:
|
||||
self.mealplan = mealplan
|
||||
@@ -170,14 +163,14 @@ class RecipeShoppingEditor():
|
||||
try:
|
||||
self._shopping_list_recipe.delete()
|
||||
return True
|
||||
except:
|
||||
except BaseException:
|
||||
return False
|
||||
|
||||
def _add_ingredients(self, ingredients=None):
|
||||
if not ingredients:
|
||||
return
|
||||
elif type(ingredients) == list:
|
||||
ingredients = Ingredient.objects.filter(id__in=ingredients)
|
||||
elif isinstance(ingredients, list):
|
||||
ingredients = Ingredient.objects.filter(id__in=ingredients, food__ignore_shopping=False)
|
||||
existing = self._shopping_list_recipe.entries.filter(ingredient__in=ingredients).values_list('ingredient__pk', flat=True)
|
||||
add_ingredients = ingredients.exclude(id__in=existing)
|
||||
|
||||
@@ -199,120 +192,3 @@ class RecipeShoppingEditor():
|
||||
to_delete = self._shopping_list_recipe.entries.exclude(ingredient__in=ingredients)
|
||||
ShoppingListEntry.objects.filter(id__in=to_delete).delete()
|
||||
self._shopping_list_recipe = self.get_shopping_list_recipe(self.id, self.created_by, self.space)
|
||||
|
||||
|
||||
# # TODO refactor as class
|
||||
# def list_from_recipe(list_recipe=None, recipe=None, mealplan=None, servings=None, ingredients=None, created_by=None, space=None, append=False):
|
||||
# """
|
||||
# Creates ShoppingListRecipe and associated ShoppingListEntrys from a recipe or a meal plan with a recipe
|
||||
# :param list_recipe: Modify an existing ShoppingListRecipe
|
||||
# :param recipe: Recipe to use as list of ingredients. One of [recipe, mealplan] are required
|
||||
# :param mealplan: alternatively use a mealplan recipe as source of ingredients
|
||||
# :param servings: Optional: Number of servings to use to scale shoppinglist. If servings = 0 an existing recipe list will be deleted
|
||||
# :param ingredients: Ingredients, list of ingredient IDs to include on the shopping list. When not provided all ingredients will be used
|
||||
# :param append: If False will remove any entries not included with ingredients, when True will append ingredients to the shopping list
|
||||
# """
|
||||
# r = recipe or getattr(mealplan, 'recipe', None) or getattr(list_recipe, 'recipe', None)
|
||||
# if not r:
|
||||
# raise ValueError(_("You must supply a recipe or mealplan"))
|
||||
|
||||
# created_by = created_by or getattr(ShoppingListEntry.objects.filter(list_recipe=list_recipe).first(), 'created_by', None)
|
||||
# if not created_by:
|
||||
# raise ValueError(_("You must supply a created_by"))
|
||||
|
||||
# try:
|
||||
# servings = float(servings)
|
||||
# except (ValueError, TypeError):
|
||||
# servings = getattr(mealplan, 'servings', 1.0)
|
||||
|
||||
# servings_factor = servings / r.servings
|
||||
|
||||
# shared_users = list(created_by.get_shopping_share())
|
||||
# shared_users.append(created_by)
|
||||
# if list_recipe:
|
||||
# created = False
|
||||
# else:
|
||||
# list_recipe = ShoppingListRecipe.objects.create(recipe=r, mealplan=mealplan, servings=servings)
|
||||
# created = True
|
||||
|
||||
# related_step_ing = []
|
||||
# if servings == 0 and not created:
|
||||
# list_recipe.delete()
|
||||
# return []
|
||||
# elif ingredients:
|
||||
# ingredients = Ingredient.objects.filter(pk__in=ingredients, space=space)
|
||||
# else:
|
||||
# ingredients = Ingredient.objects.filter(step__recipe=r, food__ignore_shopping=False, space=space)
|
||||
|
||||
# if exclude_onhand := created_by.userpreference.mealplan_autoexclude_onhand:
|
||||
# ingredients = ingredients.exclude(food__onhand_users__id__in=[x.id for x in shared_users])
|
||||
|
||||
# if related := created_by.userpreference.mealplan_autoinclude_related:
|
||||
# # TODO: add levels of related recipes (related recipes of related recipes) to use when auto-adding mealplans
|
||||
# related_recipes = r.get_related_recipes()
|
||||
|
||||
# for x in related_recipes:
|
||||
# # related recipe is a Step serving size is driven by recipe serving size
|
||||
# # TODO once/if Steps can have a serving size this needs to be refactored
|
||||
# if exclude_onhand:
|
||||
# # if steps are used more than once in a recipe or subrecipe - I don' think this results in the desired behavior
|
||||
# related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users]).values_list('id', flat=True)
|
||||
# else:
|
||||
# related_step_ing += Ingredient.objects.filter(step__recipe=x, space=space).values_list('id', flat=True)
|
||||
|
||||
# x_ing = []
|
||||
# if ingredients.filter(food__recipe=x).exists():
|
||||
# for ing in ingredients.filter(food__recipe=x):
|
||||
# if exclude_onhand:
|
||||
# x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__onhand_users__id__in=[x.id for x in shared_users])
|
||||
# else:
|
||||
# x_ing = Ingredient.objects.filter(step__recipe=x, food__ignore_shopping=False, space=space).exclude(food__ignore_shopping=True)
|
||||
# for i in [x for x in x_ing]:
|
||||
# ShoppingListEntry.objects.create(
|
||||
# list_recipe=list_recipe,
|
||||
# food=i.food,
|
||||
# unit=i.unit,
|
||||
# ingredient=i,
|
||||
# amount=i.amount * Decimal(servings_factor),
|
||||
# created_by=created_by,
|
||||
# space=space,
|
||||
# )
|
||||
# # dont' add food to the shopping list that are actually recipes that will be added as ingredients
|
||||
# ingredients = ingredients.exclude(food__recipe=x)
|
||||
|
||||
# add_ingredients = list(ingredients.values_list('id', flat=True)) + related_step_ing
|
||||
# if not append:
|
||||
# existing_list = ShoppingListEntry.objects.filter(list_recipe=list_recipe)
|
||||
# # delete shopping list entries not included in ingredients
|
||||
# existing_list.exclude(ingredient__in=ingredients).delete()
|
||||
# # add shopping list entries that did not previously exist
|
||||
# add_ingredients = set(add_ingredients) - set(existing_list.values_list('ingredient__id', flat=True))
|
||||
# add_ingredients = Ingredient.objects.filter(id__in=add_ingredients, space=space)
|
||||
|
||||
# # if servings have changed, update the ShoppingListRecipe and existing Entries
|
||||
# if servings <= 0:
|
||||
# servings = 1
|
||||
|
||||
# if not created and list_recipe.servings != servings:
|
||||
# update_ingredients = set(ingredients.values_list('id', flat=True)) - set(add_ingredients.values_list('id', flat=True))
|
||||
# list_recipe.servings = servings
|
||||
# list_recipe.save()
|
||||
# for sle in ShoppingListEntry.objects.filter(list_recipe=list_recipe, ingredient__id__in=update_ingredients):
|
||||
# sle.amount = sle.ingredient.amount * Decimal(servings_factor)
|
||||
# sle.save()
|
||||
|
||||
# # add any missing Entries
|
||||
# for i in [x for x in add_ingredients if x.food]:
|
||||
|
||||
# ShoppingListEntry.objects.create(
|
||||
# list_recipe=list_recipe,
|
||||
# food=i.food,
|
||||
# unit=i.unit,
|
||||
# ingredient=i,
|
||||
# amount=i.amount * Decimal(servings_factor),
|
||||
# created_by=created_by,
|
||||
# space=space,
|
||||
# )
|
||||
|
||||
# # return all shopping list items
|
||||
# return list_recipe
|
||||
@@ -2,7 +2,6 @@ from gettext import gettext as _
|
||||
|
||||
import bleach
|
||||
import markdown as md
|
||||
from bleach_allowlist import markdown_attrs, markdown_tags
|
||||
from jinja2 import Template, TemplateSyntaxError, UndefinedError
|
||||
from markdown.extensions.tables import TableExtension
|
||||
|
||||
@@ -15,12 +14,14 @@ class IngredientObject(object):
|
||||
unit = ""
|
||||
food = ""
|
||||
note = ""
|
||||
numeric_amount = 0
|
||||
|
||||
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>"
|
||||
self.numeric_amount = float(ingredient.amount)
|
||||
if ingredient.unit:
|
||||
if ingredient.unit.plural_name in (None, ""):
|
||||
self.unit = bleach.clean(str(ingredient.unit))
|
||||
@@ -53,9 +54,17 @@ class IngredientObject(object):
|
||||
def render_instructions(step): # TODO deduplicate markdown cleanup code
|
||||
instructions = step.instruction
|
||||
|
||||
tags = markdown_tags + [
|
||||
'pre', 'table', 'td', 'tr', 'th', 'tbody', 'style', 'thead', 'img'
|
||||
]
|
||||
tags = {
|
||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"b", "i", "strong", "em", "tt",
|
||||
"p", "br",
|
||||
"span", "div", "blockquote", "code", "pre", "hr",
|
||||
"ul", "ol", "li", "dd", "dt",
|
||||
"img",
|
||||
"a",
|
||||
"sub", "sup",
|
||||
'pre', 'table', 'td', 'tr', 'th', 'tbody', 'style', 'thead'
|
||||
}
|
||||
parsed_md = md.markdown(
|
||||
instructions,
|
||||
extensions=[
|
||||
@@ -63,7 +72,11 @@ def render_instructions(step): # TODO deduplicate markdown cleanup code
|
||||
UrlizeExtension(), MarkdownFormatExtension()
|
||||
]
|
||||
)
|
||||
markdown_attrs['*'] = markdown_attrs['*'] + ['class', 'width', 'height']
|
||||
markdown_attrs = {
|
||||
"*": ["id", "class", 'width', 'height'],
|
||||
"img": ["src", "alt", "title"],
|
||||
"a": ["href", "alt", "title"],
|
||||
}
|
||||
|
||||
instructions = bleach.clean(parsed_md, tags, markdown_attrs)
|
||||
|
||||
@@ -72,9 +85,12 @@ def render_instructions(step): # TODO deduplicate markdown cleanup code
|
||||
for i in step.ingredients.all():
|
||||
ingredients.append(IngredientObject(i))
|
||||
|
||||
def scale(number):
|
||||
return f"<scalable-number v-bind:number='{bleach.clean(str(number))}' v-bind:factor='ingredient_factor'></scalable-number>"
|
||||
|
||||
try:
|
||||
template = Template(instructions)
|
||||
instructions = template.render(ingredients=ingredients)
|
||||
instructions = template.render(ingredients=ingredients, scale=scale)
|
||||
except TemplateSyntaxError:
|
||||
return _('Could not parse template code.') + ' Error: Template Syntax broken'
|
||||
except UndefinedError:
|
||||
|
||||
@@ -36,7 +36,7 @@ class ChefTap(Integration):
|
||||
|
||||
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), space=self.request.space,)
|
||||
step = Step.objects.create(instruction='\n'.join(directions), space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,)
|
||||
|
||||
if source_url != '':
|
||||
step.instruction += '\n' + source_url
|
||||
|
||||
@@ -4,6 +4,7 @@ from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Ingredient, Keyword, Recipe, Step
|
||||
|
||||
@@ -19,6 +20,10 @@ class Chowdown(Integration):
|
||||
direction_mode = False
|
||||
description_mode = False
|
||||
|
||||
description = None
|
||||
prep_time = None
|
||||
serving = None
|
||||
|
||||
ingredients = []
|
||||
directions = []
|
||||
descriptions = []
|
||||
@@ -26,6 +31,12 @@ class Chowdown(Integration):
|
||||
line = fl.decode("utf-8")
|
||||
if 'title:' in line:
|
||||
title = line.replace('title:', '').replace('"', '').strip()
|
||||
if 'description:' in line:
|
||||
description = line.replace('description:', '').replace('"', '').strip()
|
||||
if 'prep_time:' in line:
|
||||
prep_time = line.replace('prep_time:', '').replace('"', '').strip()
|
||||
if 'yield:' in line:
|
||||
serving = line.replace('yield:', '').replace('"', '').strip()
|
||||
if 'image:' in line:
|
||||
image = line.replace('image:', '').strip()
|
||||
if 'tags:' in line:
|
||||
@@ -48,15 +59,43 @@ class Chowdown(Integration):
|
||||
descriptions.append(line)
|
||||
|
||||
recipe = Recipe.objects.create(name=title, created_by=self.request.user, internal=True, space=self.request.space)
|
||||
if description:
|
||||
recipe.description = description
|
||||
|
||||
for k in tags.split(','):
|
||||
print(f'adding keyword {k.strip()}')
|
||||
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), space=self.request.space,
|
||||
)
|
||||
ingredients_added = False
|
||||
for direction in directions:
|
||||
if len(direction.strip()) > 0:
|
||||
step = Step.objects.create(
|
||||
instruction=direction, name='', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
|
||||
)
|
||||
else:
|
||||
step = Step.objects.create(
|
||||
instruction=direction, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
|
||||
)
|
||||
if not ingredients_added:
|
||||
ingredients_added = True
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
for ingredient in ingredients:
|
||||
if len(ingredient.strip()) > 0:
|
||||
amount, unit, food, note = ingredient_parser.parse(ingredient)
|
||||
f = ingredient_parser.get_food(food)
|
||||
u = ingredient_parser.get_unit(unit)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space,
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
if serving:
|
||||
recipe.servings = parse_servings(serving)
|
||||
recipe.servings_text = 'servings'
|
||||
|
||||
if prep_time:
|
||||
recipe.working_time = parse_time(prep_time)
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
for ingredient in ingredients:
|
||||
@@ -76,6 +115,7 @@ class Chowdown(Integration):
|
||||
if re.match(f'^images/{image}$', z.filename):
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)), filetype=get_filetype(z.filename))
|
||||
|
||||
recipe.save()
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
import re
|
||||
from gettext import gettext as _
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
import validators
|
||||
import yaml
|
||||
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import (get_from_scraper, get_images_from_soup,
|
||||
iso_duration_to_minutes)
|
||||
from cookbook.helper.scrapers.scrapers import text_scraper
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Ingredient, Keyword, Recipe, Step
|
||||
from cookbook.models import Ingredient, Recipe, Step
|
||||
|
||||
|
||||
class CookBookApp(Integration):
|
||||
@@ -25,7 +20,6 @@ class CookBookApp(Integration):
|
||||
def get_recipe_from_file(self, file):
|
||||
recipe_html = file.getvalue().decode("utf-8")
|
||||
|
||||
# recipe_json, recipe_tree, html_data, images = get_recipe_from_source(recipe_html, 'CookBookApp', self.request)
|
||||
scrape = text_scraper(text=recipe_html)
|
||||
recipe_json = get_from_scraper(scrape, self.request)
|
||||
images = list(dict.fromkeys(get_images_from_soup(scrape.soup, None)))
|
||||
@@ -37,7 +31,7 @@ class CookBookApp(Integration):
|
||||
|
||||
try:
|
||||
recipe.servings = re.findall('([0-9])+', recipe_json['recipeYield'])[0]
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
@@ -47,7 +41,8 @@ class CookBookApp(Integration):
|
||||
pass
|
||||
|
||||
# assuming import files only contain single step
|
||||
step = Step.objects.create(instruction=recipe_json['steps'][0]['instruction'], space=self.request.space, )
|
||||
step = Step.objects.create(instruction=recipe_json['steps'][0]['instruction'], space=self.request.space,
|
||||
show_ingredients_table=self.request.user.userpreference.show_step_ingredients, )
|
||||
|
||||
if 'nutrition' in recipe_json:
|
||||
step.instruction = step.instruction + '\n\n' + recipe_json['nutrition']
|
||||
@@ -62,7 +57,7 @@ class CookBookApp(Integration):
|
||||
if unit := ingredient.get('unit', None):
|
||||
u = ingredient_parser.get_unit(unit.get('name', None))
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=ingredient.get('amount', None), note=ingredient.get('note', None), original_text=ingredient.get('original_text', None), space=self.request.space,
|
||||
food=f, unit=u, amount=ingredient.get('amount', None), note=ingredient.get('note', None), original_text=ingredient.get('original_text', None), space=self.request.space,
|
||||
))
|
||||
|
||||
if len(images) > 0:
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import base64
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
||||
from gettext import gettext as _
|
||||
|
||||
import requests
|
||||
import validators
|
||||
from lxml import etree
|
||||
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import parse_servings, parse_time, parse_servings_text
|
||||
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Ingredient, Keyword, Recipe, Step
|
||||
from cookbook.models import Ingredient, Recipe, Step
|
||||
|
||||
|
||||
class Cookmate(Integration):
|
||||
@@ -50,7 +45,7 @@ class Cookmate(Integration):
|
||||
for step in recipe_text.getchildren():
|
||||
if step.text:
|
||||
step = Step.objects.create(
|
||||
instruction=step.text.strip(), space=self.request.space,
|
||||
instruction=step.text.strip(), space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
|
||||
)
|
||||
recipe.steps.add(step)
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
@@ -26,12 +25,13 @@ class CopyMeThat(Integration):
|
||||
except AttributeError:
|
||||
source = None
|
||||
|
||||
recipe = Recipe.objects.create(name=file.find("div", {"id": "name"}).text.strip()[:128], source_url=source, created_by=self.request.user, internal=True, space=self.request.space, )
|
||||
recipe = Recipe.objects.create(name=file.find("div", {"id": "name"}).text.strip(
|
||||
)[:128], source_url=source, created_by=self.request.user, internal=True, space=self.request.space, )
|
||||
|
||||
for category in file.find_all("span", {"class": "recipeCategory"}):
|
||||
keyword, created = Keyword.objects.get_or_create(name=category.text, space=self.request.space)
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
|
||||
try:
|
||||
recipe.servings = parse_servings(file.find("a", {"id": "recipeYield"}).text.strip())
|
||||
recipe.working_time = iso_duration_to_minutes(file.find("span", {"meta": "prepTime"}).text.strip())
|
||||
@@ -51,7 +51,7 @@ class CopyMeThat(Integration):
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
step = Step.objects.create(instruction='', space=self.request.space, )
|
||||
step = Step.objects.create(instruction='', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, )
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
|
||||
@@ -61,7 +61,14 @@ class CopyMeThat(Integration):
|
||||
if not isinstance(ingredient, Tag) or not ingredient.text.strip() or "recipeIngredient_spacer" in ingredient['class']:
|
||||
continue
|
||||
if any(x in ingredient['class'] for x in ["recipeIngredient_subheader", "recipeIngredient_note"]):
|
||||
step.ingredients.add(Ingredient.objects.create(is_header=True, note=ingredient.text.strip()[:256], original_text=ingredient.text.strip(), space=self.request.space, ))
|
||||
step.ingredients.add(
|
||||
Ingredient.objects.create(
|
||||
is_header=True,
|
||||
note=ingredient.text.strip()[
|
||||
:256],
|
||||
original_text=ingredient.text.strip(),
|
||||
space=self.request.space,
|
||||
))
|
||||
else:
|
||||
amount, unit, food, note = ingredient_parser.parse(ingredient.text.strip())
|
||||
f = ingredient_parser.get_food(food)
|
||||
@@ -78,7 +85,7 @@ class CopyMeThat(Integration):
|
||||
step.save()
|
||||
recipe.steps.add(step)
|
||||
step = Step.objects.create(instruction='', space=self.request.space, )
|
||||
|
||||
|
||||
step.name = instruction.text.strip()[:128]
|
||||
else:
|
||||
step.instruction += instruction.text.strip() + ' \n\n'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import traceback
|
||||
from io import BytesIO, StringIO
|
||||
from re import match
|
||||
from zipfile import ZipFile
|
||||
@@ -19,7 +20,10 @@ class Default(Integration):
|
||||
recipe = self.decode_recipe(recipe_string)
|
||||
images = list(filter(lambda v: match('image.*', v), recipe_zip.namelist()))
|
||||
if images:
|
||||
self.import_recipe_image(recipe, BytesIO(recipe_zip.read(images[0])), filetype=get_filetype(images[0]))
|
||||
try:
|
||||
self.import_recipe_image(recipe, BytesIO(recipe_zip.read(images[0])), filetype=get_filetype(images[0]))
|
||||
except AttributeError:
|
||||
traceback.print_exc()
|
||||
return recipe
|
||||
|
||||
def decode_recipe(self, string):
|
||||
@@ -54,7 +58,7 @@ class Default(Integration):
|
||||
|
||||
try:
|
||||
recipe_zip_obj.writestr(f'image{get_filetype(r.image.file.name)}', r.image.file.read())
|
||||
except ValueError:
|
||||
except (ValueError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
recipe_zip_obj.close()
|
||||
@@ -67,4 +71,4 @@ class Default(Integration):
|
||||
|
||||
export_zip_obj.close()
|
||||
|
||||
return [[ self.get_export_file_name(), export_zip_stream.getvalue() ]]
|
||||
return [[self.get_export_file_name(), export_zip_stream.getvalue()]]
|
||||
|
||||
@@ -28,7 +28,7 @@ class Domestica(Integration):
|
||||
recipe.save()
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction=file['directions'], space=self.request.space,
|
||||
instruction=file['directions'], space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
|
||||
)
|
||||
|
||||
if file['source'] != '':
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import traceback
|
||||
import datetime
|
||||
import traceback
|
||||
import uuid
|
||||
@@ -18,8 +17,7 @@ from lxml import etree
|
||||
|
||||
from cookbook.helper.image_processing import handle_image
|
||||
from cookbook.models import Keyword, Recipe
|
||||
from recipes.settings import DEBUG
|
||||
from recipes.settings import EXPORT_FILE_CACHE_DURATION
|
||||
from recipes.settings import DEBUG, EXPORT_FILE_CACHE_DURATION
|
||||
|
||||
|
||||
class Integration:
|
||||
@@ -39,7 +37,6 @@ class Integration:
|
||||
self.ignored_recipes = []
|
||||
|
||||
description = f'Imported by {request.user.get_user_display_name()} at {date_format(datetime.datetime.now(), "DATETIME_FORMAT")}. Type: {export_type}'
|
||||
icon = '📥'
|
||||
|
||||
try:
|
||||
last_kw = Keyword.objects.filter(name__regex=r'^(Import [0-9]+)', space=request.space).latest('created_at')
|
||||
@@ -52,23 +49,19 @@ class Integration:
|
||||
self.keyword = parent.add_child(
|
||||
name=name,
|
||||
description=description,
|
||||
icon=icon,
|
||||
space=request.space
|
||||
)
|
||||
except (IntegrityError, ValueError): # in case, for whatever reason, the name does exist append UUID to it. Not nice but works for now.
|
||||
self.keyword = parent.add_child(
|
||||
name=f'{name} {str(uuid.uuid4())[0:8]}',
|
||||
description=description,
|
||||
icon=icon,
|
||||
space=request.space
|
||||
)
|
||||
|
||||
|
||||
|
||||
def do_export(self, recipes, el):
|
||||
|
||||
with scope(space=self.request.space):
|
||||
el.total_recipes = len(recipes)
|
||||
el.total_recipes = len(recipes)
|
||||
el.cache_duration = EXPORT_FILE_CACHE_DURATION
|
||||
el.save()
|
||||
|
||||
@@ -80,7 +73,7 @@ class Integration:
|
||||
export_file = file
|
||||
|
||||
else:
|
||||
#zip the files if there is more then one file
|
||||
# zip the files if there is more then one file
|
||||
export_filename = self.get_export_file_name()
|
||||
export_stream = BytesIO()
|
||||
export_obj = ZipFile(export_stream, 'w')
|
||||
@@ -91,8 +84,7 @@ class Integration:
|
||||
export_obj.close()
|
||||
export_file = export_stream.getvalue()
|
||||
|
||||
|
||||
cache.set('export_file_'+str(el.pk), {'filename': export_filename, 'file': export_file}, EXPORT_FILE_CACHE_DURATION)
|
||||
cache.set('export_file_' + str(el.pk), {'filename': export_filename, 'file': export_file}, EXPORT_FILE_CACHE_DURATION)
|
||||
el.running = False
|
||||
el.save()
|
||||
|
||||
@@ -100,7 +92,6 @@ class Integration:
|
||||
response['Content-Disposition'] = 'attachment; filename="' + export_filename + '"'
|
||||
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
|
||||
@@ -164,7 +155,7 @@ class Integration:
|
||||
|
||||
for z in file_list:
|
||||
try:
|
||||
if not hasattr(z, 'filename') or type(z) == Tag:
|
||||
if not hasattr(z, 'filename') or isinstance(z, Tag):
|
||||
recipe = self.get_recipe_from_file(z)
|
||||
else:
|
||||
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
|
||||
@@ -298,7 +289,6 @@ class Integration:
|
||||
if DEBUG:
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def get_export_file_name(self, format='zip'):
|
||||
return "export_{}.{}".format(datetime.datetime.now().strftime("%Y-%m-%d"), format)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Ingredient, Recipe, Step
|
||||
from cookbook.models import Ingredient, Keyword, Recipe, Step
|
||||
|
||||
|
||||
class Mealie(Integration):
|
||||
@@ -25,7 +25,7 @@ class Mealie(Integration):
|
||||
created_by=self.request.user, internal=True, space=self.request.space)
|
||||
|
||||
for s in recipe_json['recipe_instructions']:
|
||||
step = Step.objects.create(instruction=s['text'], space=self.request.space, )
|
||||
step = Step.objects.create(instruction=s['text'], space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, )
|
||||
recipe.steps.add(step)
|
||||
|
||||
step = recipe.steps.first()
|
||||
@@ -56,6 +56,12 @@ class Mealie(Integration):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if 'tags' in recipe_json and len(recipe_json['tags']) > 0:
|
||||
for k in recipe_json['tags']:
|
||||
if 'name' in k:
|
||||
keyword, created = Keyword.objects.get_or_create(name=k['name'].strip(), space=self.request.space)
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
if 'notes' in recipe_json and len(recipe_json['notes']) > 0:
|
||||
notes_text = "#### Notes \n\n"
|
||||
for n in recipe_json['notes']:
|
||||
|
||||
@@ -22,7 +22,7 @@ class MealMaster(Integration):
|
||||
if 'Yield:' in line:
|
||||
servings_text = line.replace('Yield:', '').strip()
|
||||
else:
|
||||
if re.match('\s{2,}([0-9])+', line):
|
||||
if re.match(r'\s{2,}([0-9])+', line):
|
||||
ingredients.append(line.strip())
|
||||
else:
|
||||
directions.append(line.strip())
|
||||
@@ -39,7 +39,7 @@ class MealMaster(Integration):
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction='\n'.join(directions) + '\n\n', space=self.request.space,
|
||||
instruction='\n'.join(directions) + '\n\n', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
|
||||
)
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
|
||||
@@ -57,7 +57,7 @@ class MelaRecipes(Integration):
|
||||
recipe.source_url = recipe_json['link']
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction=instruction, space=self.request.space,
|
||||
instruction=instruction, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients
|
||||
)
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
|
||||
@@ -2,13 +2,14 @@ import json
|
||||
import re
|
||||
from io import BytesIO, StringIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import iso_duration_to_minutes
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Ingredient, Keyword, Recipe, Step, NutritionInformation
|
||||
from cookbook.models import Ingredient, Keyword, NutritionInformation, Recipe, Step
|
||||
|
||||
|
||||
class NextcloudCookbook(Integration):
|
||||
@@ -51,14 +52,13 @@ class NextcloudCookbook(Integration):
|
||||
|
||||
ingredients_added = False
|
||||
for s in recipe_json['recipeInstructions']:
|
||||
instruction_text = ''
|
||||
if 'text' in s:
|
||||
step = Step.objects.create(
|
||||
instruction=s['text'], name=s['name'], space=self.request.space,
|
||||
instruction=s['text'], name=s['name'], space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
|
||||
)
|
||||
else:
|
||||
step = Step.objects.create(
|
||||
instruction=s, space=self.request.space,
|
||||
instruction=s, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
|
||||
)
|
||||
if not ingredients_added:
|
||||
if len(recipe_json['description'].strip()) > 500:
|
||||
@@ -91,7 +91,7 @@ class NextcloudCookbook(Integration):
|
||||
if nutrition != {}:
|
||||
recipe.nutrition = NutritionInformation.objects.create(**nutrition, space=self.request.space)
|
||||
recipe.save()
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for f in self.files:
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import json
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Ingredient, Recipe, Step, Keyword, Comment, CookLog
|
||||
from django.utils.translation import gettext as _
|
||||
from cookbook.models import Comment, CookLog, Ingredient, Keyword, Recipe, Step
|
||||
|
||||
|
||||
class OpenEats(Integration):
|
||||
|
||||
@@ -25,16 +27,16 @@ class OpenEats(Integration):
|
||||
if file["source"] != '':
|
||||
instructions += '\n' + _('Recipe source:') + f'[{file["source"]}]({file["source"]})'
|
||||
|
||||
cuisine_keyword, created = Keyword.objects.get_or_create(name="Cuisine", space=self.request.space)
|
||||
cuisine_keyword, created = Keyword.objects.get_or_create(name="Cuisine", space=self.request.space)
|
||||
if file["cuisine"] != '':
|
||||
keyword, created = Keyword.objects.get_or_create(name=file["cuisine"].strip(), space=self.request.space)
|
||||
keyword, created = Keyword.objects.get_or_create(name=file["cuisine"].strip(), space=self.request.space)
|
||||
if created:
|
||||
keyword.move(cuisine_keyword, pos="last-child")
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
course_keyword, created = Keyword.objects.get_or_create(name="Course", space=self.request.space)
|
||||
course_keyword, created = Keyword.objects.get_or_create(name="Course", space=self.request.space)
|
||||
if file["course"] != '':
|
||||
keyword, created = Keyword.objects.get_or_create(name=file["course"].strip(), space=self.request.space)
|
||||
keyword, created = Keyword.objects.get_or_create(name=file["course"].strip(), space=self.request.space)
|
||||
if created:
|
||||
keyword.move(course_keyword, pos="last-child")
|
||||
recipe.keywords.add(keyword)
|
||||
@@ -51,7 +53,7 @@ class OpenEats(Integration):
|
||||
recipe.image = f'recipes/openeats-import/{file["photo"]}'
|
||||
recipe.save()
|
||||
|
||||
step = Step.objects.create(instruction=instructions, space=self.request.space,)
|
||||
step = Step.objects.create(instruction=instructions, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,)
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
for ingredient in file['ingredients']:
|
||||
|
||||
@@ -58,7 +58,7 @@ class Paprika(Integration):
|
||||
pass
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction=instructions, space=self.request.space,
|
||||
instruction=instructions, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
|
||||
)
|
||||
|
||||
if 'description' in recipe_json and len(recipe_json['description'].strip()) > 500:
|
||||
@@ -90,7 +90,7 @@ class Paprika(Integration):
|
||||
if validators.url(url, public=True):
|
||||
response = requests.get(url)
|
||||
self.import_recipe_image(recipe, BytesIO(response.content))
|
||||
except:
|
||||
except Exception:
|
||||
if recipe_json.get("photo_data", None):
|
||||
self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_json['photo_data'])), filetype='.jpeg')
|
||||
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
import json
|
||||
from io import BytesIO
|
||||
from re import match
|
||||
from zipfile import ZipFile
|
||||
import asyncio
|
||||
from pyppeteer import launch
|
||||
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.serializer import RecipeExportSerializer
|
||||
|
||||
from cookbook.models import ExportLog
|
||||
from asgiref.sync import sync_to_async
|
||||
|
||||
import django.core.management.commands.runserver as runserver
|
||||
import logging
|
||||
from asgiref.sync import sync_to_async
|
||||
from pyppeteer import launch
|
||||
|
||||
from cookbook.integration.integration import Integration
|
||||
|
||||
|
||||
class PDFexport(Integration):
|
||||
|
||||
@@ -42,7 +32,6 @@ class PDFexport(Integration):
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
files = []
|
||||
for recipe in recipes:
|
||||
|
||||
@@ -50,20 +39,18 @@ class PDFexport(Integration):
|
||||
await page.emulateMedia('print')
|
||||
await page.setCookie(cookies)
|
||||
|
||||
await page.goto('http://'+cmd.default_addr+':'+cmd.default_port+'/view/recipe/'+str(recipe.id), {'waitUntil': 'domcontentloaded'})
|
||||
await page.waitForSelector('#printReady');
|
||||
await page.goto('http://' + cmd.default_addr + ':' + cmd.default_port + '/view/recipe/' + str(recipe.id), {'waitUntil': 'domcontentloaded'})
|
||||
await page.waitForSelector('#printReady')
|
||||
|
||||
files.append([recipe.name + '.pdf', await page.pdf(options)])
|
||||
await page.close();
|
||||
await page.close()
|
||||
|
||||
el.exported_recipes += 1
|
||||
el.msg += self.get_recipe_processed_msg(recipe)
|
||||
await sync_to_async(el.save, thread_sensitive=True)()
|
||||
|
||||
|
||||
await browser.close()
|
||||
return files
|
||||
|
||||
|
||||
def get_files_from_recipes(self, recipes, el, cookie):
|
||||
return asyncio.run(self.get_files_from_recipes_async(recipes, el, cookie))
|
||||
|
||||
@@ -35,7 +35,7 @@ class Pepperplate(Integration):
|
||||
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', space=self.request.space,
|
||||
instruction='\n'.join(directions) + '\n\n', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
|
||||
)
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
|
||||
@@ -46,7 +46,7 @@ class Plantoeat(Integration):
|
||||
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', space=self.request.space,
|
||||
instruction='\n'.join(directions) + '\n\n', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
|
||||
)
|
||||
|
||||
if tags:
|
||||
|
||||
@@ -46,7 +46,7 @@ class RecetteTek(Integration):
|
||||
if not instructions:
|
||||
instructions = ''
|
||||
|
||||
step = Step.objects.create(instruction=instructions, space=self.request.space,)
|
||||
step = Step.objects.create(instruction=instructions, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,)
|
||||
|
||||
# Append the original import url to the step (if it exists)
|
||||
try:
|
||||
|
||||
@@ -41,7 +41,7 @@ class RecipeKeeper(Integration):
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
step = Step.objects.create(instruction='', space=self.request.space, )
|
||||
step = Step.objects.create(instruction='', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, )
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
for ingredient in file.find("div", {"itemprop": "recipeIngredients"}).findChildren("p"):
|
||||
|
||||
@@ -39,7 +39,7 @@ class RecipeSage(Integration):
|
||||
ingredients_added = False
|
||||
for s in file['recipeInstructions']:
|
||||
step = Step.objects.create(
|
||||
instruction=s['text'], space=self.request.space,
|
||||
instruction=s['text'], space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
|
||||
)
|
||||
if not ingredients_added:
|
||||
ingredients_added = True
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from xml import etree
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import parse_time, parse_servings, parse_servings_text
|
||||
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Ingredient, Recipe, Step, Keyword
|
||||
from cookbook.models import Ingredient, Keyword, Recipe, Step
|
||||
|
||||
|
||||
class Rezeptsuitede(Integration):
|
||||
@@ -37,7 +35,7 @@ class Rezeptsuitede(Integration):
|
||||
try:
|
||||
if prep.find('step').text:
|
||||
step = Step.objects.create(
|
||||
instruction=prep.find('step').text.strip(), space=self.request.space,
|
||||
instruction=prep.find('step').text.strip(), space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
|
||||
)
|
||||
recipe.steps.add(step)
|
||||
except Exception:
|
||||
@@ -55,20 +53,23 @@ class Rezeptsuitede(Integration):
|
||||
u = ingredient_parser.get_unit(ingredient.attrib['unit'])
|
||||
amount = 0
|
||||
if ingredient.attrib['qty'].strip() != '':
|
||||
amount, unit, note = ingredient_parser.parse_amount(ingredient.attrib['qty'])
|
||||
try:
|
||||
amount, unit, note = ingredient_parser.parse_amount(ingredient.attrib['qty'])
|
||||
except ValueError: # sometimes quantities contain words which cant be parsed
|
||||
pass
|
||||
ingredient_step.ingredients.add(Ingredient.objects.create(food=f, unit=u, amount=amount, space=self.request.space, ))
|
||||
|
||||
try:
|
||||
k, created = Keyword.objects.get_or_create(name=recipe_xml.find('head').find('cat').text.strip(), space=self.request.space)
|
||||
recipe.keywords.add(k)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
recipe.save()
|
||||
|
||||
try:
|
||||
self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_xml.find('head').find('picbin').text)), filetype='.jpeg')
|
||||
except:
|
||||
except BaseException:
|
||||
pass
|
||||
|
||||
return recipe
|
||||
|
||||
@@ -38,7 +38,7 @@ class RezKonv(Integration):
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction=' \n'.join(directions) + '\n\n', space=self.request.space,
|
||||
instruction=' \n'.join(directions) + '\n\n', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
|
||||
)
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
@@ -60,8 +60,8 @@ class RezKonv(Integration):
|
||||
def split_recipe_file(self, file):
|
||||
recipe_list = []
|
||||
current_recipe = ''
|
||||
encoding_list = ['windows-1250',
|
||||
'latin-1'] # TODO build algorithm to try trough encodings and fail if none work, use for all importers
|
||||
# TODO build algorithm to try trough encodings and fail if none work, use for all importers
|
||||
# encoding_list = ['windows-1250', 'latin-1']
|
||||
encoding = 'windows-1250'
|
||||
for fl in file.readlines():
|
||||
try:
|
||||
|
||||
@@ -43,7 +43,7 @@ class Saffron(Integration):
|
||||
|
||||
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), space=self.request.space, )
|
||||
step = Step.objects.create(instruction='\n'.join(directions), space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients, )
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
for ingredient in ingredients:
|
||||
@@ -59,11 +59,11 @@ class Saffron(Integration):
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
|
||||
data = "Title: "+recipe.name if recipe.name else ""+"\n"
|
||||
data += "Description: "+recipe.description if recipe.description else ""+"\n"
|
||||
data = "Title: " + recipe.name if recipe.name else "" + "\n"
|
||||
data += "Description: " + recipe.description if recipe.description else "" + "\n"
|
||||
data += "Source: \n"
|
||||
data += "Original URL: \n"
|
||||
data += "Yield: "+str(recipe.servings)+"\n"
|
||||
data += "Yield: " + str(recipe.servings) + "\n"
|
||||
data += "Cookbook: \n"
|
||||
data += "Section: \n"
|
||||
data += "Image: \n"
|
||||
@@ -78,13 +78,13 @@ class Saffron(Integration):
|
||||
|
||||
data += "Ingredients: \n"
|
||||
for ingredient in recipeIngredient:
|
||||
data += ingredient+"\n"
|
||||
data += ingredient + "\n"
|
||||
|
||||
data += "Instructions: \n"
|
||||
for instruction in recipeInstructions:
|
||||
data += instruction+"\n"
|
||||
data += instruction + "\n"
|
||||
|
||||
return recipe.name+'.txt', data
|
||||
return recipe.name + '.txt', data
|
||||
|
||||
def get_files_from_recipes(self, recipes, el, cookie):
|
||||
files = []
|
||||
|
||||
Binary file not shown.
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2022-04-29 18:42+0200\n"
|
||||
"PO-Revision-Date: 2022-07-06 14:32+0000\n"
|
||||
"Last-Translator: Nidhal Brniyah <n1a1b1@gmail.com>\n"
|
||||
"PO-Revision-Date: 2023-11-28 11:03+0000\n"
|
||||
"Last-Translator: Mahmoud Aljouhari <mapgohary@gmail.com>\n"
|
||||
"Language-Team: Arabic <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/ar/>\n"
|
||||
"Language: ar\n"
|
||||
@@ -18,7 +18,7 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
|
||||
"&& n%100<=10 ? 3 : n%100>=11 ? 4 : 5;\n"
|
||||
"X-Generator: Weblate 4.10.1\n"
|
||||
"X-Generator: Weblate 4.15\n"
|
||||
|
||||
#: .\cookbook\filters.py:23 .\cookbook\templates\forms\ingredients.html:34
|
||||
#: .\cookbook\templates\space.html:49 .\cookbook\templates\stats.html:28
|
||||
@@ -2578,7 +2578,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\views\views.py:262
|
||||
msgid "This feature is not available in the demo version!"
|
||||
msgstr ""
|
||||
msgstr "هذه الميزة غير موجودة في النسخة التجريبية!"
|
||||
|
||||
#: .\cookbook\views\views.py:322
|
||||
msgid "You must select at least one field to search!"
|
||||
|
||||
Binary file not shown.
@@ -13,8 +13,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
|
||||
"PO-Revision-Date: 2023-04-12 11:55+0000\n"
|
||||
"Last-Translator: noxonad <noxonad@proton.me>\n"
|
||||
"PO-Revision-Date: 2023-07-06 21:19+0000\n"
|
||||
"Last-Translator: Rubens <rubenixnagios@gmail.com>\n"
|
||||
"Language-Team: Catalan <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/ca/>\n"
|
||||
"Language: ca\n"
|
||||
@@ -421,7 +421,7 @@ msgstr "Compartir Llista de la Compra"
|
||||
|
||||
#: .\cookbook\forms.py:525
|
||||
msgid "Autosync"
|
||||
msgstr "Autosync"
|
||||
msgstr "Autosinc"
|
||||
|
||||
#: .\cookbook\forms.py:526
|
||||
msgid "Auto Add Meal Plan"
|
||||
@@ -477,7 +477,7 @@ msgstr "Mostra el recompte de receptes als filtres de cerca"
|
||||
|
||||
#: .\cookbook\forms.py:559
|
||||
msgid "Use the plural form for units and food inside this space."
|
||||
msgstr ""
|
||||
msgstr "Empra el plural d'aquestes unitats i menjars dins de l'espai."
|
||||
|
||||
#: .\cookbook\helper\AllAuthCustomAdapter.py:39
|
||||
msgid ""
|
||||
|
||||
Binary file not shown.
@@ -11,8 +11,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-02-09 18:01+0100\n"
|
||||
"PO-Revision-Date: 2023-03-25 11:32+0000\n"
|
||||
"Last-Translator: Matěj Kubla <matykubla@gmail.com>\n"
|
||||
"PO-Revision-Date: 2024-01-09 12:07+0000\n"
|
||||
"Last-Translator: Jan Kubošek <kuboja@outlook.cz>\n"
|
||||
"Language-Team: Czech <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/cs/>\n"
|
||||
"Language: cs\n"
|
||||
@@ -36,7 +36,7 @@ msgid ""
|
||||
"try them out!"
|
||||
msgstr ""
|
||||
"Barva horního navigačního menu. Některé barvy neladí se všemi tématy a je "
|
||||
"třeba je vyzkoušet."
|
||||
"třeba je vyzkoušet!"
|
||||
|
||||
#: .\cookbook\forms.py:45
|
||||
msgid "Default Unit to be used when inserting a new ingredient into a recipe."
|
||||
@@ -50,7 +50,7 @@ msgid ""
|
||||
"to fractions automatically)"
|
||||
msgstr ""
|
||||
"Povolit podporu zlomků u množství ingrediencí (desetinná čísla budou "
|
||||
"automaticky převedena na zlomky)."
|
||||
"automaticky převedena na zlomky)"
|
||||
|
||||
#: .\cookbook\forms.py:47
|
||||
msgid ""
|
||||
@@ -190,7 +190,7 @@ msgid ""
|
||||
"Leave empty for dropbox and enter only base url for nextcloud "
|
||||
"(<code>/remote.php/webdav/</code> is added automatically)"
|
||||
msgstr ""
|
||||
"Pro dropbox ponechejte nevyplňené pole. Pro nextcloud použijte pouze "
|
||||
"Pro dropbox ponechejte nevyplněné pole. Pro nextcloud použijte pouze "
|
||||
"základní url (<code>/remote.php/webdav/</code> bude přidán automaticky)."
|
||||
|
||||
#: .\cookbook\forms.py:263
|
||||
@@ -529,7 +529,7 @@ msgstr "Dávková úprava receptu"
|
||||
|
||||
#: .\cookbook\templates\batch\edit.html:20
|
||||
msgid "Add the specified keywords to all recipes containing a word"
|
||||
msgstr "Přidat štítek ke všem receptům, které obsahují specifické slovo."
|
||||
msgstr "Přidat štítek ke všem receptům, které obsahují specifické slovo"
|
||||
|
||||
#: .\cookbook\templates\batch\monitor.html:6 .\cookbook\views\edit.py:66
|
||||
msgid "Sync"
|
||||
|
||||
Binary file not shown.
@@ -15,10 +15,10 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
|
||||
"PO-Revision-Date: 2023-02-09 13:55+0000\n"
|
||||
"Last-Translator: Marion Kämpfer <marion@murphyslantech.de>\n"
|
||||
"Language-Team: German <http://translate.tandoor.dev/projects/tandoor/recipes-"
|
||||
"backend/de/>\n"
|
||||
"PO-Revision-Date: 2024-02-13 16:19+0000\n"
|
||||
"Last-Translator: Kirstin Seidel-Gebert <kirstin@trebeg.de>\n"
|
||||
"Language-Team: German <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/de/>\n"
|
||||
"Language: de\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@@ -161,7 +161,7 @@ msgstr "Name"
|
||||
|
||||
#: .\cookbook\forms.py:124 .\cookbook\forms.py:315 .\cookbook\views\lists.py:88
|
||||
msgid "Keywords"
|
||||
msgstr "Stichwörter"
|
||||
msgstr "Schlagwörter"
|
||||
|
||||
#: .\cookbook\forms.py:125
|
||||
msgid "Preparation time in minutes"
|
||||
@@ -600,9 +600,8 @@ msgid "Imported %s recipes."
|
||||
msgstr "%s Rezepte importiert."
|
||||
|
||||
#: .\cookbook\integration\openeats.py:26
|
||||
#, fuzzy
|
||||
msgid "Recipe source:"
|
||||
msgstr "Rezept-Hauptseite"
|
||||
msgstr "Rezept-Quelle:"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:49
|
||||
msgid "Notes"
|
||||
@@ -1437,11 +1436,11 @@ msgid ""
|
||||
" "
|
||||
msgstr ""
|
||||
"\n"
|
||||
" <b>Password und Token</b> werden im <b>Klartext</b> in der Datenbank "
|
||||
" <b>Kennwort und Token</b> werden im <b>Klartext</b> in der Datenbank "
|
||||
"gespeichert.\n"
|
||||
" Dies ist notwendig da Passwort oder Token benötigt werden, um API-"
|
||||
"Anfragen zu stellen, bringt jedoch auch ein Sicherheitsrisiko mit sich. <br/"
|
||||
">\n"
|
||||
" Dies ist notwendig da Kennwort oder Token benötigt werden, um API-"
|
||||
"Anfragen zu stellen, bringt jedoch auch ein Sicherheitsrisiko mit sich. <br/>"
|
||||
"\n"
|
||||
" Um das Risiko zu minimieren sollten, wenn möglich, Tokens oder "
|
||||
"Accounts mit limitiertem Zugriff verwendet werden.\n"
|
||||
" "
|
||||
@@ -2601,7 +2600,7 @@ msgstr "Ungültiges URL Schema."
|
||||
|
||||
#: .\cookbook\views\api.py:1233
|
||||
msgid "No usable data could be found."
|
||||
msgstr "Es konnten keine nutzbaren Daten gefunden werden."
|
||||
msgstr "Es konnten keine passenden Daten gefunden werden."
|
||||
|
||||
#: .\cookbook\views\api.py:1326 .\cookbook\views\import_export.py:117
|
||||
msgid "Importing is not implemented for this provider"
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -14,8 +14,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
|
||||
"PO-Revision-Date: 2023-05-26 16:19+0000\n"
|
||||
"Last-Translator: Luis Cacho <luiscachog@gmail.com>\n"
|
||||
"PO-Revision-Date: 2023-09-25 09:59+0000\n"
|
||||
"Last-Translator: Matias Laporte <laportematias+weblate@gmail.com>\n"
|
||||
"Language-Team: Spanish <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/es/>\n"
|
||||
"Language: es\n"
|
||||
@@ -543,19 +543,19 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:268
|
||||
msgid "knead"
|
||||
msgstr ""
|
||||
msgstr "amasar"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:269
|
||||
msgid "thicken"
|
||||
msgstr ""
|
||||
msgstr "espesar"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:270
|
||||
msgid "warm up"
|
||||
msgstr ""
|
||||
msgstr "precalentar"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:271
|
||||
msgid "ferment"
|
||||
msgstr ""
|
||||
msgstr "fermentar"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:272
|
||||
msgid "sous-vide"
|
||||
@@ -573,11 +573,11 @@ msgstr ""
|
||||
#: .\cookbook\integration\copymethat.py:44
|
||||
#: .\cookbook\integration\melarecipes.py:37
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
msgstr "Favorito"
|
||||
|
||||
#: .\cookbook\integration\copymethat.py:50
|
||||
msgid "I made this"
|
||||
msgstr ""
|
||||
msgstr "Lo he preparado"
|
||||
|
||||
#: .\cookbook\integration\integration.py:218
|
||||
msgid ""
|
||||
@@ -604,7 +604,7 @@ msgstr "Se importaron %s recetas."
|
||||
|
||||
#: .\cookbook\integration\openeats.py:26
|
||||
msgid "Recipe source:"
|
||||
msgstr "Recipe source:"
|
||||
msgstr "Fuente de la receta:"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:49
|
||||
msgid "Notes"
|
||||
@@ -645,19 +645,21 @@ msgstr "Sección"
|
||||
|
||||
#: .\cookbook\management\commands\rebuildindex.py:14
|
||||
msgid "Rebuilds full text search index on Recipe"
|
||||
msgstr ""
|
||||
msgstr "Reconstruye el índice de búsqueda por texto completo de la receta"
|
||||
|
||||
#: .\cookbook\management\commands\rebuildindex.py:18
|
||||
msgid "Only Postgresql databases use full text search, no index to rebuild"
|
||||
msgstr ""
|
||||
"Solo las bases de datos Postgresql utilizan la búsqueda por texto completo, "
|
||||
"no hay índice para reconstruir"
|
||||
|
||||
#: .\cookbook\management\commands\rebuildindex.py:29
|
||||
msgid "Recipe index rebuild complete."
|
||||
msgstr ""
|
||||
msgstr "Se reconstruyó el índice de la receta."
|
||||
|
||||
#: .\cookbook\management\commands\rebuildindex.py:31
|
||||
msgid "Recipe index rebuild failed."
|
||||
msgstr ""
|
||||
msgstr "No fue posible reconstruir el índice de la receta."
|
||||
|
||||
#: .\cookbook\migrations\0047_auto_20200602_1133.py:14
|
||||
msgid "Breakfast"
|
||||
@@ -699,23 +701,23 @@ msgstr "Libros"
|
||||
|
||||
#: .\cookbook\models.py:580
|
||||
msgid " is part of a recipe step and cannot be deleted"
|
||||
msgstr ""
|
||||
msgstr " es parte del paso de una receta y no puede ser eliminado"
|
||||
|
||||
#: .\cookbook\models.py:1181 .\cookbook\templates\search_info.html:28
|
||||
msgid "Simple"
|
||||
msgstr ""
|
||||
msgstr "Simple"
|
||||
|
||||
#: .\cookbook\models.py:1182 .\cookbook\templates\search_info.html:33
|
||||
msgid "Phrase"
|
||||
msgstr ""
|
||||
msgstr "Frase"
|
||||
|
||||
#: .\cookbook\models.py:1183 .\cookbook\templates\search_info.html:38
|
||||
msgid "Web"
|
||||
msgstr ""
|
||||
msgstr "Web"
|
||||
|
||||
#: .\cookbook\models.py:1184 .\cookbook\templates\search_info.html:47
|
||||
msgid "Raw"
|
||||
msgstr ""
|
||||
msgstr "Crudo"
|
||||
|
||||
#: .\cookbook\models.py:1231
|
||||
msgid "Food Alias"
|
||||
@@ -762,49 +764,53 @@ msgstr "Palabra clave"
|
||||
|
||||
#: .\cookbook\serializer.py:198
|
||||
msgid "File uploads are not enabled for this Space."
|
||||
msgstr ""
|
||||
msgstr "Las cargas de archivo no están habilitadas para esta Instancia."
|
||||
|
||||
#: .\cookbook\serializer.py:209
|
||||
msgid "You have reached your file upload limit."
|
||||
msgstr ""
|
||||
msgstr "Has alcanzado el límite de cargas de archivo."
|
||||
|
||||
#: .\cookbook\serializer.py:291
|
||||
msgid "Cannot modify Space owner permission."
|
||||
msgstr ""
|
||||
msgstr "No puedes modificar los permisos del propietario de la Instancia."
|
||||
|
||||
#: .\cookbook\serializer.py:1093
|
||||
msgid "Hello"
|
||||
msgstr ""
|
||||
msgstr "Hola"
|
||||
|
||||
#: .\cookbook\serializer.py:1093
|
||||
msgid "You have been invited by "
|
||||
msgstr ""
|
||||
msgstr "Has sido invitado por: "
|
||||
|
||||
#: .\cookbook\serializer.py:1094
|
||||
msgid " to join their Tandoor Recipes space "
|
||||
msgstr ""
|
||||
msgstr " para unirte a su instancia de Tandoor Recipes "
|
||||
|
||||
#: .\cookbook\serializer.py:1095
|
||||
msgid "Click the following link to activate your account: "
|
||||
msgstr ""
|
||||
msgstr "Haz click en el siguiente enlace para activar tu cuenta: "
|
||||
|
||||
#: .\cookbook\serializer.py:1096
|
||||
msgid ""
|
||||
"If the link does not work use the following code to manually join the space: "
|
||||
msgstr ""
|
||||
"Si el enlace no funciona, utiliza el siguiente código para unirte "
|
||||
"manualmente a la instancia: "
|
||||
|
||||
#: .\cookbook\serializer.py:1097
|
||||
msgid "The invitation is valid until "
|
||||
msgstr ""
|
||||
msgstr "La invitación es válida hasta "
|
||||
|
||||
#: .\cookbook\serializer.py:1098
|
||||
msgid ""
|
||||
"Tandoor Recipes is an Open Source recipe manager. Check it out on GitHub "
|
||||
msgstr ""
|
||||
"Tandoor Recipes es un administrador de recetas Open Source. Dale una ojeada "
|
||||
"en GitHub "
|
||||
|
||||
#: .\cookbook\serializer.py:1101
|
||||
msgid "Tandoor Recipes Invite"
|
||||
msgstr ""
|
||||
msgstr "Invitación para Tandoor Recipes"
|
||||
|
||||
#: .\cookbook\serializer.py:1242
|
||||
msgid "Existing shopping list to update"
|
||||
|
||||
Binary file not shown.
@@ -14,10 +14,10 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
|
||||
"PO-Revision-Date: 2023-04-12 11:55+0000\n"
|
||||
"Last-Translator: noxonad <noxonad@proton.me>\n"
|
||||
"Language-Team: French <http://translate.tandoor.dev/projects/tandoor/recipes-"
|
||||
"backend/fr/>\n"
|
||||
"PO-Revision-Date: 2024-03-03 23:19+0000\n"
|
||||
"Last-Translator: Jocelin Lebreton <jocelin.lebreton@gmail.com>\n"
|
||||
"Language-Team: French <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/fr/>\n"
|
||||
"Language: fr\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@@ -310,7 +310,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Champs à rechercher en ignorant les accents. La sélection de cette option "
|
||||
"peut améliorer ou dégrader la qualité de la recherche en fonction de la "
|
||||
"langue."
|
||||
"langue"
|
||||
|
||||
#: .\cookbook\forms.py:466
|
||||
msgid ""
|
||||
@@ -326,8 +326,8 @@ msgid ""
|
||||
"will return 'salad' and 'sandwich')"
|
||||
msgstr ""
|
||||
"Champs permettant de rechercher les correspondances de début de mot (par "
|
||||
"exemple, si vous recherchez « sa », vous obtiendrez « salade » et "
|
||||
"« sandwich»)."
|
||||
"exemple, si vous recherchez « sa », vous obtiendrez « salade » et « "
|
||||
"sandwich»)"
|
||||
|
||||
#: .\cookbook\forms.py:470
|
||||
msgid ""
|
||||
@@ -546,38 +546,36 @@ msgid "One of queryset or hash_key must be provided"
|
||||
msgstr "Il est nécessaire de fournir soit le queryset, soit la clé de hachage"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:266
|
||||
#, fuzzy
|
||||
#| msgid "Use fractions"
|
||||
msgid "reverse rotation"
|
||||
msgstr "Utiliser les fractions"
|
||||
msgstr "sens inverse"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:267
|
||||
msgid "careful rotation"
|
||||
msgstr ""
|
||||
msgstr "sens horloger"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:268
|
||||
msgid "knead"
|
||||
msgstr ""
|
||||
msgstr "pétrir"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:269
|
||||
msgid "thicken"
|
||||
msgstr ""
|
||||
msgstr "épaissir"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:270
|
||||
msgid "warm up"
|
||||
msgstr ""
|
||||
msgstr "réchauffer"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:271
|
||||
msgid "ferment"
|
||||
msgstr ""
|
||||
msgstr "fermenter"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:272
|
||||
msgid "sous-vide"
|
||||
msgstr ""
|
||||
msgstr "sous-vide"
|
||||
|
||||
#: .\cookbook\helper\shopping_helper.py:157
|
||||
msgid "You must supply a servings size"
|
||||
msgstr "Vous devez fournir une information de portion"
|
||||
msgstr "Vous devez fournir un nombre de portions"
|
||||
|
||||
#: .\cookbook\helper\template_helper.py:79
|
||||
#: .\cookbook\helper\template_helper.py:81
|
||||
@@ -590,7 +588,6 @@ msgid "Favorite"
|
||||
msgstr "Favori"
|
||||
|
||||
#: .\cookbook\integration\copymethat.py:50
|
||||
#, fuzzy
|
||||
msgid "I made this"
|
||||
msgstr "J'ai fait ça"
|
||||
|
||||
@@ -620,10 +617,8 @@ msgid "Imported %s recipes."
|
||||
msgstr "%s recettes importées."
|
||||
|
||||
#: .\cookbook\integration\openeats.py:26
|
||||
#, fuzzy
|
||||
#| msgid "Recipe Home"
|
||||
msgid "Recipe source:"
|
||||
msgstr "Page d’accueil"
|
||||
msgstr "Source de la recette :"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:49
|
||||
msgid "Notes"
|
||||
@@ -648,7 +643,7 @@ msgstr "Portions"
|
||||
|
||||
#: .\cookbook\integration\saffron.py:25
|
||||
msgid "Waiting time"
|
||||
msgstr "temps d’attente"
|
||||
msgstr "Temps d’attente"
|
||||
|
||||
#: .\cookbook\integration\saffron.py:27
|
||||
msgid "Preparation Time"
|
||||
@@ -851,7 +846,6 @@ msgid "ID of unit to use for the shopping list"
|
||||
msgstr "ID de l’unité à utiliser pour la liste de courses"
|
||||
|
||||
#: .\cookbook\serializer.py:1259
|
||||
#, fuzzy
|
||||
msgid "When set to true will delete all food from active shopping lists."
|
||||
msgstr ""
|
||||
"Lorsqu'il est défini sur \"true\", tous les aliments des listes de courses "
|
||||
@@ -967,8 +961,9 @@ msgid ""
|
||||
" ."
|
||||
msgstr ""
|
||||
"Confirmez SVP que\n"
|
||||
" <a href=\"mailto:%(email)s\"></a> est une adresse mail de "
|
||||
"l’utilisateur %(user_display)s."
|
||||
" <a href=\"mailto:%(email)s\"></a> est une adresse mail de l’"
|
||||
"utilisateur %(user_display)s\n"
|
||||
" ."
|
||||
|
||||
#: .\cookbook\templates\account\email_confirm.html:22
|
||||
#: .\cookbook\templates\generic\delete_template.html:72
|
||||
@@ -1371,9 +1366,8 @@ msgid "Are you sure you want to delete the %(title)s: <b>%(object)s</b> "
|
||||
msgstr "Êtes-vous sûr(e) de vouloir supprimer %(title)s : <b>%(object)s</b> "
|
||||
|
||||
#: .\cookbook\templates\generic\delete_template.html:22
|
||||
#, fuzzy
|
||||
msgid "This cannot be undone!"
|
||||
msgstr "Cela ne peut pas être annulé !"
|
||||
msgstr "L'opération ne peut pas être annulée !"
|
||||
|
||||
#: .\cookbook\templates\generic\delete_template.html:27
|
||||
msgid "Protected"
|
||||
@@ -1456,12 +1450,12 @@ msgid ""
|
||||
" "
|
||||
msgstr ""
|
||||
"\n"
|
||||
" Les champs <b>Mot de passe et Token</b> sont stockés <b>en texte "
|
||||
"brut</b>dans la base de données.\n"
|
||||
" Les champs <b>Mot de passe et Token</b> sont stockés <b>en clair</"
|
||||
"b>dans la base de données.\n"
|
||||
" C'est nécessaire car ils sont utilisés pour faire des requêtes API, "
|
||||
"mais cela accroît le risque que quelqu'un les vole.<br/>\n"
|
||||
" Pour limiter les risques, des tokens ou comptes avec un accès limité "
|
||||
"devraient être utilisés.\n"
|
||||
" Pour limiter les risques, il est possible d'utiliser des tokens ou "
|
||||
"des comptes avec un accès limité.\n"
|
||||
" "
|
||||
|
||||
#: .\cookbook\templates\index.html:29
|
||||
@@ -1771,15 +1765,6 @@ msgstr ""
|
||||
" "
|
||||
|
||||
#: .\cookbook\templates\search_info.html:29
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| " \n"
|
||||
#| " Simple searches ignore punctuation and common words such as "
|
||||
#| "'the', 'a', 'and'. And will treat seperate words as required.\n"
|
||||
#| " Searching for 'apple or flour' will return any recipe that "
|
||||
#| "includes both 'apple' and 'flour' anywhere in the fields that have been "
|
||||
#| "selected for a full text search.\n"
|
||||
#| " "
|
||||
msgid ""
|
||||
" \n"
|
||||
" Simple searches ignore punctuation and common words such as "
|
||||
@@ -1791,7 +1776,7 @@ msgid ""
|
||||
msgstr ""
|
||||
" \n"
|
||||
" Les recherches simples ignorent la ponctuation et les mots "
|
||||
"courants tels que \"le\", \"a\", \"et\", et traiteront les mots séparés "
|
||||
"courants tels que \"le\", \"et\", \"a\", et traiteront les mots séparés "
|
||||
"comme il se doit.\n"
|
||||
" Si vous recherchez \"pomme ou farine\", vous obtiendrez toutes "
|
||||
"les recettes qui contiennent à la fois \"pomme\" et \"farine\" dans les "
|
||||
@@ -1817,23 +1802,6 @@ msgstr ""
|
||||
" "
|
||||
|
||||
#: .\cookbook\templates\search_info.html:39
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| " \n"
|
||||
#| " Web searches simulate functionality found on many web search "
|
||||
#| "sites supporting special syntax.\n"
|
||||
#| " Placing quotes around several words will convert those words "
|
||||
#| "into a phrase.\n"
|
||||
#| " 'or' is recongized as searching for the word (or phrase) "
|
||||
#| "immediately before 'or' OR the word (or phrase) directly after.\n"
|
||||
#| " '-' is recognized as searching for recipes that do not "
|
||||
#| "include the word (or phrase) that comes immediately after. \n"
|
||||
#| " For example searching for 'apple pie' or cherry -butter will "
|
||||
#| "return any recipe that includes the phrase 'apple pie' or the word "
|
||||
#| "'cherry' \n"
|
||||
#| " in any field included in the full text search but exclude any "
|
||||
#| "recipe that has the word 'butter' in any field included.\n"
|
||||
#| " "
|
||||
msgid ""
|
||||
" \n"
|
||||
" Web searches simulate functionality found on many web search "
|
||||
@@ -1884,19 +1852,6 @@ msgstr ""
|
||||
" "
|
||||
|
||||
#: .\cookbook\templates\search_info.html:59
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| " \n"
|
||||
#| " Another approach to searching that also requires Postgresql "
|
||||
#| "is fuzzy search or trigram similarity. A trigram is a group of three "
|
||||
#| "consecutive characters.\n"
|
||||
#| " For example searching for 'apple' will create x trigrams "
|
||||
#| "'app', 'ppl', 'ple' and will create a score of how closely words match "
|
||||
#| "the generated trigrams.\n"
|
||||
#| " One benefit of searching trigams is that a search for "
|
||||
#| "'sandwich' will find mispelled words such as 'sandwhich' that would be "
|
||||
#| "missed by other methods.\n"
|
||||
#| " "
|
||||
msgid ""
|
||||
" \n"
|
||||
" Another approach to searching that also requires Postgresql is "
|
||||
@@ -2219,7 +2174,7 @@ msgstr "Gérer l’abonnement"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:13 .\cookbook\views\delete.py:216
|
||||
msgid "Space"
|
||||
msgstr "Groupe :"
|
||||
msgstr "Groupe"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:17
|
||||
msgid ""
|
||||
@@ -2480,69 +2435,93 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\views\api.py:687
|
||||
msgid "Query string matched (fuzzy) against object name."
|
||||
msgstr ""
|
||||
msgstr "Correspondance (floue) entre la chaîne de requête et le nom de l'objet."
|
||||
|
||||
#: .\cookbook\views\api.py:731
|
||||
msgid ""
|
||||
"Query string matched (fuzzy) against recipe name. In the future also "
|
||||
"fulltext search."
|
||||
msgstr ""
|
||||
"La chaîne d'interrogation correspond (de manière floue) au nom de la "
|
||||
"recette. À l'avenir, la recherche en texte intégral sera également possible."
|
||||
|
||||
#: .\cookbook\views\api.py:733
|
||||
msgid ""
|
||||
"ID of keyword a recipe should have. For multiple repeat parameter. "
|
||||
"Equivalent to keywords_or"
|
||||
msgstr ""
|
||||
"ID du mot-clé qu'une recette doit avoir. Pour les paramètres à répétition "
|
||||
"multiple. Equivalent à keywords_or"
|
||||
|
||||
#: .\cookbook\views\api.py:736
|
||||
msgid ""
|
||||
"Keyword IDs, repeat for multiple. Return recipes with any of the keywords"
|
||||
msgstr ""
|
||||
"ID des mots-clés, répéter pour plusieurs. Retourner les recettes avec "
|
||||
"n'importe quel mot-clé"
|
||||
|
||||
#: .\cookbook\views\api.py:739
|
||||
msgid ""
|
||||
"Keyword IDs, repeat for multiple. Return recipes with all of the keywords."
|
||||
msgstr ""
|
||||
"ID des mots-clés, répéter pour plusieurs. Retourner les recettes contenant "
|
||||
"tous les mots-clés."
|
||||
|
||||
#: .\cookbook\views\api.py:742
|
||||
msgid ""
|
||||
"Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords."
|
||||
msgstr ""
|
||||
"ID des mots-clés, répéter pour plusieurs. Exclure les recettes contenant "
|
||||
"l'un des mots-clés."
|
||||
|
||||
#: .\cookbook\views\api.py:745
|
||||
msgid ""
|
||||
"Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords."
|
||||
msgstr ""
|
||||
"ID des mots-clés, répéter pour plusieurs. Exclure les recettes contenant "
|
||||
"l'un des mots-clés."
|
||||
|
||||
#: .\cookbook\views\api.py:747
|
||||
msgid "ID of food a recipe should have. For multiple repeat parameter."
|
||||
msgstr ""
|
||||
"ID de l'aliment qu'une recette doit contenir. Pour les paramètres de "
|
||||
"répétition multiples."
|
||||
|
||||
#: .\cookbook\views\api.py:750
|
||||
msgid "Food IDs, repeat for multiple. Return recipes with any of the foods"
|
||||
msgstr ""
|
||||
"ID des aliments, répéter pour plusieurs. Retourner les recettes contenant "
|
||||
"l'un des aliments"
|
||||
|
||||
#: .\cookbook\views\api.py:752
|
||||
msgid "Food IDs, repeat for multiple. Return recipes with all of the foods."
|
||||
msgstr ""
|
||||
"ID des aliments, répéter pour plusieurs. Retourner les recettes avec tous "
|
||||
"les aliments."
|
||||
|
||||
#: .\cookbook\views\api.py:754
|
||||
msgid "Food IDs, repeat for multiple. Exclude recipes with any of the foods."
|
||||
msgstr ""
|
||||
"ID des aliments, répéter pour plusieurs. Exclure les recettes contenant l'un "
|
||||
"des aliments."
|
||||
|
||||
#: .\cookbook\views\api.py:756
|
||||
msgid "Food IDs, repeat for multiple. Exclude recipes with all of the foods."
|
||||
msgstr ""
|
||||
"ID des aliments, répéter pour plusieurs. Exclure les recettes contenant tous "
|
||||
"les aliments."
|
||||
|
||||
#: .\cookbook\views\api.py:757
|
||||
msgid "ID of unit a recipe should have."
|
||||
msgstr ""
|
||||
msgstr "ID de l'unité qu'une recette doit avoir."
|
||||
|
||||
#: .\cookbook\views\api.py:759
|
||||
msgid ""
|
||||
"Rating a recipe should have or greater. [0 - 5] Negative value filters "
|
||||
"rating less than."
|
||||
msgstr ""
|
||||
"Note qu'une recette devrait avoir ou être supérieure. [0 - 5] Une valeur "
|
||||
"négative filtre une note inférieure à."
|
||||
|
||||
#: .\cookbook\views\api.py:760
|
||||
msgid "ID of book a recipe should be in. For multiple repeat parameter."
|
||||
@@ -2659,7 +2638,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\views\api.py:1394
|
||||
msgid "Sync successful!"
|
||||
msgstr "Synchro réussie !"
|
||||
msgstr "Synchronisation réussie !"
|
||||
|
||||
#: .\cookbook\views\api.py:1399
|
||||
msgid "Error synchronizing with Storage"
|
||||
@@ -2701,10 +2680,8 @@ msgid "Invite Link"
|
||||
msgstr "Lien d’invitation"
|
||||
|
||||
#: .\cookbook\views\delete.py:200
|
||||
#, fuzzy
|
||||
#| msgid "Members"
|
||||
msgid "Space Membership"
|
||||
msgstr "Membres"
|
||||
msgstr "Adhésion à l'espace"
|
||||
|
||||
#: .\cookbook\views\edit.py:116
|
||||
msgid "You cannot edit this storage!"
|
||||
@@ -2732,6 +2709,8 @@ msgid ""
|
||||
"The PDF Exporter is not enabled on this instance as it is still in an "
|
||||
"experimental state."
|
||||
msgstr ""
|
||||
"L'export PDF n'est pas activé sur cette instance car il est toujours au "
|
||||
"statut expérimental."
|
||||
|
||||
#: .\cookbook\views\lists.py:24
|
||||
msgid "Import Log"
|
||||
|
||||
BIN
cookbook/locale/he/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/he/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
2529
cookbook/locale/he/LC_MESSAGES/django.po
Normal file
2529
cookbook/locale/he/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -11,8 +11,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
|
||||
"PO-Revision-Date: 2023-04-12 11:55+0000\n"
|
||||
"Last-Translator: noxonad <noxonad@proton.me>\n"
|
||||
"PO-Revision-Date: 2023-12-05 09:15+0000\n"
|
||||
"Last-Translator: Ferenc <ugyes@freemail.hu>\n"
|
||||
"Language-Team: Hungarian <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/hu/>\n"
|
||||
"Language: hu_HU\n"
|
||||
@@ -99,7 +99,7 @@ msgstr ""
|
||||
#: .\cookbook\forms.py:74
|
||||
msgid "Users with whom newly created meal plans should be shared by default."
|
||||
msgstr ""
|
||||
"Azok a felhasználók, akikkel az újonnan létrehozott étkezési terveket "
|
||||
"Azok a felhasználók, akikkel az újonnan létrehozott menüterveket "
|
||||
"alapértelmezés szerint meg kell osztani."
|
||||
|
||||
#: .\cookbook\forms.py:75
|
||||
@@ -135,8 +135,7 @@ msgstr "A navigációs sávot az oldal tetejére rögzíti."
|
||||
|
||||
#: .\cookbook\forms.py:83 .\cookbook\forms.py:512
|
||||
msgid "Automatically add meal plan ingredients to shopping list."
|
||||
msgstr ""
|
||||
"Automatikusan hozzáadja az étkezési terv hozzávalóit a bevásárlólistához."
|
||||
msgstr "Automatikusan hozzáadja a menüterv hozzávalóit a bevásárlólistához."
|
||||
|
||||
#: .\cookbook\forms.py:84
|
||||
msgid "Exclude ingredients that are on hand."
|
||||
@@ -283,16 +282,12 @@ msgstr ""
|
||||
"hibát figyelmen kívül hagynak)."
|
||||
|
||||
#: .\cookbook\forms.py:461
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| "Select type method of search. Click <a href=\"/docs/search/\">here</a> "
|
||||
#| "for full desciption of choices."
|
||||
msgid ""
|
||||
"Select type method of search. Click <a href=\"/docs/search/\">here</a> for "
|
||||
"full description of choices."
|
||||
msgstr ""
|
||||
"Válassza ki a keresés típusát. Kattintson <a href=\"/docs/search/\">ide</a> "
|
||||
"a lehetőségek teljes leírásáért."
|
||||
"Válassza ki a keresés típusát. Kattintson <a href=\"/docs/search/\">ide</"
|
||||
"a> a lehetőségek teljes leírásáért."
|
||||
|
||||
#: .\cookbook\forms.py:462
|
||||
msgid ""
|
||||
@@ -360,10 +355,8 @@ msgid "Partial Match"
|
||||
msgstr "Részleges találat"
|
||||
|
||||
#: .\cookbook\forms.py:480
|
||||
#, fuzzy
|
||||
#| msgid "Starts Wtih"
|
||||
msgid "Starts With"
|
||||
msgstr "Kezdődik a következővel"
|
||||
msgstr "Ezzel kezdődik"
|
||||
|
||||
#: .\cookbook\forms.py:481
|
||||
msgid "Fuzzy Search"
|
||||
@@ -387,16 +380,16 @@ msgid ""
|
||||
"When adding a meal plan to the shopping list (manually or automatically), "
|
||||
"include all related recipes."
|
||||
msgstr ""
|
||||
"Amikor étkezési tervet ad hozzá a bevásárlólistához (kézzel vagy "
|
||||
"automatikusan), vegye fel az összes kapcsolódó receptet."
|
||||
"Amikor menütervet ad hozzá a bevásárlólistához (kézzel vagy automatikusan), "
|
||||
"vegye fel az összes kapcsolódó receptet."
|
||||
|
||||
#: .\cookbook\forms.py:514
|
||||
msgid ""
|
||||
"When adding a meal plan to the shopping list (manually or automatically), "
|
||||
"exclude ingredients that are on hand."
|
||||
msgstr ""
|
||||
"Amikor étkezési tervet ad hozzá a bevásárlólistához (kézzel vagy "
|
||||
"automatikusan), zárja ki a kéznél lévő összetevőket."
|
||||
"Amikor menütervet ad hozzá a bevásárlólistához (kézzel vagy automatikusan), "
|
||||
"zárja ki a kéznél lévő összetevőket."
|
||||
|
||||
#: .\cookbook\forms.py:515
|
||||
msgid "Default number of hours to delay a shopping list entry."
|
||||
@@ -436,7 +429,7 @@ msgstr "Automatikus szinkronizálás"
|
||||
|
||||
#: .\cookbook\forms.py:526
|
||||
msgid "Auto Add Meal Plan"
|
||||
msgstr "Automatikus étkezési terv hozzáadása"
|
||||
msgstr "Menüterv automatikus hozzáadása"
|
||||
|
||||
#: .\cookbook\forms.py:527
|
||||
msgid "Exclude On Hand"
|
||||
@@ -490,6 +483,7 @@ msgstr "A receptek számának megjelenítése a keresési szűrőkön"
|
||||
#: .\cookbook\forms.py:559
|
||||
msgid "Use the plural form for units and food inside this space."
|
||||
msgstr ""
|
||||
"Használja a többes számot az egységek és az ételek esetében ezen a helyen."
|
||||
|
||||
#: .\cookbook\helper\AllAuthCustomAdapter.py:39
|
||||
msgid ""
|
||||
@@ -538,10 +532,8 @@ msgid "One of queryset or hash_key must be provided"
|
||||
msgstr "A queryset vagy a hash_key valamelyikét meg kell adni"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:266
|
||||
#, fuzzy
|
||||
#| msgid "Use fractions"
|
||||
msgid "reverse rotation"
|
||||
msgstr "Törtek használata"
|
||||
msgstr "Ellentétes irány"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:267
|
||||
msgid "careful rotation"
|
||||
@@ -549,29 +541,27 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:268
|
||||
msgid "knead"
|
||||
msgstr ""
|
||||
msgstr "dagasztás"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:269
|
||||
msgid "thicken"
|
||||
msgstr ""
|
||||
msgstr "sűrítés"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:270
|
||||
msgid "warm up"
|
||||
msgstr ""
|
||||
msgstr "melegítés"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:271
|
||||
msgid "ferment"
|
||||
msgstr ""
|
||||
msgstr "fermentálás"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:272
|
||||
msgid "sous-vide"
|
||||
msgstr ""
|
||||
msgstr "sous-vide"
|
||||
|
||||
#: .\cookbook\helper\shopping_helper.py:157
|
||||
#, fuzzy
|
||||
#| msgid "You must supply a created_by"
|
||||
msgid "You must supply a servings size"
|
||||
msgstr "Meg kell adnia egy created_by"
|
||||
msgstr "Meg kell adnia az adagok nagyságát"
|
||||
|
||||
#: .\cookbook\helper\template_helper.py:79
|
||||
#: .\cookbook\helper\template_helper.py:81
|
||||
@@ -581,11 +571,11 @@ msgstr "Nem sikerült elemezni a sablon kódját."
|
||||
#: .\cookbook\integration\copymethat.py:44
|
||||
#: .\cookbook\integration\melarecipes.py:37
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
msgstr "Kedvenc"
|
||||
|
||||
#: .\cookbook\integration\copymethat.py:50
|
||||
msgid "I made this"
|
||||
msgstr ""
|
||||
msgstr "Elkészítettem"
|
||||
|
||||
#: .\cookbook\integration\integration.py:218
|
||||
msgid ""
|
||||
@@ -613,10 +603,8 @@ msgid "Imported %s recipes."
|
||||
msgstr "Importálva %s recept."
|
||||
|
||||
#: .\cookbook\integration\openeats.py:26
|
||||
#, fuzzy
|
||||
#| msgid "Recipe Home"
|
||||
msgid "Recipe source:"
|
||||
msgstr "Recipe Home"
|
||||
msgstr "Recept forrása:"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:49
|
||||
msgid "Notes"
|
||||
@@ -632,10 +620,8 @@ msgstr "Forrás"
|
||||
|
||||
#: .\cookbook\integration\recettetek.py:54
|
||||
#: .\cookbook\integration\recipekeeper.py:70
|
||||
#, fuzzy
|
||||
#| msgid "Import Log"
|
||||
msgid "Imported from"
|
||||
msgstr "Import napló"
|
||||
msgstr "Importálva a"
|
||||
|
||||
#: .\cookbook\integration\saffron.py:23
|
||||
msgid "Servings"
|
||||
@@ -662,12 +648,10 @@ msgid "Rebuilds full text search index on Recipe"
|
||||
msgstr "Újraépíti a teljes szöveges keresési indexet a Recept oldalon"
|
||||
|
||||
#: .\cookbook\management\commands\rebuildindex.py:18
|
||||
#, fuzzy
|
||||
#| msgid "Only Postgress databases use full text search, no index to rebuild"
|
||||
msgid "Only Postgresql databases use full text search, no index to rebuild"
|
||||
msgstr ""
|
||||
"Csak a Postgress adatbázisok használnak teljes szöveges keresést, nincs "
|
||||
"újjáépítendő index"
|
||||
"Csak a Postgresql adatbázisok használják a teljes szöveges keresést, nem "
|
||||
"kell indexet újjáépíteni"
|
||||
|
||||
#: .\cookbook\management\commands\rebuildindex.py:29
|
||||
msgid "Recipe index rebuild complete."
|
||||
@@ -711,7 +695,7 @@ msgstr "Keresés"
|
||||
#: .\cookbook\templates\meal_plan.html:7 .\cookbook\views\delete.py:178
|
||||
#: .\cookbook\views\edit.py:211 .\cookbook\views\new.py:179
|
||||
msgid "Meal-Plan"
|
||||
msgstr "Étkezési terv"
|
||||
msgstr "Menüterv"
|
||||
|
||||
#: .\cookbook\models.py:367 .\cookbook\templates\base.html:118
|
||||
msgid "Books"
|
||||
@@ -750,16 +734,12 @@ msgid "Keyword Alias"
|
||||
msgstr "Kulcsszó álneve"
|
||||
|
||||
#: .\cookbook\models.py:1232
|
||||
#, fuzzy
|
||||
#| msgid "Description"
|
||||
msgid "Description Replace"
|
||||
msgstr "Leírás"
|
||||
msgstr "Leírás csere"
|
||||
|
||||
#: .\cookbook\models.py:1232
|
||||
#, fuzzy
|
||||
#| msgid "Instructions"
|
||||
msgid "Instruction Replace"
|
||||
msgstr "Elkészítés"
|
||||
msgstr "Leírás cseréje"
|
||||
|
||||
#: .\cookbook\models.py:1258 .\cookbook\views\delete.py:36
|
||||
#: .\cookbook\views\edit.py:251 .\cookbook\views\new.py:48
|
||||
@@ -767,10 +747,8 @@ msgid "Recipe"
|
||||
msgstr "Recept"
|
||||
|
||||
#: .\cookbook\models.py:1259
|
||||
#, fuzzy
|
||||
#| msgid "Foods"
|
||||
msgid "Food"
|
||||
msgstr "Ételek"
|
||||
msgstr "Étel"
|
||||
|
||||
#: .\cookbook\models.py:1260 .\cookbook\templates\base.html:141
|
||||
msgid "Keyword"
|
||||
@@ -786,7 +764,7 @@ msgstr "Elérte a fájlfeltöltési limitet."
|
||||
|
||||
#: .\cookbook\serializer.py:291
|
||||
msgid "Cannot modify Space owner permission."
|
||||
msgstr ""
|
||||
msgstr "A Hely tulajdonosi engedélye nem módosítható."
|
||||
|
||||
#: .\cookbook\serializer.py:1093
|
||||
msgid "Hello"
|
||||
@@ -1176,7 +1154,7 @@ msgstr "Ételek"
|
||||
|
||||
#: .\cookbook\templates\base.html:165 .\cookbook\views\lists.py:122
|
||||
msgid "Units"
|
||||
msgstr "Egységek"
|
||||
msgstr "Mértékegységek"
|
||||
|
||||
#: .\cookbook\templates\base.html:179 .\cookbook\templates\supermarket.html:7
|
||||
msgid "Supermarket"
|
||||
@@ -1206,10 +1184,8 @@ msgstr "Előzmények"
|
||||
#: .\cookbook\templates\base.html:255
|
||||
#: .\cookbook\templates\ingredient_editor.html:7
|
||||
#: .\cookbook\templates\ingredient_editor.html:13
|
||||
#, fuzzy
|
||||
#| msgid "Ingredients"
|
||||
msgid "Ingredient Editor"
|
||||
msgstr "Hozzávalók"
|
||||
msgstr "Hozzávaló szerkesztő"
|
||||
|
||||
#: .\cookbook\templates\base.html:267
|
||||
#: .\cookbook\templates\export_response.html:7
|
||||
@@ -1244,15 +1220,13 @@ msgstr "Admin"
|
||||
|
||||
#: .\cookbook\templates\base.html:312
|
||||
#: .\cookbook\templates\space_overview.html:25
|
||||
#, fuzzy
|
||||
#| msgid "No Space"
|
||||
msgid "Your Spaces"
|
||||
msgstr "Nincs hely"
|
||||
msgstr "Ön Helye"
|
||||
|
||||
#: .\cookbook\templates\base.html:323
|
||||
#: .\cookbook\templates\space_overview.html:6
|
||||
msgid "Overview"
|
||||
msgstr ""
|
||||
msgstr "Áttekintés"
|
||||
|
||||
#: .\cookbook\templates\base.html:327
|
||||
msgid "Markdown Guide"
|
||||
@@ -1276,11 +1250,11 @@ msgstr "Kijelentkezés"
|
||||
|
||||
#: .\cookbook\templates\base.html:360
|
||||
msgid "You are using the free version of Tandor"
|
||||
msgstr ""
|
||||
msgstr "Ön a Tandoor ingyenes verzióját használja"
|
||||
|
||||
#: .\cookbook\templates\base.html:361
|
||||
msgid "Upgrade Now"
|
||||
msgstr ""
|
||||
msgstr "Frissítés most"
|
||||
|
||||
#: .\cookbook\templates\batch\edit.html:6
|
||||
msgid "Batch edit Category"
|
||||
@@ -1377,7 +1351,7 @@ msgstr "Biztos, hogy törölni akarod a %(title)s: <b>%(object)s</b> "
|
||||
|
||||
#: .\cookbook\templates\generic\delete_template.html:22
|
||||
msgid "This cannot be undone!"
|
||||
msgstr ""
|
||||
msgstr "Ezt nem lehet visszafordítani!"
|
||||
|
||||
#: .\cookbook\templates\generic\delete_template.html:27
|
||||
msgid "Protected"
|
||||
@@ -1541,8 +1515,6 @@ msgstr "A sortörés a sor vége után két szóköz hozzáadásával történik
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:57
|
||||
#: .\cookbook\templates\markdown_info.html:73
|
||||
#, fuzzy
|
||||
#| msgid "or by leaving a blank line inbetween."
|
||||
msgid "or by leaving a blank line in between."
|
||||
msgstr "vagy egy üres sort hagyva közöttük."
|
||||
|
||||
@@ -1566,10 +1538,6 @@ msgid "Lists"
|
||||
msgstr "Listák"
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:85
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| "Lists can ordered or unorderd. It is <b>important to leave a blank line "
|
||||
#| "before the list!</b>"
|
||||
msgid ""
|
||||
"Lists can ordered or unordered. It is <b>important to leave a blank line "
|
||||
"before the list!</b>"
|
||||
@@ -1701,11 +1669,11 @@ msgstr ""
|
||||
#: .\cookbook\templates\openid\login.html:27
|
||||
#: .\cookbook\templates\socialaccount\authentication_error.html:27
|
||||
msgid "Back"
|
||||
msgstr ""
|
||||
msgstr "Vissza"
|
||||
|
||||
#: .\cookbook\templates\profile.html:7
|
||||
msgid "Profile"
|
||||
msgstr ""
|
||||
msgstr "Profil"
|
||||
|
||||
#: .\cookbook\templates\recipe_view.html:41
|
||||
msgid "by"
|
||||
@@ -1718,7 +1686,7 @@ msgstr "Megjegyzés"
|
||||
|
||||
#: .\cookbook\templates\rest_framework\api.html:5
|
||||
msgid "Recipe Home"
|
||||
msgstr "Recipe Home"
|
||||
msgstr "Recept főoldal"
|
||||
|
||||
#: .\cookbook\templates\search_info.html:5
|
||||
#: .\cookbook\templates\search_info.html:9
|
||||
@@ -2104,17 +2072,15 @@ msgstr "Szuperfelhasználói fiók létrehozása"
|
||||
|
||||
#: .\cookbook\templates\socialaccount\authentication_error.html:7
|
||||
#: .\cookbook\templates\socialaccount\authentication_error.html:23
|
||||
#, fuzzy
|
||||
#| msgid "Social Login"
|
||||
msgid "Social Network Login Failure"
|
||||
msgstr "Közösségi bejelentkezés"
|
||||
msgstr "Közösségi hálózat bejelentkezési hiba"
|
||||
|
||||
#: .\cookbook\templates\socialaccount\authentication_error.html:25
|
||||
#, fuzzy
|
||||
#| msgid "An error occurred attempting to move "
|
||||
msgid ""
|
||||
"An error occurred while attempting to login via your social network account."
|
||||
msgstr "Hiba történt az áthelyezés közben "
|
||||
msgstr ""
|
||||
"Hiba történt, miközben megpróbált bejelentkezni a közösségi hálózati fiókján "
|
||||
"keresztül."
|
||||
|
||||
#: .\cookbook\templates\socialaccount\connections.html:4
|
||||
#: .\cookbook\templates\socialaccount\connections.html:15
|
||||
@@ -2152,17 +2118,19 @@ msgstr "Regisztráció"
|
||||
#: .\cookbook\templates\socialaccount\login.html:9
|
||||
#, python-format
|
||||
msgid "Connect %(provider)s"
|
||||
msgstr ""
|
||||
msgstr "Csatlakozás %(provider)s"
|
||||
|
||||
#: .\cookbook\templates\socialaccount\login.html:11
|
||||
#, python-format
|
||||
msgid "You are about to connect a new third party account from %(provider)s."
|
||||
msgstr ""
|
||||
"Ön egy új, harmadik féltől származó fiókot készül csatlakoztatni "
|
||||
"a%(provider)-tól/től."
|
||||
|
||||
#: .\cookbook\templates\socialaccount\login.html:13
|
||||
#, python-format
|
||||
msgid "Sign In Via %(provider)s"
|
||||
msgstr ""
|
||||
msgstr "Bejelentkezve %(provider)s keresztül"
|
||||
|
||||
#: .\cookbook\templates\socialaccount\login.html:15
|
||||
#, python-format
|
||||
@@ -2171,7 +2139,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\templates\socialaccount\login.html:20
|
||||
msgid "Continue"
|
||||
msgstr ""
|
||||
msgstr "Folytatás"
|
||||
|
||||
#: .\cookbook\templates\socialaccount\signup.html:10
|
||||
#, python-format
|
||||
@@ -2210,10 +2178,8 @@ msgid "Manage Subscription"
|
||||
msgstr "Feliratkozás kezelése"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:13 .\cookbook\views\delete.py:216
|
||||
#, fuzzy
|
||||
#| msgid "Space:"
|
||||
msgid "Space"
|
||||
msgstr "Tér:"
|
||||
msgstr "Tér"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:17
|
||||
msgid ""
|
||||
@@ -2230,13 +2196,11 @@ msgstr "Meghívást kaphatsz egy meglévő térbe, vagy létrehozhatod a sajáto
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:53
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
msgstr "Tulajdonos"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:57
|
||||
#, fuzzy
|
||||
#| msgid "Create Space"
|
||||
msgid "Leave Space"
|
||||
msgstr "Tér létrehozása"
|
||||
msgstr "Kilépés a Térből"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:78
|
||||
#: .\cookbook\templates\space_overview.html:88
|
||||
@@ -2485,87 +2449,111 @@ msgstr ""
|
||||
"teljes szöveges keresés is."
|
||||
|
||||
#: .\cookbook\views\api.py:733
|
||||
#, fuzzy
|
||||
#| msgid "ID of keyword a recipe should have. For multiple repeat parameter."
|
||||
msgid ""
|
||||
"ID of keyword a recipe should have. For multiple repeat parameter. "
|
||||
"Equivalent to keywords_or"
|
||||
msgstr ""
|
||||
"A recept kulcsszavának azonosítója. Többszörös ismétlődő paraméter esetén."
|
||||
"A recept kulcsszavának azonosítója. Többszörös ismétlődő paraméter esetén. "
|
||||
"Egyenértékű a keywords_or kulcsszavakkal"
|
||||
|
||||
#: .\cookbook\views\api.py:736
|
||||
msgid ""
|
||||
"Keyword IDs, repeat for multiple. Return recipes with any of the keywords"
|
||||
msgstr ""
|
||||
"Kulcsszó azonosítók. Többször is megadható. A megadott kulcsszavak "
|
||||
"mindegyikéhez tartozó receptek listázza"
|
||||
|
||||
#: .\cookbook\views\api.py:739
|
||||
msgid ""
|
||||
"Keyword IDs, repeat for multiple. Return recipes with all of the keywords."
|
||||
msgstr ""
|
||||
"Kulcsszó azonosítók. Többször is megadható. Az összes megadott kulcsszót "
|
||||
"tartalmazó receptek listázása."
|
||||
|
||||
#: .\cookbook\views\api.py:742
|
||||
msgid ""
|
||||
"Keyword IDs, repeat for multiple. Exclude recipes with any of the keywords."
|
||||
msgstr ""
|
||||
"Kulcsszó azonosító. Többször is megadható. Kizárja a recepteket a megadott "
|
||||
"kulcsszavak egyikéből."
|
||||
|
||||
#: .\cookbook\views\api.py:745
|
||||
msgid ""
|
||||
"Keyword IDs, repeat for multiple. Exclude recipes with all of the keywords."
|
||||
msgstr ""
|
||||
"Kulcsszó azonosítók. Többször is megadható. Kizárja az összes megadott "
|
||||
"kulcsszóval rendelkező receptet."
|
||||
|
||||
#: .\cookbook\views\api.py:747
|
||||
msgid "ID of food a recipe should have. For multiple repeat parameter."
|
||||
msgstr ""
|
||||
"Az ételek azonosítója egy receptnek tartalmaznia kell. Többszörös ismétlődő "
|
||||
"paraméter esetén."
|
||||
"Annak az összetevőnek az azonosítója, amelynek receptjeit fel kell sorolni. "
|
||||
"Többször is megadható."
|
||||
|
||||
#: .\cookbook\views\api.py:750
|
||||
msgid "Food IDs, repeat for multiple. Return recipes with any of the foods"
|
||||
msgstr ""
|
||||
"Összetevő azonosító. Többször is megadható. Legalább egy összetevő "
|
||||
"receptjeinek listája"
|
||||
|
||||
#: .\cookbook\views\api.py:752
|
||||
msgid "Food IDs, repeat for multiple. Return recipes with all of the foods."
|
||||
msgstr ""
|
||||
"Összetevő azonosító. Többször is megadható. Az összes megadott összetevőt "
|
||||
"tartalmazó receptek listája."
|
||||
|
||||
#: .\cookbook\views\api.py:754
|
||||
msgid "Food IDs, repeat for multiple. Exclude recipes with any of the foods."
|
||||
msgstr ""
|
||||
"Összetevő azonosító. Többször is megadható. Kizárja azokat a recepteket, "
|
||||
"amelyek a megadott összetevők bármelyikét tartalmazzák."
|
||||
|
||||
#: .\cookbook\views\api.py:756
|
||||
msgid "Food IDs, repeat for multiple. Exclude recipes with all of the foods."
|
||||
msgstr ""
|
||||
"Összetevő azonosító. Többször is megadható. Kizárja az összes megadott "
|
||||
"összetevőt tartalmazó recepteket."
|
||||
|
||||
#: .\cookbook\views\api.py:757
|
||||
msgid "ID of unit a recipe should have."
|
||||
msgstr "Az egység azonosítója, amellyel a receptnek rendelkeznie kell."
|
||||
msgstr "A recepthez tartozó mértékegység azonosítója."
|
||||
|
||||
#: .\cookbook\views\api.py:759
|
||||
msgid ""
|
||||
"Rating a recipe should have or greater. [0 - 5] Negative value filters "
|
||||
"rating less than."
|
||||
msgstr ""
|
||||
"Egy recept minimális értékelése (0-5). A negatív értékek a maximális "
|
||||
"értékelés szerint szűrnek."
|
||||
|
||||
#: .\cookbook\views\api.py:760
|
||||
msgid "ID of book a recipe should be in. For multiple repeat parameter."
|
||||
msgstr ""
|
||||
"A könyv azonosítója, amelyben a receptnek szerepelnie kell. Többszörös "
|
||||
"ismétlés esetén paraméter."
|
||||
"A könyv azonosítója, amelyben a recept található. Többször is megadható."
|
||||
|
||||
#: .\cookbook\views\api.py:762
|
||||
msgid "Book IDs, repeat for multiple. Return recipes with any of the books"
|
||||
msgstr ""
|
||||
"A könyv azonosítója. Többször is megadható. A megadott könyvek összes "
|
||||
"receptjének listája"
|
||||
|
||||
#: .\cookbook\views\api.py:764
|
||||
msgid "Book IDs, repeat for multiple. Return recipes with all of the books."
|
||||
msgstr ""
|
||||
"A könyv azonosítója. Többször is megadható. Az összes könyvben szereplő "
|
||||
"recept listája."
|
||||
|
||||
#: .\cookbook\views\api.py:766
|
||||
msgid "Book IDs, repeat for multiple. Exclude recipes with any of the books."
|
||||
msgstr ""
|
||||
"A könyv azonosítói. Többször is megadható. Kizárja a megadott könyvek "
|
||||
"receptjeit."
|
||||
|
||||
#: .\cookbook\views\api.py:768
|
||||
msgid "Book IDs, repeat for multiple. Exclude recipes with all of the books."
|
||||
msgstr ""
|
||||
"A könyv azonosítói. Többször is megadható. Kizárja az összes megadott "
|
||||
"könyvben szereplő receptet."
|
||||
|
||||
#: .\cookbook\views\api.py:770
|
||||
msgid "If only internal recipes should be returned. [true/<b>false</b>]"
|
||||
@@ -2587,36 +2575,50 @@ msgid ""
|
||||
"Filter recipes cooked X times or more. Negative values returns cooked less "
|
||||
"than X times"
|
||||
msgstr ""
|
||||
"X-szer vagy többször főzött receptek szűrése. A negatív értékek X "
|
||||
"alkalomnál kevesebbet főzött recepteket jelenítik meg"
|
||||
|
||||
#: .\cookbook\views\api.py:778
|
||||
msgid ""
|
||||
"Filter recipes last cooked on or after YYYY-MM-DD. Prepending - filters on "
|
||||
"or before date."
|
||||
msgstr ""
|
||||
"Megjeleníti azokat a recepteket, amelyeket a megadott napon (ÉÉÉÉ-HH-NN) "
|
||||
"vagy később főztek meg utoljára. A - jelölve az adott dátumon vagy azt "
|
||||
"megelőzően elkészítettek kerülnek be a receptek listájába."
|
||||
|
||||
#: .\cookbook\views\api.py:780
|
||||
msgid ""
|
||||
"Filter recipes created on or after YYYY-MM-DD. Prepending - filters on or "
|
||||
"before date."
|
||||
msgstr ""
|
||||
"Megjeleníti azokat a recepteket, amelyeket a megadott napon (ÉÉÉÉ-HH-NN) "
|
||||
"vagy később hoztak létre. A - jelölve az adott dátumon vagy azt megelőzően "
|
||||
"hozták létre."
|
||||
|
||||
#: .\cookbook\views\api.py:782
|
||||
msgid ""
|
||||
"Filter recipes updated on or after YYYY-MM-DD. Prepending - filters on or "
|
||||
"before date."
|
||||
msgstr ""
|
||||
"Megjeleníti azokat a recepteket, amelyeket a megadott napon (ÉÉÉÉ-HH-NN) "
|
||||
"vagy később frissültek. A - jelölve az adott dátumon vagy azt megelőzően "
|
||||
"frissültek."
|
||||
|
||||
#: .\cookbook\views\api.py:784
|
||||
msgid ""
|
||||
"Filter recipes lasts viewed on or after YYYY-MM-DD. Prepending - filters on "
|
||||
"or before date."
|
||||
msgstr ""
|
||||
"Megjeleníti azokat a recepteket, amelyeket a megadott napon (ÉÉÉÉ-HH-NN) "
|
||||
"vagy később néztek meg utoljára. A - jelölve az adott dátumon vagy azt "
|
||||
"megelőzően néztek meg utoljára."
|
||||
|
||||
#: .\cookbook\views\api.py:786
|
||||
#, fuzzy
|
||||
#| msgid "If only internal recipes should be returned. [true/<b>false</b>]"
|
||||
msgid "Filter recipes that can be made with OnHand food. [true/<b>false</b>]"
|
||||
msgstr "Ha csak a belső recepteket kell visszaadni. [true/<b>false</b>]"
|
||||
msgstr ""
|
||||
"Felsorolja azokat a recepteket, amelyeket a rendelkezésre álló összetevőkből "
|
||||
"el lehet készíteni. [true/<b>false</b>]"
|
||||
|
||||
#: .\cookbook\views\api.py:946
|
||||
msgid ""
|
||||
@@ -2647,7 +2649,7 @@ msgstr "Semmi feladat."
|
||||
|
||||
#: .\cookbook\views\api.py:1198
|
||||
msgid "Invalid Url"
|
||||
msgstr ""
|
||||
msgstr "Érvénytelen URL"
|
||||
|
||||
#: .\cookbook\views\api.py:1205
|
||||
msgid "Connection Refused."
|
||||
@@ -2655,13 +2657,11 @@ msgstr "Kapcsolat megtagadva."
|
||||
|
||||
#: .\cookbook\views\api.py:1210
|
||||
msgid "Bad URL Schema."
|
||||
msgstr ""
|
||||
msgstr "Rossz URL séma."
|
||||
|
||||
#: .\cookbook\views\api.py:1233
|
||||
#, fuzzy
|
||||
#| msgid "No useable data could be found."
|
||||
msgid "No usable data could be found."
|
||||
msgstr "Nem találtam használható adatokat."
|
||||
msgstr "Nem sikerült használható adatokat találni."
|
||||
|
||||
#: .\cookbook\views\api.py:1326 .\cookbook\views\import_export.py:117
|
||||
msgid "Importing is not implemented for this provider"
|
||||
@@ -2774,10 +2774,8 @@ msgid "Shopping Categories"
|
||||
msgstr "Bevásárlási kategóriák"
|
||||
|
||||
#: .\cookbook\views\lists.py:187
|
||||
#, fuzzy
|
||||
#| msgid "Filter"
|
||||
msgid "Custom Filters"
|
||||
msgstr "Szűrő"
|
||||
msgstr "Egyedi szűrők"
|
||||
|
||||
#: .\cookbook\views\lists.py:224
|
||||
msgid "Steps"
|
||||
|
||||
Binary file not shown.
@@ -12,8 +12,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
|
||||
"PO-Revision-Date: 2023-04-29 07:55+0000\n"
|
||||
"Last-Translator: Oliver Cervera <olivercervera@yahoo.it>\n"
|
||||
"PO-Revision-Date: 2024-02-17 19:16+0000\n"
|
||||
"Last-Translator: Andrea <giovannibecco@mailo.com>\n"
|
||||
"Language-Team: Italian <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/it/>\n"
|
||||
"Language: it\n"
|
||||
@@ -536,27 +536,27 @@ msgstr "rotazione inversa"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:267
|
||||
msgid "careful rotation"
|
||||
msgstr ""
|
||||
msgstr "rotazione con cura"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:268
|
||||
msgid "knead"
|
||||
msgstr ""
|
||||
msgstr "impastare"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:269
|
||||
msgid "thicken"
|
||||
msgstr ""
|
||||
msgstr "addensare"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:270
|
||||
msgid "warm up"
|
||||
msgstr ""
|
||||
msgstr "riscaldare"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:271
|
||||
msgid "ferment"
|
||||
msgstr ""
|
||||
msgstr "fermentare"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:272
|
||||
msgid "sous-vide"
|
||||
msgstr ""
|
||||
msgstr "sottovuoto"
|
||||
|
||||
#: .\cookbook\helper\shopping_helper.py:157
|
||||
msgid "You must supply a servings size"
|
||||
@@ -1760,6 +1760,13 @@ msgid ""
|
||||
"selected for a full text search.\n"
|
||||
" "
|
||||
msgstr ""
|
||||
" \n"
|
||||
" Ricerche semplici ignorano la puteggiatura e parole comuni come "
|
||||
"\"il\", \"un\", \"e\". E tratterà separatamente le parole come necessario.\n"
|
||||
" Cercare \"mela o farina\" restituisce ogni ricetta che contiene "
|
||||
"sia \"mele\" che \"farina\" ovunque nei campi che sono stati selezionati per "
|
||||
"una ricerca completa di testo.\n"
|
||||
" "
|
||||
|
||||
#: .\cookbook\templates\search_info.html:34
|
||||
msgid ""
|
||||
@@ -1771,6 +1778,13 @@ msgid ""
|
||||
"been selected for a full text search.\n"
|
||||
" "
|
||||
msgstr ""
|
||||
" \n"
|
||||
" Ricerche di frase ignorano la punteggiatura, ma cercano tutte "
|
||||
"parole nell'esatto ordine indicato.\n"
|
||||
" Cercare \"mele o farina\" restituisce una ricetta che contiene "
|
||||
"l'esatta frase \"mele o farina\" in qualsiasi campo selezionato per una "
|
||||
"ricerca completa di testo.\n"
|
||||
" "
|
||||
|
||||
#: .\cookbook\templates\search_info.html:39
|
||||
msgid ""
|
||||
@@ -2079,7 +2093,7 @@ msgstr "Proprietario"
|
||||
#, fuzzy
|
||||
#| msgid "Create Space"
|
||||
msgid "Leave Space"
|
||||
msgstr "Crea Istanza"
|
||||
msgstr "Lascia Istanza"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:78
|
||||
#: .\cookbook\templates\space_overview.html:88
|
||||
|
||||
Binary file not shown.
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-04-11 15:09+0200\n"
|
||||
"PO-Revision-Date: 2023-04-17 20:55+0000\n"
|
||||
"Last-Translator: Espen Sellevåg <buskmenn.drammer03@icloud.com>\n"
|
||||
"PO-Revision-Date: 2023-08-19 21:36+0000\n"
|
||||
"Last-Translator: NeoID <neoid@animenord.com>\n"
|
||||
"Language-Team: Norwegian Bokmål <http://translate.tandoor.dev/projects/"
|
||||
"tandoor/recipes-backend/nb_NO/>\n"
|
||||
"Language: nb_NO\n"
|
||||
@@ -31,6 +31,8 @@ msgid ""
|
||||
"Color of the top navigation bar. Not all colors work with all themes, just "
|
||||
"try them out!"
|
||||
msgstr ""
|
||||
"Farge på toppnavigasjonslinjen. Ikke alle farger fungerer med alle temaer, "
|
||||
"så bare prøv dem ut!"
|
||||
|
||||
#: .\cookbook\forms.py:46
|
||||
msgid "Default Unit to be used when inserting a new ingredient into a recipe."
|
||||
@@ -79,13 +81,15 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:56
|
||||
msgid "Makes the navbar stick to the top of the page."
|
||||
msgstr ""
|
||||
msgstr "Fest navigasjonslinjen til toppen av siden."
|
||||
|
||||
#: .\cookbook\forms.py:72
|
||||
msgid ""
|
||||
"Both fields are optional. If none are given the username will be displayed "
|
||||
"instead"
|
||||
msgstr ""
|
||||
"Begge feltene er valgfrie. Hvis ingen blir oppgitt, vil brukernavnet vises i "
|
||||
"stedet"
|
||||
|
||||
#: .\cookbook\forms.py:93 .\cookbook\forms.py:315
|
||||
#: .\cookbook\templates\forms\edit_internal_recipe.html:45
|
||||
@@ -97,15 +101,15 @@ msgstr "Navn"
|
||||
#: .\cookbook\templates\forms\edit_internal_recipe.html:81
|
||||
#: .\cookbook\templates\stats.html:24 .\cookbook\templates\url_import.html:202
|
||||
msgid "Keywords"
|
||||
msgstr ""
|
||||
msgstr "Nøkkelord"
|
||||
|
||||
#: .\cookbook\forms.py:95
|
||||
msgid "Preparation time in minutes"
|
||||
msgstr ""
|
||||
msgstr "Forberedelsestid i minutter"
|
||||
|
||||
#: .\cookbook\forms.py:96
|
||||
msgid "Waiting time (cooking/baking) in minutes"
|
||||
msgstr ""
|
||||
msgstr "Ventetid (til matlaging/baking) i minutter"
|
||||
|
||||
#: .\cookbook\forms.py:97 .\cookbook\forms.py:317
|
||||
msgid "Path"
|
||||
@@ -124,6 +128,8 @@ msgid ""
|
||||
"To prevent duplicates recipes with the same name as existing ones are "
|
||||
"ignored. Check this box to import everything."
|
||||
msgstr ""
|
||||
"For å unngå duplikater, blir oppskrifter med samme navn som eksisterende "
|
||||
"ignorert. Merk av denne boksen for å importere alt."
|
||||
|
||||
#: .\cookbook\forms.py:149
|
||||
msgid "New Unit"
|
||||
@@ -131,7 +137,7 @@ msgstr "Ny enhet"
|
||||
|
||||
#: .\cookbook\forms.py:150
|
||||
msgid "New unit that other gets replaced by."
|
||||
msgstr ""
|
||||
msgstr "Ny enhet som erstatter den gamle."
|
||||
|
||||
#: .\cookbook\forms.py:155
|
||||
msgid "Old Unit"
|
||||
@@ -143,19 +149,19 @@ msgstr "Enhet som skal erstattes."
|
||||
|
||||
#: .\cookbook\forms.py:172
|
||||
msgid "New Food"
|
||||
msgstr ""
|
||||
msgstr "Ny matvare"
|
||||
|
||||
#: .\cookbook\forms.py:173
|
||||
msgid "New food that other gets replaced by."
|
||||
msgstr ""
|
||||
msgstr "Ny matvare som erstatter den gamle."
|
||||
|
||||
#: .\cookbook\forms.py:178
|
||||
msgid "Old Food"
|
||||
msgstr ""
|
||||
msgstr "Gammel matvare"
|
||||
|
||||
#: .\cookbook\forms.py:179
|
||||
msgid "Food that should be replaced."
|
||||
msgstr ""
|
||||
msgstr "Matvare som bør erstattes."
|
||||
|
||||
#: .\cookbook\forms.py:197
|
||||
msgid "Add your comment: "
|
||||
@@ -163,17 +169,19 @@ msgstr "Legg til din kommentar: "
|
||||
|
||||
#: .\cookbook\forms.py:238
|
||||
msgid "Leave empty for dropbox and enter app password for nextcloud."
|
||||
msgstr ""
|
||||
msgstr "La det stå tomt for Dropbox og skriv inn app-passordet for Nextcloud."
|
||||
|
||||
#: .\cookbook\forms.py:245
|
||||
msgid "Leave empty for nextcloud and enter api token for dropbox."
|
||||
msgstr ""
|
||||
msgstr "La det stå tomt for Nextcloud og skriv inn API-tokenet for Dropbox."
|
||||
|
||||
#: .\cookbook\forms.py:253
|
||||
msgid ""
|
||||
"Leave empty for dropbox and enter only base url for nextcloud (<code>/remote."
|
||||
"php/webdav/</code> is added automatically)"
|
||||
msgstr ""
|
||||
"La det stå tomt for Dropbox, og skriv bare inn grunn-URLen for Nextcloud "
|
||||
"(<code>/remote.php/webdav/</code> blir lagt til automatisk)"
|
||||
|
||||
#: .\cookbook\forms.py:291
|
||||
msgid "Search String"
|
||||
@@ -185,11 +193,12 @@ msgstr "Fil-ID"
|
||||
|
||||
#: .\cookbook\forms.py:354
|
||||
msgid "You must provide at least a recipe or a title."
|
||||
msgstr ""
|
||||
msgstr "Du må oppgi minst en oppskrift eller en tittel."
|
||||
|
||||
#: .\cookbook\forms.py:367
|
||||
msgid "You can list default users to share recipes with in the settings."
|
||||
msgstr ""
|
||||
"Du kan liste opp standardbrukere for å dele oppskrifter innen innstillingene."
|
||||
|
||||
#: .\cookbook\forms.py:368
|
||||
#: .\cookbook\templates\forms\edit_internal_recipe.html:377
|
||||
@@ -197,10 +206,14 @@ msgid ""
|
||||
"You can use markdown to format this field. See the <a href=\"/docs/markdown/"
|
||||
"\">docs here</a>"
|
||||
msgstr ""
|
||||
"Du kan bruke Markdown for å formatere dette feltet. Se <a href=\"/docs/"
|
||||
"markdown/\">dokumentasjonen her</a>"
|
||||
|
||||
#: .\cookbook\forms.py:393
|
||||
msgid "A username is not required, if left blank the new user can choose one."
|
||||
msgstr ""
|
||||
"Et brukernavn er ikke påkrevd. Hvis det blir stående tomt, kan den nye "
|
||||
"brukeren velge ett selv."
|
||||
|
||||
#: .\cookbook\helper\permission_helper.py:123
|
||||
#: .\cookbook\helper\permission_helper.py:129
|
||||
@@ -222,26 +235,30 @@ msgstr "Du er ikke innlogget og kan derfor ikke vise siden!"
|
||||
#: .\cookbook\helper\permission_helper.py:167
|
||||
#: .\cookbook\helper\permission_helper.py:182
|
||||
msgid "You cannot interact with this object as it is not owned by you!"
|
||||
msgstr ""
|
||||
msgstr "Du kan ikke samhandle med dette objektet, da det ikke tilhører deg!"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:40 .\cookbook\views\api.py:549
|
||||
msgid "The requested site provided malformed data and cannot be read."
|
||||
msgstr ""
|
||||
"Nettstedet du har forespurt, har levert feilformatert data som ikke kan "
|
||||
"leses."
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:54
|
||||
msgid ""
|
||||
"The requested site does not provide any recognized data format to import the "
|
||||
"recipe from."
|
||||
msgstr ""
|
||||
"Det forespurte nettstedet gir ingen gjenkjennelig dataformat som kan "
|
||||
"importeres oppskriften fra."
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:160
|
||||
msgid "Imported from"
|
||||
msgstr ""
|
||||
msgstr "Importert fra"
|
||||
|
||||
#: .\cookbook\helper\template_helper.py:60
|
||||
#: .\cookbook\helper\template_helper.py:62
|
||||
msgid "Could not parse template code."
|
||||
msgstr ""
|
||||
msgstr "Kunne ikke analysere mal-koden."
|
||||
|
||||
#: .\cookbook\integration\integration.py:102
|
||||
#: .\cookbook\templates\import.html:14 .\cookbook\templates\import.html:20
|
||||
@@ -250,50 +267,52 @@ msgstr ""
|
||||
#: .\cookbook\templates\url_import.html:233 .\cookbook\views\delete.py:60
|
||||
#: .\cookbook\views\edit.py:190
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
msgstr "Importér"
|
||||
|
||||
#: .\cookbook\integration\integration.py:131
|
||||
msgid ""
|
||||
"Importer expected a .zip file. Did you choose the correct importer type for "
|
||||
"your data ?"
|
||||
msgstr ""
|
||||
"Importøren forventet en .zip-fil. Har du valgt riktig type importør for "
|
||||
"dataene dine?"
|
||||
|
||||
#: .\cookbook\integration\integration.py:134
|
||||
msgid "The following recipes were ignored because they already existed:"
|
||||
msgstr ""
|
||||
msgstr "Følgende oppskrifter ble ignorert fordi de allerede eksisterte:"
|
||||
|
||||
#: .\cookbook\integration\integration.py:137
|
||||
#, python-format
|
||||
msgid "Imported %s recipes."
|
||||
msgstr ""
|
||||
msgstr "Importerte %s oppskrifter."
|
||||
|
||||
#: .\cookbook\integration\paprika.py:44
|
||||
msgid "Notes"
|
||||
msgstr ""
|
||||
msgstr "Notater"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:47
|
||||
msgid "Nutritional Information"
|
||||
msgstr ""
|
||||
msgstr "Næringsinformasjon"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:50
|
||||
msgid "Source"
|
||||
msgstr ""
|
||||
msgstr "Kilde"
|
||||
|
||||
#: .\cookbook\integration\safron.py:23
|
||||
#: .\cookbook\templates\forms\edit_internal_recipe.html:75
|
||||
#: .\cookbook\templates\include\log_cooking.html:16
|
||||
#: .\cookbook\templates\url_import.html:84
|
||||
msgid "Servings"
|
||||
msgstr ""
|
||||
msgstr "Porsjoner"
|
||||
|
||||
#: .\cookbook\integration\safron.py:25
|
||||
msgid "Waiting time"
|
||||
msgstr ""
|
||||
msgstr "Ventetid"
|
||||
|
||||
#: .\cookbook\integration\safron.py:27
|
||||
#: .\cookbook\templates\forms\edit_internal_recipe.html:69
|
||||
msgid "Preparation Time"
|
||||
msgstr ""
|
||||
msgstr "Forberedelsestid"
|
||||
|
||||
#: .\cookbook\integration\safron.py:29 .\cookbook\templates\base.html:71
|
||||
#: .\cookbook\templates\forms\ingredients.html:7
|
||||
@@ -329,7 +348,7 @@ msgstr "Søk"
|
||||
#: .\cookbook\templates\meal_plan.html:5 .\cookbook\views\delete.py:152
|
||||
#: .\cookbook\views\edit.py:224 .\cookbook\views\new.py:188
|
||||
msgid "Meal-Plan"
|
||||
msgstr ""
|
||||
msgstr "Måltidsplan"
|
||||
|
||||
#: .\cookbook\models.py:112 .\cookbook\templates\base.html:82
|
||||
msgid "Books"
|
||||
@@ -337,11 +356,11 @@ msgstr "Bøker"
|
||||
|
||||
#: .\cookbook\models.py:119
|
||||
msgid "Small"
|
||||
msgstr ""
|
||||
msgstr "Liten"
|
||||
|
||||
#: .\cookbook\models.py:119
|
||||
msgid "Large"
|
||||
msgstr ""
|
||||
msgstr "Stor"
|
||||
|
||||
#: .\cookbook\models.py:327
|
||||
#: .\cookbook\templates\forms\edit_internal_recipe.html:198
|
||||
@@ -1109,22 +1128,24 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:125
|
||||
msgid "Images & Links"
|
||||
msgstr ""
|
||||
msgstr "Bilder og lenker"
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:126
|
||||
msgid ""
|
||||
"Links can be formatted with Markdown. This application also allows to paste "
|
||||
"links directly into markdown fields without any formatting."
|
||||
msgstr ""
|
||||
"Lenker kan formateres med Markdown. Denne applikasjonen lar deg også lime "
|
||||
"inn lenker direkte i Markdown-felt uten noen formatering."
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:132
|
||||
#: .\cookbook\templates\markdown_info.html:145
|
||||
msgid "This will become an image"
|
||||
msgstr ""
|
||||
msgstr "Dette vil bli til et bilde"
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:152
|
||||
msgid "Tables"
|
||||
msgstr ""
|
||||
msgstr "Tabeller"
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:153
|
||||
msgid ""
|
||||
@@ -1132,124 +1153,130 @@ msgid ""
|
||||
"editor like <a href=\"https://www.tablesgenerator.com/markdown_tables\" rel="
|
||||
"\"noreferrer noopener\" target=\"_blank\">this one.</a>"
|
||||
msgstr ""
|
||||
"Markdown-tabeller er vanskelige å lage for hånd. Det anbefales å bruke en "
|
||||
"tabellredigerer som <a href=\"https://www.tablesgenerator.com/"
|
||||
"markdown_tables\" rel=\"noreferrer noopener\" target=\"_blank\">denne.</a>"
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:155
|
||||
#: .\cookbook\templates\markdown_info.html:157
|
||||
#: .\cookbook\templates\markdown_info.html:171
|
||||
#: .\cookbook\templates\markdown_info.html:177
|
||||
msgid "Table"
|
||||
msgstr ""
|
||||
msgstr "Tabell"
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:155
|
||||
#: .\cookbook\templates\markdown_info.html:172
|
||||
msgid "Header"
|
||||
msgstr ""
|
||||
msgstr "Overskrift"
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:157
|
||||
#: .\cookbook\templates\markdown_info.html:178
|
||||
msgid "Cell"
|
||||
msgstr ""
|
||||
msgstr "Celle"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:101
|
||||
msgid "New Entry"
|
||||
msgstr ""
|
||||
msgstr "Ny oppføring"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:113
|
||||
#: .\cookbook\templates\shopping_list.html:52
|
||||
msgid "Search Recipe"
|
||||
msgstr ""
|
||||
msgstr "Søk oppskrift"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:139
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
msgstr "Tittel"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:141
|
||||
msgid "Note (optional)"
|
||||
msgstr ""
|
||||
msgstr "Merknad (valgfritt)"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:143
|
||||
msgid ""
|
||||
"You can use markdown to format this field. See the <a href=\"/docs/markdown/"
|
||||
"\" target=\"_blank\" rel=\"noopener noreferrer\">docs here</a>"
|
||||
msgstr ""
|
||||
"Du kan bruke Markdown for å formatere dette feltet. Se <a href=\"/docs/"
|
||||
"markdown/\" target=\"_blank\" rel=\"noopener noreferrer\">dokumentasjonen "
|
||||
"her</a>"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:147
|
||||
#: .\cookbook\templates\meal_plan.html:251
|
||||
msgid "Serving Count"
|
||||
msgstr ""
|
||||
msgstr "Antall porsjoner"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:153
|
||||
msgid "Create only note"
|
||||
msgstr ""
|
||||
msgstr "Opprett kun en merknad"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:168
|
||||
#: .\cookbook\templates\shopping_list.html:7
|
||||
#: .\cookbook\templates\shopping_list.html:29
|
||||
#: .\cookbook\templates\shopping_list.html:705
|
||||
msgid "Shopping List"
|
||||
msgstr ""
|
||||
msgstr "Handleliste"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:172
|
||||
msgid "Shopping list currently empty"
|
||||
msgstr ""
|
||||
msgstr "Handlelisten er for øyeblikket tom"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:175
|
||||
msgid "Open Shopping List"
|
||||
msgstr ""
|
||||
msgstr "Åpne handlelisten"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:189
|
||||
msgid "Plan"
|
||||
msgstr ""
|
||||
msgstr "Plan"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:196
|
||||
msgid "Number of Days"
|
||||
msgstr ""
|
||||
msgstr "Antall dager"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:206
|
||||
msgid "Weekday offset"
|
||||
msgstr ""
|
||||
msgstr "Ukedagsforskyvning"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:209
|
||||
msgid ""
|
||||
"Number of days starting from the first day of the week to offset the default "
|
||||
"view."
|
||||
msgstr ""
|
||||
msgstr "Antall dager fra den første dagen i uken for å endre standardvisningen."
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:217
|
||||
#: .\cookbook\templates\meal_plan.html:294
|
||||
msgid "Edit plan types"
|
||||
msgstr ""
|
||||
msgstr "Rediger plantyper"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:219
|
||||
msgid "Show help"
|
||||
msgstr ""
|
||||
msgstr "Vis hjelp"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:220
|
||||
msgid "Week iCal export"
|
||||
msgstr ""
|
||||
msgstr "Uke iCal-eksport"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:264
|
||||
#: .\cookbook\templates\meal_plan_entry.html:18
|
||||
msgid "Created by"
|
||||
msgstr ""
|
||||
msgstr "Opprettet av"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:270
|
||||
#: .\cookbook\templates\meal_plan_entry.html:20
|
||||
#: .\cookbook\templates\shopping_list.html:250
|
||||
msgid "Shared with"
|
||||
msgstr ""
|
||||
msgstr "Delt med"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:280
|
||||
msgid "Add to Shopping"
|
||||
msgstr ""
|
||||
msgstr "Legg til i handlelisten"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:323
|
||||
msgid "New meal type"
|
||||
msgstr ""
|
||||
msgstr "Ny måltidstype"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:338
|
||||
msgid "Meal Plan Help"
|
||||
msgstr ""
|
||||
msgstr "Hjelp for måltidsplanen"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:344
|
||||
msgid ""
|
||||
@@ -1289,7 +1316,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\templates\meal_plan_entry.html:6
|
||||
msgid "Meal Plan View"
|
||||
msgstr ""
|
||||
msgstr "Visning av måltidsplanen"
|
||||
|
||||
#: .\cookbook\templates\meal_plan_entry.html:50
|
||||
msgid "Never cooked before."
|
||||
@@ -1297,7 +1324,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\templates\meal_plan_entry.html:76
|
||||
msgid "Other meals on this day"
|
||||
msgstr ""
|
||||
msgstr "Andre måltider denne dagen"
|
||||
|
||||
#: .\cookbook\templates\no_groups_info.html:5
|
||||
#: .\cookbook\templates\no_groups_info.html:12
|
||||
|
||||
Binary file not shown.
@@ -13,10 +13,10 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
|
||||
"PO-Revision-Date: 2023-02-27 13:55+0000\n"
|
||||
"Last-Translator: Jesse <jesse.kamps@pm.me>\n"
|
||||
"Language-Team: Dutch <http://translate.tandoor.dev/projects/tandoor/recipes-"
|
||||
"backend/nl/>\n"
|
||||
"PO-Revision-Date: 2024-02-10 12:20+0000\n"
|
||||
"Last-Translator: Jonan B <jonanb@pm.me>\n"
|
||||
"Language-Team: Dutch <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/nl/>\n"
|
||||
"Language: nl\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@@ -159,7 +159,7 @@ msgstr "Naam"
|
||||
|
||||
#: .\cookbook\forms.py:124 .\cookbook\forms.py:315 .\cookbook\views\lists.py:88
|
||||
msgid "Keywords"
|
||||
msgstr "Etiketten"
|
||||
msgstr "Trefwoorden"
|
||||
|
||||
#: .\cookbook\forms.py:125
|
||||
msgid "Preparation time in minutes"
|
||||
@@ -522,34 +522,32 @@ msgid "One of queryset or hash_key must be provided"
|
||||
msgstr "Er moet een queryset of hash_key opgegeven worden"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:266
|
||||
#, fuzzy
|
||||
#| msgid "Use fractions"
|
||||
msgid "reverse rotation"
|
||||
msgstr "Gebruik fracties"
|
||||
msgstr "omgekeerde rotatie"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:267
|
||||
msgid "careful rotation"
|
||||
msgstr ""
|
||||
msgstr "voorzichtige rotatie"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:268
|
||||
msgid "knead"
|
||||
msgstr ""
|
||||
msgstr "kneden"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:269
|
||||
msgid "thicken"
|
||||
msgstr ""
|
||||
msgstr "verdikken"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:270
|
||||
msgid "warm up"
|
||||
msgstr ""
|
||||
msgstr "opwarmen"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:271
|
||||
msgid "ferment"
|
||||
msgstr ""
|
||||
msgstr "gisten"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:272
|
||||
msgid "sous-vide"
|
||||
msgstr ""
|
||||
msgstr "sous-vide"
|
||||
|
||||
#: .\cookbook\helper\shopping_helper.py:157
|
||||
msgid "You must supply a servings size"
|
||||
@@ -594,10 +592,8 @@ msgid "Imported %s recipes."
|
||||
msgstr "%s recepten geïmporteerd."
|
||||
|
||||
#: .\cookbook\integration\openeats.py:26
|
||||
#, fuzzy
|
||||
#| msgid "Recipe Home"
|
||||
msgid "Recipe source:"
|
||||
msgstr "Recept thuis"
|
||||
msgstr "Bron van het recept:"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:49
|
||||
msgid "Notes"
|
||||
@@ -1228,7 +1224,7 @@ msgstr "Markdown gids"
|
||||
|
||||
#: .\cookbook\templates\base.html:329
|
||||
msgid "GitHub"
|
||||
msgstr "GitHub"
|
||||
msgstr "Github"
|
||||
|
||||
#: .\cookbook\templates\base.html:331
|
||||
msgid "Translate Tandoor"
|
||||
|
||||
Binary file not shown.
@@ -12,8 +12,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-05-18 14:28+0200\n"
|
||||
"PO-Revision-Date: 2023-01-08 17:55+0000\n"
|
||||
"Last-Translator: Joachim Weber <joachim.weber@gmx.de>\n"
|
||||
"PO-Revision-Date: 2023-10-07 18:02+0000\n"
|
||||
"Last-Translator: Guilherme Roda <glealroda@gmail.com>\n"
|
||||
"Language-Team: Portuguese <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/pt/>\n"
|
||||
"Language: pt\n"
|
||||
@@ -206,8 +206,8 @@ msgid ""
|
||||
"Leave empty for dropbox and enter only base url for nextcloud (<code>/remote."
|
||||
"php/webdav/</code> is added automatically)"
|
||||
msgstr ""
|
||||
"Deixar vazio para Dropbox e inserir apenas url base para Nextcloud (<code>/"
|
||||
"remote.php/webdav/</code>é adicionado automaticamente). "
|
||||
"Deixar vazio para Dropbox e inserir apenas url base para Nextcloud "
|
||||
"(<code>/remote.php/webdav/</code>é adicionado automaticamente)"
|
||||
|
||||
#: .\cookbook\forms.py:282 .\cookbook\views\edit.py:157
|
||||
msgid "Storage"
|
||||
@@ -277,16 +277,12 @@ msgstr ""
|
||||
"ignorados)."
|
||||
|
||||
#: .\cookbook\forms.py:461
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| "Select type method of search. Click <a href=\"/docs/search/\">here</a> "
|
||||
#| "for full desciption of choices."
|
||||
msgid ""
|
||||
"Select type method of search. Click <a href=\"/docs/search/\">here</a> for "
|
||||
"full description of choices."
|
||||
msgstr ""
|
||||
"Selecionar o método de pesquisa. Uma descrição completa das opções pode ser "
|
||||
"encontrada <a href=\"/docs/search/\">aqui</a>."
|
||||
"Selecionar o método de pesquisa. Uma descrição completa das opções pode "
|
||||
"ser encontrada <a href=\"/docs/search/\">aqui</a>."
|
||||
|
||||
#: .\cookbook\forms.py:462
|
||||
msgid ""
|
||||
@@ -329,10 +325,8 @@ msgid ""
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:476
|
||||
#, fuzzy
|
||||
#| msgid "Search"
|
||||
msgid "Search Method"
|
||||
msgstr "Procurar"
|
||||
msgstr "Método de Pesquisa"
|
||||
|
||||
#: .\cookbook\forms.py:477
|
||||
msgid "Fuzzy Lookups"
|
||||
@@ -351,16 +345,12 @@ msgid "Starts With"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:481
|
||||
#, fuzzy
|
||||
#| msgid "Search"
|
||||
msgid "Fuzzy Search"
|
||||
msgstr "Procurar"
|
||||
msgstr "Pesquisa Fuzzy"
|
||||
|
||||
#: .\cookbook\forms.py:482
|
||||
#, fuzzy
|
||||
#| msgid "Text"
|
||||
msgid "Full Text"
|
||||
msgstr "Texto"
|
||||
msgstr "Texto Completo"
|
||||
|
||||
#: .\cookbook\forms.py:507
|
||||
msgid ""
|
||||
@@ -405,10 +395,8 @@ msgid "Prefix to add when copying list to the clipboard."
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:524
|
||||
#, fuzzy
|
||||
#| msgid "Shopping"
|
||||
msgid "Share Shopping List"
|
||||
msgstr "Compras"
|
||||
msgstr "Compartilhar Lista de Compras"
|
||||
|
||||
#: .\cookbook\forms.py:525
|
||||
msgid "Autosync"
|
||||
@@ -459,10 +447,8 @@ msgid "Reset all food to inherit the fields configured."
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:557
|
||||
#, fuzzy
|
||||
#| msgid "Food that should be replaced."
|
||||
msgid "Fields on food that should be inherited by default."
|
||||
msgstr "Prato a ser alterado."
|
||||
msgstr "Campos do alimento que devem ser herdados por padrão."
|
||||
|
||||
#: .\cookbook\forms.py:558
|
||||
msgid "Show recipe counts on search filters"
|
||||
@@ -516,10 +502,8 @@ msgid "One of queryset or hash_key must be provided"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:266
|
||||
#, fuzzy
|
||||
#| msgid "Use fractions"
|
||||
msgid "reverse rotation"
|
||||
msgstr "Usar frações"
|
||||
msgstr "rotação reversa"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:267
|
||||
msgid "careful rotation"
|
||||
@@ -585,16 +569,12 @@ msgid "Imported %s recipes."
|
||||
msgstr "%s receitas importadas."
|
||||
|
||||
#: .\cookbook\integration\openeats.py:26
|
||||
#, fuzzy
|
||||
#| msgid "Recipes"
|
||||
msgid "Recipe source:"
|
||||
msgstr "Receitas"
|
||||
msgstr "Fonte da Receita:"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:49
|
||||
#, fuzzy
|
||||
#| msgid "Note"
|
||||
msgid "Notes"
|
||||
msgstr "Nota"
|
||||
msgstr "Notas"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:52
|
||||
msgid "Nutritional Information"
|
||||
@@ -606,10 +586,8 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\integration\recettetek.py:54
|
||||
#: .\cookbook\integration\recipekeeper.py:70
|
||||
#, fuzzy
|
||||
#| msgid "Import"
|
||||
msgid "Imported from"
|
||||
msgstr "Importar"
|
||||
msgstr "Importado de"
|
||||
|
||||
#: .\cookbook\integration\saffron.py:23
|
||||
msgid "Servings"
|
||||
@@ -706,32 +684,24 @@ msgid "Raw"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\models.py:1231
|
||||
#, fuzzy
|
||||
#| msgid "New Food"
|
||||
msgid "Food Alias"
|
||||
msgstr "Novo Prato"
|
||||
msgstr "Apelido do Alimento"
|
||||
|
||||
#: .\cookbook\models.py:1231
|
||||
#, fuzzy
|
||||
#| msgid "Units"
|
||||
msgid "Unit Alias"
|
||||
msgstr "Unidades"
|
||||
msgstr "Apelido da Unidade"
|
||||
|
||||
#: .\cookbook\models.py:1231
|
||||
#, fuzzy
|
||||
#| msgid "Keywords"
|
||||
msgid "Keyword Alias"
|
||||
msgstr "Palavras-chave"
|
||||
msgstr "Apelido de Palavra-chave"
|
||||
|
||||
#: .\cookbook\models.py:1232
|
||||
msgid "Description Replace"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\models.py:1232
|
||||
#, fuzzy
|
||||
#| msgid "Instructions"
|
||||
msgid "Instruction Replace"
|
||||
msgstr "Instruções"
|
||||
msgstr "Substituir Instruções"
|
||||
|
||||
#: .\cookbook\models.py:1258 .\cookbook\views\delete.py:36
|
||||
#: .\cookbook\views\edit.py:251 .\cookbook\views\new.py:48
|
||||
@@ -739,10 +709,8 @@ msgid "Recipe"
|
||||
msgstr "Receita"
|
||||
|
||||
#: .\cookbook\models.py:1259
|
||||
#, fuzzy
|
||||
#| msgid "New Food"
|
||||
msgid "Food"
|
||||
msgstr "Novo Prato"
|
||||
msgstr "Alimento"
|
||||
|
||||
#: .\cookbook\models.py:1260 .\cookbook\templates\base.html:141
|
||||
msgid "Keyword"
|
||||
@@ -880,10 +848,8 @@ msgid "Primary"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\templates\account\email.html:47
|
||||
#, fuzzy
|
||||
#| msgid "Make Header"
|
||||
msgid "Make Primary"
|
||||
msgstr "Adicionar Cabeçalho"
|
||||
msgstr "Tornar Primeiro"
|
||||
|
||||
#: .\cookbook\templates\account\email.html:49
|
||||
msgid "Re-send Verification"
|
||||
@@ -1004,10 +970,8 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\templates\account\password_change.html:12
|
||||
#: .\cookbook\templates\account\password_set.html:12
|
||||
#, fuzzy
|
||||
#| msgid "Settings"
|
||||
msgid "Password"
|
||||
msgstr "Definições"
|
||||
msgstr "Senha"
|
||||
|
||||
#: .\cookbook\templates\account\password_change.html:22
|
||||
msgid "Forgot Password?"
|
||||
@@ -1050,10 +1014,8 @@ msgid ""
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\templates\account\password_reset_from_key.html:33
|
||||
#, fuzzy
|
||||
#| msgid "Settings"
|
||||
msgid "change password"
|
||||
msgstr "Definições"
|
||||
msgstr "alterar senha"
|
||||
|
||||
#: .\cookbook\templates\account\password_reset_from_key.html:36
|
||||
#: .\cookbook\templates\account\password_reset_from_key_done.html:19
|
||||
@@ -1125,10 +1087,8 @@ msgid "Shopping"
|
||||
msgstr "Compras"
|
||||
|
||||
#: .\cookbook\templates\base.html:153 .\cookbook\views\lists.py:105
|
||||
#, fuzzy
|
||||
#| msgid "New Food"
|
||||
msgid "Foods"
|
||||
msgstr "Novo Prato"
|
||||
msgstr "Alimentos"
|
||||
|
||||
#: .\cookbook\templates\base.html:165 .\cookbook\views\lists.py:122
|
||||
msgid "Units"
|
||||
@@ -1139,20 +1099,16 @@ msgid "Supermarket"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\templates\base.html:191
|
||||
#, fuzzy
|
||||
#| msgid "Batch edit Category"
|
||||
msgid "Supermarket Category"
|
||||
msgstr "Editar Categorias em massa"
|
||||
msgstr "Categoria de Supermercado"
|
||||
|
||||
#: .\cookbook\templates\base.html:203 .\cookbook\views\lists.py:171
|
||||
msgid "Automations"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\templates\base.html:217 .\cookbook\views\lists.py:207
|
||||
#, fuzzy
|
||||
#| msgid "File ID"
|
||||
msgid "Files"
|
||||
msgstr "ID the ficheiro"
|
||||
msgstr "Arquivos"
|
||||
|
||||
#: .\cookbook\templates\base.html:229
|
||||
msgid "Batch Edit"
|
||||
@@ -1166,10 +1122,8 @@ msgstr "Histórico"
|
||||
#: .\cookbook\templates\base.html:255
|
||||
#: .\cookbook\templates\ingredient_editor.html:7
|
||||
#: .\cookbook\templates\ingredient_editor.html:13
|
||||
#, fuzzy
|
||||
#| msgid "Ingredients"
|
||||
msgid "Ingredient Editor"
|
||||
msgstr "Ingredientes"
|
||||
msgstr "Editor de Ingrediente"
|
||||
|
||||
#: .\cookbook\templates\base.html:267
|
||||
#: .\cookbook\templates\export_response.html:7
|
||||
@@ -1191,10 +1145,8 @@ msgid "External Recipes"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\templates\base.html:301 .\cookbook\templates\space_manage.html:15
|
||||
#, fuzzy
|
||||
#| msgid "Settings"
|
||||
msgid "Space Settings"
|
||||
msgstr "Definições"
|
||||
msgstr "Configurar Espaço"
|
||||
|
||||
#: .\cookbook\templates\base.html:306 .\cookbook\templates\system.html:13
|
||||
msgid "System"
|
||||
@@ -1206,10 +1158,8 @@ msgstr "Administração"
|
||||
|
||||
#: .\cookbook\templates\base.html:312
|
||||
#: .\cookbook\templates\space_overview.html:25
|
||||
#, fuzzy
|
||||
#| msgid "Create"
|
||||
msgid "Your Spaces"
|
||||
msgstr "Criar"
|
||||
msgstr "Seus Espaços"
|
||||
|
||||
#: .\cookbook\templates\base.html:323
|
||||
#: .\cookbook\templates\space_overview.html:6
|
||||
@@ -1288,19 +1238,15 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\templates\batch\monitor.html:28
|
||||
msgid "Sync Now!"
|
||||
msgstr "Sincronizar"
|
||||
msgstr "Sincronizar Agora!"
|
||||
|
||||
#: .\cookbook\templates\batch\monitor.html:29
|
||||
#, fuzzy
|
||||
#| msgid "Recipes"
|
||||
msgid "Show Recipes"
|
||||
msgstr "Receitas"
|
||||
msgstr "Mostrar Receitas"
|
||||
|
||||
#: .\cookbook\templates\batch\monitor.html:30
|
||||
#, fuzzy
|
||||
#| msgid "View Log"
|
||||
msgid "Show Log"
|
||||
msgstr "Ver Registro"
|
||||
msgstr "Mostrar Log"
|
||||
|
||||
#: .\cookbook\templates\batch\waiting.html:4
|
||||
#: .\cookbook\templates\batch\waiting.html:10
|
||||
@@ -1335,7 +1281,7 @@ msgstr "Editar Receita"
|
||||
#: .\cookbook\templates\generic\delete_template.html:21
|
||||
#, python-format
|
||||
msgid "Are you sure you want to delete the %(title)s: <b>%(object)s</b> "
|
||||
msgstr "Tem a certeza que quer apagar %(title)s: <b>%(object)s</b>"
|
||||
msgstr "Tem certeza que deseja apagar %(title)s: <b>%(object)s</b> "
|
||||
|
||||
#: .\cookbook\templates\generic\delete_template.html:22
|
||||
msgid "This cannot be undone!"
|
||||
@@ -1369,7 +1315,7 @@ msgstr "Apagar ficheiro original"
|
||||
#: .\cookbook\templates\generic\list_template.html:6
|
||||
#: .\cookbook\templates\generic\list_template.html:22
|
||||
msgid "List"
|
||||
msgstr "Listar "
|
||||
msgstr "Listar"
|
||||
|
||||
#: .\cookbook\templates\generic\list_template.html:36
|
||||
msgid "Filter"
|
||||
@@ -1422,13 +1368,13 @@ msgid ""
|
||||
" "
|
||||
msgstr ""
|
||||
"\n"
|
||||
" Os </b>campos da senha e Token</b> são guardados dentro da base de "
|
||||
"dados como <b>texto simples.</b>\n"
|
||||
"Isto é necessário porque eles são usados para fazer pedidos á API, mas "
|
||||
"também aumenta o risco de\n"
|
||||
"de alguém os roubar. <br/>\n"
|
||||
"Para limitar os possíveis danos, tokens e contas com acesso limitado podem "
|
||||
"ser usadas.\n"
|
||||
" Os campos de <b>senha e Token</b> são armazenados na base de dados "
|
||||
"como <b>texto simples</b>.\n"
|
||||
" Isto é necessário porque eles são usados para fazer pedidos à API, "
|
||||
"mas também aumenta o risco\n"
|
||||
" de alguém os roubar.<br/>\n"
|
||||
" Para limitar os possíveis danos, tokens e contas com acesso limitado "
|
||||
"podem ser usadas.\n"
|
||||
" "
|
||||
|
||||
#: .\cookbook\templates\index.html:29
|
||||
@@ -1441,7 +1387,7 @@ msgstr "Nova Receita"
|
||||
|
||||
#: .\cookbook\templates\index.html:53
|
||||
msgid "Advanced Search"
|
||||
msgstr "Procura avançada "
|
||||
msgstr "Pesquisa avançada"
|
||||
|
||||
#: .\cookbook\templates\index.html:57
|
||||
msgid "Reset Search"
|
||||
@@ -1493,8 +1439,6 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:57
|
||||
#: .\cookbook\templates\markdown_info.html:73
|
||||
#, fuzzy
|
||||
#| msgid "or by leaving a blank line inbetween."
|
||||
msgid "or by leaving a blank line in between."
|
||||
msgstr "ou deixando uma linha em branco no meio."
|
||||
|
||||
@@ -1518,10 +1462,6 @@ msgid "Lists"
|
||||
msgstr "Listas"
|
||||
|
||||
#: .\cookbook\templates\markdown_info.html:85
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| "Lists can ordered or unorderd. It is <b>important to leave a blank line "
|
||||
#| "before the list!</b>"
|
||||
msgid ""
|
||||
"Lists can ordered or unordered. It is <b>important to leave a blank line "
|
||||
"before the list!</b>"
|
||||
@@ -1598,7 +1538,7 @@ msgstr "Cabeçalho"
|
||||
#: .\cookbook\templates\markdown_info.html:157
|
||||
#: .\cookbook\templates\markdown_info.html:178
|
||||
msgid "Cell"
|
||||
msgstr "Célula "
|
||||
msgstr "Célula"
|
||||
|
||||
#: .\cookbook\templates\no_groups_info.html:5
|
||||
#: .\cookbook\templates\no_groups_info.html:12
|
||||
@@ -1666,10 +1606,8 @@ msgstr ""
|
||||
#: .\cookbook\templates\search_info.html:5
|
||||
#: .\cookbook\templates\search_info.html:9
|
||||
#: .\cookbook\templates\settings.html:24
|
||||
#, fuzzy
|
||||
#| msgid "Search String"
|
||||
msgid "Search Settings"
|
||||
msgstr "Procurar"
|
||||
msgstr "Configurações de Pesquisa"
|
||||
|
||||
#: .\cookbook\templates\search_info.html:10
|
||||
msgid ""
|
||||
@@ -1684,10 +1622,8 @@ msgid ""
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\templates\search_info.html:19
|
||||
#, fuzzy
|
||||
#| msgid "Search"
|
||||
msgid "Search Methods"
|
||||
msgstr "Procurar"
|
||||
msgstr "Métodos de Pesquisa"
|
||||
|
||||
#: .\cookbook\templates\search_info.html:23
|
||||
msgid ""
|
||||
@@ -1769,10 +1705,8 @@ msgid ""
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\templates\search_info.html:69
|
||||
#, fuzzy
|
||||
#| msgid "Search Recipe"
|
||||
msgid "Search Fields"
|
||||
msgstr "Procure Receita"
|
||||
msgstr "Campos de Pesquisa"
|
||||
|
||||
#: .\cookbook\templates\search_info.html:73
|
||||
msgid ""
|
||||
@@ -1810,10 +1744,8 @@ msgid ""
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\templates\search_info.html:95
|
||||
#, fuzzy
|
||||
#| msgid "Search"
|
||||
msgid "Search Index"
|
||||
msgstr "Procurar"
|
||||
msgstr "Índice de Pesquisa"
|
||||
|
||||
#: .\cookbook\templates\search_info.html:99
|
||||
msgid ""
|
||||
@@ -2012,10 +1944,8 @@ msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:57
|
||||
#, fuzzy
|
||||
#| msgid "Create"
|
||||
msgid "Leave Space"
|
||||
msgstr "Criar"
|
||||
msgstr "Sair do Espaço"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:78
|
||||
#: .\cookbook\templates\space_overview.html:88
|
||||
@@ -2034,10 +1964,8 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:96
|
||||
#: .\cookbook\templates\space_overview.html:105
|
||||
#, fuzzy
|
||||
#| msgid "Create"
|
||||
msgid "Create Space"
|
||||
msgstr "Criar"
|
||||
msgstr "Criar Espaço"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:99
|
||||
msgid "Create your own recipe space."
|
||||
@@ -2487,10 +2415,8 @@ msgid "Shopping Categories"
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\views\lists.py:187
|
||||
#, fuzzy
|
||||
#| msgid "Filter"
|
||||
msgid "Custom Filters"
|
||||
msgstr "Filtrar"
|
||||
msgstr "Filtros Customizados"
|
||||
|
||||
#: .\cookbook\views\lists.py:224
|
||||
msgid "Steps"
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user