mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2025-12-25 19:29:30 -05:00
Compare commits
577 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c22fb0ef4 | ||
|
|
f869fc85ae | ||
|
|
f435110810 | ||
|
|
36a0693b49 | ||
|
|
32a565c1e0 | ||
|
|
f3fd087b81 | ||
|
|
8e8d25071c | ||
|
|
129cc76624 | ||
|
|
0e48ee60cb | ||
|
|
a784421b5c | ||
|
|
314d8d41d1 | ||
|
|
02fd73f43e | ||
|
|
f12c47603d | ||
|
|
e341b96249 | ||
|
|
7ed5606e9d | ||
|
|
f3fffc1a3b | ||
|
|
c5a3e22542 | ||
|
|
c6990ef2d8 | ||
|
|
12a438752b | ||
|
|
8642298eda | ||
|
|
6260aba668 | ||
|
|
c56489cfa0 | ||
|
|
33b84c3d6a | ||
|
|
45454eb27b | ||
|
|
f628823ab2 | ||
|
|
781cbc16f7 | ||
|
|
28e6c9d922 | ||
|
|
d96bac47c4 | ||
|
|
1dafeb4db9 | ||
|
|
c02cdf6e68 | ||
|
|
3628835fd4 | ||
|
|
747b5937eb | ||
|
|
2fde8f9b52 | ||
|
|
7c06da89f0 | ||
|
|
2b17b12252 | ||
|
|
07afa7957a | ||
|
|
f0488c93d2 | ||
|
|
86a3d87276 | ||
|
|
fd14d79c12 | ||
|
|
4e852374cc | ||
|
|
898b1699b7 | ||
|
|
e92dbcd63e | ||
|
|
0c28e7e1b4 | ||
|
|
f8c1411e4d | ||
|
|
f1a194e166 | ||
|
|
f374e0cb27 | ||
|
|
6f7a41f3b8 | ||
|
|
a562b571b5 | ||
|
|
d71420484c | ||
|
|
bc7758e233 | ||
|
|
0f5185c677 | ||
|
|
1e6096d5c8 | ||
|
|
bc2a092e79 | ||
|
|
9809f58c28 | ||
|
|
589302a87d | ||
|
|
173eaf44a0 | ||
|
|
576c62b8a1 | ||
|
|
da57e656eb | ||
|
|
b42e6ac0f6 | ||
|
|
cebcf266fc | ||
|
|
d5ebb0047d | ||
|
|
6d553035db | ||
|
|
0f57dc9c8a | ||
|
|
31a4bc7747 | ||
|
|
0eed67b5aa | ||
|
|
3dbf40ff44 | ||
|
|
16459c1ec1 | ||
|
|
ba575ff79b | ||
|
|
2f9e407f49 | ||
|
|
1779c1ac14 | ||
|
|
ca1c353575 | ||
|
|
cac643266b | ||
|
|
5518199f64 | ||
|
|
a508fa81c0 | ||
|
|
c5bec8b69e | ||
|
|
c4905d39c1 | ||
|
|
5275e5eba7 | ||
|
|
272341f1dc | ||
|
|
78885987f0 | ||
|
|
cdbcc971b1 | ||
|
|
5378b4c577 | ||
|
|
269593cd98 | ||
|
|
82c83f4b8d | ||
|
|
42885cefc9 | ||
|
|
54a5009f98 | ||
|
|
7c49164387 | ||
|
|
1b8d4e4494 | ||
|
|
932211e7f6 | ||
|
|
9658993163 | ||
|
|
13e3c98fac | ||
|
|
9a9644dc6c | ||
|
|
cc43b2a9b0 | ||
|
|
91c0dbd8d2 | ||
|
|
3271ec6867 | ||
|
|
d39b779db9 | ||
|
|
536b0bad20 | ||
|
|
5c2020b8dd | ||
|
|
5851d061a2 | ||
|
|
a8bcc1457d | ||
|
|
6ba9cb8b55 | ||
|
|
8e53cce3b2 | ||
|
|
7f3a4ada75 | ||
|
|
0163988593 | ||
|
|
bcf78aed0a | ||
|
|
a7c89cc32e | ||
|
|
e66502ee8f | ||
|
|
40a12f35d7 | ||
|
|
fd1a399d03 | ||
|
|
538e45d20c | ||
|
|
0304e2a1ed | ||
|
|
65245234d8 | ||
|
|
a88d7625dc | ||
|
|
1af06b6480 | ||
|
|
48270197fa | ||
|
|
86cea901b4 | ||
|
|
f5312496e3 | ||
|
|
7f57e7ab56 | ||
|
|
c8d8dd581e | ||
|
|
256c1a7d41 | ||
|
|
7aa71dc744 | ||
|
|
07c34ea7b5 | ||
|
|
2d2582b449 | ||
|
|
4f81cb10de | ||
|
|
0256864904 | ||
|
|
7725665aa4 | ||
|
|
74e3d09065 | ||
|
|
0522fa0236 | ||
|
|
38aeb285c5 | ||
|
|
a9a0716c45 | ||
|
|
afc31b313f | ||
|
|
151f43b0d5 | ||
|
|
a695261b9c | ||
|
|
1229a37d74 | ||
|
|
ef200a4283 | ||
|
|
018fcf27ea | ||
|
|
b3504699b1 | ||
|
|
cdf4476345 | ||
|
|
0edc9f48c9 | ||
|
|
41885f7d05 | ||
|
|
2b7769e92f | ||
|
|
6a47d56da4 | ||
|
|
d456d4ae64 | ||
|
|
7b55bcb045 | ||
|
|
bee1d717c5 | ||
|
|
c91fc096b3 | ||
|
|
2297210a3f | ||
|
|
d59f14001c | ||
|
|
5e5941397b | ||
|
|
3ca71c6847 | ||
|
|
2c381eb870 | ||
|
|
230a368d38 | ||
|
|
5db7267a7d | ||
|
|
528da111f9 | ||
|
|
926097a699 | ||
|
|
0b3e86f6fd | ||
|
|
24e4ea354d | ||
|
|
08bc87960b | ||
|
|
9751821f76 | ||
|
|
fa12d02a3d | ||
|
|
9856857c51 | ||
|
|
c52cd359a1 | ||
|
|
5e9ce955bc | ||
|
|
f7d85bb4b8 | ||
|
|
46db6d4186 | ||
|
|
6724328b51 | ||
|
|
0da39f2e1f | ||
|
|
99fbc5e97c | ||
|
|
a0b6261275 | ||
|
|
061aefd233 | ||
|
|
d4e332456b | ||
|
|
500bb3af72 | ||
|
|
888106bb6f | ||
|
|
8daa0ada9b | ||
|
|
98711619ff | ||
|
|
9eb17df575 | ||
|
|
c71a7dad24 | ||
|
|
b92e51c0c7 | ||
|
|
87e8268a43 | ||
|
|
5b79db0725 | ||
|
|
3aade540c1 | ||
|
|
74f155b6f5 | ||
|
|
2539d19ff4 | ||
|
|
166f4c5f6b | ||
|
|
ed313cbf9a | ||
|
|
b1db591e9f | ||
|
|
94c51f90cd | ||
|
|
3074d916dc | ||
|
|
bf467b1ec0 | ||
|
|
348c1c78f1 | ||
|
|
913e896906 | ||
|
|
a0a673a0c9 | ||
|
|
3d60379ed0 | ||
|
|
fd7e20a46b | ||
|
|
a970f0c00e | ||
|
|
297dd6244a | ||
|
|
c9fcbc9ff0 | ||
|
|
c83eb1a42b | ||
|
|
8181a6d416 | ||
|
|
4a8b50aeba | ||
|
|
388ef32475 | ||
|
|
bfe72210df | ||
|
|
02c5aed0a3 | ||
|
|
6fc0c02674 | ||
|
|
040af330cc | ||
|
|
05caad5cfe | ||
|
|
7aa1c7b53b | ||
|
|
ad24a44588 | ||
|
|
9f4eb91287 | ||
|
|
9982cae7c3 | ||
|
|
6d065cb939 | ||
|
|
b10163e309 | ||
|
|
28b8973259 | ||
|
|
4f09970130 | ||
|
|
e05019d2b1 | ||
|
|
3c778927e2 | ||
|
|
505b60cb14 | ||
|
|
14ca61b11f | ||
|
|
22900dc460 | ||
|
|
28806e6857 | ||
|
|
dbf1334ec0 | ||
|
|
7e0d9bfe49 | ||
|
|
56f80acede | ||
|
|
46a8a9f60d | ||
|
|
71fdfe6acb | ||
|
|
50572e9a36 | ||
|
|
c8054349b2 | ||
|
|
0ec5d669dd | ||
|
|
6dd778112a | ||
|
|
a14e33973c | ||
|
|
a8d01f4d5a | ||
|
|
58f841a770 | ||
|
|
c851b54a22 | ||
|
|
df53fdeb03 | ||
|
|
18333563f2 | ||
|
|
286118093c | ||
|
|
066ca27712 | ||
|
|
03c78f539d | ||
|
|
be225d2b8c | ||
|
|
3340ef9ca4 | ||
|
|
fe3e611dd1 | ||
|
|
61d1528911 | ||
|
|
88524b0411 | ||
|
|
e774845ade | ||
|
|
9a8049f71b | ||
|
|
d67bb9de25 | ||
|
|
4b7896f7d1 | ||
|
|
66ce1a88f6 | ||
|
|
9ad42ae869 | ||
|
|
1e72893c84 | ||
|
|
a21755cf81 | ||
|
|
f0ba8eb788 | ||
|
|
149020f930 | ||
|
|
200adb2fcf | ||
|
|
bbd4d20210 | ||
|
|
8aa11836a3 | ||
|
|
35338e2765 | ||
|
|
ddc91d910f | ||
|
|
34085fc949 | ||
|
|
4ab56ef9f7 | ||
|
|
47b873a2af | ||
|
|
34128ba3a3 | ||
|
|
fae25b83fa | ||
|
|
a2322c18eb | ||
|
|
f99793fc1a | ||
|
|
b10b811550 | ||
|
|
792ee43377 | ||
|
|
157beb3376 | ||
|
|
1313c962fa | ||
|
|
17c18c6d08 | ||
|
|
7650edfdc8 | ||
|
|
46e48cd3a5 | ||
|
|
223f899e88 | ||
|
|
d56c8c283c | ||
|
|
67cd74860f | ||
|
|
4df87bc7c7 | ||
|
|
16237866a1 | ||
|
|
79cc8cc905 | ||
|
|
3e2f3effeb | ||
|
|
5623879919 | ||
|
|
b1b1373d65 | ||
|
|
3270f56744 | ||
|
|
36df86c26c | ||
|
|
532d6e2867 | ||
|
|
7c6d32456a | ||
|
|
6b8aa99b24 | ||
|
|
65a6c08015 | ||
|
|
22f4612d12 | ||
|
|
f65a5a9ad7 | ||
|
|
02b2d953ce | ||
|
|
19cdf3a919 | ||
|
|
6dfe737ec5 | ||
|
|
6110c75f59 | ||
|
|
984d5aae11 | ||
|
|
e345d2eb39 | ||
|
|
90e1e69dac | ||
|
|
c4880bf5b0 | ||
|
|
3baa03396c | ||
|
|
373df5d99f | ||
|
|
d36274066a | ||
|
|
59c33798b8 | ||
|
|
d7afbc5745 | ||
|
|
c62a88d032 | ||
|
|
ed76f020c5 | ||
|
|
8b61d8c504 | ||
|
|
82abdd0144 | ||
|
|
04d131f534 | ||
|
|
8cc74f3dcd | ||
|
|
bd46962b71 | ||
|
|
059987fd9f | ||
|
|
2ecc0ab680 | ||
|
|
a69fb4922d | ||
|
|
358ba5120d | ||
|
|
254267c2a7 | ||
|
|
a701437548 | ||
|
|
25f6adba1f | ||
|
|
018fa0a62f | ||
|
|
faf458e8ef | ||
|
|
2c5348fcb4 | ||
|
|
2b16f966a2 | ||
|
|
c2931137bb | ||
|
|
461d53671c | ||
|
|
3e4e55e9c5 | ||
|
|
11fa23f3da | ||
|
|
6de128757f | ||
|
|
7c682ebab3 | ||
|
|
ee165ef0f1 | ||
|
|
4bc4ce0d7c | ||
|
|
c50bd039ef | ||
|
|
5fff5b97da | ||
|
|
2c27e06bfb | ||
|
|
e4044016c3 | ||
|
|
52df886372 | ||
|
|
c7949edb18 | ||
|
|
342a261017 | ||
|
|
e5984abd97 | ||
|
|
2ea3e4f8f3 | ||
|
|
98b3b002f9 | ||
|
|
d2a1a0ac32 | ||
|
|
2ceefdd9b0 | ||
|
|
b22392726f | ||
|
|
b89fedd07f | ||
|
|
a263771383 | ||
|
|
7cc757ac33 | ||
|
|
6acbd6d308 | ||
|
|
30a357e27f | ||
|
|
7bc3292301 | ||
|
|
7fb440a855 | ||
|
|
0e27ddab74 | ||
|
|
0a38049ce4 | ||
|
|
4aaecb4ada | ||
|
|
3f5e64fc4a | ||
|
|
154527ef1b | ||
|
|
788e253749 | ||
|
|
7c8b489857 | ||
|
|
cdc25b480d | ||
|
|
1568d86143 | ||
|
|
adeb360837 | ||
|
|
9cc6a1dc79 | ||
|
|
6a13619bbd | ||
|
|
58e33ef31a | ||
|
|
36841d74af | ||
|
|
8094c7d53a | ||
|
|
71e02c0916 | ||
|
|
25fb41baed | ||
|
|
34ff484830 | ||
|
|
91e36eb222 | ||
|
|
55ba568f3c | ||
|
|
8f3f1c230c | ||
|
|
1690abaf47 | ||
|
|
7414033495 | ||
|
|
d1b9d15816 | ||
|
|
ff43492265 | ||
|
|
3daebc4170 | ||
|
|
67485c0ea3 | ||
|
|
aafbc497cc | ||
|
|
0a99791021 | ||
|
|
7bc4ae9870 | ||
|
|
84bd33f93e | ||
|
|
7cd6a7c2a6 | ||
|
|
c661646f46 | ||
|
|
7a017899ee | ||
|
|
0ec29636b3 | ||
|
|
5ef5530392 | ||
|
|
689c447b6c | ||
|
|
239ba5f861 | ||
|
|
43c71af2af | ||
|
|
b9040cb3a4 | ||
|
|
c710d42ccb | ||
|
|
1bc5af1cab | ||
|
|
23415f8a61 | ||
|
|
d00fa10b9f | ||
|
|
cc34496c00 | ||
|
|
be84e44e43 | ||
|
|
ae3eb6cfe5 | ||
|
|
d06e6c0ab3 | ||
|
|
eba3bfa828 | ||
|
|
5ee718b578 | ||
|
|
6e91f34779 | ||
|
|
dc27f39393 | ||
|
|
ec95f8032c | ||
|
|
94b56ff742 | ||
|
|
fd1258f851 | ||
|
|
6f104d8aa4 | ||
|
|
d35f8a0ccd | ||
|
|
f2d761f7d9 | ||
|
|
d9a92fac95 | ||
|
|
a1572a4809 | ||
|
|
2b5dceee7c | ||
|
|
62e9a7f29c | ||
|
|
66bb5b4328 | ||
|
|
0f4cd4b17c | ||
|
|
ae3835b541 | ||
|
|
28fedfda1f | ||
|
|
92c65ec1e8 | ||
|
|
a376c3a5b6 | ||
|
|
058d705170 | ||
|
|
4ad5d6ef2f | ||
|
|
e676b4bac3 | ||
|
|
04488741c4 | ||
|
|
99004ad34b | ||
|
|
f78f7dfc14 | ||
|
|
49fb1e7183 | ||
|
|
7930c2417c | ||
|
|
880db58d38 | ||
|
|
2f27413c0a | ||
|
|
5869a8ad1b | ||
|
|
0640a265fc | ||
|
|
d449fc8fd8 | ||
|
|
b9ee77709b | ||
|
|
f594ba4c69 | ||
|
|
d1d65d878c | ||
|
|
3194a7580d | ||
|
|
ba061df1b6 | ||
|
|
7cc515bcdf | ||
|
|
724748d38a | ||
|
|
b2c1c6e301 | ||
|
|
987be4b04d | ||
|
|
ca84da68c4 | ||
|
|
d75e39fbcd | ||
|
|
eb2593aacd | ||
|
|
496e04cfc8 | ||
|
|
d814d13d54 | ||
|
|
d0cedaf7a1 | ||
|
|
01f504f7b1 | ||
|
|
c716346f1f | ||
|
|
fef5236931 | ||
|
|
b115c37eb8 | ||
|
|
1e17f3703a | ||
|
|
468b986314 | ||
|
|
a531d135b5 | ||
|
|
7524609cd0 | ||
|
|
a28f8e65d5 | ||
|
|
d193637091 | ||
|
|
0953af05fc | ||
|
|
19e8e5cb5b | ||
|
|
43c808380d | ||
|
|
7ab8b84044 | ||
|
|
d739fe6752 | ||
|
|
a84c41e29f | ||
|
|
393aba1f31 | ||
|
|
436a070730 | ||
|
|
2fe6788ce5 | ||
|
|
747d146389 | ||
|
|
efe4c4043d | ||
|
|
c6739ba8e0 | ||
|
|
50140db668 | ||
|
|
028b2dfb22 | ||
|
|
ec6a10ca0a | ||
|
|
3cf949bf8d | ||
|
|
0a62225797 | ||
|
|
a54f4e1367 | ||
|
|
bf3c30a8fb | ||
|
|
f811f5996e | ||
|
|
a3490240f4 | ||
|
|
b26aea96f4 | ||
|
|
4d4af5fdf2 | ||
|
|
3da74505d6 | ||
|
|
c8a4861df8 | ||
|
|
5e27cd606e | ||
|
|
a341fd8ebe | ||
|
|
9a62b6e4e7 | ||
|
|
f80c44bca3 | ||
|
|
09d2e9f831 | ||
|
|
4d5a9e446f | ||
|
|
6a2c27749f | ||
|
|
de60e12073 | ||
|
|
1188ed9227 | ||
|
|
cb708e7e47 | ||
|
|
215eadb4a0 | ||
|
|
4ffc54f720 | ||
|
|
21f6c7a21f | ||
|
|
ce7c6939d2 | ||
|
|
40a2f7ff90 | ||
|
|
4015517c0a | ||
|
|
7c8d41753c | ||
|
|
90670613c5 | ||
|
|
647c1678f1 | ||
|
|
44dee16e0a | ||
|
|
f8fedcac82 | ||
|
|
3a48d0e580 | ||
|
|
9930789aa8 | ||
|
|
83fce6461a | ||
|
|
f0d37244b6 | ||
|
|
386834f409 | ||
|
|
17c5084bc0 | ||
|
|
e38f50c352 | ||
|
|
c1abff8da0 | ||
|
|
625c04257e | ||
|
|
5521c29d43 | ||
|
|
dc3a530928 | ||
|
|
48f63b4d9f | ||
|
|
1604ae51de | ||
|
|
a1502bffd1 | ||
|
|
756e5ec668 | ||
|
|
b9cf0a7136 | ||
|
|
6bf72c4043 | ||
|
|
f2f8342b49 | ||
|
|
7d82393789 | ||
|
|
2254d6f072 | ||
|
|
8d02cad7d9 | ||
|
|
5c12d00f49 | ||
|
|
b37fc4e24f | ||
|
|
35a7f62837 | ||
|
|
ffc1c5a99c | ||
|
|
fc2a60a4ba | ||
|
|
fb0f424d82 | ||
|
|
ffb5291f4b | ||
|
|
77ba482b79 | ||
|
|
abb0be69d8 | ||
|
|
7bb23e8362 | ||
|
|
cbb659da41 | ||
|
|
52b0382243 | ||
|
|
3371dc949a | ||
|
|
49f026f2bd | ||
|
|
cce373a522 | ||
|
|
b4b3e659de | ||
|
|
f81500ec99 | ||
|
|
ecba13e97f | ||
|
|
191a6c0d9b | ||
|
|
52ba2be586 | ||
|
|
36129b29b4 | ||
|
|
99e3690a42 | ||
|
|
c780f81dd8 | ||
|
|
27f5e85e11 | ||
|
|
e6d9ffbb9c | ||
|
|
3b188c3c55 | ||
|
|
161e70dc9c | ||
|
|
1f80df262b | ||
|
|
9928675f48 | ||
|
|
3d925b29c2 | ||
|
|
4825317a58 | ||
|
|
9a5408e996 | ||
|
|
f13c1a2605 | ||
|
|
bc8aadbe4e | ||
|
|
e60843b54c | ||
|
|
46dce472db | ||
|
|
556ca1bcb1 | ||
|
|
d9b2fcaa87 | ||
|
|
3e950602a7 | ||
|
|
1fe0757f6c | ||
|
|
b6cf1cf5e6 | ||
|
|
ca7d487789 | ||
|
|
cb44136b2e | ||
|
|
c92331a79c | ||
|
|
82e7118757 | ||
|
|
6d9817183e | ||
|
|
0e05d5b18d | ||
|
|
da1d88314b | ||
|
|
c46f22d71e | ||
|
|
f21de5eddc | ||
|
|
e88fb88d8a | ||
|
|
6eb14daf4d | ||
|
|
fad40dab6c | ||
|
|
015e01afb9 | ||
|
|
2acdd16d9e | ||
|
|
d15162337f | ||
|
|
331d85a993 |
@@ -20,6 +20,10 @@ POSTGRES_USER=djangouser
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_DB=djangodb
|
||||
|
||||
# database connection string, when used overrides other database settings.
|
||||
# format might vary depending on backend
|
||||
# DATABASE_URL = engine://username:password@host:port/dbname
|
||||
|
||||
# the default value for the user preference 'fractions' (enable/disable fraction support)
|
||||
# default: disabled=0
|
||||
FRACTION_PREF_DEFAULT=0
|
||||
@@ -34,7 +38,7 @@ COMMENT_PREF_DEFAULT=1
|
||||
SHOPPING_MIN_AUTOSYNC_INTERVAL=5
|
||||
|
||||
# Default for user setting sticky navbar
|
||||
#STICKY_NAV_PREF_DEFAULT=1
|
||||
# STICKY_NAV_PREF_DEFAULT=1
|
||||
|
||||
# If staticfiles are stored at a different location uncomment and change accordingly
|
||||
# STATIC_URL=/static/
|
||||
@@ -48,11 +52,55 @@ SHOPPING_MIN_AUTOSYNC_INTERVAL=5
|
||||
# when unset: 1 (true) - this is temporary until an appropriate amount of time has passed for everyone to migrate
|
||||
GUNICORN_MEDIA=0
|
||||
|
||||
# S3 Media settings: store mediafiles in s3 or any compatible storage backend (e.g. minio)
|
||||
# as long as S3_ACCESS_KEY is not set S3 features are disabled
|
||||
# S3_ACCESS_KEY=
|
||||
# S3_SECRET_ACCESS_KEY=
|
||||
# S3_BUCKET_NAME=
|
||||
# S3_REGION_NAME= # default none, set your region might be required
|
||||
# S3_QUERYSTRING_AUTH=1 # default true, set to 0 to serve media from a public bucket without signed urls
|
||||
# S3_QUERYSTRING_EXPIRE=3600 # number of seconds querystring are valid for
|
||||
# S3_ENDPOINT_URL= # when using a custom endpoint like minio
|
||||
|
||||
# Email Settings, see https://docs.djangoproject.com/en/3.2/ref/settings/#email-host
|
||||
# Required for email confirmation and password reset (automatically activates if host is set)
|
||||
# EMAIL_HOST=
|
||||
# EMAIL_PORT=
|
||||
# EMAIL_HOST_USER=
|
||||
# EMAIL_HOST_PASSWORD=
|
||||
# EMAIL_USE_TLS=0
|
||||
# EMAIL_USE_SSL=0
|
||||
# DEFAULT_FROM_EMAIL= # email sender address (default 'webmaster@localhost')
|
||||
# ACCOUNT_EMAIL_SUBJECT_PREFIX= # prefix used for account related emails (default "[Tandoor Recipes] ")
|
||||
|
||||
# allow authentication via reverse proxy (e.g. authelia), leave off if you dont know what you are doing
|
||||
# see docs for more information https://vabene1111.github.io/recipes/features/authentication/
|
||||
# when unset: 0 (false)
|
||||
REVERSE_PROXY_AUTH=0
|
||||
|
||||
# Default settings for spaces, apply per space and can be changed in the admin view
|
||||
# SPACE_DEFAULT_MAX_RECIPES=0 # 0=unlimited recipes
|
||||
# SPACE_DEFAULT_MAX_USERS=0 # 0=unlimited users per space
|
||||
# SPACE_DEFAULT_MAX_FILES=0 # Maximum file storage for space in MB. 0 for unlimited, -1 to disable file upload.
|
||||
# SPACE_DEFAULT_ALLOW_SHARING=1 # Allow users to share recipes with public links
|
||||
|
||||
# allow people to create accounts on your application instance (without an invite link)
|
||||
# when unset: 0 (false)
|
||||
# ENABLE_SIGNUP=0
|
||||
|
||||
# If signup is enabled you might want to add a captcha to it to prevent spam
|
||||
# HCAPTCHA_SITEKEY=
|
||||
# HCAPTCHA_SECRET=
|
||||
|
||||
# if signup is enabled you might want to provide urls to data protection policies or terms and conditions
|
||||
# TERMS_URL=
|
||||
# PRIVACY_URL=
|
||||
# IMPRINT_URL=
|
||||
|
||||
# enable serving of prometheus metrics under the /metrics path
|
||||
# ATTENTION: view is not secured (as per the prometheus default way) so make sure to secure it
|
||||
# trough your web server (or leave it open of you dont care if the stats are exposed)
|
||||
# ENABLE_METRICS=0
|
||||
|
||||
# allows you to setup OAuth providers
|
||||
# see docs for more information https://vabene1111.github.io/recipes/features/authentication/
|
||||
@@ -64,4 +112,9 @@ REVERSE_PROXY_AUTH=0
|
||||
# SOCIAL_DEFAULT_ACCESS = 1
|
||||
|
||||
# if SOCIAL_DEFAULT_ACCESS is used, which group should be added
|
||||
# SOCIAL_DEFAULT_GROUP=guest
|
||||
# SOCIAL_DEFAULT_GROUP=guest
|
||||
|
||||
# Django session cookie settings. Can be changed to allow a single django application to authenticate several applications
|
||||
# when running under the same database
|
||||
# SESSION_COOKIE_DOMAIN=.example.com
|
||||
# SESSION_COOKIE_NAME=sessionid # use this only to not interfere with non unified django applications under the same top level domain
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -9,14 +9,14 @@ jobs:
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
python-version: [3.8]
|
||||
python-version: [3.9]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Set up Python 3.8
|
||||
- name: Set up Python 3.9
|
||||
uses: actions/setup-python@v1
|
||||
with:
|
||||
python-version: 3.8
|
||||
python-version: 3.9
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
|
||||
64
.github/workflows/codeql-analysis.yml
vendored
64
.github/workflows/codeql-analysis.yml
vendored
@@ -12,40 +12,42 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
fetch-depth: 2
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
fetch-depth: 2
|
||||
|
||||
# If this run was triggered by a pull request event, then checkout
|
||||
# the head of the pull request instead of the merge commit.
|
||||
- run: git checkout HEAD^2
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
# Override language selection by uncommenting this and choosing your languages
|
||||
# with:
|
||||
# languages: go, javascript, csharp, python, cpp, java
|
||||
# If this run was triggered by a pull request event, then checkout
|
||||
# the head of the pull request instead of the merge commit.
|
||||
- run: git checkout HEAD^2
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
# - name: Autobuild
|
||||
# uses: github/codeql-action/autobuild@v1
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
# Override language selection by uncommenting this and choosing your languages
|
||||
with:
|
||||
languages: python, javascript
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
# - name: Autobuild
|
||||
# uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
with:
|
||||
languages: javascript, python
|
||||
|
||||
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="jdk" jdkName="Python 3.8 (recipes)" jdkType="Python SDK" />
|
||||
<orderEntry type="jdk" jdkName="Python 3.9 (recipes)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="TemplatesService">
|
||||
|
||||
19
README.md
19
README.md
@@ -9,22 +9,25 @@
|
||||
<h4 align="center">The recipe manager that allows you to manage your ever growing collection of digital recipes.</h4>
|
||||
|
||||
<p align="center">
|
||||
|
||||
<img src="https://github.com/vabene1111/recipes/workflows/Continous%20Integration/badge.svg?branch=develop" >
|
||||
<img src="https://img.shields.io/github/stars/vabene1111/recipes" >
|
||||
<img src="https://img.shields.io/github/forks/vabene1111/recipes" >
|
||||
<img src="https://img.shields.io/docker/pulls/vabene1111/recipes" >
|
||||
|
||||
<a href="https://github.com/vabene1111/recipes/actions" target="_blank" rel="noopener noreferrer"><img src="https://github.com/vabene1111/recipes/workflows/Continous%20Integration/badge.svg?branch=master" ></a>
|
||||
<a href="https://github.com/vabene1111/recipes/stargazers" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/stars/vabene1111/recipes" ></a>
|
||||
<a href="https://github.com/vabene1111/recipes/network/members" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/github/forks/vabene1111/recipes" ></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>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://docs.tandoor.dev/install/docker/" target="_blank" rel="noopener noreferrer">Installation</a> •
|
||||
<a href="https://docs.tandoor.dev/install/docker.html" target="_blank" rel="noopener noreferrer">Installation</a> •
|
||||
<a href="https://docs.tandoor.dev/" target="_blank" rel="noopener noreferrer">Documentation</a> •
|
||||
<a href="https://app.tandoor.dev/" target="_blank" rel="noopener noreferrer">Demo</a>
|
||||
<a href="https://app.tandoor.dev/accounts/login/?demo" target="_blank" rel="noopener noreferrer">Demo</a>
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
# Your Feedback
|
||||
|
||||
Share some information on how you use Tandoor to help me improve the application [Google Survey](https://forms.gle/qNfLK2tWTeWHe9Qd7)
|
||||
|
||||
## Features
|
||||
|
||||
- 📦 **Sync** files with Dropbox and Nextcloud (more can easily be added)
|
||||
|
||||
@@ -7,7 +7,8 @@ from .models import (Comment, CookLog, Food, Ingredient, InviteLink, Keyword,
|
||||
RecipeBook, RecipeBookEntry, RecipeImport, ShareLink,
|
||||
ShoppingList, ShoppingListEntry, ShoppingListRecipe,
|
||||
Space, Step, Storage, Sync, SyncLog, Unit, UserPreference,
|
||||
ViewLog, Supermarket, SupermarketCategory, SupermarketCategoryRelation, ImportLog, TelegramBot)
|
||||
ViewLog, Supermarket, SupermarketCategory, SupermarketCategoryRelation,
|
||||
ImportLog, TelegramBot, BookmarkletImport, UserFile)
|
||||
|
||||
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
@@ -22,14 +23,20 @@ admin.site.unregister(Group)
|
||||
|
||||
|
||||
class SpaceAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'created_by', 'message')
|
||||
list_display = ('name', 'created_by', 'max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
|
||||
search_fields = ('name', 'created_by__username')
|
||||
list_filter = ('max_recipes', 'max_users', 'max_file_storage_mb', 'allow_sharing')
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
|
||||
admin.site.register(Space, SpaceAdmin)
|
||||
|
||||
|
||||
class UserPreferenceAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'space', 'theme', 'nav_color', 'default_page', 'search_style',)
|
||||
list_display = ('name', 'space', 'theme', 'nav_color', 'default_page', 'search_style',) # TODO add new fields
|
||||
search_fields = ('user__username', 'space__name')
|
||||
list_filter = ('theme', 'nav_color', 'default_page', 'search_style')
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
@staticmethod
|
||||
def name(obj):
|
||||
@@ -41,6 +48,7 @@ admin.site.register(UserPreference, UserPreferenceAdmin)
|
||||
|
||||
class StorageAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'method')
|
||||
search_fields = ('name',)
|
||||
|
||||
|
||||
admin.site.register(Storage, StorageAdmin)
|
||||
@@ -48,6 +56,7 @@ admin.site.register(Storage, StorageAdmin)
|
||||
|
||||
class SyncAdmin(admin.ModelAdmin):
|
||||
list_display = ('storage', 'path', 'active', 'last_checked')
|
||||
search_fields = ('storage__name', 'path')
|
||||
|
||||
|
||||
admin.site.register(Sync, SyncAdmin)
|
||||
@@ -76,6 +85,7 @@ admin.site.register(Keyword)
|
||||
|
||||
class StepAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'type', 'order')
|
||||
search_fields = ('name', 'type')
|
||||
|
||||
|
||||
admin.site.register(Step, StepAdmin)
|
||||
@@ -83,6 +93,9 @@ admin.site.register(Step, StepAdmin)
|
||||
|
||||
class RecipeAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'internal', 'created_by', 'storage')
|
||||
search_fields = ('name', 'created_by__username')
|
||||
list_filter = ('internal',)
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
@staticmethod
|
||||
def created_by(obj):
|
||||
@@ -97,6 +110,7 @@ admin.site.register(Food)
|
||||
|
||||
class IngredientAdmin(admin.ModelAdmin):
|
||||
list_display = ('food', 'amount', 'unit')
|
||||
search_fields = ('food__name', 'unit__name')
|
||||
|
||||
|
||||
admin.site.register(Ingredient, IngredientAdmin)
|
||||
@@ -104,6 +118,8 @@ admin.site.register(Ingredient, IngredientAdmin)
|
||||
|
||||
class CommentAdmin(admin.ModelAdmin):
|
||||
list_display = ('recipe', 'name', 'created_at')
|
||||
search_fields = ('text', 'user__username')
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
@staticmethod
|
||||
def name(obj):
|
||||
@@ -122,6 +138,7 @@ admin.site.register(RecipeImport, RecipeImportAdmin)
|
||||
|
||||
class RecipeBookAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'user_name')
|
||||
search_fields = ('name', 'created_by__username')
|
||||
|
||||
@staticmethod
|
||||
def user_name(obj):
|
||||
@@ -151,6 +168,7 @@ admin.site.register(MealPlan, MealPlanAdmin)
|
||||
|
||||
class MealTypeAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'created_by', 'order')
|
||||
search_fields = ('name', 'created_by__username')
|
||||
|
||||
|
||||
admin.site.register(MealType, MealTypeAdmin)
|
||||
@@ -165,7 +183,7 @@ admin.site.register(ViewLog, ViewLogAdmin)
|
||||
|
||||
class InviteLinkAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
'username', 'group', 'valid_until',
|
||||
'group', 'valid_until',
|
||||
'created_by', 'created_at', 'used_by'
|
||||
)
|
||||
|
||||
@@ -227,3 +245,17 @@ class TelegramBotAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
admin.site.register(TelegramBot, TelegramBotAdmin)
|
||||
|
||||
|
||||
class BookmarkletImportAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'url', 'created_by', 'created_at',)
|
||||
|
||||
|
||||
admin.site.register(BookmarkletImport, BookmarkletImportAdmin)
|
||||
|
||||
|
||||
class UserFileAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'name', 'file_size_kb', 'created_at',)
|
||||
|
||||
|
||||
admin.site.register(UserFile, UserFileAdmin)
|
||||
|
||||
@@ -49,8 +49,10 @@ with scopes_disabled():
|
||||
def filter_name(queryset, name, value):
|
||||
if not name == 'name':
|
||||
return queryset
|
||||
if settings.DATABASES['default']['ENGINE'] == 'django.db.backends.postgresql_psycopg2':
|
||||
queryset = queryset.annotate(similarity=TrigramSimilarity('name', value), ).filter(Q(similarity__gt=0.1) | Q(name__unaccent__icontains=value)).order_by('-similarity')
|
||||
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
|
||||
'django.db.backends.postgresql']:
|
||||
queryset = queryset.annotate(similarity=TrigramSimilarity('name', value), ).filter(
|
||||
Q(similarity__gt=0.1) | Q(name__unaccent__icontains=value)).order_by('-similarity')
|
||||
else:
|
||||
queryset = queryset.filter(name__icontains=value)
|
||||
return queryset
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.forms import widgets
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import scopes_disabled
|
||||
from django_scopes.forms import SafeModelChoiceField, SafeModelMultipleChoiceField
|
||||
from emoji_picker.widgets import EmojiPickerTextInput
|
||||
from hcaptcha.fields import hCaptchaField
|
||||
|
||||
from .models import (Comment, Food, InviteLink, Keyword, MealPlan, Recipe,
|
||||
RecipeBook, RecipeBookEntry, Storage, Sync, Unit, User,
|
||||
@@ -42,10 +46,15 @@ class UserPreferenceForm(forms.ModelForm):
|
||||
)
|
||||
|
||||
help_texts = {
|
||||
'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!'), # noqa: E501
|
||||
'nav_color': _('Color of the top navigation bar. Not all colors work with all themes, just try them out!'),
|
||||
# noqa: E501
|
||||
'default_unit': _('Default Unit to be used when inserting a new ingredient into a recipe.'), # noqa: E501
|
||||
'use_fractions': _('Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'), # noqa: E501
|
||||
'plan_share': _('Users with whom newly created meal plan/shopping list entries should be shared by default.'), # noqa: E501
|
||||
'use_fractions': _(
|
||||
'Enables support for fractions in ingredient amounts (e.g. convert decimals to fractions automatically)'),
|
||||
# noqa: E501
|
||||
'plan_share': _(
|
||||
'Users with whom newly created meal plan/shopping list entries should be shared by default.'),
|
||||
# noqa: E501
|
||||
'show_recent': _('Show recently viewed recipes on search page.'), # noqa: E501
|
||||
'ingredient_decimals': _('Number of decimals to round ingredients.'), # noqa: E501
|
||||
'comments': _('If you want to be able to create and see comments underneath recipes.'), # noqa: E501
|
||||
@@ -69,7 +78,7 @@ class UserNameForm(forms.ModelForm):
|
||||
fields = ('first_name', 'last_name')
|
||||
|
||||
help_texts = {
|
||||
'first_name': _('Both fields are optional. If none are given the username will be displayed instead') # noqa: E501
|
||||
'first_name': _('Both fields are optional. If none are given the username will be displayed instead')
|
||||
}
|
||||
|
||||
|
||||
@@ -112,22 +121,28 @@ class ImportExportBase(forms.Form):
|
||||
SAFRON = 'SAFRON'
|
||||
CHEFTAP = 'CHEFTAP'
|
||||
PEPPERPLATE = 'PEPPERPLATE'
|
||||
RECIPEKEEPER = 'RECIPEKEEPER'
|
||||
RECETTETEK = 'RECETTETEK'
|
||||
RECIPESAGE = 'RECIPESAGE'
|
||||
DOMESTICA = 'DOMESTICA'
|
||||
MEALMASTER = 'MEALMASTER'
|
||||
REZKONV = 'REZKONV'
|
||||
OPENEATS = 'OPENEATS'
|
||||
|
||||
type = forms.ChoiceField(choices=(
|
||||
(DEFAULT, _('Default')), (PAPRIKA, 'Paprika'), (NEXTCLOUD, 'Nextcloud Cookbook'),
|
||||
(MEALIE, 'Mealie'), (CHOWDOWN, 'Chowdown'), (SAFRON, 'Safron'), (CHEFTAP, 'ChefTap'),
|
||||
(PEPPERPLATE, 'Pepperplate'), (RECIPESAGE, 'Recipe Sage'), (DOMESTICA, 'Domestica'),
|
||||
(MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'),
|
||||
(PEPPERPLATE, 'Pepperplate'), (RECETTETEK, 'RecetteTek'), (RECIPESAGE, 'Recipe Sage'), (DOMESTICA, 'Domestica'),
|
||||
(MEALMASTER, 'MealMaster'), (REZKONV, 'RezKonv'), (OPENEATS, 'Openeats'), (RECIPEKEEPER, 'Recipe Keeper'),
|
||||
|
||||
))
|
||||
|
||||
|
||||
class ImportForm(ImportExportBase):
|
||||
files = forms.FileField(required=True, widget=forms.ClearableFileInput(attrs={'multiple': True}))
|
||||
duplicates = forms.BooleanField(help_text=_('To prevent duplicates recipes with the same name as existing ones are ignored. Check this box to import everything.'), required=False)
|
||||
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):
|
||||
@@ -250,7 +265,8 @@ class StorageForm(forms.ModelForm):
|
||||
fields = ('name', 'method', 'username', 'password', 'token', 'url', 'path')
|
||||
|
||||
help_texts = {
|
||||
'url': _('Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'),
|
||||
'url': _(
|
||||
'Leave empty for dropbox and enter only base url for nextcloud (<code>/remote.php/webdav/</code> is added automatically)'),
|
||||
}
|
||||
|
||||
|
||||
@@ -365,7 +381,8 @@ class MealPlanForm(forms.ModelForm):
|
||||
|
||||
help_texts = {
|
||||
'shared': _('You can list default users to share recipes with in the settings.'), # noqa: E501
|
||||
'note': _('You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>') # noqa: E501
|
||||
'note': _('You can use markdown to format this field. See the <a href="/docs/markdown/">docs here</a>')
|
||||
# noqa: E501
|
||||
}
|
||||
|
||||
widgets = {
|
||||
@@ -386,17 +403,62 @@ class InviteLinkForm(forms.ModelForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['space'].queryset = Space.objects.filter(created_by=user).all()
|
||||
|
||||
def clean(self):
|
||||
space = self.cleaned_data['space']
|
||||
if space.max_users != 0 and (UserPreference.objects.filter(space=space).count() + InviteLink.objects.filter(space=space).count()) >= space.max_users:
|
||||
raise ValidationError(_('Maximum number of users for this space reached.'))
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data['email']
|
||||
with scopes_disabled():
|
||||
if email != '' and User.objects.filter(email=email).exists():
|
||||
raise ValidationError(_('Email address already taken!'))
|
||||
|
||||
return email
|
||||
|
||||
class Meta:
|
||||
model = InviteLink
|
||||
fields = ('username', 'group', 'valid_until', 'space')
|
||||
fields = ('email', 'group', 'valid_until', 'space')
|
||||
help_texts = {
|
||||
'username': _('A username is not required, if left blank the new user can choose one.') # noqa: E501
|
||||
'email': _('An email address is not required but if present the invite link will be send to the user.'),
|
||||
}
|
||||
field_classes = {
|
||||
'space': SafeModelChoiceField,
|
||||
}
|
||||
|
||||
|
||||
class SpaceCreateForm(forms.Form):
|
||||
prefix = 'create'
|
||||
name = forms.CharField()
|
||||
|
||||
def clean_name(self):
|
||||
name = self.cleaned_data['name']
|
||||
with scopes_disabled():
|
||||
if Space.objects.filter(name=name).exists():
|
||||
raise ValidationError(_('Name already taken.'))
|
||||
return name
|
||||
|
||||
|
||||
class SpaceJoinForm(forms.Form):
|
||||
prefix = 'join'
|
||||
token = forms.CharField()
|
||||
|
||||
|
||||
class AllAuthSignupForm(forms.Form):
|
||||
captcha = hCaptchaField()
|
||||
terms = forms.BooleanField(label=_('Accept Terms and Privacy'))
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super(AllAuthSignupForm, self).__init__(**kwargs)
|
||||
if settings.PRIVACY_URL == '' and settings.TERMS_URL == '':
|
||||
self.fields.pop('terms')
|
||||
if settings.HCAPTCHA_SECRET == '':
|
||||
self.fields.pop('captcha')
|
||||
|
||||
def signup(self, request, user):
|
||||
pass
|
||||
|
||||
|
||||
class UserCreateForm(forms.Form):
|
||||
name = forms.CharField(label='Username')
|
||||
password = forms.CharField(
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import datetime
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from allauth.account.adapter import DefaultAccountAdapter
|
||||
from django.contrib import messages
|
||||
from django.core.cache import caches
|
||||
from gettext import gettext as _
|
||||
|
||||
from cookbook.models import InviteLink
|
||||
|
||||
|
||||
class AllAuthCustomAdapter(DefaultAccountAdapter):
|
||||
@@ -9,11 +16,23 @@ class AllAuthCustomAdapter(DefaultAccountAdapter):
|
||||
"""
|
||||
Whether to allow sign ups.
|
||||
"""
|
||||
if request.resolver_match.view_name == 'account_signup':
|
||||
signup_token = False
|
||||
if 'signup_token' in request.session and InviteLink.objects.filter(valid_until__gte=datetime.datetime.today(), used_by=None, uuid=request.session['signup_token']).exists():
|
||||
signup_token = True
|
||||
|
||||
if (request.resolver_match.view_name == 'account_signup' or request.resolver_match.view_name == 'socialaccount_signup') and not settings.ENABLE_SIGNUP and not signup_token:
|
||||
return False
|
||||
else:
|
||||
return super(AllAuthCustomAdapter, self).is_open_for_signup(request)
|
||||
|
||||
# disable password reset for now
|
||||
def send_mail(self, template_prefix, email, context):
|
||||
pass
|
||||
if settings.EMAIL_HOST != '':
|
||||
default = datetime.datetime.now()
|
||||
c = caches['default'].get_or_set(email, default, timeout=360)
|
||||
if c == default:
|
||||
super(AllAuthCustomAdapter, self).send_mail(template_prefix, email, context)
|
||||
else:
|
||||
messages.add_message(self.request, messages.ERROR, _('In order to prevent spam, the requested email was not send. Please wait a few minutes and try again.'))
|
||||
else:
|
||||
pass
|
||||
|
||||
19
cookbook/helper/CustomStorageClass.py
Normal file
19
cookbook/helper/CustomStorageClass.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import hashlib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from storages.backends.s3boto3 import S3Boto3Storage
|
||||
|
||||
|
||||
class CachedS3Boto3Storage(S3Boto3Storage):
|
||||
def url(self, name, **kwargs):
|
||||
key = hashlib.md5(f'recipes_media_urls_{name}'.encode('utf-8')).hexdigest()
|
||||
if result := cache.get(key):
|
||||
return result
|
||||
|
||||
result = super(CachedS3Boto3Storage, self).url(name, **kwargs)
|
||||
|
||||
timeout = int(settings.AWS_QUERYSTRING_EXPIRE * .95)
|
||||
cache.set(key, result, timeout)
|
||||
|
||||
return result
|
||||
13
cookbook/helper/context_processors.py
Normal file
13
cookbook/helper/context_processors.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def context_settings(request):
|
||||
return {
|
||||
'EMAIL_ENABLED': settings.EMAIL_HOST != '',
|
||||
'SIGNUP_ENABLED': settings.ENABLE_SIGNUP,
|
||||
'CAPTCHA_ENABLED': settings.HCAPTCHA_SITEKEY != '',
|
||||
'HOSTED': settings.HOSTED,
|
||||
'TERMS_URL': settings.TERMS_URL,
|
||||
'PRIVACY_URL': settings.PRIVACY_URL,
|
||||
'IMPRINT_URL': settings.IMPRINT_URL,
|
||||
}
|
||||
45
cookbook/helper/image_processing.py
Normal file
45
cookbook/helper/image_processing.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
def rescale_image_jpeg(image_object, base_width=720):
|
||||
img = Image.open(image_object)
|
||||
icc_profile = img.info.get('icc_profile') # remember color profile to not mess up colors
|
||||
width_percent = (base_width / float(img.size[0]))
|
||||
height = int((float(img.size[1]) * float(width_percent)))
|
||||
|
||||
img = img.resize((base_width, height), Image.ANTIALIAS)
|
||||
img_bytes = BytesIO()
|
||||
img.save(img_bytes, 'JPEG', quality=75, optimize=True, icc_profile=icc_profile)
|
||||
|
||||
return img_bytes
|
||||
|
||||
|
||||
def rescale_image_png(image_object, base_width=720):
|
||||
basewidth = 720
|
||||
wpercent = (basewidth / float(image_object.size[0]))
|
||||
hsize = int((float(image_object.size[1]) * float(wpercent)))
|
||||
img = image_object.resize((basewidth, hsize), Image.ANTIALIAS)
|
||||
|
||||
im_io = BytesIO()
|
||||
img.save(im_io, 'PNG', quality=70)
|
||||
return img
|
||||
|
||||
|
||||
def get_filetype(name):
|
||||
try:
|
||||
return os.path.splitext(name)[1]
|
||||
except:
|
||||
return '.jpeg'
|
||||
|
||||
|
||||
def handle_image(request, image_object, filetype='.jpeg'):
|
||||
if sys.getsizeof(image_object) / 8 > 500:
|
||||
if filetype == '.jpeg':
|
||||
return rescale_image_jpeg(image_object), filetype
|
||||
if filetype == '.png':
|
||||
return rescale_image_png(image_object), filetype
|
||||
return image_object, filetype
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
import string
|
||||
import unicodedata
|
||||
|
||||
@@ -22,20 +23,16 @@ def parse_fraction(x):
|
||||
def parse_amount(x):
|
||||
amount = 0
|
||||
unit = ''
|
||||
note = ''
|
||||
|
||||
did_check_frac = False
|
||||
end = 0
|
||||
while (
|
||||
end < len(x)
|
||||
and (
|
||||
x[end] in string.digits
|
||||
or (
|
||||
(x[end] == '.' or x[end] == ',' or x[end] == '/')
|
||||
and end + 1 < len(x)
|
||||
and x[end + 1] in string.digits
|
||||
)
|
||||
)
|
||||
):
|
||||
while (end < len(x) and (x[end] in string.digits
|
||||
or (
|
||||
(x[end] == '.' or x[end] == ',' or x[end] == '/')
|
||||
and end + 1 < len(x)
|
||||
and x[end + 1] in string.digits
|
||||
))):
|
||||
end += 1
|
||||
if end > 0:
|
||||
if "/" in x[:end]:
|
||||
@@ -55,7 +52,11 @@ def parse_amount(x):
|
||||
unit = x[end + 1:]
|
||||
except ValueError:
|
||||
unit = x[end:]
|
||||
return amount, unit
|
||||
|
||||
if unit.startswith('(') or unit.startswith('-'): # i dont know any unit that starts with ( or - so its likely an alternative like 1L (500ml) Water or 2-3
|
||||
unit = ''
|
||||
note = x
|
||||
return amount, unit, note
|
||||
|
||||
|
||||
def parse_ingredient_with_comma(tokens):
|
||||
@@ -106,6 +107,13 @@ def parse(x):
|
||||
unit = ''
|
||||
ingredient = ''
|
||||
note = ''
|
||||
unit_note = ''
|
||||
|
||||
# if the string contains parenthesis early on remove it and place it at the end
|
||||
# because its likely some kind of note
|
||||
if re.match('(.){1,6}\s\((.[^\(\)])+\)\s', x):
|
||||
match = re.search('\((.[^\(])+\)', x)
|
||||
x = x[:match.start()] + x[match.end():] + ' ' + x[match.start():match.end()]
|
||||
|
||||
tokens = x.split()
|
||||
if len(tokens) == 1:
|
||||
@@ -114,17 +122,17 @@ def parse(x):
|
||||
else:
|
||||
try:
|
||||
# try to parse first argument as amount
|
||||
amount, unit = parse_amount(tokens[0])
|
||||
amount, unit, unit_note = parse_amount(tokens[0])
|
||||
# only try to parse second argument as amount if there are at least
|
||||
# three arguments if it already has a unit there can't be
|
||||
# a fraction for the amount
|
||||
if len(tokens) > 2:
|
||||
try:
|
||||
if not unit == '':
|
||||
# a unit is already found, no need to try the second argument for a fraction # noqa: E501
|
||||
# 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 # noqa: E501
|
||||
raise ValueError
|
||||
# try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½' # noqa: E501
|
||||
# try to parse second argument as amount and add that, in case of '2 1/2' or '2 ½'
|
||||
amount += parse_fraction(tokens[1])
|
||||
# assume that units can't end with a comma
|
||||
if len(tokens) > 3 and not tokens[2].endswith(','):
|
||||
@@ -142,7 +150,10 @@ def parse(x):
|
||||
# try to use second argument as unit and everything else as ingredient, use everything as ingredient if it fails # noqa: E501
|
||||
try:
|
||||
ingredient, note = parse_ingredient(tokens[2:])
|
||||
unit = tokens[1]
|
||||
if unit == '':
|
||||
unit = tokens[1]
|
||||
else:
|
||||
note = tokens[1]
|
||||
except ValueError:
|
||||
ingredient, note = parse_ingredient(tokens[1:])
|
||||
else:
|
||||
@@ -158,11 +169,16 @@ def parse(x):
|
||||
ingredient, note = parse_ingredient(tokens)
|
||||
except ValueError:
|
||||
ingredient = ' '.join(tokens[1:])
|
||||
|
||||
if unit_note not in note:
|
||||
note += ' ' + unit_note
|
||||
return amount, unit.strip(), ingredient.strip(), note.strip()
|
||||
|
||||
|
||||
# small utility functions to prevent emtpy unit/food creation
|
||||
def get_unit(unit, space):
|
||||
if not unit:
|
||||
return None
|
||||
if len(unit) > 0:
|
||||
u, created = Unit.objects.get_or_create(name=unit, space=space)
|
||||
return u
|
||||
@@ -170,6 +186,8 @@ def get_unit(unit, space):
|
||||
|
||||
|
||||
def get_food(food, space):
|
||||
if not food:
|
||||
return None
|
||||
if len(food) > 0:
|
||||
f, created = Food.objects.get_or_create(name=food, space=space)
|
||||
return f
|
||||
|
||||
@@ -19,7 +19,8 @@ class StyleTreeprocessor(Treeprocessor):
|
||||
|
||||
|
||||
class MarkdownFormatExtension(markdown.Extension):
|
||||
def extendMarkdown(self, md, md_globals):
|
||||
# md_ globals deprecated - see here:
|
||||
def extendMarkdown(self, md):
|
||||
md.treeprocessors.register(
|
||||
StyleTreeprocessor(),
|
||||
'StyleTreeprocessor',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
Source: https://djangosnippets.org/snippets/1703/
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.core.cache import caches
|
||||
from django.views.generic.detail import SingleObjectTemplateResponseMixin
|
||||
from django.views.generic.edit import ModelFormMixin
|
||||
|
||||
@@ -90,7 +92,18 @@ def share_link_valid(recipe, share):
|
||||
:return: true if a share link with the given recipe and uuid exists
|
||||
"""
|
||||
try:
|
||||
return True if ShareLink.objects.filter(recipe=recipe, uuid=share).exists() else False
|
||||
CACHE_KEY = f'recipe_share_{recipe.pk}_{share}'
|
||||
if c := caches['default'].get(CACHE_KEY, False):
|
||||
return c
|
||||
|
||||
if link := ShareLink.objects.filter(recipe=recipe, uuid=share, abuse_blocked=False).first():
|
||||
if 0 < settings.SHARING_LIMIT < link.request_count:
|
||||
return False
|
||||
link.request_count += 1
|
||||
link.save()
|
||||
caches['default'].set(CACHE_KEY, True, timeout=3)
|
||||
return True
|
||||
return False
|
||||
except ValidationError:
|
||||
return False
|
||||
|
||||
@@ -120,13 +133,19 @@ class GroupRequiredMixin(object):
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not has_group_permission(request.user, self.groups_required):
|
||||
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
|
||||
return HttpResponseRedirect(reverse_lazy('index'))
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
messages.add_message(request, messages.ERROR,
|
||||
_('You are not logged in and therefore cannot view this page!'))
|
||||
return HttpResponseRedirect(reverse_lazy('account_login') + '?next=' + request.path)
|
||||
else:
|
||||
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
|
||||
@@ -138,17 +157,20 @@ class OwnerRequiredMixin(object):
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
messages.add_message(request, messages.ERROR, _('You are not logged in and therefore cannot view this page!'))
|
||||
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:
|
||||
if not is_object_owner(request.user, self.get_object()):
|
||||
messages.add_message(request, messages.ERROR, _('You cannot interact with this object as it is not owned by you!'))
|
||||
messages.add_message(request, messages.ERROR,
|
||||
_('You cannot interact with this object as it is not owned by you!'))
|
||||
return HttpResponseRedirect(reverse('index'))
|
||||
|
||||
try:
|
||||
obj = self.get_object()
|
||||
if obj.get_space() != request.space:
|
||||
messages.add_message(request, messages.ERROR, _('You do not have the required permissions to view this page!'))
|
||||
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
|
||||
|
||||
193
cookbook/helper/recipe_html_import.py
Normal file
193
cookbook/helper/recipe_html_import.py
Normal file
@@ -0,0 +1,193 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
from bs4.element import Tag
|
||||
from cookbook.helper import recipe_url_import as helper
|
||||
from cookbook.helper.scrapers.scrapers import text_scraper
|
||||
from json import JSONDecodeError
|
||||
from recipe_scrapers._utils import get_host_name, normalize_string
|
||||
from urllib.parse import unquote
|
||||
|
||||
|
||||
def get_recipe_from_source(text, url, space):
|
||||
def build_node(k, v):
|
||||
if isinstance(v, dict):
|
||||
node = {
|
||||
'name': k,
|
||||
'value': k,
|
||||
'children': get_children_dict(v)
|
||||
}
|
||||
elif isinstance(v, list):
|
||||
node = {
|
||||
'name': k,
|
||||
'value': k,
|
||||
'children': get_children_list(v)
|
||||
}
|
||||
else:
|
||||
node = {
|
||||
'name': k + ": " + normalize_string(str(v)),
|
||||
'value': normalize_string(str(v))
|
||||
}
|
||||
return node
|
||||
|
||||
def get_children_dict(children):
|
||||
kid_list = []
|
||||
for k, v in children.items():
|
||||
kid_list.append(build_node(k, v))
|
||||
return kid_list
|
||||
|
||||
def get_children_list(children):
|
||||
kid_list = []
|
||||
for kid in children:
|
||||
if type(kid) == list:
|
||||
node = {
|
||||
'name': "unknown list",
|
||||
'value': "unknown list",
|
||||
'children': get_children_list(kid)
|
||||
}
|
||||
kid_list.append(node)
|
||||
elif type(kid) == dict:
|
||||
for k, v in kid.items():
|
||||
kid_list.append(build_node(k, v))
|
||||
else:
|
||||
kid_list.append({
|
||||
'name': normalize_string(str(kid)),
|
||||
'value': normalize_string(str(kid))
|
||||
})
|
||||
return kid_list
|
||||
|
||||
recipe_json = {
|
||||
'name': '',
|
||||
'url': '',
|
||||
'description': '',
|
||||
'image': '',
|
||||
'keywords': [],
|
||||
'recipeIngredient': [],
|
||||
'recipeInstructions': '',
|
||||
'servings': '',
|
||||
'prepTime': '',
|
||||
'cookTime': ''
|
||||
}
|
||||
recipe_tree = []
|
||||
parse_list = []
|
||||
html_data = []
|
||||
images = []
|
||||
text = unquote(text)
|
||||
|
||||
try:
|
||||
parse_list.append(remove_graph(json.loads(text)))
|
||||
if not url and 'url' in parse_list[0]:
|
||||
url = parse_list[0]['url']
|
||||
scrape = text_scraper("<script type='application/ld+json'>" + text + "</script>", url=url)
|
||||
|
||||
except JSONDecodeError:
|
||||
soup = BeautifulSoup(text, "html.parser")
|
||||
html_data = get_from_html(soup)
|
||||
images += get_images_from_source(soup, url)
|
||||
for el in soup.find_all('script', type='application/ld+json'):
|
||||
el = remove_graph(el)
|
||||
if not url and 'url' in el:
|
||||
url = el['url']
|
||||
if type(el) == list:
|
||||
for le in el:
|
||||
parse_list.append(le)
|
||||
elif type(el) == dict:
|
||||
parse_list.append(el)
|
||||
for el in soup.find_all(type='application/json'):
|
||||
el = remove_graph(el)
|
||||
if type(el) == list:
|
||||
for le in el:
|
||||
parse_list.append(le)
|
||||
elif type(el) == dict:
|
||||
parse_list.append(el)
|
||||
scrape = text_scraper(text, url=url)
|
||||
|
||||
recipe_json = helper.get_from_scraper(scrape, space)
|
||||
|
||||
for el in parse_list:
|
||||
temp_tree = []
|
||||
if isinstance(el, Tag):
|
||||
try:
|
||||
el = json.loads(el.string)
|
||||
except TypeError:
|
||||
continue
|
||||
|
||||
for k, v in el.items():
|
||||
if isinstance(v, dict):
|
||||
node = {
|
||||
'name': k,
|
||||
'value': k,
|
||||
'children': get_children_dict(v)
|
||||
}
|
||||
elif isinstance(v, list):
|
||||
node = {
|
||||
'name': k,
|
||||
'value': k,
|
||||
'children': get_children_list(v)
|
||||
}
|
||||
else:
|
||||
node = {
|
||||
'name': k + ": " + normalize_string(str(v)),
|
||||
'value': normalize_string(str(v))
|
||||
}
|
||||
temp_tree.append(node)
|
||||
|
||||
if '@type' in el and el['@type'] == 'Recipe':
|
||||
recipe_tree += [{'name': 'ld+json', 'children': temp_tree}]
|
||||
else:
|
||||
recipe_tree += [{'name': 'json', 'children': temp_tree}]
|
||||
|
||||
return recipe_json, recipe_tree, html_data, images
|
||||
|
||||
|
||||
def get_from_html(soup):
|
||||
INVISIBLE_ELEMS = ('style', 'script', 'head', 'title')
|
||||
html = []
|
||||
for s in soup.strings:
|
||||
if ((s.parent.name not in INVISIBLE_ELEMS) and (len(s.strip()) > 0)):
|
||||
html.append(s)
|
||||
return html
|
||||
|
||||
|
||||
def get_images_from_source(soup, url):
|
||||
sources = ['src', 'srcset', 'data-src']
|
||||
images = []
|
||||
img_tags = soup.find_all('img')
|
||||
if url:
|
||||
site = get_host_name(url)
|
||||
prot = url.split(':')[0]
|
||||
|
||||
urls = []
|
||||
for img in img_tags:
|
||||
for src in sources:
|
||||
try:
|
||||
urls.append(img[src])
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
for u in urls:
|
||||
u = u.split('?')[0]
|
||||
filename = re.search(r'/([\w_-]+[.](jpg|jpeg|gif|png))$', u)
|
||||
if filename:
|
||||
if (('http' not in u) and (url)):
|
||||
# sometimes an image source can be relative
|
||||
# if it is provide the base url
|
||||
u = '{}://{}{}'.format(prot, site, u)
|
||||
if 'http' in u:
|
||||
images.append(u)
|
||||
return images
|
||||
|
||||
|
||||
def remove_graph(el):
|
||||
# recipes type might be wrapped in @graph type
|
||||
if isinstance(el, Tag):
|
||||
try:
|
||||
el = json.loads(el.string)
|
||||
if '@graph' in el:
|
||||
for x in el['@graph']:
|
||||
if '@type' in x and x['@type'] == 'Recipe':
|
||||
el = x
|
||||
except TypeError:
|
||||
pass
|
||||
return el
|
||||
76
cookbook/helper/recipe_search.py
Normal file
76
cookbook/helper/recipe_search.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from datetime import datetime, timedelta
|
||||
from functools import reduce
|
||||
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.db.models import Q, Case, When, Value
|
||||
from django.forms import IntegerField
|
||||
|
||||
from cookbook.models import ViewLog
|
||||
from recipes import settings
|
||||
|
||||
|
||||
def search_recipes(request, queryset, params):
|
||||
search_string = params.get('query', '')
|
||||
search_keywords = params.getlist('keywords', [])
|
||||
search_foods = params.getlist('foods', [])
|
||||
search_books = params.getlist('books', [])
|
||||
|
||||
search_keywords_or = params.get('keywords_or', True)
|
||||
search_foods_or = params.get('foods_or', True)
|
||||
search_books_or = params.get('books_or', True)
|
||||
|
||||
search_internal = params.get('internal', None)
|
||||
search_random = params.get('random', False)
|
||||
search_new = params.get('new', False)
|
||||
search_last_viewed = int(params.get('last_viewed', 0))
|
||||
|
||||
if search_last_viewed > 0:
|
||||
last_viewed_recipes = ViewLog.objects.filter(created_by=request.user, space=request.space,
|
||||
created_at__gte=datetime.now() - timedelta(days=14)).order_by('pk').values_list('recipe__pk', flat=True).distinct()
|
||||
|
||||
return queryset.filter(pk__in=last_viewed_recipes[len(last_viewed_recipes) - min(len(last_viewed_recipes), search_last_viewed):])
|
||||
|
||||
if search_new == 'true':
|
||||
queryset = queryset.annotate(
|
||||
new_recipe=Case(When(created_at__gte=(datetime.now() - timedelta(days=7)), then=Value(100)),
|
||||
default=Value(0), )).order_by('-new_recipe', 'name')
|
||||
else:
|
||||
queryset = queryset.order_by('name')
|
||||
|
||||
if settings.DATABASES['default']['ENGINE'] in ['django.db.backends.postgresql_psycopg2',
|
||||
'django.db.backends.postgresql']:
|
||||
queryset = queryset.annotate(similarity=TrigramSimilarity('name', search_string), ).filter(
|
||||
Q(similarity__gt=0.1) | Q(name__unaccent__icontains=search_string)).order_by('-similarity')
|
||||
else:
|
||||
queryset = queryset.filter(name__icontains=search_string)
|
||||
|
||||
if len(search_keywords) > 0:
|
||||
if search_keywords_or == 'true':
|
||||
queryset = queryset.filter(keywords__id__in=search_keywords)
|
||||
else:
|
||||
for k in search_keywords:
|
||||
queryset = queryset.filter(keywords__id=k)
|
||||
|
||||
if len(search_foods) > 0:
|
||||
if search_foods_or == 'true':
|
||||
queryset = queryset.filter(steps__ingredients__food__id__in=search_foods)
|
||||
else:
|
||||
for k in search_foods:
|
||||
queryset = queryset.filter(steps__ingredients__food__id=k)
|
||||
|
||||
if len(search_books) > 0:
|
||||
if search_books_or == 'true':
|
||||
queryset = queryset.filter(recipebookentry__book__id__in=search_books)
|
||||
else:
|
||||
for k in search_books:
|
||||
queryset = queryset.filter(recipebookentry__book__id=k)
|
||||
|
||||
queryset = queryset.distinct()
|
||||
|
||||
if search_internal == 'true':
|
||||
queryset = queryset.filter(internal=True)
|
||||
|
||||
if search_random == 'true':
|
||||
queryset = queryset.order_by("?")
|
||||
|
||||
return queryset
|
||||
@@ -1,269 +1,97 @@
|
||||
import json
|
||||
import random
|
||||
import re
|
||||
from json import JSONDecodeError
|
||||
from isodate import parse_duration as iso_parse_duration
|
||||
from isodate.isoerror import ISO8601Error
|
||||
from recipe_scrapers._exceptions import ElementNotFoundInHtml
|
||||
|
||||
import microdata
|
||||
from bs4 import BeautifulSoup
|
||||
from cookbook.helper.ingredient_parser import parse as parse_ingredient
|
||||
from cookbook.helper.ingredient_parser import parse as parse_single_ingredient
|
||||
from cookbook.models import Keyword
|
||||
from django.http import JsonResponse
|
||||
from django.utils.dateparse import parse_duration
|
||||
from django.utils.translation import gettext as _
|
||||
from recipe_scrapers import _utils
|
||||
|
||||
|
||||
def get_from_html(html_text, url, space):
|
||||
soup = BeautifulSoup(html_text, "html.parser")
|
||||
|
||||
# first try finding ld+json as its most common
|
||||
for ld in soup.find_all('script', type='application/ld+json'):
|
||||
try:
|
||||
ld_json = json.loads(ld.string.replace('\n', ''))
|
||||
if type(ld_json) != list:
|
||||
ld_json = [ld_json]
|
||||
|
||||
for ld_json_item in ld_json:
|
||||
# recipes type might be wrapped in @graph type
|
||||
if '@graph' in ld_json_item:
|
||||
for x in ld_json_item['@graph']:
|
||||
if '@type' in x and x['@type'] == 'Recipe':
|
||||
ld_json_item = x
|
||||
|
||||
if ('@type' in ld_json_item
|
||||
and ld_json_item['@type'] == 'Recipe'):
|
||||
return JsonResponse(find_recipe_json(ld_json_item, url, space))
|
||||
except JSONDecodeError:
|
||||
return JsonResponse(
|
||||
{
|
||||
'error': True,
|
||||
'msg': _('The requested site provided malformed data and cannot be read.') # noqa: E501
|
||||
},
|
||||
status=400)
|
||||
|
||||
# now try to find microdata
|
||||
items = microdata.get_items(html_text)
|
||||
for i in items:
|
||||
md_json = json.loads(i.json())
|
||||
if 'schema.org/Recipe' in str(md_json['type']):
|
||||
return JsonResponse(find_recipe_json(md_json['properties'], url, space))
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
'error': True,
|
||||
'msg': _('The requested site does not provide any recognized data format to import the recipe from.') # noqa: E501
|
||||
},
|
||||
status=400)
|
||||
|
||||
|
||||
def find_recipe_json(ld_json, url, space):
|
||||
if type(ld_json['name']) == list:
|
||||
try:
|
||||
ld_json['name'] = ld_json['name'][0]
|
||||
except Exception:
|
||||
ld_json['name'] = 'ERROR'
|
||||
|
||||
# some sites use ingredients instead of recipeIngredients
|
||||
if 'recipeIngredient' not in ld_json and 'ingredients' in ld_json:
|
||||
ld_json['recipeIngredient'] = ld_json['ingredients']
|
||||
|
||||
if 'recipeIngredient' in ld_json:
|
||||
# some pages have comma separated ingredients in a single array entry
|
||||
if (len(ld_json['recipeIngredient']) == 1
|
||||
and type(ld_json['recipeIngredient']) == list):
|
||||
ld_json['recipeIngredient'] = ld_json['recipeIngredient'][0].split(',') # noqa: E501
|
||||
elif type(ld_json['recipeIngredient']) == str:
|
||||
ld_json['recipeIngredient'] = ld_json['recipeIngredient'].split(',')
|
||||
|
||||
for x in ld_json['recipeIngredient']:
|
||||
if '\n' in x:
|
||||
ld_json['recipeIngredient'].remove(x)
|
||||
for i in x.split('\n'):
|
||||
ld_json['recipeIngredient'].insert(0, i)
|
||||
|
||||
ingredients = []
|
||||
|
||||
for x in ld_json['recipeIngredient']:
|
||||
if x.replace(' ', '') != '':
|
||||
x = x.replace('½', "0.5").replace('¼', "0.25").replace('¾', "0.75")
|
||||
try:
|
||||
amount, unit, ingredient, note = parse_ingredient(x)
|
||||
if ingredient:
|
||||
ingredients.append(
|
||||
{
|
||||
'amount': amount,
|
||||
'unit': {
|
||||
'text': unit,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'ingredient': {
|
||||
'text': ingredient,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'note': note,
|
||||
'original': x
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
ingredients.append(
|
||||
{
|
||||
'amount': 0,
|
||||
'unit': {
|
||||
'text': '',
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'ingredient': {
|
||||
'text': x,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'note': '',
|
||||
'original': x
|
||||
}
|
||||
)
|
||||
|
||||
ld_json['recipeIngredient'] = ingredients
|
||||
else:
|
||||
ld_json['recipeIngredient'] = []
|
||||
|
||||
if 'keywords' in ld_json:
|
||||
ld_json['keywords'] = parse_keywords(listify_keywords(ld_json['keywords']), space)
|
||||
|
||||
if 'recipeInstructions' in ld_json:
|
||||
instructions = ''
|
||||
|
||||
# flatten instructions if they are in a list
|
||||
if type(ld_json['recipeInstructions']) == list:
|
||||
for i in ld_json['recipeInstructions']:
|
||||
if type(i) == str:
|
||||
instructions += i
|
||||
else:
|
||||
if 'text' in i:
|
||||
instructions += i['text'] + '\n\n'
|
||||
elif 'itemListElement' in i:
|
||||
for ile in i['itemListElement']:
|
||||
if type(ile) == str:
|
||||
instructions += ile + '\n\n'
|
||||
elif 'text' in ile:
|
||||
instructions += ile['text'] + '\n\n'
|
||||
else:
|
||||
instructions += str(i)
|
||||
ld_json['recipeInstructions'] = instructions
|
||||
|
||||
ld_json['recipeInstructions'] = re.sub(r'\n\s*\n', '\n\n', ld_json['recipeInstructions']) # noqa: E501
|
||||
ld_json['recipeInstructions'] = re.sub(' +', ' ', ld_json['recipeInstructions']) # noqa: E501
|
||||
ld_json['recipeInstructions'] = ld_json['recipeInstructions'].replace('<p>', '') # noqa: E501
|
||||
ld_json['recipeInstructions'] = ld_json['recipeInstructions'].replace('</p>', '') # noqa: E501
|
||||
else:
|
||||
ld_json['recipeInstructions'] = ''
|
||||
|
||||
if url != '':
|
||||
ld_json['recipeInstructions'] += '\n\n' + _('Imported from') + ' ' + url
|
||||
|
||||
if 'image' in ld_json:
|
||||
# check if list of images is returned, take first if so
|
||||
if (type(ld_json['image'])) == list:
|
||||
if type(ld_json['image'][0]) == str:
|
||||
ld_json['image'] = ld_json['image'][0]
|
||||
elif 'url' in ld_json['image'][0]:
|
||||
ld_json['image'] = ld_json['image'][0]['url']
|
||||
|
||||
# ignore relative image paths
|
||||
if 'http' not in ld_json['image']:
|
||||
ld_json['image'] = ''
|
||||
|
||||
if 'cookTime' in ld_json:
|
||||
try:
|
||||
if (type(ld_json['cookTime']) == list
|
||||
and len(ld_json['cookTime']) > 0):
|
||||
ld_json['cookTime'] = ld_json['cookTime'][0]
|
||||
ld_json['cookTime'] = round(
|
||||
parse_duration(
|
||||
ld_json['cookTime']
|
||||
).seconds / 60
|
||||
)
|
||||
except TypeError:
|
||||
ld_json['cookTime'] = 0
|
||||
else:
|
||||
ld_json['cookTime'] = 0
|
||||
|
||||
if 'prepTime' in ld_json:
|
||||
try:
|
||||
if (type(ld_json['prepTime']) == list
|
||||
and len(ld_json['prepTime']) > 0):
|
||||
ld_json['prepTime'] = ld_json['prepTime'][0]
|
||||
ld_json['prepTime'] = round(
|
||||
parse_duration(
|
||||
ld_json['prepTime']
|
||||
).seconds / 60
|
||||
)
|
||||
except TypeError:
|
||||
ld_json['prepTime'] = 0
|
||||
else:
|
||||
ld_json['prepTime'] = 0
|
||||
|
||||
ld_json['servings'] = 1
|
||||
try:
|
||||
if 'recipeYield' in ld_json:
|
||||
if type(ld_json['recipeYield']) == str:
|
||||
ld_json['servings'] = int(re.findall(r'\b\d+\b', ld_json['recipeYield'])[0])
|
||||
elif type(ld_json['recipeYield']) == list:
|
||||
ld_json['servings'] = int(re.findall(r'\b\d+\b', ld_json['recipeYield'][0])[0])
|
||||
except Exception as e:
|
||||
print(e)
|
||||
|
||||
for key in list(ld_json):
|
||||
if key not in [
|
||||
'prepTime', 'cookTime', 'image', 'recipeInstructions',
|
||||
'keywords', 'name', 'recipeIngredient', 'servings', 'description'
|
||||
]:
|
||||
ld_json.pop(key, None)
|
||||
|
||||
return ld_json
|
||||
from html import unescape
|
||||
from recipe_scrapers._schemaorg import SchemaOrgException
|
||||
from recipe_scrapers._utils import get_minutes
|
||||
|
||||
|
||||
def get_from_scraper(scrape, space):
|
||||
# converting the scrape_me object to the existing json format based on ld+json
|
||||
|
||||
recipe_json = {}
|
||||
recipe_json['name'] = scrape.title()
|
||||
try:
|
||||
recipe_json['name'] = parse_name(scrape.title() or None)
|
||||
except Exception:
|
||||
recipe_json['name'] = None
|
||||
if not recipe_json['name']:
|
||||
try:
|
||||
recipe_json['name'] = scrape.schema.data.get('name') or ''
|
||||
except Exception:
|
||||
recipe_json['name'] = ''
|
||||
|
||||
try:
|
||||
description = scrape.schema.data.get("description") or ''
|
||||
recipe_json['prepTime'] = _utils.get_minutes(scrape.schema.data.get("prepTime")) or 0
|
||||
recipe_json['cookTime'] = _utils.get_minutes(scrape.schema.data.get("cookTime")) or 0
|
||||
except AttributeError:
|
||||
except Exception:
|
||||
description = ''
|
||||
recipe_json['prepTime'] = 0
|
||||
recipe_json['cookTime'] = 0
|
||||
|
||||
recipe_json['description'] = description
|
||||
recipe_json['description'] = parse_description(description)
|
||||
|
||||
try:
|
||||
servings = scrape.yields()
|
||||
servings = int(re.findall(r'\b\d+\b', servings)[0])
|
||||
except (AttributeError, ValueError, IndexError):
|
||||
servings = 1
|
||||
recipe_json['servings'] = servings
|
||||
servings = scrape.yields() or None
|
||||
except Exception:
|
||||
servings = None
|
||||
if not servings:
|
||||
try:
|
||||
servings = scrape.schema.data.get('recipeYield') or 1
|
||||
except Exception:
|
||||
servings = 1
|
||||
if type(servings) != int:
|
||||
try:
|
||||
servings = int(re.findall(r'\b\d+\b', servings)[0])
|
||||
except Exception:
|
||||
servings = 1
|
||||
recipe_json['servings'] = max(servings, 1)
|
||||
|
||||
try:
|
||||
recipe_json['prepTime'] = get_minutes(scrape.schema.data.get("prepTime")) or 0
|
||||
except Exception:
|
||||
recipe_json['prepTime'] = 0
|
||||
try:
|
||||
recipe_json['cookTime'] = get_minutes(scrape.schema.data.get("cookTime")) or 0
|
||||
except Exception:
|
||||
recipe_json['cookTime'] = 0
|
||||
if recipe_json['cookTime'] + recipe_json['prepTime'] == 0:
|
||||
try:
|
||||
recipe_json['prepTime'] = scrape.total_time()
|
||||
except AttributeError:
|
||||
pass
|
||||
recipe_json['prepTime'] = get_minutes(scrape.total_time()) or 0
|
||||
except Exception:
|
||||
try:
|
||||
get_minutes(scrape.schema.data.get("totalTime")) or 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
recipe_json['image'] = scrape.image()
|
||||
except AttributeError:
|
||||
pass
|
||||
recipe_json['image'] = parse_image(scrape.image()) or None
|
||||
except Exception:
|
||||
recipe_json['image'] = None
|
||||
if not recipe_json['image']:
|
||||
try:
|
||||
recipe_json['image'] = parse_image(scrape.schema.data.get('image')) or ''
|
||||
except Exception:
|
||||
recipe_json['image'] = ''
|
||||
|
||||
keywords = []
|
||||
try:
|
||||
if scrape.schema.data.get("keywords"):
|
||||
keywords += listify_keywords(scrape.schema.data.get("keywords"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if scrape.schema.data.get('recipeCategory'):
|
||||
keywords += listify_keywords(scrape.schema.data.get("recipeCategory"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if scrape.schema.data.get('recipeCuisine'):
|
||||
keywords += listify_keywords(scrape.schema.data.get("recipeCuisine"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
recipe_json['keywords'] = parse_keywords(list(set(map(str.casefold, keywords))), space)
|
||||
except AttributeError:
|
||||
recipe_json['keywords'] = keywords
|
||||
@@ -272,23 +100,22 @@ def get_from_scraper(scrape, space):
|
||||
ingredients = []
|
||||
for x in scrape.ingredients():
|
||||
try:
|
||||
amount, unit, ingredient, note = parse_ingredient(x)
|
||||
if ingredient:
|
||||
ingredients.append(
|
||||
{
|
||||
'amount': amount,
|
||||
'unit': {
|
||||
'text': unit,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'ingredient': {
|
||||
'text': ingredient,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'note': note,
|
||||
'original': x
|
||||
}
|
||||
)
|
||||
amount, unit, ingredient, note = parse_single_ingredient(x)
|
||||
ingredients.append(
|
||||
{
|
||||
'amount': amount,
|
||||
'unit': {
|
||||
'text': unit,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'ingredient': {
|
||||
'text': ingredient,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'note': note,
|
||||
'original': x
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
ingredients.append(
|
||||
{
|
||||
@@ -306,38 +133,236 @@ def get_from_scraper(scrape, space):
|
||||
}
|
||||
)
|
||||
recipe_json['recipeIngredient'] = ingredients
|
||||
except AttributeError:
|
||||
except Exception:
|
||||
recipe_json['recipeIngredient'] = ingredients
|
||||
|
||||
try:
|
||||
recipe_json['recipeInstructions'] = scrape.instructions()
|
||||
except AttributeError:
|
||||
recipe_json['recipeInstructions'] = parse_instructions(scrape.instructions())
|
||||
except Exception:
|
||||
recipe_json['recipeInstructions'] = ""
|
||||
|
||||
recipe_json['recipeInstructions'] += "\n\nImported from " + scrape.url
|
||||
if scrape.url:
|
||||
recipe_json['url'] = scrape.url
|
||||
recipe_json['recipeInstructions'] += "\n\nImported from " + scrape.url
|
||||
return recipe_json
|
||||
|
||||
|
||||
def parse_name(name):
|
||||
if type(name) == list:
|
||||
try:
|
||||
name = name[0]
|
||||
except Exception:
|
||||
name = 'ERROR'
|
||||
return normalize_string(name)
|
||||
|
||||
|
||||
def parse_ingredients(ingredients):
|
||||
# some pages have comma separated ingredients in a single array entry
|
||||
try:
|
||||
if type(ingredients[0]) == dict:
|
||||
return ingredients
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
|
||||
if (len(ingredients) == 1 and type(ingredients) == list):
|
||||
ingredients = ingredients[0].split(',')
|
||||
elif type(ingredients) == str:
|
||||
ingredients = ingredients.split(',')
|
||||
|
||||
for x in ingredients:
|
||||
if '\n' in x:
|
||||
ingredients.remove(x)
|
||||
for i in x.split('\n'):
|
||||
ingredients.insert(0, i)
|
||||
|
||||
ingredient_list = []
|
||||
|
||||
for x in ingredients:
|
||||
if x.replace(' ', '') != '':
|
||||
x = x.replace('½', "0.5").replace('¼', "0.25").replace('¾', "0.75")
|
||||
try:
|
||||
amount, unit, ingredient, note = parse_single_ingredient(x)
|
||||
if ingredient:
|
||||
ingredient_list.append(
|
||||
{
|
||||
'amount': amount,
|
||||
'unit': {
|
||||
'text': unit,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'ingredient': {
|
||||
'text': ingredient,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'note': note,
|
||||
'original': x
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
ingredient_list.append(
|
||||
{
|
||||
'amount': 0,
|
||||
'unit': {
|
||||
'text': '',
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'ingredient': {
|
||||
'text': x,
|
||||
'id': random.randrange(10000, 99999)
|
||||
},
|
||||
'note': '',
|
||||
'original': x
|
||||
}
|
||||
)
|
||||
|
||||
ingredients = ingredient_list
|
||||
else:
|
||||
ingredients = []
|
||||
return ingredients
|
||||
|
||||
|
||||
def parse_description(description):
|
||||
return normalize_string(description)
|
||||
|
||||
|
||||
def parse_instructions(instructions):
|
||||
instruction_text = ''
|
||||
|
||||
# flatten instructions if they are in a list
|
||||
if type(instructions) == list:
|
||||
for i in instructions:
|
||||
if type(i) == str:
|
||||
instruction_text += i
|
||||
else:
|
||||
if 'text' in i:
|
||||
instruction_text += i['text'] + '\n\n'
|
||||
elif 'itemListElement' in i:
|
||||
for ile in i['itemListElement']:
|
||||
if type(ile) == str:
|
||||
instruction_text += ile + '\n\n'
|
||||
elif 'text' in ile:
|
||||
instruction_text += ile['text'] + '\n\n'
|
||||
else:
|
||||
instruction_text += str(i)
|
||||
instructions = instruction_text
|
||||
|
||||
return normalize_string(instructions)
|
||||
|
||||
|
||||
def parse_image(image):
|
||||
# check if list of images is returned, take first if so
|
||||
if not image:
|
||||
return None
|
||||
if type(image) == list:
|
||||
for pic in image:
|
||||
if (type(pic) == str) and (pic[:4] == 'http'):
|
||||
image = pic
|
||||
elif 'url' in pic:
|
||||
image = pic['url']
|
||||
elif type(image) == dict:
|
||||
if 'url' in image:
|
||||
image = image['url']
|
||||
|
||||
# ignore relative image paths
|
||||
if image[:4] != 'http':
|
||||
image = ''
|
||||
return image
|
||||
|
||||
|
||||
def parse_servings(servings):
|
||||
if type(servings) == str:
|
||||
try:
|
||||
servings = int(re.search(r'\d+', servings).group())
|
||||
except AttributeError:
|
||||
servings = 1
|
||||
elif type(servings) == list:
|
||||
try:
|
||||
servings = int(re.findall(r'\b\d+\b', servings[0])[0])
|
||||
except KeyError:
|
||||
servings = 1
|
||||
return servings
|
||||
|
||||
|
||||
def parse_cooktime(cooktime):
|
||||
if type(cooktime) not in [int, float]:
|
||||
try:
|
||||
cooktime = float(re.search(r'\d+', cooktime).group())
|
||||
except (ValueError, AttributeError):
|
||||
try:
|
||||
cooktime = round(iso_parse_duration(cooktime).seconds / 60)
|
||||
except ISO8601Error:
|
||||
try:
|
||||
if (type(cooktime) == list and len(cooktime) > 0):
|
||||
cooktime = cooktime[0]
|
||||
cooktime = round(parse_duration(cooktime).seconds / 60)
|
||||
except AttributeError:
|
||||
cooktime = 0
|
||||
|
||||
return cooktime
|
||||
|
||||
|
||||
def parse_preptime(preptime):
|
||||
if type(preptime) not in [int, float]:
|
||||
try:
|
||||
preptime = float(re.search(r'\d+', preptime).group())
|
||||
except ValueError:
|
||||
try:
|
||||
preptime = round(iso_parse_duration(preptime).seconds / 60)
|
||||
except ISO8601Error:
|
||||
try:
|
||||
if (type(preptime) == list and len(preptime) > 0):
|
||||
preptime = preptime[0]
|
||||
preptime = round(parse_duration(preptime).seconds / 60)
|
||||
except AttributeError:
|
||||
preptime = 0
|
||||
|
||||
return preptime
|
||||
|
||||
|
||||
def parse_keywords(keyword_json, space):
|
||||
keywords = []
|
||||
# keywords as list
|
||||
for kw in keyword_json:
|
||||
if k := Keyword.objects.filter(name=kw, space=space).first():
|
||||
keywords.append({'id': str(k.id), 'text': str(k)})
|
||||
else:
|
||||
keywords.append({'id': random.randrange(1111111, 9999999, 1), 'text': kw})
|
||||
kw = normalize_string(kw)
|
||||
if len(kw) != 0:
|
||||
if k := Keyword.objects.filter(name=kw, space=space).first():
|
||||
keywords.append({'id': str(k.id), 'text': str(k)})
|
||||
else:
|
||||
keywords.append({'id': random.randrange(1111111, 9999999, 1), 'text': kw})
|
||||
|
||||
return keywords
|
||||
|
||||
|
||||
def listify_keywords(keyword_list):
|
||||
# keywords as string
|
||||
try:
|
||||
if type(keyword_list[0]) == dict:
|
||||
return keyword_list
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
if type(keyword_list) == str:
|
||||
keyword_list = keyword_list.split(',')
|
||||
|
||||
# keywords as string in list
|
||||
if (type(keyword_list) == list
|
||||
and len(keyword_list) == 1
|
||||
and ',' in keyword_list[0]):
|
||||
if (type(keyword_list) == list and len(keyword_list) == 1 and ',' in keyword_list[0]):
|
||||
keyword_list = keyword_list[0].split(',')
|
||||
return [x.strip() for x in keyword_list]
|
||||
|
||||
|
||||
def normalize_string(string):
|
||||
# Convert all named and numeric character references (e.g. >, >)
|
||||
unescaped_string = unescape(string)
|
||||
unescaped_string = re.sub('<[^<]+?>', '', unescaped_string)
|
||||
unescaped_string = re.sub(' +', ' ', unescaped_string)
|
||||
unescaped_string = re.sub('</p>', '\n', unescaped_string)
|
||||
unescaped_string = re.sub(r'\n\s*\n', '\n\n', unescaped_string)
|
||||
unescaped_string = unescaped_string.replace("\xa0", " ").replace("\t", " ").strip()
|
||||
return unescaped_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()
|
||||
return int(match['days'] or 0) * 24 * 60 + int(match['hours'] or 0) * 60 + int(match['minutes'] or 0)
|
||||
|
||||
@@ -16,6 +16,12 @@ class ScopeMiddleware:
|
||||
with scopes_disabled():
|
||||
return self.get_response(request)
|
||||
|
||||
if request.path.startswith('/signup/') or request.path.startswith('/invite/'):
|
||||
return self.get_response(request)
|
||||
|
||||
if request.path.startswith('/accounts/'):
|
||||
return self.get_response(request)
|
||||
|
||||
with scopes_disabled():
|
||||
if request.user.userpreference.space is None and not reverse('account_logout') in request.path:
|
||||
return views.no_space(request)
|
||||
|
||||
68
cookbook/helper/scrapers/cooksillustrated.py
Normal file
68
cookbook/helper/scrapers/cooksillustrated.py
Normal file
@@ -0,0 +1,68 @@
|
||||
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]
|
||||
43
cookbook/helper/scrapers/scrapers.py
Normal file
43
cookbook/helper/scrapers/scrapers.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from bs4 import BeautifulSoup
|
||||
from json import JSONDecodeError
|
||||
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,
|
||||
page_data,
|
||||
url=None
|
||||
):
|
||||
self.wild_mode = False
|
||||
# self.exception_handling = None # TODO add new method here, old one was deprecated
|
||||
self.meta_http_equiv = False
|
||||
self.soup = BeautifulSoup(page_data, "html.parser")
|
||||
self.url = url
|
||||
self.recipe = None
|
||||
try:
|
||||
self.schema = SchemaOrg(page_data)
|
||||
except (JSONDecodeError, AttributeError):
|
||||
pass
|
||||
|
||||
return TextScraper(text, url)
|
||||
@@ -40,7 +40,7 @@ class Pepperplate(Integration):
|
||||
recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space)
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction='\n'.join(directions) + '\n\n'
|
||||
instruction='\n'.join(directions) + '\n\n', space=self.request.space,
|
||||
)
|
||||
|
||||
for ingredient in ingredients:
|
||||
@@ -49,7 +49,7 @@ class Pepperplate(Integration):
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
food=f, unit=u, amount=amount, note=note, space=self.request.space,
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class ChefTap(Integration):
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
print("testing", zip_info_object.filename)
|
||||
return re.match(r'^cheftap_export/([A-Za-z\d\w\s-])+.txt$', zip_info_object.filename)
|
||||
return re.match(r'^cheftap_export/([A-Za-z\d\w\s-])+.txt$', zip_info_object.filename) or re.match(r'^([A-Za-z\d\w\s-])+.txt$', zip_info_object.filename)
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
source_url = ''
|
||||
@@ -38,7 +38,7 @@ class ChefTap(Integration):
|
||||
|
||||
recipe = Recipe.objects.create(name=title, created_by=self.request.user, internal=True, space=self.request.space, )
|
||||
|
||||
step = Step.objects.create(instruction='\n'.join(directions))
|
||||
step = Step.objects.create(instruction='\n'.join(directions), space=self.request.space,)
|
||||
|
||||
if source_url != '':
|
||||
step.instruction += '\n' + source_url
|
||||
@@ -50,7 +50,7 @@ class ChefTap(Integration):
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
food=f, unit=u, amount=amount, note=note, space=self.request.space,
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient, Keyword
|
||||
@@ -54,7 +55,7 @@ class Chowdown(Integration):
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction='\n'.join(directions) + '\n\n' + '\n'.join(descriptions)
|
||||
instruction='\n'.join(directions) + '\n\n' + '\n'.join(descriptions), space=self.request.space,
|
||||
)
|
||||
|
||||
for ingredient in ingredients:
|
||||
@@ -62,7 +63,7 @@ class Chowdown(Integration):
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
food=f, unit=u, amount=amount, note=note, space=self.request.space,
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
@@ -71,7 +72,7 @@ class Chowdown(Integration):
|
||||
import_zip = ZipFile(f['file'])
|
||||
for z in import_zip.filelist:
|
||||
if re.match(f'^images/{image}$', z.filename):
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)))
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)), filetype=get_filetype(z.filename))
|
||||
|
||||
return recipe
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import json
|
||||
from io import BytesIO
|
||||
from re import match
|
||||
from zipfile import ZipFile
|
||||
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.serializer import RecipeExportSerializer
|
||||
|
||||
@@ -15,8 +17,9 @@ class Default(Integration):
|
||||
|
||||
recipe_string = recipe_zip.read('recipe.json').decode("utf-8")
|
||||
recipe = self.decode_recipe(recipe_string)
|
||||
if 'image.png' in recipe_zip.namelist():
|
||||
self.import_recipe_image(recipe, BytesIO(recipe_zip.read('image.png')))
|
||||
images = list(filter(lambda v: match('image.*', v), recipe_zip.namelist()))
|
||||
if images:
|
||||
self.import_recipe_image(recipe, BytesIO(recipe_zip.read(images[0])), filetype=get_filetype(images[0]))
|
||||
return recipe
|
||||
|
||||
def decode_recipe(self, string):
|
||||
|
||||
@@ -28,7 +28,7 @@ class Domestica(Integration):
|
||||
recipe.save()
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction=file['directions']
|
||||
instruction=file['directions'], space=self.request.space,
|
||||
)
|
||||
|
||||
if file['source'] != '':
|
||||
@@ -40,12 +40,12 @@ class Domestica(Integration):
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
food=f, unit=u, amount=amount, note=note, space=self.request.space,
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
if file['image'] != '':
|
||||
self.import_recipe_image(recipe, BytesIO(base64.b64decode(file['image'].replace('data:image/jpeg;base64,', ''))))
|
||||
self.import_recipe_image(recipe, BytesIO(base64.b64decode(file['image'].replace('data:image/jpeg;base64,', ''))), filetype='.jpeg')
|
||||
|
||||
return recipe
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from io import BytesIO, StringIO
|
||||
from zipfile import ZipFile, BadZipFile
|
||||
@@ -11,6 +13,7 @@ from django.utils.translation import gettext as _
|
||||
from django_scopes import scope
|
||||
|
||||
from cookbook.forms import ImportExportBase
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.models import Keyword, Recipe
|
||||
|
||||
|
||||
@@ -57,9 +60,8 @@ class Integration:
|
||||
recipe_stream.write(data)
|
||||
recipe_zip_obj.writestr(filename, recipe_stream.getvalue())
|
||||
recipe_stream.close()
|
||||
|
||||
try:
|
||||
recipe_zip_obj.write(r.image.path, 'image.png')
|
||||
recipe_zip_obj.writestr(f'image{get_filetype(r.image.file.name)}', r.image.file.read())
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
@@ -105,33 +107,90 @@ class Integration:
|
||||
try:
|
||||
self.files = files
|
||||
for f in files:
|
||||
if '.zip' in f['name'] or '.paprikarecipes' in f['name']:
|
||||
if 'RecipeKeeper' in f['name']:
|
||||
import_zip = ZipFile(f['file'])
|
||||
file_list = []
|
||||
for z in import_zip.filelist:
|
||||
if self.import_file_name_filter(z):
|
||||
file_list.append(z)
|
||||
il.total_recipes += len(file_list)
|
||||
|
||||
for z in file_list:
|
||||
data_list = self.split_recipe_file(import_zip.read(z.filename).decode('utf-8'))
|
||||
for d in data_list:
|
||||
recipe = self.get_recipe_from_file(d)
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += f'{recipe.pk} - {recipe.name} \n'
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
il.imported_recipes += 1
|
||||
il.save()
|
||||
import_zip.close()
|
||||
elif '.zip' in f['name'] or '.paprikarecipes' in f['name']:
|
||||
import_zip = ZipFile(f['file'])
|
||||
file_list = []
|
||||
for z in import_zip.filelist:
|
||||
if self.import_file_name_filter(z):
|
||||
file_list.append(z)
|
||||
il.total_recipes += len(file_list)
|
||||
|
||||
for z in file_list:
|
||||
try:
|
||||
recipe = self.get_recipe_from_file(BytesIO(import_zip.read(z.filename)))
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += f'{recipe.pk} - {recipe.name} \n'
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
|
||||
il.imported_recipes += 1
|
||||
il.save()
|
||||
except Exception as e:
|
||||
il.msg += f'-------------------- \n ERROR \n{e}\n--------------------\n'
|
||||
import_zip.close()
|
||||
elif '.json' in f['name'] or '.txt' in f['name']:
|
||||
data_list = self.split_recipe_file(f['file'])
|
||||
il.total_recipes += len(data_list)
|
||||
for d in data_list:
|
||||
recipe = self.get_recipe_from_file(d)
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += f'{recipe.pk} - {recipe.name} \n'
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
try:
|
||||
recipe = self.get_recipe_from_file(d)
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += f'{recipe.pk} - {recipe.name} \n'
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
il.imported_recipes += 1
|
||||
il.save()
|
||||
except Exception as e:
|
||||
il.msg += f'-------------------- \n ERROR \n{e}\n--------------------\n'
|
||||
elif '.rtk' in f['name']:
|
||||
import_zip = ZipFile(f['file'])
|
||||
for z in import_zip.filelist:
|
||||
if self.import_file_name_filter(z):
|
||||
data_list = self.split_recipe_file(import_zip.read(z.filename).decode('utf-8'))
|
||||
il.total_recipes += len(data_list)
|
||||
|
||||
for d in data_list:
|
||||
try:
|
||||
recipe = self.get_recipe_from_file(d)
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += f'{recipe.pk} - {recipe.name} \n'
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
il.imported_recipes += 1
|
||||
il.save()
|
||||
except Exception as e:
|
||||
il.msg += f'-------------------- \n ERROR \n{e}\n--------------------\n'
|
||||
import_zip.close()
|
||||
else:
|
||||
recipe = self.get_recipe_from_file(f['file'])
|
||||
recipe.keywords.add(self.keyword)
|
||||
il.msg += f'{recipe.pk} - {recipe.name} \n'
|
||||
self.handle_duplicates(recipe, import_duplicates)
|
||||
except BadZipFile:
|
||||
il.msg += 'ERROR ' + _('Importer expected a .zip file. Did you choose the correct importer type for your data ?') + '\n'
|
||||
il.msg += 'ERROR ' + _(
|
||||
'Importer expected a .zip file. Did you choose the correct importer type for your data ?') + '\n'
|
||||
except:
|
||||
il.msg += 'ERROR ' + _(
|
||||
'An unexpected error occurred during the import. Please make sure you have uploaded a valid file.') + '\n'
|
||||
|
||||
if len(self.ignored_recipes) > 0:
|
||||
il.msg += '\n' + _('The following recipes were ignored because they already existed:') + ' ' + ', '.join(self.ignored_recipes) + '\n\n'
|
||||
il.msg += '\n' + _(
|
||||
'The following recipes were ignored because they already existed:') + ' ' + ', '.join(
|
||||
self.ignored_recipes) + '\n\n'
|
||||
|
||||
il.keyword = self.keyword
|
||||
il.msg += (_('Imported %s recipes.') % Recipe.objects.filter(keywords=self.keyword).count()) + '\n'
|
||||
@@ -149,13 +208,14 @@ class Integration:
|
||||
self.ignored_recipes.append(recipe.name)
|
||||
|
||||
@staticmethod
|
||||
def import_recipe_image(recipe, image_file):
|
||||
def import_recipe_image(recipe, image_file, filetype='.jpeg'):
|
||||
"""
|
||||
Adds an image to a recipe naming it correctly
|
||||
:param recipe: Recipe object
|
||||
:param image_file: ByteIO stream containing the image
|
||||
:param filetype: type of file to write bytes to, default to .jpeg if unknown
|
||||
"""
|
||||
recipe.image = File(image_file, name=f'{uuid.uuid4()}_{recipe.pk}.png')
|
||||
recipe.image = File(image_file, name=f'{uuid.uuid4()}_{recipe.pk}{filetype}')
|
||||
recipe.save()
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
|
||||
@@ -3,6 +3,7 @@ import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
@@ -11,40 +12,55 @@ from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
class Mealie(Integration):
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
return re.match(r'^recipes/([A-Za-z\d-])+.json$', zip_info_object.filename)
|
||||
return re.match(r'^recipes/([A-Za-z\d-])+/([A-Za-z\d-])+.json$', zip_info_object.filename)
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
recipe_json = json.loads(file.getvalue().decode("utf-8"))
|
||||
|
||||
description = '' if len(recipe_json['description'].strip()) > 500 else recipe_json['description'].strip()
|
||||
|
||||
recipe = Recipe.objects.create(
|
||||
name=recipe_json['name'].strip(), description=recipe_json['description'].strip(),
|
||||
name=recipe_json['name'].strip(), description=description,
|
||||
created_by=self.request.user, internal=True, space=self.request.space)
|
||||
|
||||
# TODO parse times (given in PT2H3M )
|
||||
|
||||
ingredients_added = False
|
||||
for s in recipe_json['recipeInstructions']:
|
||||
for s in recipe_json['recipe_instructions']:
|
||||
step = Step.objects.create(
|
||||
instruction=s['text']
|
||||
instruction=s['text'], space=self.request.space,
|
||||
)
|
||||
if not ingredients_added:
|
||||
ingredients_added = True
|
||||
|
||||
for ingredient in recipe_json['recipeIngredient']:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
if len(recipe_json['description'].strip()) > 500:
|
||||
step.instruction = recipe_json['description'].strip() + '\n\n' + step.instruction
|
||||
|
||||
for ingredient in recipe_json['recipe_ingredient']:
|
||||
try:
|
||||
if ingredient['food']:
|
||||
f = get_food(ingredient['food'], self.request.space)
|
||||
u = get_unit(ingredient['unit'], self.request.space)
|
||||
amount = ingredient['quantity']
|
||||
note = ingredient['note']
|
||||
else:
|
||||
amount, unit, ingredient, note = parse(ingredient['note'])
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note, space=self.request.space,
|
||||
))
|
||||
except:
|
||||
pass
|
||||
recipe.steps.add(step)
|
||||
|
||||
for f in self.files:
|
||||
if '.zip' in f['name']:
|
||||
import_zip = ZipFile(f['file'])
|
||||
for z in import_zip.filelist:
|
||||
if re.match(f'^images/{recipe_json["slug"]}.jpg$', z.filename):
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)))
|
||||
try:
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(f'recipes/{recipe_json["slug"]}/images/min-original.webp')), filetype=get_filetype(f'recipes/{recipe_json["slug"]}/images/original'))
|
||||
except:
|
||||
pass
|
||||
|
||||
return recipe
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class MealMaster(Integration):
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction='\n'.join(directions) + '\n\n'
|
||||
instruction='\n'.join(directions) + '\n\n', space=self.request.space,
|
||||
)
|
||||
|
||||
for ingredient in ingredients:
|
||||
@@ -53,7 +53,7 @@ class MealMaster(Integration):
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
food=f, unit=u, amount=amount, note=note, space=self.request.space,
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import re
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
@@ -11,13 +12,15 @@ from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
class NextcloudCookbook(Integration):
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
return re.match(r'^Recipes/([A-Za-z\d\s])+/recipe.json$', zip_info_object.filename)
|
||||
return zip_info_object.filename.endswith('.json')
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
recipe_json = json.loads(file.getvalue().decode("utf-8"))
|
||||
|
||||
description = '' if len(recipe_json['description'].strip()) > 500 else recipe_json['description'].strip()
|
||||
|
||||
recipe = Recipe.objects.create(
|
||||
name=recipe_json['name'].strip(), description=recipe_json['description'].strip(),
|
||||
name=recipe_json['name'].strip(), description=description,
|
||||
created_by=self.request.user, internal=True,
|
||||
servings=recipe_json['recipeYield'], space=self.request.space)
|
||||
|
||||
@@ -27,9 +30,12 @@ class NextcloudCookbook(Integration):
|
||||
ingredients_added = False
|
||||
for s in recipe_json['recipeInstructions']:
|
||||
step = Step.objects.create(
|
||||
instruction=s
|
||||
instruction=s, space=self.request.space,
|
||||
)
|
||||
if not ingredients_added:
|
||||
if len(recipe_json['description'].strip()) > 500:
|
||||
step.instruction = recipe_json['description'].strip() + '\n\n' + step.instruction
|
||||
|
||||
ingredients_added = True
|
||||
|
||||
for ingredient in recipe_json['recipeIngredient']:
|
||||
@@ -37,7 +43,7 @@ class NextcloudCookbook(Integration):
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
food=f, unit=u, amount=amount, note=note, space=self.request.space,
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
@@ -46,7 +52,7 @@ class NextcloudCookbook(Integration):
|
||||
import_zip = ZipFile(f['file'])
|
||||
for z in import_zip.filelist:
|
||||
if re.match(f'^Recipes/{recipe.name}/full.jpg$', z.filename):
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)))
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(z.filename)), filetype=get_filetype(z.filename))
|
||||
|
||||
return recipe
|
||||
|
||||
|
||||
71
cookbook/integration/openeats.py
Normal file
71
cookbook/integration/openeats.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient
|
||||
|
||||
|
||||
class OpenEats(Integration):
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
recipe = Recipe.objects.create(name=file['name'].strip(), created_by=self.request.user, internal=True,
|
||||
servings=file['servings'], space=self.request.space, waiting_time=file['cook_time'], working_time=file['prep_time'])
|
||||
|
||||
instructions = ''
|
||||
if file["info"] != '':
|
||||
instructions += file["info"]
|
||||
|
||||
if file["directions"] != '':
|
||||
instructions += file["directions"]
|
||||
|
||||
if file["source"] != '':
|
||||
instructions += file["source"]
|
||||
|
||||
step = Step.objects.create(instruction=instructions, space=self.request.space,)
|
||||
|
||||
for ingredient in file['ingredients']:
|
||||
f = get_food(ingredient['food'], self.request.space)
|
||||
u = get_unit(ingredient['unit'], self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=ingredient['amount'], space=self.request.space,
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
return recipe
|
||||
|
||||
def split_recipe_file(self, file):
|
||||
recipe_json = json.loads(file.read())
|
||||
recipe_dict = {}
|
||||
ingredient_group_dict = {}
|
||||
|
||||
for o in recipe_json:
|
||||
if o['model'] == 'recipe.recipe':
|
||||
recipe_dict[o['pk']] = {
|
||||
'name': o['fields']['title'],
|
||||
'info': o['fields']['info'],
|
||||
'directions': o['fields']['directions'],
|
||||
'source': o['fields']['source'],
|
||||
'prep_time': o['fields']['prep_time'],
|
||||
'cook_time': o['fields']['cook_time'],
|
||||
'servings': o['fields']['servings'],
|
||||
'ingredients': [],
|
||||
}
|
||||
if o['model'] == 'ingredient.ingredientgroup':
|
||||
ingredient_group_dict[o['pk']] = o['fields']['recipe']
|
||||
|
||||
for o in recipe_json:
|
||||
if o['model'] == 'ingredient.ingredient':
|
||||
ingredient = {
|
||||
'food': o['fields']['title'],
|
||||
'unit': o['fields']['measurement'],
|
||||
'amount': round(o['fields']['numerator'] / o['fields']['denominator'], 2),
|
||||
}
|
||||
recipe_dict[ingredient_group_dict[o['fields']['ingredient_group']]]['ingredients'].append(ingredient)
|
||||
|
||||
return list(recipe_dict.values())
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
@@ -20,11 +20,13 @@ class Paprika(Integration):
|
||||
recipe_json = json.loads(recipe_zip.read().decode("utf-8"))
|
||||
|
||||
recipe = Recipe.objects.create(
|
||||
name=recipe_json['name'].strip(), description=recipe_json['description'].strip(),
|
||||
created_by=self.request.user, internal=True, space=self.request.space)
|
||||
name=recipe_json['name'].strip(), created_by=self.request.user, internal=True, space=self.request.space)
|
||||
|
||||
if 'description' in recipe_json:
|
||||
recipe.description = '' if len(recipe_json['description'].strip()) > 500 else recipe_json['description'].strip()
|
||||
|
||||
try:
|
||||
if re.match(r'([0-9])+\s(.)*', recipe_json['servings'] ):
|
||||
if re.match(r'([0-9])+\s(.)*', recipe_json['servings']):
|
||||
s = recipe_json['servings'].split(' ')
|
||||
recipe.servings = s[0]
|
||||
recipe.servings_text = s[1]
|
||||
@@ -40,34 +42,45 @@ class Paprika(Integration):
|
||||
recipe.save()
|
||||
|
||||
instructions = recipe_json['directions']
|
||||
if len(recipe_json['notes'].strip()) > 0:
|
||||
if recipe_json['notes'] and len(recipe_json['notes'].strip()) > 0:
|
||||
instructions += '\n\n### ' + _('Notes') + ' \n' + recipe_json['notes']
|
||||
|
||||
if len(recipe_json['nutritional_info'].strip()) > 0:
|
||||
if recipe_json['nutritional_info'] and len(recipe_json['nutritional_info'].strip()) > 0:
|
||||
instructions += '\n\n### ' + _('Nutritional Information') + ' \n' + recipe_json['nutritional_info']
|
||||
|
||||
if len(recipe_json['source'].strip()) > 0 or len(recipe_json['source_url'].strip()) > 0:
|
||||
instructions += '\n\n### ' + _('Source') + ' \n' + recipe_json['source'].strip() + ' \n' + recipe_json['source_url'].strip()
|
||||
try:
|
||||
if len(recipe_json['source'].strip()) > 0 or len(recipe_json['source_url'].strip()) > 0:
|
||||
instructions += '\n\n### ' + _('Source') + ' \n' + recipe_json['source'].strip() + ' \n' + recipe_json['source_url'].strip()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction=instructions
|
||||
instruction=instructions, space=self.request.space,
|
||||
)
|
||||
|
||||
if len(recipe_json['description'].strip()) > 500:
|
||||
step.instruction = recipe_json['description'].strip() + '\n\n' + step.instruction
|
||||
|
||||
if 'categories' in recipe_json:
|
||||
for c in recipe_json['categories']:
|
||||
keyword, created = Keyword.objects.get_or_create(name=c.strip(), space=self.request.space)
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
for ingredient in recipe_json['ingredients'].split('\n'):
|
||||
if len(ingredient.strip()) > 0:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
))
|
||||
try:
|
||||
for ingredient in recipe_json['ingredients'].split('\n'):
|
||||
if len(ingredient.strip()) > 0:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note, space=self.request.space,
|
||||
))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
recipe.steps.add(step)
|
||||
|
||||
self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_json['photo_data'])))
|
||||
if recipe_json.get("photo_data", None):
|
||||
self.import_recipe_image(recipe, BytesIO(base64.b64decode(recipe_json['photo_data'])), filetype='.jpeg')
|
||||
|
||||
return recipe
|
||||
|
||||
136
cookbook/integration/recettetek.py
Normal file
136
cookbook/integration/recettetek.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import re
|
||||
import json
|
||||
import base64
|
||||
import requests
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
import imghdr
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.image_processing import get_filetype
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient, Keyword
|
||||
|
||||
|
||||
class RecetteTek(Integration):
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
print("testing", zip_info_object.filename)
|
||||
return re.match(r'^recipes_0.json$', zip_info_object.filename) or re.match(r'^recipes.json$', zip_info_object.filename)
|
||||
|
||||
def split_recipe_file(self, file):
|
||||
|
||||
recipe_json = json.loads(file)
|
||||
|
||||
recipe_list = [r for r in recipe_json]
|
||||
|
||||
return recipe_list
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
|
||||
# Create initial recipe with just a title and a decription
|
||||
recipe = Recipe.objects.create(name=file['title'], created_by=self.request.user, internal=True, space=self.request.space, )
|
||||
|
||||
# set the description as an empty string for later use for the source URL, incase there is no description text.
|
||||
recipe.description = ''
|
||||
|
||||
try:
|
||||
if file['description'] != '':
|
||||
recipe.description = file['description'].strip()
|
||||
except Exception as e:
|
||||
print(recipe.name, ': failed to parse recipe description ', str(e))
|
||||
|
||||
instructions = file['instructions']
|
||||
if not instructions:
|
||||
instructions = ''
|
||||
|
||||
step = Step.objects.create(instruction=instructions, space=self.request.space,)
|
||||
|
||||
# Append the original import url to the step (if it exists)
|
||||
try:
|
||||
if file['url'] != '':
|
||||
step.instruction += '\n\nImported from: ' + file['url']
|
||||
step.save()
|
||||
except Exception as e:
|
||||
print(recipe.name, ': failed to import source url ', str(e))
|
||||
|
||||
try:
|
||||
# Process the ingredients. Assumes 1 ingredient per line.
|
||||
for ingredient in file['ingredients'].split('\n'):
|
||||
if len(ingredient.strip()) > 0:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note, space=self.request.space,
|
||||
))
|
||||
except Exception as e:
|
||||
print(recipe.name, ': failed to parse recipe ingredients ', str(e))
|
||||
recipe.steps.add(step)
|
||||
|
||||
# Attempt to import prep/cooking times
|
||||
# quick hack, this assumes only one number in the quantity field.
|
||||
try:
|
||||
if file['quantity'] != '':
|
||||
for item in file['quantity'].split(' '):
|
||||
if item.isdigit():
|
||||
recipe.servings = int(item)
|
||||
break
|
||||
except Exception as e:
|
||||
print(recipe.name, ': failed to parse quantity ', str(e))
|
||||
|
||||
try:
|
||||
if file['totalTime'] != '':
|
||||
recipe.waiting_time = int(file['totalTime'])
|
||||
except Exception as e:
|
||||
print(recipe.name, ': failed to parse total times ', str(e))
|
||||
|
||||
try:
|
||||
if file['preparationTime'] != '':
|
||||
recipe.working_time = int(file['preparationTime'])
|
||||
except Exception as e:
|
||||
print(recipe.name, ': failed to parse prep time ', str(e))
|
||||
|
||||
try:
|
||||
if file['cookingTime'] != '':
|
||||
recipe.waiting_time = int(file['cookingTime'])
|
||||
except Exception as e:
|
||||
print(recipe.name, ': failed to parse cooking time ', str(e))
|
||||
|
||||
recipe.save()
|
||||
|
||||
# Import the recipe keywords
|
||||
try:
|
||||
if file['keywords'] != '':
|
||||
for keyword in file['keywords'].split(';'):
|
||||
k, created = Keyword.objects.get_or_create(name=keyword.strip(), space=self.request.space)
|
||||
recipe.keywords.add(k)
|
||||
recipe.save()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
# TODO: Parse Nutritional Information
|
||||
|
||||
# Import the original image from the zip file, if we cannot do that, attempt to download it again.
|
||||
try:
|
||||
if file['pictures'][0] != '':
|
||||
image_file_name = file['pictures'][0].split('/')[-1]
|
||||
for f in self.files:
|
||||
if '.rtk' in f['name']:
|
||||
import_zip = ZipFile(f['file'])
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(image_file_name)), filetype=get_filetype(image_file_name))
|
||||
else:
|
||||
if file['originalPicture'] != '':
|
||||
response = requests.get(file['originalPicture'])
|
||||
if imghdr.what(BytesIO(response.content)) != None:
|
||||
self.import_recipe_image(recipe, BytesIO(response.content), filetype=get_filetype(file['originalPicture']))
|
||||
else:
|
||||
raise Exception("Original image failed to download.")
|
||||
except Exception as e:
|
||||
print(recipe.name, ': failed to import image ', str(e))
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
80
cookbook/integration/recipekeeper.py
Normal file
80
cookbook/integration/recipekeeper.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import re
|
||||
from bs4 import BeautifulSoup
|
||||
from io import BytesIO
|
||||
from zipfile import ZipFile
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cookbook.helper.ingredient_parser import parse, get_food, get_unit
|
||||
from cookbook.helper.recipe_url_import import parse_servings, iso_duration_to_minutes
|
||||
from cookbook.integration.integration import Integration
|
||||
from cookbook.models import Recipe, Step, Food, Unit, Ingredient, Keyword
|
||||
|
||||
|
||||
class RecipeKeeper(Integration):
|
||||
|
||||
def import_file_name_filter(self, zip_info_object):
|
||||
return re.match(r'^recipes.html$', zip_info_object.filename)
|
||||
|
||||
def split_recipe_file(self, file):
|
||||
recipe_html = BeautifulSoup(file, 'html.parser')
|
||||
return recipe_html.find_all('div', class_='recipe-details')
|
||||
|
||||
def get_recipe_from_file(self, file):
|
||||
# 'file' comes is as a beautifulsoup object
|
||||
recipe = Recipe.objects.create(name=file.find("h2", {"itemprop": "name"}).text.strip(), created_by=self.request.user, internal=True, space=self.request.space, )
|
||||
|
||||
# add 'Courses' and 'Categories' as keywords
|
||||
for course in file.find_all("span", {"itemprop": "recipeCourse"}):
|
||||
keyword, created = Keyword.objects.get_or_create(name=course.text, space=self.request.space)
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
for category in file.find_all("meta", {"itemprop": "recipeCategory"}):
|
||||
keyword, created = Keyword.objects.get_or_create(name=category.get("content"), space=self.request.space)
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
try:
|
||||
recipe.servings = parse_servings(file.find("span", {"itemprop": "recipeYield"}).text.strip())
|
||||
recipe.working_time = iso_duration_to_minutes(file.find("span", {"meta": "prepTime"}).text.strip())
|
||||
recipe.waiting_time = iso_duration_to_minutes(file.find("span", {"meta": "cookTime"}).text.strip())
|
||||
recipe.save()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
step = Step.objects.create(instruction='', space=self.request.space,)
|
||||
|
||||
for ingredient in file.find("div", {"itemprop": "recipeIngredients"}).findChildren("p"):
|
||||
if ingredient.text == "":
|
||||
continue
|
||||
amount, unit, ingredient, note = parse(ingredient.text.strip())
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note, space=self.request.space,
|
||||
))
|
||||
|
||||
for s in file.find("div", {"itemprop": "recipeDirections"}).find_all("p"):
|
||||
if s.text == "":
|
||||
continue
|
||||
step.instruction += s.text + ' \n'
|
||||
|
||||
if file.find("span", {"itemprop": "recipeSource"}).text != '':
|
||||
step.instruction += "\n\nImported from: " + file.find("span", {"itemprop": "recipeSource"}).text
|
||||
step.save()
|
||||
source_url_added = True
|
||||
|
||||
recipe.steps.add(step)
|
||||
|
||||
# import the Primary recipe image that is stored in the Zip
|
||||
try:
|
||||
for f in self.files:
|
||||
if '.zip' in f['name']:
|
||||
import_zip = ZipFile(f['file'])
|
||||
self.import_recipe_image(recipe, BytesIO(import_zip.read(file.find("img", class_="recipe-photo").get("src"))), filetype='.jpeg')
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return recipe
|
||||
|
||||
def get_file_from_recipe(self, recipe):
|
||||
raise NotImplementedError('Method not implemented in storage integration')
|
||||
@@ -36,7 +36,7 @@ class RecipeSage(Integration):
|
||||
ingredients_added = False
|
||||
for s in file['recipeInstructions']:
|
||||
step = Step.objects.create(
|
||||
instruction=s['text']
|
||||
instruction=s['text'], space=self.request.space,
|
||||
)
|
||||
if not ingredients_added:
|
||||
ingredients_added = True
|
||||
@@ -46,7 +46,7 @@ class RecipeSage(Integration):
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
food=f, unit=u, amount=amount, note=note, space=self.request.space,
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ class RezKonv(Integration):
|
||||
recipe.keywords.add(keyword)
|
||||
|
||||
step = Step.objects.create(
|
||||
instruction='\n'.join(directions) + '\n\n'
|
||||
instruction='\n'.join(directions) + '\n\n', space=self.request.space,
|
||||
)
|
||||
|
||||
for ingredient in ingredients:
|
||||
@@ -52,7 +52,7 @@ class RezKonv(Integration):
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
food=f, unit=u, amount=amount, note=note, space=self.request.space,
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
|
||||
@@ -43,14 +43,14 @@ class Safron(Integration):
|
||||
|
||||
recipe = Recipe.objects.create(name=title, description=description, created_by=self.request.user, internal=True, space=self.request.space, )
|
||||
|
||||
step = Step.objects.create(instruction='\n'.join(directions))
|
||||
step = Step.objects.create(instruction='\n'.join(directions), space=self.request.space,)
|
||||
|
||||
for ingredient in ingredients:
|
||||
amount, unit, ingredient, note = parse(ingredient)
|
||||
f = get_food(ingredient, self.request.space)
|
||||
u = get_unit(unit, self.request.space)
|
||||
step.ingredients.add(Ingredient.objects.create(
|
||||
food=f, unit=u, amount=amount, note=note
|
||||
food=f, unit=u, amount=amount, note=note, space=self.request.space,
|
||||
))
|
||||
recipe.steps.add(step)
|
||||
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
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
@@ -6,20 +6,21 @@
|
||||
# Translators:
|
||||
# H K <hkocharyan@ctemplar.com>, 2021
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2021-02-09 18:01+0100\n"
|
||||
"PO-Revision-Date: 2020-06-02 19:28+0000\n"
|
||||
"Last-Translator: H K <hkocharyan@ctemplar.com>, 2021\n"
|
||||
"Language-Team: Armenian (https://www.transifex.com/django-recipes/teams/110507/hy/)\n"
|
||||
"PO-Revision-Date: 2021-04-12 20:22+0000\n"
|
||||
"Last-Translator: Hrachya Kocharyan <hkocharyan@ctemplar.com>\n"
|
||||
"Language-Team: Armenian <http://translate.tandoor.dev/projects/tandoor/"
|
||||
"recipes-backend/hy/>\n"
|
||||
"Language: hy\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: hy\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: Weblate 4.5.3\n"
|
||||
|
||||
#: .\cookbook\filters.py:22 .\cookbook\templates\base.html:87
|
||||
#: .\cookbook\templates\forms\edit_internal_recipe.html:219
|
||||
@@ -54,7 +55,7 @@ msgid ""
|
||||
"shared by default."
|
||||
msgstr ""
|
||||
"Օգտատերեր, ում հետ նոր ստեղծված ճաշացուցակները/գնումների ցուցակները պետք է "
|
||||
"կիսվեն լռելյայն"
|
||||
"կիսվեն լռելյայն:"
|
||||
|
||||
#: .\cookbook\forms.py:48
|
||||
msgid "Show recently viewed recipes on search page."
|
||||
@@ -62,7 +63,7 @@ msgstr "Ցույց տալ վերջերս դիտած բաղադրատոմսերը
|
||||
|
||||
#: .\cookbook\forms.py:49
|
||||
msgid "Number of decimals to round ingredients."
|
||||
msgstr "Բաղադրիչների կլորացման համար տասնորդականների քանակը"
|
||||
msgstr "Բաղադրիչների կլորացման համար տասնորդականների քանակը:"
|
||||
|
||||
#: .\cookbook\forms.py:50
|
||||
msgid "If you want to be able to create and see comments underneath recipes."
|
||||
@@ -84,7 +85,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\forms.py:55
|
||||
msgid "Makes the navbar stick to the top of the page."
|
||||
msgstr "Կցել նավիգացիոն տողը էջի վերևում"
|
||||
msgstr "Կցել նավիգացիոն տողը էջի վերևում:"
|
||||
|
||||
#: .\cookbook\forms.py:71
|
||||
msgid ""
|
||||
@@ -152,7 +153,7 @@ msgstr "Հին միավոր"
|
||||
|
||||
#: .\cookbook\forms.py:169
|
||||
msgid "Unit that should be replaced."
|
||||
msgstr "Փոխարինման ենթակա միավոր"
|
||||
msgstr "Փոխարինման ենթակա միավոր:"
|
||||
|
||||
#: .\cookbook\forms.py:179
|
||||
msgid "New Food"
|
||||
@@ -172,7 +173,7 @@ msgstr "Փոխարինման ենթակա սննդամթերք։"
|
||||
|
||||
#: .\cookbook\forms.py:198
|
||||
msgid "Add your comment: "
|
||||
msgstr "Ավելացրեք ձեր մեկնաբանությունը՝"
|
||||
msgstr "Ավելացրեք ձեր մեկնաբանությունը՝ "
|
||||
|
||||
#: .\cookbook\forms.py:229
|
||||
msgid "Leave empty for dropbox and enter app password for nextcloud."
|
||||
@@ -190,8 +191,8 @@ msgid ""
|
||||
"Leave empty for dropbox and enter only base url for nextcloud "
|
||||
"(<code>/remote.php/webdav/</code> is added automatically)"
|
||||
msgstr ""
|
||||
"Թողնել դատարկ dropbox-ի համար և մուտքագրել միայն հիմքային հղումը nextcloud-ի"
|
||||
" համար (<code>/remote.php/webdav/</code> ինքնաբերաբար ավելացվում է)"
|
||||
"Թողնել դատարկ dropbox-ի համար և մուտքագրել միայն հիմքային հղումը nextcloud-ի "
|
||||
"համար (<code>/remote.php/webdav/</code> ինքնաբերաբար ավելացվում է)"
|
||||
|
||||
#: .\cookbook\forms.py:263
|
||||
msgid "Search String"
|
||||
@@ -203,13 +204,13 @@ msgstr "Ֆայլի ID"
|
||||
|
||||
#: .\cookbook\forms.py:299
|
||||
msgid "You must provide at least a recipe or a title."
|
||||
msgstr "Դուք պետք է տրամադրեք առնվազն բաղադրատոմս կամ վերնագիր"
|
||||
msgstr "Դուք պետք է տրամադրեք առնվազն բաղադրատոմս կամ վերնագիր:"
|
||||
|
||||
#: .\cookbook\forms.py:312
|
||||
msgid "You can list default users to share recipes with in the settings."
|
||||
msgstr ""
|
||||
"Դուք կարող եք կարգավորումներում ավելացնել այն օգտատերերին, ում հետ "
|
||||
"բաղադրատոմսերը պետք է կիսվեն լռելյայն"
|
||||
"բաղադրատոմսերը պետք է կիսվեն լռելյայն:"
|
||||
|
||||
#: .\cookbook\forms.py:313
|
||||
#: .\cookbook\templates\forms\edit_internal_recipe.html:377
|
||||
@@ -289,7 +290,7 @@ msgstr "Պատրաստման տևողություն"
|
||||
#: .\cookbook\templates\forms\ingredients.html:7
|
||||
#: .\cookbook\templates\index.html:7
|
||||
msgid "Cookbook"
|
||||
msgstr "Խոհարարական գիրք "
|
||||
msgstr "Խոհարարական գիրք"
|
||||
|
||||
#: .\cookbook\integration\safron.py:31
|
||||
msgid "Section"
|
||||
@@ -409,7 +410,7 @@ msgstr "Դուրս գալ"
|
||||
|
||||
#: .\cookbook\templates\account\logout.html:11
|
||||
msgid "Are you sure you want to sign out?"
|
||||
msgstr "Համոզվա՞ծ եք, որ ցանկանում եք դուրս գալ"
|
||||
msgstr "Համոզվա՞ծ եք, որ ցանկանում եք դուրս գալ:"
|
||||
|
||||
#: .\cookbook\templates\account\password_reset.html:5
|
||||
#: .\cookbook\templates\account\password_reset_done.html:5
|
||||
@@ -419,7 +420,7 @@ msgstr "Գաղտնաբառի վերականգնում"
|
||||
#: .\cookbook\templates\account\password_reset.html:9
|
||||
#: .\cookbook\templates\account\password_reset_done.html:9
|
||||
msgid "Password reset is not implemented for the time being!"
|
||||
msgstr "Գաղտնաբառի վերականգնում առայժմ իրականացված չէ"
|
||||
msgstr "Գաղտնաբառի վերականգնում առայժմ իրականացված չէ:"
|
||||
|
||||
#: .\cookbook\templates\account\signup.html:5
|
||||
msgid "Register"
|
||||
@@ -478,7 +479,7 @@ msgstr "Բացահայտումների մատյան"
|
||||
|
||||
#: .\cookbook\templates\base.html:117 .\cookbook\templates\stats.html:10
|
||||
msgid "Statistics"
|
||||
msgstr "Վիճակագրություն "
|
||||
msgstr "Վիճակագրություն"
|
||||
|
||||
#: .\cookbook\templates\base.html:119
|
||||
msgid "Units & Ingredients"
|
||||
@@ -910,7 +911,7 @@ msgstr "Գրանցել բաղադրատոմսի օգտագործում"
|
||||
|
||||
#: .\cookbook\templates\include\log_cooking.html:13
|
||||
msgid "All fields are optional and can be left empty."
|
||||
msgstr "Բոլոր դաշտերը կամավոր են և կարող են դատարկ թողնվել"
|
||||
msgstr "Բոլոր դաշտերը կամավոր են և կարող են դատարկ թողնվել:"
|
||||
|
||||
#: .\cookbook\templates\include\log_cooking.html:19
|
||||
msgid "Rating"
|
||||
@@ -1201,7 +1202,7 @@ msgid ""
|
||||
" view."
|
||||
msgstr ""
|
||||
"Շաբաթվա առաջին օրվանից հաշված օրերի քանակը, որը պետք է փոխհատուցել լռելյայն "
|
||||
"էջում"
|
||||
"էջում:"
|
||||
|
||||
#: .\cookbook\templates\meal_plan.html:217
|
||||
#: .\cookbook\templates\meal_plan.html:294
|
||||
@@ -1287,7 +1288,7 @@ msgstr "Ճաշացուցակի Դիտման էջ"
|
||||
|
||||
#: .\cookbook\templates\meal_plan_entry.html:50
|
||||
msgid "Never cooked before."
|
||||
msgstr "Երբեք պատրաստված չէ"
|
||||
msgstr "Երբեք պատրաստված չէ:"
|
||||
|
||||
#: .\cookbook\templates\meal_plan_entry.html:76
|
||||
msgid "Other meals on this day"
|
||||
@@ -1412,7 +1413,7 @@ msgstr "Կարգավորում"
|
||||
msgid ""
|
||||
"To start using this application you must first create a superuser account."
|
||||
msgstr ""
|
||||
"Այս ծրագիրն օգտագործելու համար նախ պետք է ստեղծեք սուպեր-օգտատերի հաշիվ"
|
||||
"Այս ծրագիրն օգտագործելու համար նախ պետք է ստեղծեք սուպեր-օգտատերի հաշիվ:"
|
||||
|
||||
#: .\cookbook\templates\setup.html:20
|
||||
msgid "Create Superuser account"
|
||||
@@ -1468,7 +1469,7 @@ msgstr "Ցուցակի նախածանց"
|
||||
|
||||
#: .\cookbook\templates\shopping_list.html:696
|
||||
msgid "There was an error creating a resource!"
|
||||
msgstr "Ռեսուրսը ստեղծելիս սխալ է գրանցվել"
|
||||
msgstr "Ռեսուրսը ստեղծելիս սխալ է գրանցվել:"
|
||||
|
||||
#: .\cookbook\templates\socialaccount\connections.html:4
|
||||
#: .\cookbook\templates\socialaccount\connections.html:7
|
||||
@@ -1490,7 +1491,7 @@ msgstr "Հեռացնել"
|
||||
#: .\cookbook\templates\socialaccount\connections.html:44
|
||||
msgid ""
|
||||
"You currently have no social network accounts connected to this account."
|
||||
msgstr "Դուք այս հաշվին կապված սոցիալական հաշիվներ չունեք "
|
||||
msgstr "Դուք այս հաշվին կապված սոցիալական հաշիվներ չունեք:"
|
||||
|
||||
#: .\cookbook\templates\socialaccount\connections.html:47
|
||||
msgid "Add a 3rd Party Account"
|
||||
@@ -1534,7 +1535,7 @@ msgstr "Ցուցադրել հղումները"
|
||||
|
||||
#: .\cookbook\templates\system.html:27
|
||||
msgid "Backup & Restore"
|
||||
msgstr "Կրկնօրինակում և վերականգնում "
|
||||
msgstr "Կրկնօրինակում և վերականգնում"
|
||||
|
||||
#: .\cookbook\templates\system.html:28
|
||||
msgid "Download Backup"
|
||||
@@ -1673,7 +1674,7 @@ msgstr "Բոլոր բանալի բառերը"
|
||||
|
||||
#: .\cookbook\templates\url_import.html:206
|
||||
msgid "Import all keywords, not only the ones already existing."
|
||||
msgstr "Ներմուծել բոլոր բանալի բառերը, ոչ միայն արդեն գոյություն ունեցողները"
|
||||
msgstr "Ներմուծել բոլոր բանալի բառերը, ոչ միայն արդեն գոյություն ունեցողները:"
|
||||
|
||||
#: .\cookbook\templates\url_import.html:233
|
||||
msgid "Information"
|
||||
@@ -1719,7 +1720,7 @@ msgstr "Այս հատկությունը հասանելի չէ փորձնական
|
||||
|
||||
#: .\cookbook\views\api.py:439
|
||||
msgid "Sync successful!"
|
||||
msgstr "Սինքրոնիզացիան հաջողված է"
|
||||
msgstr "Սինքրոնիզացիան հաջողված է:"
|
||||
|
||||
#: .\cookbook\views\api.py:444
|
||||
msgid "Error synchronizing with Storage"
|
||||
@@ -1727,20 +1728,20 @@ msgstr "Պահոցի հետ սինքրոնիզացիայի սխալ"
|
||||
|
||||
#: .\cookbook\views\api.py:510
|
||||
msgid "The requested page could not be found."
|
||||
msgstr "Պահանջվող էջը չի գտնվել"
|
||||
msgstr "Պահանջվող էջը չի գտնվել:"
|
||||
|
||||
#: .\cookbook\views\api.py:519
|
||||
msgid ""
|
||||
"The requested page refused to provide any information (Status Code 403)."
|
||||
msgstr ""
|
||||
"Պահանջվող էջը մերժեց տրամադրել որևէ տեղեկություն (Կարգավիճակի ծածկագիր 403)"
|
||||
"Պահանջվող էջը մերժեց տրամադրել որևէ տեղեկություն (Կարգավիճակի ծածկագիր 403):"
|
||||
|
||||
#: .\cookbook\views\data.py:101
|
||||
#, python-format
|
||||
msgid "Batch edit done. %(count)d recipe was updated."
|
||||
msgid_plural "Batch edit done. %(count)d Recipes where updated."
|
||||
msgstr[0] "Խմբային խմբագրումն ավարտված է։ %(count)d բաղադրատոմս թարմացված է"
|
||||
msgstr[1] "Խմբային խմբագրումն ավարտված է։ %(count)d բաղադրատոմս թարմացված է։"
|
||||
msgstr[0] "Խմբային խմբագրումն ավարտված է։ %(count)d բաղադրատոմս թարմացված է:"
|
||||
msgstr[1] "Խմբային խմբագրումն ավարտված է։ %(count)d բաղադրատոմսեր թարմացված են։"
|
||||
|
||||
#: .\cookbook\views\delete.py:72
|
||||
msgid "Monitor"
|
||||
@@ -1779,7 +1780,7 @@ msgstr "Դուք կարող եք խմբագրել այս պահոցը։"
|
||||
|
||||
#: .\cookbook\views\edit.py:131
|
||||
msgid "Storage saved!"
|
||||
msgstr "Պահոցը պահպանված է"
|
||||
msgstr "Պահոցը պահպանված է։"
|
||||
|
||||
#: .\cookbook\views\edit.py:137
|
||||
msgid "There was an error updating this storage backend!"
|
||||
@@ -1791,23 +1792,23 @@ msgstr "Պահոց"
|
||||
|
||||
#: .\cookbook\views\edit.py:245
|
||||
msgid "Changes saved!"
|
||||
msgstr "Փոփոխությունները պահպանված են"
|
||||
msgstr "Փոփոխությունները պահպանված են:"
|
||||
|
||||
#: .\cookbook\views\edit.py:253
|
||||
msgid "Error saving changes!"
|
||||
msgstr "Փոփոխությունների պահպանման սխալ"
|
||||
msgstr "Փոփոխությունների պահպանման սխալ:"
|
||||
|
||||
#: .\cookbook\views\edit.py:289
|
||||
msgid "Units merged!"
|
||||
msgstr "Միավորները միավորված են"
|
||||
msgstr "Միավորները միավորված են:"
|
||||
|
||||
#: .\cookbook\views\edit.py:295 .\cookbook\views\edit.py:317
|
||||
msgid "Cannot merge with the same object!"
|
||||
msgstr "Հնարավոր չէ միավորել նույն օբյեկտի հետ"
|
||||
msgstr "Հնարավոր չէ միավորել նույն օբյեկտի հետ:"
|
||||
|
||||
#: .\cookbook\views\edit.py:311
|
||||
msgid "Foods merged!"
|
||||
msgstr "Սննդամթերքները միավորված են"
|
||||
msgstr "Սննդամթերքները միավորված են:"
|
||||
|
||||
#: .\cookbook\views\import_export.py:42
|
||||
msgid "Importing is not implemented for this provider"
|
||||
@@ -1831,7 +1832,7 @@ msgstr "Գնումների ցուցակներ"
|
||||
|
||||
#: .\cookbook\views\new.py:107
|
||||
msgid "Imported new recipe!"
|
||||
msgstr "Բաղադրատոմսը ներմուծված է"
|
||||
msgstr "Բաղադրատոմսը ներմուծված է:"
|
||||
|
||||
#: .\cookbook\views\new.py:114
|
||||
msgid "There was an error importing this recipe!"
|
||||
@@ -1843,15 +1844,15 @@ msgstr "Դուք չունեք բավարար թույլտվություն այս
|
||||
|
||||
#: .\cookbook\views\views.py:136
|
||||
msgid "Comment saved!"
|
||||
msgstr "Մեկնաբանությունը պահպանված է"
|
||||
msgstr "Մեկնաբանությունը պահպանված է:"
|
||||
|
||||
#: .\cookbook\views\views.py:152
|
||||
msgid "This recipe is already linked to the book!"
|
||||
msgstr "Բաղադրատոմսն արդեն կապված է գրքին"
|
||||
msgstr "Բաղադրատոմսն արդեն կապված է գրքին:"
|
||||
|
||||
#: .\cookbook\views\views.py:158
|
||||
msgid "Bookmark saved!"
|
||||
msgstr "Էջանիշը պահպանված է"
|
||||
msgstr "Էջանիշը պահպանված է:"
|
||||
|
||||
#: .\cookbook\views\views.py:380
|
||||
msgid ""
|
||||
@@ -1865,7 +1866,7 @@ msgstr ""
|
||||
|
||||
#: .\cookbook\views\views.py:388 .\cookbook\views\views.py:435
|
||||
msgid "Passwords dont match!"
|
||||
msgstr "Գաղտնաբառերը չեն համընկնում"
|
||||
msgstr "Գաղտնաբառերը չեն համընկնում:"
|
||||
|
||||
#: .\cookbook\views\views.py:402 .\cookbook\views\views.py:449
|
||||
msgid "User has been created, please login!"
|
||||
@@ -1877,4 +1878,4 @@ msgstr "Հրավերի արատավոր հղում է տրամադրվել։"
|
||||
|
||||
#: .\cookbook\views\views.py:470
|
||||
msgid "Invite Link not valid or already used!"
|
||||
msgstr "Հրավերի հղումը վավեր չէ, կամ արդեն օգտագործվել է"
|
||||
msgstr "Հրավերի հղումը վավեր չէ, կամ արդեն օգտագործվել է:"
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/nb_NO/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/nb_NO/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1832
cookbook/locale/nb_NO/LC_MESSAGES/django.po
Normal file
1832
cookbook/locale/nb_NO/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/pl/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/pl/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1797
cookbook/locale/pl/LC_MESSAGES/django.po
Normal file
1797
cookbook/locale/pl/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/sv/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/sv/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1853
cookbook/locale/sv/LC_MESSAGES/django.po
Normal file
1853
cookbook/locale/sv/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/vi/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/vi/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1787
cookbook/locale/vi/LC_MESSAGES/django.po
Normal file
1787
cookbook/locale/vi/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
BIN
cookbook/locale/zh_Hant/LC_MESSAGES/django.mo
Normal file
BIN
cookbook/locale/zh_Hant/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
2303
cookbook/locale/zh_Hant/LC_MESSAGES/django.po
Normal file
2303
cookbook/locale/zh_Hant/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
15
cookbook/migrations/0119_auto_20210411_2101.py
Normal file
15
cookbook/migrations/0119_auto_20210411_2101.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# Generated by Django 3.2 on 2021-04-11 19:01
|
||||
from django.contrib.postgres.operations import UnaccentExtension, TrigramExtension
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0118_auto_20210406_1805'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
TrigramExtension(),
|
||||
UnaccentExtension(),
|
||||
]
|
||||
34
cookbook/migrations/0120_bookmarklet.py
Normal file
34
cookbook/migrations/0120_bookmarklet.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 3.1.7 on 2021-03-29 11:05
|
||||
|
||||
import cookbook.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0119_auto_20210411_2101'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userpreference',
|
||||
name='use_fractions',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BookmarkletImport',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('html', models.TextField()),
|
||||
('url', models.CharField(blank=True, max_length=256, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('space', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space')),
|
||||
],
|
||||
bases=(models.Model, cookbook.models.PermissionModelMixin),
|
||||
),
|
||||
]
|
||||
23
cookbook/migrations/0121_auto_20210518_1638.py
Normal file
23
cookbook/migrations/0121_auto_20210518_1638.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.3 on 2021-05-18 14:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0120_bookmarklet'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userpreference',
|
||||
name='search_style',
|
||||
field=models.CharField(choices=[('SMALL', 'Small'), ('LARGE', 'Large'), ('NEW', 'New')], default='LARGE', max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='userpreference',
|
||||
name='use_fractions',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
23
cookbook/migrations/0122_auto_20210527_1712.py
Normal file
23
cookbook/migrations/0122_auto_20210527_1712.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.3 on 2021-05-27 15:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0121_auto_20210518_1638'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='allow_files',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='max_users',
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0123_invitelink_email.py
Normal file
18
cookbook/migrations/0123_invitelink_email.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.3 on 2021-05-28 12:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0122_auto_20210527_1712'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='invitelink',
|
||||
name='email',
|
||||
field=models.EmailField(blank=True, max_length=254),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0124_alter_userpreference_theme.py
Normal file
18
cookbook/migrations/0124_alter_userpreference_theme.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.3 on 2021-05-30 15:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0123_invitelink_email'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userpreference',
|
||||
name='theme',
|
||||
field=models.CharField(choices=[('BOOTSTRAP', 'Bootstrap'), ('DARKLY', 'Darkly'), ('FLATLY', 'Flatly'), ('SUPERHERO', 'Superhero'), ('TANDOOR', 'Tandoor')], default='FLATLY', max_length=128),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0125_space_demo.py
Normal file
18
cookbook/migrations/0125_space_demo.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.3 on 2021-06-04 14:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0124_alter_userpreference_theme'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='demo',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0126_alter_userpreference_theme.py
Normal file
18
cookbook/migrations/0126_alter_userpreference_theme.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.3 on 2021-06-05 15:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0125_space_demo'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userpreference',
|
||||
name='theme',
|
||||
field=models.CharField(choices=[('TANDOOR', 'Tandoor'), ('BOOTSTRAP', 'Bootstrap'), ('DARKLY', 'Darkly'), ('FLATLY', 'Flatly'), ('SUPERHERO', 'Superhero')], default='TANDOOR', max_length=128),
|
||||
),
|
||||
]
|
||||
17
cookbook/migrations/0127_remove_invitelink_username.py
Normal file
17
cookbook/migrations/0127_remove_invitelink_username.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.2.3 on 2021-06-07 14:21
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0126_alter_userpreference_theme'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='invitelink',
|
||||
name='username',
|
||||
),
|
||||
]
|
||||
30
cookbook/migrations/0128_userfile.py
Normal file
30
cookbook/migrations/0128_userfile.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 3.2.3 on 2021-06-08 10:23
|
||||
|
||||
import cookbook.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('cookbook', '0127_remove_invitelink_username'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserFile',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=128)),
|
||||
('file', models.FileField(upload_to='files/')),
|
||||
('file_size_kb', models.IntegerField()),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('space', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space')),
|
||||
],
|
||||
bases=(models.Model, cookbook.models.PermissionModelMixin),
|
||||
),
|
||||
]
|
||||
22
cookbook/migrations/0129_auto_20210608_1233.py
Normal file
22
cookbook/migrations/0129_auto_20210608_1233.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 3.2.3 on 2021-06-08 10:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0128_userfile'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='space',
|
||||
name='allow_files',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='max_file_storage_mb',
|
||||
field=models.IntegerField(default=0, help_text='Maximum file storage for space in MB. 0 for unlimited, -1 to disable file upload.'),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0130_alter_userfile_file_size_kb.py
Normal file
18
cookbook/migrations/0130_alter_userfile_file_size_kb.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.3 on 2021-06-08 10:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0129_auto_20210608_1233'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userfile',
|
||||
name='file_size_kb',
|
||||
field=models.IntegerField(blank=True, default=0),
|
||||
),
|
||||
]
|
||||
24
cookbook/migrations/0131_auto_20210608_1929.py
Normal file
24
cookbook/migrations/0131_auto_20210608_1929.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.2.4 on 2021-06-08 17:29
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0130_alter_userfile_file_size_kb'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='step',
|
||||
name='file',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='cookbook.userfile'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='step',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('TEXT', 'Text'), ('TIME', 'Time'), ('FILE', 'File')], default='TEXT', max_length=16),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0132_sharelink_request_count.py
Normal file
18
cookbook/migrations/0132_sharelink_request_count.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.4 on 2021-06-12 18:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0131_auto_20210608_1929'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sharelink',
|
||||
name='request_count',
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0133_sharelink_abuse_blocked.py
Normal file
18
cookbook/migrations/0133_sharelink_abuse_blocked.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.4 on 2021-06-12 18:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0132_sharelink_request_count'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sharelink',
|
||||
name='abuse_blocked',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
18
cookbook/migrations/0134_space_allow_sharing.py
Normal file
18
cookbook/migrations/0134_space_allow_sharing.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.4 on 2021-06-15 19:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0133_sharelink_abuse_blocked'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='allow_sharing',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
29
cookbook/migrations/0135_auto_20210615_2210.py
Normal file
29
cookbook/migrations/0135_auto_20210615_2210.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 3.2.4 on 2021-06-15 20:10
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('cookbook', '0134_space_allow_sharing'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ingredient',
|
||||
name='space',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='nutritioninformation',
|
||||
name='space',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='step',
|
||||
name='space',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
),
|
||||
|
||||
]
|
||||
23
cookbook/migrations/0136_auto_20210617_1343.py
Normal file
23
cookbook/migrations/0136_auto_20210617_1343.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.4 on 2021-06-17 11:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0135_auto_20210615_2210'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='importlog',
|
||||
name='imported_recipes',
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='importlog',
|
||||
name='total_recipes',
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
]
|
||||
34
cookbook/migrations/0137_auto_20210617_1501.py
Normal file
34
cookbook/migrations/0137_auto_20210617_1501.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 3.2.4 on 2021-06-17 13:01
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.models import Subquery, OuterRef
|
||||
from django_scopes import scopes_disabled
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def migrate_spaces(apps, schema_editor):
|
||||
with scopes_disabled():
|
||||
Recipe = apps.get_model('cookbook', 'Recipe')
|
||||
Step = apps.get_model('cookbook', 'Step')
|
||||
Ingredient = apps.get_model('cookbook', 'Ingredient')
|
||||
NutritionInformation = apps.get_model('cookbook', 'NutritionInformation')
|
||||
|
||||
Step.objects.filter(recipe__isnull=True).delete()
|
||||
Ingredient.objects.filter(step__recipe__isnull=True).delete()
|
||||
NutritionInformation.objects.filter(recipe__isnull=True).delete()
|
||||
|
||||
Step.objects.update(space=Subquery(Step.objects.filter(pk=OuterRef('pk')).values('recipe__space')[:1]))
|
||||
Ingredient.objects.update(space=Subquery(Ingredient.objects.filter(pk=OuterRef('pk')).values('step__recipe__space')[:1]))
|
||||
NutritionInformation.objects.update(space=Subquery(NutritionInformation.objects.filter(pk=OuterRef('pk')).values('recipe__space')[:1]))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('cookbook', '0136_auto_20210617_1343'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_spaces),
|
||||
|
||||
]
|
||||
31
cookbook/migrations/0138_auto_20210617_1602.py
Normal file
31
cookbook/migrations/0138_auto_20210617_1602.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 3.2.4 on 2021-06-17 14:02
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.models import Subquery, OuterRef
|
||||
from django_scopes import scopes_disabled
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('cookbook', '0137_auto_20210617_1501'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='ingredient',
|
||||
name='space',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='nutritioninformation',
|
||||
name='space',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='step',
|
||||
name='space',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='cookbook.space'),
|
||||
),
|
||||
]
|
||||
20
cookbook/migrations/0139_space_created_at.py
Normal file
20
cookbook/migrations/0139_space_created_at.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 3.2.4 on 2021-06-22 16:14
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0138_auto_20210617_1602'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='space',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
20
cookbook/migrations/0140_userpreference_created_at.py
Normal file
20
cookbook/migrations/0140_userpreference_created_at.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 3.2.4 on 2021-06-22 16:19
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0139_space_created_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
24
cookbook/migrations/0141_auto_20210713_1042.py
Normal file
24
cookbook/migrations/0141_auto_20210713_1042.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 3.2.5 on 2021-07-13 08:42
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0140_userpreference_created_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='step',
|
||||
name='step_recipe',
|
||||
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.PROTECT, to='cookbook.recipe'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='step',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('TEXT', 'Text'), ('TIME', 'Time'), ('FILE', 'File'), ('RECIPE', 'Recipe')], default='TEXT', max_length=16),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.2.5 on 2021-07-29 14:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('cookbook', '0141_auto_20210713_1042'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userpreference',
|
||||
name='search_style',
|
||||
field=models.CharField(choices=[('SMALL', 'Small'), ('LARGE', 'Large'), ('NEW', 'New')], default='NEW', max_length=64),
|
||||
),
|
||||
]
|
||||
@@ -1,3 +1,5 @@
|
||||
import operator
|
||||
import pathlib
|
||||
import re
|
||||
import uuid
|
||||
from datetime import date, timedelta
|
||||
@@ -5,10 +7,12 @@ from datetime import date, timedelta
|
||||
from annoying.fields import AutoOneToOneField
|
||||
from django.contrib import auth
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.core.files.uploadedfile import UploadedFile, InMemoryUploadedFile
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from django_prometheus.models import ExportModelOperationsMixin
|
||||
from django_scopes import ScopedManager
|
||||
|
||||
from recipes.settings import (COMMENT_PREF_DEFAULT, FRACTION_PREF_DEFAULT,
|
||||
@@ -52,16 +56,23 @@ class PermissionModelMixin:
|
||||
|
||||
def get_space(self):
|
||||
p = '.'.join(self.get_space_key())
|
||||
if getattr(self, p, None):
|
||||
return getattr(self, p)
|
||||
raise NotImplementedError('get space for method not implemented and standard fields not available')
|
||||
try:
|
||||
if space := operator.attrgetter(p)(self):
|
||||
return space
|
||||
except AttributeError:
|
||||
raise NotImplementedError('get space for method not implemented and standard fields not available')
|
||||
|
||||
|
||||
class Space(models.Model):
|
||||
class Space(ExportModelOperationsMixin('space'), models.Model):
|
||||
name = models.CharField(max_length=128, default='Default')
|
||||
created_by = models.ForeignKey(User, on_delete=models.PROTECT, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
message = models.CharField(max_length=512, default='', blank=True)
|
||||
max_recipes = models.IntegerField(default=0)
|
||||
max_file_storage_mb = models.IntegerField(default=0, help_text=_('Maximum file storage for space in MB. 0 for unlimited, -1 to disable file upload.'))
|
||||
max_users = models.IntegerField(default=0)
|
||||
allow_sharing = models.BooleanField(default=True)
|
||||
demo = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -73,12 +84,14 @@ class UserPreference(models.Model, PermissionModelMixin):
|
||||
DARKLY = 'DARKLY'
|
||||
FLATLY = 'FLATLY'
|
||||
SUPERHERO = 'SUPERHERO'
|
||||
TANDOOR = 'TANDOOR'
|
||||
|
||||
THEMES = (
|
||||
(TANDOOR, 'Tandoor'),
|
||||
(BOOTSTRAP, 'Bootstrap'),
|
||||
(DARKLY, 'Darkly'),
|
||||
(FLATLY, 'Flatly'),
|
||||
(SUPERHERO, 'Superhero')
|
||||
(SUPERHERO, 'Superhero'),
|
||||
)
|
||||
|
||||
# Nav colors
|
||||
@@ -115,11 +128,12 @@ class UserPreference(models.Model, PermissionModelMixin):
|
||||
# Search Style
|
||||
SMALL = 'SMALL'
|
||||
LARGE = 'LARGE'
|
||||
NEW = 'NEW'
|
||||
|
||||
SEARCH_STYLE = ((SMALL, _('Small')), (LARGE, _('Large')),)
|
||||
SEARCH_STYLE = ((SMALL, _('Small')), (LARGE, _('Large')), (NEW, _('New')))
|
||||
|
||||
user = AutoOneToOneField(User, on_delete=models.CASCADE, primary_key=True)
|
||||
theme = models.CharField(choices=THEMES, max_length=128, default=FLATLY)
|
||||
theme = models.CharField(choices=THEMES, max_length=128, default=TANDOOR)
|
||||
nav_color = models.CharField(
|
||||
choices=COLORS, max_length=128, default=PRIMARY
|
||||
)
|
||||
@@ -129,7 +143,7 @@ class UserPreference(models.Model, PermissionModelMixin):
|
||||
choices=PAGES, max_length=64, default=SEARCH
|
||||
)
|
||||
search_style = models.CharField(
|
||||
choices=SEARCH_STYLE, max_length=64, default=LARGE
|
||||
choices=SEARCH_STYLE, max_length=64, default=NEW
|
||||
)
|
||||
show_recent = models.BooleanField(default=True)
|
||||
plan_share = models.ManyToManyField(
|
||||
@@ -140,6 +154,7 @@ class UserPreference(models.Model, PermissionModelMixin):
|
||||
shopping_auto_sync = models.IntegerField(default=5)
|
||||
sticky_navbar = models.BooleanField(default=STICKY_NAV_PREF_DEFAULT)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE, null=True)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
@@ -242,7 +257,7 @@ class SyncLog(models.Model, PermissionModelMixin):
|
||||
return f"{self.created_at}:{self.sync} - {self.status}"
|
||||
|
||||
|
||||
class Keyword(models.Model, PermissionModelMixin):
|
||||
class Keyword(ExportModelOperationsMixin('keyword'), models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=64)
|
||||
icon = models.CharField(max_length=16, blank=True, null=True)
|
||||
description = models.TextField(default="", blank=True)
|
||||
@@ -262,7 +277,7 @@ class Keyword(models.Model, PermissionModelMixin):
|
||||
unique_together = (('space', 'name'),)
|
||||
|
||||
|
||||
class Unit(models.Model, PermissionModelMixin):
|
||||
class Unit(ExportModelOperationsMixin('unit'), models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||
description = models.TextField(blank=True, null=True)
|
||||
|
||||
@@ -276,7 +291,7 @@ class Unit(models.Model, PermissionModelMixin):
|
||||
unique_together = (('space', 'name'),)
|
||||
|
||||
|
||||
class Food(models.Model, PermissionModelMixin):
|
||||
class Food(ExportModelOperationsMixin('food'), models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128, validators=[MinLengthValidator(1)])
|
||||
recipe = models.ForeignKey('Recipe', null=True, blank=True, on_delete=models.SET_NULL)
|
||||
supermarket_category = models.ForeignKey(SupermarketCategory, null=True, blank=True, on_delete=models.SET_NULL)
|
||||
@@ -293,7 +308,7 @@ class Food(models.Model, PermissionModelMixin):
|
||||
unique_together = (('space', 'name'),)
|
||||
|
||||
|
||||
class Ingredient(models.Model, PermissionModelMixin):
|
||||
class Ingredient(ExportModelOperationsMixin('ingredient'), models.Model, PermissionModelMixin):
|
||||
food = models.ForeignKey(Food, on_delete=models.PROTECT, null=True, blank=True)
|
||||
unit = models.ForeignKey(Unit, on_delete=models.PROTECT, null=True, blank=True)
|
||||
amount = models.DecimalField(default=0, decimal_places=16, max_digits=32)
|
||||
@@ -302,14 +317,8 @@ class Ingredient(models.Model, PermissionModelMixin):
|
||||
no_amount = models.BooleanField(default=False)
|
||||
order = models.IntegerField(default=0)
|
||||
|
||||
objects = ScopedManager(space='step__recipe__space')
|
||||
|
||||
@staticmethod
|
||||
def get_space_key():
|
||||
return 'step', 'recipe', 'space'
|
||||
|
||||
def get_space(self):
|
||||
return self.step_set.first().recipe_set.first().space
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return str(self.amount) + ' ' + str(self.unit) + ' ' + str(self.food)
|
||||
@@ -318,13 +327,15 @@ class Ingredient(models.Model, PermissionModelMixin):
|
||||
ordering = ['order', 'pk']
|
||||
|
||||
|
||||
class Step(models.Model, PermissionModelMixin):
|
||||
class Step(ExportModelOperationsMixin('step'), models.Model, PermissionModelMixin):
|
||||
TEXT = 'TEXT'
|
||||
TIME = 'TIME'
|
||||
FILE = 'FILE'
|
||||
RECIPE = 'RECIPE'
|
||||
|
||||
name = models.CharField(max_length=128, default='', blank=True)
|
||||
type = models.CharField(
|
||||
choices=((TEXT, _('Text')), (TIME, _('Time')),),
|
||||
choices=((TEXT, _('Text')), (TIME, _('Time')), (FILE, _('File')), (RECIPE, _('Recipe')),),
|
||||
default=TEXT,
|
||||
max_length=16
|
||||
)
|
||||
@@ -332,16 +343,12 @@ class Step(models.Model, PermissionModelMixin):
|
||||
ingredients = models.ManyToManyField(Ingredient, blank=True)
|
||||
time = models.IntegerField(default=0, blank=True)
|
||||
order = models.IntegerField(default=0)
|
||||
file = models.ForeignKey('UserFile', on_delete=models.PROTECT, null=True, blank=True)
|
||||
show_as_header = models.BooleanField(default=True)
|
||||
step_recipe = models.ForeignKey('Recipe', default=None, blank=True, null=True, on_delete=models.PROTECT)
|
||||
|
||||
objects = ScopedManager(space='recipe__space')
|
||||
|
||||
@staticmethod
|
||||
def get_space_key():
|
||||
return 'recipe', 'space'
|
||||
|
||||
def get_space(self):
|
||||
return self.recipe_set.first().space
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def get_instruction_render(self):
|
||||
from cookbook.helper.template_helper import render_instructions
|
||||
@@ -362,20 +369,14 @@ class NutritionInformation(models.Model, PermissionModelMixin):
|
||||
max_length=512, default="", null=True, blank=True
|
||||
)
|
||||
|
||||
objects = ScopedManager(space='recipe__space')
|
||||
|
||||
@staticmethod
|
||||
def get_space_key():
|
||||
return 'recipe', 'space'
|
||||
|
||||
def get_space(self):
|
||||
return self.recipe_set.first().space
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
objects = ScopedManager(space='space')
|
||||
|
||||
def __str__(self):
|
||||
return 'Nutrition'
|
||||
return f'Nutrition {self.pk}'
|
||||
|
||||
|
||||
class Recipe(models.Model, PermissionModelMixin):
|
||||
class Recipe(ExportModelOperationsMixin('recipe'), models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128)
|
||||
description = models.CharField(max_length=512, blank=True, null=True)
|
||||
servings = models.IntegerField(default=1)
|
||||
@@ -407,7 +408,7 @@ class Recipe(models.Model, PermissionModelMixin):
|
||||
return self.name
|
||||
|
||||
|
||||
class Comment(models.Model, PermissionModelMixin):
|
||||
class Comment(ExportModelOperationsMixin('comment'), models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
text = models.TextField()
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
@@ -420,6 +421,9 @@ class Comment(models.Model, PermissionModelMixin):
|
||||
def get_space_key():
|
||||
return 'recipe', 'space'
|
||||
|
||||
def get_space(self):
|
||||
return self.recipe.space
|
||||
|
||||
def __str__(self):
|
||||
return self.text
|
||||
|
||||
@@ -438,7 +442,7 @@ class RecipeImport(models.Model, PermissionModelMixin):
|
||||
return self.name
|
||||
|
||||
|
||||
class RecipeBook(models.Model, PermissionModelMixin):
|
||||
class RecipeBook(ExportModelOperationsMixin('book'), models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128)
|
||||
description = models.TextField(blank=True)
|
||||
icon = models.CharField(max_length=16, blank=True, null=True)
|
||||
@@ -452,7 +456,7 @@ class RecipeBook(models.Model, PermissionModelMixin):
|
||||
return self.name
|
||||
|
||||
|
||||
class RecipeBookEntry(models.Model, PermissionModelMixin):
|
||||
class RecipeBookEntry(ExportModelOperationsMixin('book_entry'), models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
book = models.ForeignKey(RecipeBook, on_delete=models.CASCADE)
|
||||
|
||||
@@ -487,7 +491,7 @@ class MealType(models.Model, PermissionModelMixin):
|
||||
return self.name
|
||||
|
||||
|
||||
class MealPlan(models.Model, PermissionModelMixin):
|
||||
class MealPlan(ExportModelOperationsMixin('meal_plan'), models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, blank=True, null=True)
|
||||
servings = models.DecimalField(default=1, max_digits=8, decimal_places=4)
|
||||
title = models.CharField(max_length=64, blank=True, default='')
|
||||
@@ -512,7 +516,7 @@ class MealPlan(models.Model, PermissionModelMixin):
|
||||
return f'{self.get_label()} - {self.date} - {self.meal_type.name}'
|
||||
|
||||
|
||||
class ShoppingListRecipe(models.Model, PermissionModelMixin):
|
||||
class ShoppingListRecipe(ExportModelOperationsMixin('shopping_list_recipe'), models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE, null=True, blank=True)
|
||||
servings = models.DecimalField(default=1, max_digits=8, decimal_places=4)
|
||||
|
||||
@@ -535,7 +539,7 @@ class ShoppingListRecipe(models.Model, PermissionModelMixin):
|
||||
return None
|
||||
|
||||
|
||||
class ShoppingListEntry(models.Model, PermissionModelMixin):
|
||||
class ShoppingListEntry(ExportModelOperationsMixin('shopping_list_entry'), models.Model, PermissionModelMixin):
|
||||
list_recipe = models.ForeignKey(ShoppingListRecipe, on_delete=models.CASCADE, null=True, blank=True)
|
||||
food = models.ForeignKey(Food, on_delete=models.CASCADE)
|
||||
unit = models.ForeignKey(Unit, on_delete=models.CASCADE, null=True, blank=True)
|
||||
@@ -565,7 +569,7 @@ class ShoppingListEntry(models.Model, PermissionModelMixin):
|
||||
return None
|
||||
|
||||
|
||||
class ShoppingList(models.Model, PermissionModelMixin):
|
||||
class ShoppingList(ExportModelOperationsMixin('shopping_list'), models.Model, PermissionModelMixin):
|
||||
uuid = models.UUIDField(default=uuid.uuid4)
|
||||
note = models.TextField(blank=True, null=True)
|
||||
recipes = models.ManyToManyField(ShoppingListRecipe, blank=True)
|
||||
@@ -583,9 +587,11 @@ class ShoppingList(models.Model, PermissionModelMixin):
|
||||
return f'Shopping list {self.id}'
|
||||
|
||||
|
||||
class ShareLink(models.Model, PermissionModelMixin):
|
||||
class ShareLink(ExportModelOperationsMixin('share_link'), models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
uuid = models.UUIDField(default=uuid.uuid4)
|
||||
request_count = models.IntegerField(default=0)
|
||||
abuse_blocked = models.BooleanField(default=False)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@@ -600,9 +606,9 @@ def default_valid_until():
|
||||
return date.today() + timedelta(days=14)
|
||||
|
||||
|
||||
class InviteLink(models.Model, PermissionModelMixin):
|
||||
class InviteLink(ExportModelOperationsMixin('invite_link'), models.Model, PermissionModelMixin):
|
||||
uuid = models.UUIDField(default=uuid.uuid4)
|
||||
username = models.CharField(blank=True, max_length=64)
|
||||
email = models.EmailField(blank=True)
|
||||
group = models.ForeignKey(Group, on_delete=models.CASCADE)
|
||||
valid_until = models.DateField(default=default_valid_until)
|
||||
used_by = models.ForeignKey(
|
||||
@@ -632,7 +638,7 @@ class TelegramBot(models.Model, PermissionModelMixin):
|
||||
return f"{self.name}"
|
||||
|
||||
|
||||
class CookLog(models.Model, PermissionModelMixin):
|
||||
class CookLog(ExportModelOperationsMixin('cook_log'), models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(default=timezone.now)
|
||||
@@ -646,7 +652,7 @@ class CookLog(models.Model, PermissionModelMixin):
|
||||
return self.recipe.name
|
||||
|
||||
|
||||
class ViewLog(models.Model, PermissionModelMixin):
|
||||
class ViewLog(ExportModelOperationsMixin('view_log'), models.Model, PermissionModelMixin):
|
||||
recipe = models.ForeignKey(Recipe, on_delete=models.CASCADE)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
@@ -663,6 +669,10 @@ class ImportLog(models.Model, PermissionModelMixin):
|
||||
running = models.BooleanField(default=True)
|
||||
msg = models.TextField(default="")
|
||||
keyword = models.ForeignKey(Keyword, null=True, blank=True, on_delete=models.SET_NULL)
|
||||
|
||||
total_recipes = models.IntegerField(default=0)
|
||||
imported_recipes = models.IntegerField(default=0)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
@@ -671,3 +681,30 @@ class ImportLog(models.Model, PermissionModelMixin):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.created_at}:{self.type}"
|
||||
|
||||
|
||||
class BookmarkletImport(ExportModelOperationsMixin('bookmarklet_import'), models.Model, PermissionModelMixin):
|
||||
html = models.TextField()
|
||||
url = models.CharField(max_length=256, null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
objects = ScopedManager(space='space')
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
|
||||
|
||||
class UserFile(ExportModelOperationsMixin('user_files'), models.Model, PermissionModelMixin):
|
||||
name = models.CharField(max_length=128)
|
||||
file = models.FileField(upload_to='files/')
|
||||
file_size_kb = models.IntegerField(default=0, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
objects = ScopedManager(space='space')
|
||||
space = models.ForeignKey(Space, on_delete=models.CASCADE)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if hasattr(self.file, 'file') and isinstance(self.file.file, UploadedFile) or isinstance(self.file.file, InMemoryUploadedFile):
|
||||
self.file.name = f'{uuid.uuid4()}' + pathlib.Path(self.file.name).suffix
|
||||
self.file_size_kb = round(self.file.size / 1000)
|
||||
super(UserFile, self).save(*args, **kwargs)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user