Compare commits
679 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 | ||
|
|
899a9955fb | ||
|
|
3c08e3a3f1 | ||
|
|
5a6a1787cf | ||
|
|
8d4cb4f08d | ||
|
|
537276c62f | ||
|
|
f168fb825f | ||
|
|
3cabe85091 | ||
|
|
4e05bc2f6a | ||
|
|
8f8147fda4 | ||
|
|
530d6b0cb6 | ||
|
|
2397c66218 | ||
|
|
f826f93b03 | ||
|
|
9da66c9f6c | ||
|
|
124211a2f4 | ||
|
|
71555fee28 | ||
|
|
05a99c9b64 | ||
|
|
32690f04b2 | ||
|
|
29b74557a6 | ||
|
|
c43e7e0331 | ||
|
|
fe7fd7700d | ||
|
|
c6ef0e0087 | ||
|
|
6149f693ab | ||
|
|
daef57823f | ||
|
|
332c4bd94a | ||
|
|
5c7b9a93ae | ||
|
|
b681364f95 | ||
|
|
40d14eeb9f | ||
|
|
46b09f11b6 | ||
|
|
900291dc5f | ||
|
|
e9f9134e2e | ||
|
|
8fe11b12f8 | ||
|
|
a1cfb7ad9f | ||
|
|
2bddf21175 | ||
|
|
aa5490adb3 | ||
|
|
bea089dd5e | ||
|
|
2c7237adaa | ||
|
|
98af1e1e4c | ||
|
|
4a1aee38a3 | ||
|
|
92c21bc382 | ||
|
|
ba748cc5fe | ||
|
|
22b1a9634a | ||
|
|
eeb5395efc | ||
|
|
6ea259596a | ||
|
|
49275a96fe |
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
@@ -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
@@ -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"
|
||||
}
|
||||
180
.env.template
@@ -1,185 +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=*
|
||||
|
||||
# Cross Site Request Forgery protection
|
||||
# (https://docs.djangoproject.com/en/4.2/ref/settings/#std-setting-CSRF_TRUSTED_ORIGINS)
|
||||
# CSRF_TRUSTED_ORIGINS = []
|
||||
|
||||
# Cross Origin Resource Sharing
|
||||
# (https://github.com/adamchainz/django-cors-header)
|
||||
# CORS_ALLOW_ALL_ORIGINS = True
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# ---------------------------- AT LEAST ONE REQUIRED -------------------------
|
||||
SECRET_KEY=
|
||||
SECRET_KEY_FILE=
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
# 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
|
||||
# ---------------------------- AT LEAST ONE REQUIRED -------------------------
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_PASSWORD_FILE=
|
||||
# ---------------------------------------------------------------
|
||||
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 the REMOTE-USER header (can be used for e.g. authelia).
|
||||
# ATTENTION: Leave off if you don't know what you are doing! Enabling this without proper configuration will enable anybody
|
||||
# to login with any username!
|
||||
# See docs for additional information: https://docs.tandoor.dev/features/authentication/#reverse-proxy-authentication
|
||||
# when unset: 0 (false)
|
||||
REMOTE_USER_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 local accounts on your application instance (without an invite link)
|
||||
# social accounts will always be able to sign up
|
||||
# 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
@@ -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:
|
||||
|
||||
4
.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
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
path: ./recipes/plugins/open_data_plugin
|
||||
|
||||
# Build Vue frontend
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: yarn
|
||||
|
||||
4
.github/workflows/build-docker.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
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# Build Vue frontend
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: yarn
|
||||
|
||||
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: '18'
|
||||
- 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
@@ -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
@@ -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
|
||||
|
||||
5
.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
|
||||
@@ -80,10 +80,11 @@ 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
@@ -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,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>
|
||||
|
||||
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
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"python.testing.pytestArgs": [
|
||||
"cookbook/tests"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true
|
||||
}
|
||||
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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.10-alpine3.18
|
||||
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 openldap git
|
||||
@@ -19,12 +19,14 @@ 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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
2
boot.sh
@@ -76,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
|
||||
|
||||
@@ -13,10 +13,10 @@ from cookbook.managers import DICTIONARY
|
||||
from .models import (BookmarkletImport, Comment, CookLog, Food, ImportLog, Ingredient, InviteLink,
|
||||
Keyword, MealPlan, MealType, NutritionInformation, Property, PropertyType,
|
||||
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink,
|
||||
ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
||||
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
||||
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
|
||||
TelegramBot, Unit, UnitConversion, UserFile, UserPreference, UserSpace,
|
||||
ViewLog)
|
||||
ViewLog, ConnectorConfig)
|
||||
|
||||
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
@@ -60,9 +60,9 @@ 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',)
|
||||
|
||||
@@ -95,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')
|
||||
@@ -108,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):
|
||||
@@ -163,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)
|
||||
|
||||
@@ -183,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'
|
||||
|
||||
@@ -198,7 +220,14 @@ class RecipeAdmin(admin.ModelAdmin):
|
||||
|
||||
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)
|
||||
@@ -229,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)
|
||||
|
||||
@@ -258,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
|
||||
@@ -288,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)
|
||||
@@ -334,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',)
|
||||
|
||||
@@ -349,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
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
@@ -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
@@ -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, 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', 'show_step_ingredients',
|
||||
)
|
||||
|
||||
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'),
|
||||
'show_step_ingredients': _('Show step ingredients table')
|
||||
}
|
||||
|
||||
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.'),
|
||||
'show_step_ingredients': _('Add ingredients table next to recipe steps. Applies at creation time for manually created and URL imported recipes. Individual steps can be overridden in the edit recipe view.')
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -187,9 +118,8 @@ class MultipleFileField(forms.FileField):
|
||||
|
||||
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):
|
||||
@@ -208,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'),
|
||||
}
|
||||
|
||||
|
||||
@@ -275,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')
|
||||
@@ -302,6 +233,7 @@ class BatchEditForm(forms.Form):
|
||||
|
||||
|
||||
class ImportRecipeForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -311,19 +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,
|
||||
}
|
||||
field_classes = {'keywords': SafeModelChoiceField, }
|
||||
|
||||
|
||||
class InviteLinkForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -331,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):
|
||||
@@ -346,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):
|
||||
@@ -371,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 == '':
|
||||
@@ -386,135 +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', 'use_plural')
|
||||
|
||||
help_texts = {
|
||||
'food_inherit': _('Fields on food that should be inherited by default.'),
|
||||
'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,
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -98,7 +98,7 @@ class AutomationEngine:
|
||||
try:
|
||||
return self.food_aliases[food.lower()]
|
||||
except KeyError:
|
||||
return food
|
||||
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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -169,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):
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import traceback
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
|
||||
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:
|
||||
@@ -18,69 +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,115 +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')
|
||||
|
||||
unit_g = Unit.objects.filter(space=self.request.space, base_unit__iexact='g').first()
|
||||
|
||||
insert_list = []
|
||||
insert_list_flat = []
|
||||
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):
|
||||
if not (self.data[datatype][k]['name'] in insert_list_flat or self.data[datatype][k]['plural_name'] in insert_list_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,
|
||||
'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,
|
||||
}})
|
||||
# build a fake second flat array to prevent duplicate foods from being inserted.
|
||||
# trying to insert a duplicate would throw a db error :(
|
||||
insert_list_flat.append(self.data[datatype][k]['name'])
|
||||
insert_list_flat.append(self.data[datatype][k]['plural_name'])
|
||||
|
||||
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,
|
||||
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['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'],
|
||||
open_data_food_slug=k,
|
||||
space=self.request.space,
|
||||
)
|
||||
|
||||
for k in list(self.data[datatype].keys()):
|
||||
for fp in self.data[datatype][k]['properties']['type_values']:
|
||||
# try catch here because somettimes key "k" is not set for he food cache
|
||||
try:
|
||||
food_property_list.append(Property(
|
||||
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],
|
||||
space=self.request.space,
|
||||
))
|
||||
except KeyError:
|
||||
print(str(k) + ' is not in self.slug_id_cache["food"]')
|
||||
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',))
|
||||
|
||||
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()):
|
||||
# try catch here because sometimes key "k" is not set for he food cache
|
||||
# try catch here because sometimes key "k" is not set for the food cache
|
||||
try:
|
||||
insert_list.append(UnitConversion(
|
||||
base_amount=self.data[datatype][k]['base_amount'],
|
||||
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=self.data[datatype][k]['converted_amount'],
|
||||
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=self.request.user,
|
||||
))
|
||||
except KeyError:
|
||||
print(str(k) + ' is not in self.slug_id_cache["food"]')
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -45,12 +45,12 @@ 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
|
||||
@@ -58,13 +58,17 @@ class FoodPropertyHelper:
|
||||
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:
|
||||
|
||||
@@ -309,7 +309,7 @@ class RecipeSearch():
|
||||
|
||||
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:
|
||||
|
||||
@@ -163,10 +163,9 @@ def get_from_scraper(scrape, request):
|
||||
if len(recipe_json['steps']) == 0:
|
||||
recipe_json['steps'].append({'instruction': '', 'ingredients': [], })
|
||||
|
||||
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']
|
||||
else:
|
||||
recipe_json['description'] = recipe_json['description'][:512]
|
||||
|
||||
try:
|
||||
for x in scrape.ingredients():
|
||||
@@ -232,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
|
||||
@@ -259,13 +258,14 @@ def get_from_youtube_scraper(url, request):
|
||||
]
|
||||
}
|
||||
|
||||
# TODO add automation here
|
||||
try:
|
||||
automation_engine = AutomationEngine(request, source=url)
|
||||
video = YouTube(url=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'] = automation_engine.apply_regex_replace_automation(video.description, Automation.INSTRUCTION_REPLACE)
|
||||
if video.description:
|
||||
default_recipe_json['steps'][0]['instruction'] = automation_engine.apply_regex_replace_automation(video.description, Automation.INSTRUCTION_REPLACE)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
@@ -415,8 +415,8 @@ def parse_keywords(keyword_json, request):
|
||||
# if alias exists use that instead
|
||||
|
||||
if len(kw) != 0:
|
||||
automation_engine.apply_keyword_automation(kw)
|
||||
if k := Keyword.objects.filter(name=kw, space=request.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})
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
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.models import (Ingredient, MealPlan, Recipe, ShoppingListEntry, ShoppingListRecipe,
|
||||
@@ -27,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')
|
||||
@@ -79,10 +75,8 @@ 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()
|
||||
|
||||
|
||||
@@ -14,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))
|
||||
@@ -83,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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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_servings, parse_servings_text
|
||||
@@ -53,7 +53,10 @@ 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:
|
||||
|
||||
@@ -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!"
|
||||
|
||||
@@ -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-07-31 14:19+0000\n"
|
||||
"Last-Translator: Mára Štěpánek <stepanekm7@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"
|
||||
@@ -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"
|
||||
|
||||
@@ -15,8 +15,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-11-22 18:19+0000\n"
|
||||
"Last-Translator: Spreez <tandoor@larsdev.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"
|
||||
@@ -161,7 +161,7 @@ msgstr "Name"
|
||||
|
||||
#: .\cookbook\forms.py:124 .\cookbook\forms.py:315 .\cookbook\views\lists.py:88
|
||||
msgid "Keywords"
|
||||
msgstr "Schlüsselwörter"
|
||||
msgstr "Schlagwörter"
|
||||
|
||||
#: .\cookbook\forms.py:125
|
||||
msgid "Preparation time in minutes"
|
||||
@@ -1436,9 +1436,9 @@ msgid ""
|
||||
" "
|
||||
msgstr ""
|
||||
"\n"
|
||||
" <b>Passwort 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-"
|
||||
" 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 "
|
||||
|
||||
@@ -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-10-12 20:19+0000\n"
|
||||
"Last-Translator: pharok <pharok@free.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"
|
||||
@@ -551,7 +551,7 @@ 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"
|
||||
@@ -1802,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 "
|
||||
@@ -1869,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 "
|
||||
@@ -2465,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."
|
||||
@@ -2686,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!"
|
||||
|
||||
BIN
cookbook/locale/he/LC_MESSAGES/django.mo
Normal file
@@ -11,7 +11,7 @@ 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-10-20 14:05+0000\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"
|
||||
@@ -282,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 ""
|
||||
@@ -536,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"
|
||||
@@ -770,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"
|
||||
@@ -1226,10 +1220,8 @@ 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
|
||||
@@ -2132,6 +2124,8 @@ msgstr "Csatlakozás %(provider)s"
|
||||
#, 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-08-15 19:19+0000\n"
|
||||
"Last-Translator: Jochum van der Heide <jochum@famvanderheide.com>\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"
|
||||
@@ -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"
|
||||
@@ -1224,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"
|
||||
|
||||
@@ -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: 2022-04-17 00:31+0000\n"
|
||||
"Last-Translator: Oskar Stenberg <01ste02@gmail.com>\n"
|
||||
"PO-Revision-Date: 2024-02-27 12:19+0000\n"
|
||||
"Last-Translator: Lukas Åteg <lukas@ategsolutions.se>\n"
|
||||
"Language-Team: Swedish <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/sv/>\n"
|
||||
"Language: sv\n"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 4.10.1\n"
|
||||
"X-Generator: Weblate 4.15\n"
|
||||
|
||||
#: .\cookbook\filters.py:23 .\cookbook\templates\base.html:91
|
||||
#: .\cookbook\templates\forms\edit_internal_recipe.html:219
|
||||
@@ -1812,7 +1812,7 @@ msgstr "Google ld+json info"
|
||||
|
||||
#: .\cookbook\templates\url_import.html:268
|
||||
msgid "GitHub Issues"
|
||||
msgstr "GitHub Issues"
|
||||
msgstr "GitHub Problem"
|
||||
|
||||
#: .\cookbook\templates\url_import.html:270
|
||||
msgid "Recipe Markup Specification"
|
||||
@@ -1852,7 +1852,7 @@ msgstr "Kunde inte tolka korrekt..."
|
||||
msgid "Batch edit done. %(count)d recipe was updated."
|
||||
msgid_plural "Batch edit done. %(count)d Recipes where updated."
|
||||
msgstr[0] "Batchredigering klar. %(count)d recept uppdaterades."
|
||||
msgstr[1] "Batchredigering klar. %(count)d recept uppdaterades."
|
||||
msgstr[1] "Batchredigering klar. %(count)d recepten uppdaterades."
|
||||
|
||||
#: .\cookbook\views\delete.py:72
|
||||
msgid "Monitor"
|
||||
|
||||
@@ -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: 2022-11-06 22:09+0000\n"
|
||||
"Last-Translator: Gorkem <g.kalipcilar@gmail.com>\n"
|
||||
"PO-Revision-Date: 2024-03-03 23:19+0000\n"
|
||||
"Last-Translator: M Ugur <mugurd@gmail.com>\n"
|
||||
"Language-Team: Turkish <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/tr/>\n"
|
||||
"Language: tr\n"
|
||||
@@ -20,7 +20,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
"X-Generator: Weblate 4.14.1\n"
|
||||
"X-Generator: Weblate 4.15\n"
|
||||
|
||||
#: .\cookbook\forms.py:52
|
||||
msgid "Default unit"
|
||||
@@ -28,41 +28,39 @@ msgstr "Varsayılan birim"
|
||||
|
||||
#: .\cookbook\forms.py:53
|
||||
msgid "Use fractions"
|
||||
msgstr ""
|
||||
msgstr "Kesirleri kullan"
|
||||
|
||||
#: .\cookbook\forms.py:54
|
||||
msgid "Use KJ"
|
||||
msgstr ""
|
||||
msgstr "KiloJoule kullan"
|
||||
|
||||
#: .\cookbook\forms.py:55
|
||||
msgid "Theme"
|
||||
msgstr ""
|
||||
msgstr "Tema"
|
||||
|
||||
#: .\cookbook\forms.py:56
|
||||
msgid "Navbar color"
|
||||
msgstr ""
|
||||
msgstr "Gezinti çubuğu rengi"
|
||||
|
||||
#: .\cookbook\forms.py:57
|
||||
msgid "Sticky navbar"
|
||||
msgstr ""
|
||||
msgstr "Yapışkan gezinti çubuğu"
|
||||
|
||||
#: .\cookbook\forms.py:58
|
||||
msgid "Default page"
|
||||
msgstr ""
|
||||
msgstr "Varsayılan sayfa"
|
||||
|
||||
#: .\cookbook\forms.py:59
|
||||
msgid "Plan sharing"
|
||||
msgstr ""
|
||||
msgstr "Plan paylaşımı"
|
||||
|
||||
#: .\cookbook\forms.py:60
|
||||
#, fuzzy
|
||||
#| msgid "Ingredients"
|
||||
msgid "Ingredient decimal places"
|
||||
msgstr "Malzemeler"
|
||||
msgstr "Malzeme ondalık virgül yeri"
|
||||
|
||||
#: .\cookbook\forms.py:61
|
||||
msgid "Shopping list auto sync period"
|
||||
msgstr ""
|
||||
msgstr "Alışveriş listesinin otomatik eşleşme sıklığı"
|
||||
|
||||
#: .\cookbook\forms.py:62 .\cookbook\templates\recipe_view.html:36
|
||||
msgid "Comments"
|
||||
@@ -70,7 +68,7 @@ msgstr "Yorumlar"
|
||||
|
||||
#: .\cookbook\forms.py:63
|
||||
msgid "Left-handed mode"
|
||||
msgstr ""
|
||||
msgstr "Solaklar için"
|
||||
|
||||
#: .\cookbook\forms.py:67
|
||||
msgid ""
|
||||
@@ -89,10 +87,12 @@ msgid ""
|
||||
"Enables support for fractions in ingredient amounts (e.g. convert decimals "
|
||||
"to fractions automatically)"
|
||||
msgstr ""
|
||||
"Malzeme miktarı için kesir desteğini etkinleştir (örn. ondalıkları kesire "
|
||||
"otomatik çevir)"
|
||||
|
||||
#: .\cookbook\forms.py:73
|
||||
msgid "Display nutritional energy amounts in joules instead of calories"
|
||||
msgstr ""
|
||||
msgstr "Besin değerlerini kalori yerine jul olarak görüntüle"
|
||||
|
||||
#: .\cookbook\forms.py:74
|
||||
msgid "Users with whom newly created meal plans should be shared by default."
|
||||
@@ -100,7 +100,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:75
|
||||
msgid "Users with whom to share shopping lists."
|
||||
msgstr ""
|
||||
msgstr "Alışveriş listesinin paylaşılacağı kullanıcılar."
|
||||
|
||||
#: .\cookbook\forms.py:76
|
||||
msgid "Number of decimals to round ingredients."
|
||||
@@ -129,11 +129,11 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:83 .\cookbook\forms.py:512
|
||||
msgid "Automatically add meal plan ingredients to shopping list."
|
||||
msgstr ""
|
||||
msgstr "Otomatik olarak yemek planındaki malzemeleri alışveriş listesine ekle."
|
||||
|
||||
#: .\cookbook\forms.py:84
|
||||
msgid "Exclude ingredients that are on hand."
|
||||
msgstr ""
|
||||
msgstr "Var olan malzemeleri hariç tut."
|
||||
|
||||
#: .\cookbook\forms.py:85
|
||||
msgid "Will optimize the UI for use with your left hand."
|
||||
@@ -144,6 +144,8 @@ msgid ""
|
||||
"Both fields are optional. If none are given the username will be displayed "
|
||||
"instead"
|
||||
msgstr ""
|
||||
"Her iki değer de tercihe bağlıdır. Hiç birisi verilmezse yerlerine kullanıcı "
|
||||
"adı gösterilecektir"
|
||||
|
||||
#: .\cookbook\forms.py:123 .\cookbook\forms.py:314
|
||||
msgid "Name"
|
||||
@@ -151,23 +153,23 @@ msgstr "İsim"
|
||||
|
||||
#: .\cookbook\forms.py:124 .\cookbook\forms.py:315 .\cookbook\views\lists.py:88
|
||||
msgid "Keywords"
|
||||
msgstr ""
|
||||
msgstr "Anahtar kelimeler"
|
||||
|
||||
#: .\cookbook\forms.py:125
|
||||
msgid "Preparation time in minutes"
|
||||
msgstr ""
|
||||
msgstr "Hazırlık süresi dakika cinsinden"
|
||||
|
||||
#: .\cookbook\forms.py:126
|
||||
msgid "Waiting time (cooking/baking) in minutes"
|
||||
msgstr ""
|
||||
msgstr "Bekleme süresi (pişirme/fırınlama) dakika cinsinden"
|
||||
|
||||
#: .\cookbook\forms.py:127 .\cookbook\forms.py:283 .\cookbook\forms.py:316
|
||||
msgid "Path"
|
||||
msgstr ""
|
||||
msgstr "Adres"
|
||||
|
||||
#: .\cookbook\forms.py:128
|
||||
msgid "Storage UID"
|
||||
msgstr ""
|
||||
msgstr "Saklama UID (biricik tanımlayıcı)"
|
||||
|
||||
#: .\cookbook\forms.py:161
|
||||
msgid "Default"
|
||||
@@ -424,10 +426,8 @@ msgid "Fields on food that should be inherited by default."
|
||||
msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:558
|
||||
#, fuzzy
|
||||
#| msgid "Show recently viewed recipes on search page."
|
||||
msgid "Show recipe counts on search filters"
|
||||
msgstr "Son görüntülenen tarifleri arama sayfasında göster."
|
||||
msgstr "süzülen tarifleri arama sayfasında göster."
|
||||
|
||||
#: .\cookbook\forms.py:559
|
||||
msgid "Use the plural form for units and food inside this space."
|
||||
|
||||
@@ -8,8 +8,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-02-26 13:15+0000\n"
|
||||
"Last-Translator: 吕楪 <thy@irithys.com>\n"
|
||||
"PO-Revision-Date: 2024-02-15 03:19+0000\n"
|
||||
"Last-Translator: dalan <xzdlj@outlook.com>\n"
|
||||
"Language-Team: Chinese (Simplified) <http://translate.tandoor.dev/projects/"
|
||||
"tandoor/recipes-backend/zh_Hans/>\n"
|
||||
"Language: zh_CN\n"
|
||||
@@ -480,34 +480,32 @@ msgid "One of queryset or hash_key must be provided"
|
||||
msgstr "必须提供 queryset 或 hash_key 之一"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:266
|
||||
#, fuzzy
|
||||
#| msgid "Use fractions"
|
||||
msgid "reverse rotation"
|
||||
msgstr "使用分数"
|
||||
msgstr "反向旋转"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:267
|
||||
msgid "careful rotation"
|
||||
msgstr ""
|
||||
msgstr "小心旋转"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:268
|
||||
msgid "knead"
|
||||
msgstr ""
|
||||
msgstr "揉"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:269
|
||||
msgid "thicken"
|
||||
msgstr ""
|
||||
msgstr "增稠"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:270
|
||||
msgid "warm up"
|
||||
msgstr ""
|
||||
msgstr "预热"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:271
|
||||
msgid "ferment"
|
||||
msgstr ""
|
||||
msgstr "发酵"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:272
|
||||
msgid "sous-vide"
|
||||
msgstr ""
|
||||
msgstr "真空烹调法"
|
||||
|
||||
#: .\cookbook\helper\shopping_helper.py:157
|
||||
msgid "You must supply a servings size"
|
||||
@@ -549,10 +547,8 @@ msgid "Imported %s recipes."
|
||||
msgstr "导入了%s菜谱。"
|
||||
|
||||
#: .\cookbook\integration\openeats.py:26
|
||||
#, fuzzy
|
||||
#| msgid "Recipe Home"
|
||||
msgid "Recipe source:"
|
||||
msgstr "菜谱主页"
|
||||
msgstr "菜谱来源:"
|
||||
|
||||
#: .\cookbook\integration\paprika.py:49
|
||||
msgid "Notes"
|
||||
|
||||
34
cookbook/management/commands/fix_duplicate_properties.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.search import SearchVector
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Count
|
||||
from django.utils import translation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.managers import DICTIONARY
|
||||
from cookbook.models import Recipe, Step, FoodProperty, Food
|
||||
|
||||
|
||||
# can be executed at the command line with 'python manage.py rebuildindex'
|
||||
class Command(BaseCommand):
|
||||
help = _('Fixes foods with ')
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('-d', '--dry-run', help='does not delete properties but instead prints them', action='store_true')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
with scopes_disabled():
|
||||
foods_with_duplicate_properties = Food.objects.annotate(property_type_count=Count('foodproperty__property__property_type') - Count('foodproperty__property__property_type', distinct=True)).filter(property_type_count__gt=0).all()
|
||||
for f in foods_with_duplicate_properties:
|
||||
found_property_types = []
|
||||
for fp in f.properties.all():
|
||||
if fp.property_type.id in found_property_types:
|
||||
if options['dry_run']:
|
||||
print(f'Property id {fp.id} duplicate type {fp.property_type}({fp.property_type.id}) for food {f}({f.id})')
|
||||
else:
|
||||
print(f'DELETING property id {fp.id} duplicate type {fp.property_type}({fp.property_type.id}) for food {f}({f.id})')
|
||||
fp.delete()
|
||||
|
||||
else:
|
||||
found_property_types.append(fp.property_type.id)
|
||||
25
cookbook/management/commands/seed_basic_data.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.postgres.search import SearchVector
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import translation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.managers import DICTIONARY
|
||||
from cookbook.models import Recipe, Step, Space
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Seeds some basic data (space, account, food)'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
with scopes_disabled():
|
||||
user = User.objects.get_or_create(username='test')[0]
|
||||
user.set_password('test')
|
||||
user.save()
|
||||
|
||||
space = Space.objects.get_or_create(
|
||||
name='Test Space',
|
||||
created_by=user
|
||||
)[0]
|
||||
@@ -6,11 +6,12 @@ from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import PermissionModelMixin, ShoppingListEntry
|
||||
from cookbook.models import PermissionModelMixin
|
||||
|
||||
|
||||
def copy_values_to_sle(apps, schema_editor):
|
||||
with scopes_disabled():
|
||||
ShoppingListEntry = apps.get_model('cookbook', 'ShoppingListEntry')
|
||||
entries = ShoppingListEntry.objects.all()
|
||||
for entry in entries:
|
||||
if entry.shoppinglist_set.first():
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
# Generated by Django 3.2.7 on 2021-10-01 22:34
|
||||
|
||||
import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
from django.db import migrations
|
||||
from django.utils import timezone
|
||||
from django.utils.timezone import utc
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from cookbook.models import FoodInheritField, ShoppingListEntry
|
||||
|
||||
|
||||
def delete_orphaned_sle(apps, schema_editor):
|
||||
ShoppingListEntry = apps.get_model('cookbook', 'ShoppingListEntry')
|
||||
with scopes_disabled():
|
||||
# shopping list entry is orphaned - delete it
|
||||
ShoppingListEntry.objects.filter(shoppinglist=None).delete()
|
||||
|
||||
|
||||
def create_inheritfields(apps, schema_editor):
|
||||
FoodInheritField = apps.get_model('cookbook', 'FoodInheritField')
|
||||
FoodInheritField.objects.create(name='Supermarket Category', field='supermarket_category')
|
||||
FoodInheritField.objects.create(name='On Hand', field='food_onhand')
|
||||
FoodInheritField.objects.create(name='Diet', field='diet')
|
||||
@@ -29,6 +26,7 @@ def create_inheritfields(apps, schema_editor):
|
||||
|
||||
|
||||
def set_completed_at(apps, schema_editor):
|
||||
ShoppingListEntry = apps.get_model('cookbook', 'ShoppingListEntry')
|
||||
today_start = timezone.now().replace(hour=0, minute=0, second=0)
|
||||
# arbitrary - keeping all of the closed shopping list items out of the 'recent' view
|
||||
month_ago = today_start - timedelta(days=30)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Generated by Django 4.1.10 on 2023-08-29 11:59
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.models import F, Value
|
||||
from django.db.models import F, Value, Count
|
||||
from django.db.models.functions import Concat
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
@@ -13,9 +13,24 @@ def migrate_icons(apps, schema_editor):
|
||||
PropertyType = apps.get_model('cookbook', 'PropertyType')
|
||||
RecipeBook = apps.get_model('cookbook', 'RecipeBook')
|
||||
|
||||
duplicate_meal_types = MealType.objects.values('space_id', 'name').annotate(name_count=Count('name')).exclude(name_count=1).all()
|
||||
if len(duplicate_meal_types) > 0:
|
||||
raise RuntimeError(f'Duplicate MealTypes found, please remove/rename them and run migrations again/restart the container. {duplicate_meal_types}')
|
||||
MealType.objects.update(name=Concat(F('icon'), Value(' '), F('name')))
|
||||
|
||||
duplicate_meal_types = Keyword.objects.values('space_id', 'name').annotate(name_count=Count('name')).exclude(name_count=1).all()
|
||||
if len(duplicate_meal_types) > 0:
|
||||
raise RuntimeError(f'Duplicate Keyword found, please remove/rename them and run migrations again/restart the container. {duplicate_meal_types}')
|
||||
Keyword.objects.update(name=Concat(F('icon'), Value(' '), F('name')))
|
||||
|
||||
duplicate_meal_types = PropertyType.objects.values('space_id', 'name').annotate(name_count=Count('name')).exclude(name_count=1).all()
|
||||
if len(duplicate_meal_types) > 0:
|
||||
raise RuntimeError(f'Duplicate PropertyType found, please remove/rename them and run migrations again/restart the container. {duplicate_meal_types}')
|
||||
PropertyType.objects.update(name=Concat(F('icon'), Value(' '), F('name')))
|
||||
|
||||
duplicate_meal_types = RecipeBook.objects.values('space_id', 'name').annotate(name_count=Count('name')).exclude(name_count=1).all()
|
||||
if len(duplicate_meal_types) > 0:
|
||||
raise RuntimeError(f'Duplicate RecipeBook found, please remove/rename them and run migrations again/restart the container. {duplicate_meal_types}')
|
||||
RecipeBook.objects.update(name=Concat(F('icon'), Value(' '), F('name')))
|
||||
|
||||
|
||||
@@ -25,10 +40,7 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
migrations.RunPython(
|
||||
migrate_icons
|
||||
),
|
||||
migrations.RunPython(migrate_icons),
|
||||
migrations.AlterModelOptions(
|
||||
name='propertytype',
|
||||
options={'ordering': ('order',)},
|
||||
|
||||
@@ -12,6 +12,6 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='mealtype',
|
||||
constraint=models.UniqueConstraint(fields=('space', 'name'), name='mt_unique_name_per_space'),
|
||||
constraint=models.UniqueConstraint(fields=('space', 'name', 'created_by'), name='mt_unique_name_per_space'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# Generated by Django 4.2.7 on 2023-11-27 21:09
|
||||
|
||||
from django.db import migrations, models
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0203_alter_unique_contstraints'),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 4.2.7 on 2023-11-29 19:44
|
||||
|
||||
from django.db import migrations, models
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def fix_fdc_ids(apps, schema_editor):
|
||||
with scopes_disabled():
|
||||
# in case any food had a non digit fdc ID before this migration, remove it
|
||||
Food = apps.get_model('cookbook', 'Food')
|
||||
Food.objects.exclude(fdc_id__regex=r'^\d+$').exclude(fdc_id=None).update(fdc_id=None)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('cookbook', '0204_propertytype_fdc_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(fix_fdc_ids),
|
||||
migrations.AlterField(
|
||||
model_name='food',
|
||||
name='fdc_id',
|
||||
field=models.IntegerField(blank=True, default=None, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='propertytype',
|
||||
name='fdc_id',
|
||||
field=models.IntegerField(blank=True, default=None, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,128 @@
|
||||
# Generated by Django 4.2.7 on 2024-01-01 18:44
|
||||
import django
|
||||
from django.db import migrations, models
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
TANDOOR = 'TANDOOR'
|
||||
TANDOOR_DARK = 'TANDOOR_DARK'
|
||||
BOOTSTRAP = 'BOOTSTRAP'
|
||||
DARKLY = 'DARKLY'
|
||||
FLATLY = 'FLATLY'
|
||||
SUPERHERO = 'SUPERHERO'
|
||||
|
||||
PRIMARY = 'PRIMARY'
|
||||
SECONDARY = 'SECONDARY'
|
||||
SUCCESS = 'SUCCESS'
|
||||
INFO = 'INFO'
|
||||
WARNING = 'WARNING'
|
||||
DANGER = 'DANGER'
|
||||
LIGHT = 'LIGHT'
|
||||
DARK = 'DARK'
|
||||
|
||||
|
||||
# ['light', 'warning', 'info', 'success'] --> light (theming_tags L45)
|
||||
def get_nav_bg_color(theme, nav_color):
|
||||
if theme == TANDOOR: # primary not actually primary color but override existed before update, same for dark
|
||||
return {PRIMARY: '#ddbf86', SECONDARY: '#b55e4f', SUCCESS: '#82aa8b', INFO: '#385f84', WARNING: '#eaaa21', DANGER: '#a7240e', LIGHT: '#cfd5cd', DARK: '#221e1e'}[nav_color]
|
||||
if theme == TANDOOR_DARK:
|
||||
return {PRIMARY: '#ddbf86', SECONDARY: '#b55e4f', SUCCESS: '#82aa8b', INFO: '#385f84', WARNING: '#eaaa21', DANGER: '#a7240e', LIGHT: '#cfd5cd', DARK: '#221e1e'}[nav_color]
|
||||
if theme == BOOTSTRAP:
|
||||
return {PRIMARY: '#007bff', SECONDARY: '#6c757d', SUCCESS: '#28a745', INFO: '#17a2b8', WARNING: '#ffc107', DANGER: '#dc3545', LIGHT: '#f8f9fa', DARK: '#343a40'}[nav_color]
|
||||
if theme == DARKLY:
|
||||
return {PRIMARY: '#375a7f', SECONDARY: '#444', SUCCESS: '#00bc8c', INFO: '#3498DB', WARNING: '#F39C12', DANGER: '#E74C3C', LIGHT: '#999', DARK: '#303030'}[nav_color]
|
||||
if theme == FLATLY:
|
||||
return {PRIMARY: '#2C3E50', SECONDARY: '#95a5a6', SUCCESS: '#18BC9C', INFO: '#3498DB', WARNING: '#F39C12', DANGER: '#E74C3C', LIGHT: '#ecf0f1', DARK: '#7b8a8b'}[nav_color]
|
||||
if theme == SUPERHERO:
|
||||
return {PRIMARY: '#DF691A', SECONDARY: '#4E5D6C', SUCCESS: '#5cb85c', INFO: '#5bc0de', WARNING: '#f0ad4e', DANGER: '#d9534f', LIGHT: '#abb6c2', DARK: '#4E5D6C'}[nav_color]
|
||||
|
||||
|
||||
def get_nav_text_color(theme, nav_color):
|
||||
if theme == TANDOOR:
|
||||
return {PRIMARY: DARK, SECONDARY: DARK, SUCCESS: DARK, INFO: DARK, WARNING: DARK, DANGER: DARK, LIGHT: DARK, DARK: DARK}[nav_color]
|
||||
if theme == TANDOOR_DARK:
|
||||
return {PRIMARY: DARK, SECONDARY: DARK, SUCCESS: DARK, INFO: DARK, WARNING: DARK, DANGER: DARK, LIGHT: DARK, DARK: DARK}[nav_color]
|
||||
if theme == BOOTSTRAP:
|
||||
return {PRIMARY: DARK, SECONDARY: DARK, SUCCESS: DARK, INFO: DARK, WARNING: DARK, DANGER: DARK, LIGHT: DARK, DARK: DARK}[nav_color]
|
||||
if theme == DARKLY:
|
||||
return {PRIMARY: DARK, SECONDARY: DARK, SUCCESS: DARK, INFO: DARK, WARNING: DARK, DANGER: DARK, LIGHT: DARK, DARK: DARK}[nav_color]
|
||||
if theme == FLATLY:
|
||||
return {PRIMARY: DARK, SECONDARY: DARK, SUCCESS: LIGHT, INFO: LIGHT, WARNING: LIGHT, DANGER: DARK, LIGHT: LIGHT, DARK: DARK}[nav_color]
|
||||
if theme == SUPERHERO:
|
||||
return {PRIMARY: DARK, SECONDARY: DARK, SUCCESS: LIGHT, INFO: LIGHT, WARNING: LIGHT, DANGER: DARK, LIGHT: LIGHT, DARK: DARK}[nav_color]
|
||||
|
||||
|
||||
def get_current_colors(apps, schema_editor):
|
||||
with scopes_disabled():
|
||||
# in case any food had a non digit fdc ID before this migration, remove it
|
||||
UserPreference = apps.get_model('cookbook', 'UserPreference')
|
||||
|
||||
update_ups = []
|
||||
for up in UserPreference.objects.all():
|
||||
if up.theme != TANDOOR or up.nav_color != PRIMARY:
|
||||
up.nav_bg_color = get_nav_bg_color(up.theme, up.nav_color)
|
||||
up.nav_text_color = get_nav_text_color(up.theme, up.nav_color)
|
||||
up.nav_show_logo = (up.theme == TANDOOR or up.theme == TANDOOR_DARK)
|
||||
update_ups.append(up)
|
||||
|
||||
UserPreference.objects.bulk_update(update_ups, ['nav_bg_color', 'nav_text_color', 'nav_show_logo'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('cookbook', '0205_alter_food_fdc_id_alter_propertytype_fdc_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='userpreference',
|
||||
old_name='sticky_navbar',
|
||||
new_name='nav_sticky',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='nav_bg_color',
|
||||
field=models.CharField(default='#ddbf86', max_length=8),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='nav_text_color',
|
||||
field=models.CharField(choices=[('LIGHT', 'Light'), ('DARK', 'Dark')], default='DARK', max_length=16),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='nav_show_logo',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.RunPython(get_current_colors),
|
||||
migrations.RemoveField(
|
||||
model_name='userpreference',
|
||||
name='nav_color',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='nav_bg_color',
|
||||
field=models.CharField(blank=True, default='', max_length=8),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='nav_logo',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_nav_logo', to='cookbook.userfile'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='nav_text_color',
|
||||
field=models.CharField(choices=[('BLANK', '-------'), ('LIGHT', 'Light'), ('DARK', 'Dark')], default='BLANK', max_length=16),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='space_theme',
|
||||
field=models.CharField(choices=[('BLANK', '-------'), ('TANDOOR', 'Tandoor'), ('BOOTSTRAP', 'Bootstrap'), ('DARKLY', 'Darkly'), ('FLATLY', 'Flatly'), ('SUPERHERO', 'Superhero'), ('TANDOOR_DARK', 'Tandoor Dark (INCOMPLETE)')],
|
||||
default='BLANK',
|
||||
max_length=128),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='custom_space_theme',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_theme', to='cookbook.userfile'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
# Generated by Django 4.2.7 on 2024-01-06 15:05
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0206_rename_sticky_navbar_userpreference_nav_sticky_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='logo_color_128',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_logo_color_128', to='cookbook.userfile'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='logo_color_144',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_logo_color_144', to='cookbook.userfile'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='logo_color_180',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_logo_color_180', to='cookbook.userfile'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='logo_color_192',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_logo_color_192', to='cookbook.userfile'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='logo_color_32',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_logo_color_32', to='cookbook.userfile'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='logo_color_512',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_logo_color_512', to='cookbook.userfile'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='logo_color_svg',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='space_logo_color_svg', to='cookbook.userfile'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.7 on 2024-01-14 23:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0207_space_logo_color_128_space_logo_color_144_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='app_name',
|
||||
field=models.CharField(blank=True, max_length=40, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='max_owned_spaces',
|
||||
field=models.IntegerField(default=100),
|
||||
),
|
||||
]
|
||||
17
cookbook/migrations/0209_remove_space_use_plural.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.2.7 on 2024-01-28 07:42
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0208_space_app_name_userpreference_max_owned_spaces'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='space',
|
||||
name='use_plural',
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0210_shoppinglistentry_updated_at.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.7 on 2024-01-28 10:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0209_remove_space_use_plural'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='shoppinglistentry',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0211_recipebook_order.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.7 on 2024-02-16 19:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0210_shoppinglistentry_updated_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='recipebook',
|
||||
name='order',
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0212_alter_property_property_amount.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.7 on 2024-02-18 07:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0211_recipebook_order'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='property',
|
||||
name='property_amount',
|
||||
field=models.DecimalField(decimal_places=4, default=None, max_digits=32, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 4.2.10 on 2024-02-19 13:48
|
||||
|
||||
from django.db import migrations, models
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
def migrate_property_import_slug(apps, schema_editor):
|
||||
with scopes_disabled():
|
||||
Property = apps.get_model('cookbook', 'Property')
|
||||
Food = apps.get_model('cookbook', 'Food')
|
||||
|
||||
id_slug_mapping = {}
|
||||
with scopes_disabled():
|
||||
for f in Food.objects.filter(open_data_slug__isnull=False).values('id', 'open_data_slug').all():
|
||||
id_slug_mapping[f['id']] = f['open_data_slug']
|
||||
|
||||
property_update_list = []
|
||||
|
||||
for p in Property.objects.filter().values('id', 'import_food_id').all():
|
||||
if p['import_food_id'] in id_slug_mapping:
|
||||
property_update_list.append(Property(
|
||||
id=p['id'],
|
||||
open_data_food_slug=id_slug_mapping[p['import_food_id']]
|
||||
))
|
||||
|
||||
Property.objects.bulk_update(property_update_list, ('open_data_food_slug',))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('cookbook', '0212_alter_property_property_amount'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='property',
|
||||
name='open_data_food_slug',
|
||||
field=models.CharField(blank=True, default=None, max_length=128, null=True),
|
||||
),
|
||||
migrations.RunPython(migrate_property_import_slug),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='property',
|
||||
name='property_unique_import_food_per_space',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='property',
|
||||
name='import_food_id',
|
||||
),
|
||||
|
||||
migrations.AddConstraint(
|
||||
model_name='property',
|
||||
constraint=models.UniqueConstraint(fields=('space', 'property_type', 'open_data_food_slug'), name='property_unique_import_food_per_space'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 4.2.7 on 2024-02-24 12:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0213_remove_property_property_unique_import_food_per_space_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cooklog',
|
||||
name='comment',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cooklog',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cooklog',
|
||||
name='rating',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cooklog',
|
||||
name='servings',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
36
cookbook/migrations/0215_connectorconfig.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 4.2.10 on 2024-02-26 14:41
|
||||
|
||||
import cookbook.models
|
||||
from django.conf import settings
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0214_cooklog_comment_cooklog_updated_at_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ConnectorConfig',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=128, validators=[django.core.validators.MinLengthValidator(1)])),
|
||||
('type', models.CharField(choices=[('HomeAssistant', 'HomeAssistant')], default='HomeAssistant', max_length=128)),
|
||||
('enabled', models.BooleanField(default=True, help_text='Is Connector Enabled')),
|
||||
('on_shopping_list_entry_created_enabled', models.BooleanField(default=False)),
|
||||
('on_shopping_list_entry_updated_enabled', models.BooleanField(default=False)),
|
||||
('on_shopping_list_entry_deleted_enabled', models.BooleanField(default=False)),
|
||||
('url', models.URLField(blank=True, null=True)),
|
||||
('token', models.CharField(blank=True, max_length=512, null=True)),
|
||||
('todo_entity', models.CharField(blank=True, max_length=128, null=True)),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
|
||||
('space', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space')),
|
||||
],
|
||||
bases=(models.Model, cookbook.models.PermissionModelMixin),
|
||||
),
|
||||
]
|
||||
16
cookbook/migrations/0216_delete_shoppinglist.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# Generated by Django 4.2.10 on 2024-02-28 16:21
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0215_connectorconfig'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='ShoppingList',
|
||||
),
|
||||
]
|
||||
@@ -24,7 +24,7 @@ from PIL import Image
|
||||
from treebeard.mp_tree import MP_Node, MP_NodeManager
|
||||
|
||||
from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT, KJ_PREF_DEFAULT,
|
||||
SORT_TREE_BY_NAME, STICKY_NAV_PREF_DEFAULT)
|
||||
SORT_TREE_BY_NAME, STICKY_NAV_PREF_DEFAULT, MAX_OWNED_SPACES_PREF_DEFAULT)
|
||||
|
||||
|
||||
def get_user_display_name(self):
|
||||
@@ -209,6 +209,27 @@ class TreeModel(MP_Node):
|
||||
abstract = True
|
||||
|
||||
|
||||
class MergeModelMixin:
|
||||
|
||||
def merge_into(self, target):
|
||||
"""
|
||||
very simple merge function that replaces the current instance with the target instance
|
||||
:param target: target object
|
||||
:return: target with data merged
|
||||
"""
|
||||
|
||||
if self == target:
|
||||
raise ValueError('Cannot merge an object with itself')
|
||||
|
||||
if getattr(self, 'space', 0) != getattr(target, 'space', 0):
|
||||
raise RuntimeError('Cannot merge objects from different spaces')
|
||||
|
||||
if hasattr(self, 'get_descendants_and_self') and target in callable(getattr(self, 'get_descendants_and_self')):
|
||||
raise RuntimeError('Cannot merge parent (source) with child (target) object')
|
||||
|
||||
# TODO copy field values
|
||||
|
||||
|
||||
class PermissionModelMixin:
|
||||
@staticmethod
|
||||
def get_space_key():
|
||||
@@ -251,15 +272,58 @@ class FoodInheritField(models.Model, PermissionModelMixin):
|
||||
|
||||
|
||||
class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
# TODO remove redundant theming constants
|
||||
# Themes
|
||||
BLANK = 'BLANK'
|
||||
TANDOOR = 'TANDOOR'
|
||||
TANDOOR_DARK = 'TANDOOR_DARK'
|
||||
BOOTSTRAP = 'BOOTSTRAP'
|
||||
DARKLY = 'DARKLY'
|
||||
FLATLY = 'FLATLY'
|
||||
SUPERHERO = 'SUPERHERO'
|
||||
|
||||
THEMES = (
|
||||
(BLANK, '-------'),
|
||||
(TANDOOR, 'Tandoor'),
|
||||
(BOOTSTRAP, 'Bootstrap'),
|
||||
(DARKLY, 'Darkly'),
|
||||
(FLATLY, 'Flatly'),
|
||||
(SUPERHERO, 'Superhero'),
|
||||
(TANDOOR_DARK, 'Tandoor Dark (INCOMPLETE)'),
|
||||
)
|
||||
|
||||
LIGHT = 'LIGHT'
|
||||
DARK = 'DARK'
|
||||
|
||||
NAV_TEXT_COLORS = (
|
||||
(BLANK, '-------'),
|
||||
(LIGHT, 'Light'),
|
||||
(DARK, 'Dark')
|
||||
)
|
||||
|
||||
name = models.CharField(max_length=128, default='Default')
|
||||
|
||||
image = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_image')
|
||||
space_theme = models.CharField(choices=THEMES, max_length=128, default=BLANK)
|
||||
custom_space_theme = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_theme')
|
||||
nav_logo = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_nav_logo')
|
||||
nav_bg_color = models.CharField(max_length=8, default='', blank=True, )
|
||||
nav_text_color = models.CharField(max_length=16, choices=NAV_TEXT_COLORS, default=BLANK)
|
||||
app_name = models.CharField(max_length=40, null=True, blank=True, )
|
||||
logo_color_32 = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_logo_color_32')
|
||||
logo_color_128 = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_logo_color_128')
|
||||
logo_color_144 = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_logo_color_144')
|
||||
logo_color_180 = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_logo_color_180')
|
||||
logo_color_192 = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_logo_color_192')
|
||||
logo_color_512 = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_logo_color_512')
|
||||
logo_color_svg = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='space_logo_color_svg')
|
||||
|
||||
created_by = models.ForeignKey(User, on_delete=models.PROTECT, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
message = models.CharField(max_length=512, default='', blank=True)
|
||||
max_recipes = models.IntegerField(default=0)
|
||||
max_file_storage_mb = models.IntegerField(default=0, help_text=_('Maximum file storage for space in MB. 0 for unlimited, -1 to disable file upload.'))
|
||||
max_users = models.IntegerField(default=0)
|
||||
use_plural = models.BooleanField(default=True)
|
||||
allow_sharing = models.BooleanField(default=True)
|
||||
no_sharing_limit = models.BooleanField(default=False)
|
||||
demo = models.BooleanField(default=False)
|
||||
@@ -277,10 +341,18 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
BookmarkletImport.objects.filter(space=self).delete()
|
||||
CustomFilter.objects.filter(space=self).delete()
|
||||
|
||||
Property.objects.filter(space=self).delete()
|
||||
PropertyType.objects.filter(space=self).delete()
|
||||
|
||||
Comment.objects.filter(recipe__space=self).delete()
|
||||
Keyword.objects.filter(space=self).delete()
|
||||
Ingredient.objects.filter(space=self).delete()
|
||||
Food.objects.filter(space=self).delete()
|
||||
Keyword.objects.filter(space=self).delete()
|
||||
|
||||
# delete food in batches because treabeard might fail to delete otherwise
|
||||
while Food.objects.filter(space=self).count() > 0:
|
||||
pks = Food.objects.filter(space=self).values_list('pk')[:200]
|
||||
Food.objects.filter(pk__in=pks).delete()
|
||||
|
||||
Unit.objects.filter(space=self).delete()
|
||||
Step.objects.filter(space=self).delete()
|
||||
NutritionInformation.objects.filter(space=self).delete()
|
||||
@@ -295,18 +367,20 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
SyncLog.objects.filter(sync__space=self).delete()
|
||||
Sync.objects.filter(space=self).delete()
|
||||
Storage.objects.filter(space=self).delete()
|
||||
ConnectorConfig.objects.filter(space=self).delete()
|
||||
|
||||
ShoppingListEntry.objects.filter(shoppinglist__space=self).delete()
|
||||
ShoppingListRecipe.objects.filter(shoppinglist__space=self).delete()
|
||||
ShoppingList.objects.filter(space=self).delete()
|
||||
ShoppingListEntry.objects.filter(space=self).delete()
|
||||
ShoppingListRecipe.objects.filter(recipe__space=self).delete()
|
||||
|
||||
SupermarketCategoryRelation.objects.filter(supermarket__space=self).delete()
|
||||
SupermarketCategory.objects.filter(space=self).delete()
|
||||
Supermarket.objects.filter(space=self).delete()
|
||||
|
||||
InviteLink.objects.filter(space=self).delete()
|
||||
UserFile.objects.filter(space=self).delete()
|
||||
UserSpace.objects.filter(space=self).delete()
|
||||
Automation.objects.filter(space=self).delete()
|
||||
InviteLink.objects.filter(space=self).delete()
|
||||
TelegramBot.objects.filter(space=self).delete()
|
||||
self.delete()
|
||||
|
||||
def get_owner(self):
|
||||
@@ -319,6 +393,31 @@ class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
return self.name
|
||||
|
||||
|
||||
class ConnectorConfig(models.Model, PermissionModelMixin):
|
||||
HOMEASSISTANT = 'HomeAssistant'
|
||||
CONNECTER_TYPE = ((HOMEASSISTANT, 'HomeAssistant'),)
|
||||
|
||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||
type = models.CharField(
|
||||
choices=CONNECTER_TYPE, max_length=128, default=HOMEASSISTANT
|
||||
)
|
||||
|
||||
enabled = models.BooleanField(default=True, help_text="Is Connector Enabled")
|
||||
on_shopping_list_entry_created_enabled = models.BooleanField(default=False)
|
||||
on_shopping_list_entry_updated_enabled = models.BooleanField(default=False)
|
||||
on_shopping_list_entry_deleted_enabled = models.BooleanField(default=False)
|
||||
|
||||
url = models.URLField(blank=True, null=True)
|
||||
token = models.CharField(max_length=512, blank=True, null=True)
|
||||
todo_entity = models.CharField(max_length=128, blank=True, null=True)
|
||||
|
||||
created_by = models.ForeignKey(User, on_delete=models.PROTECT)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
|
||||
|
||||
class UserPreference(models.Model, PermissionModelMixin):
|
||||
# Themes
|
||||
BOOTSTRAP = 'BOOTSTRAP'
|
||||
@@ -338,22 +437,10 @@ class UserPreference(models.Model, PermissionModelMixin):
|
||||
)
|
||||
|
||||
# Nav colors
|
||||
PRIMARY = 'PRIMARY'
|
||||
SECONDARY = 'SECONDARY'
|
||||
SUCCESS = 'SUCCESS'
|
||||
INFO = 'INFO'
|
||||
WARNING = 'WARNING'
|
||||
DANGER = 'DANGER'
|
||||
LIGHT = 'LIGHT'
|
||||
DARK = 'DARK'
|
||||
|
||||
COLORS = (
|
||||
(PRIMARY, 'Primary'),
|
||||
(SECONDARY, 'Secondary'),
|
||||
(SUCCESS, 'Success'),
|
||||
(INFO, 'Info'),
|
||||
(WARNING, 'Warning'),
|
||||
(DANGER, 'Danger'),
|
||||
NAV_TEXT_COLORS = (
|
||||
(LIGHT, 'Light'),
|
||||
(DARK, 'Dark')
|
||||
)
|
||||
@@ -371,8 +458,13 @@ class UserPreference(models.Model, PermissionModelMixin):
|
||||
|
||||
user = AutoOneToOneField(User, on_delete=models.CASCADE, primary_key=True)
|
||||
image = models.ForeignKey("UserFile", on_delete=models.SET_NULL, null=True, blank=True, related_name='user_image')
|
||||
|
||||
theme = models.CharField(choices=THEMES, max_length=128, default=TANDOOR)
|
||||
nav_color = models.CharField(choices=COLORS, max_length=128, default=PRIMARY)
|
||||
nav_bg_color = models.CharField(max_length=8, default='#ddbf86')
|
||||
nav_text_color = models.CharField(max_length=16, choices=NAV_TEXT_COLORS, default=DARK)
|
||||
nav_show_logo = models.BooleanField(default=True)
|
||||
nav_sticky = models.BooleanField(default=STICKY_NAV_PREF_DEFAULT)
|
||||
max_owned_spaces = models.IntegerField(default=MAX_OWNED_SPACES_PREF_DEFAULT)
|
||||
default_unit = models.CharField(max_length=32, default='g')
|
||||
use_fractions = models.BooleanField(default=FRACTION_PREF_DEFAULT)
|
||||
use_kj = models.BooleanField(default=KJ_PREF_DEFAULT)
|
||||
@@ -382,7 +474,6 @@ class UserPreference(models.Model, PermissionModelMixin):
|
||||
ingredient_decimals = models.IntegerField(default=2)
|
||||
comments = models.BooleanField(default=COMMENT_PREF_DEFAULT)
|
||||
shopping_auto_sync = models.IntegerField(default=5)
|
||||
sticky_navbar = models.BooleanField(default=STICKY_NAV_PREF_DEFAULT)
|
||||
mealplan_autoadd_shopping = models.BooleanField(default=False)
|
||||
mealplan_autoexclude_onhand = models.BooleanField(default=True)
|
||||
mealplan_autoinclude_related = models.BooleanField(default=True)
|
||||
@@ -398,6 +489,16 @@ class UserPreference(models.Model, PermissionModelMixin):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.pk:
|
||||
self.max_owned_spaces = MAX_OWNED_SPACES_PREF_DEFAULT
|
||||
self.comments = COMMENT_PREF_DEFAULT
|
||||
self.nav_sticky = STICKY_NAV_PREF_DEFAULT
|
||||
self.use_kj = KJ_PREF_DEFAULT
|
||||
self.use_fractions = FRACTION_PREF_DEFAULT
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.user)
|
||||
|
||||
@@ -457,7 +558,7 @@ class Sync(models.Model, PermissionModelMixin):
|
||||
return self.path
|
||||
|
||||
|
||||
class SupermarketCategory(models.Model, PermissionModelMixin):
|
||||
class SupermarketCategory(models.Model, PermissionModelMixin, MergeModelMixin):
|
||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||
description = models.TextField(blank=True, null=True)
|
||||
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
@@ -468,6 +569,14 @@ class SupermarketCategory(models.Model, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def merge_into(self, target):
|
||||
super().merge_into(target)
|
||||
|
||||
Food.objects.filter(supermarket_category=self).update(supermarket_category=target)
|
||||
SupermarketCategoryRelation.objects.filter(category=self).update(category=target)
|
||||
self.delete()
|
||||
return target
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='smc_unique_name_per_space'),
|
||||
@@ -542,7 +651,7 @@ class Keyword(ExportModelOperationsMixin('keyword'), TreeModel, PermissionModelM
|
||||
indexes = (Index(fields=['id', 'name']),)
|
||||
|
||||
|
||||
class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixin):
|
||||
class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixin, MergeModelMixin):
|
||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||
plural_name = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
@@ -552,6 +661,17 @@ class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixi
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def merge_into(self, target):
|
||||
super().merge_into(target)
|
||||
|
||||
Ingredient.objects.filter(unit=self).update(unit=target)
|
||||
ShoppingListEntry.objects.filter(unit=self).update(unit=target)
|
||||
Food.objects.filter(properties_food_unit=self).update(properties_food_unit=target)
|
||||
Food.objects.filter(preferred_unit=self).update(preferred_unit=target)
|
||||
Food.objects.filter(preferred_shopping_unit=self).update(preferred_shopping_unit=target)
|
||||
self.delete()
|
||||
return target
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -591,7 +711,7 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||
|
||||
preferred_unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='preferred_unit')
|
||||
preferred_shopping_unit = models.ForeignKey(Unit, on_delete=models.SET_NULL, null=True, blank=True, default=None, related_name='preferred_shopping_unit')
|
||||
fdc_id = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
fdc_id = models.IntegerField(null=True, default=None, blank=True)
|
||||
|
||||
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
@@ -600,6 +720,32 @@ class Food(ExportModelOperationsMixin('food'), TreeModel, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def merge_into(self, target):
|
||||
"""
|
||||
very simple merge function that replaces the current food with the target food
|
||||
also replaces a few attributes on the target field if they were empty before
|
||||
:param target: target food object
|
||||
:return: target with data merged
|
||||
"""
|
||||
if self == target:
|
||||
raise ValueError('Cannot merge an object with itself')
|
||||
|
||||
if self.space != target.space:
|
||||
raise RuntimeError('Cannot merge objects from different spaces')
|
||||
|
||||
try:
|
||||
if target in self.get_descendants_and_self():
|
||||
raise RuntimeError('Cannot merge parent (source) with child (target) object')
|
||||
except AttributeError:
|
||||
pass # AttributeError is raised when the object is not a tree and thus does not have the get_descendants_and_self() function
|
||||
|
||||
self.properties.all().delete()
|
||||
self.properties.clear()
|
||||
Ingredient.objects.filter(food=self).update(food=target)
|
||||
ShoppingListEntry.objects.filter(food=self).update(food=target)
|
||||
self.delete()
|
||||
return target
|
||||
|
||||
def delete(self):
|
||||
if self.ingredient_set.all().exclude(step=None).count() > 0:
|
||||
raise ProtectedError(self.name + _(" is part of a recipe step and cannot be deleted"), self.ingredient_set.all().exclude(step=None))
|
||||
@@ -718,6 +864,9 @@ class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, Permiss
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.pk}: {self.amount} ' + (self.food.name if self.food else ' ') + (self.unit.name if self.unit else '')
|
||||
|
||||
class Meta:
|
||||
ordering = ['order', 'pk']
|
||||
indexes = (
|
||||
@@ -745,14 +894,16 @@ class Step(ExportModelOperationsMixin('step'), models.Model, PermissionModelMixi
|
||||
return render_instructions(self)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.pk} {self.name}'
|
||||
if not self.recipe_set.exists():
|
||||
return f"{self.pk}: {_('Orphaned Step')}"
|
||||
return f"{self.pk}: {self.name}" if self.name else f"Step: {self.pk}"
|
||||
|
||||
class Meta:
|
||||
ordering = ['order', 'pk']
|
||||
indexes = (GinIndex(fields=["search_vector"]),)
|
||||
|
||||
|
||||
class PropertyType(models.Model, PermissionModelMixin):
|
||||
class PropertyType(models.Model, PermissionModelMixin, MergeModelMixin):
|
||||
NUTRITION = 'NUTRITION'
|
||||
ALLERGEN = 'ALLERGEN'
|
||||
PRICE = 'PRICE'
|
||||
@@ -767,7 +918,7 @@ class PropertyType(models.Model, PermissionModelMixin):
|
||||
(PRICE, _('Price')), (GOAL, _('Goal')), (OTHER, _('Other'))), null=True, blank=True)
|
||||
open_data_slug = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
|
||||
fdc_id = models.CharField(max_length=128, null=True, blank=True, default=None)
|
||||
fdc_id = models.IntegerField(null=True, default=None, blank=True)
|
||||
# TODO show if empty property?
|
||||
# TODO formatting property?
|
||||
|
||||
@@ -777,6 +928,13 @@ class PropertyType(models.Model, PermissionModelMixin):
|
||||
def __str__(self):
|
||||
return f'{self.name}'
|
||||
|
||||
def merge_into(self, target):
|
||||
super().merge_into(target)
|
||||
|
||||
Property.objects.filter(property_type=self).update(property_type=target)
|
||||
self.delete()
|
||||
return target
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='property_type_unique_name_per_space'),
|
||||
@@ -786,10 +944,10 @@ class PropertyType(models.Model, PermissionModelMixin):
|
||||
|
||||
|
||||
class Property(models.Model, PermissionModelMixin):
|
||||
property_amount = models.DecimalField(default=0, decimal_places=4, max_digits=32)
|
||||
property_amount = models.DecimalField(default=None, null=True, decimal_places=4, max_digits=32)
|
||||
property_type = models.ForeignKey(PropertyType, on_delete=models.PROTECT)
|
||||
|
||||
import_food_id = models.IntegerField(null=True, blank=True) # field to hold food id when importing properties from the open data project
|
||||
open_data_food_slug = models.CharField(max_length=128, null=True, blank=True, default=None) # field to hold food id when importing properties from the open data project
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
@@ -799,7 +957,7 @@ class Property(models.Model, PermissionModelMixin):
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'property_type', 'import_food_id'], name='property_unique_import_food_per_space')
|
||||
models.UniqueConstraint(fields=['space', 'property_type', 'open_data_food_slug'], name='property_unique_import_food_per_space')
|
||||
]
|
||||
|
||||
|
||||
@@ -809,7 +967,7 @@ class FoodProperty(models.Model):
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['food', 'property'], name='property_unique_food')
|
||||
models.UniqueConstraint(fields=['food', 'property'], name='property_unique_food'),
|
||||
]
|
||||
|
||||
|
||||
@@ -934,6 +1092,7 @@ class RecipeBook(ExportModelOperationsMixin('book'), models.Model, PermissionMod
|
||||
shared = models.ManyToManyField(User, blank=True, related_name='shared_with')
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
filter = models.ForeignKey('cookbook.CustomFilter', null=True, blank=True, on_delete=models.SET_NULL)
|
||||
order = models.IntegerField(default=0)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
@@ -985,7 +1144,7 @@ class MealType(models.Model, PermissionModelMixin):
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['space', 'name'], name='mt_unique_name_per_space'),
|
||||
models.UniqueConstraint(fields=['space', 'name', 'created_by'], name='mt_unique_name_per_space'),
|
||||
]
|
||||
|
||||
|
||||
@@ -1035,7 +1194,10 @@ class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), mod
|
||||
|
||||
def get_owner(self):
|
||||
try:
|
||||
return getattr(self.entries.first(), 'created_by', None) or getattr(self.shoppinglist_set.first(), 'created_by', None)
|
||||
if not self.entries.exists():
|
||||
return 'orphan'
|
||||
else:
|
||||
return getattr(self.entries.first(), 'created_by', None)
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@@ -1050,59 +1212,27 @@ class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), model
|
||||
checked = models.BooleanField(default=False)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
delay_until = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
@staticmethod
|
||||
def get_space_key():
|
||||
return 'shoppinglist', 'space'
|
||||
|
||||
def get_space(self):
|
||||
return self.shoppinglist_set.first().space
|
||||
|
||||
def __str__(self):
|
||||
return f'Shopping list entry {self.id}'
|
||||
|
||||
def get_shared(self):
|
||||
try:
|
||||
return self.shoppinglist_set.first().shared.all()
|
||||
except AttributeError:
|
||||
return self.created_by.userpreference.shopping_share.all()
|
||||
return self.created_by.userpreference.shopping_share.all()
|
||||
|
||||
def get_owner(self):
|
||||
try:
|
||||
return self.created_by or self.shoppinglist_set.first().created_by
|
||||
return self.created_by
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
|
||||
class ShoppingList(ExportModelOperationsMixin('shopping_list'), models.Model, PermissionModelMixin):
|
||||
uuid = models.UUIDField(default=uuid.uuid4)
|
||||
note = models.TextField(blank=True, null=True)
|
||||
recipes = models.ManyToManyField(ShoppingListRecipe, blank=True)
|
||||
entries = models.ManyToManyField(ShoppingListEntry, blank=True)
|
||||
shared = models.ManyToManyField(User, blank=True, related_name='list_share')
|
||||
supermarket = models.ForeignKey(Supermarket, null=True, blank=True, on_delete=models.SET_NULL)
|
||||
finished = models.BooleanField(default=False)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return f'Shopping list {self.id}'
|
||||
|
||||
def get_shared(self):
|
||||
try:
|
||||
return self.shared.all() or self.created_by.userpreference.shopping_share.all()
|
||||
except AttributeError:
|
||||
return []
|
||||
|
||||
|
||||
class ShareLink(ExportModelOperationsMixin('share_link'), models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
uuid = models.UUIDField(default=uuid.uuid4)
|
||||
@@ -1157,10 +1287,13 @@ class TelegramBot(models.Model, PermissionModelMixin):
|
||||
|
||||
class CookLog(ExportModelOperationsMixin('cook_log'), models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
rating = models.IntegerField(null=True, blank=True)
|
||||
servings = models.IntegerField(null=True, blank=True)
|
||||
comment = models.TextField(null=True, blank=True)
|
||||
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(default=timezone.now)
|
||||
rating = models.IntegerField(null=True)
|
||||
servings = models.IntegerField(default=0)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
@@ -1311,6 +1444,9 @@ class UserFile(ExportModelOperationsMixin('user_files'), models.Model, Permissio
|
||||
self.file_size_kb = round(self.file.size / 1000)
|
||||
super(UserFile, self).save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name} (#{self.id})'
|
||||
|
||||
|
||||
class Automation(ExportModelOperationsMixin('automations'), models.Model, PermissionModelMixin):
|
||||
FOOD_ALIAS = 'FOOD_ALIAS'
|
||||
|
||||
@@ -44,12 +44,12 @@ class TreeSchema(AutoSchema):
|
||||
"name": 'root', "in": "query", "required": False,
|
||||
"description": 'Return first level children of {obj} with ID [int]. Integer 0 will return root {obj}s.'.format(
|
||||
obj=api_name),
|
||||
'schema': {'type': 'int', },
|
||||
'schema': {'type': 'integer', },
|
||||
})
|
||||
parameters.append({
|
||||
"name": 'tree', "in": "query", "required": False,
|
||||
"description": 'Return all self and children of {} with ID [int].'.format(api_name),
|
||||
'schema': {'type': 'int', },
|
||||
'schema': {'type': 'integer', },
|
||||
})
|
||||
return parameters
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import traceback
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
@@ -19,6 +18,7 @@ from oauth2_provider.models import AccessToken
|
||||
from PIL import Image
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
from rest_framework.fields import IntegerField
|
||||
|
||||
from cookbook.helper.CustomStorageClass import CachedS3Boto3Storage
|
||||
from cookbook.helper.HelperFunctions import str2bool
|
||||
@@ -30,10 +30,10 @@ from cookbook.models import (Automation, BookmarkletImport, Comment, CookLog, Cu
|
||||
ExportLog, Food, FoodInheritField, ImportLog, Ingredient, InviteLink,
|
||||
Keyword, MealPlan, MealType, NutritionInformation, Property,
|
||||
PropertyType, Recipe, RecipeBook, RecipeBookEntry, RecipeImport,
|
||||
ShareLink, ShoppingList, ShoppingListEntry, ShoppingListRecipe, Space,
|
||||
ShareLink, ShoppingListEntry, ShoppingListRecipe, Space,
|
||||
Step, Storage, Supermarket, SupermarketCategory,
|
||||
SupermarketCategoryRelation, Sync, SyncLog, Unit, UnitConversion,
|
||||
UserFile, UserPreference, UserSpace, ViewLog)
|
||||
UserFile, UserPreference, UserSpace, ViewLog, ConnectorConfig)
|
||||
from cookbook.templatetags.custom_tags import markdown
|
||||
from recipes.settings import AWS_ENABLED, MEDIA_URL
|
||||
|
||||
@@ -56,7 +56,8 @@ class ExtendedRecipeMixin(serializers.ModelSerializer):
|
||||
api_serializer = None
|
||||
# extended values are computationally expensive and not needed in normal circumstances
|
||||
try:
|
||||
if str2bool(self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer:
|
||||
if str2bool(
|
||||
self.context['request'].query_params.get('extended', False)) and self.__class__ == api_serializer:
|
||||
return fields
|
||||
except (AttributeError, KeyError):
|
||||
pass
|
||||
@@ -121,7 +122,8 @@ class CustomOnHandField(serializers.Field):
|
||||
if not self.context["request"].user.is_authenticated:
|
||||
return []
|
||||
shared_users = []
|
||||
if c := caches['default'].get(f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None):
|
||||
if c := caches['default'].get(
|
||||
f'shopping_shared_users_{self.context["request"].space.id}_{self.context["request"].user.id}', None):
|
||||
shared_users = c
|
||||
else:
|
||||
try:
|
||||
@@ -211,7 +213,7 @@ class UserFileSerializer(serializers.ModelSerializer):
|
||||
Image.open(obj.file.file.file)
|
||||
return self.context['request'].build_absolute_uri(obj.file.url)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
# traceback.print_exc()
|
||||
return ""
|
||||
|
||||
def check_file_limit(self, validated_data):
|
||||
@@ -259,7 +261,7 @@ class UserFileViewSerializer(serializers.ModelSerializer):
|
||||
Image.open(obj.file.file.file)
|
||||
return self.context['request'].build_absolute_uri(obj.file.url)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
# traceback.print_exc()
|
||||
return ""
|
||||
|
||||
def create(self, validated_data):
|
||||
@@ -280,6 +282,15 @@ class SpaceSerializer(WritableNestedModelSerializer):
|
||||
file_size_mb = serializers.SerializerMethodField('get_file_size_mb')
|
||||
food_inherit = FoodInheritFieldSerializer(many=True)
|
||||
image = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
nav_logo = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
custom_space_theme = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
logo_color_32 = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
logo_color_128 = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
logo_color_144 = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
logo_color_180 = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
logo_color_192 = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
logo_color_512 = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
logo_color_svg = UserFileViewSerializer(required=False, many=False, allow_null=True)
|
||||
|
||||
def get_user_count(self, obj):
|
||||
return UserSpace.objects.filter(space=obj).count()
|
||||
@@ -301,7 +312,8 @@ class SpaceSerializer(WritableNestedModelSerializer):
|
||||
fields = (
|
||||
'id', 'name', 'created_by', 'created_at', 'message', 'max_recipes', 'max_file_storage_mb', 'max_users',
|
||||
'allow_sharing', 'demo', 'food_inherit', 'user_count', 'recipe_count', 'file_size_mb',
|
||||
'image', 'use_plural',)
|
||||
'image', 'nav_logo', 'space_theme', 'custom_space_theme', 'nav_bg_color', 'nav_text_color',
|
||||
'logo_color_32', 'logo_color_128', 'logo_color_144', 'logo_color_180', 'logo_color_192', 'logo_color_512', 'logo_color_svg',)
|
||||
read_only_fields = (
|
||||
'id', 'created_by', 'created_at', 'max_recipes', 'max_file_storage_mb', 'max_users', 'allow_sharing',
|
||||
'demo',)
|
||||
@@ -337,7 +349,7 @@ class MealTypeSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
||||
validated_data['name'] = validated_data['name'].strip()
|
||||
space = validated_data.pop('space', self.context['request'].space)
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
obj, created = MealType.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data)
|
||||
obj, created = MealType.objects.get_or_create(name__iexact=validated_data['name'], space=space, created_by=self.context['request'].user, defaults=validated_data)
|
||||
return obj
|
||||
|
||||
class Meta:
|
||||
@@ -371,13 +383,15 @@ class UserPreferenceSerializer(WritableNestedModelSerializer):
|
||||
class Meta:
|
||||
model = UserPreference
|
||||
fields = (
|
||||
'user', 'image', 'theme', 'nav_color', 'default_unit', 'default_page', 'use_fractions', 'use_kj',
|
||||
'plan_share', 'sticky_navbar',
|
||||
'user', 'image', 'theme', 'nav_bg_color', 'nav_text_color', 'nav_show_logo', 'default_unit', 'default_page',
|
||||
'use_fractions', 'use_kj',
|
||||
'plan_share', 'nav_sticky',
|
||||
'ingredient_decimals', 'comments', 'shopping_auto_sync', 'mealplan_autoadd_shopping',
|
||||
'food_inherit_default', 'default_delay',
|
||||
'mealplan_autoinclude_related', 'mealplan_autoexclude_onhand', 'shopping_share', 'shopping_recent_days',
|
||||
'csv_delim', 'csv_prefix',
|
||||
'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'show_step_ingredients', 'food_children_exist'
|
||||
'filter_to_supermarket', 'shopping_add_onhand', 'left_handed', 'show_step_ingredients',
|
||||
'food_children_exist'
|
||||
)
|
||||
|
||||
|
||||
@@ -402,6 +416,27 @@ class StorageSerializer(SpacedModelSerializer):
|
||||
}
|
||||
|
||||
|
||||
class ConnectorConfigConfigSerializer(SpacedModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = ConnectorConfig
|
||||
fields = (
|
||||
'id', 'name', 'url', 'token', 'todo_entity', 'enabled',
|
||||
'on_shopping_list_entry_created_enabled', 'on_shopping_list_entry_updated_enabled',
|
||||
'on_shopping_list_entry_deleted_enabled', 'created_by'
|
||||
)
|
||||
|
||||
read_only_fields = ('created_by',)
|
||||
|
||||
extra_kwargs = {
|
||||
'token': {'write_only': True},
|
||||
}
|
||||
|
||||
|
||||
class SyncSerializer(SpacedModelSerializer):
|
||||
class Meta:
|
||||
model = Sync
|
||||
@@ -466,10 +501,13 @@ class UnitSerializer(UniqueFieldsMixin, ExtendedRecipeMixin, OpenDataModelMixin)
|
||||
if x := validated_data.get('name', None):
|
||||
validated_data['plural_name'] = x.strip()
|
||||
|
||||
if unit := Unit.objects.filter(Q(name__iexact=validated_data['name']) | Q(plural_name__iexact=validated_data['name']), space=space).first():
|
||||
if unit := Unit.objects.filter(
|
||||
Q(name__iexact=validated_data['name']) | Q(plural_name__iexact=validated_data['name']),
|
||||
space=space).first():
|
||||
return unit
|
||||
|
||||
obj, created = Unit.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data)
|
||||
obj, created = Unit.objects.get_or_create(name__iexact=validated_data['name'], space=space,
|
||||
defaults=validated_data)
|
||||
return obj
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@@ -489,7 +527,8 @@ class SupermarketCategorySerializer(UniqueFieldsMixin, WritableNestedModelSerial
|
||||
def create(self, validated_data):
|
||||
validated_data['name'] = validated_data['name'].strip()
|
||||
space = validated_data.pop('space', self.context['request'].space)
|
||||
obj, created = SupermarketCategory.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data)
|
||||
obj, created = SupermarketCategory.objects.get_or_create(name__iexact=validated_data['name'], space=space,
|
||||
defaults=validated_data)
|
||||
return obj
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@@ -514,7 +553,8 @@ class SupermarketSerializer(UniqueFieldsMixin, SpacedModelSerializer, OpenDataMo
|
||||
def create(self, validated_data):
|
||||
validated_data['name'] = validated_data['name'].strip()
|
||||
space = validated_data.pop('space', self.context['request'].space)
|
||||
obj, created = Supermarket.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data)
|
||||
obj, created = Supermarket.objects.get_or_create(name__iexact=validated_data['name'], space=space,
|
||||
defaults=validated_data)
|
||||
return obj
|
||||
|
||||
class Meta:
|
||||
@@ -524,11 +564,13 @@ class SupermarketSerializer(UniqueFieldsMixin, SpacedModelSerializer, OpenDataMo
|
||||
|
||||
class PropertyTypeSerializer(OpenDataModelMixin, WritableNestedModelSerializer, UniqueFieldsMixin):
|
||||
id = serializers.IntegerField(required=False)
|
||||
order = IntegerField(default=0, required=False)
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['name'] = validated_data['name'].strip()
|
||||
space = validated_data.pop('space', self.context['request'].space)
|
||||
obj, created = PropertyType.objects.get_or_create(name__iexact=validated_data['name'], space=space, defaults=validated_data)
|
||||
obj, created = PropertyType.objects.get_or_create(name__iexact=validated_data['name'], space=space,
|
||||
defaults=validated_data)
|
||||
return obj
|
||||
|
||||
class Meta:
|
||||
@@ -538,7 +580,7 @@ class PropertyTypeSerializer(OpenDataModelMixin, WritableNestedModelSerializer,
|
||||
|
||||
class PropertySerializer(UniqueFieldsMixin, WritableNestedModelSerializer):
|
||||
property_type = PropertyTypeSerializer()
|
||||
property_amount = CustomDecimalField()
|
||||
property_amount = CustomDecimalField(allow_null=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['space'] = self.context['request'].space
|
||||
@@ -653,12 +695,14 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
||||
|
||||
properties = validated_data.pop('properties', None)
|
||||
|
||||
obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space, properties_food_unit=properties_food_unit,
|
||||
obj, created = Food.objects.get_or_create(name=name, plural_name=plural_name, space=space,
|
||||
properties_food_unit=properties_food_unit,
|
||||
defaults=validated_data)
|
||||
|
||||
if properties and len(properties) > 0:
|
||||
for p in properties:
|
||||
obj.properties.add(Property.objects.create(property_type_id=p['property_type']['id'], property_amount=p['property_amount'], space=space))
|
||||
obj.properties.add(Property.objects.create(property_type_id=p['property_type']['id'],
|
||||
property_amount=p['property_amount'], space=space))
|
||||
|
||||
return obj
|
||||
|
||||
@@ -690,7 +734,8 @@ class FoodSerializer(UniqueFieldsMixin, WritableNestedModelSerializer, ExtendedR
|
||||
'properties', 'properties_food_amount', 'properties_food_unit', 'fdc_id',
|
||||
'food_onhand', 'supermarket_category',
|
||||
'image', 'parent', 'numchild', 'numrecipe', 'inherit_fields', 'full_name', 'ignore_shopping',
|
||||
'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields', 'open_data_slug',
|
||||
'substitute', 'substitute_siblings', 'substitute_children', 'substitute_onhand', 'child_inherit_fields',
|
||||
'open_data_slug',
|
||||
)
|
||||
read_only_fields = ('id', 'numchild', 'parent', 'image', 'numrecipe')
|
||||
|
||||
@@ -714,7 +759,8 @@ class IngredientSimpleSerializer(WritableNestedModelSerializer):
|
||||
uch = UnitConversionHelper(self.context['request'].space)
|
||||
conversions = []
|
||||
for c in uch.get_conversions(obj):
|
||||
conversions.append({'food': c.food.name, 'unit': c.unit.name, 'amount': c.amount}) # TODO do formatting in helper
|
||||
conversions.append(
|
||||
{'food': c.food.name, 'unit': c.unit.name, 'amount': c.amount}) # TODO do formatting in helper
|
||||
return conversions
|
||||
else:
|
||||
return []
|
||||
@@ -743,8 +789,7 @@ class IngredientSerializer(IngredientSimpleSerializer):
|
||||
|
||||
class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
|
||||
ingredients = IngredientSerializer(many=True)
|
||||
ingredients_markdown = serializers.SerializerMethodField('get_ingredients_markdown')
|
||||
ingredients_vue = serializers.SerializerMethodField('get_ingredients_vue')
|
||||
instructions_markdown = serializers.SerializerMethodField('get_instructions_markdown')
|
||||
file = UserFileViewSerializer(allow_null=True, required=False)
|
||||
step_recipe_data = serializers.SerializerMethodField('get_step_recipe_data')
|
||||
recipe_filter = 'steps'
|
||||
@@ -753,10 +798,7 @@ class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
|
||||
validated_data['space'] = self.context['request'].space
|
||||
return super().create(validated_data)
|
||||
|
||||
def get_ingredients_vue(self, obj):
|
||||
return obj.get_instruction_render()
|
||||
|
||||
def get_ingredients_markdown(self, obj):
|
||||
def get_instructions_markdown(self, obj):
|
||||
return obj.get_instruction_render()
|
||||
|
||||
def get_step_recipes(self, obj):
|
||||
@@ -771,8 +813,8 @@ class StepSerializer(WritableNestedModelSerializer, ExtendedRecipeMixin):
|
||||
class Meta:
|
||||
model = Step
|
||||
fields = (
|
||||
'id', 'name', 'instruction', 'ingredients', 'ingredients_markdown',
|
||||
'ingredients_vue', 'time', 'order', 'show_as_header', 'file', 'step_recipe',
|
||||
'id', 'name', 'instruction', 'ingredients', 'instructions_markdown',
|
||||
'time', 'order', 'show_as_header', 'file', 'step_recipe',
|
||||
'step_recipe_data', 'numrecipe', 'show_ingredients_table'
|
||||
)
|
||||
|
||||
@@ -843,6 +885,13 @@ class RecipeBaseSerializer(WritableNestedModelSerializer):
|
||||
return False
|
||||
|
||||
|
||||
class CommentSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Comment
|
||||
fields = '__all__'
|
||||
read_only_fields = ['id', 'created_at', 'created_by', 'updated_at', ]
|
||||
|
||||
|
||||
class RecipeOverviewSerializer(RecipeBaseSerializer):
|
||||
keywords = KeywordLabelSerializer(many=True)
|
||||
new = serializers.SerializerMethodField('is_recipe_new')
|
||||
@@ -871,7 +920,7 @@ class RecipeSerializer(RecipeBaseSerializer):
|
||||
nutrition = NutritionInformationSerializer(allow_null=True, required=False)
|
||||
properties = PropertySerializer(many=True, required=False)
|
||||
steps = StepSerializer(many=True)
|
||||
keywords = KeywordSerializer(many=True)
|
||||
keywords = KeywordSerializer(many=True, required=False)
|
||||
shared = UserSerializer(many=True, required=False)
|
||||
rating = CustomDecimalField(required=False, allow_null=True, read_only=True)
|
||||
last_cooked = serializers.DateTimeField(required=False, allow_null=True, read_only=True)
|
||||
@@ -886,7 +935,8 @@ class RecipeSerializer(RecipeBaseSerializer):
|
||||
fields = (
|
||||
'id', 'name', 'description', 'image', 'keywords', 'steps', 'working_time',
|
||||
'waiting_time', 'created_by', 'created_at', 'updated_at', 'source_url',
|
||||
'internal', 'show_ingredient_overview', 'nutrition', 'properties', 'food_properties', 'servings', 'file_path', 'servings_text', 'rating',
|
||||
'internal', 'show_ingredient_overview', 'nutrition', 'properties', 'food_properties', 'servings',
|
||||
'file_path', 'servings_text', 'rating',
|
||||
'last_cooked',
|
||||
'private', 'shared',
|
||||
)
|
||||
@@ -919,12 +969,6 @@ class RecipeImportSerializer(SpacedModelSerializer):
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class CommentSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Comment
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class CustomFilterSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
||||
shared = UserSerializer(many=True, required=False)
|
||||
|
||||
@@ -948,7 +992,7 @@ class RecipeBookSerializer(SpacedModelSerializer, WritableNestedModelSerializer)
|
||||
|
||||
class Meta:
|
||||
model = RecipeBook
|
||||
fields = ('id', 'name', 'description', 'shared', 'created_by', 'filter')
|
||||
fields = ('id', 'name', 'description', 'shared', 'created_by', 'filter', 'order')
|
||||
read_only_fields = ('created_by',)
|
||||
|
||||
|
||||
@@ -965,7 +1009,8 @@ class RecipeBookEntrySerializer(serializers.ModelSerializer):
|
||||
def create(self, validated_data):
|
||||
book = validated_data['book']
|
||||
recipe = validated_data['recipe']
|
||||
if not book.get_owner() == self.context['request'].user and not self.context['request'].user in book.get_shared():
|
||||
if not book.get_owner() == self.context['request'].user and not self.context[
|
||||
'request'].user in book.get_shared():
|
||||
raise NotFound(detail=None, code=None)
|
||||
obj, created = RecipeBookEntry.objects.get_or_create(book=book, recipe=recipe)
|
||||
return obj
|
||||
@@ -985,6 +1030,8 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
||||
shared = UserSerializer(many=True, required=False, allow_null=True)
|
||||
shopping = serializers.SerializerMethodField('in_shopping')
|
||||
|
||||
to_date = serializers.DateField(required=False)
|
||||
|
||||
def get_note_markdown(self, obj):
|
||||
return markdown(obj.note)
|
||||
|
||||
@@ -993,6 +1040,10 @@ class MealPlanSerializer(SpacedModelSerializer, WritableNestedModelSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
|
||||
if 'to_date' not in validated_data or validated_data['to_date'] is None:
|
||||
validated_data['to_date'] = validated_data['from_date']
|
||||
|
||||
mealplan = super().create(validated_data)
|
||||
if self.context['request'].data.get('addshopping', False) and self.context['request'].data.get('recipe', None):
|
||||
SLR = RecipeShoppingEditor(user=validated_data['created_by'], space=validated_data['space'])
|
||||
@@ -1013,7 +1064,7 @@ class AutoMealPlanSerializer(serializers.Serializer):
|
||||
start_date = serializers.DateField()
|
||||
end_date = serializers.DateField()
|
||||
meal_type_id = serializers.IntegerField()
|
||||
keywords = KeywordSerializer(many=True)
|
||||
keyword_ids = serializers.ListField()
|
||||
servings = CustomDecimalField()
|
||||
shared = UserSerializer(many=True, required=False, allow_null=True)
|
||||
addshopping = serializers.BooleanField()
|
||||
@@ -1023,6 +1074,8 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
|
||||
name = serializers.SerializerMethodField('get_name') # should this be done at the front end?
|
||||
recipe_name = serializers.ReadOnlyField(source='recipe.name')
|
||||
mealplan_note = serializers.ReadOnlyField(source='mealplan.note')
|
||||
mealplan_from_date = serializers.ReadOnlyField(source='mealplan.from_date')
|
||||
mealplan_type = serializers.ReadOnlyField(source='mealplan.meal_type.name')
|
||||
servings = CustomDecimalField()
|
||||
|
||||
def get_name(self, obj):
|
||||
@@ -1046,14 +1099,14 @@ class ShoppingListRecipeSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ShoppingListRecipe
|
||||
fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note')
|
||||
fields = ('id', 'recipe_name', 'name', 'recipe', 'mealplan', 'servings', 'mealplan_note', 'mealplan_from_date',
|
||||
'mealplan_type')
|
||||
read_only_fields = ('id',)
|
||||
|
||||
|
||||
class ShoppingListEntrySerializer(WritableNestedModelSerializer):
|
||||
food = FoodSerializer(allow_null=True)
|
||||
unit = UnitSerializer(allow_null=True, required=False)
|
||||
ingredient_note = serializers.ReadOnlyField(source='ingredient.note')
|
||||
recipe_mealplan = ShoppingListRecipeSerializer(source='list_recipe', read_only=True)
|
||||
amount = CustomDecimalField()
|
||||
created_by = UserSerializer(read_only=True)
|
||||
@@ -1064,7 +1117,7 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
|
||||
|
||||
# autosync values are only needed for frequent 'checked' value updating
|
||||
if self.context['request'] and bool(int(self.context['request'].query_params.get('autosync', False))):
|
||||
for f in list(set(fields) - set(['id', 'checked'])):
|
||||
for f in list(set(fields) - set(['id', 'checked', 'updated_at', ])):
|
||||
del fields[f]
|
||||
return fields
|
||||
|
||||
@@ -1106,11 +1159,16 @@ class ShoppingListEntrySerializer(WritableNestedModelSerializer):
|
||||
class Meta:
|
||||
model = ShoppingListEntry
|
||||
fields = (
|
||||
'id', 'list_recipe', 'food', 'unit', 'ingredient', 'ingredient_note', 'amount', 'order', 'checked',
|
||||
'id', 'list_recipe', 'food', 'unit', 'amount', 'order', 'checked',
|
||||
'recipe_mealplan',
|
||||
'created_by', 'created_at', 'completed_at', 'delay_until'
|
||||
'created_by', 'created_at', 'updated_at', 'completed_at', 'delay_until'
|
||||
)
|
||||
read_only_fields = ('id', 'created_by', 'created_at',)
|
||||
read_only_fields = ('id', 'created_by', 'created_at', 'updated_at',)
|
||||
|
||||
|
||||
class ShoppingListEntryBulkSerializer(serializers.Serializer):
|
||||
ids = serializers.ListField()
|
||||
checked = serializers.BooleanField()
|
||||
|
||||
|
||||
# TODO deprecate
|
||||
@@ -1120,37 +1178,6 @@ class ShoppingListEntryCheckedSerializer(serializers.ModelSerializer):
|
||||
fields = ('id', 'checked')
|
||||
|
||||
|
||||
# TODO deprecate
|
||||
class ShoppingListSerializer(WritableNestedModelSerializer):
|
||||
recipes = ShoppingListRecipeSerializer(many=True, allow_null=True)
|
||||
entries = ShoppingListEntrySerializer(many=True, allow_null=True)
|
||||
shared = UserSerializer(many=True)
|
||||
supermarket = SupermarketSerializer(allow_null=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['space'] = self.context['request'].space
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
|
||||
class Meta:
|
||||
model = ShoppingList
|
||||
fields = (
|
||||
'id', 'uuid', 'note', 'recipes', 'entries',
|
||||
'shared', 'finished', 'supermarket', 'created_by', 'created_at'
|
||||
)
|
||||
read_only_fields = ('id', 'created_by',)
|
||||
|
||||
|
||||
# TODO deprecate
|
||||
class ShoppingListAutoSyncSerializer(WritableNestedModelSerializer):
|
||||
entries = ShoppingListEntryCheckedSerializer(many=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = ShoppingList
|
||||
fields = ('id', 'entries',)
|
||||
read_only_fields = ('id',)
|
||||
|
||||
|
||||
class ShareLinkSerializer(SpacedModelSerializer):
|
||||
class Meta:
|
||||
model = ShareLink
|
||||
@@ -1158,6 +1185,8 @@ class ShareLinkSerializer(SpacedModelSerializer):
|
||||
|
||||
|
||||
class CookLogSerializer(serializers.ModelSerializer):
|
||||
created_by = UserSerializer(read_only=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['created_by'] = self.context['request'].user
|
||||
validated_data['space'] = self.context['request'].space
|
||||
@@ -1165,7 +1194,7 @@ class CookLogSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = CookLog
|
||||
fields = ('id', 'recipe', 'servings', 'rating', 'created_by', 'created_at')
|
||||
fields = ('id', 'recipe', 'servings', 'rating', 'comment', 'created_by', 'created_at', 'updated_at')
|
||||
read_only_fields = ('id', 'created_by')
|
||||
|
||||
|
||||
@@ -1265,7 +1294,8 @@ class InviteLinkSerializer(WritableNestedModelSerializer):
|
||||
class Meta:
|
||||
model = InviteLink
|
||||
fields = (
|
||||
'id', 'uuid', 'email', 'group', 'valid_until', 'used_by', 'reusable', 'internal_note', 'created_by', 'created_at',)
|
||||
'id', 'uuid', 'email', 'group', 'valid_until', 'used_by', 'reusable', 'internal_note', 'created_by',
|
||||
'created_at',)
|
||||
read_only_fields = ('id', 'uuid', 'created_by', 'created_at',)
|
||||
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,41 +0,0 @@
|
||||
<svg width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g id="Logo" transform="matrix(0.637323,0,0,0.637323,-243.095,-716.725)">
|
||||
<g id="Kreis" transform="matrix(1.44936,0,0,1.50279,387.258,1039.34)">
|
||||
<ellipse cx="273.123" cy="324.015" rx="259.822" ry="250.584" style="fill:url(#_Linear1);"/>
|
||||
<clipPath id="_clip2">
|
||||
<ellipse cx="273.123" cy="324.015" rx="259.822" ry="250.584"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip2)">
|
||||
<g id="Shadow" transform="matrix(1.10322,0,0,1.064,-5.58287,50.5786)">
|
||||
<path d="M156.285,427.208L389.554,660.477L668.803,495.551L374.012,200.761L156.285,427.208Z" style="fill:rgb(22,22,22);"/>
|
||||
<g transform="matrix(1,0,0,1,-4.22105,0.775864)">
|
||||
<path d="M208.628,178.613L485.935,455.919L590.027,364.63L296.923,71.526L294.175,138.989L208.628,178.613Z" style="fill:rgb(22,22,22);"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-85.3876,27.8512)">
|
||||
<path d="M310.385,145.641L587.692,422.948L590.392,361.357L297.288,68.253L294.175,138.989L310.385,145.641Z" style="fill:rgb(22,22,22);"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(1.471,0,0,1.471,406.537,1149.69)">
|
||||
<path d="M256.049,220C286.222,219.994 312.656,207.31 329.388,194.134C346.35,180.754 370.899,183.406 384.611,200.1C407.129,227.376 420.598,261.944 420.598,299.53C420.598,361.08 382.604,437.101 329.764,463.706C307.035,475.15 283.466,480.586 256.098,480.599L256.098,480.599L256.049,480.599L256,480.599L256,480.599C228.632,480.586 205.063,475.15 182.334,463.706C129.494,437.101 91.5,361.08 91.5,299.53C91.5,261.944 104.969,227.376 127.487,200.1C141.199,183.406 165.748,180.754 182.71,194.134C199.442,207.31 225.876,219.994 256.049,220Z" style="fill:rgb(255,203,118);"/>
|
||||
</g>
|
||||
<g id="Flame-2" transform="matrix(0.965725,0,0,0.89175,164.497,436.391)">
|
||||
<path d="M604.408,844.314C601.981,840.845 601.962,836.056 604.362,832.565C606.763,829.074 611.005,827.721 614.769,829.246C633.87,836.869 658.833,848.629 678.207,864.452C718.526,897.381 729.55,919.407 738.552,942.091C749.208,968.943 750.785,996.68 748.515,1016.08C742.018,1071.61 700.355,1117.5 641.034,1117.5C581.713,1117.5 534.493,1072.05 533.553,1016.08C532.986,982.372 543.985,955.443 555.988,936.22C558.982,931.437 564.594,929.469 569.609,931.444C574.623,933.419 577.757,938.831 577.215,944.58C575.493,956.716 574.362,969.372 574.932,979.484C576.863,1013.7 597.171,1022.5 618.083,1022.29C640.371,1022.08 662.925,1003.17 654.797,954.895C647.69,912.681 622.362,870.194 604.408,844.314Z" style="fill:rgb(255,111,0);"/>
|
||||
<clipPath id="_clip3">
|
||||
<path d="M604.408,844.314C601.981,840.845 601.962,836.056 604.362,832.565C606.763,829.074 611.005,827.721 614.769,829.246C633.87,836.869 658.833,848.629 678.207,864.452C718.526,897.381 729.55,919.407 738.552,942.091C749.208,968.943 750.785,996.68 748.515,1016.08C742.018,1071.61 700.355,1117.5 641.034,1117.5C581.713,1117.5 534.493,1072.05 533.553,1016.08C532.986,982.372 543.985,955.443 555.988,936.22C558.982,931.437 564.594,929.469 569.609,931.444C574.623,933.419 577.757,938.831 577.215,944.58C575.493,956.716 574.362,969.372 574.932,979.484C576.863,1013.7 597.171,1022.5 618.083,1022.29C640.371,1022.08 662.925,1003.17 654.797,954.895C647.69,912.681 622.362,870.194 604.408,844.314Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip3)">
|
||||
<g transform="matrix(1.28784,-0.270602,0.285942,1.59598,247.349,825.209)">
|
||||
<path d="M255.004,46.957C279.547,58.545 306,85.447 313.307,120.161C325.437,177.791 291.571,193.789 262.496,192.403C215.889,190.181 200.194,153.246 231.326,108.9C250.631,81.401 232.663,36.408 255.004,46.957Z" style="fill:rgb(255,209,0);"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Hut" transform="matrix(1.521,0,0,1.521,393.566,1149.06)">
|
||||
<path d="M228.197,408.524C222.698,408.524 217.813,406.688 214.024,403.619C211.776,401.794 210.92,398.752 211.888,396.024C212.856,393.295 215.437,391.472 218.332,391.472C232.214,391.4 256.112,391.396 256.112,391.396C256.112,391.396 280.009,391.4 293.891,391.472C296.786,391.472 299.367,393.295 300.335,396.024C301.303,398.752 300.447,401.794 298.199,403.619C294.41,406.688 289.526,408.524 284.027,408.524L228.197,408.524ZM217.24,378.877C214.208,378.877 211.3,377.671 209.158,375.525C207.015,373.379 205.814,370.469 205.82,367.436C205.831,361.119 205.842,354.539 205.842,354.539C205.842,350.423 203.097,346.814 199.131,345.714C185.313,341.841 175.2,329.468 175.2,314.823C175.2,297.07 190.059,282.657 208.362,282.657C208.362,282.657 208.362,282.657 208.362,282.657C215.401,282.657 221.675,278.218 224.017,271.581C227.243,262.39 236.411,252.015 256,251.998L256,251.998L256.223,251.998L256.223,251.998C275.812,252.015 284.98,262.39 288.206,271.581C290.549,278.218 296.822,282.657 303.861,282.657C303.861,282.657 303.861,282.657 303.861,282.657C322.164,282.657 337.023,297.07 337.023,314.823C337.023,329.468 326.911,341.841 313.093,345.714C309.127,346.814 306.382,350.423 306.381,354.539C306.381,354.539 306.386,361.127 306.391,367.447C306.394,370.478 305.191,373.385 303.049,375.529C300.907,377.672 298.001,378.877 294.971,378.877C275.615,378.877 236.604,378.877 217.24,378.877Z" style="fill:rgb(22,22,22);"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(2e-06,0,0,2e-06,3755.77,81.7179)"><stop offset="0" style="stop-color:rgb(39,39,39);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(108,108,108);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
BIN
cookbook/static/assets/logo_color_128.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.5 KiB |
BIN
cookbook/static/assets/logo_color_192.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
36
cookbook/static/themes/tandoor.min.css
vendored
@@ -480,7 +480,7 @@ hr {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 0;
|
||||
border-top: 1px solid rgba(0, 0, 0, .1)
|
||||
border-top: 1px solid #ced4da;
|
||||
}
|
||||
|
||||
.small, small {
|
||||
@@ -4457,28 +4457,28 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt
|
||||
}
|
||||
|
||||
.navbar-light .navbar-brand, .navbar-light .navbar-brand:focus, .navbar-light .navbar-brand:hover {
|
||||
color: rgba(46, 46, 46, .9)
|
||||
color: rgba(0, 0, 0, .9)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-nav .nav-link {
|
||||
color: rgba(46, 46, 46, .5)
|
||||
color: rgba(0, 0, 0, .5)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-nav .nav-link:focus, .navbar-light .navbar-nav .nav-link:hover {
|
||||
color: rgba(46, 46, 46, .7)
|
||||
color: rgba(0, 0, 0, .7)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-nav .nav-link.disabled {
|
||||
color: rgba(46, 46, 46, .3)
|
||||
color: rgba(0, 0, 0, .3)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-nav .active > .nav-link, .navbar-light .navbar-nav .nav-link.active, .navbar-light .navbar-nav .nav-link.show, .navbar-light .navbar-nav .show > .nav-link {
|
||||
color: rgba(46, 46, 46, .9)
|
||||
color: rgba(0, 0, 0, .9)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-toggler {
|
||||
color: rgba(46, 46, 46, .5);
|
||||
border-color: rgba(46, 46, 46, .1)
|
||||
color: rgba(0, 0, 0, .5);
|
||||
border-color: rgba(0, 0, 0, .1)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-toggler-icon {
|
||||
@@ -4486,11 +4486,7 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt
|
||||
}
|
||||
|
||||
.navbar-light .navbar-text {
|
||||
color: rgba(46, 46, 46, .5)
|
||||
}
|
||||
|
||||
.navbar-light .navbar-text a, .navbar-light .navbar-text a:focus, .navbar-light .navbar-text a:hover {
|
||||
color: rgba(46, 46, 46, .9)
|
||||
color: rgba(0, 0, 0, .5)
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-brand, .navbar-dark .navbar-brand:focus, .navbar-dark .navbar-brand:hover {
|
||||
@@ -4498,24 +4494,24 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link {
|
||||
color: hsla(0, 0%, 18%, .5)
|
||||
color: rgba(255, 255, 255, .5)
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link:focus, .navbar-dark .navbar-nav .nav-link:hover {
|
||||
color: hsla(0, 0%, 18%, .75)
|
||||
color: rgba(255, 255, 255, .75)
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link.disabled {
|
||||
color: hsla(0, 0%, 18%, .25)
|
||||
color: rgba(255, 255, 255, .25)
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .active > .nav-link, .navbar-dark .navbar-nav .nav-link.active, .navbar-dark .navbar-nav .nav-link.show, .navbar-dark .navbar-nav .show > .nav-link {
|
||||
color: #2e2e2e
|
||||
color: #FFF
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-toggler {
|
||||
color: rgba(46, 46, 46, 0.5);
|
||||
border-color: rgba(46, 46, 46, 0.5);
|
||||
color: rgba(255, 255, 255, .5);
|
||||
border-color: rgba(255, 255, 255, .1)
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-toggler-icon {
|
||||
@@ -4523,7 +4519,7 @@ input[type=button].btn-block, input[type=reset].btn-block, input[type=submit].bt
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-text {
|
||||
color: hsla(0, 0%, 18%, .5)
|
||||
color: rgba(255, 255, 255, .5)
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-text a, .navbar-dark .navbar-text a:focus, .navbar-dark .navbar-text a:hover {
|
||||
|
||||