mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-25 11:19:39 -05:00
Compare commits
1586 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed8f97e9e0 | ||
|
|
034f68fc28 | ||
|
|
0158087a0b | ||
|
|
cb6bfd741d | ||
|
|
afeee5f7cb | ||
|
|
b43d6e08d4 | ||
|
|
1188624376 | ||
|
|
9ac837c969 | ||
|
|
fc4b017d30 | ||
|
|
4636ac28f9 | ||
|
|
397912e87f | ||
|
|
d0b860e623 | ||
|
|
8a90ed1274 | ||
|
|
163c2a53b6 | ||
|
|
286d707347 | ||
|
|
98d308aee9 | ||
|
|
a7c5240227 | ||
|
|
75fcff8e70 | ||
|
|
2f27cf4deb | ||
|
|
686b595f45 | ||
|
|
0f9f9e8f7c | ||
|
|
aba45657c3 | ||
|
|
e6abdf8cd4 | ||
|
|
6cedde7b2d | ||
|
|
741e9eb370 | ||
|
|
7db523d8c4 | ||
|
|
41f0060c43 | ||
|
|
5572833f64 | ||
|
|
780e441a3b | ||
|
|
c4fd2d0b4e | ||
|
|
1c6618f452 | ||
|
|
8c96a75a1e | ||
|
|
44baa8322c | ||
|
|
0fbb95438a | ||
|
|
c56dd9563c | ||
|
|
0008b7c975 | ||
|
|
524f086cc5 | ||
|
|
8550387e0c | ||
|
|
1618f8df79 | ||
|
|
22dfb2a410 | ||
|
|
f099e2e5d3 | ||
|
|
6973c65142 | ||
|
|
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 | ||
|
|
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 | ||
|
|
698aa5a753 | ||
|
|
60f2494eae | ||
|
|
303f999b74 | ||
|
|
f83daf154f | ||
|
|
6444680e06 | ||
|
|
38e1db9c53 | ||
|
|
d71c929ba8 | ||
|
|
c604369e86 | ||
|
|
4865b742c7 | ||
|
|
1246549f4b | ||
|
|
55ee75fed1 | ||
|
|
846a4796e2 | ||
|
|
545c464f4f | ||
|
|
fabf0c28e3 | ||
|
|
e219f7e07c | ||
|
|
830ce14c65 | ||
|
|
79abb8bf8f | ||
|
|
fd4236672e | ||
|
|
00148a2993 | ||
|
|
359fcb24cf | ||
|
|
c1bfa563a3 | ||
|
|
bf4fc9a7aa | ||
|
|
4312b5ad65 | ||
|
|
6fc078090a | ||
|
|
a4548abc19 | ||
|
|
1a39066b45 | ||
|
|
9cf1435130 | ||
|
|
e895bda80b | ||
|
|
882f18ac29 | ||
|
|
ebfe5eee6d | ||
|
|
afac08dc01 | ||
|
|
a0ac379b3c | ||
|
|
f5cb393138 | ||
|
|
b93f8e5e95 | ||
|
|
38c8e1b84f | ||
|
|
275c879e62 | ||
|
|
cde632241b | ||
|
|
ea1e47e579 | ||
|
|
07b399cd80 | ||
|
|
ed20cf3df4 | ||
|
|
f5d7919f72 | ||
|
|
86c4278553 | ||
|
|
2a5c0bb740 | ||
|
|
432dfa9e86 | ||
|
|
8a4e32f05e | ||
|
|
5a8b4fb4ce | ||
|
|
65034e523f | ||
|
|
0c6637406a | ||
|
|
4b0d022db1 | ||
|
|
7a2b3c2d2e | ||
|
|
205eae785e | ||
|
|
e0223e0c5c | ||
|
|
d611391bea | ||
|
|
0c547353cd | ||
|
|
5ce859f267 | ||
|
|
8e0da93476 | ||
|
|
3f7c22cfe0 | ||
|
|
55ee575c9c | ||
|
|
de6627ab32 | ||
|
|
f61a8371f4 | ||
|
|
9b586ae709 | ||
|
|
0bcdf5e0a3 | ||
|
|
64a43d3d40 | ||
|
|
679957b48c | ||
|
|
0399763888 | ||
|
|
3e3780028b | ||
|
|
1c9d19fc76 | ||
|
|
ecdb1e9fee | ||
|
|
b9e5126ab4 | ||
|
|
a34fddc866 | ||
|
|
9b3bfd3d1c | ||
|
|
74000551e6 | ||
|
|
325ee16d39 | ||
|
|
37761bf6e7 | ||
|
|
ad57ce7790 | ||
|
|
169f799a23 | ||
|
|
942d1130a1 | ||
|
|
64cc20aed2 | ||
|
|
3a6731ec8d | ||
|
|
e6f11a17b9 | ||
|
|
cc1cd610e7 | ||
|
|
6a3b5ee844 | ||
|
|
49b119571e | ||
|
|
e024e3deb0 | ||
|
|
f1f907ee33 | ||
|
|
e3f20459dd | ||
|
|
da567a9d6c | ||
|
|
01a4fb57df | ||
|
|
7ccedb559d | ||
|
|
01d52143e8 | ||
|
|
35461ba926 | ||
|
|
b9f49cad45 | ||
|
|
d7487c6d5c | ||
|
|
15122387f4 | ||
|
|
103daf000d | ||
|
|
70df456307 | ||
|
|
375174ee41 | ||
|
|
931064a695 | ||
|
|
49c8a5a375 | ||
|
|
ab9f9701d8 | ||
|
|
da4abcfce2 | ||
|
|
4149549c88 | ||
|
|
fa8cd4a2f0 | ||
|
|
423dc7a6bf | ||
|
|
f19beba014 | ||
|
|
865756e4b2 | ||
|
|
41f834db08 | ||
|
|
2c94753a5a | ||
|
|
557954a259 | ||
|
|
38b72c8c72 | ||
|
|
3f395e816f | ||
|
|
0e05c77fa7 | ||
|
|
6dcc77243a | ||
|
|
110ffe52b7 | ||
|
|
2fab51ea9b | ||
|
|
425f00860f | ||
|
|
793c152b26 | ||
|
|
9df75f551c | ||
|
|
da49280ef2 | ||
|
|
e6087d5129 | ||
|
|
d91a3080d5 | ||
|
|
0485e5192b | ||
|
|
4f9bff20c8 | ||
|
|
683f1ac10a | ||
|
|
e844d2995a | ||
|
|
c0af3d19cd | ||
|
|
78d20e8340 | ||
|
|
6a90caee04 | ||
|
|
98c95a94bc | ||
|
|
d4dc4a30b8 | ||
|
|
70d2dc089c | ||
|
|
8698ad3054 | ||
|
|
7531c83379 | ||
|
|
6188f175ae | ||
|
|
189fab2401 | ||
|
|
a3adba1941 | ||
|
|
cea41af1b8 | ||
|
|
a325070f7f | ||
|
|
d782c54c2c | ||
|
|
58917fbc4d | ||
|
|
8b0547aeb9 | ||
|
|
9efc101161 | ||
|
|
691e8a940b | ||
|
|
bee7623eaf | ||
|
|
430697879f | ||
|
|
749974654a | ||
|
|
f31a661aaf | ||
|
|
70ea3acb05 | ||
|
|
81547563c6 | ||
|
|
c107f2f497 | ||
|
|
5fea2131cd | ||
|
|
d671df30a3 | ||
|
|
3aca96148d | ||
|
|
100b75a5d2 | ||
|
|
7dba36d210 | ||
|
|
0dea5c9877 | ||
|
|
1777dfe821 | ||
|
|
2b6edfb96d | ||
|
|
faa3c99965 | ||
|
|
1cc5a0a094 | ||
|
|
cbafbd44a4 | ||
|
|
f16a804758 | ||
|
|
c30e1bb43a | ||
|
|
31051086ba | ||
|
|
67d3c852dd | ||
|
|
4ea9b10fc4 | ||
|
|
e609f4e0e0 | ||
|
|
b9ee2611a3 | ||
|
|
8de3c41958 | ||
|
|
2a350a9bbe | ||
|
|
89c4c09481 | ||
|
|
a97b023504 | ||
|
|
4aa7152ab4 | ||
|
|
80ff8ddb12 | ||
|
|
45aeeaa069 | ||
|
|
0c24db72c6 | ||
|
|
95c4d72b74 | ||
|
|
1330c0e7a3 | ||
|
|
9af22bcf9f | ||
|
|
d24214c463 | ||
|
|
e8101dd433 | ||
|
|
94bd343eed | ||
|
|
f409633ade | ||
|
|
e927418535 | ||
|
|
be9f9d68db | ||
|
|
ba401877e8 | ||
|
|
77748a951b | ||
|
|
4692526e48 | ||
|
|
eb4b8555c2 | ||
|
|
6a12ca78d1 | ||
|
|
2ed53f8e50 | ||
|
|
615e3bfa3d | ||
|
|
e406bdbc2c | ||
|
|
7fd402aade | ||
|
|
e8522a4a6d | ||
|
|
eb872ddbf6 | ||
|
|
1b1c1bdd5e | ||
|
|
b2f950ebe4 | ||
|
|
8501dcc34e | ||
|
|
9fd1d76fd8 | ||
|
|
3a002cce9e | ||
|
|
416ddf3d34 | ||
|
|
e0f8d1ea9e | ||
|
|
be886b108c | ||
|
|
8b0a19c6a2 | ||
|
|
757fa1b768 | ||
|
|
736d829bd0 | ||
|
|
6829d5351d | ||
|
|
cbcddfbcd1 | ||
|
|
7d531d18d4 | ||
|
|
eee8ed70e7 | ||
|
|
805eb87754 | ||
|
|
e910ec4a51 | ||
|
|
4f425fb99a | ||
|
|
9395a456f0 | ||
|
|
a8256b461a | ||
|
|
3494bce2b8 | ||
|
|
4c0ace1d84 | ||
|
|
cae26e7fe0 | ||
|
|
b6b3e83c1e | ||
|
|
7d47fcf4e9 | ||
|
|
b857c9e4d9 | ||
|
|
25de4326d2 | ||
|
|
257f4f2b5b | ||
|
|
e4a6bd0a1f | ||
|
|
4ab0fbf36b | ||
|
|
8d070349a6 | ||
|
|
d1379935b7 | ||
|
|
64c5fe3157 | ||
|
|
ddf977f665 | ||
|
|
166697d791 | ||
|
|
90d93b733d | ||
|
|
272d2e94a1 | ||
|
|
f84a401714 | ||
|
|
f6b19d40b1 | ||
|
|
969b7ba492 | ||
|
|
c9edec6308 | ||
|
|
9f0ff1348c | ||
|
|
c85d62fc66 | ||
|
|
dae51a8d3e | ||
|
|
d522534a12 | ||
|
|
a3ee2fb69c | ||
|
|
143eafa24a | ||
|
|
a38308a24a | ||
|
|
b981960221 | ||
|
|
67fff17f06 | ||
|
|
5ada8e529c | ||
|
|
22a3654dfd | ||
|
|
9a94c650da | ||
|
|
ddaeb054d0 | ||
|
|
7d1461a752 | ||
|
|
0a3611b94a | ||
|
|
0db439c80d | ||
|
|
99103bc419 | ||
|
|
fcf9f30af0 | ||
|
|
beab927f64 | ||
|
|
91f2f34cd3 | ||
|
|
24a589fe41 | ||
|
|
b1d28a46c3 | ||
|
|
a44f61b507 | ||
|
|
fc567d2fbb | ||
|
|
12e0c7fd7b | ||
|
|
7e77e56e7f | ||
|
|
46706c633e | ||
|
|
6b376fbfbb | ||
|
|
c532b1f94f | ||
|
|
26ab7f5580 | ||
|
|
72c638ca6f | ||
|
|
5478b7d550 | ||
|
|
ea8ab582eb | ||
|
|
73e4f22256 | ||
|
|
a173e66a59 | ||
|
|
cad75408c3 | ||
|
|
cd93d9c697 | ||
|
|
ce869950c3 | ||
|
|
55bd417105 | ||
|
|
ed4592ae0c | ||
|
|
7c30204dd7 | ||
|
|
e6eacc48d6 | ||
|
|
0259e000e3 | ||
|
|
eb0a95f594 | ||
|
|
41ee8cf66f | ||
|
|
8c8834e6aa | ||
|
|
b52d61b6db | ||
|
|
1bed90b804 | ||
|
|
5eabf6869d | ||
|
|
4ee5d348bf | ||
|
|
252a7207f6 | ||
|
|
abc2dc8437 | ||
|
|
145abfa344 | ||
|
|
f60e61e331 | ||
|
|
fd534aba95 | ||
|
|
f23c99f5ea | ||
|
|
1a14cc9513 | ||
|
|
e98131ce7a | ||
|
|
7cff47efab | ||
|
|
27fe267181 | ||
|
|
1dcc3e06d4 | ||
|
|
9125921038 | ||
|
|
5f184693ca | ||
|
|
6087668e26 | ||
|
|
eea2a2c252 | ||
|
|
9554c69a39 | ||
|
|
506ab0f0c7 | ||
|
|
3e26ca5c68 | ||
|
|
3fa8a9f27e | ||
|
|
0cdff07e4b | ||
|
|
8a142c3b11 | ||
|
|
20ca7151d1 | ||
|
|
15abe9f24b | ||
|
|
754b7c3376 | ||
|
|
00b02248d3 | ||
|
|
fda302ebb6 | ||
|
|
70cd675aa1 | ||
|
|
960db5aaab | ||
|
|
8daf43d6e8 | ||
|
|
114ca55b26 | ||
|
|
59f5219c0b | ||
|
|
bc465c7b2f | ||
|
|
bf1b2db415 | ||
|
|
9085756fca | ||
|
|
db43bd1962 | ||
|
|
3c13c06ce1 | ||
|
|
830a385bda | ||
|
|
c92c19d9b2 | ||
|
|
8fe6f5c141 | ||
|
|
39ab8b00c4 | ||
|
|
d3164d3e0d | ||
|
|
beb1823a57 | ||
|
|
b6fa74c601 | ||
|
|
596b6c6f98 | ||
|
|
e6af0e3845 | ||
|
|
0eabf1f7c4 | ||
|
|
91111c7d74 | ||
|
|
7397f4c381 | ||
|
|
02ffb727d5 | ||
|
|
4ba769a49e | ||
|
|
ad71804b70 | ||
|
|
1bf9696a27 | ||
|
|
1bfe5bb6a0 | ||
|
|
b5e0055876 | ||
|
|
f6f6754669 | ||
|
|
752a25b1f8 | ||
|
|
8e8e4bb8bb | ||
|
|
06dc02d6b2 | ||
|
|
540daf2a38 | ||
|
|
9e671d93cb | ||
|
|
269be4e31a | ||
|
|
5969edc0b8 | ||
|
|
42176f42ed | ||
|
|
8c8bb159ea | ||
|
|
b9cf29a0ec | ||
|
|
5db9f33723 | ||
|
|
1ba09cc119 | ||
|
|
02bbe3fa13 | ||
|
|
6a3534da76 | ||
|
|
105a5f2bdc | ||
|
|
45543e9669 | ||
|
|
36bc96f192 | ||
|
|
516b345807 | ||
|
|
4a6d542965 | ||
|
|
fb9bbafd6e | ||
|
|
0c77ca91c1 | ||
|
|
0e44243e55 | ||
|
|
fbadf14b58 | ||
|
|
2558fe6c2b | ||
|
|
10fca9b5ae | ||
|
|
01f338e58b | ||
|
|
5e998796ab | ||
|
|
40231f45f6 | ||
|
|
b19b51c275 | ||
|
|
854877e685 | ||
|
|
028a8ddbda | ||
|
|
abb81209af | ||
|
|
578201c519 | ||
|
|
a5a23b366e | ||
|
|
ac57837f53 | ||
|
|
65b451b465 | ||
|
|
abff1136f9 | ||
|
|
3630172723 | ||
|
|
d241ee3cde | ||
|
|
6994f6bc1d | ||
|
|
25147f84ec | ||
|
|
c02523ee51 | ||
|
|
5cdc8302bb | ||
|
|
b095718545 | ||
|
|
0b53285b89 | ||
|
|
d1ea4360ca | ||
|
|
257372db5a | ||
|
|
855f20f2da | ||
|
|
8a4ffc5e0c | ||
|
|
5b3445a5b5 | ||
|
|
b05bff0fa0 | ||
|
|
51f0f943f7 | ||
|
|
11b42470b6 | ||
|
|
71e5e32206 | ||
|
|
6f44c8ba17 | ||
|
|
4f73e57ab2 | ||
|
|
1b529bba10 | ||
|
|
072cd00e59 | ||
|
|
4b03c1a8dd | ||
|
|
45a6564e17 | ||
|
|
f614413fb1 | ||
|
|
74153d79b8 | ||
|
|
edf06944e0 | ||
|
|
a02582e9f8 | ||
|
|
65641c1256 | ||
|
|
930e686cbd | ||
|
|
c4ff29beda | ||
|
|
cc839a1ae9 | ||
|
|
af9a95651d | ||
|
|
45106ae99a | ||
|
|
0ac3c55180 | ||
|
|
a289fd427b | ||
|
|
6385866e98 | ||
|
|
c2176305db | ||
|
|
fd3760198b | ||
|
|
826ddddd5d | ||
|
|
93613c9781 | ||
|
|
818ca0b2e4 | ||
|
|
17d34c5ca7 | ||
|
|
965f7c04d8 | ||
|
|
0e1e312f7c | ||
|
|
202d8afa10 | ||
|
|
769561349f | ||
|
|
0cb415f70d | ||
|
|
08309a6f04 | ||
|
|
10b4c3da05 | ||
|
|
6257f6ffb7 | ||
|
|
80f8a524ef | ||
|
|
6d4dbc26a4 | ||
|
|
6f3fc2fcab | ||
|
|
63759c985f | ||
|
|
98d4ce5ff8 | ||
|
|
2cf9c288be | ||
|
|
9254a36636 | ||
|
|
6a8b2b6338 | ||
|
|
3f258fbd87 | ||
|
|
fd1c6d718e | ||
|
|
7100f4eb8b | ||
|
|
7285d8b01c | ||
|
|
78da168a2e | ||
|
|
f54009ae00 | ||
|
|
f92f18ecb2 | ||
|
|
44832f7c11 | ||
|
|
e475115b6a | ||
|
|
b32a7a9134 | ||
|
|
381adb2a5b | ||
|
|
d976fafd45 | ||
|
|
7e7657e444 | ||
|
|
5f2e683b6b | ||
|
|
5f4283ca3f | ||
|
|
9df03a73d9 | ||
|
|
6d813ebb2f | ||
|
|
f961413e94 | ||
|
|
17693b90b5 | ||
|
|
4b551c595a | ||
|
|
c11dd8bcae | ||
|
|
ef3913d91f | ||
|
|
569b7e78fe | ||
|
|
c87964f6d0 | ||
|
|
353b2d6d21 | ||
|
|
1d58f318ca | ||
|
|
32b75250dc | ||
|
|
743fcbcfc1 | ||
|
|
748db37a1b | ||
|
|
a75650c045 | ||
|
|
ce9c469acc | ||
|
|
dc8958bee1 | ||
|
|
e405aab310 | ||
|
|
846c3e36cc | ||
|
|
771375b40b | ||
|
|
b42a444a67 | ||
|
|
4771f890cb | ||
|
|
991ff88767 | ||
|
|
4fa7155fc3 | ||
|
|
9e49652f80 | ||
|
|
931865b61f | ||
|
|
f0088b256e | ||
|
|
a047d36bf1 | ||
|
|
ebcc814abf | ||
|
|
b83f3a291b | ||
|
|
e4ff6ed732 | ||
|
|
a67b084b52 | ||
|
|
dd3f38fe75 | ||
|
|
3184deb00e | ||
|
|
2847721584 | ||
|
|
894a298f45 | ||
|
|
17610663c1 | ||
|
|
12cf9da8fc | ||
|
|
1e4f6d243c | ||
|
|
717c989ae9 | ||
|
|
4933726b79 | ||
|
|
c47e46263c | ||
|
|
e040a10096 | ||
|
|
d81e7f6c2b | ||
|
|
f1ab60bcd1 | ||
|
|
ce6c43fb62 | ||
|
|
faf025d2c4 | ||
|
|
68e6ab4be5 | ||
|
|
7ea9592398 | ||
|
|
52d8e8d9ba | ||
|
|
8e50742372 | ||
|
|
b0533f2f9b | ||
|
|
fd0d5813fb | ||
|
|
8cd79cdc20 | ||
|
|
d3abe4db3e | ||
|
|
b7e23df4c2 | ||
|
|
71765f3542 | ||
|
|
1e326fe414 | ||
|
|
f06b3e8af0 | ||
|
|
4edd729850 | ||
|
|
8412aa19fb | ||
|
|
fce57f7f03 | ||
|
|
5f0eb73927 | ||
|
|
fd8411b475 | ||
|
|
f312f6028d | ||
|
|
f401b0f635 | ||
|
|
7eaa33ac69 | ||
|
|
7ce2c042c3 | ||
|
|
e22c89a0e9 | ||
|
|
e5691d0e98 | ||
|
|
4266029e84 | ||
|
|
849856df26 | ||
|
|
371d8a76b8 | ||
|
|
7f63451b9a | ||
|
|
ee8237d493 | ||
|
|
91d3c43422 | ||
|
|
1700b9c531 | ||
|
|
7133249f4b | ||
|
|
ab9ea87549 | ||
|
|
975ad34672 | ||
|
|
6cb6861228 | ||
|
|
cc24964368 | ||
|
|
714242b6df | ||
|
|
e398fc0b38 | ||
|
|
c33c4ed79b | ||
|
|
971f8f3dad | ||
|
|
ca5fda7927 | ||
|
|
2e6cc3e58e | ||
|
|
42b7d1afcb | ||
|
|
99868e4e80 | ||
|
|
38b22f3a56 | ||
|
|
b70a3bce9f | ||
|
|
fb821ba0ef | ||
|
|
f14acc371d | ||
|
|
514c4106b1 | ||
|
|
3cf89aca10 | ||
|
|
9bf8b615dc | ||
|
|
cb8dd3bc99 | ||
|
|
1cd9caef4a | ||
|
|
1025829123 | ||
|
|
019a931b99 | ||
|
|
8398193a51 | ||
|
|
c031db9019 | ||
|
|
78686faad3 | ||
|
|
f560365ded | ||
|
|
ebc2902450 | ||
|
|
b9e806b818 | ||
|
|
d7669279ff | ||
|
|
4293ec77c0 | ||
|
|
0a30c39add | ||
|
|
1b6449270b | ||
|
|
3ae264eea7 | ||
|
|
c9dc7164f5 | ||
|
|
5751ba1ec5 | ||
|
|
3eca8c6db4 | ||
|
|
5cccbb8e5c | ||
|
|
4390703c0c | ||
|
|
47bc3cfbe7 | ||
|
|
30a2012e90 | ||
|
|
b38ea866b4 | ||
|
|
5a0b9e14d2 | ||
|
|
4f0f59a55c | ||
|
|
768b678c93 | ||
|
|
10373b6ac5 | ||
|
|
4e0780d512 | ||
|
|
959ad2a45c | ||
|
|
dfd9f7b066 | ||
|
|
cb98b6723f | ||
|
|
dcf7d44d72 | ||
|
|
369c460837 | ||
|
|
e99ff005d6 | ||
|
|
c1d6e98349 | ||
|
|
497a4bbeb9 | ||
|
|
f47c806ebd | ||
|
|
bb2a0eb642 | ||
|
|
28fd1917ec | ||
|
|
cf0f7482f1 | ||
|
|
2475eadab9 | ||
|
|
97cf2e6372 | ||
|
|
94045905d3 | ||
|
|
ad8d8daf79 | ||
|
|
b623abf81e | ||
|
|
f8b8d3f199 | ||
|
|
2c12ce3edf | ||
|
|
be388b0d10 | ||
|
|
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 | ||
|
|
41a448578a | ||
|
|
441c55936d | ||
|
|
3207b69874 | ||
|
|
b67281bbc8 | ||
|
|
5a1a5f3c4d | ||
|
|
30e2fc4895 | ||
|
|
57304f9c6c | ||
|
|
87327b0959 | ||
|
|
bd317858bf | ||
|
|
9d4c26fd29 | ||
|
|
7957413ca0 | ||
|
|
d99a157416 | ||
|
|
20b812c2cc | ||
|
|
f0d0550251 | ||
|
|
7bfa23b953 | ||
|
|
ae37abf8b2 | ||
|
|
f132eacb83 | ||
|
|
5f211e420e | ||
|
|
1235cb8da5 | ||
|
|
b75e3a7848 | ||
|
|
5e3a5eb8f5 | ||
|
|
b6a42e8e81 | ||
|
|
49539ef3ba | ||
|
|
db310c4076 | ||
|
|
79a7e60cfc | ||
|
|
0248e1c500 | ||
|
|
db04386997 | ||
|
|
54f0b2b036 | ||
|
|
33b23b299d | ||
|
|
a047613edb | ||
|
|
149cf93618 | ||
|
|
673c660d26 | ||
|
|
8f03899302 | ||
|
|
20e0c948c4 | ||
|
|
ceb68af503 | ||
|
|
d8c86a4bb8 | ||
|
|
a9dcc7261c | ||
|
|
341c6abc02 | ||
|
|
5c2d92103b | ||
|
|
7b9bd5bc2a | ||
|
|
e242412ec4 | ||
|
|
6aaec29c8a | ||
|
|
854af133c4 | ||
|
|
ac961ef7d2 | ||
|
|
b6f3ed6bd9 | ||
|
|
ccf56e24be | ||
|
|
5298b69d83 | ||
|
|
f2f004db87 | ||
|
|
9416406732 | ||
|
|
eeae2c1740 | ||
|
|
45d3fd34be | ||
|
|
bd61906aa4 | ||
|
|
c322782e89 | ||
|
|
8ccd4b5045 | ||
|
|
5b3207bc24 | ||
|
|
57314c56c8 | ||
|
|
4cd1e0a4a5 | ||
|
|
83e9a2bbfb | ||
|
|
46d2b7730e | ||
|
|
b7c2b5c294 | ||
|
|
2e6becb73d | ||
|
|
7b9d140e74 | ||
|
|
d2aeef7e63 | ||
|
|
0c6850d498 | ||
|
|
8e700ba53c | ||
|
|
ffe02bf210 | ||
|
|
e78dd305f3 | ||
|
|
e12c83faf1 | ||
|
|
05102d3842 | ||
|
|
2f203d7786 | ||
|
|
2d021a83cf | ||
|
|
18767c54ce | ||
|
|
dda2cc16e7 | ||
|
|
07957814f9 | ||
|
|
658bc5ca54 | ||
|
|
09dc35228f | ||
|
|
539eb8e612 | ||
|
|
ba54a44e04 | ||
|
|
5ecddaf02f | ||
|
|
a6c0dba684 | ||
|
|
7986d9c8f3 | ||
|
|
312c215813 | ||
|
|
02523f5325 | ||
|
|
36887b3488 | ||
|
|
bb77f80abf | ||
|
|
9c92e0f4c0 | ||
|
|
a6e8fa8ddf | ||
|
|
37fb0418ac | ||
|
|
2264050d40 | ||
|
|
aebc4a45ff | ||
|
|
f061e02a95 | ||
|
|
952d50d8dd | ||
|
|
3489216daf | ||
|
|
8e9285a24e | ||
|
|
95583dbe2c | ||
|
|
cf20b22404 | ||
|
|
8f55e15767 | ||
|
|
f2ce164a1e | ||
|
|
bfdd5a8bfc | ||
|
|
73ba5a591d | ||
|
|
82ebeacea9 | ||
|
|
c0c71c3967 | ||
|
|
aa5a87a1fc | ||
|
|
302faa193a | ||
|
|
9fe3eeb823 | ||
|
|
e0f7ce5de9 | ||
|
|
76eecedfb5 | ||
|
|
8f216a2791 | ||
|
|
75e3d826a0 | ||
|
|
cf61de0dba | ||
|
|
29a937c44d | ||
|
|
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,12 @@
|
||||
# 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
|
||||
|
||||
# add only a database password if you want to run with the default postgres, otherwise change settings accordingly
|
||||
DB_ENGINE=django.db.backends.postgresql
|
||||
POSTGRES_HOST=db_recipes
|
||||
|
||||
22
.flake8
Normal file
22
.flake8
Normal file
@@ -0,0 +1,22 @@
|
||||
[flake8]
|
||||
extend-ignore =
|
||||
# Whitespace before ':' - Required for black compatibility
|
||||
E203,
|
||||
# Line break occurred before a binary operator - Required for black compatibility
|
||||
W503,
|
||||
# Comparison to False should be 'if cond is False:' or 'if not cond:'
|
||||
E712
|
||||
exclude =
|
||||
.git,
|
||||
**/__pycache__,
|
||||
**/.git,
|
||||
**/.svn,
|
||||
**/.hg,
|
||||
**/CVS,
|
||||
**/.DS_Store,
|
||||
.vscode,
|
||||
**/*.pyc
|
||||
per-file-ignores=
|
||||
cookbook/apps.py:F401
|
||||
max-line-length = 179
|
||||
|
||||
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
|
||||
18
.github/workflows/build-docker.yml
vendored
18
.github/workflows/build-docker.yml
vendored
@@ -17,7 +17,7 @@ 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:
|
||||
@@ -34,17 +34,17 @@ jobs:
|
||||
echo VERSION=develop >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Build Vue frontend
|
||||
# Build Vue 3 frontend
|
||||
- uses: actions/setup-node@v4
|
||||
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! 🥳'
|
||||
|
||||
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -9,14 +9,13 @@ 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.1
|
||||
- uses: awalsh128/cache-apt-pkgs-action@v1.5.1
|
||||
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
|
||||
@@ -37,10 +36,9 @@ 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 }}
|
||||
@@ -49,30 +47,28 @@ jobs:
|
||||
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/*') }}
|
||||
|
||||
30
.gitignore
vendored
30
.gitignore
vendored
@@ -47,6 +47,11 @@ docs/reports/**
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
mediafiles/
|
||||
*.sqlite3*
|
||||
staticfiles/
|
||||
postgresql/
|
||||
data/
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
@@ -59,32 +64,31 @@ target/
|
||||
|
||||
\.idea/dataSources\.local\.xml
|
||||
|
||||
venv/
|
||||
|
||||
mediafiles/
|
||||
|
||||
*.sqlite3*
|
||||
|
||||
\.idea/workspace\.xml
|
||||
|
||||
\.idea/misc\.xml
|
||||
|
||||
# Deployment
|
||||
|
||||
\.env
|
||||
staticfiles/
|
||||
postgresql/
|
||||
data/
|
||||
|
||||
|
||||
cookbook/static/vue
|
||||
vue/webpack-stats.json
|
||||
/docker-compose.override.yml
|
||||
vue/node_modules
|
||||
plugins
|
||||
/recipes/plugins
|
||||
vetur.config.js
|
||||
cookbook/static/vue
|
||||
vue/webpack-stats.json
|
||||
cookbook/templates/sw.js
|
||||
.prettierignore
|
||||
vue/.yarn
|
||||
vue3/.vite
|
||||
|
||||
# Configs
|
||||
vetur.config.js
|
||||
venv/
|
||||
.idea/easy-i18n.xml
|
||||
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>
|
||||
91
.idea/watcherTasks.xml
generated
Normal file
91
.idea/watcherTasks.xml
generated
Normal file
@@ -0,0 +1,91 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectTasksOptions">
|
||||
<TaskOptions isEnabled="false">
|
||||
<option name="arguments" value="-m flake8 $FilePath$ --config $ContentRoot$\.flake8" />
|
||||
<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="Flake8 Watcher" />
|
||||
<option name="output" value="$FilePath$" />
|
||||
<option name="outputFilters">
|
||||
<array>
|
||||
<FilterInfo>
|
||||
<option name="description" value="" />
|
||||
<option name="name" value="" />
|
||||
<option name="regExp" value="$FILE_PATH$:$LINE$:$COLUMN$: $MESSAGE$" />
|
||||
</FilterInfo>
|
||||
</array>
|
||||
</option>
|
||||
<option name="outputFromStdout" value="false" />
|
||||
<option name="program" value="$PyInterpreterDirectory$/python" />
|
||||
<option name="runOnExternalChanges" value="false" />
|
||||
<option name="scopeName" value="Current File" />
|
||||
<option name="trackOnlyRoot" value="false" />
|
||||
<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>
|
||||
13
.prettierignore
Normal file
13
.prettierignore
Normal file
@@ -0,0 +1,13 @@
|
||||
# generated files
|
||||
api.ts
|
||||
vue/src/apps/*.js
|
||||
vue/node_modules
|
||||
staticfiles/
|
||||
docs/reports/
|
||||
/vue3/src/openapi/
|
||||
|
||||
# ignored files - prettier interferes with django templates and github actions
|
||||
*.html
|
||||
*.yml
|
||||
*.yaml
|
||||
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"printWidth": 179,
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"experimentalTernaries": true
|
||||
}
|
||||
21
.vscode/launch.json
vendored
21
.vscode/launch.json
vendored
@@ -4,7 +4,6 @@
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
|
||||
{
|
||||
"name": "Python Debugger: Django",
|
||||
"type": "debugpy",
|
||||
@@ -13,6 +12,22 @@
|
||||
"args": ["runserver"],
|
||||
"django": true,
|
||||
"justMyCode": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Python: Debug Tests",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${file}",
|
||||
"purpose": [
|
||||
"debug-test"
|
||||
],
|
||||
"console": "integratedTerminal",
|
||||
"env": {
|
||||
// coverage and pytest can't both be running at the same time
|
||||
"PYTEST_ADDOPTS": "--no-cov -n 0"
|
||||
},
|
||||
"django": true,
|
||||
"justMyCode": true
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
11
.vscode/settings.json
vendored
11
.vscode/settings.json
vendored
@@ -3,5 +3,12 @@
|
||||
"cookbook/tests"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true
|
||||
}
|
||||
"python.testing.pytestEnabled": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "eeyore.yapf",
|
||||
},
|
||||
"yapf.args": [],
|
||||
"isort.args": []
|
||||
}
|
||||
|
||||
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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
37
Dockerfile
37
Dockerfile
@@ -1,13 +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 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
|
||||
@@ -15,28 +16,38 @@ 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 . ./
|
||||
|
||||
# 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
|
||||
# delete git repositories to reduce image size
|
||||
RUN find . -type d -name ".git" | xargs rm -rf
|
||||
|
||||
RUN chmod +x boot.sh
|
||||
ENTRYPOINT ["/opt/recipes/boot.sh"]
|
||||
ENTRYPOINT ["/sbin/tini", "--", "/opt/recipes/boot.sh"]
|
||||
|
||||
15
README.md
15
README.md
@@ -15,14 +15,15 @@
|
||||
<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>
|
||||
|
||||
@@ -71,7 +72,7 @@ Because of that there are several ways you can support us
|
||||
- **Let us host for you** We are offering a [hosted version](https://app.tandoor.dev) where all profits support us and the development of tandoor (currently only available in germany).
|
||||
|
||||
## Contributing
|
||||
Contributions are welcome but please read [this](https://docs.tandoor.dev/contribute/#contributing-code) **BEFORE** contributing anything!
|
||||
Contributions are welcome but please read [this](https://docs.tandoor.dev/contribute/guidelines/) **BEFORE** contributing anything!
|
||||
|
||||
## Your Feedback
|
||||
|
||||
@@ -81,13 +82,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>
|
||||
|
||||
|
||||
62
boot.sh
Normal file → Executable file
62
boot.sh
Normal file → Executable file
@@ -1,11 +1,21 @@
|
||||
#!/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:-8080}"
|
||||
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}"
|
||||
|
||||
if [ "${TANDOOR_PORT}" -eq 80 ]; then
|
||||
echo "TANDOOR_PORT set to 8080 because 80 is now taken by the integrated nginx"
|
||||
TANDOOR_PORT=8080
|
||||
fi
|
||||
|
||||
display_warning() {
|
||||
echo "[WARNING]"
|
||||
@@ -14,11 +24,6 @@ display_warning() {
|
||||
|
||||
echo "Checking configuration..."
|
||||
|
||||
# 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
|
||||
|
||||
# SECRET_KEY (or a valid file at SECRET_KEY_FILE) must be set in .env file
|
||||
|
||||
if [ -f "${SECRET_KEY_FILE}" ]; then
|
||||
@@ -29,6 +34,21 @@ if [ -z "${SECRET_KEY}" ]; then
|
||||
display_warning "The environment variable 'SECRET_KEY' (or 'SECRET_KEY_FILE' that points to an existing file) is not set but REQUIRED for running Tandoor!"
|
||||
fi
|
||||
|
||||
if [ -f "${AUTH_LDAP_BIND_PASSWORD_FILE}" ]; then
|
||||
export AUTH_LDAP_BIND_PASSWORD=$(cat "$AUTH_LDAP_BIND_PASSWORD_FILE")
|
||||
fi
|
||||
|
||||
if [ -f "${EMAIL_HOST_PASSWORD_FILE}" ]; then
|
||||
export EMAIL_HOST_PASSWORD=$(cat "$EMAIL_HOST_PASSWORD_FILE")
|
||||
fi
|
||||
|
||||
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..."
|
||||
|
||||
@@ -64,16 +84,34 @@ echo "Database is ready"
|
||||
|
||||
echo "Migrating database"
|
||||
|
||||
|
||||
python manage.py migrate
|
||||
|
||||
echo "Generating static files"
|
||||
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
|
||||
|
||||
echo "Done"
|
||||
|
||||
chmod -R 755 /opt/recipes/mediafiles
|
||||
chmod -R 755 ${MEDIA_ROOT:-/opt/recipes/mediafiles}
|
||||
|
||||
exec gunicorn -b "[::]:$TANDOOR_PORT" --workers $GUNICORN_WORKERS --threads $GUNICORN_THREADS --access-logfile - --error-logfile - --log-level $GUNICORN_LOG_LEVEL recipes.wsgi
|
||||
ipv6_disable=$(cat /sys/module/ipv6/parameters/disable)
|
||||
|
||||
# prepare nginx config
|
||||
envsubst '$MEDIA_ROOT $STATIC_ROOT $TANDOOR_PORT' < /opt/recipes/http.d/Recipes.conf.template > /opt/recipes/http.d/Recipes.conf
|
||||
|
||||
# start nginx
|
||||
echo "Starting nginx"
|
||||
nginx
|
||||
|
||||
echo "Starting gunicorn"
|
||||
# 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
|
||||
|
||||
@@ -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',)
|
||||
search_fields = ('name', 'space', 'model',)
|
||||
|
||||
|
||||
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')
|
||||
@@ -185,7 +209,7 @@ class StepAdmin(admin.ModelAdmin):
|
||||
@admin.display(description="Name")
|
||||
def recipe_and_name(obj):
|
||||
if not obj.recipe_set.exists():
|
||||
return f"Orphaned Step{'':s if not obj.name else f': {obj.name}'}"
|
||||
return "Orphaned Step" + ('' if not obj.name else f': {obj.name}')
|
||||
return f"{obj.recipe_set.first().name}: {obj.name}" if obj.name else obj.recipe_set.first().name
|
||||
|
||||
|
||||
@@ -376,10 +400,17 @@ class ShareLinkAdmin(admin.ModelAdmin):
|
||||
admin.site.register(ShareLink, ShareLinkAdmin)
|
||||
|
||||
|
||||
@admin.action(description='Delete all properties with type')
|
||||
def delete_properties_with_type(modeladmin, request, queryset):
|
||||
for pt in queryset:
|
||||
Property.objects.filter(property_type=pt).delete()
|
||||
|
||||
|
||||
class PropertyTypeAdmin(admin.ModelAdmin):
|
||||
search_fields = ('space',)
|
||||
search_fields = ('name',)
|
||||
|
||||
list_display = ('id', 'space', 'name', 'fdc_id')
|
||||
actions = [delete_properties_with_type]
|
||||
|
||||
|
||||
admin.site.register(PropertyType, PropertyTypeAdmin)
|
||||
|
||||
@@ -16,15 +16,11 @@ class CookbookConfig(AppConfig):
|
||||
import cookbook.signals # noqa
|
||||
|
||||
if not settings.DISABLE_EXTERNAL_CONNECTORS:
|
||||
try:
|
||||
from cookbook.connectors.connector_manager import ConnectorManager # Needs to be here to prevent loading race condition of oauth2 modules in models.py
|
||||
handler = ConnectorManager()
|
||||
post_save.connect(handler, dispatch_uid="connector_manager")
|
||||
post_delete.connect(handler, dispatch_uid="connector_manager")
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
print('Failed to initialize connectors')
|
||||
pass
|
||||
from cookbook.connectors.connector_manager import ConnectorManager # Needs to be here to prevent loading race condition of oauth2 modules in models.py
|
||||
handler = ConnectorManager()
|
||||
post_save.connect(handler, dispatch_uid="post_save-connector_manager")
|
||||
post_delete.connect(handler, dispatch_uid="post_delete-connector_manager")
|
||||
|
||||
# if not settings.DISABLE_TREE_FIX_STARTUP:
|
||||
# # when starting up run fix_tree to:
|
||||
# # a) make sure that nodes are sorted when switching between sort modes
|
||||
@@ -45,4 +41,4 @@ class CookbookConfig(AppConfig):
|
||||
# except Exception:
|
||||
# if DEBUG:
|
||||
# traceback.print_exc()
|
||||
# pass # dont break startup just because fix could not run, need to investigate cases when this happens
|
||||
# pass # dont break startup just because fix could not run, need to investigate cases when this happens
|
||||
@@ -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?)
|
||||
|
||||
@@ -5,13 +5,14 @@ import threading
|
||||
from asyncio import Task
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from logging import Logger
|
||||
from types import UnionType
|
||||
from typing import List, Any, Dict, Optional, Type
|
||||
|
||||
from django.conf import settings
|
||||
from django_scopes import scope
|
||||
|
||||
from cookbook.connectors.connector import Connector
|
||||
from cookbook.connectors.connector import Connector, ShoppingListEntryDTO
|
||||
from cookbook.connectors.homeassistant import HomeAssistant
|
||||
from cookbook.models import ShoppingListEntry, Space, ConnectorConfig
|
||||
|
||||
@@ -30,6 +31,15 @@ class Work:
|
||||
actionType: ActionType
|
||||
|
||||
|
||||
class Singleton(type):
|
||||
_instances = {}
|
||||
|
||||
def __call__(cls, *args, **kwargs):
|
||||
if cls not in cls._instances:
|
||||
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
|
||||
return cls._instances[cls]
|
||||
|
||||
|
||||
# The way ConnectionManager works is as follows:
|
||||
# 1. On init, it starts a worker & creates a queue for 'Work'
|
||||
# 2. Then any time its called, it verifies the type of action (create/update/delete) and if the item is of interest, pushes the Work (non-blocking) to the queue.
|
||||
@@ -38,20 +48,21 @@ class Work:
|
||||
# 3.2 If work is of type REGISTERED_CLASSES, it asynchronously fires of all connectors and wait for them to finish (runtime should depend on the 'slowest' connector)
|
||||
# 4. Work is marked as consumed, and next entry of the queue is consumed.
|
||||
# Each 'Work' is processed in sequential by the worker, so the throughput is about [workers * the slowest connector]
|
||||
class ConnectorManager:
|
||||
# The Singleton class is used for ConnectorManager to have a self-reference and so Python does not garbage collect it
|
||||
class ConnectorManager(metaclass=Singleton):
|
||||
_logger: Logger
|
||||
_queue: queue.Queue
|
||||
_listening_to_classes = REGISTERED_CLASSES | ConnectorConfig
|
||||
|
||||
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
|
||||
@@ -62,22 +73,45 @@ class ConnectorManager:
|
||||
else:
|
||||
return
|
||||
|
||||
try:
|
||||
self._queue.put_nowait(Work(instance, action_type))
|
||||
except queue.Full:
|
||||
logging.info(f"queue was full, so skipping {action_type} of type {type(instance)}")
|
||||
return
|
||||
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")
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
logging.info(f"started ConnectionManager worker {worker_id}")
|
||||
logger.info(f"started ConnectionManager worker {worker_id}")
|
||||
|
||||
# When multiple workers are used, please make sure the cache is shared across all threads, otherwise it might lead to un-expected behavior.
|
||||
_connectors_cache: Dict[int, List[Connector]] = dict()
|
||||
@@ -91,6 +125,8 @@ class ConnectorManager:
|
||||
if item is None:
|
||||
break
|
||||
|
||||
logger.debug(f"received {item.instance=} with {item.actionType=}")
|
||||
|
||||
# If a Connector was changed/updated, refresh connector from the database for said space
|
||||
refresh_connector_cache = isinstance(item.instance, ConnectorConfig)
|
||||
|
||||
@@ -99,7 +135,7 @@ class ConnectorManager:
|
||||
|
||||
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()
|
||||
@@ -111,7 +147,7 @@ class ConnectorManager:
|
||||
try:
|
||||
connector: Optional[Connector] = ConnectorManager.get_connected_for_config(config)
|
||||
except BaseException:
|
||||
logging.exception(f"failed to initialize {config.name}")
|
||||
logger.exception(f"failed to initialize {config.name}")
|
||||
continue
|
||||
|
||||
if connector is not None:
|
||||
@@ -123,10 +159,12 @@ class ConnectorManager:
|
||||
worker_queue.task_done()
|
||||
continue
|
||||
|
||||
loop.run_until_complete(run_connectors(connectors, space, item.instance, item.actionType))
|
||||
logger.debug(f"running {len(connectors)} connectors for {item.instance=} with {item.actionType=}")
|
||||
|
||||
loop.run_until_complete(run_connectors(connectors, item.instance, item.actionType))
|
||||
worker_queue.task_done()
|
||||
|
||||
logging.info(f"terminating ConnectionManager worker {worker_id}")
|
||||
logger.info(f"terminating ConnectionManager worker {worker_id}")
|
||||
|
||||
asyncio.set_event_loop(None)
|
||||
loop.close()
|
||||
@@ -140,7 +178,14 @@ class ConnectorManager:
|
||||
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:
|
||||
@@ -152,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
|
||||
|
||||
@@ -1,85 +1,101 @@
|
||||
import logging
|
||||
from logging import Logger
|
||||
from typing import Dict, Tuple
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from homeassistant_api import Client, HomeassistantAPIError, Domain
|
||||
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):
|
||||
_domains_cache: dict[str, Domain]
|
||||
_config: ConnectorConfig
|
||||
_logger: Logger
|
||||
_client: Client
|
||||
|
||||
def __init__(self, config: ConnectorConfig):
|
||||
if not config.token or not config.url or not config.todo_entity:
|
||||
raise ValueError("config for HomeAssistantConnector in incomplete")
|
||||
|
||||
self._domains_cache = dict()
|
||||
self._config = config
|
||||
self._logger = logging.getLogger("connector.HomeAssistant")
|
||||
self._client = Client(self._config.url, self._config.token, async_cache_session=False, use_async=True)
|
||||
self._logger = logging.getLogger(f"recipes.connector.homeassistant.{config.name}")
|
||||
|
||||
async def on_shopping_list_entry_created(self, space: Space, shopping_list_entry: ShoppingListEntry) -> None:
|
||||
if config.url[-1] != "/":
|
||||
config.url += "/"
|
||||
self._config = config
|
||||
|
||||
async def homeassistant_api_call(self, method: str, path: str, data: Dict) -> str:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._config.token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
async with request(method, urljoin(self._config.url, path), headers=headers, json=data) as response:
|
||||
response.raise_for_status()
|
||||
return await response.json()
|
||||
|
||||
async def on_shopping_list_entry_created(self, shopping_list_entry: ShoppingListEntryDTO) -> None:
|
||||
if not self._config.on_shopping_list_entry_created_enabled:
|
||||
return
|
||||
|
||||
item, description = _format_shopping_list_entry(shopping_list_entry)
|
||||
|
||||
todo_domain = self._domains_cache.get('todo')
|
||||
self._logger.debug(f"adding {item=} with {description=} to {self._config.todo_entity}")
|
||||
|
||||
data = {
|
||||
"entity_id": self._config.todo_entity,
|
||||
"item": item,
|
||||
}
|
||||
|
||||
if self._config.supports_description_field:
|
||||
data["description"] = description
|
||||
|
||||
try:
|
||||
if todo_domain is None:
|
||||
todo_domain = await self._client.async_get_domain('todo')
|
||||
self._domains_cache['todo'] = todo_domain
|
||||
await self.homeassistant_api_call("POST", "services/todo/add_item", data)
|
||||
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)=}")
|
||||
|
||||
logging.debug(f"pushing {item} to {self._config.name}")
|
||||
await todo_domain.add_item(entity_id=self._config.todo_entity, item=item)
|
||||
except HomeassistantAPIError as err:
|
||||
self._logger.warning(f"[HomeAssistant {self._config.name}] Received an exception from the api: {err=}, {type(err)=}")
|
||||
|
||||
async def on_shopping_list_entry_updated(self, space: Space, shopping_list_entry: ShoppingListEntry) -> None:
|
||||
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
|
||||
|
||||
item, description = _format_shopping_list_entry(shopping_list_entry)
|
||||
item, _ = _format_shopping_list_entry(shopping_list_entry)
|
||||
|
||||
self._logger.debug(f"removing {item=} from {self._config.todo_entity}")
|
||||
|
||||
data = {
|
||||
"entity_id": self._config.todo_entity,
|
||||
"item": item,
|
||||
}
|
||||
|
||||
todo_domain = self._domains_cache.get('todo')
|
||||
try:
|
||||
if todo_domain is None:
|
||||
todo_domain = await self._client.async_get_domain('todo')
|
||||
self._domains_cache['todo'] = todo_domain
|
||||
|
||||
logging.debug(f"deleting {item} from {self._config.name}")
|
||||
await todo_domain.remove_item(entity_id=self._config.todo_entity, item=item)
|
||||
except HomeassistantAPIError as err:
|
||||
self._logger.warning(f"[HomeAssistant {self._config.name}] Received an exception from the api: {err=}, {type(err)=}")
|
||||
await self.homeassistant_api_call("POST", "services/todo/remove_item", data)
|
||||
except ClientResponseError as err:
|
||||
# This error will always trigger if the item is not present/found
|
||||
self._logger.debug(f"received an exception from the api: {err.request_info.url=}, {err.request_info.method=}, {err.status=}, {err.message=}, {type(err)=}")
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.async_cache_session.close()
|
||||
pass
|
||||
|
||||
|
||||
def _format_shopping_list_entry(shopping_list_entry: ShoppingListEntry):
|
||||
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 = "Imported by TandoorRecipes"
|
||||
if shopping_list_entry.created_by.first_name and len(shopping_list_entry.created_by.first_name) > 0:
|
||||
description += f", created by {shopping_list_entry.created_by.first_name}"
|
||||
description = "From TandoorRecipes"
|
||||
if shopping_list_entry.created_by.first_name:
|
||||
description += f", by {shopping_list_entry.created_by.first_name}"
|
||||
else:
|
||||
description += f", created by {shopping_list_entry.created_by.username}"
|
||||
description += f", by {shopping_list_entry.created_by.username}"
|
||||
|
||||
return item, description
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import datetime
|
||||
|
||||
from allauth.account.forms import ResetPasswordForm, SignupForm
|
||||
from allauth.socialaccount.forms import SignupForm as SocialSignupForm
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -12,60 +13,14 @@ 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', )
|
||||
js = ('custom/js/form_select.js',)
|
||||
|
||||
|
||||
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, }
|
||||
|
||||
|
||||
js = ('custom/js/form_multiselect.js',)
|
||||
class ImportExportBase(forms.Form):
|
||||
DEFAULT = 'DEFAULT'
|
||||
PAPRIKA = 'PAPRIKA'
|
||||
@@ -89,13 +44,13 @@ class ImportExportBase(forms.Form):
|
||||
COOKMATE = 'COOKMATE'
|
||||
REZEPTSUITEDE = 'REZEPTSUITEDE'
|
||||
PDF = 'PDF'
|
||||
GOURMET = 'GOURMET'
|
||||
|
||||
type = forms.ChoiceField(choices=((DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'), (MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'),
|
||||
(SAFFRON, 'Saffron'), (CHEFTAP, 'ChefTap'), (PEPPERPLATE, 'Pepperplate'), (RECETTETEK, 'RecetteTek'), (RECIPESAGE, 'Recipe Sage'),
|
||||
(DOMESTICA, 'Domestica'), (MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'), (OPENEATS, 'Openeats'), (RECIPEKEEPER, 'Recipe Keeper'),
|
||||
(PLANTOEAT, 'Plantoeat'), (COOKBOOKAPP, 'CookBookApp'), (COPYMETHAT, 'CopyMeThat'), (PDF, 'PDF'), (MELARECIPES, 'Melarecipes'),
|
||||
(COOKMATE, 'Cookmate'), (REZEPTSUITEDE, 'Recipesuite.de')))
|
||||
|
||||
(COOKMATE, 'Cookmate'), (REZEPTSUITEDE, 'Recipesuite.de'), (GOURMET, 'Gourmet')))
|
||||
|
||||
class MultipleFileInput(forms.ClearableFileInput):
|
||||
allow_multiple_selected = True
|
||||
@@ -120,8 +75,6 @@ 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)
|
||||
|
||||
|
||||
class ExportForm(ImportExportBase):
|
||||
recipes = forms.ModelMultipleChoiceField(widget=MultiSelectWidget, queryset=Recipe.objects.none(), required=False)
|
||||
all = forms.BooleanField(required=False)
|
||||
@@ -132,120 +85,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,
|
||||
)
|
||||
|
||||
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', '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):
|
||||
@@ -308,6 +148,18 @@ class AllAuthSignupForm(SignupForm):
|
||||
pass
|
||||
|
||||
|
||||
class AllAuthSocialSignupForm(SocialSignupForm):
|
||||
terms = forms.BooleanField(label=_('Accept Terms and Privacy'))
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
if settings.PRIVACY_URL == '' and settings.TERMS_URL == '':
|
||||
self.fields.pop('terms')
|
||||
|
||||
def signup(self, request, user):
|
||||
pass
|
||||
|
||||
|
||||
class CustomPasswordResetForm(ResetPasswordForm):
|
||||
captcha = hCaptchaField()
|
||||
|
||||
@@ -321,37 +173,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,
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
from django.test.runner import DiscoverRunner
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
|
||||
class CustomTestRunner(DiscoverRunner):
|
||||
def run_tests(self, *args, **kwargs):
|
||||
with scopes_disabled():
|
||||
return super().run_tests(*args, **kwargs)
|
||||
@@ -1,4 +1,12 @@
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
from django.db.models import Func
|
||||
from ipaddress import ip_address
|
||||
|
||||
from recipes import settings
|
||||
|
||||
|
||||
class Round(Func):
|
||||
@@ -11,3 +19,30 @@ def str2bool(v):
|
||||
return v
|
||||
else:
|
||||
return v.lower() in ("yes", "true", "1")
|
||||
|
||||
|
||||
"""
|
||||
validates an url that is supposed to be imported
|
||||
checks that the protocol used is http(s) and that no local address is accessed
|
||||
@:param url to test
|
||||
@:return true if url is valid, false otherwise
|
||||
"""
|
||||
|
||||
|
||||
def validate_import_url(url):
|
||||
try:
|
||||
validator = URLValidator(schemes=['http', 'https'])
|
||||
validator(url)
|
||||
except ValidationError:
|
||||
# if schema is not http or https, consider url invalid
|
||||
return False
|
||||
|
||||
# resolve IP address of url
|
||||
try:
|
||||
url_ip_address = ip_address(str(socket.gethostbyname(urlparse(url).hostname)))
|
||||
except (ValueError, AttributeError, TypeError, Exception) as e:
|
||||
# if ip cannot be parsed, consider url invalid
|
||||
return False
|
||||
|
||||
# validate that IP is neither private nor any other special address
|
||||
return not any([url_ip_address.is_private, url_ip_address.is_reserved, url_ip_address.is_loopback, url_ip_address.is_multicast, url_ip_address.is_link_local, ])
|
||||
|
||||
80
cookbook/helper/ai_helper.py
Normal file
80
cookbook/helper/ai_helper.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.utils import timezone
|
||||
from django.db.models import Sum
|
||||
from litellm import CustomLogger
|
||||
|
||||
from cookbook.models import AiLog
|
||||
|
||||
|
||||
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
|
||||
|
||||
def __init__(self, space, user, ai_provider):
|
||||
super().__init__()
|
||||
self.space = space
|
||||
self.user = user
|
||||
self.ai_provider = ai_provider
|
||||
|
||||
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
|
||||
|
||||
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=AiLog.F_FILE_IMPORT,
|
||||
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
|
||||
@@ -339,7 +341,7 @@ 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']],
|
||||
'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', '', self.data[datatype][k]['fdc_id']) if self.data[datatype][k]['fdc_id'] != '' else None,
|
||||
'open_data_slug': k,
|
||||
'properties_food_unit_id': None,
|
||||
@@ -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
|
||||
|
||||
@@ -160,18 +160,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
|
||||
@@ -333,6 +330,24 @@ class CustomRecipePermission(permissions.BasePermission):
|
||||
return ((has_group_permission(request.user, ['guest']) and request.method in SAFE_METHODS)
|
||||
or has_group_permission(request.user, ['user'])) and obj.space == request.space
|
||||
|
||||
class 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):
|
||||
"""
|
||||
|
||||
@@ -44,13 +44,17 @@ 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:
|
||||
found_property = True
|
||||
@@ -58,12 +62,19 @@ class FoodPropertyHelper:
|
||||
computed_properties[pt.id]['food_values'] = self.add_or_create(
|
||||
computed_properties[p.property_type.id]['food_values'], c.food.id, (c.amount / i.food.properties_food_amount) * p.property_amount, c.food)
|
||||
if not found_property:
|
||||
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 and 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}
|
||||
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 +82,8 @@ class FoodPropertyHelper:
|
||||
# TODO move to central helper ? --> use defaultdict
|
||||
@staticmethod
|
||||
def add_or_create(d, key, value, food):
|
||||
if key in d:
|
||||
if key in d and d[key]['value']:
|
||||
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__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 or self._timescooked_gte or self._timescooked_lte:
|
||||
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':
|
||||
if self._timescooked:
|
||||
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))
|
||||
elif self._timescooked_lte:
|
||||
self._queryset = self._queryset.filter(favorite__lte=int(self._timescooked_lte)).exclude(favorite=0)
|
||||
elif self._timescooked_gte:
|
||||
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),
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.utils.dateparse import parse_duration
|
||||
from django.utils.translation import gettext as _
|
||||
from isodate import parse_duration as iso_parse_duration
|
||||
from isodate.isoerror import ISO8601Error
|
||||
from pytube import YouTube
|
||||
from pytubefix import YouTube
|
||||
from recipe_scrapers._utils import get_host_name, get_minutes
|
||||
|
||||
from cookbook.helper.automation_helper import AutomationEngine
|
||||
@@ -15,12 +15,9 @@ from cookbook.models import Automation, Keyword, PropertyType
|
||||
|
||||
|
||||
def get_from_scraper(scrape, request):
|
||||
# converting the scrape_me object to the existing json format based on ld+json
|
||||
# converting the scrape_html object to the existing json format based on ld+json
|
||||
|
||||
recipe_json = {
|
||||
'steps': [],
|
||||
'internal': True
|
||||
}
|
||||
recipe_json = {'steps': [], 'internal': True}
|
||||
keywords = []
|
||||
|
||||
# assign source URL
|
||||
@@ -31,7 +28,9 @@ def get_from_scraper(scrape, request):
|
||||
source_url = scrape.url
|
||||
except Exception:
|
||||
pass
|
||||
if source_url:
|
||||
if source_url == "https://urlnotfound.none" or not source_url:
|
||||
recipe_json['source_url'] = ''
|
||||
else:
|
||||
recipe_json['source_url'] = source_url
|
||||
try:
|
||||
keywords.append(source_url.replace('http://', '').replace('https://', '').split('/')[0])
|
||||
@@ -107,14 +106,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:
|
||||
@@ -157,11 +156,18 @@ def get_from_scraper(scrape, request):
|
||||
# assign steps
|
||||
try:
|
||||
for i in parse_instructions(scrape.instructions()):
|
||||
recipe_json['steps'].append({'instruction': i, 'ingredients': [], 'show_ingredients_table': request.user.userpreference.show_step_ingredients, })
|
||||
recipe_json['steps'].append({
|
||||
'instruction': i,
|
||||
'ingredients': [],
|
||||
'show_ingredients_table': request.user.userpreference.show_step_ingredients,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
if len(recipe_json['steps']) == 0:
|
||||
recipe_json['steps'].append({'instruction': '', 'ingredients': [], })
|
||||
recipe_json['steps'].append({
|
||||
'instruction': '',
|
||||
'ingredients': [],
|
||||
})
|
||||
|
||||
recipe_json['description'] = recipe_json['description'][:512]
|
||||
if len(recipe_json['description']) > 256: # split at 256 as long descriptions don't look good on recipe cards
|
||||
@@ -182,23 +188,24 @@ def get_from_scraper(scrape, request):
|
||||
'original_text': x
|
||||
}
|
||||
if unit:
|
||||
ingredient['unit'] = {'name': unit, }
|
||||
ingredient['unit'] = {
|
||||
'name': unit,
|
||||
}
|
||||
recipe_json['steps'][0]['ingredients'].append(ingredient)
|
||||
except Exception:
|
||||
recipe_json['steps'][0]['ingredients'].append(
|
||||
{
|
||||
'amount': 0,
|
||||
'unit': None,
|
||||
'food': {
|
||||
'name': x,
|
||||
},
|
||||
'note': '',
|
||||
'original_text': x
|
||||
}
|
||||
)
|
||||
recipe_json['steps'][0]['ingredients'].append({
|
||||
'amount': 0,
|
||||
'unit': None,
|
||||
'food': {
|
||||
'name': x,
|
||||
},
|
||||
'note': '',
|
||||
'original_text': x
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
recipe_json['properties'] = []
|
||||
try:
|
||||
recipe_json['properties'] = get_recipe_properties(request.space, scrape.schema.nutrients())
|
||||
print(recipe_json['properties'])
|
||||
@@ -221,6 +228,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()):
|
||||
@@ -231,7 +245,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
|
||||
@@ -248,14 +262,16 @@ def get_from_youtube_scraper(url, request):
|
||||
'working_time': 0,
|
||||
'waiting_time': 0,
|
||||
'image': "",
|
||||
'keywords': [{'name': kw.name, 'label': kw.name, 'id': kw.pk}],
|
||||
'keywords': [{
|
||||
'name': kw.name,
|
||||
'label': kw.name,
|
||||
'id': kw.pk
|
||||
}],
|
||||
'source_url': url,
|
||||
'steps': [
|
||||
{
|
||||
'ingredients': [],
|
||||
'instruction': ''
|
||||
}
|
||||
]
|
||||
'steps': [{
|
||||
'ingredients': [],
|
||||
'instruction': ''
|
||||
}]
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -266,9 +282,8 @@ def get_from_youtube_scraper(url, request):
|
||||
default_recipe_json['image'] = video.thumbnail_url
|
||||
if video.description:
|
||||
default_recipe_json['steps'][0]['instruction'] = automation_engine.apply_regex_replace_automation(video.description, Automation.INSTRUCTION_REPLACE)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
traceback.print_exc()
|
||||
|
||||
return default_recipe_json
|
||||
|
||||
@@ -367,8 +382,8 @@ def parse_servings(servings):
|
||||
servings = 1
|
||||
elif isinstance(servings, list):
|
||||
try:
|
||||
servings = int(re.findall(r'\b\d+\b', servings[0])[0])
|
||||
except KeyError:
|
||||
servings = int(re.findall(r'\b\d+\b', str(servings[0]))[0])
|
||||
except (KeyError, IndexError):
|
||||
servings = 1
|
||||
return servings
|
||||
|
||||
@@ -417,9 +432,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
|
||||
|
||||
@@ -452,10 +467,7 @@ def normalize_string(string):
|
||||
|
||||
|
||||
def iso_duration_to_minutes(string):
|
||||
match = re.match(
|
||||
r'P((?P<years>\d+)Y)?((?P<months>\d+)M)?((?P<weeks>\d+)W)?((?P<days>\d+)D)?T((?P<hours>\d+)H)?((?P<minutes>\d+)M)?((?P<seconds>\d+)S)?',
|
||||
string
|
||||
).groupdict()
|
||||
match = re.match(r'P((?P<years>\d+)Y)?((?P<months>\d+)M)?((?P<weeks>\d+)W)?((?P<days>\d+)D)?T((?P<hours>\d+)H)?((?P<minutes>\d+)M)?((?P<seconds>\d+)S)?', string).groupdict()
|
||||
return int(match['days'] or 0) * 24 * 60 + int(match['hours'] or 0) * 60 + int(match['minutes'] or 0)
|
||||
|
||||
|
||||
@@ -479,7 +491,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)
|
||||
|
||||
@@ -12,7 +12,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/'):
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import json
|
||||
from recipe_scrapers._abstract import AbstractScraper
|
||||
|
||||
|
||||
class CooksIllustrated(AbstractScraper):
|
||||
@classmethod
|
||||
def host(cls, site='cooksillustrated'):
|
||||
return {
|
||||
'cooksillustrated': f"{site}.com",
|
||||
'americastestkitchen': f"{site}.com",
|
||||
'cookscountry': f"{site}.com",
|
||||
}.get(site)
|
||||
|
||||
def title(self):
|
||||
return self.schema.title()
|
||||
|
||||
def image(self):
|
||||
return self.schema.image()
|
||||
|
||||
def total_time(self):
|
||||
if not self.recipe:
|
||||
self.get_recipe()
|
||||
return self.recipe['recipeTimeNote']
|
||||
|
||||
def yields(self):
|
||||
if not self.recipe:
|
||||
self.get_recipe()
|
||||
return self.recipe['yields']
|
||||
|
||||
def ingredients(self):
|
||||
if not self.recipe:
|
||||
self.get_recipe()
|
||||
ingredients = []
|
||||
for group in self.recipe['ingredientGroups']:
|
||||
ingredients += group['fields']['recipeIngredientItems']
|
||||
return [
|
||||
"{} {} {}{}".format(
|
||||
i['fields']['qty'] or '',
|
||||
i['fields']['measurement'] or '',
|
||||
i['fields']['ingredient']['fields']['title'] or '',
|
||||
i['fields']['postText'] or ''
|
||||
)
|
||||
for i in ingredients
|
||||
]
|
||||
|
||||
def instructions(self):
|
||||
if not self.recipe:
|
||||
self.get_recipe()
|
||||
if self.recipe.get('headnote', False):
|
||||
i = ['Note: ' + self.recipe.get('headnote', '')]
|
||||
else:
|
||||
i = []
|
||||
return "\n".join(
|
||||
i
|
||||
+ [self.recipe.get('whyThisWorks', '')]
|
||||
+ [
|
||||
instruction['fields']['content']
|
||||
for instruction in self.recipe['instructions']
|
||||
]
|
||||
)
|
||||
|
||||
def nutrients(self):
|
||||
raise NotImplementedError("This should be implemented.")
|
||||
|
||||
def get_recipe(self):
|
||||
j = json.loads(self.soup.find(type='application/json').string)
|
||||
name = list(j['props']['initialState']['content']['documents'])[0]
|
||||
self.recipe = j['props']['initialState']['content']['documents'][name]
|
||||
@@ -1,43 +0,0 @@
|
||||
from json import JSONDecodeError
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from recipe_scrapers import SCRAPERS, get_host_name
|
||||
from recipe_scrapers._factory import SchemaScraperFactory
|
||||
from recipe_scrapers._schemaorg import SchemaOrg
|
||||
|
||||
from .cooksillustrated import CooksIllustrated
|
||||
|
||||
CUSTOM_SCRAPERS = {
|
||||
CooksIllustrated.host(site="cooksillustrated"): CooksIllustrated,
|
||||
CooksIllustrated.host(site="americastestkitchen"): CooksIllustrated,
|
||||
CooksIllustrated.host(site="cookscountry"): CooksIllustrated,
|
||||
}
|
||||
SCRAPERS.update(CUSTOM_SCRAPERS)
|
||||
|
||||
|
||||
def text_scraper(text, url=None):
|
||||
domain = None
|
||||
if url:
|
||||
domain = get_host_name(url)
|
||||
if domain in SCRAPERS:
|
||||
scraper_class = SCRAPERS[domain]
|
||||
else:
|
||||
scraper_class = SchemaScraperFactory.SchemaScraper
|
||||
|
||||
class TextScraper(scraper_class):
|
||||
def __init__(
|
||||
self,
|
||||
html=None,
|
||||
url=None,
|
||||
):
|
||||
self.wild_mode = False
|
||||
self.meta_http_equiv = False
|
||||
self.soup = BeautifulSoup(html, "html.parser")
|
||||
self.url = url
|
||||
self.recipe = None
|
||||
try:
|
||||
self.schema = SchemaOrg(html)
|
||||
except (JSONDecodeError, AttributeError):
|
||||
pass
|
||||
|
||||
return TextScraper(url=url, html=text)
|
||||
@@ -113,7 +113,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)
|
||||
@@ -153,8 +153,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
|
||||
|
||||
@@ -3,6 +3,8 @@ from gettext import gettext as _
|
||||
import bleach
|
||||
import markdown as md
|
||||
from jinja2 import Template, TemplateSyntaxError, UndefinedError
|
||||
from jinja2.exceptions import SecurityError
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
from markdown.extensions.tables import TableExtension
|
||||
|
||||
from cookbook.helper.mdx_attributes import MarkdownFormatExtension
|
||||
@@ -68,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()
|
||||
]
|
||||
)
|
||||
@@ -89,11 +91,13 @@ def render_instructions(step): # TODO deduplicate markdown cleanup code
|
||||
return f"<scalable-number v-bind:number='{bleach.clean(str(number))}' v-bind:factor='ingredient_factor'></scalable-number>"
|
||||
|
||||
try:
|
||||
template = Template(instructions)
|
||||
instructions = template.render(ingredients=ingredients, scale=scale)
|
||||
env = SandboxedEnvironment()
|
||||
instructions = env.from_string(instructions).render(ingredients=ingredients, scale=scale)
|
||||
except TemplateSyntaxError:
|
||||
return _('Could not parse template code.') + ' Error: Template Syntax broken'
|
||||
except UndefinedError:
|
||||
return _('Could not parse template code.') + ' Error: Undefined Error'
|
||||
except SecurityError:
|
||||
return _('Could not parse template code.') + ' Error: Security Error'
|
||||
|
||||
return instructions
|
||||
|
||||
@@ -20,6 +20,7 @@ CONVERSION_TABLE = {
|
||||
'gallon': 0.264172,
|
||||
'tbsp': 67.628,
|
||||
'tsp': 202.884,
|
||||
'us_cup': 4.22675,
|
||||
'imperial_fluid_ounce': 35.1951,
|
||||
'imperial_pint': 1.75975,
|
||||
'imperial_quart': 0.879877,
|
||||
|
||||
@@ -2,12 +2,12 @@ import re
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
import validators
|
||||
|
||||
from cookbook.helper.HelperFunctions import validate_import_url
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import (get_from_scraper, get_images_from_soup,
|
||||
iso_duration_to_minutes)
|
||||
from cookbook.helper.scrapers.scrapers import text_scraper
|
||||
from recipe_scrapers import scrape_html
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Ingredient, Recipe, Step
|
||||
|
||||
@@ -20,7 +20,7 @@ class CookBookApp(Integration):
|
||||
def get_recipe_from_file(self, file):
|
||||
recipe_html = file.getvalue().decode("utf-8")
|
||||
|
||||
scrape = text_scraper(text=recipe_html)
|
||||
scrape = scrape_html(html=recipe_html, org_url="https://cookbookapp.import", supported_only=False)
|
||||
recipe_json = get_from_scraper(scrape, self.request)
|
||||
images = list(dict.fromkeys(get_images_from_soup(scrape.soup, 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 validators.url(url, public=True):
|
||||
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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
import validators
|
||||
|
||||
from cookbook.helper.HelperFunctions import validate_import_url
|
||||
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
|
||||
@@ -69,7 +69,7 @@ class Cookmate(Integration):
|
||||
if recipe_xml.find('imageurl') is not None:
|
||||
try:
|
||||
url = recipe_xml.find('imageurl').text.strip()
|
||||
if validators.url(url, public=True):
|
||||
if validate_import_url(url):
|
||||
response = requests.get(url)
|
||||
self.import_recipe_image(recipe, BytesIO(response.content))
|
||||
except Exception as e:
|
||||
|
||||
211
cookbook/integration/gourmet.py
Normal file
211
cookbook/integration/gourmet.py
Normal file
@@ -0,0 +1,211 @@
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from lxml import etree
|
||||
import requests
|
||||
from pathlib import Path
|
||||
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
|
||||
from cookbook.helper.HelperFunctions import validate_import_url
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text, parse_time, iso_duration_to_minutes
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Ingredient, Recipe, Step, Keyword
|
||||
from recipe_scrapers import scrape_html
|
||||
|
||||
|
||||
class Gourmet(Integration):
|
||||
|
||||
def split_recipe_file(self, file):
|
||||
encoding = 'utf-8'
|
||||
byte_string = file.read()
|
||||
text_obj = byte_string.decode(encoding, errors="ignore")
|
||||
soup = BeautifulSoup(text_obj, "html.parser")
|
||||
return soup.find_all("div", {"class": "recipe"})
|
||||
|
||||
def get_ingredients_recursive(self, step, ingredients, ingredient_parser):
|
||||
if isinstance(ingredients, Tag):
|
||||
for ingredient in ingredients.children:
|
||||
if not isinstance(ingredient, Tag):
|
||||
continue
|
||||
|
||||
if ingredient.name in ["li"]:
|
||||
step_name = "".join(ingredient.findAll(text=True, recursive=False)).strip().rstrip(":")
|
||||
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
is_header=True,
|
||||
note=step_name[:256],
|
||||
original_text=step_name,
|
||||
space=self.request.space,
|
||||
))
|
||||
next_ingrediets = ingredient.find("ul", {"class": "ing"})
|
||||
self.get_ingredients_recursive(step, next_ingrediets, ingredient_parser)
|
||||
|
||||
else:
|
||||
try:
|
||||
amount, unit, food, note = ingredient_parser.parse(ingredient.text.strip())
|
||||
f = ingredient_parser.get_food(food)
|
||||
u = ingredient_parser.get_unit(unit)
|
||||
step.ingredients.add(
|
||||
Ingredient.objects.create(
|
||||
food=f,
|
||||
unit=u,
|
||||
amount=amount,
|
||||
note=note,
|
||||
original_text=ingredient.text.strip(),
|
||||
space=self.request.space,
|
||||
)
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
# 'file' comes is as a beautifulsoup object
|
||||
|
||||
source_url = None
|
||||
for item in file.find_all('a'):
|
||||
if item.has_attr('href'):
|
||||
source_url = item.get("href")
|
||||
break
|
||||
|
||||
name = file.find("p", {"class": "title"}).find("span", {"itemprop": "name"}).text.strip()
|
||||
|
||||
recipe = Recipe.objects.create(
|
||||
name=name[:128],
|
||||
source_url=source_url,
|
||||
created_by=self.request.user,
|
||||
internal=True,
|
||||
space=self.request.space,
|
||||
)
|
||||
|
||||
for category in file.find_all("span", {"itemprop": "recipeCategory"}):
|
||||
keyword, created = Keyword.objects.get_or_create(name=category.text, space=self.request.space)
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
try:
|
||||
recipe.servings = parse_servings(file.find("span", {"itemprop": "recipeYield"}).text.strip())
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
prep_time = file.find("span", {"itemprop": "prepTime"}).text.strip().split()
|
||||
prep_time[0] = prep_time[0].replace(',', '.')
|
||||
if prep_time[1].lower() in ['stunde', 'stunden', 'hour', 'hours']:
|
||||
prep_time_min = int(float(prep_time[0]) * 60)
|
||||
elif prep_time[1].lower() in ['tag', 'tage', 'day', 'days']:
|
||||
prep_time_min = int(float(prep_time[0]) * 60 * 24)
|
||||
else:
|
||||
prep_time_min = int(prep_time[0])
|
||||
recipe.waiting_time = prep_time_min
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
cook_time = file.find("span", {"itemprop": "cookTime"}).text.strip().split()
|
||||
cook_time[0] = cook_time[0].replace(',', '.')
|
||||
if cook_time[1].lower() in ['stunde', 'stunden', 'hour', 'hours']:
|
||||
cook_time_min = int(float(cook_time[0]) * 60)
|
||||
elif cook_time[1].lower() in ['tag', 'tage', 'day', 'days']:
|
||||
cook_time_min = int(float(cook_time[0]) * 60 * 24)
|
||||
else:
|
||||
cook_time_min = int(cook_time[0])
|
||||
|
||||
recipe.working_time = cook_time_min
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
for cuisine in file.find_all('span', {'itemprop': 'recipeCuisine'}):
|
||||
cuisine_name = cuisine.text
|
||||
keyword = Keyword.objects.get_or_create(space=self.request.space, name=cuisine_name)
|
||||
if len(keyword):
|
||||
recipe.keywords.add(keyword[0])
|
||||
|
||||
for category in file.find_all('span', {'itemprop': 'recipeCategory'}):
|
||||
category_name = category.text
|
||||
keyword = Keyword.objects.get_or_create(space=self.request.space, name=category_name)
|
||||
if len(keyword):
|
||||
recipe.keywords.add(keyword[0])
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction='',
|
||||
space=self.request.space,
|
||||
show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
|
||||
)
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
|
||||
ingredients = file.find("ul", {"class": "ing"})
|
||||
self.get_ingredients_recursive(step, ingredients, ingredient_parser)
|
||||
|
||||
instructions = file.find("div", {"class": "instructions"})
|
||||
if isinstance(instructions, Tag):
|
||||
for instruction in instructions.children:
|
||||
if not isinstance(instruction, Tag) or instruction.text == "":
|
||||
continue
|
||||
if instruction.name == "h3":
|
||||
if step.instruction:
|
||||
step.save()
|
||||
recipe.steps.add(step)
|
||||
step = Step.objects.create(
|
||||
instruction='',
|
||||
space=self.request.space,
|
||||
)
|
||||
|
||||
step.name = instruction.text.strip()[:128]
|
||||
else:
|
||||
if instruction.name == "div":
|
||||
for instruction_step in instruction.children:
|
||||
for br in instruction_step.find_all("br"):
|
||||
br.replace_with("\n")
|
||||
step.instruction += instruction_step.text.strip() + ' \n\n'
|
||||
|
||||
notes = file.find("div", {"class": "modifications"})
|
||||
if notes:
|
||||
for n in notes.children:
|
||||
if n.text == "":
|
||||
continue
|
||||
if n.name == "h3":
|
||||
step.instruction += f'*{n.text.strip()}:* \n\n'
|
||||
else:
|
||||
for br in n.find_all("br"):
|
||||
br.replace_with("\n")
|
||||
|
||||
step.instruction += '*' + n.text.strip() + '* \n\n'
|
||||
|
||||
description = ''
|
||||
try:
|
||||
description = file.find("div", {"id": "description"}).text.strip()
|
||||
except AttributeError:
|
||||
pass
|
||||
if len(description) <= 512:
|
||||
recipe.description = description
|
||||
else:
|
||||
recipe.description = description[:480] + ' ... (full description below)'
|
||||
step.instruction += '*Description:* \n\n*' + description + '* \n\n'
|
||||
|
||||
step.save()
|
||||
recipe.steps.add(step)
|
||||
|
||||
# import the Primary recipe image that is stored in the Zip
|
||||
try:
|
||||
image_path = file.find("img").get("src")
|
||||
image_filename = image_path.split("\\")[1]
|
||||
|
||||
for f in self.import_zip.filelist:
|
||||
zip_file_name = Path(f.filename).name
|
||||
if image_filename == zip_file_name:
|
||||
image_file = self.import_zip.read(f)
|
||||
image_bytes = BytesIO(image_file)
|
||||
self.import_recipe_image(recipe, image_bytes, filetype='.jpeg')
|
||||
break
|
||||
except Exception as e:
|
||||
print(recipe.name, ': failed to import image ', str(e))
|
||||
|
||||
recipe.save()
|
||||
return recipe
|
||||
|
||||
def get_files_from_recipes(self, recipes, el, cookie):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
@@ -153,6 +153,19 @@ class Integration:
|
||||
il.total_recipes = len(new_file_list)
|
||||
file_list = new_file_list
|
||||
|
||||
if isinstance(self, cookbook.integration.gourmet.Gourmet):
|
||||
self.import_zip = import_zip
|
||||
new_file_list = []
|
||||
for file in file_list:
|
||||
if file.file_size == 0:
|
||||
next
|
||||
if file.filename.startswith("index.htm"):
|
||||
next
|
||||
if file.filename.endswith(".htm"):
|
||||
new_file_list += self.split_recipe_file(BytesIO(import_zip.read(file.filename)))
|
||||
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):
|
||||
|
||||
@@ -72,14 +72,14 @@ class Mealie(Integration):
|
||||
)
|
||||
recipe.steps.add(step)
|
||||
|
||||
if 'recipe_yield' in recipe_json:
|
||||
if 'recipe_yield' in recipe_json and recipe_json['recipe_yield'] is not None:
|
||||
recipe.servings = parse_servings(recipe_json['recipe_yield'])
|
||||
recipe.servings_text = parse_servings_text(recipe_json['recipe_yield'])
|
||||
|
||||
if 'total_time' in recipe_json and recipe_json['total_time'] is not None:
|
||||
recipe.working_time = parse_time(recipe_json['total_time'])
|
||||
|
||||
if 'org_url' in recipe_json:
|
||||
if 'org_url' in recipe_json and recipe_json['org_url'] is not None:
|
||||
recipe.source_url = recipe_json['org_url']
|
||||
|
||||
recipe.save()
|
||||
|
||||
@@ -60,20 +60,20 @@ class NextcloudCookbook(Integration):
|
||||
step = Step.objects.create(
|
||||
instruction=s, space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
|
||||
)
|
||||
if not ingredients_added:
|
||||
if len(recipe_json['description'].strip()) > 500:
|
||||
step.instruction = recipe_json['description'].strip() + '\n\n' + step.instruction
|
||||
|
||||
ingredients_added = True
|
||||
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
ingredient_parser = IngredientParser(self.request, True)
|
||||
if ingredients_added == False:
|
||||
for ingredient in recipe_json['recipeIngredient']:
|
||||
amount, unit, food, note = ingredient_parser.parse(ingredient)
|
||||
f = ingredient_parser.get_food(food)
|
||||
u = ingredient_parser.get_unit(unit)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space,
|
||||
))
|
||||
ingredients_added = True
|
||||
if ingredient.startswith('##'):
|
||||
subheader = ingredient.replace('##', '', 1)
|
||||
step.ingredients.add(Ingredient.objects.create(note=subheader, is_header=True, no_amount=True, space=self.request.space))
|
||||
else:
|
||||
amount, unit, food, note = ingredient_parser.parse(ingredient)
|
||||
f = ingredient_parser.get_food(food)
|
||||
u = ingredient_parser.get_unit(unit)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note, original_text=ingredient, space=self.request.space,))
|
||||
recipe.steps.add(step)
|
||||
|
||||
if 'nutrition' in recipe_json:
|
||||
|
||||
@@ -6,8 +6,8 @@ from gettext import gettext as _
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
import validators
|
||||
|
||||
from cookbook.helper.HelperFunctions import validate_import_url
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.helper.recipe_url_import import parse_servings, parse_servings_text
|
||||
from cookbook.integration.integration import Integration
|
||||
@@ -84,13 +84,23 @@ class Paprika(Integration):
|
||||
|
||||
recipe.steps.add(step)
|
||||
|
||||
# Paprika exports can have images in either of image_url, or photo_data.
|
||||
# If a user takes an image himself, only photo_data will be set.
|
||||
# If a user imports an image, both will be set. But the photo_data will be a center-cropped square resized version, so the image_url is preferred.
|
||||
|
||||
# Try to download image if possible
|
||||
try:
|
||||
if recipe_json.get("image_url", None):
|
||||
url = recipe_json.get("image_url", None)
|
||||
if validators.url(url, public=True):
|
||||
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
|
||||
|
||||
# If no image downloaded, try to extract from photo_data
|
||||
if not recipe.image:
|
||||
if recipe_json.get("photo_data", None):
|
||||
self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_json['photo_data'])), filetype='.jpeg')
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
import validators
|
||||
|
||||
from cookbook.helper.HelperFunctions import validate_import_url
|
||||
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
|
||||
|
||||
@@ -18,32 +19,38 @@ class Plantoeat(Integration):
|
||||
tags = None
|
||||
ingredients = []
|
||||
directions = []
|
||||
description = ''
|
||||
fields = {}
|
||||
for line in file.replace('\r', '').split('\n'):
|
||||
if line.strip() != '':
|
||||
if 'Title:' in line:
|
||||
title = line.replace('Title:', '').replace('"', '').strip()
|
||||
fields['name'] = line.replace('Title:', '').replace('"', '').strip()
|
||||
if 'Description:' in line:
|
||||
description = line.replace('Description:', '').strip()
|
||||
if 'Source:' in line or 'Serves:' in line or 'Prep Time:' in line or 'Cook Time:' in line:
|
||||
directions.append(line.strip() + '\n')
|
||||
fields['description'] = line.replace('Description:', '').strip()
|
||||
if 'Serves:' in line:
|
||||
fields['servings'] = parse_servings(line.replace('Serves:', '').strip())
|
||||
if 'Source:' in line:
|
||||
fields['source_url'] = line.replace('Source:', '').strip()
|
||||
if 'Photo Url:' in line:
|
||||
image_url = line.replace('Photo Url:', '').strip()
|
||||
if 'Prep Time:' in line:
|
||||
fields['working_time'] = parse_time(line.replace('Prep Time:', '').strip())
|
||||
if 'Cook Time:' in line:
|
||||
fields['waiting_time'] = parse_time(line.replace('Cook Time:', '').strip())
|
||||
if 'Tags:' in line:
|
||||
tags = line.replace('Tags:', '').strip()
|
||||
if ingredient_mode:
|
||||
if len(line) > 2 and 'Instructions:' not in line:
|
||||
ingredients.append(line.strip())
|
||||
if direction_mode:
|
||||
if len(line) > 2:
|
||||
directions.append(line.strip() + '\n')
|
||||
if 'Ingredients:' in line:
|
||||
ingredient_mode = True
|
||||
if 'Directions:' in line:
|
||||
ingredient_mode = False
|
||||
direction_mode = True
|
||||
if ingredient_mode:
|
||||
if len(line) > 2 and 'Ingredients:' not in line:
|
||||
ingredients.append(line.strip())
|
||||
if direction_mode:
|
||||
if len(line) > 2:
|
||||
directions.append(line.strip() + '\n')
|
||||
|
||||
recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space)
|
||||
recipe = Recipe.objects.create(**fields, created_by=self.request.user, internal=True, space=self.request.space)
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction='\n'.join(directions) + '\n\n', space=self.request.space, show_ingredients_table=self.request.user.userpreference.show_step_ingredients,
|
||||
@@ -68,7 +75,7 @@ class Plantoeat(Integration):
|
||||
|
||||
if image_url:
|
||||
try:
|
||||
if validators.url(image_url, public=True):
|
||||
if validate_import_url(image_url):
|
||||
response = requests.get(image_url)
|
||||
self.import_recipe_image(recipe, BytesIO(response.content))
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import imghdr
|
||||
import json
|
||||
import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
import requests
|
||||
import validators
|
||||
from PIL import Image
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.HelperFunctions import validate_import_url
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.ingredient_parser import IngredientParser
|
||||
from cookbook.integration.integration import Integration
|
||||
@@ -125,9 +126,9 @@ class RecetteTek(Integration):
|
||||
else:
|
||||
if file['originalPicture'] != '':
|
||||
url = file['originalPicture']
|
||||
if validators.url(url, public=True):
|
||||
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.")
|
||||
|
||||
@@ -2,8 +2,8 @@ import json
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
import validators
|
||||
|
||||
from cookbook.helper.HelperFunctions import validate_import_url
|
||||
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
|
||||
@@ -56,7 +56,7 @@ class RecipeSage(Integration):
|
||||
if len(file['image']) > 0:
|
||||
try:
|
||||
url = file['image'][0]
|
||||
if validators.url(url, public=True):
|
||||
if validate_import_url(url):
|
||||
response = requests.get(url)
|
||||
self.import_recipe_image(recipe, BytesIO(response.content))
|
||||
except Exception as e:
|
||||
|
||||
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
2543
cookbook/locale/hr/LC_MESSAGES/django.po
Normal file
2543
cookbook/locale/hr/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.
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user