mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-25 03:13:13 -05:00
Compare commits
1507 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28e554d04b | ||
|
|
5040caf91c | ||
|
|
c5fcfd07a7 | ||
|
|
a1a172e223 | ||
|
|
0039654d40 | ||
|
|
17de37b9fc | ||
|
|
d0856ce3b7 | ||
|
|
24426c2b7e | ||
|
|
5380b7d697 | ||
|
|
1d0488fbb0 | ||
|
|
2213346297 | ||
|
|
e1a9938c0b | ||
|
|
126f21842f | ||
|
|
9dfc9e1020 | ||
|
|
84898b09f2 | ||
|
|
d4ded02c2a | ||
|
|
8db294255e | ||
|
|
c99d13e2e7 | ||
|
|
5608f80246 | ||
|
|
59040f4cdf | ||
|
|
af476480c1 | ||
|
|
9307e61c1a | ||
|
|
8f5593d5ca | ||
|
|
f4eded5b03 | ||
|
|
3d9c51053a | ||
|
|
0046233b6f | ||
|
|
78ede7b601 | ||
|
|
7e7e133604 | ||
|
|
b0ec569a00 | ||
|
|
7674084ae0 | ||
|
|
798e2ac48b | ||
|
|
714d4a32a9 | ||
|
|
66b5097872 | ||
|
|
c1d4fed142 | ||
|
|
1cc0806729 | ||
|
|
09487a0e94 | ||
|
|
89e58edcad | ||
|
|
887d7fe9f0 | ||
|
|
14696e3ce8 | ||
|
|
dd56bb4b35 | ||
|
|
8ec0ba9541 | ||
|
|
c105c9190e | ||
|
|
9c1700adb9 | ||
|
|
b43a87a7e3 | ||
|
|
01e78baecf | ||
|
|
0ee241524d | ||
|
|
2bd60a6f13 | ||
|
|
e0196f17da | ||
|
|
fe75052baa | ||
|
|
0a3b750294 | ||
|
|
fcd3918b5f | ||
|
|
e7aac06ca7 | ||
|
|
67e1f57723 | ||
|
|
10581329e8 | ||
|
|
553c06f291 | ||
|
|
e0cbfd824c | ||
|
|
a5a522d378 | ||
|
|
8502bb235b | ||
|
|
42f2ad624f | ||
|
|
3b95bf40da | ||
|
|
81a6837b06 | ||
|
|
a95e352250 | ||
|
|
a4ca66d287 | ||
|
|
ec30b81ae5 | ||
|
|
92211b1f51 | ||
|
|
8eeea42057 | ||
|
|
7e76a71ccc | ||
|
|
b397c94f0a | ||
|
|
9552564e59 | ||
|
|
4ecf323e4f | ||
|
|
d5d5c2c52b | ||
|
|
7ffabfe711 | ||
|
|
49e0b5b962 | ||
|
|
a05f1ece24 | ||
|
|
748b91bb8a | ||
|
|
bd2e9cc3d9 | ||
|
|
c40bb20a7a | ||
|
|
b377d2cd35 | ||
|
|
dc0e91d0f9 | ||
|
|
5f12907544 | ||
|
|
889ddac7dc | ||
|
|
b369e2618a | ||
|
|
5a4e0204c9 | ||
|
|
bfc2e96b54 | ||
|
|
f065ef80aa | ||
|
|
61c14b8b05 | ||
|
|
35d5d64809 | ||
|
|
63c711d18c | ||
|
|
59e3ea70d1 | ||
|
|
6771662a9f | ||
|
|
9b792a1393 | ||
|
|
862957c121 | ||
|
|
bdcbafd52f | ||
|
|
5e454a5212 | ||
|
|
20bea63997 | ||
|
|
8a265772c0 | ||
|
|
6febb4e3e8 | ||
|
|
04f9167fd8 | ||
|
|
8f29e01daf | ||
|
|
e810363b22 | ||
|
|
b5a2120bdf | ||
|
|
643fcbad9b | ||
|
|
4a3b834463 | ||
|
|
003149133a | ||
|
|
a43de0ca4d | ||
|
|
e05aaed75c | ||
|
|
4984e3e31b | ||
|
|
11dce4c6ad | ||
|
|
8d0d338ea2 | ||
|
|
d09e629415 | ||
|
|
53ef2ef99f | ||
|
|
d7b26d1b29 | ||
|
|
673d12d233 | ||
|
|
6359245925 | ||
|
|
a7c4822322 | ||
|
|
e94419f320 | ||
|
|
01f46483ff | ||
|
|
d6da5688af | ||
|
|
680ae39201 | ||
|
|
2472ee9c26 | ||
|
|
4428b06d4a | ||
|
|
e9c38d7d5e | ||
|
|
6f28d58807 | ||
|
|
88db611f0a | ||
|
|
f3302b4014 | ||
|
|
d4bb161275 | ||
|
|
32f1538938 | ||
|
|
029baea4c7 | ||
|
|
38d1b7cef5 | ||
|
|
85821bcc94 | ||
|
|
2345af8fd6 | ||
|
|
51107c64ee | ||
|
|
81983c5ae2 | ||
|
|
f7713a43a7 | ||
|
|
ffd951a7f4 | ||
|
|
319ac8e191 | ||
|
|
e292b72e34 | ||
|
|
4e795ecf55 | ||
|
|
e3c2a66723 | ||
|
|
eec3e97f97 | ||
|
|
3f481d6922 | ||
|
|
0810ab7210 | ||
|
|
abd621145c | ||
|
|
7d218aa93d | ||
|
|
1b41bd9115 | ||
|
|
aea247b4a3 | ||
|
|
e2843bb02f | ||
|
|
e3aa3e1137 | ||
|
|
da1187b03a | ||
|
|
f9ed79978c | ||
|
|
920a3ed4a3 | ||
|
|
2077eae142 | ||
|
|
b1ef35e415 | ||
|
|
0a687d840c | ||
|
|
6a3034b966 | ||
|
|
3d7afbfe4f | ||
|
|
02e43730bd | ||
|
|
6adf077ee5 | ||
|
|
d73ffa46ff | ||
|
|
8572f338ad | ||
|
|
920ec8e74b | ||
|
|
2328bf2342 | ||
|
|
85620a1431 | ||
|
|
0037858885 | ||
|
|
9df3ff0028 | ||
|
|
0a43272126 | ||
|
|
ff96eb194f | ||
|
|
6b69c4184b | ||
|
|
e90e21181c | ||
|
|
5237228673 | ||
|
|
ecb3172085 | ||
|
|
b4f4e9fd2b | ||
|
|
6d0f3b99c8 | ||
|
|
cdb94ae628 | ||
|
|
0d589444fd | ||
|
|
95fa420c3a | ||
|
|
dd4dc1083f | ||
|
|
04f889b742 | ||
|
|
67d374c071 | ||
|
|
8d749e351d | ||
|
|
417ffcab5d | ||
|
|
d4f654554b | ||
|
|
c8115545b8 | ||
|
|
6dbf0871ec | ||
|
|
f1c5c8bc43 | ||
|
|
22e0108992 | ||
|
|
e2e05c8d1d | ||
|
|
b02b36812d | ||
|
|
0c0012aab8 | ||
|
|
e562883da3 | ||
|
|
a81bc335cc | ||
|
|
ebee1ccd4b | ||
|
|
b1104b4581 | ||
|
|
f5e952d88c | ||
|
|
968fcc3936 | ||
|
|
73d3d87217 | ||
|
|
9050f648f9 | ||
|
|
a4a9e104b5 | ||
|
|
3ed85ea0c4 | ||
|
|
da8ceb7abe | ||
|
|
5ff3a6bb2e | ||
|
|
3ed750b330 | ||
|
|
0315911802 | ||
|
|
1fe96f2b3d | ||
|
|
11761c0b15 | ||
|
|
a7b0a1ab30 | ||
|
|
e4fcae3b00 | ||
|
|
b0401639f1 | ||
|
|
5f8770f502 | ||
|
|
aaa627d3b6 | ||
|
|
e77734f696 | ||
|
|
73df6bb961 | ||
|
|
7d187b638e | ||
|
|
68bb750f8c | ||
|
|
1e35035540 | ||
|
|
561ba2f1da | ||
|
|
bd600301f9 | ||
|
|
cf5483a4d9 | ||
|
|
ba417c49dd | ||
|
|
0259b1dc08 | ||
|
|
34553dadd7 | ||
|
|
535b88c8db | ||
|
|
71eb8818b5 | ||
|
|
0dc94a817f | ||
|
|
4df862c7f3 | ||
|
|
69f013c980 | ||
|
|
23c420dda8 | ||
|
|
63daf1e958 | ||
|
|
52b44eacdd | ||
|
|
fa7cc12b99 | ||
|
|
64d2108ef6 | ||
|
|
dccfdcc11c | ||
|
|
974e72631d | ||
|
|
70f31b8553 | ||
|
|
3cb980c0e7 | ||
|
|
b8a403b7c1 | ||
|
|
b037d90220 | ||
|
|
ad32e457fa | ||
|
|
8e2726caeb | ||
|
|
e693737c57 | ||
|
|
9f239c06d3 | ||
|
|
0f551c5f88 | ||
|
|
eb224a769d | ||
|
|
4515eba9d7 | ||
|
|
30b37bf0b6 | ||
|
|
f17207e56e | ||
|
|
2cba0e18af | ||
|
|
ec6e81316a | ||
|
|
b72897b222 | ||
|
|
bca1ebbf99 | ||
|
|
f0342d4568 | ||
|
|
81f62de500 | ||
|
|
f783949a61 | ||
|
|
820fad1b5c | ||
|
|
1169abd942 | ||
|
|
48e175f58f | ||
|
|
5450e18342 | ||
|
|
ea590f8e49 | ||
|
|
13626ca11b | ||
|
|
f53fe1e3c4 | ||
|
|
d177316b47 | ||
|
|
338db1fac2 | ||
|
|
377619473c | ||
|
|
000962c5bb | ||
|
|
9228c1d59f | ||
|
|
27007de7a0 | ||
|
|
29c99b66a1 | ||
|
|
bc179f430d | ||
|
|
58c412ad95 | ||
|
|
4f248afe76 | ||
|
|
f722d24eaa | ||
|
|
723b74509f | ||
|
|
ad4b1393dd | ||
|
|
04bab7072c | ||
|
|
6391cee9eb | ||
|
|
14884fc0d4 | ||
|
|
f2191f79dd | ||
|
|
c2533d9ea2 | ||
|
|
db72fdb1bb | ||
|
|
78252662cb | ||
|
|
4e078bf477 | ||
|
|
2e9e226fe0 | ||
|
|
18cfbd80ab | ||
|
|
4d284b4fff | ||
|
|
b1128dd134 | ||
|
|
3aebf58406 | ||
|
|
f3816a77df | ||
|
|
e4183d79ab | ||
|
|
f4aa1a083f | ||
|
|
ed5508b576 | ||
|
|
040e247487 | ||
|
|
5d28c7b17d | ||
|
|
15b2df07f2 | ||
|
|
ed8f97e9e0 | ||
|
|
034f68fc28 | ||
|
|
0158087a0b | ||
|
|
cb6bfd741d | ||
|
|
afeee5f7cb | ||
|
|
b43d6e08d4 | ||
|
|
1188624376 | ||
|
|
9ac837c969 | ||
|
|
fc4b017d30 | ||
|
|
4636ac28f9 | ||
|
|
397912e87f | ||
|
|
d0b860e623 | ||
|
|
8a90ed1274 | ||
|
|
163c2a53b6 | ||
|
|
286d707347 | ||
|
|
98d308aee9 | ||
|
|
a7c5240227 | ||
|
|
75fcff8e70 | ||
|
|
2f27cf4deb | ||
|
|
686b595f45 | ||
|
|
0f9f9e8f7c | ||
|
|
7be7c5b954 | ||
|
|
0853a9ec64 | ||
|
|
fa3daee965 | ||
|
|
aba45657c3 | ||
|
|
e6abdf8cd4 | ||
|
|
6cedde7b2d | ||
|
|
741e9eb370 | ||
|
|
7db523d8c4 | ||
|
|
41f0060c43 | ||
|
|
5572833f64 | ||
|
|
780e441a3b | ||
|
|
c4fd2d0b4e | ||
|
|
1c6618f452 | ||
|
|
8c96a75a1e | ||
|
|
44baa8322c | ||
|
|
0fbb95438a | ||
|
|
c56dd9563c | ||
|
|
0008b7c975 | ||
|
|
524f086cc5 | ||
|
|
8550387e0c | ||
|
|
1618f8df79 | ||
|
|
22dfb2a410 | ||
|
|
f099e2e5d3 | ||
|
|
6973c65142 | ||
|
|
774c05e76f | ||
|
|
b08c39e284 | ||
|
|
ae036cfa9a | ||
|
|
37628c1735 | ||
|
|
530a6db35c | ||
|
|
2930093da0 | ||
|
|
b7e63a466b | ||
|
|
a01f86a14e | ||
|
|
9704268fdc | ||
|
|
84cc4c1165 | ||
|
|
5cb70becb8 | ||
|
|
5f99abf459 | ||
|
|
4a8ddce391 | ||
|
|
9a14a87c27 | ||
|
|
c01634f9bd | ||
|
|
f055df3b4d | ||
|
|
a83f474d70 | ||
|
|
63d358df36 | ||
|
|
e70548fcc0 | ||
|
|
17b03905e6 | ||
|
|
90403e6a13 | ||
|
|
db400cae25 | ||
|
|
0f8eee4e0f | ||
|
|
1f532f6276 | ||
|
|
b32715e493 | ||
|
|
0d19e12118 | ||
|
|
96e5213fa6 | ||
|
|
44c567d20b | ||
|
|
3c920593cf | ||
|
|
1d90f8b6f1 | ||
|
|
6b1217ec35 | ||
|
|
a71564a424 | ||
|
|
76c2e144fc | ||
|
|
981353380c | ||
|
|
96a520b1af | ||
|
|
05f537dc6b | ||
|
|
948d8da3b1 | ||
|
|
f8e4b39d88 | ||
|
|
6c498f7dac | ||
|
|
d25702b717 | ||
|
|
aca18fcbe0 | ||
|
|
98b57d2854 | ||
|
|
5e1c804fd1 | ||
|
|
a35c92439c | ||
|
|
eed09a7891 | ||
|
|
a30deb4bae | ||
|
|
45a567856a | ||
|
|
7065d96f90 | ||
|
|
f8cd42dec9 | ||
|
|
8d736c0f88 | ||
|
|
8183e350c9 | ||
|
|
4438bfcb89 | ||
|
|
f42b2cfd31 | ||
|
|
09131e8eae | ||
|
|
f5f001b3d2 | ||
|
|
7f8587922d | ||
|
|
a3460bc023 | ||
|
|
5faa74a75d | ||
|
|
65dbc643d3 | ||
|
|
f0b169647b | ||
|
|
d786ee09fa | ||
|
|
a46f3958fe | ||
|
|
6c17937313 | ||
|
|
a26835ccc4 | ||
|
|
86fc4aa2d0 | ||
|
|
4bd3da451d | ||
|
|
0003405e98 | ||
|
|
b586794337 | ||
|
|
460cb43113 | ||
|
|
5128fcc9eb | ||
|
|
243ff8601c | ||
|
|
97f8d46afb | ||
|
|
e469ebf35e | ||
|
|
e04c729476 | ||
|
|
d98bf9155d | ||
|
|
e98d00a962 | ||
|
|
cf5f896cec | ||
|
|
e8d616ac98 | ||
|
|
7a22d43959 | ||
|
|
6b68f48227 | ||
|
|
115f18889a | ||
|
|
0aaffb7545 | ||
|
|
087cbdade8 | ||
|
|
7e55115a3a | ||
|
|
31ee55a113 | ||
|
|
61be55e4b7 | ||
|
|
e3f695bde1 | ||
|
|
0fb3d22f6a | ||
|
|
7ba5187ecf | ||
|
|
168c0f3a0d | ||
|
|
1179e226ab | ||
|
|
bed22c055d | ||
|
|
c25a1df480 | ||
|
|
d1df772218 | ||
|
|
cbdd23020b | ||
|
|
10f8a56343 | ||
|
|
006c5b3af8 | ||
|
|
562a0dceae | ||
|
|
cde03a0f33 | ||
|
|
b42285a9a5 | ||
|
|
f4d4a5b714 | ||
|
|
ee7d611086 | ||
|
|
e51fda5f20 | ||
|
|
bee759e166 | ||
|
|
5802dfd0a5 | ||
|
|
c18ce7635d | ||
|
|
942a8a6119 | ||
|
|
4015edde90 | ||
|
|
1c32940f5c | ||
|
|
447ffa9fe2 | ||
|
|
8480234592 | ||
|
|
2e0345a4a8 | ||
|
|
49fc0cf80f | ||
|
|
c67ecb6e31 | ||
|
|
b4f12c4e84 | ||
|
|
0b2adf5249 | ||
|
|
7dcb5884d9 | ||
|
|
35bd550101 | ||
|
|
707abfacb0 | ||
|
|
ed4f4c77e8 | ||
|
|
c492fb513b | ||
|
|
310b8e04e1 | ||
|
|
efeae4debc | ||
|
|
6bc25c32ff | ||
|
|
7f2b0438fe | ||
|
|
8481f8c658 | ||
|
|
d842795c25 | ||
|
|
58dd700207 | ||
|
|
1331d2cb6d | ||
|
|
ad2a613fd8 | ||
|
|
0565189580 | ||
|
|
5aa351b885 | ||
|
|
d5226eb5cf | ||
|
|
9ead1d0022 | ||
|
|
67342c3ba9 | ||
|
|
3fecd82cd0 | ||
|
|
a033c4290f | ||
|
|
b6597af0d7 | ||
|
|
af6ed4bd24 | ||
|
|
cc4bddb3fe | ||
|
|
95a9df9c05 | ||
|
|
c44de28c2c | ||
|
|
9f1b87fa4f | ||
|
|
b96e0bab11 | ||
|
|
fe97fb371b | ||
|
|
bb7df960cc | ||
|
|
c3c7d803dc | ||
|
|
99ce3327cc | ||
|
|
d1949df23d | ||
|
|
119b47c3c4 | ||
|
|
8b50c59ad3 | ||
|
|
e2ac65467b | ||
|
|
c5cc492f0a | ||
|
|
8c73b5254c | ||
|
|
4b0315ffd3 | ||
|
|
6a96f5b7c5 | ||
|
|
8875dd4083 | ||
|
|
7299f265d3 | ||
|
|
cac186f63c | ||
|
|
fd4c571e48 | ||
|
|
db99450475 | ||
|
|
ec50add571 | ||
|
|
1fc3746619 | ||
|
|
debf4c124a | ||
|
|
0e071255e5 | ||
|
|
4d0b8c690b | ||
|
|
85b3e0a0a6 | ||
|
|
d8573ce16f | ||
|
|
7f7e3180fa | ||
|
|
57cc6feef0 | ||
|
|
fb198a80d2 | ||
|
|
5b324a86dc | ||
|
|
f633274bef | ||
|
|
65b2eb6d7e | ||
|
|
0c509ec02e | ||
|
|
babcddeeb1 | ||
|
|
f23b282f27 | ||
|
|
fcfedd3026 | ||
|
|
efd65c1024 | ||
|
|
6f4f5381ff | ||
|
|
95b63f5180 | ||
|
|
f33a52a94c | ||
|
|
90baf26eb8 | ||
|
|
9119d773f1 | ||
|
|
4ea5cdb8b9 | ||
|
|
f36e5f1d89 | ||
|
|
bce95ff604 | ||
|
|
0f0a5b32cd | ||
|
|
0bd0b794df | ||
|
|
5267ac12b0 | ||
|
|
02678ffe30 | ||
|
|
2907e29a11 | ||
|
|
9d49c4d550 | ||
|
|
e2c6eec628 | ||
|
|
63716e4397 | ||
|
|
27e5955c78 | ||
|
|
e9e6cdccca | ||
|
|
8c8096e348 | ||
|
|
9fcbbc17e8 | ||
|
|
0a2f83cf85 | ||
|
|
01fff0783f | ||
|
|
7ccdb90f9b | ||
|
|
c2e522d9f2 | ||
|
|
92578dd6a2 | ||
|
|
3103f28fc8 | ||
|
|
a5df1275ec | ||
|
|
a4308f9864 | ||
|
|
21526fb676 | ||
|
|
5dc3116c44 | ||
|
|
2a6a87ec16 | ||
|
|
8149b05185 | ||
|
|
61afbbdfbe | ||
|
|
a37455ccda | ||
|
|
6d711aff41 | ||
|
|
d4adb975ec | ||
|
|
9b581d58bd | ||
|
|
79db8a2fe0 | ||
|
|
f722d4751b | ||
|
|
368ed2aaf3 | ||
|
|
50400e1d20 | ||
|
|
750115cab5 | ||
|
|
9d8acdc41f | ||
|
|
7ab36f1a7a | ||
|
|
b8d0e32550 | ||
|
|
d9f0889b36 | ||
|
|
35f40f175c | ||
|
|
291ff86c42 | ||
|
|
d2b0aeab52 | ||
|
|
3cab6e538e | ||
|
|
db67ab6b30 | ||
|
|
b5b31b3dc6 | ||
|
|
a15dd2ccbc | ||
|
|
62cc54f9f5 | ||
|
|
75c5bba7e5 | ||
|
|
642a0493af | ||
|
|
8d8e0be328 | ||
|
|
744b588cea | ||
|
|
d3a21b9ff0 | ||
|
|
3a9c40c566 | ||
|
|
387e0a5250 | ||
|
|
4ea28ba22a | ||
|
|
20660f547c | ||
|
|
2ee63d8568 | ||
|
|
2179d7d1f7 | ||
|
|
034d59373f | ||
|
|
d1ad0ade0f | ||
|
|
991089c17a | ||
|
|
54960d8480 | ||
|
|
5fcfe09bb6 | ||
|
|
01c4974507 | ||
|
|
2d57e0dab2 | ||
|
|
d52e5408c0 | ||
|
|
fdce69daf4 | ||
|
|
cb3ffcb12d | ||
|
|
d7342a349b | ||
|
|
794bbed833 | ||
|
|
0b335e80a6 | ||
|
|
2716d72e31 | ||
|
|
8c849a1077 | ||
|
|
8c1769458d | ||
|
|
2ac6451370 | ||
|
|
7841397b59 | ||
|
|
cd11194ce5 | ||
|
|
be7558f82b | ||
|
|
35a7875f6f | ||
|
|
55f1f834c2 | ||
|
|
f5f32912b1 | ||
|
|
5709435d43 | ||
|
|
1c219dbc3b | ||
|
|
1262982588 | ||
|
|
be8a340a0c | ||
|
|
fb1de15de6 | ||
|
|
2180f11768 | ||
|
|
1083b7521e | ||
|
|
70d40f9e70 | ||
|
|
1094cf2d92 | ||
|
|
aaf6e0f197 | ||
|
|
ec59cd6e4f | ||
|
|
3f7cb41db7 | ||
|
|
432ed09ef8 | ||
|
|
6c8a8841c7 | ||
|
|
c3c762c262 | ||
|
|
cc6f393a86 | ||
|
|
2546c5b74c | ||
|
|
118440c960 | ||
|
|
4f8ca0710f | ||
|
|
e2c1c4c1f7 | ||
|
|
8c18499fd7 | ||
|
|
bd9d5200b1 | ||
|
|
cb98b6347b | ||
|
|
a34d403b2c | ||
|
|
957d8092a3 | ||
|
|
eb799dead3 | ||
|
|
b37d9e2c25 | ||
|
|
9fc6e775aa | ||
|
|
211bf226eb | ||
|
|
360a923e96 | ||
|
|
2b689ffd6b | ||
|
|
feb6107c79 | ||
|
|
d39d5d4cbf | ||
|
|
62a66aa9a7 | ||
|
|
885433a1bd | ||
|
|
f67efd55a7 | ||
|
|
c238ea9136 | ||
|
|
5a0a5b09a1 | ||
|
|
e698d14ec3 | ||
|
|
0caf2fe77f | ||
|
|
c079f49d71 | ||
|
|
dd921f1555 | ||
|
|
e1a504564f | ||
|
|
424fbd4de8 | ||
|
|
7266ff8978 | ||
|
|
3c8d434358 | ||
|
|
088387fa90 | ||
|
|
7bc7017162 | ||
|
|
22adc9fef9 | ||
|
|
1f688c3fe2 | ||
|
|
68040aa568 | ||
|
|
1d751f73ff | ||
|
|
8490ac01cc | ||
|
|
84477ef52a | ||
|
|
b789573de3 | ||
|
|
d5d8e7ce63 | ||
|
|
c7a49458b9 | ||
|
|
a5dde3d3c8 | ||
|
|
5f2fff229d | ||
|
|
f3c67f134c | ||
|
|
1c48fc1c53 | ||
|
|
7c51419f3b | ||
|
|
b3984b02b4 | ||
|
|
758c3fa1a9 | ||
|
|
52a55edd04 | ||
|
|
896dd28fa8 | ||
|
|
c7b4fcd761 | ||
|
|
075e50b911 | ||
|
|
cc436898d5 | ||
|
|
a2cd7fb82d | ||
|
|
213ca2009c | ||
|
|
6add8b9b02 | ||
|
|
adbff13556 | ||
|
|
65757b0019 | ||
|
|
0b17ef329f | ||
|
|
d51a44a240 | ||
|
|
f4a7ef144f | ||
|
|
2a8a79b263 | ||
|
|
edcc5f6441 | ||
|
|
9fc77be51b | ||
|
|
a861d8a89c | ||
|
|
6baf640e6d | ||
|
|
1dd3f227ac | ||
|
|
a89cf71c17 | ||
|
|
2a6a785453 | ||
|
|
7dc9e012fa | ||
|
|
5d65c76686 | ||
|
|
ff184f7e5d | ||
|
|
767cc9b58a | ||
|
|
d8f444e596 | ||
|
|
6bc3e26eff | ||
|
|
68399acb8f | ||
|
|
22923b8e7e | ||
|
|
612c5e6668 | ||
|
|
a5a0264e41 | ||
|
|
fdb73563d6 | ||
|
|
cd2c4b35df | ||
|
|
d1d4cec00f | ||
|
|
6940f405eb | ||
|
|
3e6f7a554a | ||
|
|
4df451a540 | ||
|
|
78e2ee6631 | ||
|
|
fd06d67218 | ||
|
|
97aa3301ea | ||
|
|
ec0bc43c21 | ||
|
|
a8a5db401f | ||
|
|
23c4c2e543 | ||
|
|
d1a69dac90 | ||
|
|
7b819843b7 | ||
|
|
8e1893a215 | ||
|
|
6a43a01dd4 | ||
|
|
63069dd716 | ||
|
|
cd707d20a1 | ||
|
|
c648ee954c | ||
|
|
5695cacc0e | ||
|
|
bad5ad1f1f | ||
|
|
db8d35e332 | ||
|
|
c88df548e9 | ||
|
|
81180207ba | ||
|
|
2a1a6ea433 | ||
|
|
fa8af5596f | ||
|
|
f546a4d382 | ||
|
|
fc0b12af12 | ||
|
|
b2da40421b | ||
|
|
8740bf3a83 | ||
|
|
c2ecf7ef57 | ||
|
|
b9771d7c11 | ||
|
|
d5b53d2bca | ||
|
|
f502cd47e1 | ||
|
|
358fab9a8f | ||
|
|
445e78825a | ||
|
|
d49e17f088 | ||
|
|
e7d9824520 | ||
|
|
c9ebbdd4ec | ||
|
|
85fa211390 | ||
|
|
40a7db086f | ||
|
|
20f066104c | ||
|
|
1d9de9bae2 | ||
|
|
086272f103 | ||
|
|
9a7a05e8a9 | ||
|
|
36352ae6fb | ||
|
|
9419a823e2 | ||
|
|
f32fcd6e11 | ||
|
|
4ded92fcde | ||
|
|
1206215ea7 | ||
|
|
ac033600e9 | ||
|
|
5013d5ec94 | ||
|
|
3f5db2cb03 | ||
|
|
79227b347e | ||
|
|
ffe0c22b3e | ||
|
|
25e5faf135 | ||
|
|
e57ab1835c | ||
|
|
a6f8a9ef06 | ||
|
|
05b51e83a6 | ||
|
|
5625a9ae4f | ||
|
|
ac440aa8b1 | ||
|
|
0cebc33e27 | ||
|
|
11678431e1 | ||
|
|
75c03706cb | ||
|
|
7232c9373f | ||
|
|
06920807c6 | ||
|
|
ccae32f21c | ||
|
|
eaad4e21e4 | ||
|
|
6b4b65109d | ||
|
|
441e9ea887 | ||
|
|
25c369d400 | ||
|
|
491b5beded | ||
|
|
13c26d199b | ||
|
|
a02722a525 | ||
|
|
2553bea835 | ||
|
|
302528256c | ||
|
|
ca28a44743 | ||
|
|
32fd6e3827 | ||
|
|
ec4fa50012 | ||
|
|
8e60a1954b | ||
|
|
885a316955 | ||
|
|
cb1d45b625 | ||
|
|
52d2d75fe9 | ||
|
|
41d4728c89 | ||
|
|
edb669ab36 | ||
|
|
e9ed28b44d | ||
|
|
343373bf4d | ||
|
|
4a9082b70c | ||
|
|
c1e56920ec | ||
|
|
fe64da0841 | ||
|
|
6bf605f98e | ||
|
|
e18b0ad049 | ||
|
|
d7f37e8293 | ||
|
|
f576aa34e4 | ||
|
|
698ede9eb6 | ||
|
|
3ae73f7cad | ||
|
|
340d36c628 | ||
|
|
b08c119e57 | ||
|
|
703f782be1 | ||
|
|
052219e141 | ||
|
|
604d18d594 | ||
|
|
f23d4a4188 | ||
|
|
88f2177e9b | ||
|
|
675d7a0647 | ||
|
|
781d8845be | ||
|
|
c2fdf4812c | ||
|
|
ae1532e509 | ||
|
|
121d2471a7 | ||
|
|
278342f3f0 | ||
|
|
608526b348 | ||
|
|
a0ba1ecfae | ||
|
|
2ef4514987 | ||
|
|
67f63730a3 | ||
|
|
eeb3b2e5d5 | ||
|
|
7314572fc0 | ||
|
|
934d78c50e | ||
|
|
431eb7baf7 | ||
|
|
e0c8895733 | ||
|
|
b18a1d0110 | ||
|
|
a89411aeec | ||
|
|
838ce6615b | ||
|
|
e0cc42653d | ||
|
|
74b940d4eb | ||
|
|
c03e82f094 | ||
|
|
235c5d6b4a | ||
|
|
42e6e0bc50 | ||
|
|
f7eabfe458 | ||
|
|
988dcd1522 | ||
|
|
22aa0d2cb7 | ||
|
|
dd1975817e | ||
|
|
77195718d8 | ||
|
|
ebd354bc8d | ||
|
|
aa1fa3a40e | ||
|
|
2add3b70a4 | ||
|
|
d38a4a2e7e | ||
|
|
8827e6f453 | ||
|
|
742297c1fc | ||
|
|
7ae66a14be | ||
|
|
0cf89ca33c | ||
|
|
68ceada28b | ||
|
|
192ca44d89 | ||
|
|
421dab2405 | ||
|
|
3204aaf161 | ||
|
|
6cf6791fe6 | ||
|
|
0ff16f280e | ||
|
|
a297e4731c | ||
|
|
de5ac9181f | ||
|
|
fbab90e954 | ||
|
|
05472b5a29 | ||
|
|
d6839c5dfa | ||
|
|
dea4fa000e | ||
|
|
c36eaf934f | ||
|
|
4a13cc63ae | ||
|
|
96e0be0a78 | ||
|
|
5f190bdc6c | ||
|
|
73c376427c | ||
|
|
d34f39a9e0 | ||
|
|
8ae9de580d | ||
|
|
a144f347f8 | ||
|
|
f43788d676 | ||
|
|
cac00423a2 | ||
|
|
32429af5e9 | ||
|
|
792c3a07e3 | ||
|
|
2879fa466e | ||
|
|
9eed6693b4 | ||
|
|
c4c6eb3ca6 | ||
|
|
e29f318453 | ||
|
|
8eb2ba9512 | ||
|
|
e9f87bb475 | ||
|
|
8b4e6ac5ae | ||
|
|
ee0f652981 | ||
|
|
7f06f888df | ||
|
|
54f43c7938 | ||
|
|
6ebb18a932 | ||
|
|
7bde1bf44f | ||
|
|
e00c6459ab | ||
|
|
3075ec066f | ||
|
|
3ddc3c3768 | ||
|
|
4d8d4af42a | ||
|
|
1fd0028351 | ||
|
|
f59c5ae16e | ||
|
|
7b7b726ec5 | ||
|
|
9c1d161785 | ||
|
|
5f542e9ce6 | ||
|
|
5484506bc3 | ||
|
|
2e71bc4505 | ||
|
|
136f05b886 | ||
|
|
83cf03a10b | ||
|
|
7b56719716 | ||
|
|
348296763d | ||
|
|
5c7a57ac32 | ||
|
|
0c0b8ea455 | ||
|
|
2f20f43efe | ||
|
|
cf6435503c | ||
|
|
752e98275d | ||
|
|
40ee21a613 | ||
|
|
1662b793a5 | ||
|
|
2e4efe2500 | ||
|
|
3d01faed5f | ||
|
|
9bc3443a3c | ||
|
|
87dc32210e | ||
|
|
671216488e | ||
|
|
f596fc33a1 | ||
|
|
82756923ad | ||
|
|
14042563bf | ||
|
|
06e12396e8 | ||
|
|
b698eee3a1 | ||
|
|
9d15634dc8 | ||
|
|
5f6c298d04 | ||
|
|
f20a2ca781 | ||
|
|
6dcfbc24a9 | ||
|
|
ea9404301b | ||
|
|
da268b4429 | ||
|
|
2775960cf7 | ||
|
|
bff6b2ffb0 | ||
|
|
193600564e | ||
|
|
2abd683759 | ||
|
|
0163800a05 | ||
|
|
958ccd7f7d | ||
|
|
a0eb1df4fa | ||
|
|
3f1c6d1dd0 | ||
|
|
49d1946f29 | ||
|
|
7c448a5362 | ||
|
|
06c40b6c80 | ||
|
|
63f24e75b3 | ||
|
|
87f4068736 | ||
|
|
87a80e0caa | ||
|
|
debdd03a7a | ||
|
|
c58697aecd | ||
|
|
3b3ad37d64 | ||
|
|
d392b06e98 | ||
|
|
99a9f594e2 | ||
|
|
549a5b764d | ||
|
|
26592c31a9 | ||
|
|
378eb924e9 | ||
|
|
f8f4a87a99 | ||
|
|
55289e6837 | ||
|
|
60efbd0389 | ||
|
|
f3903c1aa5 | ||
|
|
b01e62007f | ||
|
|
d23139cd8a | ||
|
|
f59a450448 | ||
|
|
e14bb78ed1 | ||
|
|
7730890525 | ||
|
|
29977609aa | ||
|
|
88c3a7d0bf | ||
|
|
2c89add187 | ||
|
|
0461c57cf3 | ||
|
|
ba0335bb28 | ||
|
|
51e01ee40c | ||
|
|
09a76a2057 | ||
|
|
741220aefe | ||
|
|
2917d6ffcf | ||
|
|
6c73ca5b45 | ||
|
|
79658e2f7c | ||
|
|
f335c3f755 | ||
|
|
86fe129eba | ||
|
|
081104e655 | ||
|
|
96d997a817 | ||
|
|
b55ceda978 | ||
|
|
37b4f5c896 | ||
|
|
b0821a9c6b | ||
|
|
f178790709 | ||
|
|
425b71984a | ||
|
|
3cb2cf5333 | ||
|
|
9c0dc64a47 | ||
|
|
4a9bd3626e | ||
|
|
23a561e8cd | ||
|
|
2a2f1033d0 | ||
|
|
4234c5b35f | ||
|
|
1b09234e91 | ||
|
|
9b5878faae | ||
|
|
e27f4c5768 | ||
|
|
a78b42d9ab | ||
|
|
a1f1bc19e5 | ||
|
|
f6983993d5 | ||
|
|
a868860b7d | ||
|
|
d1b6a4eb43 | ||
|
|
61bfaf012b | ||
|
|
92adc9b610 | ||
|
|
feb4a63bb0 | ||
|
|
30f8318531 | ||
|
|
75eff42329 | ||
|
|
5b040900a7 | ||
|
|
717666fc14 | ||
|
|
a012a6f0d5 | ||
|
|
d0703f9639 | ||
|
|
7b2ade5fcd | ||
|
|
28c839a59d | ||
|
|
1ead724af6 | ||
|
|
3341c6db19 | ||
|
|
206d13594a | ||
|
|
37af406831 | ||
|
|
88f18a1b24 | ||
|
|
8bf410dbca | ||
|
|
c98411ca0e | ||
|
|
fcfb404edf | ||
|
|
a0c8b39f0c | ||
|
|
3a1f2540b1 | ||
|
|
9c680bc59c | ||
|
|
fde95a6a8e | ||
|
|
150a0332f6 | ||
|
|
618c32bdb8 | ||
|
|
ea35850b71 | ||
|
|
87561943f9 | ||
|
|
5fd8d4e847 | ||
|
|
3880f205b6 | ||
|
|
fcbc4cb792 | ||
|
|
f5837fdbd2 | ||
|
|
eb6d0d7922 | ||
|
|
d3dd9099f6 | ||
|
|
a112b23d07 | ||
|
|
4c591f3827 | ||
|
|
62a28f57f6 | ||
|
|
ef689b0857 | ||
|
|
aa903da042 | ||
|
|
9587d8832d | ||
|
|
f125be1347 | ||
|
|
269301852a | ||
|
|
8e5dcd57ce | ||
|
|
920273197d | ||
|
|
1be2e9fbb2 | ||
|
|
7c93eededf | ||
|
|
e0b414d8e9 | ||
|
|
1b17031523 | ||
|
|
2d76c3e84c | ||
|
|
a56c7c29a6 | ||
|
|
54f2e26f93 | ||
|
|
842f5bed81 | ||
|
|
1e5c656e51 | ||
|
|
539e01f97d | ||
|
|
503a4881fe | ||
|
|
03dd4370b9 | ||
|
|
04181fdda7 | ||
|
|
316fd65ab8 | ||
|
|
e8cbd91baf | ||
|
|
157af15a2a | ||
|
|
cba80aeac9 | ||
|
|
efa9e8aa3b | ||
|
|
d294341926 | ||
|
|
b930ecdcd0 | ||
|
|
35dce661bf | ||
|
|
100242f0a6 | ||
|
|
d695f71d36 | ||
|
|
5d60b7a67c | ||
|
|
5d5d89dab9 | ||
|
|
35a625e04b | ||
|
|
1a2d3bb441 | ||
|
|
2e3ac02afb | ||
|
|
1d2bfb4462 | ||
|
|
a5b8a65b7d | ||
|
|
dc320f2e6d | ||
|
|
acbca83553 | ||
|
|
cb26c5dfc8 | ||
|
|
b5c4174700 | ||
|
|
3e37d11c6a | ||
|
|
36e83a9d01 | ||
|
|
efcd759869 | ||
|
|
9f8830b341 | ||
|
|
7c81396ec5 | ||
|
|
9b50665375 | ||
|
|
83795581e6 | ||
|
|
af51524109 | ||
|
|
749f2bff02 | ||
|
|
cd8f8bb90c | ||
|
|
3a031bbbaf | ||
|
|
f4b1acb757 | ||
|
|
3e55e04fbd | ||
|
|
1822a62e14 | ||
|
|
2f991b5557 | ||
|
|
827efbd783 | ||
|
|
9573a68bfe | ||
|
|
738aa12243 | ||
|
|
f25de4b4ce | ||
|
|
c75d265686 | ||
|
|
c691d6028b | ||
|
|
c073512b17 | ||
|
|
dc827df95c | ||
|
|
b8db54d198 | ||
|
|
dc58b42f68 | ||
|
|
9eca4673cd | ||
|
|
ac41a55d4f | ||
|
|
741c05a519 | ||
|
|
ac2d78f7a5 | ||
|
|
a4a93c5f4a | ||
|
|
05e507278e | ||
|
|
cab1641b33 | ||
|
|
9e99edf6f6 | ||
|
|
2841e086df | ||
|
|
00ae511076 | ||
|
|
f97d8ffdfd | ||
|
|
34f337290c | ||
|
|
8159838fc3 | ||
|
|
60f2494eae | ||
|
|
303f999b74 | ||
|
|
f83daf154f | ||
|
|
38e1db9c53 | ||
|
|
d71c929ba8 | ||
|
|
4865b742c7 | ||
|
|
1246549f4b | ||
|
|
55ee75fed1 | ||
|
|
846a4796e2 | ||
|
|
545c464f4f | ||
|
|
fabf0c28e3 | ||
|
|
e219f7e07c | ||
|
|
830ce14c65 | ||
|
|
c1bfa563a3 | ||
|
|
bf4fc9a7aa | ||
|
|
4312b5ad65 | ||
|
|
6fc078090a | ||
|
|
a4548abc19 | ||
|
|
1a39066b45 | ||
|
|
9cf1435130 | ||
|
|
e895bda80b | ||
|
|
882f18ac29 | ||
|
|
ebfe5eee6d | ||
|
|
afac08dc01 | ||
|
|
a0ac379b3c | ||
|
|
f5cb393138 | ||
|
|
b93f8e5e95 | ||
|
|
38c8e1b84f | ||
|
|
275c879e62 | ||
|
|
cde632241b | ||
|
|
ea1e47e579 | ||
|
|
07b399cd80 | ||
|
|
ed20cf3df4 | ||
|
|
8a4e32f05e | ||
|
|
5a8b4fb4ce | ||
|
|
65034e523f | ||
|
|
0c6637406a | ||
|
|
4b0d022db1 | ||
|
|
7a2b3c2d2e | ||
|
|
205eae785e | ||
|
|
e0223e0c5c | ||
|
|
d611391bea | ||
|
|
0c547353cd | ||
|
|
5ce859f267 | ||
|
|
8e0da93476 | ||
|
|
3f7c22cfe0 | ||
|
|
55ee575c9c | ||
|
|
de6627ab32 | ||
|
|
9b586ae709 | ||
|
|
64a43d3d40 | ||
|
|
679957b48c | ||
|
|
0399763888 | ||
|
|
3e3780028b | ||
|
|
1c9d19fc76 | ||
|
|
ecdb1e9fee | ||
|
|
b9e5126ab4 | ||
|
|
a34fddc866 | ||
|
|
9b3bfd3d1c | ||
|
|
74000551e6 | ||
|
|
325ee16d39 | ||
|
|
37761bf6e7 | ||
|
|
ad57ce7790 | ||
|
|
f1f907ee33 | ||
|
|
e3f20459dd | ||
|
|
da567a9d6c | ||
|
|
01a4fb57df | ||
|
|
01d52143e8 | ||
|
|
35461ba926 | ||
|
|
b9f49cad45 | ||
|
|
d7487c6d5c | ||
|
|
15122387f4 | ||
|
|
931064a695 | ||
|
|
49c8a5a375 | ||
|
|
ab9f9701d8 | ||
|
|
da4abcfce2 | ||
|
|
4149549c88 | ||
|
|
fa8cd4a2f0 | ||
|
|
423dc7a6bf | ||
|
|
557954a259 | ||
|
|
38b72c8c72 | ||
|
|
3f395e816f | ||
|
|
6dcc77243a | ||
|
|
110ffe52b7 | ||
|
|
2fab51ea9b | ||
|
|
425f00860f | ||
|
|
d91a3080d5 | ||
|
|
0485e5192b | ||
|
|
7531c83379 | ||
|
|
3aca96148d | ||
|
|
ba401877e8 | ||
|
|
77748a951b | ||
|
|
4692526e48 | ||
|
|
eb4b8555c2 | ||
|
|
6a12ca78d1 | ||
|
|
2ed53f8e50 | ||
|
|
615e3bfa3d | ||
|
|
e406bdbc2c | ||
|
|
7fd402aade | ||
|
|
e8522a4a6d | ||
|
|
eb872ddbf6 | ||
|
|
1b1c1bdd5e | ||
|
|
b2f950ebe4 | ||
|
|
8501dcc34e | ||
|
|
9fd1d76fd8 | ||
|
|
e0f8d1ea9e | ||
|
|
be886b108c | ||
|
|
cbcddfbcd1 | ||
|
|
7d531d18d4 | ||
|
|
eee8ed70e7 | ||
|
|
4f425fb99a | ||
|
|
9395a456f0 | ||
|
|
a8256b461a | ||
|
|
3494bce2b8 | ||
|
|
b6b3e83c1e | ||
|
|
7d47fcf4e9 | ||
|
|
b857c9e4d9 | ||
|
|
25de4326d2 | ||
|
|
257f4f2b5b | ||
|
|
e4a6bd0a1f | ||
|
|
4ab0fbf36b | ||
|
|
d1379935b7 | ||
|
|
64c5fe3157 | ||
|
|
ddf977f665 | ||
|
|
a3ee2fb69c | ||
|
|
143eafa24a | ||
|
|
67fff17f06 | ||
|
|
5ada8e529c | ||
|
|
22a3654dfd | ||
|
|
9a94c650da | ||
|
|
ddaeb054d0 | ||
|
|
0a3611b94a | ||
|
|
0db439c80d | ||
|
|
99103bc419 | ||
|
|
fcf9f30af0 | ||
|
|
beab927f64 | ||
|
|
91f2f34cd3 | ||
|
|
a44f61b507 | ||
|
|
fc567d2fbb | ||
|
|
12e0c7fd7b | ||
|
|
7e77e56e7f | ||
|
|
46706c633e | ||
|
|
ea8ab582eb | ||
|
|
73e4f22256 | ||
|
|
a173e66a59 | ||
|
|
cad75408c3 | ||
|
|
cd93d9c697 | ||
|
|
ce869950c3 | ||
|
|
55bd417105 | ||
|
|
ed4592ae0c | ||
|
|
7c30204dd7 | ||
|
|
b52d61b6db | ||
|
|
1bed90b804 | ||
|
|
5eabf6869d | ||
|
|
4ee5d348bf | ||
|
|
252a7207f6 | ||
|
|
abc2dc8437 | ||
|
|
27fe267181 | ||
|
|
5f184693ca | ||
|
|
6087668e26 | ||
|
|
830a385bda | ||
|
|
c92c19d9b2 | ||
|
|
8fe6f5c141 | ||
|
|
39ab8b00c4 | ||
|
|
d3164d3e0d | ||
|
|
e6af0e3845 | ||
|
|
0eabf1f7c4 | ||
|
|
91111c7d74 | ||
|
|
7397f4c381 | ||
|
|
02ffb727d5 | ||
|
|
4ba769a49e | ||
|
|
ad71804b70 | ||
|
|
1bf9696a27 | ||
|
|
b5e0055876 | ||
|
|
6a3534da76 | ||
|
|
105a5f2bdc | ||
|
|
45543e9669 | ||
|
|
36bc96f192 | ||
|
|
516b345807 | ||
|
|
4a6d542965 | ||
|
|
fb9bbafd6e | ||
|
|
0e44243e55 | ||
|
|
65b451b465 | ||
|
|
abff1136f9 | ||
|
|
3630172723 | ||
|
|
d241ee3cde | ||
|
|
6994f6bc1d | ||
|
|
25147f84ec | ||
|
|
c02523ee51 | ||
|
|
8a4ffc5e0c | ||
|
|
5b3445a5b5 | ||
|
|
b05bff0fa0 | ||
|
|
51f0f943f7 | ||
|
|
11b42470b6 | ||
|
|
71e5e32206 | ||
|
|
6f44c8ba17 | ||
|
|
45a6564e17 | ||
|
|
65641c1256 | ||
|
|
930e686cbd | ||
|
|
08309a6f04 | ||
|
|
10b4c3da05 | ||
|
|
6257f6ffb7 | ||
|
|
80f8a524ef | ||
|
|
6f3fc2fcab | ||
|
|
6a8b2b6338 | ||
|
|
fd1c6d718e | ||
|
|
5f2e683b6b | ||
|
|
5f4283ca3f | ||
|
|
9df03a73d9 | ||
|
|
6d813ebb2f | ||
|
|
f961413e94 | ||
|
|
17693b90b5 | ||
|
|
c11dd8bcae | ||
|
|
ef3913d91f | ||
|
|
569b7e78fe | ||
|
|
32b75250dc | ||
|
|
743fcbcfc1 | ||
|
|
748db37a1b | ||
|
|
a75650c045 | ||
|
|
ce9c469acc | ||
|
|
dc8958bee1 | ||
|
|
e405aab310 | ||
|
|
846c3e36cc | ||
|
|
b42a444a67 | ||
|
|
4771f890cb | ||
|
|
991ff88767 | ||
|
|
4fa7155fc3 | ||
|
|
9e49652f80 | ||
|
|
931865b61f | ||
|
|
f0088b256e | ||
|
|
a047d36bf1 | ||
|
|
ebcc814abf | ||
|
|
b83f3a291b | ||
|
|
e4ff6ed732 | ||
|
|
a67b084b52 | ||
|
|
dd3f38fe75 | ||
|
|
3184deb00e | ||
|
|
2847721584 | ||
|
|
894a298f45 | ||
|
|
17610663c1 | ||
|
|
12cf9da8fc | ||
|
|
c47e46263c | ||
|
|
e040a10096 | ||
|
|
ce6c43fb62 | ||
|
|
faf025d2c4 | ||
|
|
8e50742372 | ||
|
|
b0533f2f9b | ||
|
|
fd0d5813fb | ||
|
|
8cd79cdc20 | ||
|
|
d3abe4db3e | ||
|
|
71765f3542 | ||
|
|
1e326fe414 | ||
|
|
f06b3e8af0 | ||
|
|
4edd729850 | ||
|
|
8412aa19fb | ||
|
|
5f0eb73927 | ||
|
|
fd8411b475 | ||
|
|
f312f6028d | ||
|
|
f401b0f635 | ||
|
|
e22c89a0e9 | ||
|
|
e5691d0e98 | ||
|
|
4266029e84 | ||
|
|
849856df26 | ||
|
|
e398fc0b38 | ||
|
|
514c4106b1 | ||
|
|
3cf89aca10 | ||
|
|
9bf8b615dc | ||
|
|
cb8dd3bc99 | ||
|
|
1cd9caef4a | ||
|
|
1025829123 | ||
|
|
019a931b99 | ||
|
|
8398193a51 | ||
|
|
f560365ded | ||
|
|
ebc2902450 | ||
|
|
dfd9f7b066 | ||
|
|
cb98b6723f | ||
|
|
dcf7d44d72 | ||
|
|
369c460837 | ||
|
|
e99ff005d6 | ||
|
|
c1d6e98349 | ||
|
|
497a4bbeb9 | ||
|
|
f47c806ebd | ||
|
|
bb2a0eb642 | ||
|
|
28fd1917ec | ||
|
|
cf0f7482f1 | ||
|
|
2475eadab9 | ||
|
|
97cf2e6372 | ||
|
|
2c12ce3edf | ||
|
|
daf343c5fd | ||
|
|
31eacad5fb | ||
|
|
54e147ce8e | ||
|
|
b217ac7ae7 | ||
|
|
0727a52b8a | ||
|
|
2c24017e96 | ||
|
|
0f5d37fc7c | ||
|
|
2b3e2039b8 | ||
|
|
748518f567 | ||
|
|
91980d91e8 | ||
|
|
630f2fbf4e | ||
|
|
394d7d73ed | ||
|
|
49a437b103 | ||
|
|
15b38241da | ||
|
|
e02594ba83 | ||
|
|
061fbfff65 | ||
|
|
a5aa6d74b5 | ||
|
|
8a9e150f64 | ||
|
|
44a68bab71 | ||
|
|
facbe08e20 | ||
|
|
728bb76a43 | ||
|
|
bdd9ff796a | ||
|
|
77a46a4ef6 | ||
|
|
a4225769f6 | ||
|
|
cf74187be1 | ||
|
|
454a05986c | ||
|
|
fa2fcf4f08 | ||
|
|
44d7f18428 | ||
|
|
da60b4a097 | ||
|
|
40d460b458 | ||
|
|
86652a8f1f | ||
|
|
e4caf4169f | ||
|
|
5cd7538ed5 | ||
|
|
bfbe19b49b | ||
|
|
45f8d2b1c8 | ||
|
|
8006d7663c | ||
|
|
17f875863c | ||
|
|
4a8ad3db1e | ||
|
|
71261fc767 | ||
|
|
58d1c94b79 | ||
|
|
761b974aa5 | ||
|
|
bd96a29200 | ||
|
|
1427dd989f | ||
|
|
b4281aaf83 | ||
|
|
418821d8d3 | ||
|
|
22968495fd | ||
|
|
ab3de1871c | ||
|
|
f691da53d7 | ||
|
|
25887c0595 | ||
|
|
644be2d59b | ||
|
|
3f880ef304 | ||
|
|
851cb28714 | ||
|
|
92b7439969 | ||
|
|
257e886d87 | ||
|
|
89c6964e30 | ||
|
|
bb6356cfa8 | ||
|
|
8728865b97 | ||
|
|
62bfc6f7b0 | ||
|
|
54ad7db2c1 | ||
|
|
ccbbebccef | ||
|
|
0a5b707fec | ||
|
|
fb3473459d | ||
|
|
d6929e5cf9 | ||
|
|
3207b69874 | ||
|
|
bd317858bf | ||
|
|
9d4c26fd29 | ||
|
|
f0d0550251 | ||
|
|
f132eacb83 | ||
|
|
b75e3a7848 | ||
|
|
79a7e60cfc | ||
|
|
673c660d26 | ||
|
|
8f03899302 | ||
|
|
8ccd4b5045 | ||
|
|
5b3207bc24 | ||
|
|
57314c56c8 | ||
|
|
4cd1e0a4a5 | ||
|
|
83e9a2bbfb | ||
|
|
46d2b7730e | ||
|
|
b7c2b5c294 | ||
|
|
7b9d140e74 | ||
|
|
0c6850d498 | ||
|
|
ffe02bf210 | ||
|
|
e78dd305f3 | ||
|
|
e12c83faf1 | ||
|
|
05102d3842 | ||
|
|
18767c54ce | ||
|
|
09dc35228f | ||
|
|
312c215813 | ||
|
|
95583dbe2c | ||
|
|
cf20b22404 | ||
|
|
73ba5a591d | ||
|
|
82ebeacea9 | ||
|
|
c0c71c3967 | ||
|
|
aa5a87a1fc | ||
|
|
302faa193a | ||
|
|
9fe3eeb823 | ||
|
|
e0f7ce5de9 | ||
|
|
76eecedfb5 | ||
|
|
8f216a2791 | ||
|
|
75e3d826a0 | ||
|
|
a57b8f6081 | ||
|
|
1cac34d2a0 | ||
|
|
e47bdd043e | ||
|
|
521c71733a | ||
|
|
963273853f | ||
|
|
c0c26a5a20 | ||
|
|
b7533457de | ||
|
|
388a7ceb16 | ||
|
|
8391365d05 | ||
|
|
6a7c3b472a | ||
|
|
f9efe44e1d | ||
|
|
7817ed2f7e | ||
|
|
79c71bd5d9 | ||
|
|
0ddb013e94 | ||
|
|
3b2e75db1d | ||
|
|
3c7fd0fa35 | ||
|
|
1e349214fe | ||
|
|
e689cef201 | ||
|
|
f58d9e49d8 | ||
|
|
1b8d501208 | ||
|
|
1842bb7105 | ||
|
|
d53706bd5d | ||
|
|
b0e01e13bf | ||
|
|
5587429475 | ||
|
|
1e6e843e05 | ||
|
|
4972418dc5 | ||
|
|
1f39ed9d4e |
@@ -1,7 +1,7 @@
|
||||
FROM python:3.10-alpine3.18
|
||||
FROM python:3.13-alpine3.22
|
||||
|
||||
#Install all dependencies.
|
||||
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git yarn
|
||||
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git yarn libgcc libstdc++ nginx tini envsubst nodejs npm
|
||||
|
||||
#Print all logs without buffering it.
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
@@ -19,8 +19,10 @@ 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 && \
|
||||
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 xmlsec-dev xmlsec build-base g++ curl rust && \
|
||||
python -m pip install --upgrade pip && \
|
||||
pip debug -v && \
|
||||
pip install wheel==0.45.1 && \
|
||||
pip install setuptools_rust==1.10.2 && \
|
||||
pip install -r /tmp/pip-tmp/requirements.txt --no-cache-dir &&\
|
||||
apk --purge del .build-deps
|
||||
@@ -20,6 +20,10 @@
|
||||
"ms-python.python"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"containerEnv": {
|
||||
"CSRF_TRUSTED_ORIGINS": "http://localhost:8000,http://localhost:8080"
|
||||
}
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
|
||||
@@ -29,3 +29,4 @@ vue/babel.config*
|
||||
vue/package.json
|
||||
vue/tsconfig.json
|
||||
vue/src/utils/openapi
|
||||
venv
|
||||
@@ -6,6 +6,9 @@
|
||||
# random secret key, use for example `base64 /dev/urandom | head -c50` to generate one
|
||||
SECRET_KEY=
|
||||
|
||||
# your default timezone See https://timezonedb.com/time-zones for a list of timezones
|
||||
TZ=Europe/Berlin
|
||||
|
||||
# allowed hosts (see documentation), should be set to your hostname(s) but might be * (default) for some proxies/providers
|
||||
# ALLOWED_HOSTS=recipes.mydomain.com
|
||||
|
||||
|
||||
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -16,7 +16,7 @@ updates:
|
||||
interval: "monthly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/vue/"
|
||||
directory: "/vue3/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
|
||||
|
||||
110
.github/workflows/build-docker-open-data.yml
vendored
110
.github/workflows/build-docker-open-data.yml
vendored
@@ -1,110 +0,0 @@
|
||||
name: Build Docker Container with open data plugin installed
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build-container:
|
||||
name: Build ${{ matrix.name }} Container
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository_owner == 'TandoorRecipes'
|
||||
continue-on-error: ${{ matrix.continue-on-error }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
# Standard build config
|
||||
- name: Standard
|
||||
dockerfile: Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
suffix: ""
|
||||
continue-on-error: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get version number
|
||||
id: get_version
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" = refs/tags/* ]]; then
|
||||
echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
|
||||
elif [[ "$GITHUB_REF" = refs/heads/beta ]]; then
|
||||
echo VERSION=beta >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo VERSION=develop >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# clone open data plugin
|
||||
- name: clone open data plugin repo
|
||||
uses: actions/checkout@master
|
||||
with:
|
||||
repository: TandoorRecipes/open_data_plugin
|
||||
ref: master
|
||||
path: ./recipes/plugins/open_data_plugin
|
||||
|
||||
# Build Vue frontend
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: yarn
|
||||
cache-dependency-path: vue/yarn.lock
|
||||
- name: Install dependencies
|
||||
working-directory: ./vue
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build dependencies
|
||||
working-directory: ./vue
|
||||
run: yarn build
|
||||
|
||||
- name: Setup Open Data Plugin Links
|
||||
working-directory: ./recipes/plugins/open_data_plugin
|
||||
run: python setup_repo.py
|
||||
|
||||
- name: Build Open Data Frontend
|
||||
working-directory: ./recipes/plugins/open_data_plugin/vue
|
||||
run: yarn build
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
if: github.secret_source == 'Actions'
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
if: github.secret_source == 'Actions'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
vabene1111/recipes
|
||||
ghcr.io/TandoorRecipes/recipes
|
||||
flavor: |
|
||||
latest=false
|
||||
suffix=${{ matrix.suffix }}
|
||||
tags: |
|
||||
type=raw,value=latest,suffix=-open-data-plugin,enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
type=semver,suffix=-open-data-plugin,pattern={{version}}
|
||||
type=semver,suffix=-open-data-plugin,pattern={{major}}.{{minor}}
|
||||
type=semver,suffix=-open-data-plugin,pattern={{major}}
|
||||
type=ref,suffix=-open-data-plugin,event=branch
|
||||
- name: Build and Push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
pull: true
|
||||
push: ${{ github.secret_source == 'Actions' }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
22
.github/workflows/build-docker.yml
vendored
22
.github/workflows/build-docker.yml
vendored
@@ -17,11 +17,11 @@ jobs:
|
||||
# Standard build config
|
||||
- name: Standard
|
||||
dockerfile: Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
platforms: linux/amd64,linux/arm64
|
||||
suffix: ""
|
||||
continue-on-error: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Get version number
|
||||
id: get_version
|
||||
@@ -34,17 +34,17 @@ jobs:
|
||||
echo VERSION=develop >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Build Vue frontend
|
||||
- uses: actions/setup-node@v4
|
||||
# Build Vue 3 frontend
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '22'
|
||||
cache: yarn
|
||||
cache-dependency-path: vue/yarn.lock
|
||||
cache-dependency-path: vue3/yarn.lock
|
||||
- name: Install dependencies
|
||||
working-directory: ./vue
|
||||
working-directory: ./vue3
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build dependencies
|
||||
working-directory: ./vue
|
||||
working-directory: ./vue3
|
||||
run: yarn build
|
||||
|
||||
- name: Set up QEMU
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
- name: Discord notification
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@0.3.2
|
||||
uses: Ilshidur/action-discord@0.4.0
|
||||
with:
|
||||
args: '🚀 Version {{ VERSION }} of tandoor has been released 🥳 Check it out https://github.com/vabene1111/recipes/releases/tag/{{ VERSION }}'
|
||||
|
||||
@@ -121,6 +121,6 @@ jobs:
|
||||
- name: Discord notification
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_BETA_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@0.3.2
|
||||
uses: Ilshidur/action-discord@0.4.0
|
||||
with:
|
||||
args: '🚀 The BETA Image has been updated! 🥳'
|
||||
args: '🚀 The Tandoor 2 Image has been updated! 🥳'
|
||||
|
||||
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@@ -9,19 +9,18 @@ jobs:
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
node-version: ["18"]
|
||||
|
||||
python-version: ["3.12"]
|
||||
node-version: ["22"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: awalsh128/cache-apt-pkgs-action@v1.4.2
|
||||
- uses: actions/checkout@v5
|
||||
- uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
||||
with:
|
||||
packages: libsasl2-dev python3-dev libldap2-dev libssl-dev
|
||||
packages: libsasl2-dev python3-dev libxml2-dev libxmlsec1-dev libxslt-dev libxmlsec1-openssl libxslt-dev libldap2-dev libssl-dev gcc musl-dev postgresql-dev zlib-dev jpeg-dev libwebp-dev openssl-dev libffi-dev cargo openldap-dev python3-dev xmlsec-dev xmlsec build-base g++ curl
|
||||
version: 1.0
|
||||
|
||||
# Setup python & dependencies
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "pip"
|
||||
@@ -37,42 +36,39 @@ jobs:
|
||||
with:
|
||||
path: |
|
||||
./cookbook/static
|
||||
./vue/webpack-stats.json
|
||||
./staticfiles
|
||||
key: |
|
||||
${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.node-version }}-collectstatic-${{ hashFiles('**/*.css', '**/*.js', 'vue/src/*') }}
|
||||
${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.node-version }}-collectstatic-${{ hashFiles('**/*.css', '**/*.js', 'vue3/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
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "yarn"
|
||||
cache-dependency-path: ./vue/yarn.lock
|
||||
cache-dependency-path: ./vue3/yarn.lock
|
||||
|
||||
- name: Install Vue dependencies
|
||||
if: steps.django_cache.outputs.cache-hit != 'true'
|
||||
working-directory: ./vue
|
||||
working-directory: ./vue3
|
||||
run: yarn install
|
||||
|
||||
- name: Build Vue dependencies
|
||||
if: steps.django_cache.outputs.cache-hit != 'true'
|
||||
working-directory: ./vue
|
||||
working-directory: ./vue3
|
||||
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/*') }}
|
||||
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
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@v3
|
||||
uses: github/codeql-action/init@v4
|
||||
# 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@v3
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
languages: javascript, python
|
||||
|
||||
4
.github/workflows/docs.yml
vendored
4
.github/workflows/docs.yml
vendored
@@ -12,8 +12,8 @@ jobs:
|
||||
if: github.repository_owner == 'TandoorRecipes' && ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.x
|
||||
- run: pip install mkdocs-material mkdocs-include-markdown-plugin
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -75,8 +75,10 @@ cookbook/static/vue
|
||||
vue/webpack-stats.json
|
||||
/docker-compose.override.yml
|
||||
vue/node_modules
|
||||
plugins
|
||||
vue3/node_modules
|
||||
/recipes/plugins
|
||||
vetur.config.js
|
||||
cookbook/static/vue
|
||||
vue/webpack-stats.json
|
||||
cookbook/templates/sw.js
|
||||
vue/.yarn
|
||||
vue3/.vite
|
||||
@@ -85,4 +87,8 @@ vue3/.vite
|
||||
vetur.config.js
|
||||
venv/
|
||||
.idea/easy-i18n.xml
|
||||
cookbook/static/vue3
|
||||
cookbook/static/vue3
|
||||
vue3/node_modules
|
||||
cookbook/tests/other/docs/reports/tests/tests.html
|
||||
cookbook/tests/other/docs/reports/tests/pytest.xml
|
||||
vue3/src/plugins
|
||||
|
||||
6
.idea/prettier.xml
generated
Normal file
6
.idea/prettier.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="PrettierConfiguration">
|
||||
<option name="myConfigurationMode" value="AUTOMATIC" />
|
||||
</component>
|
||||
</project>
|
||||
2
.idea/recipes.iml
generated
2
.idea/recipes.iml
generated
@@ -18,7 +18,7 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/staticfiles" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="jdk" jdkName="Python 3.12 (recipes)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="TemplatesService">
|
||||
|
||||
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
@@ -2,5 +2,7 @@
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/recipes/plugins/enterprise_plugin" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/recipes/plugins/open_data_plugin" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
62
.idea/watcherTasks.xml
generated
62
.idea/watcherTasks.xml
generated
@@ -5,7 +5,7 @@
|
||||
<option name="arguments" value="-m flake8 $FilePath$ --config $ContentRoot$\.flake8" />
|
||||
<option name="checkSyntaxErrors" value="true" />
|
||||
<option name="description" />
|
||||
<option name="exitCodeBehavior" value="ALWAYS" />
|
||||
<option name="exitCodeBehavior" value="NEVER" />
|
||||
<option name="fileExtension" value="py" />
|
||||
<option name="immediateSync" value="false" />
|
||||
<option name="name" value="Flake8 Watcher" />
|
||||
@@ -27,5 +27,65 @@
|
||||
<option name="workingDir" value="" />
|
||||
<envs />
|
||||
</TaskOptions>
|
||||
<TaskOptions isEnabled="false">
|
||||
<option name="arguments" value="-m isort $FilePath$" />
|
||||
<option name="checkSyntaxErrors" value="true" />
|
||||
<option name="description" />
|
||||
<option name="exitCodeBehavior" value="ERROR" />
|
||||
<option name="fileExtension" value="py" />
|
||||
<option name="immediateSync" value="false" />
|
||||
<option name="name" value="isort Watcher" />
|
||||
<option name="output" value="$FilePath$" />
|
||||
<option name="outputFilters">
|
||||
<array />
|
||||
</option>
|
||||
<option name="outputFromStdout" value="false" />
|
||||
<option name="program" value="$PyInterpreterDirectory$/python" />
|
||||
<option name="runOnExternalChanges" value="false" />
|
||||
<option name="scopeName" value="Project Files" />
|
||||
<option name="trackOnlyRoot" value="false" />
|
||||
<option name="workingDir" value="" />
|
||||
<envs />
|
||||
</TaskOptions>
|
||||
<TaskOptions isEnabled="false">
|
||||
<option name="arguments" value="-m yapf -i $FilePath$" />
|
||||
<option name="checkSyntaxErrors" value="true" />
|
||||
<option name="description" />
|
||||
<option name="exitCodeBehavior" value="NEVER" />
|
||||
<option name="fileExtension" value="py" />
|
||||
<option name="immediateSync" value="false" />
|
||||
<option name="name" value="YAPF" />
|
||||
<option name="output" value="$FilePath$" />
|
||||
<option name="outputFilters">
|
||||
<array />
|
||||
</option>
|
||||
<option name="outputFromStdout" value="false" />
|
||||
<option name="program" value="$PyInterpreterDirectory$/python" />
|
||||
<option name="runOnExternalChanges" value="false" />
|
||||
<option name="scopeName" value="Project Files" />
|
||||
<option name="trackOnlyRoot" value="false" />
|
||||
<option name="workingDir" value="" />
|
||||
<envs />
|
||||
</TaskOptions>
|
||||
<TaskOptions isEnabled="false">
|
||||
<option name="arguments" value="--cwd $ProjectFileDir$\vue prettier -w --config $ProjectFileDir$\.prettierrc $FilePath$" />
|
||||
<option name="checkSyntaxErrors" value="true" />
|
||||
<option name="description" />
|
||||
<option name="exitCodeBehavior" value="ERROR" />
|
||||
<option name="fileExtension" value="*" />
|
||||
<option name="immediateSync" value="true" />
|
||||
<option name="name" value="Prettier" />
|
||||
<option name="output" value="" />
|
||||
<option name="outputFilters">
|
||||
<array />
|
||||
</option>
|
||||
<option name="outputFromStdout" value="false" />
|
||||
<option name="program" value="yarn" />
|
||||
<option name="runOnExternalChanges" value="true" />
|
||||
<option name="scopeName" value="Prettier" />
|
||||
<option name="trackOnlyRoot" value="false" />
|
||||
<option name="workingDir" value="" />
|
||||
<envs />
|
||||
</TaskOptions>
|
||||
</component>
|
||||
</project>
|
||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -24,7 +24,7 @@
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
// coverage and pytest can't both be running at the same time
|
||||
"PYTEST_ADDOPTS": "--no-cov"
|
||||
"PYTEST_ADDOPTS": "--no-cov -n 0"
|
||||
},
|
||||
"django": true,
|
||||
"justMyCode": true
|
||||
|
||||
154
.vscode/tasks.json
vendored
154
.vscode/tasks.json
vendored
@@ -1,75 +1,83 @@
|
||||
{
|
||||
"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"],
|
||||
"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"]
|
||||
},
|
||||
{
|
||||
"label": "Run Dev Server",
|
||||
"type": "shell",
|
||||
"dependsOn": ["Setup Dev Server"],
|
||||
"command": "DEBUG=1 python3 manage.py runserver"
|
||||
},
|
||||
{
|
||||
"label": "Yarn Install",
|
||||
"type": "shell",
|
||||
"command": "yarn install --force",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue3"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Generate API",
|
||||
"type": "shell",
|
||||
"command": "openapi-generator-cli generate -g typescript-fetch -i http://127.0.0.1:8000/openapi/",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue3/src/openapi"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Yarn Dev",
|
||||
"type": "shell",
|
||||
"command": "yarn dev",
|
||||
"dependsOn": ["Yarn Install"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Yarn Build",
|
||||
"type": "shell",
|
||||
"command": "yarn build",
|
||||
"dependsOn": ["Yarn Install"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/vue3"
|
||||
},
|
||||
"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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
43
Dockerfile
43
Dockerfile
@@ -1,15 +1,14 @@
|
||||
FROM python:3.12-alpine3.19
|
||||
FROM python:3.13-alpine3.22
|
||||
|
||||
#Install all dependencies.
|
||||
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git
|
||||
RUN apk add --no-cache postgresql-libs postgresql-client gettext zlib libjpeg libwebp libxml2-dev libxslt-dev openldap git libgcc libstdc++ nginx tini envsubst nodejs npm
|
||||
|
||||
#Print all logs without buffering it.
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
ENV DOCKER true
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
DOCKER=true
|
||||
|
||||
#This port will be used by gunicorn.
|
||||
EXPOSE 8080
|
||||
EXPOSE 80 8080
|
||||
|
||||
#Create app dir and install requirements.
|
||||
RUN mkdir /opt/recipes
|
||||
@@ -17,29 +16,33 @@ WORKDIR /opt/recipes
|
||||
|
||||
COPY requirements.txt ./
|
||||
|
||||
RUN \
|
||||
if [ `apk --print-arch` = "armv7" ]; then \
|
||||
printf "[global]\nextra-index-url=https://www.piwheels.org/simple\n" > /etc/pip.conf ; \
|
||||
fi
|
||||
# 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 && \
|
||||
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 xmlsec-dev xmlsec build-base g++ curl rust && \
|
||||
python -m venv venv && \
|
||||
/opt/recipes/venv/bin/python -m pip install --upgrade pip && \
|
||||
venv/bin/pip install wheel==0.42.0 && \
|
||||
venv/bin/pip install setuptools_rust==1.9.0 && \
|
||||
venv/bin/pip debug -v && \
|
||||
venv/bin/pip install wheel==0.45.1 && \
|
||||
venv/bin/pip install setuptools_rust==1.10.2 && \
|
||||
venv/bin/pip install -r requirements.txt --no-cache-dir &&\
|
||||
apk --purge del .build-deps
|
||||
|
||||
#Copy project and execute it.
|
||||
COPY . ./
|
||||
|
||||
HEALTHCHECK --interval=30s \
|
||||
--timeout=5s \
|
||||
--start-period=10s \
|
||||
--retries=3 \
|
||||
CMD [ "/usr/bin/wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8080/openapi" ]
|
||||
# delete default nginx config and link it to tandoors config
|
||||
# create symlinks to access and error log to show them on stdout
|
||||
RUN rm -rf /etc/nginx/http.d && \
|
||||
ln -s /opt/recipes/http.d /etc/nginx/http.d && \
|
||||
ln -sf /dev/stdout /var/log/nginx/access.log && \
|
||||
ln -sf /dev/stderr /var/log/nginx/error.log
|
||||
|
||||
# commented for now https://github.com/TandoorRecipes/recipes/issues/3478
|
||||
#HEALTHCHECK --interval=30s \
|
||||
# --timeout=5s \
|
||||
# --start-period=10s \
|
||||
# --retries=3 \
|
||||
# CMD [ "/usr/bin/wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8080/openapi" ]
|
||||
|
||||
# collect information from git repositories
|
||||
RUN /opt/recipes/venv/bin/python version.py
|
||||
@@ -47,4 +50,4 @@ RUN /opt/recipes/venv/bin/python version.py
|
||||
RUN find . -type d -name ".git" | xargs rm -rf
|
||||
|
||||
RUN chmod +x boot.sh
|
||||
ENTRYPOINT ["/opt/recipes/boot.sh"]
|
||||
ENTRYPOINT ["/sbin/tini", "--", "/opt/recipes/boot.sh"]
|
||||
|
||||
18
README.md
18
README.md
@@ -15,23 +15,26 @@
|
||||
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer"><img src="https://badgen.net/badge/icon/discord?icon=discord&label" ></a>
|
||||
<a href="https://hub.docker.com/r/vabene1111/recipes" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/docker/pulls/vabene1111/recipes" ></a>
|
||||
<a href="https://github.com/vabene1111/recipes/releases/latest" rel="noopener noreferrer"><img src="https://img.shields.io/github/v/release/vabene1111/recipes" ></a>
|
||||
<a href="https://app.tandoor.dev/accounts/login/?demo" rel="noopener noreferrer"><img src="https://img.shields.io/badge/demo-available-success" ></a>
|
||||
<a href="https://app.tandoor.dev/e/demo-auto-login/" rel="noopener noreferrer"><img src="https://img.shields.io/badge/demo-available-success" ></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://tandoor.dev" target="_blank" rel="noopener noreferrer">Website</a> •
|
||||
<a href="https://docs.tandoor.dev/install/docker/" target="_blank" rel="noopener noreferrer">Installation</a> •
|
||||
<a href="https://docs.tandoor.dev/" target="_blank" rel="noopener noreferrer">Docs</a> •
|
||||
<a href="https://app.tandoor.dev/accounts/login/?demo" target="_blank" rel="noopener noreferrer">Demo</a> •
|
||||
<a href="https://app.tandoor.dev/e/demo-auto-login/" target="_blank" rel="noopener noreferrer">Demo</a> •
|
||||
<a href="https://community.tandoor.dev" target="_blank" rel="noopener noreferrer">Community</a> •
|
||||
<a href="https://discord.gg/RhzBrfWgtp" target="_blank" rel="noopener noreferrer">Discord</a>
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
## Core Features
|
||||
|
||||
- 🥗 **Manage your recipes** - Manage your ever growing recipe collection
|
||||
- 📆 **Plan** - multiple meals for each day
|
||||
- 🛒 **Shopping lists** - via the meal plan or straight from recipes
|
||||
- 🪄 **use AI** to recognize images, sort recipe steps, find nutrition facts and more
|
||||
- 📚 **Cookbooks** - collect recipes into books
|
||||
- 👪 **Share and collaborate** on recipes with friends and family
|
||||
|
||||
@@ -61,12 +64,13 @@ a public page.
|
||||
|
||||
Documentation can be found [here](https://docs.tandoor.dev/).
|
||||
|
||||
## Support our work
|
||||
## ❤️ Support our work ❤️
|
||||
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)
|
||||
- **Patron** You can sponsor contributors of this project on Patron: [vabene111](https://www.patreon.com/cw/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).
|
||||
|
||||
@@ -81,13 +85,13 @@ Share some information on how you use Tandoor to help me improve the application
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><a href="https://discord.gg/RhzBrfWgtp">Discord</a></td>
|
||||
<td>We have a public Discord server that anyone can join. This is where all our developers and contributors hang out and where we make announcements</td>
|
||||
<td><a href="https://community.tandoor.dev">Community</a></td>
|
||||
<td>Get support, share best practices, discuss feature ideas, and meet other Tandoor users.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><a href="https://twitter.com/TandoorRecipes">Twitter</a></td>
|
||||
<td>You can follow our Twitter account to get updates on new features or releases</td>
|
||||
<td><a href="https://discord.gg/RhzBrfWgtp">Discord</a></td>
|
||||
<td>We have a public Discord server that anyone can join. This is where all our developers and contributors hang out and where we make announcements</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
44
boot.sh
Normal file → Executable file
44
boot.sh
Normal file → Executable file
@@ -1,23 +1,30 @@
|
||||
#!/bin/sh
|
||||
source venv/bin/activate
|
||||
|
||||
TANDOOR_PORT="${TANDOOR_PORT:-8080}"
|
||||
# these are envsubst in the nginx config, make sure they default to something sensible when unset
|
||||
export TANDOOR_PORT="${TANDOOR_PORT:-80}"
|
||||
export MEDIA_ROOT=${MEDIA_ROOT:-/opt/recipes/mediafiles};
|
||||
export STATIC_ROOT=${STATIC_ROOT:-/opt/recipes/staticfiles};
|
||||
|
||||
GUNICORN_WORKERS="${GUNICORN_WORKERS:-3}"
|
||||
GUNICORN_THREADS="${GUNICORN_THREADS:-2}"
|
||||
GUNICORN_LOG_LEVEL="${GUNICORN_LOG_LEVEL:-'info'}"
|
||||
NGINX_CONF_FILE=/opt/recipes/nginx/conf.d/Recipes.conf
|
||||
|
||||
PLUGINS_BUILD="${PLUGINS_BUILD:-0}"
|
||||
|
||||
display_warning() {
|
||||
echo "[WARNING]"
|
||||
echo -e "$1"
|
||||
}
|
||||
|
||||
echo "Checking configuration..."
|
||||
# prepare nginx config
|
||||
envsubst '$MEDIA_ROOT $STATIC_ROOT $TANDOOR_PORT' < /opt/recipes/http.d/Recipes.conf.template > /opt/recipes/http.d/Recipes.conf
|
||||
|
||||
# Nginx config file must exist if gunicorn is not active
|
||||
if [ ! -f "$NGINX_CONF_FILE" ] && [ $GUNICORN_MEDIA -eq 0 ]; then
|
||||
display_warning "Nginx configuration file could not be found at the default location!\nPath: ${NGINX_CONF_FILE}"
|
||||
fi
|
||||
# start nginx early to display error pages
|
||||
echo "Starting nginx"
|
||||
nginx
|
||||
|
||||
echo "Checking configuration..."
|
||||
|
||||
# SECRET_KEY (or a valid file at SECRET_KEY_FILE) must be set in .env file
|
||||
|
||||
@@ -41,6 +48,9 @@ if [ -f "${SOCIALACCOUNT_PROVIDERS_FILE}" ]; then
|
||||
export SOCIALACCOUNT_PROVIDERS=$(cat "$SOCIALACCOUNT_PROVIDERS_FILE")
|
||||
fi
|
||||
|
||||
if [ -f "${S3_SECRET_ACCESS_KEY_FILE}" ]; then
|
||||
export S3_SECRET_ACCESS_KEY=$(cat "$S3_SECRET_ACCESS_KEY_FILE")
|
||||
fi
|
||||
|
||||
echo "Waiting for database to be ready..."
|
||||
|
||||
@@ -76,23 +86,23 @@ echo "Database is ready"
|
||||
|
||||
echo "Migrating database"
|
||||
|
||||
|
||||
python manage.py migrate
|
||||
|
||||
if [ "${PLUGINS_BUILD}" -eq 1 ]; then
|
||||
echo "Running yarn build at startup because PLUGINS_BUILD is enabled"
|
||||
python plugin.py
|
||||
fi
|
||||
|
||||
echo "Collecting static files, this may take a while..."
|
||||
|
||||
python manage.py collectstatic_js_reverse
|
||||
python manage.py collectstatic --noinput
|
||||
python manage.py collectstatic --noinput --clear
|
||||
|
||||
echo "Done"
|
||||
|
||||
chmod -R 755 /opt/recipes/mediafiles
|
||||
chmod -R 755 ${MEDIA_ROOT:-/opt/recipes/mediafiles}
|
||||
|
||||
ipv6_disable=$(cat /sys/module/ipv6/parameters/disable)
|
||||
|
||||
# Check if IPv6 is enabled, only then run gunicorn with ipv6 support
|
||||
if [ "$ipv6_disable" -eq 0 ]; then
|
||||
exec gunicorn -b "[::]:$TANDOOR_PORT" --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
|
||||
else
|
||||
exec gunicorn -b ":$TANDOOR_PORT" --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
|
||||
fi
|
||||
echo "Starting gunicorn"
|
||||
exec gunicorn --bind unix:/run/tandoor.sock --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --timeout ${GUNICORN_TIMEOUT:-30} --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
|
||||
|
||||
|
||||
@@ -7,16 +7,19 @@ from django.utils import translation
|
||||
from django_scopes import scopes_disabled
|
||||
from treebeard.admin import TreeAdmin
|
||||
from treebeard.forms import movenodeform_factory
|
||||
from allauth.account.decorators import secure_admin_login
|
||||
|
||||
from cookbook.managers import DICTIONARY
|
||||
|
||||
from .models import (BookmarkletImport, Comment, CookLog, Food, ImportLog, Ingredient, InviteLink,
|
||||
from .models import (BookmarkletImport, Comment, CookLog, CustomFilter, Food, ImportLog, Ingredient, InviteLink,
|
||||
Keyword, MealPlan, MealType, NutritionInformation, Property, PropertyType,
|
||||
Recipe, RecipeBook, RecipeBookEntry, RecipeImport, SearchPreference, ShareLink,
|
||||
ShoppingListEntry, ShoppingListRecipe, Space, Step, Storage,
|
||||
Supermarket, SupermarketCategory, SupermarketCategoryRelation, Sync, SyncLog,
|
||||
TelegramBot, Unit, UnitConversion, UserFile, UserPreference, UserSpace,
|
||||
ViewLog, ConnectorConfig)
|
||||
ViewLog, ConnectorConfig, AiProvider, AiLog)
|
||||
|
||||
admin.site.login = secure_admin_login(admin.site.login)
|
||||
|
||||
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
@@ -87,6 +90,20 @@ class SearchPreferenceAdmin(admin.ModelAdmin):
|
||||
admin.site.register(SearchPreference, SearchPreferenceAdmin)
|
||||
|
||||
|
||||
class AiProviderAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'space', 'model_name',)
|
||||
search_fields = ('name', 'space', 'model_name',)
|
||||
|
||||
|
||||
admin.site.register(AiProvider, AiProviderAdmin)
|
||||
|
||||
|
||||
class AiLogAdmin(admin.ModelAdmin):
|
||||
list_display = ('ai_provider', 'function', 'credit_cost', 'created_by', 'created_at',)
|
||||
|
||||
admin.site.register(AiLog, AiLogAdmin)
|
||||
|
||||
|
||||
class StorageAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'method')
|
||||
search_fields = ('name',)
|
||||
@@ -103,6 +120,13 @@ class ConnectorConfigAdmin(admin.ModelAdmin):
|
||||
admin.site.register(ConnectorConfig, ConnectorConfigAdmin)
|
||||
|
||||
|
||||
class CustomFilterAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'type', 'name')
|
||||
|
||||
|
||||
admin.site.register(CustomFilter, CustomFilterAdmin)
|
||||
|
||||
|
||||
class SyncAdmin(admin.ModelAdmin):
|
||||
list_display = ('storage', 'path', 'active', 'last_checked')
|
||||
search_fields = ('storage__name', 'path')
|
||||
|
||||
@@ -1,6 +1,43 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from cookbook.models import ShoppingListEntry, Space, ConnectorConfig
|
||||
from cookbook.models import ShoppingListEntry, User, ConnectorConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserDTO:
|
||||
username: str
|
||||
first_name: Optional[str]
|
||||
|
||||
@staticmethod
|
||||
def create_from_user(instance: User) -> 'UserDTO':
|
||||
return UserDTO(
|
||||
username=instance.username,
|
||||
first_name=instance.first_name if instance.first_name else None
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShoppingListEntryDTO:
|
||||
food_name: str
|
||||
amount: Optional[float]
|
||||
base_unit: Optional[str]
|
||||
unit_name: Optional[str]
|
||||
created_by: UserDTO
|
||||
|
||||
@staticmethod
|
||||
def try_create_from_entry(instance: ShoppingListEntry) -> Optional['ShoppingListEntryDTO']:
|
||||
if instance.food is None or instance.created_by is None:
|
||||
return None
|
||||
|
||||
return ShoppingListEntryDTO(
|
||||
food_name=instance.food.name,
|
||||
amount=instance.amount if instance.amount else None,
|
||||
unit_name=instance.unit.name if instance.unit else None,
|
||||
base_unit=instance.unit.base_unit if instance.unit and instance.unit.base_unit else None,
|
||||
created_by=UserDTO.create_from_user(instance.created_by),
|
||||
)
|
||||
|
||||
|
||||
# A Connector is 'destroyed' & recreated each time 'any' ConnectorConfig in a space changes.
|
||||
@@ -10,20 +47,18 @@ class Connector(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def on_shopping_list_entry_created(self, space: Space, instance: ShoppingListEntry) -> None:
|
||||
async def on_shopping_list_entry_created(self, instance: ShoppingListEntryDTO) -> 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:
|
||||
async def on_shopping_list_entry_updated(self, instance: ShoppingListEntryDTO) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def on_shopping_list_entry_deleted(self, space: Space, instance: ShoppingListEntry) -> None:
|
||||
async def on_shopping_list_entry_deleted(self, instance: ShoppingListEntryDTO) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
# TODO: Add Recipes & possibly Meal Place listeners/hooks (And maybe more?)
|
||||
|
||||
@@ -12,7 +12,7 @@ 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.connector import Connector, ShoppingListEntryDTO
|
||||
from cookbook.connectors.homeassistant import HomeAssistant
|
||||
from cookbook.models import ShoppingListEntry, Space, ConnectorConfig
|
||||
|
||||
@@ -56,15 +56,13 @@ class ConnectorManager(metaclass=Singleton):
|
||||
|
||||
def __init__(self):
|
||||
self._logger = logging.getLogger("recipes.connector")
|
||||
self._logger.debug("ConnectorManager initializing")
|
||||
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
|
||||
@@ -75,16 +73,37 @@ class ConnectorManager(metaclass=Singleton):
|
||||
else:
|
||||
return
|
||||
|
||||
try:
|
||||
self._queue.put_nowait(Work(instance, action_type))
|
||||
except queue.Full:
|
||||
self._logger.info(f"queue was full, so skipping {action_type} of type {type(instance)}")
|
||||
return
|
||||
self._add_work(action_type, instance)
|
||||
|
||||
def _add_work(self, action_type: ActionType, *instances: REGISTERED_CLASSES):
|
||||
for instance in instances:
|
||||
if not isinstance(instance, self._listening_to_classes) or not hasattr(instance, "space"):
|
||||
continue
|
||||
try:
|
||||
_force_load_instance(instance)
|
||||
self._queue.put_nowait(Work(instance, action_type))
|
||||
except queue.Full:
|
||||
self._logger.info(f"queue was full, so skipping {action_type} of type {type(instance)}")
|
||||
|
||||
def stop(self):
|
||||
self._queue.join()
|
||||
self._worker.join()
|
||||
|
||||
@classmethod
|
||||
def is_initialized(cls):
|
||||
return cls in cls._instances
|
||||
|
||||
@staticmethod
|
||||
def add_work(action_type: ActionType, *instances: REGISTERED_CLASSES):
|
||||
"""
|
||||
Manually inject work that failed to come in through the __call__ (aka Django signal)
|
||||
Before the work is processed, we check if the connectionManager is initialized, because if it's not, we don't want to accidentally initialize it.
|
||||
Be careful calling it, because it might result in a instance being processed twice.
|
||||
"""
|
||||
if not ConnectorManager.is_initialized():
|
||||
return
|
||||
ConnectorManager()._add_work(action_type, *instances)
|
||||
|
||||
@staticmethod
|
||||
def worker(worker_id: int, worker_queue: queue.Queue):
|
||||
logger = logging.getLogger("recipes.connector.worker")
|
||||
@@ -116,7 +135,7 @@ class ConnectorManager(metaclass=Singleton):
|
||||
|
||||
if connectors is None or refresh_connector_cache:
|
||||
if connectors is not None:
|
||||
loop.run_until_complete(close_connectors(connectors))
|
||||
loop.run_until_complete(_close_connectors(connectors))
|
||||
|
||||
with scope(space=space):
|
||||
connectors: List[Connector] = list()
|
||||
@@ -142,7 +161,7 @@ class ConnectorManager(metaclass=Singleton):
|
||||
|
||||
logger.debug(f"running {len(connectors)} connectors for {item.instance=} with {item.actionType=}")
|
||||
|
||||
loop.run_until_complete(run_connectors(connectors, space, item.instance, item.actionType))
|
||||
loop.run_until_complete(run_connectors(connectors, item.instance, item.actionType))
|
||||
worker_queue.task_done()
|
||||
|
||||
logger.info(f"terminating ConnectionManager worker {worker_id}")
|
||||
@@ -159,7 +178,14 @@ class ConnectorManager(metaclass=Singleton):
|
||||
return None
|
||||
|
||||
|
||||
async def close_connectors(connectors: List[Connector]):
|
||||
def _force_load_instance(instance: REGISTERED_CLASSES):
|
||||
if isinstance(instance, ShoppingListEntry):
|
||||
_ = instance.food # Force load food
|
||||
_ = instance.unit # Force load unit
|
||||
_ = instance.created_by # Force load created_by
|
||||
|
||||
|
||||
async def _close_connectors(connectors: List[Connector]):
|
||||
tasks: List[Task] = [asyncio.create_task(connector.close()) for connector in connectors]
|
||||
|
||||
if len(tasks) == 0:
|
||||
@@ -171,22 +197,24 @@ async def close_connectors(connectors: List[Connector]):
|
||||
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):
|
||||
async def run_connectors(connectors: List[Connector], instance: REGISTERED_CLASSES, action_type: ActionType):
|
||||
tasks: List[Task] = list()
|
||||
|
||||
if isinstance(instance, ShoppingListEntry):
|
||||
shopping_list_entry: ShoppingListEntry = instance
|
||||
shopping_list_entry = ShoppingListEntryDTO.try_create_from_entry(instance)
|
||||
if shopping_list_entry is None:
|
||||
return
|
||||
|
||||
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)))
|
||||
tasks.append(asyncio.create_task(connector.on_shopping_list_entry_created(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)))
|
||||
tasks.append(asyncio.create_task(connector.on_shopping_list_entry_updated(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)))
|
||||
tasks.append(asyncio.create_task(connector.on_shopping_list_entry_deleted(shopping_list_entry)))
|
||||
|
||||
if len(tasks) == 0:
|
||||
return
|
||||
|
||||
@@ -5,16 +5,14 @@ from urllib.parse import urljoin
|
||||
|
||||
from aiohttp import request, ClientResponseError
|
||||
|
||||
from cookbook.connectors.connector import Connector
|
||||
from cookbook.models import ShoppingListEntry, ConnectorConfig, Space
|
||||
from cookbook.connectors.connector import Connector, ShoppingListEntryDTO
|
||||
from cookbook.models import ConnectorConfig
|
||||
|
||||
|
||||
class HomeAssistant(Connector):
|
||||
_config: ConnectorConfig
|
||||
_logger: Logger
|
||||
|
||||
_required_foreign_keys = ("food", "unit", "created_by")
|
||||
|
||||
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")
|
||||
@@ -34,7 +32,7 @@ class HomeAssistant(Connector):
|
||||
response.raise_for_status()
|
||||
return await response.json()
|
||||
|
||||
async def on_shopping_list_entry_created(self, space: Space, shopping_list_entry: ShoppingListEntry) -> None:
|
||||
async def on_shopping_list_entry_created(self, shopping_list_entry: ShoppingListEntryDTO) -> None:
|
||||
if not self._config.on_shopping_list_entry_created_enabled:
|
||||
return
|
||||
|
||||
@@ -55,20 +53,15 @@ class HomeAssistant(Connector):
|
||||
except ClientResponseError as err:
|
||||
self._logger.warning(f"received an exception from the api: {err.request_info.url=}, {err.request_info.method=}, {err.status=}, {err.message=}, {type(err)=}")
|
||||
|
||||
async def on_shopping_list_entry_updated(self, space: Space, shopping_list_entry: ShoppingListEntry) -> None:
|
||||
async def on_shopping_list_entry_updated(self, shopping_list_entry: ShoppingListEntryDTO) -> 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:
|
||||
async def on_shopping_list_entry_deleted(self, shopping_list_entry: ShoppingListEntryDTO) -> None:
|
||||
if not self._config.on_shopping_list_entry_deleted_enabled:
|
||||
return
|
||||
|
||||
if not all(k in shopping_list_entry._state.fields_cache for k in self._required_foreign_keys):
|
||||
# Sometimes the food foreign key is not loaded, and we cant load it from an async process
|
||||
self._logger.debug("required property was not present in ShoppingListEntry")
|
||||
return
|
||||
|
||||
item, _ = _format_shopping_list_entry(shopping_list_entry)
|
||||
|
||||
self._logger.debug(f"removing {item=} from {self._config.todo_entity}")
|
||||
@@ -88,19 +81,19 @@ class HomeAssistant(Connector):
|
||||
pass
|
||||
|
||||
|
||||
def _format_shopping_list_entry(shopping_list_entry: ShoppingListEntry) -> Tuple[str, str]:
|
||||
item = shopping_list_entry.food.name
|
||||
if shopping_list_entry.amount > 0:
|
||||
def _format_shopping_list_entry(shopping_list_entry: ShoppingListEntryDTO) -> Tuple[str, str]:
|
||||
item = shopping_list_entry.food_name
|
||||
if shopping_list_entry.amount:
|
||||
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})"
|
||||
if shopping_list_entry.base_unit:
|
||||
item += f" {shopping_list_entry.base_unit})"
|
||||
elif shopping_list_entry.unit_name:
|
||||
item += f" {shopping_list_entry.unit_name})"
|
||||
else:
|
||||
item += ")"
|
||||
|
||||
description = "From TandoorRecipes"
|
||||
if shopping_list_entry.created_by.first_name and len(shopping_list_entry.created_by.first_name) > 0:
|
||||
if shopping_list_entry.created_by.first_name:
|
||||
description += f", by {shopping_list_entry.created_by.first_name}"
|
||||
else:
|
||||
description += f", by {shopping_list_entry.created_by.username}"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
from allauth.account.forms import ResetPasswordForm, SignupForm
|
||||
from allauth.socialaccount.forms import SignupForm as SocialSignupForm
|
||||
from django import forms
|
||||
@@ -14,7 +13,6 @@ from hcaptcha.fields import hCaptchaField
|
||||
|
||||
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',)
|
||||
@@ -23,54 +21,12 @@ class SelectWidget(widgets.Select):
|
||||
class MultiSelectWidget(widgets.SelectMultiple):
|
||||
class Media:
|
||||
js = ('custom/js/form_multiselect.js',)
|
||||
|
||||
|
||||
# Yes there are some stupid browsers that still dont support this but
|
||||
# I dont support people using these browsers.
|
||||
class DateWidget(forms.DateInput):
|
||||
input_type = 'date'
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
kwargs["format"] = "%Y-%m-%d"
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
||||
class UserNameForm(forms.ModelForm):
|
||||
prefix = 'name'
|
||||
|
||||
class Meta:
|
||||
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')}
|
||||
|
||||
|
||||
class ExternalRecipeForm(forms.ModelForm):
|
||||
file_path = forms.CharField(disabled=True, required=False)
|
||||
file_uid = forms.CharField(disabled=True, required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['keywords'].queryset = Keyword.objects.filter(space=space).all()
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ('name', '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'),
|
||||
}
|
||||
widgets = {'keywords': MultiSelectWidget}
|
||||
field_classes = {'keywords': SafeModelMultipleChoiceField, }
|
||||
|
||||
|
||||
class ImportExportBase(forms.Form):
|
||||
DEFAULT = 'DEFAULT'
|
||||
PAPRIKA = 'PAPRIKA'
|
||||
NEXTCLOUD = 'NEXTCLOUD'
|
||||
MEALIE = 'MEALIE'
|
||||
MEALIE1 = 'MEALIE1'
|
||||
CHOWDOWN = 'CHOWDOWN'
|
||||
SAFFRON = 'SAFFRON'
|
||||
CHEFTAP = 'CHEFTAP'
|
||||
@@ -91,13 +47,12 @@ class ImportExportBase(forms.Form):
|
||||
PDF = 'PDF'
|
||||
GOURMET = 'GOURMET'
|
||||
|
||||
type = forms.ChoiceField(choices=((DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'), (MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'),
|
||||
type = forms.ChoiceField(choices=((DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'), (MEALIE, 'Mealie'), (MEALIE1, 'Mealie1'), (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'), (GOURMET, 'Gourmet')))
|
||||
|
||||
|
||||
class MultipleFileInput(forms.ClearableFileInput):
|
||||
allow_multiple_selected = True
|
||||
|
||||
@@ -121,6 +76,9 @@ 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)
|
||||
meal_plans = forms.BooleanField(required=False)
|
||||
shopping_lists = forms.BooleanField(required=False)
|
||||
nutrition_per_serving = forms.BooleanField(required=False) # some managers (e.g. mealie) do not specify what the nutrition's relate to so we let the user choose
|
||||
|
||||
|
||||
class ExportForm(ImportExportBase):
|
||||
@@ -133,125 +91,7 @@ class ExportForm(ImportExportBase):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['recipes'].queryset = Recipe.objects.filter(space=space).all()
|
||||
|
||||
|
||||
class CommentForm(forms.ModelForm):
|
||||
prefix = 'comment'
|
||||
|
||||
class Meta:
|
||||
model = Comment
|
||||
fields = ('text',)
|
||||
|
||||
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.'))
|
||||
|
||||
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)'), }
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
supports_description_field = forms.BooleanField(
|
||||
help_text="Does the connector todo entity support the description field",
|
||||
initial=True,
|
||||
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 = ConnectorConfig
|
||||
|
||||
fields = (
|
||||
'name', 'type', 'enabled', 'on_shopping_list_entry_created_enabled', 'on_shopping_list_entry_updated_enabled',
|
||||
'on_shopping_list_entry_deleted_enabled', 'supports_description_field', 'url', 'todo_entity',
|
||||
)
|
||||
|
||||
help_texts = {
|
||||
'url': _('http://homeassistant.local:8123/api for example'),
|
||||
}
|
||||
|
||||
|
||||
class SyncForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['storage'].queryset = Storage.objects.filter(space=space).all()
|
||||
|
||||
class Meta:
|
||||
model = Sync
|
||||
fields = ('storage', 'path', 'active')
|
||||
|
||||
field_classes = {'storage': SafeModelChoiceField, }
|
||||
|
||||
labels = {'storage': _('Storage'), 'path': _('Path'), 'active': _('Active')}
|
||||
|
||||
|
||||
class BatchEditForm(forms.Form):
|
||||
search = forms.CharField(label=_('Search String'))
|
||||
keywords = forms.ModelMultipleChoiceField(queryset=Keyword.objects.none(), required=False, widget=MultiSelectWidget)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['keywords'].queryset = Keyword.objects.filter(space=space).all().order_by('id')
|
||||
|
||||
|
||||
class ImportRecipeForm(forms.ModelForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
space = kwargs.pop('space')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['keywords'].queryset = Keyword.objects.filter(space=space).all()
|
||||
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = ('name', 'keywords', 'file_path', 'file_uid')
|
||||
|
||||
labels = {'name': _('Name'), 'keywords': _('Keywords'), 'file_path': _('Path'), 'file_uid': _('File ID'), }
|
||||
widgets = {'keywords': MultiSelectWidget}
|
||||
field_classes = {'keywords': SafeModelChoiceField, }
|
||||
from .models import InviteLink, SearchPreference, Space, User, UserPreference
|
||||
|
||||
|
||||
class InviteLinkForm(forms.ModelForm):
|
||||
@@ -339,38 +179,3 @@ 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'}))
|
||||
|
||||
|
||||
class SearchPreferenceForm(forms.ModelForm):
|
||||
prefix = 'search'
|
||||
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).'))
|
||||
preset = forms.CharField(widget=forms.HiddenInput(), required=False)
|
||||
|
||||
class Meta:
|
||||
model = SearchPreference
|
||||
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."),
|
||||
}
|
||||
|
||||
labels = {
|
||||
'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,
|
||||
}
|
||||
|
||||
85
cookbook/helper/ai_helper.py
Normal file
85
cookbook/helper/ai_helper.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.utils import timezone
|
||||
from django.db.models import Sum
|
||||
from litellm import CustomLogger
|
||||
|
||||
from cookbook.models import AiLog
|
||||
from recipes import settings
|
||||
|
||||
|
||||
def get_monthly_token_usage(space):
|
||||
"""
|
||||
returns the number of credits the space has used in the current month
|
||||
"""
|
||||
token_usage = AiLog.objects.filter(space=space, credits_from_balance=False, created_at__month=timezone.now().month).aggregate(Sum('credit_cost'))['credit_cost__sum']
|
||||
if token_usage is None:
|
||||
token_usage = 0
|
||||
return token_usage
|
||||
|
||||
|
||||
def has_monthly_token(space):
|
||||
"""
|
||||
checks if the monthly credit limit has been exceeded
|
||||
"""
|
||||
return get_monthly_token_usage(space) < space.ai_credits_monthly
|
||||
|
||||
|
||||
def can_perform_ai_request(space):
|
||||
return (has_monthly_token(space) or space.ai_credits_balance > 0) and space.ai_enabled
|
||||
|
||||
|
||||
class AiCallbackHandler(CustomLogger):
|
||||
space = None
|
||||
user = None
|
||||
ai_provider = None
|
||||
function = None
|
||||
|
||||
def __init__(self, space, user, ai_provider, function):
|
||||
super().__init__()
|
||||
self.space = space
|
||||
self.user = user
|
||||
self.ai_provider = ai_provider
|
||||
self.function = function
|
||||
|
||||
def log_pre_api_call(self, model, messages, kwargs):
|
||||
pass
|
||||
|
||||
def log_post_api_call(self, kwargs, response_obj, start_time, end_time):
|
||||
pass
|
||||
|
||||
def log_success_event(self, kwargs, response_obj, start_time, end_time):
|
||||
self.create_ai_log(kwargs, response_obj, start_time, end_time)
|
||||
|
||||
def log_failure_event(self, kwargs, response_obj, start_time, end_time):
|
||||
self.create_ai_log(kwargs, response_obj, start_time, end_time)
|
||||
|
||||
def create_ai_log(self, kwargs, response_obj, start_time, end_time):
|
||||
credit_cost = 0
|
||||
credits_from_balance = False
|
||||
if self.ai_provider.log_credit_cost:
|
||||
credit_cost = kwargs.get("response_cost", 0) * 100
|
||||
|
||||
if (not has_monthly_token(self.space)) and self.space.ai_credits_balance > 0:
|
||||
remaining_balance = self.space.ai_credits_balance - Decimal(str(credit_cost))
|
||||
if remaining_balance < 0:
|
||||
remaining_balance = 0
|
||||
if settings.HOSTED and self.space.ai_credits_monthly == 0:
|
||||
self.space.ai_enabled = False
|
||||
|
||||
self.space.ai_credits_balance = remaining_balance
|
||||
credits_from_balance = True
|
||||
self.space.save()
|
||||
|
||||
AiLog.objects.create(
|
||||
created_by=self.user,
|
||||
space=self.space,
|
||||
ai_provider=self.ai_provider,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
input_tokens=response_obj['usage']['prompt_tokens'],
|
||||
output_tokens=response_obj['usage']['completion_tokens'],
|
||||
function=self.function,
|
||||
credit_cost=credit_cost,
|
||||
credits_from_balance=credits_from_balance,
|
||||
)
|
||||
@@ -109,7 +109,7 @@ class AutomationEngine:
|
||||
Moves a string that should never be treated as a unit to next token and optionally replaced with default unit
|
||||
e.g. NEVER_UNIT: param1: egg, param2: None would modify ['1', 'egg', 'white'] to ['1', '', 'egg', 'white']
|
||||
or NEVER_UNIT: param1: egg, param2: pcs would modify ['1', 'egg', 'yolk'] to ['1', 'pcs', 'egg', 'yolk']
|
||||
:param1 string: string that should never be considered a unit, will be moved to token[2]
|
||||
:param1 tokens: string that should never be considered a unit, will be moved to token[2]
|
||||
:param2 (optional) unit as string: will insert unit string into token[1]
|
||||
:return: unit as string (possibly changed by automation)
|
||||
"""
|
||||
@@ -135,7 +135,7 @@ class AutomationEngine:
|
||||
new_unit = self.never_unit[tokens[1].lower()]
|
||||
never_unit = True
|
||||
except KeyError:
|
||||
return tokens
|
||||
return tokens, never_unit
|
||||
else:
|
||||
if a := Automation.objects.annotate(param_1_lower=Lower('param_1')).filter(space=self.request.space, type=Automation.NEVER_UNIT, param_1_lower__in=[
|
||||
tokens[1].lower(), alt_unit.lower()], disabled=False).order_by('order').first():
|
||||
@@ -144,7 +144,7 @@ class AutomationEngine:
|
||||
|
||||
if never_unit:
|
||||
tokens.insert(1, new_unit)
|
||||
return tokens
|
||||
return tokens, never_unit
|
||||
|
||||
def apply_transpose_automation(self, string):
|
||||
"""
|
||||
|
||||
22
cookbook/helper/batch_edit_helper.py
Normal file
22
cookbook/helper/batch_edit_helper.py
Normal file
@@ -0,0 +1,22 @@
|
||||
def add_to_relation(relation_model, base_field_name, base_ids, related_field_name, related_ids):
|
||||
"""
|
||||
given a model, the base and related field and the base and related ids, bulk create relation objects
|
||||
"""
|
||||
relation_objects = []
|
||||
for b in base_ids:
|
||||
for r in related_ids:
|
||||
relation_objects.append(relation_model(**{base_field_name: b, related_field_name: r}))
|
||||
relation_model.objects.bulk_create(relation_objects, ignore_conflicts=True, unique_fields=(base_field_name, related_field_name,))
|
||||
|
||||
|
||||
def remove_from_relation(relation_model, base_field_name, base_ids, related_field_name, related_ids):
|
||||
relation_model.objects.filter(**{f'{base_field_name}__in': base_ids, f'{related_field_name}__in': related_ids}).delete()
|
||||
|
||||
|
||||
def remove_all_from_relation(relation_model, base_field_name, base_ids):
|
||||
relation_model.objects.filter(**{f'{base_field_name}__in': base_ids}).delete()
|
||||
|
||||
|
||||
def set_relation(relation_model, base_field_name, base_ids, related_field_name, related_ids):
|
||||
remove_all_from_relation(relation_model, base_field_name, base_ids)
|
||||
add_to_relation(relation_model, base_field_name, base_ids, related_field_name, related_ids)
|
||||
102
cookbook/helper/drf_spectacular_hooks.py
Normal file
102
cookbook/helper/drf_spectacular_hooks.py
Normal file
@@ -0,0 +1,102 @@
|
||||
# custom processing for schema
|
||||
# reason: DRF writable nested needs ID's to decide if a nested object should be created or updated
|
||||
# the API schema/client make ID's read only by default and strips them entirely in request objects (with COMPONENT_SPLIT_REQUEST enabled)
|
||||
# change the schema to make IDs optional but writable so they are included in the request
|
||||
|
||||
def custom_postprocessing_hook(result, generator, request, public):
|
||||
for c in result['components']['schemas'].keys():
|
||||
# handle schemas used by the client to do requests on the server
|
||||
if 'properties' in result['components']['schemas'][c] and 'id' in result['components']['schemas'][c]['properties']:
|
||||
# make ID field not read only so it's not stripped from the request on the client
|
||||
result['components']['schemas'][c]['properties']['id']['readOnly'] = False
|
||||
# make ID field not required
|
||||
if 'required' in result['components']['schemas'][c] and 'id' in result['components']['schemas'][c]['required']:
|
||||
result['components']['schemas'][c]['required'].remove('id')
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# TODO remove below once legacy API has been fully deprecated
|
||||
from drf_spectacular.openapi import AutoSchema # noqa: E402 isort: skip
|
||||
import functools # noqa: E402 isort: skip
|
||||
import re # noqa: E402 isort: skip
|
||||
|
||||
|
||||
class LegacySchema(AutoSchema):
|
||||
operation_id_base = None
|
||||
|
||||
@functools.cached_property
|
||||
def path(self):
|
||||
path = re.sub(pattern=self.path_prefix, repl='', string=self.path, flags=re.IGNORECASE)
|
||||
# remove path variables
|
||||
return re.sub(pattern=r'\{[\w\-]+\}', repl='', string=path)
|
||||
|
||||
def get_operation_id(self):
|
||||
"""
|
||||
Compute an operation ID from the view type and get_operation_id_base method.
|
||||
"""
|
||||
method_name = getattr(self.view, 'action', self.method.lower())
|
||||
if self._is_list_view():
|
||||
action = 'list'
|
||||
elif method_name not in self.method_mapping:
|
||||
action = self._to_camel_case(method_name)
|
||||
else:
|
||||
action = self.method_mapping[self.method.lower()]
|
||||
|
||||
name = self.get_operation_id_base(action)
|
||||
|
||||
return action + name
|
||||
|
||||
def get_operation_id_base(self, action):
|
||||
"""
|
||||
Compute the base part for operation ID from the model, serializer or view name.
|
||||
"""
|
||||
model = getattr(getattr(self.view, 'queryset', None), 'model', None)
|
||||
|
||||
if self.operation_id_base is not None:
|
||||
name = self.operation_id_base
|
||||
|
||||
# Try to deduce the ID from the view's model
|
||||
elif model is not None:
|
||||
name = model.__name__
|
||||
|
||||
# Try with the serializer class name
|
||||
elif self.get_serializer() is not None:
|
||||
name = self.get_serializer().__class__.__name__
|
||||
if name.endswith('Serializer'):
|
||||
name = name[:-10]
|
||||
|
||||
# Fallback to the view name
|
||||
else:
|
||||
name = self.view.__class__.__name__
|
||||
if name.endswith('APIView'):
|
||||
name = name[:-7]
|
||||
elif name.endswith('View'):
|
||||
name = name[:-4]
|
||||
|
||||
# Due to camel-casing of classes and `action` being lowercase, apply title in order to find if action truly
|
||||
# comes at the end of the name
|
||||
if name.endswith(action.title()): # ListView, UpdateAPIView, ThingDelete ...
|
||||
name = name[:-len(action)]
|
||||
|
||||
if action == 'list' and not name.endswith('s'): # listThings instead of listThing
|
||||
name += 's'
|
||||
|
||||
return name
|
||||
|
||||
def get_serializer(self):
|
||||
view = self.view
|
||||
|
||||
if not hasattr(view, 'get_serializer'):
|
||||
return None
|
||||
|
||||
try:
|
||||
return view.get_serializer()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _to_camel_case(self, snake_str):
|
||||
components = snake_str.split('_')
|
||||
# We capitalize the first letter of each component except the first one
|
||||
# with the 'title' method and join them together.
|
||||
return components[0] + ''.join(x.title() for x in components[1:])
|
||||
@@ -35,6 +35,33 @@ def get_filetype(name):
|
||||
return '.jpeg'
|
||||
|
||||
|
||||
def is_file_type_allowed(filename, image_only=False):
|
||||
is_file_allowed = False
|
||||
allowed_file_types = ['.pdf', '.docx', '.xlsx', '.css', '.mp4', '.mov']
|
||||
allowed_image_types = ['.png', '.jpg', '.jpeg', '.gif', '.webp']
|
||||
check_list = allowed_image_types
|
||||
if not image_only:
|
||||
check_list += allowed_file_types
|
||||
|
||||
for file_type in check_list:
|
||||
if filename.lower().endswith(file_type):
|
||||
is_file_allowed = True
|
||||
|
||||
return is_file_allowed
|
||||
|
||||
|
||||
def strip_image_meta(image_object, file_format):
|
||||
image_object = Image.open(image_object)
|
||||
|
||||
data = list(image_object.getdata())
|
||||
image_without_exif = Image.new(image_object.mode, image_object.size)
|
||||
image_without_exif.putdata(data)
|
||||
|
||||
im_io = BytesIO()
|
||||
image_without_exif.save(im_io, file_format)
|
||||
return im_io
|
||||
|
||||
|
||||
# TODO this whole file needs proper documentation, refactoring, and testing
|
||||
# TODO also add env variable to define which images sizes should be compressed
|
||||
# filetype argument can not be optional, otherwise this function will treat all images as if they were a jpeg
|
||||
@@ -45,9 +72,21 @@ def handle_image(request, image_object, filetype):
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
file_format = None
|
||||
if filetype == '.jpeg' or filetype == '.jpg':
|
||||
file_format = 'JPEG'
|
||||
if filetype == '.png':
|
||||
file_format = 'PNG'
|
||||
if filetype == '.webp':
|
||||
file_format = 'WEBP'
|
||||
|
||||
if (image_object.size / 1000) > 500: # if larger than 500 kb compress
|
||||
if filetype == '.jpeg' or filetype == '.jpg':
|
||||
return rescale_image_jpeg(image_object)
|
||||
if filetype == '.png':
|
||||
return rescale_image_png(image_object)
|
||||
else:
|
||||
return strip_image_meta(image_object, file_format)
|
||||
|
||||
# TODO webp and gifs bypass the scaling and metadata checks, fix
|
||||
return image_object
|
||||
|
||||
@@ -118,7 +118,7 @@ class IngredientParser:
|
||||
note = ''
|
||||
start = 0
|
||||
# search for first occurrence of an argument ending in a comma
|
||||
while start < len(tokens) and not tokens[start].endswith(','):
|
||||
while start < len(tokens) and not tokens[start].endswith((',', ';', ':')):
|
||||
start += 1
|
||||
if start == len(tokens):
|
||||
# no token ending in a comma found -> use everything as food
|
||||
@@ -176,7 +176,6 @@ class IngredientParser:
|
||||
# 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):
|
||||
match = re.search(r'[1-9](\d)*\s*([^\W\d_])+', ingredient)
|
||||
print(f'reording from {ingredient} to {ingredient[match.start():match.end()] + " " + ingredient.replace(ingredient[match.start():match.end()], "")}')
|
||||
ingredient = ingredient[match.start():match.end()] + ' ' + ingredient.replace(ingredient[match.start():match.end()], '')
|
||||
|
||||
# if the string contains parenthesis early on remove it and place it at the end
|
||||
@@ -211,39 +210,46 @@ class IngredientParser:
|
||||
# three arguments if it already has a unit there can't be
|
||||
# a fraction for the amount
|
||||
if len(tokens) > 2:
|
||||
never_unit_applied = False
|
||||
if not self.ignore_rules:
|
||||
tokens = self.automation.apply_never_unit_automation(tokens)
|
||||
try:
|
||||
if unit is not None:
|
||||
# a unit is already found, no need to try the second argument for a fraction
|
||||
# probably not the best method to do it, but I didn't want to make an if check and paste the exact same thing in the else as already is in the except
|
||||
raise ValueError
|
||||
# try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½'
|
||||
amount += self.parse_fraction(tokens[1])
|
||||
# assume that units can't end with a comma
|
||||
if len(tokens) > 3 and not tokens[2].endswith(','):
|
||||
# try to use third argument as unit and everything else as food, use everything as food if it fails
|
||||
try:
|
||||
food, note = self.parse_food(tokens[3:])
|
||||
unit = tokens[2]
|
||||
except ValueError:
|
||||
tokens, never_unit_applied = self.automation.apply_never_unit_automation(tokens)
|
||||
|
||||
if never_unit_applied:
|
||||
unit = tokens[1]
|
||||
food, note = self.parse_food(tokens[2:])
|
||||
else:
|
||||
try:
|
||||
if unit is not None:
|
||||
# a unit is already found, no need to try the second argument for a fraction
|
||||
# probably not the best method to do it, but I didn't want to make an if check and paste the exact same thing in the else as already is in the except
|
||||
raise ValueError
|
||||
# try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½'
|
||||
if tokens[1]:
|
||||
amount += self.parse_fraction(tokens[1])
|
||||
# assume that units can't end with a comma
|
||||
if len(tokens) > 3 and not tokens[2].endswith(','):
|
||||
# try to use third argument as unit and everything else as food, use everything as food if it fails
|
||||
try:
|
||||
food, note = self.parse_food(tokens[3:])
|
||||
unit = tokens[2]
|
||||
except ValueError:
|
||||
food, note = self.parse_food(tokens[2:])
|
||||
else:
|
||||
food, note = self.parse_food(tokens[2:])
|
||||
else:
|
||||
food, note = self.parse_food(tokens[2:])
|
||||
except ValueError:
|
||||
# assume that units can't end with a comma
|
||||
if not tokens[1].endswith(','):
|
||||
# try to use second argument as unit and everything else as food, use everything as food if it fails
|
||||
try:
|
||||
food, note = self.parse_food(tokens[2:])
|
||||
if unit is None:
|
||||
unit = tokens[1]
|
||||
else:
|
||||
note = tokens[1]
|
||||
except ValueError:
|
||||
except ValueError:
|
||||
# assume that units can't end with a comma
|
||||
if not tokens[1].endswith(','):
|
||||
# try to use second argument as unit and everything else as food, use everything as food if it fails
|
||||
try:
|
||||
food, note = self.parse_food(tokens[2:])
|
||||
if unit is None:
|
||||
unit = tokens[1]
|
||||
else:
|
||||
note = tokens[1]
|
||||
except ValueError:
|
||||
food, note = self.parse_food(tokens[1:])
|
||||
else:
|
||||
food, note = self.parse_food(tokens[1:])
|
||||
else:
|
||||
food, note = self.parse_food(tokens[1:])
|
||||
else:
|
||||
# only two arguments, first one is the amount
|
||||
# which means this is the food
|
||||
@@ -264,6 +270,7 @@ class IngredientParser:
|
||||
|
||||
if food and not self.ignore_rules:
|
||||
food = self.automation.apply_food_automation(food)
|
||||
|
||||
if len(food) > Food._meta.get_field('name').max_length: # test if food name is to long
|
||||
# try splitting it at a space and taking only the first arg
|
||||
if len(food.split()) > 1 and len(food.split()[0]) < Food._meta.get_field('name').max_length:
|
||||
|
||||
@@ -7,7 +7,9 @@ class StyleTreeprocessor(Treeprocessor):
|
||||
def run_processor(self, node):
|
||||
for child in node:
|
||||
if child.tag == "table":
|
||||
child.set("class", "table table-bordered")
|
||||
child.set("class", "markdown-table")
|
||||
if child.tag == "th" or child.tag == "td":
|
||||
child.set("class", "markdown-table-cell")
|
||||
if child.tag == "img":
|
||||
child.set("class", "img-fluid")
|
||||
self.run_processor(child)
|
||||
|
||||
@@ -6,6 +6,8 @@ from cookbook.models import (Food, FoodProperty, Property, PropertyType, Superma
|
||||
SupermarketCategory, SupermarketCategoryRelation, Unit, UnitConversion)
|
||||
import re
|
||||
|
||||
from recipes.settings import DEBUG
|
||||
|
||||
|
||||
class OpenDataImportResponse:
|
||||
total_created = 0
|
||||
@@ -49,10 +51,10 @@ class OpenDataImporter:
|
||||
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]})')
|
||||
#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]})')
|
||||
#print(f'comparing {obj} failed because field {field} is not equal ({getattr(obj, field)} != {existing_obj[field]})')
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -339,8 +341,8 @@ class OpenDataImporter:
|
||||
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,
|
||||
'supermarket_category_id': self.slug_id_cache['category'][self.data[datatype][k]['store_category']] if self.data[datatype][k]['store_category'] in self.slug_id_cache['category'] else None,
|
||||
'fdc_id': re.sub(r'\D', '', str(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,
|
||||
@@ -367,12 +369,28 @@ class OpenDataImporter:
|
||||
create_list.append({'data': obj_dict})
|
||||
|
||||
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)
|
||||
try:
|
||||
model_type.objects.bulk_update(update_list, field_list)
|
||||
od_response.total_updated += len(update_list)
|
||||
except Exception:
|
||||
if DEBUG:
|
||||
print('========= LOAD FOOD FAILED ============')
|
||||
print(update_list)
|
||||
print(existing_data_names)
|
||||
print(existing_data_slugs)
|
||||
traceback.print_exc()
|
||||
|
||||
if len(create_list) > 0:
|
||||
Food.load_bulk(create_list, None)
|
||||
od_response.total_created += len(create_list)
|
||||
try:
|
||||
Food.load_bulk(create_list, None)
|
||||
od_response.total_created += len(create_list)
|
||||
except Exception:
|
||||
if DEBUG:
|
||||
print('========= LOAD FOOD FAILED ============')
|
||||
print(create_list)
|
||||
print(existing_data_names)
|
||||
print(existing_data_slugs)
|
||||
traceback.print_exc()
|
||||
|
||||
# --------------- PROPERTY STUFF -----------------------
|
||||
model_type = Property
|
||||
|
||||
@@ -3,17 +3,19 @@ import inspect
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import user_passes_test
|
||||
from django.contrib.auth.models import Group
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
from django_scopes import scopes_disabled
|
||||
from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope, TokenHasScope
|
||||
from oauth2_provider.models import AccessToken
|
||||
from rest_framework import permissions
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
|
||||
from cookbook.models import Recipe, ShareLink, UserSpace
|
||||
import random
|
||||
from cookbook.models import Recipe, ShareLink, UserSpace, Space
|
||||
|
||||
|
||||
def get_allowed_groups(groups_required):
|
||||
@@ -160,18 +162,15 @@ class GroupRequiredMixin(object):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not has_group_permission(request.user, self.groups_required):
|
||||
if not request.user.is_authenticated:
|
||||
messages.add_message(request, messages.ERROR,
|
||||
_('You are not logged in and therefore cannot view this page!'))
|
||||
messages.add_message(request, messages.ERROR, _('You are not logged in and therefore cannot view this page!'))
|
||||
return HttpResponseRedirect(reverse_lazy('account_login') + '?next=' + request.path)
|
||||
else:
|
||||
messages.add_message(request, messages.ERROR,
|
||||
_('You do not have the required permissions to view this page!'))
|
||||
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
|
||||
return HttpResponseRedirect(reverse_lazy('index'))
|
||||
try:
|
||||
obj = self.get_object()
|
||||
if obj.get_space() != request.space:
|
||||
messages.add_message(request, messages.ERROR,
|
||||
_('You do not have the required permissions to view this page!'))
|
||||
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
|
||||
return HttpResponseRedirect(reverse_lazy('index'))
|
||||
except AttributeError:
|
||||
pass
|
||||
@@ -334,6 +333,25 @@ class CustomRecipePermission(permissions.BasePermission):
|
||||
or has_group_permission(request.user, ['user'])) and obj.space == request.space
|
||||
|
||||
|
||||
class CustomAiProviderPermission(permissions.BasePermission):
|
||||
"""
|
||||
Custom permission class for the AiProvider api endpoint
|
||||
users: can read all
|
||||
admins: can read and write
|
||||
superusers: can read and write + write providers without a space
|
||||
"""
|
||||
message = _('You do not have the required permissions to view this page!')
|
||||
|
||||
def has_permission(self, request, view): # user is either at least a user and the request is safe
|
||||
return (has_group_permission(request.user, ['user']) and request.method in SAFE_METHODS) or (has_group_permission(request.user, ['admin']) or request.user.is_superuser)
|
||||
|
||||
# editing of global providers allowed for superusers, space providers by admins and users can read only access
|
||||
def has_object_permission(self, request, view, obj):
|
||||
return ((obj.space is None and request.user.is_superuser)
|
||||
or (obj.space == request.space and has_group_permission(request.user, ['admin']))
|
||||
or (obj.space == request.space and has_group_permission(request.user, ['user']) and request.method in SAFE_METHODS))
|
||||
|
||||
|
||||
class CustomUserPermission(permissions.BasePermission):
|
||||
"""
|
||||
Custom permission class for user api endpoint
|
||||
@@ -440,3 +458,36 @@ class IsReadOnlyDRF(permissions.BasePermission):
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.method in SAFE_METHODS
|
||||
|
||||
|
||||
class IsCreateDRF(permissions.BasePermission):
|
||||
message = 'You cannot interact with this object, you can only create'
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.method == 'POST'
|
||||
|
||||
|
||||
def create_space_for_user(user, name=None):
|
||||
with scopes_disabled():
|
||||
if not name:
|
||||
name = f"{user.username}'s Space"
|
||||
|
||||
if Space.objects.filter(name=name).exists():
|
||||
name = f'{name} #{random.randrange(1, 10 ** 5)}'
|
||||
|
||||
created_space = Space(name=name,
|
||||
created_by=user,
|
||||
max_file_storage_mb=settings.SPACE_DEFAULT_MAX_FILES,
|
||||
max_recipes=settings.SPACE_DEFAULT_MAX_RECIPES,
|
||||
max_users=settings.SPACE_DEFAULT_MAX_USERS,
|
||||
allow_sharing=settings.SPACE_DEFAULT_ALLOW_SHARING,
|
||||
ai_enabled=settings.SPACE_AI_ENABLED,
|
||||
ai_credits_monthly=settings.SPACE_AI_CREDITS_MONTHLY,
|
||||
space_setup_completed=False, )
|
||||
created_space.save()
|
||||
|
||||
UserSpace.objects.filter(user=user).update(active=False)
|
||||
user_space = UserSpace.objects.create(space=created_space, user=user, active=True)
|
||||
user_space.groups.add(Group.objects.filter(name='admin').get())
|
||||
|
||||
return user_space
|
||||
|
||||
@@ -44,26 +44,39 @@ class FoodPropertyHelper:
|
||||
if i.food is not None:
|
||||
conversions = uch.get_conversions(i)
|
||||
for pt in property_types:
|
||||
# if a property could be calculated with an actual value
|
||||
found_property = False
|
||||
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}
|
||||
# if food has a value for the given property type (no matter if conversion is possible)
|
||||
has_property_value = False
|
||||
if (i.food.properties_food_amount == 0 or i.food.properties_food_unit is None) and not (i.amount == 0 or i.no_amount): # if food is configured incorrectly
|
||||
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': {'id': i.food.id, 'name': 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 and p.property_amount is not None:
|
||||
has_property_value = True
|
||||
for c in conversions:
|
||||
if c.unit == i.food.properties_food_unit:
|
||||
if c.unit == i.food.properties_food_unit and i.food.properties_food_amount != 0:
|
||||
found_property = True
|
||||
computed_properties[pt.id]['total_value'] += (c.amount / i.food.properties_food_amount) * p.property_amount
|
||||
computed_properties[pt.id]['food_values'] = self.add_or_create(
|
||||
computed_properties[p.property_type.id]['food_values'], c.food.id, (c.amount / i.food.properties_food_amount) * p.property_amount, c.food)
|
||||
if not found_property:
|
||||
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}
|
||||
# if no amount and food does not exist yet add it but don't count as missing
|
||||
if i.amount == 0 or i.no_amount:
|
||||
if i.food.id not in computed_properties[pt.id]['food_values']:
|
||||
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': {'id': i.food.id, 'name': i.food.name}, 'value': 0}
|
||||
# if amount is present but unit is missing indicate it in the result
|
||||
elif i.unit is None:
|
||||
if i.food.id not in computed_properties[pt.id]['food_values']:
|
||||
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': {'id': i.food.id, 'name': i.food.name}, 'value': 0}
|
||||
computed_properties[pt.id]['food_values'][i.food.id]['missing_unit'] = True
|
||||
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}
|
||||
if i.food.id not in computed_properties[pt.id]['food_values']:
|
||||
computed_properties[pt.id]['food_values'][i.food.id] = {'id': i.food.id, 'food': {'id': i.food.id, 'name': i.food.name}, 'value': None}
|
||||
if has_property_value and i.unit is not None:
|
||||
computed_properties[pt.id]['food_values'][i.food.id]['missing_conversion'] = {'base_unit': {'id': i.unit.id, 'name': i.unit.name}, 'converted_unit': {'id': i.food.properties_food_unit.id, 'name': i.food.properties_food_unit.name}}
|
||||
|
||||
return computed_properties
|
||||
|
||||
@@ -71,8 +84,12 @@ class FoodPropertyHelper:
|
||||
# TODO move to central helper ? --> use defaultdict
|
||||
@staticmethod
|
||||
def add_or_create(d, key, value, food):
|
||||
if key in d and d[key]['value']:
|
||||
d[key]['value'] += value
|
||||
if key in d:
|
||||
# value can be None if a previous instance of the same food was missing a conversion
|
||||
if d[key]['value']:
|
||||
d[key]['value'] += value
|
||||
else:
|
||||
d[key]['value'] = value
|
||||
else:
|
||||
d[key] = {'id': food.id, 'food': food.name, 'value': value}
|
||||
d[key] = {'id': food.id, 'food': {'id': food.id, 'name': food.name}, 'value': value}
|
||||
return d
|
||||
|
||||
@@ -49,7 +49,11 @@ class RecipeSearch():
|
||||
self._search_prefs = SearchPreference()
|
||||
self._string = self._params.get('query').strip(
|
||||
) if self._params.get('query', None) else None
|
||||
|
||||
self._rating = self._params.get('rating', None)
|
||||
self._rating_gte = self._params.get('rating_gte', None)
|
||||
self._rating_lte = self._params.get('rating_lte', None)
|
||||
|
||||
self._keywords = {
|
||||
'or': self._params.get('keywords_or', None) or self._params.get('keywords', None),
|
||||
'and': self._params.get('keywords_and', None),
|
||||
@@ -70,20 +74,36 @@ class RecipeSearch():
|
||||
}
|
||||
self._steps = self._params.get('steps', None)
|
||||
self._units = self._params.get('units', None)
|
||||
# TODO add created by
|
||||
# TODO image exists
|
||||
self._sort_order = self._params.get('sort_order', None)
|
||||
self._internal = str2bool(self._params.get('internal', None))
|
||||
self._random = str2bool(self._params.get('random', False))
|
||||
self._sort_order = self._params.get('sort_order', None)
|
||||
if self._sort_order == 'random':
|
||||
self._random = True
|
||||
self.sort_order = None
|
||||
else:
|
||||
self._random = str2bool(self._params.get('random', False))
|
||||
self._new = str2bool(self._params.get('new', False))
|
||||
self._num_recent = int(self._params.get('num_recent', 0))
|
||||
self._include_children = str2bool(
|
||||
self._params.get('include_children', None))
|
||||
self._timescooked = self._params.get('timescooked', None)
|
||||
self._cookedon = self._params.get('cookedon', None)
|
||||
self._timescooked_gte = self._params.get('timescooked_gte', None)
|
||||
self._timescooked_lte = self._params.get('timescooked_lte', None)
|
||||
|
||||
self._createdon = self._params.get('createdon', None)
|
||||
self._createdon_gte = self._params.get('createdon_gte', None)
|
||||
self._createdon_lte = self._params.get('createdon_lte', None)
|
||||
|
||||
self._updatedon = self._params.get('updatedon', None)
|
||||
self._viewedon = self._params.get('viewedon', None)
|
||||
self._updatedon_gte = self._params.get('updatedon_gte', None)
|
||||
self._updatedon_lte = self._params.get('updatedon_lte', None)
|
||||
|
||||
self._viewedon_gte = self._params.get('viewedon_gte', None)
|
||||
self._viewedon_lte = self._params.get('viewedon_lte', None)
|
||||
|
||||
self._cookedon_gte = self._params.get('cookedon_gte', None)
|
||||
self._cookedon_lte = self._params.get('cookedon_lte', None)
|
||||
|
||||
self._createdby = self._params.get('createdby', None)
|
||||
self._makenow = self._params.get('makenow', None)
|
||||
# this supports hidden feature to find recipes missing X ingredients
|
||||
if isinstance(self._makenow, bool) and self._makenow == True:
|
||||
@@ -130,16 +150,19 @@ class RecipeSearch():
|
||||
|
||||
self._build_sort_order()
|
||||
self._recently_viewed(num_recent=self._num_recent)
|
||||
self._cooked_on_filter(cooked_date=self._cookedon)
|
||||
self._created_on_filter(created_date=self._createdon)
|
||||
self._updated_on_filter(updated_date=self._updatedon)
|
||||
self._viewed_on_filter(viewed_date=self._viewedon)
|
||||
self._favorite_recipes(times_cooked=self._timescooked)
|
||||
|
||||
self._cooked_on_filter()
|
||||
self._created_on_filter()
|
||||
self._updated_on_filter()
|
||||
self._viewed_on_filter()
|
||||
|
||||
self._created_by_filter(created_by_user_id=self._createdby)
|
||||
self._favorite_recipes()
|
||||
self._new_recipes()
|
||||
self.keyword_filters(**self._keywords)
|
||||
self.food_filters(**self._foods)
|
||||
self.book_filters(**self._books)
|
||||
self.rating_filter(rating=self._rating)
|
||||
self.rating_filter()
|
||||
self.internal_filter(internal=self._internal)
|
||||
self.step_filters(steps=self._steps)
|
||||
self.unit_filters(units=self._units)
|
||||
@@ -186,9 +209,9 @@ class RecipeSearch():
|
||||
else:
|
||||
order += default_order
|
||||
order[:] = [Lower('name').asc() if x ==
|
||||
'name' else x for x in order]
|
||||
'name' else x for x in order]
|
||||
order[:] = [Lower('name').desc() if x ==
|
||||
'-name' else x for x in order]
|
||||
'-name' else x for x in order]
|
||||
self.orderby = order
|
||||
|
||||
def string_filters(self, string=None):
|
||||
@@ -227,9 +250,9 @@ class RecipeSearch():
|
||||
query_filter |= Q(**{"%s" % f: self._string})
|
||||
self._queryset = self._queryset.filter(query_filter).distinct()
|
||||
|
||||
def _cooked_on_filter(self, cooked_date=None):
|
||||
if self._sort_includes('lastcooked') or cooked_date:
|
||||
lessthan = self._sort_includes('-lastcooked') or '-' in (cooked_date or [])[:1]
|
||||
def _cooked_on_filter(self):
|
||||
if self._sort_includes('lastcooked') or self._cookedon_gte or self._cookedon_lte:
|
||||
lessthan = self._sort_includes('-lastcooked') or self._cookedon_lte
|
||||
if lessthan:
|
||||
default = timezone.now() - timedelta(days=100000)
|
||||
else:
|
||||
@@ -237,51 +260,44 @@ class RecipeSearch():
|
||||
self._queryset = self._queryset.annotate(
|
||||
lastcooked=Coalesce(Max(Case(When(cooklog__created_by=self._request.user, cooklog__space=self._request.space, then='cooklog__created_at'))), Value(default))
|
||||
)
|
||||
if cooked_date is None:
|
||||
return
|
||||
|
||||
cooked_date = date(*[int(x)for x in cooked_date.split('-') if x != ''])
|
||||
|
||||
if lessthan:
|
||||
self._queryset = self._queryset.filter(lastcooked__date__lte=cooked_date).exclude(lastcooked=default)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(lastcooked__date__gte=cooked_date).exclude(lastcooked=default)
|
||||
|
||||
def _created_on_filter(self, created_date=None):
|
||||
if created_date is None:
|
||||
return
|
||||
lessthan = '-' in created_date[:1]
|
||||
created_date = date(*[int(x) for x in created_date.split('-') if x != ''])
|
||||
if lessthan:
|
||||
self._queryset = self._queryset.filter(created_at__date__lte=created_date)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(created_at__date__gte=created_date)
|
||||
|
||||
def _updated_on_filter(self, updated_date=None):
|
||||
if updated_date is None:
|
||||
return
|
||||
lessthan = '-' in updated_date[:1]
|
||||
updated_date = date(*[int(x)for x in updated_date.split('-') if x != ''])
|
||||
if lessthan:
|
||||
self._queryset = self._queryset.filter(updated_at__date__lte=updated_date)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(updated_at__date__gte=updated_date)
|
||||
if self._cookedon_lte:
|
||||
self._queryset = self._queryset.filter(lastcooked__date__lte=self._cookedon_lte).exclude(lastcooked=default)
|
||||
elif self._cookedon_gte:
|
||||
self._queryset = self._queryset.filter(lastcooked__date__gte=self._cookedon_gte).exclude(lastcooked=default)
|
||||
|
||||
def _viewed_on_filter(self, viewed_date=None):
|
||||
if self._sort_includes('lastviewed') or viewed_date:
|
||||
if self._sort_includes('lastviewed') or self._viewedon_gte or self._viewedon_lte:
|
||||
longTimeAgo = timezone.now() - timedelta(days=100000)
|
||||
self._queryset = self._queryset.annotate(
|
||||
lastviewed=Coalesce(Max(Case(When(viewlog__created_by=self._request.user, viewlog__space=self._request.space, then='viewlog__created_at'))), Value(longTimeAgo))
|
||||
)
|
||||
if viewed_date is None:
|
||||
return
|
||||
lessthan = '-' in viewed_date[:1]
|
||||
viewed_date = date(*[int(x)for x in viewed_date.split('-') if x != ''])
|
||||
|
||||
if lessthan:
|
||||
self._queryset = self._queryset.filter(lastviewed__date__lte=viewed_date).exclude(lastviewed=longTimeAgo)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(lastviewed__date__gte=viewed_date).exclude(lastviewed=longTimeAgo)
|
||||
if self._viewedon_lte:
|
||||
self._queryset = self._queryset.filter(lastviewed__date__lte=self._viewedon_lte).exclude(lastviewed=longTimeAgo)
|
||||
elif self._viewedon_gte:
|
||||
self._queryset = self._queryset.filter(lastviewed__date__gte=self._viewedon_gte).exclude(lastviewed=longTimeAgo)
|
||||
|
||||
def _created_on_filter(self):
|
||||
if self._createdon:
|
||||
self._queryset = self._queryset.filter(created_at__date=self._createdon)
|
||||
elif self._createdon_lte:
|
||||
self._queryset = self._queryset.filter(created_at__date__lte=self._createdon_lte)
|
||||
elif self._createdon_gte:
|
||||
self._queryset = self._queryset.filter(created_at__date__gte=self._createdon_gte)
|
||||
|
||||
def _updated_on_filter(self):
|
||||
if self._updatedon:
|
||||
self._queryset = self._queryset.filter(updated_at__date=self._updatedon)
|
||||
elif self._updatedon_lte:
|
||||
self._queryset = self._queryset.filter(updated_at__date__lte=self._updatedon_lte)
|
||||
elif self._updatedon_gte:
|
||||
self._queryset = self._queryset.filter(updated_at__date__gte=self._updatedon_gte)
|
||||
|
||||
def _created_by_filter(self, created_by_user_id=None):
|
||||
if created_by_user_id is None:
|
||||
return
|
||||
self._queryset = self._queryset.filter(created_by__id=created_by_user_id)
|
||||
|
||||
def _new_recipes(self, new_days=7):
|
||||
# TODO make new days a user-setting
|
||||
@@ -307,9 +323,9 @@ class RecipeSearch():
|
||||
)
|
||||
self._queryset = self._queryset.annotate(recent=Coalesce(Max(Case(When(pk__in=num_recent_recipes.values('recipe'), then='viewlog__pk'))), Value(0)))
|
||||
|
||||
def _favorite_recipes(self, times_cooked=None):
|
||||
if self._sort_includes('favorite') or times_cooked:
|
||||
less_than = '-' in (str(times_cooked) or []) and not self._sort_includes('-favorite')
|
||||
def _favorite_recipes(self):
|
||||
if self._sort_includes('favorite') or self._timescooked is not None or self._timescooked_gte is not None or self._timescooked_lte is not None:
|
||||
less_than = self._timescooked_lte and not self._sort_includes('-favorite')
|
||||
if less_than:
|
||||
default = 1000
|
||||
else:
|
||||
@@ -321,15 +337,13 @@ class RecipeSearch():
|
||||
.values('count')
|
||||
)
|
||||
self._queryset = self._queryset.annotate(favorite=Coalesce(Subquery(favorite_recipes), default))
|
||||
if times_cooked is None:
|
||||
return
|
||||
|
||||
if times_cooked == '0':
|
||||
self._queryset = self._queryset.filter(favorite=0)
|
||||
elif less_than:
|
||||
self._queryset = self._queryset.filter(favorite__lte=int(times_cooked.replace('-', ''))).exclude(favorite=0)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(favorite__gte=int(times_cooked))
|
||||
if self._timescooked is not None:
|
||||
self._queryset = self._queryset.filter(favorite=self._timescooked)
|
||||
elif self._timescooked_lte is not None:
|
||||
self._queryset = self._queryset.filter(favorite__lte=int(self._timescooked_lte)).exclude(favorite=0)
|
||||
elif self._timescooked_gte is not None:
|
||||
self._queryset = self._queryset.filter(favorite__gte=int(self._timescooked_gte))
|
||||
|
||||
def keyword_filters(self, **kwargs):
|
||||
if all([kwargs[x] is None for x in kwargs]):
|
||||
@@ -407,25 +421,16 @@ class RecipeSearch():
|
||||
units = [units]
|
||||
self._queryset = self._queryset.filter(steps__ingredients__unit__in=units)
|
||||
|
||||
def rating_filter(self, rating=None):
|
||||
if rating or self._sort_includes('rating'):
|
||||
lessthan = '-' in (rating or [])
|
||||
reverse = 'rating' in (self._sort_order or []) and '-rating' not in (self._sort_order or [])
|
||||
if lessthan or reverse:
|
||||
default = 100
|
||||
else:
|
||||
default = 0
|
||||
# TODO make ratings a settings user-only vs all-users
|
||||
self._queryset = self._queryset.annotate(rating=Round(Avg(Case(When(cooklog__created_by=self._request.user, then='cooklog__rating'), default=default))))
|
||||
if rating is None:
|
||||
return
|
||||
def rating_filter(self):
|
||||
if self._rating or self._rating_lte or self._rating_gte or self._sort_includes('rating'):
|
||||
self._queryset = self._queryset.annotate(rating=Round(Avg(Case(When(cooklog__created_by=self._request.user, then='cooklog__rating'), default=0))))
|
||||
|
||||
if rating == '0':
|
||||
self._queryset = self._queryset.filter(rating=0)
|
||||
elif lessthan:
|
||||
self._queryset = self._queryset.filter(rating__lte=int(rating[1:])).exclude(rating=0)
|
||||
else:
|
||||
self._queryset = self._queryset.filter(rating__gte=int(rating))
|
||||
if self._rating:
|
||||
self._queryset = self._queryset.filter(rating=round(int(self._rating)))
|
||||
elif self._rating_gte:
|
||||
self._queryset = self._queryset.filter(rating__gte=int(self._rating_gte))
|
||||
elif self._rating_lte:
|
||||
self._queryset = self._queryset.filter(rating__gte=int(self._rating_lte)).exclude(rating=0)
|
||||
|
||||
def internal_filter(self, internal=None):
|
||||
if not internal:
|
||||
@@ -535,11 +540,11 @@ class RecipeSearch():
|
||||
shopping_users = [*self._request.user.get_shopping_share(), self._request.user]
|
||||
|
||||
onhand_filter = (
|
||||
Q(steps__ingredients__food__onhand_users__in=shopping_users) # food onhand
|
||||
# or substitute food onhand
|
||||
| Q(steps__ingredients__food__substitute__onhand_users__in=shopping_users)
|
||||
| Q(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users))
|
||||
| Q(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users))
|
||||
Q(steps__ingredients__food__onhand_users__in=shopping_users) # food onhand
|
||||
# or substitute food onhand
|
||||
| Q(steps__ingredients__food__substitute__onhand_users__in=shopping_users)
|
||||
| Q(steps__ingredients__food__in=self.__children_substitute_filter(shopping_users))
|
||||
| Q(steps__ingredients__food__in=self.__sibling_substitute_filter(shopping_users))
|
||||
)
|
||||
makenow_recipes = Recipe.objects.annotate(
|
||||
count_food=Count('steps__ingredients__food__pk', filter=Q(steps__ingredients__food__isnull=False), distinct=True),
|
||||
|
||||
@@ -69,15 +69,8 @@ def get_from_scraper(scrape, request):
|
||||
recipe_json['description'] = parse_description(description)
|
||||
recipe_json['description'] = automation_engine.apply_regex_replace_automation(recipe_json['description'], Automation.DESCRIPTION_REPLACE)
|
||||
|
||||
# assign servings attributes
|
||||
try:
|
||||
# dont use scrape.yields() as this will always return "x servings" or "x items", should be improved in scrapers directly
|
||||
servings = scrape.schema.data.get('recipeYield') or 1
|
||||
except Exception:
|
||||
servings = 1
|
||||
|
||||
recipe_json['servings'] = parse_servings(servings)
|
||||
recipe_json['servings_text'] = parse_servings_text(servings)
|
||||
recipe_json['servings'] = parse_servings(scrape.schema.data.get('recipeYield'))
|
||||
recipe_json['servings_text'] = parse_servings_text(scrape.schema.data.get('recipeYield'))
|
||||
|
||||
# assign time attributes
|
||||
try:
|
||||
@@ -106,14 +99,14 @@ def get_from_scraper(scrape, request):
|
||||
|
||||
# assign image
|
||||
try:
|
||||
recipe_json['image'] = parse_image(scrape.image()) or None
|
||||
recipe_json['image_url'] = parse_image(scrape.image()) or None
|
||||
except Exception:
|
||||
recipe_json['image'] = None
|
||||
if not recipe_json['image']:
|
||||
recipe_json['image_url'] = None
|
||||
if not recipe_json['image_url']:
|
||||
try:
|
||||
recipe_json['image'] = parse_image(scrape.schema.data.get('image')) or ''
|
||||
recipe_json['image_url'] = parse_image(scrape.schema.data.get('image')) or ''
|
||||
except Exception:
|
||||
recipe_json['image'] = ''
|
||||
recipe_json['image_url'] = ''
|
||||
|
||||
# assign keywords
|
||||
try:
|
||||
@@ -155,7 +148,7 @@ def get_from_scraper(scrape, request):
|
||||
|
||||
# assign steps
|
||||
try:
|
||||
for i in parse_instructions(scrape.instructions()):
|
||||
for i in parse_instructions(scrape.instructions_list()):
|
||||
recipe_json['steps'].append({
|
||||
'instruction': i,
|
||||
'ingredients': [],
|
||||
@@ -177,11 +170,11 @@ def get_from_scraper(scrape, request):
|
||||
for x in scrape.ingredients():
|
||||
if x.strip() != '':
|
||||
try:
|
||||
amount, unit, ingredient, note = ingredient_parser.parse(x)
|
||||
amount, unit, food, note = ingredient_parser.parse(x)
|
||||
ingredient = {
|
||||
'amount': amount,
|
||||
'food': {
|
||||
'name': ingredient,
|
||||
'name': food,
|
||||
},
|
||||
'unit': None,
|
||||
'note': note,
|
||||
@@ -205,6 +198,7 @@ def get_from_scraper(scrape, request):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
recipe_json['properties'] = []
|
||||
try:
|
||||
recipe_json['properties'] = get_recipe_properties(request.space, scrape.schema.nutrients())
|
||||
print(recipe_json['properties'])
|
||||
@@ -227,6 +221,13 @@ def get_recipe_properties(space, property_data):
|
||||
"property-proteins": "proteinContent",
|
||||
"property-fats": "fatContent",
|
||||
}
|
||||
|
||||
serving_size = 1
|
||||
try:
|
||||
serving_size = parse_servings(property_data['servingSize'])
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
recipe_properties = []
|
||||
for pt in PropertyType.objects.filter(space=space, open_data_slug__in=list(properties.keys())).all():
|
||||
for p in list(properties.keys()):
|
||||
@@ -237,7 +238,7 @@ def get_recipe_properties(space, property_data):
|
||||
'id': pt.id,
|
||||
'name': pt.name,
|
||||
},
|
||||
'property_amount': parse_servings(property_data[properties[p]]) / parse_servings(property_data['servingSize']),
|
||||
'property_amount': parse_servings(property_data[properties[p]]) / serving_size,
|
||||
})
|
||||
|
||||
return recipe_properties
|
||||
@@ -307,14 +308,29 @@ def clean_instruction_string(instruction):
|
||||
# handle unsupported, special UTF8 character in Thermomix-specific instructions,
|
||||
# that happen in nearly every recipe on Cookidoo, Zaubertopf Club, Rezeptwelt
|
||||
# and in Thermomix-specific recipes on many other sites
|
||||
return normalized_string \
|
||||
.replace("", _('reverse rotation')) \
|
||||
.replace("", _('careful rotation')) \
|
||||
.replace("", _('knead')) \
|
||||
.replace("Andicken ", _('thicken')) \
|
||||
.replace("Erwärmen ", _('warm up')) \
|
||||
.replace("Fermentieren ", _('ferment')) \
|
||||
.replace("Sous-vide ", _("sous-vide"))
|
||||
normalized_string = normalized_string \
|
||||
.replace(u"\uE003", _('reverse rotation')) \
|
||||
.replace(u"\uE002", _('careful rotation')) \
|
||||
.replace(u"\uE001", _('knead')) \
|
||||
.replace(u"\uE031", _('thicken')) \
|
||||
.replace(u"\uE019", _('warm up')) \
|
||||
.replace(u"\uE02E", _('ferment')) \
|
||||
.replace(u"\uE018", _('slow cook')) \
|
||||
.replace(u"\uE033", _('egg boiler')) \
|
||||
.replace(u"\uE016", _('kettle')) \
|
||||
.replace(u"\uE01E", _('blend')) \
|
||||
.replace(u"\uE011", _('pre-clean')) \
|
||||
.replace(u"\uE026", _('high temperature')) \
|
||||
.replace(u"\uE00D", _('rice cooker')) \
|
||||
.replace(u"\uE00C", _('caramelize')) \
|
||||
.replace(u"\uE038", _('peeler')) \
|
||||
.replace(u"\uE037", _('slicer')) \
|
||||
.replace(u"\uE036", _('grater')) \
|
||||
.replace(u"\uE04C", _('spiralizer')) \
|
||||
.replace(u"\uE02D", _("sous-vide"))
|
||||
|
||||
|
||||
return normalized_string
|
||||
|
||||
|
||||
def parse_instructions(instructions):
|
||||
@@ -383,7 +399,7 @@ def parse_servings(servings):
|
||||
def parse_servings_text(servings):
|
||||
if isinstance(servings, str):
|
||||
try:
|
||||
servings = re.sub("\\d+", '', servings).strip()
|
||||
servings = re.sub("\\d+", '', servings, 1).strip()
|
||||
except Exception:
|
||||
servings = ''
|
||||
if isinstance(servings, list):
|
||||
@@ -395,6 +411,8 @@ def parse_servings_text(servings):
|
||||
|
||||
|
||||
def parse_time(recipe_time):
|
||||
if not recipe_time:
|
||||
return 0
|
||||
if type(recipe_time) not in [int, float]:
|
||||
try:
|
||||
recipe_time = float(re.search(r'\d+', recipe_time).group())
|
||||
@@ -424,9 +442,9 @@ def parse_keywords(keyword_json, request):
|
||||
if len(kw) != 0:
|
||||
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})
|
||||
keywords.append({'label': str(k), 'name': k.name, 'id': k.id, 'import_keyword': True})
|
||||
else:
|
||||
keywords.append({'label': kw, 'name': kw})
|
||||
keywords.append({'label': kw, 'name': kw, 'import_keyword': False})
|
||||
|
||||
return keywords
|
||||
|
||||
@@ -483,7 +501,11 @@ def get_images_from_soup(soup, url):
|
||||
u = u.split('?')[0]
|
||||
filename = re.search(r'/([\w_-]+[.](jpg|jpeg|gif|png))$', u)
|
||||
if filename:
|
||||
if (('http' not in u) and (url)):
|
||||
if u.startswith('//'):
|
||||
# urls from e.g. ottolenghi.co.uk start with //
|
||||
u = 'https:' + u
|
||||
if ('http' not in u) and url:
|
||||
print(f'rewriting URL {u}')
|
||||
# sometimes an image source can be relative
|
||||
# if it is provide the base url
|
||||
u = '{}://{}{}'.format(prot, site, u)
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
from django.contrib.auth.models import Group
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django_scopes import scope, scopes_disabled
|
||||
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
|
||||
from psycopg2.errors import UniqueViolation
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
import random
|
||||
|
||||
from cookbook.helper.permission_helper import create_space_for_user
|
||||
from cookbook.models import Space, UserSpace
|
||||
from cookbook.views import views
|
||||
from recipes import settings
|
||||
|
||||
@@ -12,7 +19,7 @@ class ScopeMiddleware:
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
prefix = settings.JS_REVERSE_SCRIPT_PREFIX or ''
|
||||
prefix = settings.SCRIPT_NAME or ''
|
||||
|
||||
# need to disable scopes for writing requests into userpref and enable for loading ?
|
||||
if request.path.startswith(prefix + '/api/user-preference/'):
|
||||
@@ -34,16 +41,28 @@ class ScopeMiddleware:
|
||||
if request.path.startswith(prefix + '/switch-space/'):
|
||||
return self.get_response(request)
|
||||
|
||||
with scopes_disabled():
|
||||
if request.user.userspace_set.count() == 0 and not reverse('account_logout') in request.path:
|
||||
return views.space_overview(request)
|
||||
if request.path.startswith(prefix + '/invite/'):
|
||||
return self.get_response(request)
|
||||
|
||||
# get active user space, if for some reason more than one space is active select first (group permission checks will fail, this is not intended at this point)
|
||||
user_space = request.user.userspace_set.filter(active=True).first()
|
||||
|
||||
if not user_space:
|
||||
return views.space_overview(request)
|
||||
if not user_space and request.user.userspace_set.count() > 0:
|
||||
# if the users has a userspace but nothing is active, activate the first one
|
||||
user_space = request.user.userspace_set.first()
|
||||
if user_space:
|
||||
user_space.active = True
|
||||
user_space.save()
|
||||
|
||||
if not user_space:
|
||||
if 'signup_token' in request.session:
|
||||
# if user is authenticated, has no space but a signup token (InviteLink) is present, redirect to invite link logic
|
||||
return HttpResponseRedirect(reverse('view_invite', args=[request.session.pop('signup_token', '')]))
|
||||
else:
|
||||
# if user does not yet have a space create one for him
|
||||
user_space = create_space_for_user(request.user)
|
||||
|
||||
# TODO remove the need for this view
|
||||
if user_space.groups.count() == 0 and not reverse('account_logout') in request.path:
|
||||
return views.no_groups(request)
|
||||
|
||||
|
||||
@@ -75,7 +75,8 @@ class RecipeShoppingEditor():
|
||||
|
||||
@staticmethod
|
||||
def get_shopping_list_recipe(id, user, space):
|
||||
return ShoppingListRecipe.objects.filter(id=id).filter(entries__space=space).filter(
|
||||
# TODO this sucks since it wont find SLR's that no longer have any entries
|
||||
return ShoppingListRecipe.objects.filter(id=id, space=space).filter(
|
||||
Q(entries__created_by=user)
|
||||
| Q(entries__created_by__in=list(user.get_shopping_share()))
|
||||
).prefetch_related('entries').first()
|
||||
@@ -113,7 +114,7 @@ class RecipeShoppingEditor():
|
||||
if not self.servings:
|
||||
self.servings = getattr(self.mealplan, 'servings', None) or getattr(self.recipe, 'servings', 1.0)
|
||||
|
||||
self._shopping_list_recipe = ShoppingListRecipe.objects.create(recipe=self.recipe, mealplan=self.mealplan, servings=self.servings)
|
||||
self._shopping_list_recipe = ShoppingListRecipe.objects.create(recipe=self.recipe, mealplan=self.mealplan, servings=self.servings, space=self.space, created_by=self.created_by)
|
||||
|
||||
if ingredients:
|
||||
self._add_ingredients(ingredients=ingredients)
|
||||
@@ -136,7 +137,8 @@ class RecipeShoppingEditor():
|
||||
self.servings = servings
|
||||
|
||||
self._delete_ingredients(ingredients=ingredients)
|
||||
if self.servings != self._shopping_list_recipe.servings:
|
||||
# need to check if there is a SLR because its possible it cant be found if all entries are deleted
|
||||
if self._shopping_list_recipe and self.servings != self._shopping_list_recipe.servings:
|
||||
self.edit_servings()
|
||||
self._add_ingredients(ingredients=ingredients)
|
||||
return True
|
||||
@@ -153,8 +155,9 @@ class RecipeShoppingEditor():
|
||||
return True
|
||||
|
||||
for sle in ShoppingListEntry.objects.filter(list_recipe=self._shopping_list_recipe):
|
||||
sle.amount = sle.ingredient.amount * Decimal(self._servings_factor)
|
||||
sle.save()
|
||||
if sle.ingredient: # TODO temporarily dont scale manual entries until ingredient_amount or some other base amount has been migrated to SLE
|
||||
sle.amount = sle.ingredient.amount * Decimal(self._servings_factor)
|
||||
sle.save()
|
||||
self._shopping_list_recipe.servings = self.servings
|
||||
self._shopping_list_recipe.save()
|
||||
return True
|
||||
@@ -174,8 +177,9 @@ class RecipeShoppingEditor():
|
||||
existing = self._shopping_list_recipe.entries.filter(ingredient__in=ingredients).values_list('ingredient__pk', flat=True)
|
||||
add_ingredients = ingredients.exclude(id__in=existing)
|
||||
|
||||
entries = []
|
||||
for i in [x for x in add_ingredients if x.food]:
|
||||
ShoppingListEntry.objects.create(
|
||||
entry = ShoppingListEntry(
|
||||
list_recipe=self._shopping_list_recipe,
|
||||
food=i.food,
|
||||
unit=i.unit,
|
||||
@@ -184,6 +188,12 @@ class RecipeShoppingEditor():
|
||||
created_by=self.created_by,
|
||||
space=self.space,
|
||||
)
|
||||
entries.append(entry)
|
||||
|
||||
ShoppingListEntry.objects.bulk_create(entries)
|
||||
for e in entries:
|
||||
if e.food.shopping_lists.count() > 0:
|
||||
e.shopping_lists.set(e.food.shopping_lists.all())
|
||||
|
||||
# deletes shopping list entries not in ingredients list
|
||||
def _delete_ingredients(self, ingredients=None):
|
||||
|
||||
@@ -70,7 +70,7 @@ def render_instructions(step): # TODO deduplicate markdown cleanup code
|
||||
parsed_md = md.markdown(
|
||||
instructions,
|
||||
extensions=[
|
||||
'markdown.extensions.fenced_code', TableExtension(),
|
||||
'markdown.extensions.fenced_code', 'markdown.extensions.sane_lists', 'markdown.extensions.nl2br', TableExtension(),
|
||||
UrlizeExtension(), MarkdownFormatExtension()
|
||||
]
|
||||
)
|
||||
|
||||
@@ -135,8 +135,9 @@ class UnitConversionHelper:
|
||||
:param food: base food
|
||||
:return: converted ingredient object from base amount/unit/food
|
||||
"""
|
||||
if uc.food is None or uc.food == food:
|
||||
if (uc.food is None or uc.food == food) and uc.converted_amount > 0 and uc.base_amount > 0:
|
||||
if unit == uc.base_unit:
|
||||
return Ingredient(amount=amount * (uc.converted_amount / uc.base_amount), unit=uc.converted_unit, food=food, space=self.space)
|
||||
else:
|
||||
return Ingredient(amount=amount * (uc.base_amount / uc.converted_amount), unit=uc.base_unit, food=food, space=self.space)
|
||||
return None
|
||||
|
||||
@@ -60,14 +60,15 @@ class CookBookApp(Integration):
|
||||
food=f, unit=u, amount=ingredient.get('amount', None), note=ingredient.get('note', None), original_text=ingredient.get('original_text', None), space=self.request.space,
|
||||
))
|
||||
|
||||
if len(images) > 0:
|
||||
try:
|
||||
url = images[0]
|
||||
if validate_import_url(url):
|
||||
try:
|
||||
for url in images:
|
||||
# import the first valid image which is not cookbookapp branding
|
||||
if validate_import_url(url) and not url.startswith("https://media.cookbookmanager.com/brand/"):
|
||||
response = requests.get(url)
|
||||
self.import_recipe_image(recipe, BytesIO(response.content))
|
||||
except Exception as e:
|
||||
print('failed to import image ', str(e))
|
||||
break
|
||||
except Exception as e:
|
||||
print('failed to import image ', str(e))
|
||||
|
||||
recipe.save()
|
||||
return recipe
|
||||
|
||||
@@ -26,6 +26,12 @@ class Integration:
|
||||
files = None
|
||||
export_type = None
|
||||
ignored_recipes = []
|
||||
import_log = None
|
||||
import_duplicates = False
|
||||
|
||||
import_meal_plans = True
|
||||
import_shopping_lists = True
|
||||
nutrition_per_serving = False
|
||||
|
||||
def __init__(self, request, export_type):
|
||||
"""
|
||||
@@ -102,7 +108,7 @@ class Integration:
|
||||
"""
|
||||
return True
|
||||
|
||||
def do_import(self, files, il, import_duplicates):
|
||||
def do_import(self, files, il, import_duplicates, meal_plans=True, shopping_lists=True, nutrition_per_serving=False):
|
||||
"""
|
||||
Imports given files
|
||||
:param import_duplicates: if true duplicates are imported as well
|
||||
@@ -111,6 +117,12 @@ class Integration:
|
||||
:return: HttpResponseRedirect to the recipe search showing all imported recipes
|
||||
"""
|
||||
with scope(space=self.request.space):
|
||||
self.import_log = il
|
||||
self.import_duplicates = import_duplicates
|
||||
|
||||
self.import_meal_plans = meal_plans
|
||||
self.import_shopping_lists = shopping_lists
|
||||
self.nutrition_per_serving = nutrition_per_serving
|
||||
|
||||
try:
|
||||
self.files = files
|
||||
@@ -166,20 +178,24 @@ class Integration:
|
||||
il.total_recipes = len(new_file_list)
|
||||
file_list = new_file_list
|
||||
|
||||
for z in file_list:
|
||||
try:
|
||||
if not hasattr(z, 'filename') or isinstance(z, Tag):
|
||||
recipe = self.get_recipe_from_file(z)
|
||||
else:
|
||||
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += self.get_recipe_processed_msg(recipe)
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
il.imported_recipes += 1
|
||||
il.save()
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self.handle_exception(e, log=il, message=f'-------------------- \nERROR \n{e}\n--------------------\n')
|
||||
if isinstance(self, cookbook.integration.mealie1.Mealie1):
|
||||
# since the mealie 1.0 export is a backup and not a classic recipe export we treat it a bit differently
|
||||
recipes = self.get_recipe_from_file(import_zip)
|
||||
else:
|
||||
for z in file_list:
|
||||
try:
|
||||
if not hasattr(z, 'filename') or isinstance(z, Tag):
|
||||
recipe = self.get_recipe_from_file(z)
|
||||
else:
|
||||
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += self.get_recipe_processed_msg(recipe)
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
il.imported_recipes += 1
|
||||
il.save()
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self.handle_exception(e, log=il, message=f'-------------------- \nERROR \n{e}\n--------------------\n')
|
||||
import_zip.close()
|
||||
elif '.json' in f['name'] or '.xml' in f['name'] or '.txt' in f['name'] or '.mmf' in f['name'] or '.rk' in f['name'] or '.melarecipe' in f['name']:
|
||||
data_list = self.split_recipe_file(f['file'])
|
||||
|
||||
372
cookbook/integration/mealie1.py
Normal file
372
cookbook/integration/mealie1.py
Normal file
@@ -0,0 +1,372 @@
|
||||
import json
|
||||
import re
|
||||
import traceback
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
from gettext import gettext as _
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from cookbook.helper import ingredient_parser
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Ingredient, Keyword, Recipe, Step, Food, Unit, SupermarketCategory, PropertyType, Property, MealType, MealPlan, CookLog, ShoppingListEntry
|
||||
|
||||
|
||||
class Mealie1(Integration):
|
||||
"""
|
||||
integration for mealie past version 1.0
|
||||
"""
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
mealie_database = json.loads(BytesIO(file.read('database.json')).getvalue().decode("utf-8"))
|
||||
self.import_log.total_recipes = len(mealie_database['recipes'])
|
||||
self.import_log.msg += f"Importing {len(mealie_database["categories"]) + len(mealie_database["tags"])} tags and categories as keywords...\n"
|
||||
self.import_log.save()
|
||||
|
||||
keywords_categories_dict = {}
|
||||
for c in mealie_database['categories']:
|
||||
if keyword := Keyword.objects.filter(name=c['name'], space=self.request.space).first():
|
||||
keywords_categories_dict[c['id']] = keyword.pk
|
||||
else:
|
||||
keyword = Keyword.objects.create(name=c['name'], space=self.request.space)
|
||||
keywords_categories_dict[c['id']] = keyword.pk
|
||||
|
||||
keywords_tags_dict = {}
|
||||
for t in mealie_database['tags']:
|
||||
if keyword := Keyword.objects.filter(name=t['name'], space=self.request.space).first():
|
||||
keywords_tags_dict[t['id']] = keyword.pk
|
||||
else:
|
||||
keyword = Keyword.objects.create(name=t['name'], space=self.request.space)
|
||||
keywords_tags_dict[t['id']] = keyword.pk
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["multi_purpose_labels"])} multi purpose labels as supermarket categories...\n"
|
||||
self.import_log.save()
|
||||
|
||||
supermarket_categories_dict = {}
|
||||
for m in mealie_database['multi_purpose_labels']:
|
||||
if supermarket_category := SupermarketCategory.objects.filter(name=m['name'], space=self.request.space).first():
|
||||
supermarket_categories_dict[m['id']] = supermarket_category.pk
|
||||
else:
|
||||
supermarket_category = SupermarketCategory.objects.create(name=m['name'], space=self.request.space)
|
||||
supermarket_categories_dict[m['id']] = supermarket_category.pk
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["ingredient_foods"])} foods...\n"
|
||||
self.import_log.save()
|
||||
|
||||
foods_dict = {}
|
||||
for f in mealie_database['ingredient_foods']:
|
||||
if food := Food.objects.filter(name=f['name'], space=self.request.space).first():
|
||||
foods_dict[f['id']] = food.pk
|
||||
else:
|
||||
food = {'name': f['name'],
|
||||
'plural_name': f['plural_name'],
|
||||
'description': f['description'],
|
||||
'space': self.request.space}
|
||||
|
||||
if f['label_id'] and f['label_id'] in supermarket_categories_dict:
|
||||
food['supermarket_category_id'] = supermarket_categories_dict[f['label_id']]
|
||||
|
||||
food = Food.objects.create(**food)
|
||||
if f['on_hand']:
|
||||
food.onhand_users.add(self.request.user)
|
||||
foods_dict[f['id']] = food.pk
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["ingredient_units"])} units...\n"
|
||||
self.import_log.save()
|
||||
|
||||
units_dict = {}
|
||||
for u in mealie_database['ingredient_units']:
|
||||
if unit := Unit.objects.filter(name=u['name'], space=self.request.space).first():
|
||||
units_dict[u['id']] = unit.pk
|
||||
else:
|
||||
unit = Unit.objects.create(name=u['name'], plural_name=u['plural_name'], description=u['description'], space=self.request.space)
|
||||
units_dict[u['id']] = unit.pk
|
||||
|
||||
recipes_dict = {}
|
||||
recipe_property_factor_dict = {}
|
||||
recipes = []
|
||||
recipe_keyword_relation = []
|
||||
for r in mealie_database['recipes']:
|
||||
if Recipe.objects.filter(space=self.request.space, name=r['name']).exists() and not self.import_duplicates:
|
||||
self.import_log.msg += f"Ignoring {r['name']} because a recipe with this name already exists.\n"
|
||||
self.import_log.save()
|
||||
else:
|
||||
servings = 1
|
||||
try:
|
||||
servings = r['recipe_servings'] if r['recipe_servings'] and r['recipe_servings'] != 0 else 1
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
recipe = Recipe.objects.create(
|
||||
waiting_time=parse_time(r['perform_time']),
|
||||
working_time=parse_time(r['prep_time']),
|
||||
description=r['description'][:512],
|
||||
name=r['name'],
|
||||
source_url=r['org_url'],
|
||||
servings=servings,
|
||||
servings_text=r['recipe_yield'].strip()[:32] if r['recipe_yield'] else "",
|
||||
internal=True,
|
||||
created_at=r['created_at'],
|
||||
space=self.request.space,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
|
||||
if not self.nutrition_per_serving:
|
||||
recipe_property_factor_dict[r['id']] = recipe.servings
|
||||
|
||||
self.import_log.msg += self.get_recipe_processed_msg(recipe)
|
||||
self.import_log.imported_recipes += 1
|
||||
self.import_log.save()
|
||||
|
||||
recipes.append(recipe)
|
||||
recipes_dict[r['id']] = recipe.pk
|
||||
recipe_keyword_relation.append(Recipe.keywords.through(recipe_id=recipe.pk, keyword_id=self.keyword.pk))
|
||||
|
||||
Recipe.keywords.through.objects.bulk_create(recipe_keyword_relation, ignore_conflicts=True)
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["recipe_instructions"])} instructions...\n"
|
||||
self.import_log.save()
|
||||
|
||||
steps_relation = []
|
||||
first_step_of_recipe_dict = {}
|
||||
step_id_dict = {}
|
||||
for s in mealie_database['recipe_instructions']:
|
||||
if s['recipe_id'] in recipes_dict:
|
||||
step = Step.objects.create(instruction=(s['text'] if s['text'] else "") + (f" \n {s['summary']}" if 'summary' in s and s['summary'] else ""),
|
||||
order=s['position'],
|
||||
name=s['title'],
|
||||
space=self.request.space)
|
||||
steps_relation.append(Recipe.steps.through(recipe_id=recipes_dict[s['recipe_id']], step_id=step.pk))
|
||||
step_id_dict[s["id"]] = step.pk
|
||||
if s['recipe_id'] not in first_step_of_recipe_dict:
|
||||
first_step_of_recipe_dict[s['recipe_id']] = step.pk
|
||||
|
||||
# it is possible for a recipe to not have steps but have ingredients, in that case create an empty step to add them to later
|
||||
for r in recipes_dict.keys():
|
||||
if r not in first_step_of_recipe_dict:
|
||||
step = Step.objects.create(instruction='',
|
||||
order=0,
|
||||
name='',
|
||||
space=self.request.space)
|
||||
steps_relation.append(Recipe.steps.through(recipe_id=recipes_dict[r], step_id=step.pk))
|
||||
first_step_of_recipe_dict[r] = step.pk
|
||||
|
||||
for n in mealie_database['notes']:
|
||||
if n['recipe_id'] in recipes_dict:
|
||||
step = Step.objects.create(instruction=n['text'],
|
||||
name=n['title'][:128] if n['title'] else "",
|
||||
order=100,
|
||||
space=self.request.space)
|
||||
steps_relation.append(Recipe.steps.through(recipe_id=recipes_dict[n['recipe_id']], step_id=step.pk))
|
||||
|
||||
Recipe.steps.through.objects.bulk_create(steps_relation)
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["recipes_ingredients"])} ingredients...\n"
|
||||
self.import_log.save()
|
||||
|
||||
# mealie stores the reference to a step (instruction) from an ingredient (reference) in the recipe_ingredient_ref_link table
|
||||
recipe_ingredient_ref_link_dict = {}
|
||||
for ref in mealie_database['recipe_ingredient_ref_link']:
|
||||
recipe_ingredient_ref_link_dict[ref["reference_id"]] = ref["instruction_id"]
|
||||
|
||||
ingredients_relation = []
|
||||
for i in mealie_database['recipes_ingredients']:
|
||||
if i['recipe_id'] in recipes_dict:
|
||||
if i['title']:
|
||||
title_ingredient = Ingredient.objects.create(
|
||||
note=i['title'],
|
||||
is_header=True,
|
||||
space=self.request.space,
|
||||
)
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=get_step_id(i, first_step_of_recipe_dict, step_id_dict,recipe_ingredient_ref_link_dict), ingredient_id=title_ingredient.pk))
|
||||
if i['food_id']:
|
||||
ingredient = Ingredient.objects.create(
|
||||
food_id=foods_dict[i['food_id']] if i['food_id'] in foods_dict else None,
|
||||
unit_id=units_dict[i['unit_id']] if i['unit_id'] in units_dict else None,
|
||||
original_text=i['original_text'],
|
||||
order=i['position'],
|
||||
amount=i['quantity'] if i['quantity'] else 0,
|
||||
note=i['note'],
|
||||
space=self.request.space,
|
||||
)
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=get_step_id(i, first_step_of_recipe_dict, step_id_dict,recipe_ingredient_ref_link_dict), ingredient_id=ingredient.pk))
|
||||
elif i['note'] and i['note'].strip():
|
||||
amount, unit, food, note = ingredient_parser.parse(i['note'].strip())
|
||||
f = ingredient_parser.get_food(food)
|
||||
u = ingredient_parser.get_unit(unit)
|
||||
ingredient = Ingredient.objects.create(
|
||||
food=f,
|
||||
unit=u,
|
||||
amount=amount,
|
||||
note=note,
|
||||
original_text=i['original_text'],
|
||||
space=self.request.space,
|
||||
)
|
||||
ingredients_relation.append(Step.ingredients.through(step_id=get_step_id(i, first_step_of_recipe_dict, step_id_dict,recipe_ingredient_ref_link_dict), ingredient_id=ingredient.pk))
|
||||
Step.ingredients.through.objects.bulk_create(ingredients_relation)
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["recipes_to_categories"]) + len(mealie_database["recipes_to_tags"])} category and keyword relations...\n"
|
||||
self.import_log.save()
|
||||
|
||||
recipe_keyword_relation = []
|
||||
for rC in mealie_database['recipes_to_categories']:
|
||||
if rC['recipe_id'] in recipes_dict:
|
||||
recipe_keyword_relation.append(Recipe.keywords.through(recipe_id=recipes_dict[rC['recipe_id']], keyword_id=keywords_categories_dict[rC['category_id']]))
|
||||
|
||||
for rT in mealie_database['recipes_to_tags']:
|
||||
if rT['recipe_id'] in recipes_dict:
|
||||
recipe_keyword_relation.append(Recipe.keywords.through(recipe_id=recipes_dict[rT['recipe_id']], keyword_id=keywords_tags_dict[rT['tag_id']]))
|
||||
|
||||
Recipe.keywords.through.objects.bulk_create(recipe_keyword_relation, ignore_conflicts=True)
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["recipe_nutrition"])} properties...\n"
|
||||
self.import_log.save()
|
||||
|
||||
property_types_dict = {
|
||||
'calories': PropertyType.objects.get_or_create(name=_('Calories'), space=self.request.space, defaults={'unit': 'kcal', 'fdc_id': 1008})[0],
|
||||
'carbohydrate_content': PropertyType.objects.get_or_create(name=_('Carbohydrates'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1005})[0],
|
||||
'cholesterol_content': PropertyType.objects.get_or_create(name=_('Cholesterol'), space=self.request.space, defaults={'unit': 'mg', 'fdc_id': 1253})[0],
|
||||
'fat_content': PropertyType.objects.get_or_create(name=_('Fat'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1004})[0],
|
||||
'fiber_content': PropertyType.objects.get_or_create(name=_('Fiber'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1079})[0],
|
||||
'protein_content': PropertyType.objects.get_or_create(name=_('Protein'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1003})[0],
|
||||
'saturated_fat_content': PropertyType.objects.get_or_create(name=_('Saturated Fat'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1258})[0],
|
||||
'sodium_content': PropertyType.objects.get_or_create(name=_('Sodium'), space=self.request.space, defaults={'unit': 'mg', 'fdc_id': 1093})[0],
|
||||
'sugar_content': PropertyType.objects.get_or_create(name=_('Sugar'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1063})[0],
|
||||
'trans_fat_content': PropertyType.objects.get_or_create(name=_('Trans Fat'), space=self.request.space, defaults={'unit': 'g', 'fdc_id': 1257})[0],
|
||||
'unsaturated_fat_content': PropertyType.objects.get_or_create(name=_('Unsaturated Fat'), space=self.request.space, defaults={'unit': 'g'})[0],
|
||||
}
|
||||
|
||||
with transaction.atomic():
|
||||
recipe_properties_relation = []
|
||||
properties_relation = []
|
||||
for r in mealie_database['recipe_nutrition']:
|
||||
if r['recipe_id'] in recipes_dict:
|
||||
for key in property_types_dict:
|
||||
if key in r and r[key]:
|
||||
properties_relation.append(
|
||||
Property(property_type_id=property_types_dict[key].pk,
|
||||
property_amount=Decimal(str(r[key])) / (
|
||||
Decimal(str(recipe_property_factor_dict[r['recipe_id']])) if r['recipe_id'] in recipe_property_factor_dict else 1),
|
||||
open_data_food_slug=r['recipe_id'],
|
||||
space=self.request.space))
|
||||
properties = Property.objects.bulk_create(properties_relation)
|
||||
property_ids = []
|
||||
for p in properties:
|
||||
recipe_properties_relation.append(Recipe.properties.through(recipe_id=recipes_dict[p.open_data_food_slug], property_id=p.pk))
|
||||
property_ids.append(p.pk)
|
||||
Recipe.properties.through.objects.bulk_create(recipe_properties_relation, ignore_conflicts=True)
|
||||
Property.objects.filter(id__in=property_ids).update(open_data_food_slug=None)
|
||||
|
||||
# delete unused property types
|
||||
for pT in property_types_dict:
|
||||
try:
|
||||
property_types_dict[pT].delete()
|
||||
except:
|
||||
pass
|
||||
|
||||
self.import_log.msg += f"Importing {len(mealie_database["recipe_comments"]) + len(mealie_database["recipe_timeline_events"])} comments and cook logs...\n"
|
||||
self.import_log.save()
|
||||
|
||||
cook_log_list = []
|
||||
for c in mealie_database['recipe_comments']:
|
||||
if c['recipe_id'] in recipes_dict:
|
||||
cook_log_list.append(CookLog(
|
||||
recipe_id=recipes_dict[c['recipe_id']],
|
||||
comment=c['text'],
|
||||
created_at=c['created_at'],
|
||||
created_by=self.request.user,
|
||||
space=self.request.space,
|
||||
))
|
||||
|
||||
for c in mealie_database['recipe_timeline_events']:
|
||||
if c['recipe_id'] in recipes_dict:
|
||||
if c['event_type'] == 'comment':
|
||||
cook_log_list.append(CookLog(
|
||||
recipe_id=recipes_dict[c['recipe_id']],
|
||||
comment=c['message'],
|
||||
created_at=c['created_at'],
|
||||
created_by=self.request.user,
|
||||
space=self.request.space,
|
||||
))
|
||||
|
||||
CookLog.objects.bulk_create(cook_log_list)
|
||||
|
||||
if self.import_meal_plans:
|
||||
self.import_log.msg += f"Importing {len(mealie_database["group_meal_plans"])} meal plans...\n"
|
||||
self.import_log.save()
|
||||
|
||||
meal_types_dict = {}
|
||||
meal_plans = []
|
||||
for m in mealie_database['group_meal_plans']:
|
||||
if m['recipe_id'] in recipes_dict:
|
||||
if not m['entry_type'] in meal_types_dict:
|
||||
meal_type = MealType.objects.get_or_create(name=m['entry_type'], created_by=self.request.user, space=self.request.space)[0]
|
||||
meal_types_dict[m['entry_type']] = meal_type.pk
|
||||
meal_plans.append(MealPlan(
|
||||
recipe_id=recipes_dict[m['recipe_id']] if m['recipe_id'] else None,
|
||||
title=m['title'] if m['title'] else "",
|
||||
note=m['text'] if m['text'] else "",
|
||||
from_date=m['date'],
|
||||
to_date=m['date'],
|
||||
meal_type_id=meal_types_dict[m['entry_type']],
|
||||
created_by=self.request.user,
|
||||
space=self.request.space,
|
||||
))
|
||||
|
||||
MealPlan.objects.bulk_create(meal_plans)
|
||||
|
||||
if self.import_shopping_lists:
|
||||
self.import_log.msg += f"Importing {len(mealie_database["shopping_list_items"])} shopping list items...\n"
|
||||
self.import_log.save()
|
||||
|
||||
shopping_list_items = []
|
||||
for sli in mealie_database['shopping_list_items']:
|
||||
if not sli['checked']:
|
||||
if sli['food_id']:
|
||||
shopping_list_items.append(ShoppingListEntry(
|
||||
amount=sli['quantity'],
|
||||
unit_id=units_dict[sli['unit_id']] if sli['unit_id'] else None,
|
||||
food_id=foods_dict[sli['food_id']] if sli['food_id'] else None,
|
||||
created_by=self.request.user,
|
||||
space=self.request.space,
|
||||
))
|
||||
elif not sli['food_id'] and sli['note'].strip():
|
||||
amount, unit, food, note = ingredient_parser.parse(sli['note'].strip())
|
||||
f = ingredient_parser.get_food(food)
|
||||
u = ingredient_parser.get_unit(unit)
|
||||
shopping_list_items.append(ShoppingListEntry(
|
||||
amount=amount,
|
||||
unit=u,
|
||||
food=f,
|
||||
created_by=self.request.user,
|
||||
space=self.request.space,
|
||||
))
|
||||
ShoppingListEntry.objects.bulk_create(shopping_list_items)
|
||||
|
||||
self.import_log.msg += f"Importing Images. This might take some time ...\n"
|
||||
self.import_log.save()
|
||||
for r in mealie_database['recipes']:
|
||||
try:
|
||||
if recipe := Recipe.objects.filter(pk=recipes_dict[r['id']]).first():
|
||||
self.import_recipe_image(recipe, BytesIO(file.read(f'data/recipes/{str(uuid.UUID(str(r['id'])))}/images/original.webp')), filetype='.webp')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return recipes
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
|
||||
|
||||
def get_step_id(i, first_step_of_recipe_dict, step_id_dict, recipe_ingredient_ref_link_dict):
|
||||
try:
|
||||
return step_id_dict[recipe_ingredient_ref_link_dict[i['reference_id']]]
|
||||
except KeyError:
|
||||
return first_step_of_recipe_dict[i['recipe_id']]
|
||||
@@ -63,7 +63,15 @@ class MealMaster(Integration):
|
||||
current_recipe = ''
|
||||
|
||||
for fl in file.readlines():
|
||||
line = fl.decode("windows-1250")
|
||||
line = ""
|
||||
try:
|
||||
line = fl.decode("UTF-8")
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
line = fl.decode("windows-1250")
|
||||
except Exception as e:
|
||||
line = "ERROR DECODING LINE"
|
||||
|
||||
if (line.startswith('MMMMM') or line.startswith('-----')) and 'meal-master' in line.lower():
|
||||
if current_recipe != '':
|
||||
recipe_list.append(current_recipe)
|
||||
|
||||
@@ -94,7 +94,8 @@ class Paprika(Integration):
|
||||
url = recipe_json.get("image_url", None)
|
||||
if validate_import_url(url):
|
||||
response = requests.get(url)
|
||||
self.import_recipe_image(recipe, BytesIO(response.content))
|
||||
if response.status_code == 200 and len(response.content) > 0:
|
||||
self.import_recipe_image(recipe, BytesIO(response.content))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import imghdr
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
@@ -128,7 +128,7 @@ class RecetteTek(Integration):
|
||||
url = file['originalPicture']
|
||||
if validate_import_url(url):
|
||||
response = requests.get(url)
|
||||
if imghdr.what(BytesIO(response.content)) is not None:
|
||||
if Image.open(BytesIO(response.content)).verify():
|
||||
self.import_recipe_image(recipe, BytesIO(response.content), filetype=get_filetype(file['originalPicture']))
|
||||
else:
|
||||
raise Exception("Original image failed to download.")
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/hr/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/hr/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
2453
cookbook/locale/ko/LC_MESSAGES/django.po
Normal file
2453
cookbook/locale/ko/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -13,8 +13,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-08-01 15:04+0200\n"
|
||||
"PO-Revision-Date: 2024-09-03 19:58+0000\n"
|
||||
"Last-Translator: \"S. v.d. Gevel\" <stefan18_gw@hotmail.com>\n"
|
||||
"PO-Revision-Date: 2025-09-23 19:45+0000\n"
|
||||
"Last-Translator: Justin Straver <justin.straver@gmail.com>\n"
|
||||
"Language-Team: Dutch <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/nl/>\n"
|
||||
"Language: nl\n"
|
||||
@@ -22,14 +22,14 @@ 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 5.6.2\n"
|
||||
"X-Generator: Weblate 5.13.1\n"
|
||||
|
||||
#: .\cookbook\forms.py:45
|
||||
msgid ""
|
||||
"Both fields are optional. If none are given the username will be displayed "
|
||||
"instead"
|
||||
msgstr ""
|
||||
"Beide velden zijn optioneel. Indien niks is opgegeven wordt de "
|
||||
"Beide velden zijn optioneel. Indien niets is opgegeven wordt de "
|
||||
"gebruikersnaam weergegeven"
|
||||
|
||||
#: .\cookbook\forms.py:62 .\cookbook\forms.py:246
|
||||
@@ -46,7 +46,7 @@ msgstr "Voorbereidingstijd in minuten"
|
||||
|
||||
#: .\cookbook\forms.py:62
|
||||
msgid "Waiting time (cooking/baking) in minutes"
|
||||
msgstr "Wacht tijd in minuten (koken en bakken)"
|
||||
msgstr "Wachttijd in minuten (koken en bakken)"
|
||||
|
||||
#: .\cookbook\forms.py:63 .\cookbook\forms.py:222 .\cookbook\forms.py:246
|
||||
msgid "Path"
|
||||
@@ -286,7 +286,7 @@ msgstr "omgekeerde rotatie"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:311
|
||||
msgid "careful rotation"
|
||||
msgstr "voorzichtige rotatie"
|
||||
msgstr "rotire atentă"
|
||||
|
||||
#: .\cookbook\helper\recipe_url_import.py:312
|
||||
msgid "knead"
|
||||
@@ -771,7 +771,7 @@ msgstr ""
|
||||
#: .\cookbook\templates\account\email_confirm.html:22
|
||||
#: .\cookbook\templates\generic\delete_template.html:72
|
||||
msgid "Confirm"
|
||||
msgstr "Bevestigen"
|
||||
msgstr "Bevestig"
|
||||
|
||||
#: .\cookbook\templates\account\email_confirm.html:29
|
||||
#, python-format
|
||||
@@ -1052,7 +1052,7 @@ msgstr "Beheer"
|
||||
#: .\cookbook\templates\base.html:351
|
||||
#: .\cookbook\templates\space_overview.html:25
|
||||
msgid "Your Spaces"
|
||||
msgstr "Jouw Spaces"
|
||||
msgstr "Jouw Ruimtes"
|
||||
|
||||
#: .\cookbook\templates\base.html:362
|
||||
#: .\cookbook\templates\space_overview.html:6
|
||||
@@ -1996,12 +1996,12 @@ msgstr "Eigenaar"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:57
|
||||
msgid "Leave Space"
|
||||
msgstr "Verlaat Space"
|
||||
msgstr "Verlaat Ruimte"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:78
|
||||
#: .\cookbook\templates\space_overview.html:88
|
||||
msgid "Join Space"
|
||||
msgstr "Sluit aan bij ruimte"
|
||||
msgstr "Sluit aan bij Ruimte"
|
||||
|
||||
#: .\cookbook\templates\space_overview.html:81
|
||||
msgid "Join an existing space."
|
||||
@@ -2237,7 +2237,7 @@ msgstr "Er bestaat geen {self.basename} met id {target}"
|
||||
|
||||
#: .\cookbook\views\api.py:250 .\cookbook\views\api.py:251
|
||||
msgid "Cannot merge with child object!"
|
||||
msgstr "Kan niet met kindobject samenvoegen!"
|
||||
msgstr "Kan niet met sub object samenvoegen!"
|
||||
|
||||
#: .\cookbook\views\api.py:288 .\cookbook\views\api.py:289
|
||||
#, python-brace-format
|
||||
@@ -2592,7 +2592,7 @@ msgstr "Uitnodigingslink"
|
||||
|
||||
#: .\cookbook\views\delete.py:168
|
||||
msgid "Space Membership"
|
||||
msgstr "Space Lidmaatschap"
|
||||
msgstr "Ruimte Lidmaatschap"
|
||||
|
||||
#: .\cookbook\views\edit.py:84
|
||||
msgid "You cannot edit this storage!"
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user