mirror of
https://gitlab.crans.org/bde/nk20
synced 2025-04-27 17:12:38 +00:00
Compare commits
830 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
f0e9a7d3dc | ||
|
a2b42c5329 | ||
|
6d6583bfe6 | ||
|
485d093002 | ||
|
ff4353d344 | ||
|
a90f45bd8b | ||
|
10c22ccc53 | ||
|
6969cee0f3 | ||
|
ddeada200b | ||
|
8e2b24b2da | ||
|
bd76c280ec | ||
|
ca0a95ba9e | ||
|
614f76e699 | ||
|
a5815f0bc7 | ||
|
84e9fea15f | ||
|
b7a660ee40 | ||
|
b9ebb1718a | ||
|
7ba5c76a89 | ||
|
702ddb5679 | ||
|
93aed87265 | ||
|
60355196ce | ||
|
9bffb32a5e | ||
|
5ef019c5c2 | ||
|
8da62e62fb | ||
|
56a43396d4 | ||
|
7966d6f397 | ||
|
cb61c511ce | ||
|
25bfa575ed | ||
|
e21d9fcfbe | ||
|
b293904525 | ||
|
bd7e6b8ad4 | ||
|
a208a4fa25 | ||
|
4799b2c52d | ||
|
562dcfb908 | ||
|
12ef258ff0 | ||
|
2ae32ee3b6 | ||
|
ec1bd45481 | ||
|
370a9a069e | ||
|
7f0a3784e9 | ||
|
36f4adf2e7 | ||
|
ae7d5d5489 | ||
|
434097aba4 | ||
|
a0ebf8658d | ||
|
423454ba5d | ||
|
3ccb31639c | ||
|
5fb12a1388 | ||
|
fe029893b0 | ||
|
767e98c2a3 | ||
|
1bdad76fe9 | ||
|
0196db7fff | ||
|
1f53ad4407 | ||
|
018f6e3f13 | ||
|
9752a030d9 | ||
|
b27bdb090d | ||
|
55a0fbb6cb | ||
|
c356534309 | ||
|
51315a0555 | ||
|
e5f9fe2cf5 | ||
|
6c63c6417c | ||
|
4563b2b640 | ||
|
c630a3fbd5 | ||
|
79b8ebeca4 | ||
|
dc14ba0101 | ||
|
6028bfeb56 | ||
|
bd9773a8af | ||
|
cdeb76d9f8 | ||
|
ac4574200d | ||
|
b17d31e8ee | ||
|
30d27459dd | ||
|
333f7aa284 | ||
|
587314e03c | ||
|
9f888a5281 | ||
|
88b1a25ca0 | ||
|
8cb50f58f2 | ||
|
041a8f20a9 | ||
|
b1ffb28532 | ||
|
6225fb51f1 | ||
|
1dd74e8024 | ||
|
1af9f5f23c | ||
|
83d5a7ceff | ||
|
a7cba0a4a3 | ||
|
ccd9a66ab9 | ||
|
c7a92fa4b2 | ||
|
5f1b698d58 | ||
|
0a5368d23f | ||
|
26b351a51c | ||
|
1836677c47 | ||
|
e7a98c86f0 | ||
|
eb5044490b | ||
|
983d7ec052 | ||
|
dc56deaf85 | ||
|
19d1ecfc66 | ||
|
694f54e1c4 | ||
|
b0c3eee699 | ||
|
cd942779ca | ||
|
0d0fdef363 | ||
|
7ed544b3ac | ||
|
821efbf78b | ||
|
a209e0d366 | ||
|
ef485e0628 | ||
|
1481aa0635 | ||
|
867bf9fd25 | ||
|
47fda0ea36 | ||
|
623290827a | ||
|
a87ce625f3 | ||
|
3559787fa7 | ||
|
bd6ed27ae5 | ||
|
43dc676747 | ||
|
caaeab6b0b | ||
|
54ba786884 | ||
|
80e109114f | ||
|
787005e60d | ||
|
414e103686 | ||
|
942d887c2e | ||
|
a63c34fe37 | ||
|
2be6133458 | ||
|
7975fe47a6 | ||
|
476fbceeea | ||
|
8fbaa0bdc8 | ||
|
a0de63effd | ||
|
09fb1d227e | ||
|
2e27d4f05c | ||
|
5d16dc4e7d | ||
|
3c34033bf5 | ||
|
131f508433 | ||
|
c1a353963a | ||
|
178ce2b579 | ||
|
9162319734 | ||
|
5d2a8e9b79 | ||
|
33c94d0720 | ||
|
5040e8e8ea | ||
|
c5697c4cb4 | ||
|
e188c5a153 | ||
|
94e1fdc93a | ||
|
d1ef367bab | ||
|
0fbb19c5fd | ||
|
21cbf2b21a | ||
|
185a2cabf2 | ||
|
7552e55c8d | ||
|
361de9f8b4 | ||
|
e2426bd6a6 | ||
|
7fea619a9f | ||
|
7b5eefcc0a | ||
|
e4aa16986f | ||
|
b92e6e4e10 | ||
|
dd675b3676 | ||
|
f50849b4f8 | ||
|
73ff35c232 | ||
|
a5df98224f | ||
|
2cb9ac8735 | ||
|
35d4849a28 | ||
|
96539d262f | ||
|
946674f59b | ||
|
a201d8376a | ||
|
a21b9275ea | ||
|
d4e85e8215 | ||
|
7af2ebba40 | ||
|
bd94400883 | ||
|
5558341c8c | ||
|
35ef82223c | ||
|
9ccac36831 | ||
|
2e71ce05a9 | ||
|
f2cb10b69f | ||
|
24c4edf2e3 | ||
|
213e9a8b12 | ||
|
2c56178b15 | ||
|
48a5b04579 | ||
|
2ab5c4082a | ||
|
053225c6dc | ||
|
ac7b86651d | ||
|
21f5a5d566 | ||
|
ff9c78ed4e | ||
|
1e121297d1 | ||
|
549f56dc0b | ||
|
debeb33d46 | ||
|
6d7076b03e | ||
|
196df1e775 | ||
|
28117c8c61 | ||
|
0d9891fbd8 | ||
|
4be4a18dd1 | ||
|
27b00ba4f0 | ||
|
3fcbb4f310 | ||
|
d1c9a2a7f1 | ||
|
a673fd6871 | ||
|
a324d3a892 | ||
|
951ba74f8f | ||
|
abc4f14bd1 | ||
|
47138bafd4 | ||
|
a3920fcae3 | ||
|
ae4213d087 | ||
|
b2b1f03b46 | ||
|
1c5ed2bd3f | ||
|
a7e87ea639 | ||
|
cbf92651f0 | ||
|
12c93ff9da | ||
|
354c79bb82 | ||
|
1ea7b3dda1 | ||
|
35ffbfcf55 | ||
|
162371042c | ||
|
581715d804 | ||
|
c7c6f0350f | ||
|
9d1024024b | ||
d595d908c6 | |||
734f5b242d | |||
b0c7d43a50 | |||
|
6f67d2c629 | ||
|
4b97ab2e2a | ||
|
dcfd0167e7 | ||
|
50a680eed2 | ||
|
226a2a6357 | ||
|
48462f2ffc | ||
|
260513ae3b | ||
|
210a3cc93c | ||
|
896095a44c | ||
|
3f997f94fa | ||
|
0801ad64ae | ||
|
64bd5ed546 | ||
|
4c390dce17 | ||
|
adacc293f5 | ||
|
968fa64d37 | ||
|
a481adbae4 | ||
|
4de2e987ef | ||
|
9e6342c929 | ||
|
74de358953 | ||
|
7322d55789 | ||
1a258dfe9e | |||
|
b8f81048a5 | ||
|
af819f45a1 | ||
|
076d065ffa | ||
|
2da77d9c17 | ||
|
01584d6330 | ||
|
4c0a5922c4 | ||
|
f90b28fc7c | ||
|
bbbdcc7247 | ||
|
925e0f26f5 | ||
|
feeb99041f | ||
|
c912383f86 | ||
|
32830e43fd | ||
|
11c6a6fa7a | ||
|
201d6b114a | ||
|
19e77df299 | ||
|
5fd6ec5668 | ||
|
10a01c5bc2 | ||
|
989905ea64 | ||
|
0218d43a17 | ||
|
5d30b0e819 | ||
|
ec759dd3c0 | ||
|
2eb965291d | ||
|
7f182ee2ee | ||
|
3132aa4c38 | ||
|
c7eb774859 | ||
|
32f8d285b3 | ||
|
050256ea13 | ||
|
7afd15b1cc | ||
|
258361f116 | ||
|
a307530579 | ||
|
5de930bf40 | ||
|
f7ebe0e99b | ||
|
73de6e2176 | ||
|
201611b105 | ||
|
40c239e9da | ||
|
2aaab2b454 | ||
|
fc088dec86 | ||
|
2d60f1fd7b | ||
|
7b48b09329 | ||
|
ffac940511 | ||
|
50f98fd5ad | ||
|
402e19d1ce | ||
|
0b0394b61f | ||
|
98422d8259 | ||
|
29509b5b26 | ||
|
0d64ad31e0 | ||
|
5781cbd6a5 | ||
|
5295e61a00 | ||
|
e79ed6226a | ||
|
68152e6354 | ||
|
6c61daf1c5 | ||
|
b8cc297baf | ||
|
cd8224f2e0 | ||
|
3c882a7854 | ||
|
357e1bbaa2 | ||
|
f5c4c58525 | ||
|
dafb602b08 | ||
|
5b377e6a75 | ||
|
28bd62531e | ||
|
b3a31c27a5 | ||
|
c7a8e6a1a5 | ||
|
546a3a72b1 | ||
|
2e5664f79d | ||
|
e367666fe9 | ||
|
04a9b3daf0 | ||
|
d1df8f3eac | ||
|
a5221f66ef | ||
|
7d59cd6cd2 | ||
|
96215cc1ff | ||
|
b7a71d911d | ||
|
2ee7f41dfe | ||
|
fb3337966e | ||
|
0db0474217 | ||
|
2b3eb15f59 | ||
|
399a32bece | ||
|
82fea65b5e | ||
|
abc88d0118 | ||
|
b6b81a8b8f | ||
|
d228dbf225 | ||
|
a6b479db19 | ||
|
048d251f75 | ||
|
7b11cb0797 | ||
|
516a7f4be5 | ||
|
2f8c9b54e7 | ||
|
e9f18c3ed9 | ||
|
ff3c30517e | ||
|
f481ea6acb | ||
|
802fd8c2d7 | ||
|
5209a586a9 | ||
|
24f54ac876 | ||
|
988b4c9e88 | ||
|
e32c267995 | ||
|
5e39209ab1 | ||
|
08b2fabe07 | ||
|
405479e5ad | ||
|
0cc130092f | ||
|
ff6e207512 | ||
|
0f1e4d2e60 | ||
|
6255bcbbb1 | ||
|
d82a1001c4 | ||
|
31a54482f0 | ||
|
4ee02345d4 | ||
|
422c087d17 | ||
|
30d6e2c95e | ||
|
f3a3f07e38 | ||
|
a5e802f370 | ||
|
540f3bc354 | ||
|
2d19457506 | ||
|
72786d0d2b | ||
|
f099cbc879 | ||
|
977eb7c0d4 | ||
|
d81b1f2710 | ||
|
6a69590a82 | ||
|
7afc583282 | ||
|
4fb0b7d736 | ||
|
18a5b65a1c | ||
|
f545af4977 | ||
|
103e2d0635 | ||
|
aedf0e87ba | ||
|
dab45b5fd4 | ||
|
b3353b563c | ||
|
6bc52be707 | ||
|
834d68fe35 | ||
|
c6a2849d35 | ||
|
4ab22c92b3 | ||
|
c328c1457c | ||
|
96da7d01ae | ||
|
d27f942339 | ||
|
738d6c932d | ||
|
1760196578 | ||
|
13b9b6edea | ||
|
e06e3b2972 | ||
|
9596aa7b8c | ||
|
ba0d64f0d4 | ||
|
8d17801e28 | ||
|
609362c4f8 | ||
|
03d2d5f03e | ||
|
d2057a9f45 | ||
|
b6e68eeebe | ||
|
6410542027 | ||
|
6b1cd3ba7a | ||
|
9f114b8ca2 | ||
|
e0132b6dc8 | ||
|
f1cc82fab3 | ||
|
644cf14c4b | ||
|
f19a489313 | ||
|
dedd6c69cc | ||
|
b42f5afeab | ||
|
31e67ae3f6 | ||
|
b08da7a727 | ||
|
451aa64f33 | ||
|
3c99b0f3e9 | ||
|
201a179947 | ||
|
96784aee3b | ||
|
981c4d0300 | ||
|
11223430fd | ||
|
7aeb977e72 | ||
|
52fef1df42 | ||
|
16f8a60a3f | ||
|
2839d3de1e | ||
|
30afa6da0a | ||
|
84fc77696f | ||
|
19fc620d1f | ||
|
d5819ac562 | ||
|
a79df8f1f6 | ||
|
364b18e188 | ||
|
10a883b2e5 | ||
|
1410ab6c4f | ||
|
623dd61be6 | ||
|
48a0a87e7c | ||
|
563f525b11 | ||
|
63c1d74f1a | ||
|
c42fb380a6 | ||
|
c636d52a73 | ||
|
6a9021ec14 | ||
|
9c9149b53a | ||
|
cb74311e7b | ||
|
9d7dd566c9 | ||
|
6bceb394c5 | ||
|
62cf8f9d84 | ||
|
9944ebcaad | ||
|
8537f043f7 | ||
|
2dd1c3fb89 | ||
|
c8665c5798 | ||
|
e9f1b6f52d | ||
|
1d95ae4810 | ||
|
c89a95f8d2 | ||
|
73640b1dfa | ||
|
84b16ab603 | ||
|
6a1b51dbbf | ||
|
c441a43a8b | ||
|
87f3b51b04 | ||
|
0a853fd3e6 | ||
|
c429734810 | ||
|
5d759111b6 | ||
|
70baf7566c | ||
|
eb355f547c | ||
|
7068170f18 | ||
|
45ee9a8941 | ||
|
454ea19603 | ||
5a77a66391 | |||
|
761fc170eb | ||
|
ac23d7eb54 | ||
|
40e7415062 | ||
|
319405d2b1 | ||
|
633ab88b04 | ||
|
e29b42eecc | ||
|
dc69faaf1d | ||
|
442a5c5e36 | ||
|
7ab0fec3bc | ||
|
bd4fb23351 | ||
|
ee22e9b3b6 | ||
|
19ae616fb4 | ||
|
b7657ec362 | ||
|
4d03d9460d | ||
3633f66a87 | |||
d43fbe7ac6 | |||
|
df5f9b5f1e | ||
4161248bff | |||
58136f3c48 | |||
d9b4e0a9a9 | |||
8563a8d235 | |||
5f69232560 | |||
d3273e9ee2 | |||
4e30f805a7 | |||
546e422e64 | |||
9048a416df | |||
8578bd743c | |||
45a10dad00 | |||
18a1282773 | |||
132afc3d15 | |||
6bf16a181a | |||
e20df82346 | |||
1eb72044c2 | |||
f88eae924c | |||
4b6e3ba546 | |||
bf0fe3479f | |||
45ba4f9537 | |||
b204805ce2 | |||
2f28e34cec | |||
9c8ea2cd41 | |||
41289857b2 | |||
28a8792c9f | |||
58cafad032 | |||
7848cd9cc2 | |||
d18ccfac23 | |||
|
e479e1e3a4 | ||
|
82b0c83b1f | ||
38ca414ef6 | |||
fd811053c7 | |||
9d386d1ecf | |||
|
0bd447b608 | ||
|
3f3c93d928 | ||
|
340c90f5d3 | ||
ca2b9f061c | |||
a05dfcbf3d | |||
ba3c0fb18d | |||
ab69963ea1 | |||
654c01631a | |||
d94cc2a7ad | |||
69bb38297f | |||
9628560d64 | |||
df3bb71357 | |||
2a216fd994 | |||
8dd2619013 | |||
62431a4910 | |||
|
946bc1e497 | ||
d4896bfd76 | |||
23f46cc598 | |||
d1a9f21b56 | |||
d809b2595a | |||
97803ac983 | |||
b951c4aa05 | |||
69b3d2ac9c | |||
f29054558a | |||
11dd8adbb7 | |||
d437f2bdbd | |||
ac8453b04c | |||
|
6b4d18f4b3 | ||
|
668cfa71a7 | ||
161db0b00b | |||
8638c16b34 | |||
9583cec3ff | |||
1ef25924a0 | |||
e89383e3f4 | |||
79a116d9c6 | |||
aa75ce5c7a | |||
a3a9dfc812 | |||
76531595ad | |||
a0b920ac94 | |||
ab2e580e68 | |||
0234f19a33 | |||
1a4b7c83e8 | |||
4c17e2a92b | |||
e68afc7d0a | |||
c6e3b54f94 | |||
7e6a14296a | |||
780f78b385 | |||
4e3c32eb5e | |||
ef118c2445 | |||
600ba15faa | |||
944bb127e2 | |||
f6d042c998 | |||
bb9a0a2593 | |||
61feac13c7 | |||
81e708a7e3 | |||
3532846c87 | |||
49551e88f8 | |||
db936bf75a | |||
5828a20383 | |||
cea3138daf | |||
fb98d9cd8b | |||
0dd3da5c01 | |||
af4be98b5b | |||
be6059eba6 | |||
5793b83de7 | |||
2c02c747f4 | |||
a78f3b7caa | |||
1ee40cb94e | |||
bd035744a4 | |||
7edd622755 | |||
8fd5b6ee01 | |||
03411ac9bd | |||
d965732b65 | |||
048266ed61 | |||
b27341009e | |||
da1e15c5e6 | |||
4b03a78ad6 | |||
fb6e3c3de0 | |||
391f3bde8f | |||
ad04e45992 | |||
4e1ba1447a | |||
b646f549d6 | |||
ba9ef0371a | |||
881cd88f48 | |||
b4ed354b73 | |||
e5051ab018 | |||
bb69627ac5 | |||
ffaa020310 | |||
6d2b7054e2 | |||
d888d5863a | |||
dbc7b3444b | |||
f25eb1d2c5 | |||
a2a749e1ca | |||
5bf6a5501d | |||
9523b5f05f | |||
5eb3ffca66 | |||
9930c48253 | |||
d902e63a0c | |||
48b0bade51 | |||
f75dbc4525 | |||
fbf64db16e | |||
a3fd8ba063 | |||
9b26207515 | |||
7ea36a5415 | |||
898f6d52bf | |||
8be16e7b58 | |||
ea092803d7 | |||
5e9f36ef1a | |||
b4d87bc6b5 | |||
dd639d829e | |||
7b809ff3a6 | |||
d36edfc063 | |||
cf87da096f | |||
e452b7acbf | |||
74ab4df9fe | |||
451851c955 | |||
789ca149af | |||
7d3f1930b8 | |||
e8f4ca1e09 | |||
733f145be3 | |||
48c37353ea | |||
8056dc096d | |||
6d5b69cd26 | |||
a7bdffd71a | |||
0887e4bbde | |||
199f4ca1f2 | |||
802a6c68cb | |||
41a0b3a1c1 | |||
aa35724be2 | |||
9086d33158 | |||
43d214b982 | |||
b93e4a8d11 | |||
b9a9704061 | |||
fee52f326a | |||
317966d5c1 | |||
9f0a22d3d1 | |||
a5ecdd100c | |||
f60691846b | |||
d5ecb72a71 | |||
8cf9dfb9b9 | |||
c3ab61bd04 | |||
0b4b6dcb3e | |||
0d5f6c0332 | |||
7b28938cde | |||
35ffb36fbd | |||
|
08ba0b263a | ||
|
c4c4e9594f | ||
|
4166823d55 | ||
|
dc0f3dbcef | ||
|
4583958f50 | ||
|
b3abe9ab18 | ||
|
27f23b48b6 | ||
|
67e170d4a6 | ||
|
8f895dc4d7 | ||
|
1187577728 | ||
|
8a58af3b31 | ||
|
0c23625147 | ||
|
21219b9c62 | ||
|
5ab8beecef | ||
|
1ca5133026 | ||
|
93bc6bb245 | ||
|
952c4383e7 | ||
15dd2b8f0c | |||
c540b6334c | |||
|
bab394908d | ||
0b93968b9e | |||
97375ef6c0 | |||
36cfcd533f | |||
21dbc53615 | |||
e6f10ebdac | |||
47968844ce | |||
a435460e29 | |||
b7c4360108 | |||
|
8d8c417c50 | ||
2b189af25b | |||
5a07c8a94f | |||
6cc1857eb6 | |||
601534d610 | |||
c271593839 | |||
f351794aa0 | |||
2793fee58c | |||
7a715df121 | |||
9308878054 | |||
b5ccf5b800 | |||
5e63254439 | |||
da96506218 | |||
b4714b896a | |||
cdb2647a4d | |||
cc12e3ec63 | |||
be168c5ada | |||
b46ae6f856 | |||
ec0bcbf015 | |||
81303b8ef8 | |||
910b98fefc | |||
5a7a219ba8 | |||
116451603c | |||
b2437ef9b5 | |||
d8c9618772 | |||
c825dee95a | |||
73d27e820b | |||
40e1b42078 | |||
72806f0ace | |||
b244e01231 | |||
76d1784aea | |||
56c5fa4057 | |||
b5ef937a03 | |||
e95a8b6e18 | |||
635adf1360 | |||
d5a9bf175f | |||
b597a6ac5b | |||
|
a704b92c3d | ||
53090b1a21 | |||
c49af0b83a | |||
5a05997d9d | |||
|
c109cd3ddd | ||
|
84304971d7 | ||
b8b781f9a2 | |||
002128eed2 | |||
8d71783c42 | |||
|
a6f23df7d5 | ||
|
d9c97628e2 | ||
|
893534955d | ||
|
dfbf9972c2 | ||
|
b5f3b3ffc1 | ||
|
3aad4e7398 | ||
|
b4a1b513cc | ||
c0c64f225c | |||
|
9d8f47115c | ||
|
f4156f1b94 | ||
|
e60994e065 | ||
|
801f711994 | ||
|
e4568b410f | ||
c8f7986d5a | |||
|
d3a9c442a5 | ||
|
016ab5a9c9 | ||
|
7866ab7ec0 | ||
|
f570ff3cd5 | ||
|
6b2638c271 | ||
|
5cb4183e9f | ||
|
3a20555663 | ||
|
95be0042e9 | ||
|
48880e7fd3 | ||
|
e0030771e4 | ||
|
d47799e6ee | ||
|
eae091625a | ||
|
aceb77ffb9 | ||
|
338c94ed05 | ||
|
290848f904 | ||
|
72dca54bbf | ||
|
117d9da3ba | ||
|
37efebe85b | ||
|
3af2ec71b6 | ||
|
0b4a95525b | ||
|
af664e481f | ||
|
0171f16311 | ||
|
296b94d237 | ||
|
4942553335 | ||
|
c1efb87180 | ||
|
72eead8595 | ||
|
ade7e583e5 | ||
4a8a101822 | |||
dd2cfa6327 | |||
2adf84b7fc | |||
|
2f54e64ea2 | ||
|
8434c0062c | ||
|
6d976f32bf | ||
|
b9d49d53f2 | ||
|
23243e09bb | ||
|
2682e9a610 | ||
|
5635598bbc | ||
|
b58a0c43cd | ||
|
e1f647bd02 | ||
|
39fd3a2471 | ||
|
1072e227b8 | ||
|
cbf7e6fe6c | ||
|
950922d041 | ||
|
78fe070cd3 | ||
|
51d5733578 | ||
|
7bd895c1df | ||
|
e5e94c52f2 | ||
|
051591cb7a | ||
|
0e7390b669 | ||
|
fe4363b83d | ||
|
6e80016b38 | ||
|
08e50ffc22 | ||
|
9cb65277f3 | ||
|
224a0fdd8c | ||
|
6dc7604e90 | ||
|
cb7f3c9f18 | ||
|
f910feca9e | ||
|
91f784872c | ||
|
b655135a42 | ||
|
58aa4983e3 | ||
|
6cc3cf4174 | ||
|
2097e67321 | ||
|
d773303d18 | ||
|
3cabcf40e7 | ||
|
bf29efda0a | ||
|
ceccba0d71 | ||
|
3eced33082 | ||
|
acb3fb4a91 | ||
|
1c5e951c2f | ||
|
beb1853aef | ||
|
0078eb8f90 | ||
|
e5e758f9d9 | ||
|
4a78328717 | ||
|
65a2e8c08c | ||
|
b5fa428bad | ||
|
fb72385773 | ||
|
2f68601e8b | ||
|
0b1bed8048 | ||
|
8ada0e51f2 | ||
|
c3d613947f | ||
|
36b8157372 | ||
|
992cfe8e23 | ||
|
18a8ff1b8a | ||
|
c61bb2e90d | ||
|
4b12e3ed08 | ||
|
af07ed9807 | ||
|
bbe53b3b63 | ||
|
536f0ec226 | ||
|
541ed59f40 | ||
|
e172b4f4bb | ||
|
d666179037 | ||
|
f22e92132c | ||
|
ca7ad05746 | ||
|
f55ca2f725 | ||
|
d4e4ed580f | ||
|
8756751344 | ||
|
fd83fe19bf | ||
|
a00d95608b | ||
|
3303edd01f | ||
|
e48ef92137 | ||
|
919d0b7e85 | ||
|
439bf35b62 | ||
|
74b26335d1 | ||
|
3d733ed6af | ||
|
d54ab94ceb | ||
|
4f188ca3e5 | ||
|
72bac75fbd | ||
|
6d54aae614 | ||
|
8052152ea5 | ||
|
70448db8e5 | ||
|
ac2d1e8111 | ||
|
3ba61385a3 | ||
|
7353348d7a | ||
|
f63e2e088e | ||
|
420a24ebac | ||
|
d566def706 | ||
|
eaf6769e8b | ||
|
a61ec81cff | ||
|
60f2a73cc5 | ||
|
bcd96b2ed8 |
@ -10,7 +10,6 @@ DJANGO_SECRET_KEY=CHANGE_ME
|
||||
DJANGO_SETTINGS_MODULE=note_kfet.settings
|
||||
CONTACT_EMAIL=tresorerie.bde@localhost
|
||||
NOTE_URL=localhost
|
||||
DOMAIN=localhost
|
||||
|
||||
# Config for mails. Only used in production
|
||||
NOTE_MAIL=notekfet@localhost
|
||||
|
7
.gitignore
vendored
7
.gitignore
vendored
@ -42,8 +42,15 @@ map.json
|
||||
backups/
|
||||
/static/
|
||||
/media/
|
||||
/tmp/
|
||||
|
||||
# Virtualenv
|
||||
env/
|
||||
venv/
|
||||
db.sqlite3
|
||||
shell.nix
|
||||
|
||||
# ansibles customs host
|
||||
ansible/host_vars/*.yaml
|
||||
!ansible/host_vars/bde*
|
||||
ansible/hosts
|
||||
|
@ -1,30 +1,16 @@
|
||||
stages:
|
||||
- test
|
||||
- quality-assurance
|
||||
- docs
|
||||
|
||||
# Also fetch submodules
|
||||
variables:
|
||||
GIT_SUBMODULE_STRATEGY: recursive
|
||||
|
||||
# Debian Buster
|
||||
py37-django22:
|
||||
# Ubuntu 22.04
|
||||
py310-django42:
|
||||
stage: test
|
||||
image: debian:buster-backports
|
||||
before_script:
|
||||
- >
|
||||
apt-get update &&
|
||||
apt-get install --no-install-recommends -t buster-backports -y
|
||||
python3-django python3-django-crispy-forms
|
||||
python3-django-extensions python3-django-filters python3-django-polymorphic
|
||||
python3-djangorestframework python3-django-cas-server python3-psycopg2 python3-pil
|
||||
python3-babel python3-lockfile python3-pip python3-phonenumbers
|
||||
python3-bs4 python3-setuptools tox texlive-xetex
|
||||
script: tox -e py37-django22
|
||||
|
||||
# Ubuntu 20.04
|
||||
py38-django22:
|
||||
stage: test
|
||||
image: ubuntu:20.04
|
||||
image: ubuntu:22.04
|
||||
before_script:
|
||||
# Fix tzdata prompt
|
||||
- ln -sf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
|
||||
@ -33,17 +19,46 @@ py38-django22:
|
||||
apt-get install --no-install-recommends -y
|
||||
python3-django python3-django-crispy-forms
|
||||
python3-django-extensions python3-django-filters python3-django-polymorphic
|
||||
python3-djangorestframework python3-django-cas-server python3-psycopg2 python3-pil
|
||||
python3-babel python3-lockfile python3-pip python3-phonenumbers
|
||||
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
|
||||
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
|
||||
python3-bs4 python3-setuptools tox texlive-xetex
|
||||
script: tox -e py38-django22
|
||||
script: tox -e py310-django42
|
||||
|
||||
# Debian Bookworm
|
||||
py311-django42:
|
||||
stage: test
|
||||
image: debian:bookworm
|
||||
before_script:
|
||||
- >
|
||||
apt-get update &&
|
||||
apt-get install --no-install-recommends -y
|
||||
python3-django python3-django-crispy-forms
|
||||
python3-django-extensions python3-django-filters python3-django-polymorphic
|
||||
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil
|
||||
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache
|
||||
python3-bs4 python3-setuptools tox texlive-xetex
|
||||
script: tox -e py311-django42
|
||||
|
||||
linters:
|
||||
stage: quality-assurance
|
||||
image: debian:buster-backports
|
||||
image: debian:bookworm
|
||||
before_script:
|
||||
- apt-get update && apt-get install -y tox
|
||||
script: tox -e linters
|
||||
|
||||
# Be nice to new contributors, but please use `tox`
|
||||
allow_failure: true
|
||||
|
||||
# Compile documentation
|
||||
documentation:
|
||||
stage: docs
|
||||
image: sphinxdoc/sphinx
|
||||
before_script:
|
||||
- pip install sphinx-rtd-theme
|
||||
- cd docs
|
||||
script:
|
||||
- make dirhtml
|
||||
artifacts:
|
||||
paths:
|
||||
- docs/_build
|
||||
expire_in: 1 day
|
||||
|
2
.gitmodules
vendored
2
.gitmodules
vendored
@ -1,3 +1,3 @@
|
||||
[submodule "apps/scripts"]
|
||||
path = apps/scripts
|
||||
url = https://gitlab.crans.org/bde/nk20-scripts.git
|
||||
url = https://gitlab.crans.org/bde/nk20-scripts
|
||||
|
@ -8,8 +8,8 @@ RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -t buster-backports -y \
|
||||
python3-django python3-django-crispy-forms \
|
||||
python3-django-extensions python3-django-filters python3-django-polymorphic \
|
||||
python3-djangorestframework python3-django-cas-server python3-psycopg2 python3-pil \
|
||||
python3-babel python3-lockfile python3-pip python3-phonenumbers ipython3 \
|
||||
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil \
|
||||
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache ipython3 \
|
||||
python3-bs4 python3-setuptools \
|
||||
uwsgi uwsgi-plugin-python3 \
|
||||
texlive-xetex gettext libjs-bootstrap4 fonts-font-awesome && \
|
||||
|
63
README.md
63
README.md
@ -1,8 +1,8 @@
|
||||
# NoteKfet 2020
|
||||
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
[](https://gitlab.crans.org/bde/nk20/commits/master)
|
||||
[](https://gitlab.crans.org/bde/nk20/commits/master)
|
||||
[](https://gitlab.crans.org/bde/nk20/commits/main)
|
||||
[](https://gitlab.crans.org/bde/nk20/commits/main)
|
||||
|
||||
## Table des matières
|
||||
|
||||
@ -55,10 +55,16 @@ Bien que cela permette de créer une instance sur toutes les distributions,
|
||||
(env)$ ./manage.py makemigrations
|
||||
(env)$ ./manage.py migrate
|
||||
(env)$ ./manage.py loaddata initial
|
||||
(env)$ ./manage.py createsuperuser # Création d'un utilisateur initial
|
||||
(env)$ ./manage.py createsuperuser # Création d'un⋅e utilisateur⋅rice initial
|
||||
```
|
||||
|
||||
6. Enjoy :
|
||||
6. (Optionnel) **Création d'une clé privée OpenID Connect**
|
||||
|
||||
Pour activer le support d'OpenID Connect, il faut générer une clé privée, par
|
||||
exemple avec openssl (`openssl genrsa -out oidc.key 4096`), et renseigner son
|
||||
emplacement dans `OIDC_RSA_PRIVATE_KEY` (par défaut `/var/secrets/oidc.key`).
|
||||
|
||||
7. Enjoy :
|
||||
|
||||
```bash
|
||||
(env)$ ./manage.py runserver 0.0.0.0:8000
|
||||
@ -69,13 +75,31 @@ accessible depuis l'ensemble de votre réseau, pratique pour tester le rendu
|
||||
de la note sur un téléphone !
|
||||
|
||||
## Installation d'une instance de production
|
||||
Pour déployer facilement la note il est possible d'utiliser le playbook Ansible (sinon vous pouvez toujours le faire a la main, voir plus bas).
|
||||
### Avec ansible
|
||||
Il vous faudra un serveur sous debian ou ubuntu connecté à internet et que vous souhaiterez accéder à cette instance de la note sur `note.nomdedomaine.tld`.
|
||||
|
||||
0. Installer Ansible sur votre machine personnelle.
|
||||
|
||||
0. (bis) cloner le dépot sur votre machine personelle.
|
||||
|
||||
1. Copier le fichier `ansible/host_example`
|
||||
``` bash
|
||||
$ cp ansible/hosts_example ansible/hosts
|
||||
```
|
||||
et ajouter sous [dev] et/ou [prod] les serveurs sur lesquels vous souhaitez installer la note.
|
||||
2. Créer un fichier `ansible/host_vars/<note.nomdedomaine.tld.yaml>` sur le modèle des fichiers existants dans `ansible/hosts` et compléter les variables nécessaires.
|
||||
|
||||
3. lancer `ansible/base.yaml -l <nomdedomaine.tld.yaml>`
|
||||
4. Aller vous faire un café, ca peux durer un moment.
|
||||
|
||||
### Installation manuelle
|
||||
|
||||
**En production on souhaite absolument utiliser les modules Python packagées dans le gestionnaire de paquet.**
|
||||
Cela permet de mettre à jour facilement les dépendances critiques telles que Django.
|
||||
|
||||
L'installation d'une instance de production néccessite **une installation de Debian Buster ou d'Ubuntu 20.04**.
|
||||
|
||||
Pour aller vite vous pouvez lancer le Playbook Ansible fournit dans ce dépôt en l'adaptant.
|
||||
Sinon vous pouvez suivre les étapes décrites ci-dessous.
|
||||
|
||||
0. Sous Debian Buster, **activer Debian Backports.** En effet Django 2.2 LTS n'est que disponible dans les backports.
|
||||
@ -93,10 +117,10 @@ Sinon vous pouvez suivre les étapes décrites ci-dessous.
|
||||
$ sudo apt install --no-install-recommends -t buster-backports -y \
|
||||
python3-django python3-django-crispy-forms \
|
||||
python3-django-extensions python3-django-filters python3-django-polymorphic \
|
||||
python3-djangorestframework python3-django-cas-server python3-psycopg2 python3-pil \
|
||||
python3-babel python3-lockfile python3-pip python3-phonenumbers ipython3 \
|
||||
python3-bs4 python3-setuptools \
|
||||
uwsgi uwsgi-plugin-python3 \
|
||||
python3-djangorestframework python3-django-oauth-toolkit python3-psycopg2 python3-pil \
|
||||
python3-babel python3-lockfile python3-pip python3-phonenumbers python3-memcache ipython3 \
|
||||
python3-bs4 python3-setuptools python3-docutils \
|
||||
memcached uwsgi uwsgi-plugin-python3 \
|
||||
texlive-xetex gettext libjs-bootstrap4 fonts-font-awesome \
|
||||
nginx python3-venv git acl
|
||||
```
|
||||
@ -210,7 +234,13 @@ Sinon vous pouvez suivre les étapes décrites ci-dessous.
|
||||
(env)$ ./manage.py check # pas de bêtise qui traine
|
||||
(env)$ ./manage.py migrate
|
||||
|
||||
7. *Enjoy \o/*
|
||||
7. **Création d'une clé privée OpenID Connect**
|
||||
|
||||
Pour activer le support d'OpenID Connect, il faut générer une clé privée, par
|
||||
exemple avec openssl (`openssl genrsa -out oidc.key 4096`), et renseigner son
|
||||
emplacement dans `OIDC_RSA_PRIVATE_KEY` (par défaut `/var/secrets/oidc.key`).
|
||||
|
||||
8. *Enjoy \o/*
|
||||
|
||||
### Installation avec Docker
|
||||
|
||||
@ -261,20 +291,25 @@ Le cahier des charges initial est disponible [sur le Wiki Crans](https://wiki.cr
|
||||
La documentation des classes et fonctions est directement dans le code et est explorable à partir de la partie documentation de l'interface d'administration de Django.
|
||||
**Commentez votre code !**
|
||||
|
||||
La documentation plus haut niveau sur le développement est disponible sur [le Wiki associé au dépôt Git](https://gitlab.crans.org/bde/nk20/-/wikis/home).
|
||||
La documentation plus haut niveau sur le développement et sur l'utilisation
|
||||
est disponible sur <https://note.crans.org/doc> et également dans le dossier `docs`.
|
||||
|
||||
## FAQ
|
||||
|
||||
### Regénérer les fichiers de traduction
|
||||
|
||||
Pour regénérer les traductions vous pouvez vous placer à la racine du projet et lancer le script `makemessages`. Il faut penser à ignorer les dossiers ne contenant pas notre code, dont le virtualenv.
|
||||
Pour regénérer les traductions vous pouvez vous placer à la racine du projet et lancer le script `makemessages`.
|
||||
Il faut penser à ignorer les dossiers ne contenant pas notre code, dont le virtualenv.
|
||||
De plus, il faut aussi extraire les variables des fichiers JavaScript.
|
||||
|
||||
```bash
|
||||
django-admin makemessages -i env
|
||||
python3 manage.py makemessages -i env
|
||||
python3 manage.py makemessages -i env -e js -d djangojs
|
||||
```
|
||||
|
||||
Une fois les fichiers édités, vous pouvez compiler les nouvelles traductions avec
|
||||
|
||||
```bash
|
||||
django-admin compilemessages
|
||||
python3 manage.py compilemessages
|
||||
python3 manage.py compilejsmessages
|
||||
```
|
||||
|
@ -7,7 +7,7 @@
|
||||
prompt: "Password of the database (leave it blank to skip database init)"
|
||||
private: yes
|
||||
vars:
|
||||
mirror: deb.debian.org
|
||||
mirror: eclats.crans.org
|
||||
roles:
|
||||
- 1-apt-basic
|
||||
- 2-nk20
|
||||
@ -16,3 +16,4 @@
|
||||
- 5-nginx
|
||||
- 6-psql
|
||||
- 7-postinstall
|
||||
- 8-docs
|
||||
|
@ -1,5 +0,0 @@
|
||||
---
|
||||
note:
|
||||
server_name: note-beta.crans.org
|
||||
git_branch: beta
|
||||
cron_enabled: false
|
@ -2,4 +2,6 @@
|
||||
note:
|
||||
server_name: note-dev.crans.org
|
||||
git_branch: beta
|
||||
serve_static: false
|
||||
cron_enabled: false
|
||||
email: notekfet2020@lists.crans.org
|
@ -1,5 +1,7 @@
|
||||
---
|
||||
note:
|
||||
server_name: note.crans.org
|
||||
git_branch: master
|
||||
git_branch: main
|
||||
serve_static: true
|
||||
cron_enabled: true
|
||||
email: notekfet2020@lists.crans.org
|
||||
|
@ -1,6 +1,5 @@
|
||||
[dev]
|
||||
bde3-virt.adh.crans.org
|
||||
bde-nk20-beta.adh.crans.org
|
||||
bde-note-dev.adh.crans.org
|
||||
|
||||
[prod]
|
||||
bde-note.adh.crans.org
|
@ -1,13 +1,15 @@
|
||||
---
|
||||
- name: Add buster-backports to apt sources
|
||||
- name: Add buster-backports to apt sources if needed
|
||||
apt_repository:
|
||||
repo: deb http://{{ mirror }}/debian buster-backports main
|
||||
state: present
|
||||
when:
|
||||
- ansible_distribution == "Debian"
|
||||
- ansible_distribution_major_version | int == 10
|
||||
|
||||
- name: Install note_kfet APT dependencies
|
||||
apt:
|
||||
update_cache: true
|
||||
default_release: buster-backports
|
||||
install_recommends: false
|
||||
name:
|
||||
# Common tools
|
||||
@ -23,13 +25,14 @@
|
||||
- python3-babel
|
||||
- python3-bs4
|
||||
- python3-django
|
||||
- python3-django-cas-server
|
||||
- python3-django-crispy-forms
|
||||
- python3-django-extensions
|
||||
- python3-django-filters
|
||||
- python3-django-oauth-toolkit
|
||||
- python3-django-polymorphic
|
||||
- python3-djangorestframework
|
||||
- python3-lockfile
|
||||
- python3-memcache
|
||||
- python3-phonenumbers
|
||||
- python3-pil
|
||||
- python3-pip
|
||||
@ -40,6 +43,9 @@
|
||||
# LaTeX (PDF generation)
|
||||
- texlive-xetex
|
||||
|
||||
# Cache server
|
||||
- memcached
|
||||
|
||||
# WSGI server
|
||||
- uwsgi
|
||||
- uwsgi-plugin-python3
|
||||
|
@ -16,7 +16,7 @@
|
||||
|
||||
- name: Use default env vars (should be updated!)
|
||||
template:
|
||||
src: "env_example"
|
||||
src: "env.j2"
|
||||
dest: "/var/www/note_kfet/.env"
|
||||
mode: 0644
|
||||
force: false
|
||||
@ -36,3 +36,13 @@
|
||||
dest: /etc/cron.d/note
|
||||
owner: root
|
||||
group: root
|
||||
|
||||
- name: Set default directory to /var/www/note_kfet
|
||||
lineinfile:
|
||||
path: /etc/skel/.bashrc
|
||||
line: 'cd /var/www/note_kfet'
|
||||
|
||||
- name: Automatically source Python virtual environment
|
||||
lineinfile:
|
||||
path: /etc/skel/.bashrc
|
||||
line: 'source /var/www/note_kfet/env/bin/activate'
|
||||
|
23
ansible/roles/2-nk20/templates/env.j2
Normal file
23
ansible/roles/2-nk20/templates/env.j2
Normal file
@ -0,0 +1,23 @@
|
||||
DJANGO_APP_STAGE=prod
|
||||
# Only used in dev mode, change to "postgresql" if you want to use PostgreSQL in dev
|
||||
DJANGO_DEV_STORE_METHOD=sqlite
|
||||
DJANGO_DB_HOST=localhost
|
||||
DJANGO_DB_NAME=note_db
|
||||
DJANGO_DB_USER=note
|
||||
DJANGO_DB_PASSWORD={{ DB_PASSWORD }}
|
||||
DJANGO_DB_PORT=
|
||||
DJANGO_SECRET_KEY=CHANGE_ME
|
||||
DJANGO_SETTINGS_MODULE=note_kfet.settings
|
||||
CONTACT_EMAIL=tresorerie.bde@localhost
|
||||
NOTE_URL= {{note.server_name}}
|
||||
|
||||
# Config for mails. Only used in production
|
||||
NOTE_MAIL=notekfet@localhost
|
||||
EMAIL_HOST=smtp.localhost
|
||||
EMAIL_PORT=25
|
||||
EMAIL_USER=notekfet@localhost
|
||||
EMAIL_PASSWORD=CHANGE_ME
|
||||
|
||||
# Wiki configuration
|
||||
WIKI_USER=NoteKfet2020
|
||||
WIKI_PASSWORD=
|
@ -9,6 +9,11 @@
|
||||
retries: 3
|
||||
until: pkg_result is succeeded
|
||||
|
||||
- name: Check if certificate already exists.
|
||||
stat:
|
||||
path: /etc/letsencrypt/live/{{note.server_name}}/cert.pem
|
||||
register: letsencrypt_cert
|
||||
|
||||
- name: Create /etc/letsencrypt/conf.d
|
||||
file:
|
||||
path: /etc/letsencrypt/conf.d
|
||||
@ -19,3 +24,17 @@
|
||||
src: "letsencrypt/conf.d/nk20.ini.j2"
|
||||
dest: "/etc/letsencrypt/conf.d/nk20.ini"
|
||||
mode: 0644
|
||||
|
||||
- name: Stop services to allow certbot to generate a cert.
|
||||
service:
|
||||
name: nginx
|
||||
state: stopped
|
||||
|
||||
- name: Generate new certificate if one doesn't exist.
|
||||
shell: "certbot certonly --non-interactive --agree-tos --config /etc/letsencrypt/conf.d/nk20.ini -d {{note.server_name}}"
|
||||
when: letsencrypt_cert.stat.exists == False
|
||||
|
||||
- name: Restart services to allow certbot to generate a cert.
|
||||
service:
|
||||
name: nginx
|
||||
state: started
|
||||
|
@ -10,7 +10,7 @@ rsa-key-size = 4096
|
||||
# server = https://acme-staging.api.letsencrypt.org/directory
|
||||
|
||||
# Uncomment and update to register with the specified e-mail address
|
||||
email = notekfet2020@lists.crans.org
|
||||
email = {{ note.email }}
|
||||
|
||||
# Uncomment to use a text interface instead of ncurses
|
||||
text = True
|
||||
|
@ -1,5 +1,5 @@
|
||||
# the upstream component nginx needs to connect to
|
||||
upstream note{
|
||||
upstream note {
|
||||
server unix:///var/www/note_kfet/note_kfet.sock; # file socket
|
||||
}
|
||||
|
||||
@ -41,6 +41,7 @@ server {
|
||||
# max upload size
|
||||
client_max_body_size 75M; # adjust to taste
|
||||
|
||||
{% if note.serve_static %}
|
||||
# Django media
|
||||
location /media {
|
||||
alias /var/www/note_kfet/media; # your Django project's media files - amend as required
|
||||
@ -50,6 +51,11 @@ server {
|
||||
alias /var/www/note_kfet/static; # your Django project's static files - amend as required
|
||||
}
|
||||
|
||||
{% endif %}
|
||||
location /doc {
|
||||
alias /var/www/documentation; # The documentation of the project
|
||||
}
|
||||
|
||||
# Finally, send all non-media requests to the Django server.
|
||||
location / {
|
||||
uwsgi_pass note;
|
||||
|
@ -11,14 +11,14 @@
|
||||
until: pkg_result is succeeded
|
||||
|
||||
- name: Create role note
|
||||
when: "DB_PASSWORD|bool" # If the password is not defined, skip the installation
|
||||
when: DB_PASSWORD|length > 0 # If the password is not defined, skip the installation
|
||||
postgresql_user:
|
||||
name: note
|
||||
password: "{{ DB_PASSWORD }}"
|
||||
become_user: postgres
|
||||
|
||||
- name: Create NK20 database
|
||||
when: "DB_PASSWORD|bool"
|
||||
when: DB_PASSWORD|length >0
|
||||
postgresql_db:
|
||||
name: note_db
|
||||
owner: note
|
||||
|
@ -1,4 +1,10 @@
|
||||
---
|
||||
- name: Collect static files
|
||||
command: /var/www/note_kfet/env/bin/python manage.py collectstatic --noinput
|
||||
args:
|
||||
chdir: /var/www/note_kfet
|
||||
become_user: www-data
|
||||
|
||||
- name: Migrate Django database
|
||||
command: /var/www/note_kfet/env/bin/python manage.py migrate
|
||||
args:
|
||||
@ -11,14 +17,14 @@
|
||||
chdir: /var/www/note_kfet
|
||||
become_user: www-data
|
||||
|
||||
- name: Compile JavaScript messages
|
||||
command: /var/www/note_kfet/env/bin/python manage.py compilejsmessages
|
||||
args:
|
||||
chdir: /var/www/note_kfet
|
||||
become_user: www-data
|
||||
|
||||
- name: Install initial fixtures
|
||||
command: /var/www/note_kfet/env/bin/python manage.py loaddata initial
|
||||
args:
|
||||
chdir: /var/www/note_kfet
|
||||
become_user: postgres
|
||||
|
||||
- name: Collect static files
|
||||
command: /var/www/note_kfet/env/bin/python manage.py collectstatic --noinput
|
||||
args:
|
||||
chdir: /var/www/note_kfet
|
||||
become_user: www-data
|
||||
|
20
ansible/roles/8-docs/tasks/main.yml
Normal file
20
ansible/roles/8-docs/tasks/main.yml
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
- name: Install Sphinx and RTD theme
|
||||
pip:
|
||||
requirements: /var/www/note_kfet/docs/requirements.txt
|
||||
virtualenv: /var/www/note_kfet/env
|
||||
virtualenv_command: /usr/bin/python3 -m venv
|
||||
virtualenv_site_packages: true
|
||||
become_user: www-data
|
||||
|
||||
- name: Create documentation directory with good permissions
|
||||
file:
|
||||
path: /var/www/documentation
|
||||
state: directory
|
||||
owner: www-data
|
||||
group: www-data
|
||||
mode: u=rwx,g=rwxs,o=rx
|
||||
|
||||
- name: Build HTML documentation
|
||||
command: /var/www/note_kfet/env/bin/sphinx-build -b dirhtml /var/www/note_kfet/docs/ /var/www/documentation/
|
||||
become_user: www-data
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
default_app_config = 'activity.apps.ActivityConfig'
|
||||
|
@ -1,11 +1,11 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.contrib import admin
|
||||
from note_kfet.admin import admin_site
|
||||
|
||||
from .forms import GuestForm
|
||||
from .models import Activity, ActivityType, Entry, Guest
|
||||
from .models import Activity, ActivityType, Entry, Guest, Opener
|
||||
|
||||
|
||||
@admin.register(Activity, site=admin_site)
|
||||
@ -35,7 +35,7 @@ class GuestAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin customisation for Guest
|
||||
"""
|
||||
list_display = ('last_name', 'first_name', 'activity', 'inviter')
|
||||
list_display = ('last_name', 'first_name', 'school', 'activity', 'inviter')
|
||||
form = GuestForm
|
||||
|
||||
|
||||
@ -45,3 +45,11 @@ class EntryAdmin(admin.ModelAdmin):
|
||||
Admin customisation for Entry
|
||||
"""
|
||||
list_display = ('note', 'activity', 'time', 'guest')
|
||||
|
||||
|
||||
@admin.register(Opener, site=admin_site)
|
||||
class OpenerAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin customisation for Opener
|
||||
"""
|
||||
list_display = ('activity', 'opener')
|
||||
|
@ -1,9 +1,11 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
from rest_framework.validators import UniqueTogetherValidator
|
||||
|
||||
from ..models import Activity, ActivityType, Entry, Guest, GuestTransaction
|
||||
from ..models import Activity, ActivityType, Entry, Guest, GuestTransaction, Opener
|
||||
|
||||
|
||||
class ActivityTypeSerializer(serializers.ModelSerializer):
|
||||
@ -59,3 +61,17 @@ class GuestTransactionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = GuestTransaction
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class OpenerSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
REST API Serializer for Openers.
|
||||
The djangorestframework plugin will analyse the model `Opener` and parse all fields in the API.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = Opener
|
||||
fields = '__all__'
|
||||
validators = [UniqueTogetherValidator(
|
||||
queryset=Opener.objects.all(), fields=("opener", "activity"),
|
||||
message=_("This opener already exists"))]
|
||||
|
@ -1,7 +1,7 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from .views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet
|
||||
from .views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet, OpenerViewSet
|
||||
|
||||
|
||||
def register_activity_urls(router, path):
|
||||
@ -12,3 +12,4 @@ def register_activity_urls(router, path):
|
||||
router.register(path + '/type', ActivityTypeViewSet)
|
||||
router.register(path + '/guest', GuestViewSet)
|
||||
router.register(path + '/entry', EntryViewSet)
|
||||
router.register(path + '/opener', OpenerViewSet)
|
||||
|
@ -1,12 +1,15 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from api.filters import RegexSafeSearchFilter
|
||||
from api.viewsets import ReadProtectedModelViewSet
|
||||
from django.core.exceptions import ValidationError
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.filters import SearchFilter
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
from .serializers import ActivitySerializer, ActivityTypeSerializer, EntrySerializer, GuestSerializer
|
||||
from ..models import Activity, ActivityType, Entry, Guest
|
||||
from .serializers import ActivitySerializer, ActivityTypeSerializer, EntrySerializer, GuestSerializer, OpenerSerializer
|
||||
from ..models import Activity, ActivityType, Entry, Guest, Opener
|
||||
|
||||
|
||||
class ActivityTypeViewSet(ReadProtectedModelViewSet):
|
||||
@ -15,10 +18,10 @@ class ActivityTypeViewSet(ReadProtectedModelViewSet):
|
||||
The djangorestframework plugin will get all `ActivityType` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/activity/type/
|
||||
"""
|
||||
queryset = ActivityType.objects.all()
|
||||
queryset = ActivityType.objects.order_by('id')
|
||||
serializer_class = ActivityTypeSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = ['name', 'can_invite', ]
|
||||
filterset_fields = ['name', 'manage_entries', 'can_invite', 'guest_entry_fee', ]
|
||||
|
||||
|
||||
class ActivityViewSet(ReadProtectedModelViewSet):
|
||||
@ -27,10 +30,16 @@ class ActivityViewSet(ReadProtectedModelViewSet):
|
||||
The djangorestframework plugin will get all `Activity` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/activity/activity/
|
||||
"""
|
||||
queryset = Activity.objects.all()
|
||||
queryset = Activity.objects.order_by('id')
|
||||
serializer_class = ActivitySerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = ['name', 'description', 'activity_type', ]
|
||||
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||
filterset_fields = ['name', 'description', 'activity_type', 'location', 'creater', 'organizer', 'attendees_club',
|
||||
'date_start', 'date_end', 'valid', 'open', ]
|
||||
search_fields = ['$name', '$description', '$location', '$creater__last_name', '$creater__first_name',
|
||||
'$creater__email', '$creater__note__alias__name', '$creater__note__alias__normalized_name',
|
||||
'$organizer__name', '$organizer__email', '$organizer__note__alias__name',
|
||||
'$organizer__note__alias__normalized_name', '$attendees_club__name', '$attendees_club__email',
|
||||
'$attendees_club__note__alias__name', '$attendees_club__note__alias__normalized_name', ]
|
||||
|
||||
|
||||
class GuestViewSet(ReadProtectedModelViewSet):
|
||||
@ -39,10 +48,13 @@ class GuestViewSet(ReadProtectedModelViewSet):
|
||||
The djangorestframework plugin will get all `Guest` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/activity/guest/
|
||||
"""
|
||||
queryset = Guest.objects.all()
|
||||
queryset = Guest.objects.order_by('id')
|
||||
serializer_class = GuestSerializer
|
||||
filter_backends = [SearchFilter]
|
||||
search_fields = ['$last_name', '$first_name', '$inviter__alias__name', '$inviter__alias__normalized_name', ]
|
||||
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||
filterset_fields = ['activity', 'activity__name', 'last_name', 'first_name', 'school', 'inviter', 'inviter__alias__name',
|
||||
'inviter__alias__normalized_name', ]
|
||||
search_fields = ['$activity__name', '$last_name', '$first_name', '$school', '$inviter__user__email', '$inviter__alias__name',
|
||||
'$inviter__alias__normalized_name', ]
|
||||
|
||||
|
||||
class EntryViewSet(ReadProtectedModelViewSet):
|
||||
@ -51,7 +63,38 @@ class EntryViewSet(ReadProtectedModelViewSet):
|
||||
The djangorestframework plugin will get all `Entry` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/activity/entry/
|
||||
"""
|
||||
queryset = Entry.objects.all()
|
||||
queryset = Entry.objects.order_by('id')
|
||||
serializer_class = EntrySerializer
|
||||
filter_backends = [SearchFilter]
|
||||
search_fields = ['$last_name', '$first_name', '$inviter__alias__name', '$inviter__alias__normalized_name', ]
|
||||
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||
filterset_fields = ['activity', 'time', 'note', 'guest', ]
|
||||
search_fields = ['$activity__name', '$note__user__email', '$note__alias__name', '$note__alias__normalized_name',
|
||||
'$guest__last_name', '$guest__first_name', ]
|
||||
|
||||
|
||||
class OpenerViewSet(ReadProtectedModelViewSet):
|
||||
"""
|
||||
REST Opener View set.
|
||||
The djangorestframework plugin will get all `Opener` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/activity/opener/
|
||||
"""
|
||||
queryset = Opener.objects
|
||||
serializer_class = OpenerSerializer
|
||||
filter_backends = [RegexSafeSearchFilter, DjangoFilterBackend]
|
||||
search_fields = ['$opener__alias__name', '$opener__alias__normalized_name',
|
||||
'$activity__name']
|
||||
filterset_fields = ['opener', 'opener__noteuser__user', 'activity']
|
||||
|
||||
def get_serializer_class(self):
|
||||
serializer_class = self.serializer_class
|
||||
if self.request.method in ['PUT', 'PATCH']:
|
||||
# opener-activity can't change
|
||||
serializer_class.Meta.read_only_fields = ('opener', 'acitivity',)
|
||||
return serializer_class
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
try:
|
||||
self.perform_destroy(instance)
|
||||
except ValidationError as e:
|
||||
return Response({e.code: str(e)}, status.HTTP_400_BAD_REQUEST)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
@ -6,7 +6,7 @@
|
||||
"name": "Pot",
|
||||
"manage_entries": true,
|
||||
"can_invite": true,
|
||||
"guest_entry_fee": 500
|
||||
"guest_entry_fee": 1000
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -28,5 +28,25 @@
|
||||
"can_invite": false,
|
||||
"guest_entry_fee": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "activity.activitytype",
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"name": "Soir\u00e9e avec entrées",
|
||||
"manage_entries": true,
|
||||
"can_invite": false,
|
||||
"guest_entry_fee": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "activity.activitytype",
|
||||
"pk": 7,
|
||||
"fields": {
|
||||
"name": "Soir\u00e9e avec invitations",
|
||||
"manage_entries": true,
|
||||
"can_invite": true,
|
||||
"guest_entry_fee": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
|
@ -1,17 +1,18 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from datetime import timedelta
|
||||
from random import shuffle
|
||||
|
||||
from bootstrap_datepicker_plus.widgets import DateTimePickerInput
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from member.models import Club
|
||||
from note.models import Note, NoteUser
|
||||
from note_kfet.inputs import Autocomplete, DateTimePickerInput
|
||||
from note_kfet.middlewares import get_current_authenticated_user
|
||||
from note_kfet.inputs import Autocomplete
|
||||
from note_kfet.middlewares import get_current_request
|
||||
from permission.backends import PermissionBackend
|
||||
|
||||
from .models import Activity, Guest
|
||||
@ -24,10 +25,16 @@ class ActivityForm(forms.ModelForm):
|
||||
self.fields["attendees_club"].initial = Club.objects.get(name="Kfet")
|
||||
self.fields["attendees_club"].widget.attrs["placeholder"] = "Kfet"
|
||||
clubs = list(Club.objects.filter(PermissionBackend
|
||||
.filter_queryset(get_current_authenticated_user(), Club, "view")).all())
|
||||
.filter_queryset(get_current_request(), Club, "view")).all())
|
||||
shuffle(clubs)
|
||||
self.fields["organizer"].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
|
||||
|
||||
def clean_organizer(self):
|
||||
organizer = self.cleaned_data['organizer']
|
||||
if not organizer.note.is_active:
|
||||
self.add_error('organiser', _('The note of this club is inactive.'))
|
||||
return organizer
|
||||
|
||||
def clean_date_end(self):
|
||||
date_end = self.cleaned_data["date_end"]
|
||||
date_start = self.cleaned_data["date_start"]
|
||||
@ -37,7 +44,7 @@ class ActivityForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Activity
|
||||
exclude = ('creater', 'valid', 'open', )
|
||||
exclude = ('creater', 'valid', 'open', 'opener', )
|
||||
widgets = {
|
||||
"organizer": Autocomplete(
|
||||
model=Club,
|
||||
@ -100,7 +107,7 @@ class GuestForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Guest
|
||||
fields = ('last_name', 'first_name', 'inviter', )
|
||||
fields = ('last_name', 'first_name', 'school', 'inviter', )
|
||||
widgets = {
|
||||
"inviter": Autocomplete(
|
||||
NoteUser,
|
||||
|
18
apps/activity/migrations/0003_auto_20240323_1422.py
Normal file
18
apps/activity/migrations/0003_auto_20240323_1422.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.28 on 2024-03-23 13:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('activity', '0002_auto_20200904_2341'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='activity',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, default='', verbose_name='description'),
|
||||
),
|
||||
]
|
28
apps/activity/migrations/0004_opener.py
Normal file
28
apps/activity/migrations/0004_opener.py
Normal file
@ -0,0 +1,28 @@
|
||||
# Generated by Django 2.2.28 on 2024-08-01 12:36
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('note', '0006_trust'),
|
||||
('activity', '0003_auto_20240323_1422'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Opener',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='opener', to='activity.Activity', verbose_name='activity')),
|
||||
('opener', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_responsible', to='note.Note', verbose_name='opener')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'opener',
|
||||
'verbose_name_plural': 'openers',
|
||||
'unique_together': {('opener', 'activity')},
|
||||
},
|
||||
),
|
||||
]
|
@ -0,0 +1,24 @@
|
||||
# Generated by Django 4.2.15 on 2024-08-28 08:00
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('note', '0006_trust'),
|
||||
('activity', '0004_opener'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='opener',
|
||||
options={'verbose_name': 'Opener', 'verbose_name_plural': 'Openers'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='opener',
|
||||
name='opener',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_responsible', to='note.note', verbose_name='Opener'),
|
||||
),
|
||||
]
|
18
apps/activity/migrations/0006_guest_school.py
Normal file
18
apps/activity/migrations/0006_guest_school.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.20 on 2025-03-25 09:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("activity", "0005_alter_opener_options_alter_opener_opener"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="guest",
|
||||
name="school",
|
||||
field=models.CharField(default="", max_length=255, verbose_name="school"),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import os
|
||||
@ -11,7 +11,7 @@ from django.db import models, transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from note.models import NoteUser, Transaction
|
||||
from note.models import NoteUser, Transaction, Note
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
|
||||
@ -66,6 +66,8 @@ class Activity(models.Model):
|
||||
|
||||
description = models.TextField(
|
||||
verbose_name=_('description'),
|
||||
blank=True,
|
||||
default="",
|
||||
)
|
||||
|
||||
location = models.CharField(
|
||||
@ -123,6 +125,14 @@ class Activity(models.Model):
|
||||
verbose_name=_('open'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("activity")
|
||||
verbose_name_plural = _("activities")
|
||||
unique_together = ("name", "date_start", "date_end",)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@transaction.atomic
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
@ -144,14 +154,6 @@ class Activity(models.Model):
|
||||
if settings.DATABASES["default"]["ENGINE"] == 'django.db.backends.postgresql' else refresh_activities()
|
||||
return ret
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("activity")
|
||||
verbose_name_plural = _("activities")
|
||||
unique_together = ("name", "date_start", "date_end",)
|
||||
|
||||
|
||||
class Entry(models.Model):
|
||||
"""
|
||||
@ -199,7 +201,8 @@ class Entry(models.Model):
|
||||
def save(self, *args, **kwargs):
|
||||
qs = Entry.objects.filter(~Q(pk=self.pk), activity=self.activity, note=self.note, guest=self.guest)
|
||||
if qs.exists():
|
||||
raise ValidationError(_("Already entered on ") + _("{:%Y-%m-%d %H:%M:%S}").format(qs.get().time, ))
|
||||
raise ValidationError(_("Already entered on ")
|
||||
+ _("{:%Y-%m-%d %H:%M:%S}").format(timezone.localtime(qs.get().time), ))
|
||||
|
||||
if self.guest:
|
||||
self.note = self.guest.inviter
|
||||
@ -245,6 +248,11 @@ class Guest(models.Model):
|
||||
verbose_name=_("first name"),
|
||||
)
|
||||
|
||||
school = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("school"),
|
||||
)
|
||||
|
||||
inviter = models.ForeignKey(
|
||||
NoteUser,
|
||||
on_delete=models.PROTECT,
|
||||
@ -252,14 +260,13 @@ class Guest(models.Model):
|
||||
verbose_name=_("inviter"),
|
||||
)
|
||||
|
||||
@property
|
||||
def has_entry(self):
|
||||
try:
|
||||
if self.entry:
|
||||
return True
|
||||
return False
|
||||
except AttributeError:
|
||||
return False
|
||||
class Meta:
|
||||
verbose_name = _("guest")
|
||||
verbose_name_plural = _("guests")
|
||||
unique_together = ("activity", "last_name", "first_name", )
|
||||
|
||||
def __str__(self):
|
||||
return self.first_name + " " + self.last_name
|
||||
|
||||
@transaction.atomic
|
||||
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
|
||||
@ -290,13 +297,14 @@ class Guest(models.Model):
|
||||
|
||||
return super().save(force_insert, force_update, using, update_fields)
|
||||
|
||||
def __str__(self):
|
||||
return self.first_name + " " + self.last_name
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("guest")
|
||||
verbose_name_plural = _("guests")
|
||||
unique_together = ("activity", "last_name", "first_name", )
|
||||
@property
|
||||
def has_entry(self):
|
||||
try:
|
||||
if self.entry:
|
||||
return True
|
||||
return False
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
|
||||
class GuestTransaction(Transaction):
|
||||
@ -308,3 +316,31 @@ class GuestTransaction(Transaction):
|
||||
@property
|
||||
def type(self):
|
||||
return _('Invitation')
|
||||
|
||||
|
||||
class Opener(models.Model):
|
||||
"""
|
||||
Allow the user to make activity entries without more rights
|
||||
"""
|
||||
activity = models.ForeignKey(
|
||||
Activity,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='opener',
|
||||
verbose_name=_('activity')
|
||||
)
|
||||
|
||||
opener = models.ForeignKey(
|
||||
Note,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='activity_responsible',
|
||||
verbose_name=_('Opener')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Opener")
|
||||
verbose_name_plural = _("Openers")
|
||||
unique_together = ("opener", "activity")
|
||||
|
||||
def __str__(self):
|
||||
return _("{opener} is opener of activity {acivity}").format(
|
||||
opener=str(self.opener), acivity=str(self.activity))
|
||||
|
57
apps/activity/static/activity/js/opener.js
Normal file
57
apps/activity/static/activity/js/opener.js
Normal file
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* On form submit, add a new opener
|
||||
*/
|
||||
function form_create_opener (e) {
|
||||
// Do not submit HTML form
|
||||
e.preventDefault()
|
||||
|
||||
// Get data and send to API
|
||||
const formData = new FormData(e.target)
|
||||
$.getJSON('/api/note/alias/'+formData.get('opener') + '/',
|
||||
function (opener_alias) {
|
||||
create_opener(formData.get('activity'), opener_alias.note)
|
||||
}).fail(function (xhr, _textStatus, _error) {
|
||||
errMsg(xhr.responseJSON)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an opener between an activity and a user
|
||||
* @param activity:Integer activity id
|
||||
* @param opener:Integer user note id
|
||||
*/
|
||||
function create_opener(activity, opener) {
|
||||
$.post('/api/activity/opener/', {
|
||||
activity: activity,
|
||||
opener: opener,
|
||||
csrfmiddlewaretoken: CSRF_TOKEN
|
||||
}).done(function () {
|
||||
// Reload tables
|
||||
$('#opener_table').load(location.pathname + ' #opener_table')
|
||||
addMsg(gettext('Opener successfully added'), 'success')
|
||||
}).fail(function (xhr, _textStatus, _error) {
|
||||
errMsg(xhr.responseJSON)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* On click of "delete", delete the opener
|
||||
* @param button_id:Integer Opener id to remove
|
||||
*/
|
||||
function delete_button (button_id) {
|
||||
$.ajax({
|
||||
url: '/api/activity/opener/' + button_id + '/',
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRFTOKEN': CSRF_TOKEN }
|
||||
}).done(function () {
|
||||
addMsg(gettext('Opener successfully deleted'), 'success')
|
||||
$('#opener_table').load(location.pathname + ' #opener_table')
|
||||
}).fail(function (xhr, _textStatus, _error) {
|
||||
errMsg(xhr.responseJSON)
|
||||
})
|
||||
}
|
||||
|
||||
$(document).ready(function () {
|
||||
// Attach event
|
||||
document.getElementById('form_opener').addEventListener('submit', form_create_opener)
|
||||
})
|
@ -1,13 +1,17 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.html import format_html
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from note_kfet.middlewares import get_current_request
|
||||
import django_tables2 as tables
|
||||
from django_tables2 import A
|
||||
from permission.backends import PermissionBackend
|
||||
from note.templatetags.pretty_money import pretty_money
|
||||
|
||||
from .models import Activity, Entry, Guest
|
||||
from .models import Activity, Entry, Guest, Opener
|
||||
|
||||
|
||||
class ActivityTable(tables.Table):
|
||||
@ -47,13 +51,13 @@ class GuestTable(tables.Table):
|
||||
}
|
||||
model = Guest
|
||||
template_name = 'django_tables2/bootstrap4.html'
|
||||
fields = ("last_name", "first_name", "inviter", )
|
||||
fields = ("last_name", "first_name", "inviter", "school")
|
||||
|
||||
def render_entry(self, record):
|
||||
if record.has_entry:
|
||||
return str(_("Entered on ") + str(_("{:%Y-%m-%d %H:%M:%S}").format(record.entry.time, )))
|
||||
return format_html('<button id="{id}" class="btn btn-danger btn-sm" onclick="remove_guest(this.id)"> '
|
||||
'{delete_trans}</button>'.format(id=record.id, delete_trans=_("remove").capitalize()))
|
||||
return str(_("Entered on ") + str(_("{:%Y-%m-%d %H:%M:%S}").format(timezone.localtime(record.entry.time))))
|
||||
return mark_safe('<button id="{id}" class="btn btn-danger btn-sm" onclick="remove_guest(this.id)"> '
|
||||
'{delete_trans}</button>'.format(id=record.id, delete_trans=_("remove").capitalize()))
|
||||
|
||||
|
||||
def get_row_class(record):
|
||||
@ -91,7 +95,7 @@ class EntryTable(tables.Table):
|
||||
if hasattr(record, 'username'):
|
||||
username = record.username
|
||||
if username != value:
|
||||
return format_html(value + " <em>aka.</em> " + username)
|
||||
return mark_safe(escape(value) + " <em>aka.</em> " + escape(username))
|
||||
return value
|
||||
|
||||
def render_balance(self, value):
|
||||
@ -111,3 +115,34 @@ class EntryTable(tables.Table):
|
||||
'data-last-name': lambda record: record.last_name,
|
||||
'data-first-name': lambda record: record.first_name,
|
||||
}
|
||||
|
||||
|
||||
# function delete_button(id) provided in template file
|
||||
DELETE_TEMPLATE = """
|
||||
<button id="{{ record.pk }}" class="btn btn-danger btn-sm" onclick="delete_button(this.id)"> {{ delete_trans }}</button>
|
||||
"""
|
||||
|
||||
|
||||
class OpenerTable(tables.Table):
|
||||
class Meta:
|
||||
attrs = {
|
||||
'class': 'table table condensed table-striped',
|
||||
'id': "opener_table"
|
||||
}
|
||||
model = Opener
|
||||
fields = ("opener",)
|
||||
template_name = 'django_tables2/bootstrap4.html'
|
||||
|
||||
show_header = False
|
||||
opener = tables.Column(attrs={'td': {'class': 'text-center'}})
|
||||
|
||||
delete_col = tables.TemplateColumn(
|
||||
template_code=DELETE_TEMPLATE,
|
||||
extra_context={"delete_trans": _('Delete')},
|
||||
attrs={
|
||||
'td': {
|
||||
'class': lambda record: 'col-sm-1'
|
||||
+ (' d-none' if not PermissionBackend.check_perm(
|
||||
get_current_request(), "activity.delete_opener", record)
|
||||
else '')}},
|
||||
verbose_name=_("Delete"),)
|
||||
|
@ -4,11 +4,31 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load i18n perms %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load static django_tables2 i18n %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="text-white">{{ title }}</h1>
|
||||
{% include "activity/includes/activity_info.html" %}
|
||||
|
||||
{% if activity.activity_type.manage_entries and ".change__opener"|has_perm:activity %}
|
||||
<div class="card bg-white mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{% trans "Openers" %}
|
||||
</h3>
|
||||
<div class="card-body">
|
||||
<form class="input-group" method="POST" id="form_opener">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="activity" value="{{ object.pk }}">
|
||||
{%include "autocomplete_model.html" %}
|
||||
<div class="input-group-append">
|
||||
<input type="submit" class="btn btn-success" value="{% trans "Add" %}">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% render_table opener %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if guests.data %}
|
||||
<div class="card bg-white mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
@ -22,6 +42,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
<script src="{% static "activity/js/opener.js" %}"></script>
|
||||
<script src="{% static "js/autocomplete_model.js" %}"></script>
|
||||
<script>
|
||||
function remove_guest(guest_id) {
|
||||
$.ajax({
|
||||
@ -30,7 +52,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
headers: {"X-CSRFTOKEN": CSRF_TOKEN}
|
||||
})
|
||||
.done(function() {
|
||||
addMsg('Invité supprimé','success');
|
||||
addMsg('{% trans "Guest deleted" %}', 'success');
|
||||
$("#guests_table").load(location.pathname + " #guests_table");
|
||||
})
|
||||
.fail(function(xhr, textStatus, error) {
|
||||
|
@ -63,7 +63,12 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
refreshBalance();
|
||||
}
|
||||
|
||||
alias_obj.keyup(reloadTable);
|
||||
alias_obj.keyup(function(event) {
|
||||
let code = event.originalEvent.keyCode
|
||||
if (65 <= code <= 122 || code === 13) {
|
||||
debounce(reloadTable)()
|
||||
}
|
||||
});
|
||||
|
||||
$(document).ready(init);
|
||||
|
||||
@ -86,10 +91,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
}).done(function () {
|
||||
if (target.hasClass("table-info"))
|
||||
addMsg(
|
||||
"Entrée effectuée, mais attention : la personne n'est plus adhérente Kfet.",
|
||||
"{% trans "Entry done, but caution: the user is not a Kfet member." %}",
|
||||
"warning", 10000);
|
||||
else
|
||||
addMsg("Entrée effectuée !", "success", 4000);
|
||||
addMsg("Entry made!", "success", 4000);
|
||||
reloadTable(true);
|
||||
}).fail(function (xhr) {
|
||||
errMsg(xhr.responseJSON, 4000);
|
||||
@ -121,10 +126,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
}).done(function () {
|
||||
if (target.hasClass("table-info"))
|
||||
addMsg(
|
||||
"Entrée effectuée, mais attention : la personne n'est plus adhérente Kfet.",
|
||||
"{% trans "Entry done, but caution: the user is not a Kfet member." %}",
|
||||
"warning", 10000);
|
||||
else
|
||||
addMsg("Entrée effectuée !", "success", 4000);
|
||||
addMsg("{% trans "Entry done!" %}", "success", 4000);
|
||||
reloadTable(true);
|
||||
}).fail(function (xhr) {
|
||||
errMsg(xhr.responseJSON, 4000);
|
||||
|
@ -17,4 +17,27 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
<script>
|
||||
var date_end = document.getElementById("id_date_end");
|
||||
var date_start = document.getElementById("id_date_start");
|
||||
|
||||
function update_date_end (){
|
||||
if(date_end.value=="" || date_end.value<date_start.value){
|
||||
date_end.value = date_start.value;
|
||||
};
|
||||
};
|
||||
|
||||
function update_date_start (){
|
||||
if(date_start.value=="" || date_end.value<date_start.value){
|
||||
date_start.value = date_end.value;
|
||||
};
|
||||
};
|
||||
|
||||
date_start.addEventListener('focusout', update_date_end);
|
||||
date_end.addEventListener('focusout', update_date_start);
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
@ -46,4 +46,4 @@ SPDX-License-Identifier: GPL-3.0-or-later
|
||||
</h3>
|
||||
{% render_table table %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
@ -1,15 +1,18 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from api.tests import TestAPI
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from activity.models import Activity, ActivityType, Guest, Entry
|
||||
from member.models import Club
|
||||
|
||||
from ..api.views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet
|
||||
from ..models import Activity, ActivityType, Guest, Entry
|
||||
|
||||
|
||||
class TestActivities(TestCase):
|
||||
"""
|
||||
@ -47,6 +50,7 @@ class TestActivities(TestCase):
|
||||
inviter=self.user.note,
|
||||
last_name="GUEST",
|
||||
first_name="Guest",
|
||||
school="School",
|
||||
)
|
||||
|
||||
def test_activity_list(self):
|
||||
@ -153,6 +157,7 @@ class TestActivities(TestCase):
|
||||
inviter=self.user.note.id,
|
||||
last_name="GUEST2",
|
||||
first_name="Guest",
|
||||
school="School",
|
||||
))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@ -164,6 +169,7 @@ class TestActivities(TestCase):
|
||||
inviter=self.user.note.id,
|
||||
last_name="GUEST2",
|
||||
first_name="Guest",
|
||||
school="School",
|
||||
))
|
||||
self.assertRedirects(response, reverse("activity:activity_detail", args=(self.activity.pk,)), 302, 200)
|
||||
|
||||
@ -173,3 +179,59 @@ class TestActivities(TestCase):
|
||||
"""
|
||||
response = self.client.get(reverse("activity:calendar_ics"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class TestActivityAPI(TestAPI):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.activity = Activity.objects.create(
|
||||
name="Activity",
|
||||
description="This is a test activity\non two very very long lines\nbecause this is very important.",
|
||||
location="Earth",
|
||||
activity_type=ActivityType.objects.get(name="Pot"),
|
||||
creater=self.user,
|
||||
organizer=Club.objects.get(name="Kfet"),
|
||||
attendees_club=Club.objects.get(name="Kfet"),
|
||||
date_start=timezone.now(),
|
||||
date_end=timezone.now() + timedelta(days=2),
|
||||
valid=True,
|
||||
)
|
||||
|
||||
self.guest = Guest.objects.create(
|
||||
activity=self.activity,
|
||||
inviter=self.user.note,
|
||||
last_name="GUEST",
|
||||
first_name="Guest",
|
||||
school="School",
|
||||
)
|
||||
|
||||
self.entry = Entry.objects.create(
|
||||
activity=self.activity,
|
||||
note=self.user.note,
|
||||
guest=self.guest,
|
||||
)
|
||||
|
||||
def test_activity_api(self):
|
||||
"""
|
||||
Load Activity API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(ActivityViewSet, "/api/activity/activity/")
|
||||
|
||||
def test_activity_type_api(self):
|
||||
"""
|
||||
Load ActivityType API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(ActivityTypeViewSet, "/api/activity/type/")
|
||||
|
||||
def test_entry_api(self):
|
||||
"""
|
||||
Load Entry API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(EntryViewSet, "/api/activity/entry/")
|
||||
|
||||
def test_guest_api(self):
|
||||
"""
|
||||
Load Guest API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(GuestViewSet, "/api/activity/guest/")
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.urls import path
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from hashlib import md5
|
||||
@ -12,17 +12,21 @@ from django.db.models import F, Q
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.decorators.cache import cache_page
|
||||
from django.views.generic import DetailView, TemplateView, UpdateView
|
||||
from django_tables2.views import SingleTableView
|
||||
from django.views.generic.list import ListView
|
||||
from django_tables2.views import MultiTableMixin, SingleTableMixin
|
||||
from api.viewsets import is_regex
|
||||
from note.models import Alias, NoteSpecial, NoteUser
|
||||
from permission.backends import PermissionBackend
|
||||
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
|
||||
|
||||
from .forms import ActivityForm, GuestForm
|
||||
from .models import Activity, Entry, Guest
|
||||
from .tables import ActivityTable, EntryTable, GuestTable
|
||||
from .models import Activity, Entry, Guest, Opener
|
||||
from .tables import ActivityTable, EntryTable, GuestTable, OpenerTable
|
||||
|
||||
|
||||
class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
@ -55,36 +59,44 @@ class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
return reverse_lazy('activity:activity_detail', kwargs={"pk": self.object.pk})
|
||||
|
||||
|
||||
class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView):
|
||||
class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
|
||||
"""
|
||||
Displays all Activities, and classify if they are on-going or upcoming ones.
|
||||
"""
|
||||
model = Activity
|
||||
table_class = ActivityTable
|
||||
ordering = ('-date_start',)
|
||||
tables = [
|
||||
lambda data: ActivityTable(data, prefix="all-"),
|
||||
lambda data: ActivityTable(data, prefix="upcoming-"),
|
||||
]
|
||||
extra_context = {"title": _("Activities")}
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().distinct()
|
||||
def get_queryset(self, **kwargs):
|
||||
return super().get_queryset(**kwargs).distinct()
|
||||
|
||||
def get_tables_data(self):
|
||||
# first table = all activities, second table = upcoming
|
||||
return [
|
||||
self.get_queryset().order_by("-date_start"),
|
||||
Activity.objects.filter(date_end__gt=timezone.now())
|
||||
.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))
|
||||
.distinct()
|
||||
.order_by("date_start")
|
||||
]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
upcoming_activities = Activity.objects.filter(date_end__gt=timezone.now())
|
||||
context['upcoming'] = ActivityTable(
|
||||
data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")),
|
||||
prefix='upcoming-',
|
||||
)
|
||||
tables = context["tables"]
|
||||
for name, table in zip(["table", "upcoming"], tables):
|
||||
context[name] = table
|
||||
|
||||
started_activities = Activity.objects\
|
||||
.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
|
||||
.filter(open=True, valid=True).all()
|
||||
started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all()
|
||||
context["started_activities"] = started_activities
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, DetailView):
|
||||
"""
|
||||
Shows details about one activity. Add guest to context
|
||||
"""
|
||||
@ -92,15 +104,40 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
context_object_name = "activity"
|
||||
extra_context = {"title": _("Activity detail")}
|
||||
|
||||
tables = [
|
||||
lambda data: GuestTable(data, prefix="guests-"),
|
||||
lambda data: OpenerTable(data, prefix="opener-"),
|
||||
]
|
||||
|
||||
def get_tables_data(self):
|
||||
return [
|
||||
Guest.objects.filter(activity=self.object)
|
||||
.filter(PermissionBackend.filter_queryset(self.request, Guest, "view")),
|
||||
self.object.opener.filter(activity=self.object)
|
||||
.filter(PermissionBackend.filter_queryset(self.request, Opener, "view")),
|
||||
]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data()
|
||||
|
||||
table = GuestTable(data=Guest.objects.filter(activity=self.object)
|
||||
.filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view")))
|
||||
context["guests"] = table
|
||||
tables = context["tables"]
|
||||
for name, table in zip(["guests", "opener"], tables):
|
||||
context[name] = table
|
||||
|
||||
context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start)
|
||||
|
||||
context["widget"] = {
|
||||
"name": "opener",
|
||||
"resetable": True,
|
||||
"attrs": {
|
||||
"class": "autocomplete form-control",
|
||||
"id": "opener",
|
||||
"api_url": "/api/note/alias/?note__polymorphic_ctype__model=noteuser",
|
||||
"name_field": "name",
|
||||
"placeholder": ""
|
||||
}
|
||||
}
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@ -131,6 +168,7 @@ class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
activity=activity,
|
||||
first_name="",
|
||||
last_name="",
|
||||
school="",
|
||||
inviter=self.request.user.note,
|
||||
)
|
||||
|
||||
@ -142,36 +180,41 @@ class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
|
||||
def get_form(self, form_class=None):
|
||||
form = super().get_form(form_class)
|
||||
form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
|
||||
.get(pk=self.kwargs["pk"])
|
||||
form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
|
||||
.filter(pk=self.kwargs["pk"]).first()
|
||||
form.fields["inviter"].initial = self.request.user.note
|
||||
return form
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
form.instance.activity = Activity.objects\
|
||||
.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")).get(pk=self.kwargs["pk"])
|
||||
.filter(PermissionBackend.filter_queryset(self.request, Activity, "view")).get(pk=self.kwargs["pk"])
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]})
|
||||
|
||||
|
||||
class ActivityEntryView(LoginRequiredMixin, TemplateView):
|
||||
class ActivityEntryView(LoginRequiredMixin, SingleTableMixin, TemplateView):
|
||||
"""
|
||||
Manages entry to an activity
|
||||
"""
|
||||
template_name = "activity/activity_entry.html"
|
||||
|
||||
table_class = EntryTable
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""
|
||||
Don't display the entry interface if the user has no right to see it (no right to add an entry for itself),
|
||||
it is closed or doesn't manage entries.
|
||||
"""
|
||||
if not self.request.user.is_authenticated:
|
||||
return self.handle_no_permission()
|
||||
|
||||
activity = Activity.objects.get(pk=self.kwargs["pk"])
|
||||
|
||||
sample_entry = Entry(activity=activity, note=self.request.user.note)
|
||||
if not PermissionBackend.check_perm(self.request.user, "activity.add_entry", sample_entry):
|
||||
if not PermissionBackend.check_perm(self.request, "activity.add_entry", sample_entry):
|
||||
raise PermissionDenied(_("You are not allowed to display the entry interface for this activity."))
|
||||
|
||||
if not activity.activity_type.manage_entries:
|
||||
@ -189,22 +232,25 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
|
||||
guest_qs = Guest.objects\
|
||||
.annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\
|
||||
.filter(activity=activity)\
|
||||
.filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))\
|
||||
.order_by('last_name', 'first_name').distinct()
|
||||
.filter(PermissionBackend.filter_queryset(self.request, Guest, "view"))\
|
||||
.order_by('last_name', 'first_name')
|
||||
|
||||
if "search" in self.request.GET and self.request.GET["search"]:
|
||||
pattern = self.request.GET["search"]
|
||||
if pattern[0] != "^":
|
||||
pattern = "^" + pattern
|
||||
|
||||
# Check if this is a valid regex. If not, we won't check regex
|
||||
valid_regex = is_regex(pattern)
|
||||
suffix = "__iregex" if valid_regex else "__istartswith"
|
||||
pattern = "^" + pattern if valid_regex and pattern[0] != "^" else pattern
|
||||
guest_qs = guest_qs.filter(
|
||||
Q(first_name__iregex=pattern)
|
||||
| Q(last_name__iregex=pattern)
|
||||
| Q(inviter__alias__name__iregex=pattern)
|
||||
| Q(inviter__alias__normalized_name__iregex=Alias.normalize(pattern))
|
||||
Q(**{f"first_name{suffix}": pattern})
|
||||
| Q(**{f"last_name{suffix}": pattern})
|
||||
| Q(**{f"inviter__alias__name{suffix}": pattern})
|
||||
| Q(**{f"inviter__alias__normalized_name{suffix}": Alias.normalize(pattern)})
|
||||
)
|
||||
else:
|
||||
guest_qs = guest_qs.none()
|
||||
return guest_qs
|
||||
return guest_qs.distinct()
|
||||
|
||||
def get_invited_note(self, activity):
|
||||
"""
|
||||
@ -220,23 +266,26 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
|
||||
# Keep only users that have a note
|
||||
note_qs = note_qs.filter(note__noteuser__isnull=False)
|
||||
|
||||
# Keep only members
|
||||
# Keep only valid members
|
||||
note_qs = note_qs.filter(
|
||||
note__noteuser__user__memberships__club=activity.attendees_club,
|
||||
note__noteuser__user__memberships__date_start__lte=timezone.now(),
|
||||
note__noteuser__user__memberships__date_end__gte=timezone.now(),
|
||||
)
|
||||
note__noteuser__user__memberships__date_end__gte=timezone.now()).exclude(note__inactivity_reason='forced')
|
||||
|
||||
# Filter with permission backend
|
||||
note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view"))
|
||||
note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request, Alias, "view"))
|
||||
|
||||
if "search" in self.request.GET and self.request.GET["search"]:
|
||||
pattern = self.request.GET["search"]
|
||||
|
||||
# Check if this is a valid regex. If not, we won't check regex
|
||||
valid_regex = is_regex(pattern)
|
||||
suffix = "__iregex" if valid_regex else "__icontains"
|
||||
note_qs = note_qs.filter(
|
||||
Q(note__noteuser__user__first_name__iregex=pattern)
|
||||
| Q(note__noteuser__user__last_name__iregex=pattern)
|
||||
| Q(name__iregex=pattern)
|
||||
| Q(normalized_name__iregex=Alias.normalize(pattern))
|
||||
Q(**{f"note__noteuser__user__first_name{suffix}": pattern})
|
||||
| Q(**{f"note__noteuser__user__last_name{suffix}": pattern})
|
||||
| Q(**{f"name{suffix}": pattern})
|
||||
| Q(**{f"normalized_name{suffix}": Alias.normalize(pattern)})
|
||||
)
|
||||
else:
|
||||
note_qs = note_qs.none()
|
||||
@ -248,15 +297,9 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
|
||||
if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql' else note_qs.distinct()[:20]
|
||||
return note_qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""
|
||||
Query the list of Guest and Note to the activity and add information to makes entry with JS.
|
||||
"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
|
||||
def get_table_data(self):
|
||||
activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
|
||||
.distinct().get(pk=self.kwargs["pk"])
|
||||
context["activity"] = activity
|
||||
|
||||
matched = []
|
||||
|
||||
@ -269,8 +312,17 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
|
||||
note.activity = activity
|
||||
matched.append(note)
|
||||
|
||||
table = EntryTable(data=matched)
|
||||
context["table"] = table
|
||||
return matched
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""
|
||||
Query the list of Guest and Note to the activity and add information to makes entry with JS.
|
||||
"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
|
||||
.distinct().get(pk=self.kwargs["pk"])
|
||||
context["activity"] = activity
|
||||
|
||||
context["entries"] = Entry.objects.filter(activity=activity)
|
||||
|
||||
@ -278,16 +330,18 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
|
||||
context["noteuser_ctype"] = ContentType.objects.get_for_model(NoteUser).pk
|
||||
context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk
|
||||
|
||||
activities_open = Activity.objects.filter(open=True).filter(
|
||||
PermissionBackend.filter_queryset(self.request.user, Activity, "view")).distinct().all()
|
||||
activities_open = Activity.objects.filter(open=True, activity_type__manage_entries=True).filter(
|
||||
PermissionBackend.filter_queryset(self.request, Activity, "view")).distinct().all()
|
||||
context["activities_open"] = [a for a in activities_open
|
||||
if PermissionBackend.check_perm(self.request.user,
|
||||
if PermissionBackend.check_perm(self.request,
|
||||
"activity.add_entry",
|
||||
Entry(activity=a, note=self.request.user.note,))]
|
||||
|
||||
return context
|
||||
|
||||
|
||||
# Cache for 1 hour
|
||||
@method_decorator(cache_page(60 * 60), name='dispatch')
|
||||
class CalendarView(View):
|
||||
"""
|
||||
Render an ICS calendar with all valid activities.
|
||||
@ -310,8 +364,8 @@ X-WR-CALNAME:Kfet Calendar
|
||||
NAME:Kfet Calendar
|
||||
CALSCALE:GREGORIAN
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Europe/Berlin
|
||||
X-LIC-LOCATION:Europe/Berlin
|
||||
TZID:Europe/Paris
|
||||
X-LIC-LOCATION:Europe/Paris
|
||||
BEGIN:DAYLIGHT
|
||||
TZOFFSETFROM:+0100
|
||||
TZOFFSETTO:+0200
|
||||
@ -333,10 +387,10 @@ END:VTIMEZONE
|
||||
DTSTAMP:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)}Z
|
||||
UID:{md5((activity.name + "$" + str(activity.id) + str(activity.date_start)).encode("UTF-8")).hexdigest()}
|
||||
SUMMARY;CHARSET=UTF-8:{self.multilines(activity.name, 75, 22)}
|
||||
DTSTART;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_start)}
|
||||
DTEND;TZID=Europe/Berlin:{"{:%Y%m%dT%H%M%S}".format(activity.date_end)}
|
||||
DTSTART:{"{:%Y%m%dT%H%M%S}Z".format(activity.date_start)}
|
||||
DTEND:{"{:%Y%m%dT%H%M%S}Z".format(activity.date_end)}
|
||||
LOCATION:{self.multilines(activity.location, 75, 9) if activity.location else "Kfet"}
|
||||
DESCRIPTION;CHARSET=UTF-8:""" + self.multilines(activity.description.replace("\n", "\\n"), 75, 26) + """
|
||||
DESCRIPTION;CHARSET=UTF-8:""" + self.multilines(activity.description.replace("\n", "\\n"), 75, 26) + f"""
|
||||
-- {activity.organizer.name}
|
||||
END:VEVENT
|
||||
"""
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
default_app_config = 'api.apps.APIConfig'
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
42
apps/api/filters.py
Normal file
42
apps/api/filters.py
Normal file
@ -0,0 +1,42 @@
|
||||
import re
|
||||
from functools import lru_cache
|
||||
|
||||
from rest_framework.filters import SearchFilter
|
||||
|
||||
|
||||
class RegexSafeSearchFilter(SearchFilter):
|
||||
@lru_cache
|
||||
def validate_regex(self, search_term) -> bool:
|
||||
try:
|
||||
re.compile(search_term)
|
||||
return True
|
||||
except re.error:
|
||||
return False
|
||||
|
||||
def get_search_fields(self, view, request):
|
||||
"""
|
||||
Ensure that given regex are valid.
|
||||
If not, we consider that the user is trying to search by substring.
|
||||
"""
|
||||
search_fields = super().get_search_fields(view, request)
|
||||
search_terms = self.get_search_terms(request)
|
||||
|
||||
for search_term in search_terms:
|
||||
if not self.validate_regex(search_term):
|
||||
# Invalid regex. We assume we don't query by regex but by substring.
|
||||
search_fields = [f.replace('$', '') for f in search_fields]
|
||||
break
|
||||
|
||||
return search_fields
|
||||
|
||||
def get_search_terms(self, request):
|
||||
"""
|
||||
Ensure that search field is a valid regex query. If not, we remove extra characters.
|
||||
"""
|
||||
terms = super().get_search_terms(request)
|
||||
if not all(self.validate_regex(term) for term in terms):
|
||||
# Invalid regex. If a ^ is prefixed to the search term, we remove it.
|
||||
terms = [term[1:] if term[0] == '^' else term for term in terms]
|
||||
# Same for dollars.
|
||||
terms = [term[:-1] if term[-1] == '$' else term for term in terms]
|
||||
return terms
|
5
apps/api/pagination.py
Normal file
5
apps/api/pagination.py
Normal file
@ -0,0 +1,5 @@
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
|
||||
class CustomPagination(PageNumberPagination):
|
||||
page_size_query_param = 'page_size'
|
@ -1,13 +1,20 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from django.utils import timezone
|
||||
from rest_framework import serializers
|
||||
from member.api.serializers import ProfileSerializer, MembershipSerializer
|
||||
from member.models import Membership
|
||||
from note.api.serializers import NoteSerializer
|
||||
from note.models import Alias
|
||||
from note_kfet.middlewares import get_current_request
|
||||
from permission.backends import PermissionBackend
|
||||
|
||||
|
||||
class UserSerializer(ModelSerializer):
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
REST API Serializer for Users.
|
||||
The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
|
||||
@ -22,7 +29,7 @@ class UserSerializer(ModelSerializer):
|
||||
)
|
||||
|
||||
|
||||
class ContentTypeSerializer(ModelSerializer):
|
||||
class ContentTypeSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
REST API Serializer for Users.
|
||||
The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
|
||||
@ -31,3 +38,54 @@ class ContentTypeSerializer(ModelSerializer):
|
||||
class Meta:
|
||||
model = ContentType
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class OAuthSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Informations that are transmitted by OAuth.
|
||||
For now, this includes user, profile and valid memberships.
|
||||
This should be better managed later.
|
||||
"""
|
||||
normalized_name = serializers.SerializerMethodField()
|
||||
|
||||
profile = serializers.SerializerMethodField()
|
||||
|
||||
note = serializers.SerializerMethodField()
|
||||
|
||||
memberships = serializers.SerializerMethodField()
|
||||
|
||||
def get_normalized_name(self, obj):
|
||||
return Alias.normalize(obj.username)
|
||||
|
||||
def get_profile(self, obj):
|
||||
# Display the profile of the user only if we have rights to see it.
|
||||
return ProfileSerializer().to_representation(obj.profile) \
|
||||
if PermissionBackend.check_perm(get_current_request(), 'member.view_profile', obj.profile) else None
|
||||
|
||||
def get_note(self, obj):
|
||||
# Display the note of the user only if we have rights to see it.
|
||||
return NoteSerializer().to_representation(obj.note) \
|
||||
if PermissionBackend.check_perm(get_current_request(), 'note.view_note', obj.note) else None
|
||||
|
||||
def get_memberships(self, obj):
|
||||
# Display only memberships that we are allowed to see.
|
||||
return serializers.ListSerializer(child=MembershipSerializer()).to_representation(
|
||||
obj.memberships.filter(date_start__lte=timezone.now(), date_end__gte=timezone.now())
|
||||
.filter(PermissionBackend.filter_queryset(get_current_request(), Membership, 'view')))
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = (
|
||||
'id',
|
||||
'username',
|
||||
'normalized_name',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'email',
|
||||
'is_superuser',
|
||||
'is_active',
|
||||
'is_staff',
|
||||
'profile',
|
||||
'note',
|
||||
'memberships',
|
||||
)
|
||||
|
241
apps/api/tests.py
Normal file
241
apps/api/tests.py
Normal file
@ -0,0 +1,241 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import json
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from urllib.parse import quote_plus
|
||||
from warnings import warn
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models.fields.files import ImageFieldFile
|
||||
from django.test import TestCase
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from phonenumbers import PhoneNumber
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from api.filters import RegexSafeSearchFilter
|
||||
from member.models import Membership, Club
|
||||
from note.models import NoteClub, NoteUser, Alias, Note
|
||||
from permission.models import PermissionMask, Permission, Role
|
||||
|
||||
from .viewsets import ContentTypeViewSet, UserViewSet
|
||||
|
||||
|
||||
class TestAPI(TestCase):
|
||||
"""
|
||||
Load API pages and check that filters are working.
|
||||
"""
|
||||
fixtures = ('initial', )
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create_superuser(
|
||||
username="adminapi",
|
||||
password="adminapi",
|
||||
email="adminapi@example.com",
|
||||
last_name="Admin",
|
||||
first_name="Admin",
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
sess = self.client.session
|
||||
sess["permission_mask"] = 42
|
||||
sess.save()
|
||||
|
||||
def check_viewset(self, viewset, url):
|
||||
"""
|
||||
This function should be called inside a unit test.
|
||||
This loads the viewset and for each filter entry, it checks that the filter is running good.
|
||||
"""
|
||||
resp = self.client.get(url + "?format=json")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
model = viewset.serializer_class.Meta.model
|
||||
|
||||
if not model.objects.exists(): # pragma: no cover
|
||||
warn(f"Warning: unable to test API filters for the model {model._meta.verbose_name} "
|
||||
"since there is no instance of it.")
|
||||
return
|
||||
|
||||
if hasattr(viewset, "filter_backends"):
|
||||
backends = viewset.filter_backends
|
||||
obj = model.objects.last()
|
||||
|
||||
if DjangoFilterBackend in backends:
|
||||
# Specific search
|
||||
for field in viewset.filterset_fields:
|
||||
obj = self.fix_note_object(obj, field)
|
||||
|
||||
value = self.get_value(obj, field)
|
||||
if value is None: # pragma: no cover
|
||||
warn(f"Warning: the filter {field} for the model {model._meta.verbose_name} "
|
||||
"has not been tested.")
|
||||
continue
|
||||
resp = self.client.get(url + f"?format=json&{field}={quote_plus(str(value))}")
|
||||
self.assertEqual(resp.status_code, 200, f"The filter {field} for the model "
|
||||
f"{model._meta.verbose_name} does not work. "
|
||||
f"Given parameter: {value}")
|
||||
content = json.loads(resp.content)
|
||||
self.assertGreater(content["count"], 0, f"The filter {field} for the model "
|
||||
f"{model._meta.verbose_name} does not work. "
|
||||
f"Given parameter: {value}")
|
||||
|
||||
if OrderingFilter in backends:
|
||||
# Ensure that ordering is working well
|
||||
for field in viewset.ordering_fields:
|
||||
resp = self.client.get(url + f"?ordering={field}")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
resp = self.client.get(url + f"?ordering=-{field}")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
if RegexSafeSearchFilter in backends:
|
||||
# Basic search
|
||||
for field in viewset.search_fields:
|
||||
obj = self.fix_note_object(obj, field)
|
||||
|
||||
if field[0] == '$' or field[0] == '=':
|
||||
field = field[1:]
|
||||
value = self.get_value(obj, field)
|
||||
if value is None: # pragma: no cover
|
||||
warn(f"Warning: the filter {field} for the model {model._meta.verbose_name} "
|
||||
"has not been tested.")
|
||||
continue
|
||||
resp = self.client.get(url + f"?format=json&search={quote_plus(str(value))}")
|
||||
self.assertEqual(resp.status_code, 200, f"The filter {field} for the model "
|
||||
f"{model._meta.verbose_name} does not work. "
|
||||
f"Given parameter: {value}")
|
||||
content = json.loads(resp.content)
|
||||
self.assertGreater(content["count"], 0, f"The filter {field} for the model "
|
||||
f"{model._meta.verbose_name} does not work. "
|
||||
f"Given parameter: {value}")
|
||||
|
||||
self.check_permissions(url, obj)
|
||||
|
||||
def check_permissions(self, url, obj):
|
||||
"""
|
||||
Check that permissions are working
|
||||
"""
|
||||
# Drop rights
|
||||
self.user.is_superuser = False
|
||||
self.user.save()
|
||||
sess = self.client.session
|
||||
sess["permission_mask"] = 0
|
||||
sess.save()
|
||||
|
||||
# Delete user permissions
|
||||
for m in Membership.objects.filter(user=self.user).all():
|
||||
m.roles.clear()
|
||||
m.save()
|
||||
|
||||
# Create a new role, which will have the checking permission
|
||||
role = Role.objects.get_or_create(name="β-tester")[0]
|
||||
role.permissions.clear()
|
||||
role.save()
|
||||
membership = Membership.objects.get_or_create(user=self.user, club=Club.objects.get(name="BDE"))[0]
|
||||
membership.roles.set([role])
|
||||
membership.save()
|
||||
|
||||
# Ensure that the access to the object is forbidden without permission
|
||||
resp = self.client.get(url + f"{obj.pk}/")
|
||||
self.assertEqual(resp.status_code, 404, f"Mysterious access to {url}{obj.pk}/ for {obj}")
|
||||
|
||||
obj.refresh_from_db()
|
||||
|
||||
# There are problems with polymorphism
|
||||
if isinstance(obj, Note) and hasattr(obj, "note_ptr"):
|
||||
obj = obj.note_ptr
|
||||
|
||||
mask = PermissionMask.objects.get(rank=0)
|
||||
|
||||
for field in obj._meta.fields:
|
||||
# Build permission query
|
||||
value = self.get_value(obj, field.name)
|
||||
if isinstance(value, date) or isinstance(value, datetime):
|
||||
value = value.isoformat()
|
||||
elif isinstance(value, ImageFieldFile):
|
||||
value = value.name
|
||||
elif isinstance(value, Decimal):
|
||||
value = str(value)
|
||||
query = json.dumps({field.name: value})
|
||||
|
||||
# Create sample permission
|
||||
permission = Permission.objects.get_or_create(
|
||||
model=ContentType.objects.get_for_model(obj._meta.model),
|
||||
query=query,
|
||||
mask=mask,
|
||||
type="view",
|
||||
permanent=False,
|
||||
description=f"Can view {obj._meta.verbose_name}",
|
||||
)[0]
|
||||
role.permissions.set([permission])
|
||||
role.save()
|
||||
|
||||
# Check that the access is possible
|
||||
resp = self.client.get(url + f"{obj.pk}/")
|
||||
self.assertEqual(resp.status_code, 200, f"Permission {permission.query} is not working "
|
||||
f"for the model {obj._meta.verbose_name}")
|
||||
|
||||
# Restore rights
|
||||
self.user.is_superuser = True
|
||||
self.user.save()
|
||||
sess = self.client.session
|
||||
sess["permission_mask"] = 42
|
||||
sess.save()
|
||||
|
||||
@staticmethod
|
||||
def get_value(obj, key: str):
|
||||
"""
|
||||
Resolve the queryset filter to get the Python value of an object.
|
||||
"""
|
||||
if hasattr(obj, "all"):
|
||||
# obj is a RelatedManager
|
||||
obj = obj.last()
|
||||
|
||||
if obj is None: # pragma: no cover
|
||||
return None
|
||||
|
||||
if '__' not in key:
|
||||
obj = getattr(obj, key)
|
||||
if hasattr(obj, "pk"):
|
||||
return obj.pk
|
||||
elif hasattr(obj, "all"):
|
||||
if not obj.exists(): # pragma: no cover
|
||||
return None
|
||||
return obj.last().pk
|
||||
elif isinstance(obj, bool):
|
||||
return int(obj)
|
||||
elif isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
elif isinstance(obj, PhoneNumber):
|
||||
return obj.raw_input
|
||||
return obj
|
||||
|
||||
key, remaining = key.split('__', 1)
|
||||
return TestAPI.get_value(getattr(obj, key), remaining)
|
||||
|
||||
@staticmethod
|
||||
def fix_note_object(obj, field):
|
||||
"""
|
||||
When querying an object that has a noteclub or a noteuser field,
|
||||
ensure that the object has a good value.
|
||||
"""
|
||||
if isinstance(obj, Alias):
|
||||
if "noteuser" in field:
|
||||
return NoteUser.objects.last().alias.last()
|
||||
elif "noteclub" in field:
|
||||
return NoteClub.objects.last().alias.last()
|
||||
elif isinstance(obj, Note):
|
||||
if "noteuser" in field:
|
||||
return NoteUser.objects.last()
|
||||
elif "noteclub" in field:
|
||||
return NoteClub.objects.last()
|
||||
return obj
|
||||
|
||||
|
||||
class TestBasicAPI(TestAPI):
|
||||
def test_user_api(self):
|
||||
"""
|
||||
Load the user page.
|
||||
"""
|
||||
self.check_viewset(ContentTypeViewSet, "/api/models/")
|
||||
self.check_viewset(UserViewSet, "/api/user/")
|
@ -1,10 +1,12 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls import url, include
|
||||
from django.conf.urls import include
|
||||
from django.urls import re_path
|
||||
from rest_framework import routers
|
||||
|
||||
from .views import UserInformationView
|
||||
from .viewsets import ContentTypeViewSet, UserViewSet
|
||||
|
||||
# Routers provide an easy way of automatically determining the URL conf.
|
||||
@ -13,39 +15,48 @@ router = routers.DefaultRouter()
|
||||
router.register('models', ContentTypeViewSet)
|
||||
router.register('user', UserViewSet)
|
||||
|
||||
if "member" in settings.INSTALLED_APPS:
|
||||
from member.api.urls import register_members_urls
|
||||
register_members_urls(router, 'members')
|
||||
|
||||
if "member" in settings.INSTALLED_APPS:
|
||||
if "activity" in settings.INSTALLED_APPS:
|
||||
from activity.api.urls import register_activity_urls
|
||||
register_activity_urls(router, 'activity')
|
||||
|
||||
if "note" in settings.INSTALLED_APPS:
|
||||
from note.api.urls import register_note_urls
|
||||
register_note_urls(router, 'note')
|
||||
|
||||
if "treasury" in settings.INSTALLED_APPS:
|
||||
from treasury.api.urls import register_treasury_urls
|
||||
register_treasury_urls(router, 'treasury')
|
||||
|
||||
if "permission" in settings.INSTALLED_APPS:
|
||||
from permission.api.urls import register_permission_urls
|
||||
register_permission_urls(router, 'permission')
|
||||
if "food" in settings.INSTALLED_APPS:
|
||||
from food.api.urls import register_food_urls
|
||||
register_food_urls(router, 'food')
|
||||
|
||||
if "logs" in settings.INSTALLED_APPS:
|
||||
from logs.api.urls import register_logs_urls
|
||||
register_logs_urls(router, 'logs')
|
||||
|
||||
if "member" in settings.INSTALLED_APPS:
|
||||
from member.api.urls import register_members_urls
|
||||
register_members_urls(router, 'members')
|
||||
|
||||
if "note" in settings.INSTALLED_APPS:
|
||||
from note.api.urls import register_note_urls
|
||||
register_note_urls(router, 'note')
|
||||
|
||||
if "permission" in settings.INSTALLED_APPS:
|
||||
from permission.api.urls import register_permission_urls
|
||||
register_permission_urls(router, 'permission')
|
||||
|
||||
if "treasury" in settings.INSTALLED_APPS:
|
||||
from treasury.api.urls import register_treasury_urls
|
||||
register_treasury_urls(router, 'treasury')
|
||||
|
||||
if "wei" in settings.INSTALLED_APPS:
|
||||
from wei.api.urls import register_wei_urls
|
||||
register_wei_urls(router, 'wei')
|
||||
|
||||
if "wrapped" in settings.INSTALLED_APPS:
|
||||
from wrapped.api.urls import register_wrapped_urls
|
||||
register_wrapped_urls(router, 'wrapped')
|
||||
|
||||
app_name = 'api'
|
||||
|
||||
# Wire up our API using automatic URL routing.
|
||||
# Additionally, we include login URLs for the browsable API.
|
||||
urlpatterns = [
|
||||
url('^', include(router.urls)),
|
||||
url('^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||
re_path('^', include(router.urls)),
|
||||
re_path('^me/', UserInformationView.as_view()),
|
||||
re_path('^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||
]
|
||||
|
20
apps/api/views.py
Normal file
20
apps/api/views.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework.generics import RetrieveAPIView
|
||||
|
||||
from .serializers import OAuthSerializer
|
||||
|
||||
|
||||
class UserInformationView(RetrieveAPIView):
|
||||
"""
|
||||
These fields are give to OAuth authenticators.
|
||||
"""
|
||||
serializer_class = OAuthSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
return User.objects.filter(pk=self.request.user.pk)
|
||||
|
||||
def get_object(self):
|
||||
return self.request.user
|
@ -1,6 +1,8 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import re
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django.db.models import Q
|
||||
@ -8,12 +10,20 @@ from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet, ModelViewSet
|
||||
from permission.backends import PermissionBackend
|
||||
from note_kfet.middlewares import get_current_session
|
||||
from note.models import Alias
|
||||
|
||||
from .filters import RegexSafeSearchFilter
|
||||
from .serializers import UserSerializer, ContentTypeSerializer
|
||||
|
||||
|
||||
def is_regex(pattern):
|
||||
try:
|
||||
re.compile(pattern)
|
||||
return True
|
||||
except (re.error, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
class ReadProtectedModelViewSet(ModelViewSet):
|
||||
"""
|
||||
Protect a ModelViewSet by filtering the objects that the user cannot see.
|
||||
@ -24,9 +34,7 @@ class ReadProtectedModelViewSet(ModelViewSet):
|
||||
self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
get_current_session().setdefault("permission_mask", 42)
|
||||
return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()
|
||||
return self.queryset.filter(PermissionBackend.filter_queryset(self.request, self.model, "view")).distinct()
|
||||
|
||||
|
||||
class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet):
|
||||
@ -39,21 +47,20 @@ class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet):
|
||||
self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
get_current_session().setdefault("permission_mask", 42)
|
||||
return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct()
|
||||
return self.queryset.filter(PermissionBackend.filter_queryset(self.request, self.model, "view")).distinct()
|
||||
|
||||
|
||||
class UserViewSet(ReadProtectedModelViewSet):
|
||||
"""
|
||||
REST API View set.
|
||||
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/users/
|
||||
then render it on /api/user/
|
||||
"""
|
||||
queryset = User.objects.all()
|
||||
queryset = User.objects
|
||||
serializer_class = UserSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active', ]
|
||||
filterset_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active',
|
||||
'note__alias__name', 'note__alias__normalized_name', ]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
@ -63,34 +70,38 @@ class UserViewSet(ReadProtectedModelViewSet):
|
||||
|
||||
if "search" in self.request.GET:
|
||||
pattern = self.request.GET["search"]
|
||||
# Check if this is a valid regex. If not, we won't check regex
|
||||
valid_regex = is_regex(pattern)
|
||||
suffix = "__iregex" if valid_regex else "__istartswith"
|
||||
prefix = "^" if valid_regex else ""
|
||||
|
||||
# Filter with different rules
|
||||
# We use union-all to keep each filter rule sorted in result
|
||||
queryset = queryset.filter(
|
||||
# Match without normalization
|
||||
note__alias__name__iregex="^" + pattern
|
||||
Q(**{f"note__alias__name{suffix}": prefix + pattern})
|
||||
).union(
|
||||
queryset.filter(
|
||||
# Match with normalization
|
||||
Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
|
||||
& ~Q(note__alias__name__iregex="^" + pattern)
|
||||
Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
|
||||
& ~Q(**{f"note__alias__name{suffix}": prefix + pattern})
|
||||
),
|
||||
all=True,
|
||||
).union(
|
||||
queryset.filter(
|
||||
# Match on lower pattern
|
||||
Q(note__alias__normalized_name__iregex="^" + pattern.lower())
|
||||
& ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
|
||||
& ~Q(note__alias__name__iregex="^" + pattern)
|
||||
Q(**{f"note__alias__normalized_name{suffix}": prefix + pattern.lower()})
|
||||
& ~Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
|
||||
& ~Q(**{f"note__alias__name{suffix}": prefix + pattern})
|
||||
),
|
||||
all=True,
|
||||
).union(
|
||||
queryset.filter(
|
||||
# Match on firstname or lastname
|
||||
(Q(last_name__iregex="^" + pattern) | Q(first_name__iregex="^" + pattern))
|
||||
& ~Q(note__alias__normalized_name__iregex="^" + pattern.lower())
|
||||
& ~Q(note__alias__normalized_name__iregex="^" + Alias.normalize(pattern))
|
||||
& ~Q(note__alias__name__iregex="^" + pattern)
|
||||
(Q(**{f"last_name{suffix}": prefix + pattern}) | Q(**{f"first_name{suffix}": prefix + pattern}))
|
||||
& ~Q(**{f"note__alias__normalized_name{suffix}": prefix + pattern.lower()})
|
||||
& ~Q(**{f"note__alias__normalized_name{suffix}": prefix + Alias.normalize(pattern)})
|
||||
& ~Q(**{f"note__alias__name{suffix}": prefix + pattern})
|
||||
),
|
||||
all=True,
|
||||
)
|
||||
@ -106,7 +117,10 @@ class ContentTypeViewSet(ReadOnlyModelViewSet):
|
||||
"""
|
||||
REST API View set.
|
||||
The djangorestframework plugin will get all `User` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/users/
|
||||
then render it on /api/models/
|
||||
"""
|
||||
queryset = ContentType.objects.all()
|
||||
queryset = ContentType.objects.order_by('id')
|
||||
serializer_class = ContentTypeSerializer
|
||||
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||
filterset_fields = ['id', 'app_label', 'model', ]
|
||||
search_fields = ['$app_label', '$model', ]
|
||||
|
0
apps/food/__init__.py
Normal file
0
apps/food/__init__.py
Normal file
59
apps/food/admin.py
Normal file
59
apps/food/admin.py
Normal file
@ -0,0 +1,59 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.contrib import admin
|
||||
from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicParentModelAdmin
|
||||
from note_kfet.admin import admin_site
|
||||
|
||||
from .models import Allergen, Food, BasicFood, TransformedFood, QRCode
|
||||
|
||||
|
||||
@admin.register(Allergen, site=admin_site)
|
||||
class AllergenAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin customisation for Allergen
|
||||
"""
|
||||
ordering = ['name']
|
||||
|
||||
|
||||
@admin.register(Food, site=admin_site)
|
||||
class FoodAdmin(PolymorphicParentModelAdmin):
|
||||
"""
|
||||
Admin customisation for Food
|
||||
"""
|
||||
child_models = (Food, BasicFood, TransformedFood)
|
||||
list_display = ('name', 'expiry_date', 'owner', 'is_ready')
|
||||
list_filter = ('is_ready', 'end_of_life')
|
||||
search_fields = ['name']
|
||||
ordering = ['expiry_date', 'name']
|
||||
|
||||
|
||||
@admin.register(BasicFood, site=admin_site)
|
||||
class BasicFood(PolymorphicChildModelAdmin):
|
||||
"""
|
||||
Admin customisation for BasicFood
|
||||
"""
|
||||
list_display = ('name', 'expiry_date', 'date_type', 'owner', 'is_ready')
|
||||
list_filter = ('is_ready', 'date_type', 'end_of_life')
|
||||
search_fields = ['name']
|
||||
ordering = ['expiry_date', 'name']
|
||||
|
||||
|
||||
@admin.register(TransformedFood, site=admin_site)
|
||||
class TransformedFood(PolymorphicChildModelAdmin):
|
||||
"""
|
||||
Admin customisation for TransformedFood
|
||||
"""
|
||||
list_display = ('name', 'expiry_date', 'shelf_life', 'owner', 'is_ready')
|
||||
list_filter = ('is_ready', 'end_of_life', 'shelf_life')
|
||||
search_fields = ['name']
|
||||
ordering = ['expiry_date', 'name']
|
||||
|
||||
|
||||
@admin.register(QRCode, site=admin_site)
|
||||
class QRCodeAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin customisation for QRCode
|
||||
"""
|
||||
list_diplay = ('qr_code_number', 'food_container')
|
||||
search_fields = ['food_container__name']
|
0
apps/food/api/__init__.py
Normal file
0
apps/food/api/__init__.py
Normal file
46
apps/food/api/serializers.py
Normal file
46
apps/food/api/serializers.py
Normal file
@ -0,0 +1,46 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from ..models import Allergen, BasicFood, TransformedFood, QRCode
|
||||
|
||||
|
||||
class AllergenSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
REST API Serializer for Allergen.
|
||||
The djangorestframework plugin will analyse the model `Allergen` and parse all fields in the API.
|
||||
"""
|
||||
class Meta:
|
||||
model = Allergen
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class BasicFoodSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
REST API Serializer for BasicFood.
|
||||
The djangorestframework plugin will analyse the model `BasicFood` and parse all fields in the API.
|
||||
"""
|
||||
class Meta:
|
||||
model = BasicFood
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class TransformedFoodSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
REST API Serializer for TransformedFood.
|
||||
The djangorestframework plugin will analyse the model `TransformedFood` and parse all fields in the API.
|
||||
"""
|
||||
class Meta:
|
||||
model = TransformedFood
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class QRCodeSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
REST API Serializer for QRCode.
|
||||
The djangorestframework plugin will analyse the model `QRCode` and parse all fields in the API.
|
||||
"""
|
||||
class Meta:
|
||||
model = QRCode
|
||||
fields = '__all__'
|
14
apps/food/api/urls.py
Normal file
14
apps/food/api/urls.py
Normal file
@ -0,0 +1,14 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from .views import AllergenViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet
|
||||
|
||||
|
||||
def register_food_urls(router, path):
|
||||
"""
|
||||
Configure router for Food REST API.
|
||||
"""
|
||||
router.register(path + '/allergen', AllergenViewSet)
|
||||
router.register(path + '/basicfood', BasicFoodViewSet)
|
||||
router.register(path + '/transformedfood', TransformedFoodViewSet)
|
||||
router.register(path + '/qrcode', QRCodeViewSet)
|
61
apps/food/api/views.py
Normal file
61
apps/food/api/views.py
Normal file
@ -0,0 +1,61 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from api.viewsets import ReadProtectedModelViewSet
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.filters import SearchFilter
|
||||
|
||||
from .serializers import AllergenSerializer, BasicFoodSerializer, TransformedFoodSerializer, QRCodeSerializer
|
||||
from ..models import Allergen, BasicFood, TransformedFood, QRCode
|
||||
|
||||
|
||||
class AllergenViewSet(ReadProtectedModelViewSet):
|
||||
"""
|
||||
REST API View set.
|
||||
The djangorestframework plugin will get all `Allergen` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/food/allergen/
|
||||
"""
|
||||
queryset = Allergen.objects.order_by('id')
|
||||
serializer_class = AllergenSerializer
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
filterset_fields = ['name', ]
|
||||
search_fields = ['$name', ]
|
||||
|
||||
|
||||
class BasicFoodViewSet(ReadProtectedModelViewSet):
|
||||
"""
|
||||
REST API View set.
|
||||
The djangorestframework plugin will get all `BasicFood` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/food/basicfood/
|
||||
"""
|
||||
queryset = BasicFood.objects.order_by('id')
|
||||
serializer_class = BasicFoodSerializer
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
filterset_fields = ['name', ]
|
||||
search_fields = ['$name', ]
|
||||
|
||||
|
||||
class TransformedFoodViewSet(ReadProtectedModelViewSet):
|
||||
"""
|
||||
REST API View set.
|
||||
The djangorestframework plugin will get all `TransformedFood` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/food/transformedfood/
|
||||
"""
|
||||
queryset = TransformedFood.objects.order_by('id')
|
||||
serializer_class = TransformedFoodSerializer
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
filterset_fields = ['name', ]
|
||||
search_fields = ['$name', ]
|
||||
|
||||
|
||||
class QRCodeViewSet(ReadProtectedModelViewSet):
|
||||
"""
|
||||
REST API View set.
|
||||
The djangorestframework plugin will get all `QRCode` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/food/qrcode/
|
||||
"""
|
||||
queryset = QRCode.objects.order_by('id')
|
||||
serializer_class = QRCodeSerializer
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
filterset_fields = ['qr_code_number', ]
|
||||
search_fields = ['$qr_code_number', ]
|
11
apps/food/apps.py
Normal file
11
apps/food/apps.py
Normal file
@ -0,0 +1,11 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class FoodkfetConfig(AppConfig):
|
||||
name = 'food'
|
||||
verbose_name = _('food')
|
100
apps/food/fixtures/initial.json
Normal file
100
apps/food/fixtures/initial.json
Normal file
@ -0,0 +1,100 @@
|
||||
[
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"name": "Lait"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"name": "Oeufs"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"name": "Gluten"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"name": "Fruits à coques"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"name": "Arachides"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 6,
|
||||
"fields": {
|
||||
"name": "Sésame"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 7,
|
||||
"fields": {
|
||||
"name": "Soja"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 8,
|
||||
"fields": {
|
||||
"name": "Céléri"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 9,
|
||||
"fields": {
|
||||
"name": "Lupin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 10,
|
||||
"fields": {
|
||||
"name": "Moutarde"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 11,
|
||||
"fields": {
|
||||
"name": "Sulfites"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 12,
|
||||
"fields": {
|
||||
"name": "Crustacés"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 13,
|
||||
"fields": {
|
||||
"name": "Mollusques"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "food.allergen",
|
||||
"pk": 14,
|
||||
"fields": {
|
||||
"name": "Poissons"
|
||||
}
|
||||
}
|
||||
]
|
153
apps/food/forms.py
Normal file
153
apps/food/forms.py
Normal file
@ -0,0 +1,153 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from random import shuffle
|
||||
|
||||
from bootstrap_datepicker_plus.widgets import DateTimePickerInput
|
||||
from django import forms
|
||||
from django.forms.widgets import NumberInput
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from member.models import Club
|
||||
from note_kfet.inputs import Autocomplete
|
||||
from note_kfet.middlewares import get_current_request
|
||||
from permission.backends import PermissionBackend
|
||||
|
||||
from .models import BasicFood, TransformedFood, QRCode
|
||||
|
||||
|
||||
class QRCodeForms(forms.ModelForm):
|
||||
"""
|
||||
Form for create QRCode for container
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['food_container'].queryset = self.fields['food_container'].queryset.filter(
|
||||
is_ready=False,
|
||||
end_of_life__isnull=True,
|
||||
polymorphic_ctype__model='transformedfood',
|
||||
).filter(PermissionBackend.filter_queryset(
|
||||
get_current_request(),
|
||||
TransformedFood,
|
||||
"view",
|
||||
))
|
||||
|
||||
class Meta:
|
||||
model = QRCode
|
||||
fields = ('food_container',)
|
||||
|
||||
|
||||
class BasicFoodForms(forms.ModelForm):
|
||||
"""
|
||||
Form for add basicfood
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['name'].widget.attrs.update({"autofocus": "autofocus"})
|
||||
self.fields['name'].required = True
|
||||
self.fields['owner'].required = True
|
||||
|
||||
# Some example
|
||||
self.fields['name'].widget.attrs.update({"placeholder": _("Pasta METRO 5kg")})
|
||||
clubs = list(Club.objects.filter(PermissionBackend.filter_queryset(get_current_request(), Club, "change")).all())
|
||||
shuffle(clubs)
|
||||
self.fields['owner'].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
|
||||
self.fields['order'].widget.attrs["placeholder"] = _("Specific order given to GCKs")
|
||||
|
||||
class Meta:
|
||||
model = BasicFood
|
||||
fields = ('name', 'owner', 'date_type', 'expiry_date', 'allergens', 'order',)
|
||||
widgets = {
|
||||
"owner": Autocomplete(
|
||||
model=Club,
|
||||
attrs={"api_url": "/api/members/club/"},
|
||||
),
|
||||
"expiry_date": DateTimePickerInput(),
|
||||
}
|
||||
|
||||
|
||||
class TransformedFoodForms(forms.ModelForm):
|
||||
"""
|
||||
Form for add transformedfood
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['name'].required = True
|
||||
self.fields['owner'].required = True
|
||||
|
||||
# Some example
|
||||
self.fields['name'].widget.attrs.update({"placeholder": _("Lasagna")})
|
||||
clubs = list(Club.objects.filter(PermissionBackend.filter_queryset(get_current_request(), Club, "change")).all())
|
||||
shuffle(clubs)
|
||||
self.fields['owner'].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..."
|
||||
self.fields['order'].widget.attrs["placeholder"] = _("Specific order given to GCKs")
|
||||
|
||||
class Meta:
|
||||
model = TransformedFood
|
||||
fields = ('name', 'owner', 'order',)
|
||||
widgets = {
|
||||
"owner": Autocomplete(
|
||||
model=Club,
|
||||
attrs={"api_url": "/api/members/club/"},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class BasicFoodUpdateForms(forms.ModelForm):
|
||||
"""
|
||||
Form for update basicfood object
|
||||
"""
|
||||
class Meta:
|
||||
model = BasicFood
|
||||
fields = ('name', 'owner', 'date_type', 'expiry_date', 'end_of_life', 'is_ready', 'order', 'allergens')
|
||||
widgets = {
|
||||
"owner": Autocomplete(
|
||||
model=Club,
|
||||
attrs={"api_url": "/api/members/club/"},
|
||||
),
|
||||
"expiry_date": DateTimePickerInput(),
|
||||
}
|
||||
|
||||
|
||||
class TransformedFoodUpdateForms(forms.ModelForm):
|
||||
"""
|
||||
Form for update transformedfood object
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['shelf_life'].label = _('Shelf life (in hours)')
|
||||
|
||||
class Meta:
|
||||
model = TransformedFood
|
||||
fields = ('name', 'owner', 'end_of_life', 'is_ready', 'order', 'shelf_life')
|
||||
widgets = {
|
||||
"owner": Autocomplete(
|
||||
model=Club,
|
||||
attrs={"api_url": "/api/members/club/"},
|
||||
),
|
||||
"expiry_date": DateTimePickerInput(),
|
||||
"shelf_life": NumberInput(),
|
||||
}
|
||||
|
||||
|
||||
class AddIngredientForms(forms.ModelForm):
|
||||
"""
|
||||
Form for add an ingredient
|
||||
"""
|
||||
fully_used = forms.BooleanField()
|
||||
fully_used.initial = True
|
||||
fully_used.required = False
|
||||
fully_used.label = _("Fully used")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# TODO find a better way to get pk (be not url scheme dependant)
|
||||
pk = get_current_request().path.split('/')[-1]
|
||||
self.fields['ingredients'].queryset = self.fields['ingredients'].queryset.filter(
|
||||
polymorphic_ctype__model="transformedfood",
|
||||
is_ready=False,
|
||||
end_of_life='',
|
||||
).filter(PermissionBackend.filter_queryset(get_current_request(), TransformedFood, "change")).exclude(pk=pk)
|
||||
|
||||
class Meta:
|
||||
model = TransformedFood
|
||||
fields = ('ingredients',)
|
199
apps/food/migrations/0001_initial.py
Normal file
199
apps/food/migrations/0001_initial.py
Normal file
@ -0,0 +1,199 @@
|
||||
# Generated by Django 4.2.20 on 2025-04-17 21:43
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("member", "0013_auto_20240801_1436"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Allergen",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255, verbose_name="name")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Allergen",
|
||||
"verbose_name_plural": "Allergens",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Food",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255, verbose_name="name")),
|
||||
("expiry_date", models.DateTimeField(verbose_name="expiry date")),
|
||||
(
|
||||
"end_of_life",
|
||||
models.CharField(max_length=255, verbose_name="end of life"),
|
||||
),
|
||||
(
|
||||
"is_ready",
|
||||
models.BooleanField(max_length=255, verbose_name="is ready"),
|
||||
),
|
||||
("order", models.CharField(max_length=255, verbose_name="order")),
|
||||
(
|
||||
"allergens",
|
||||
models.ManyToManyField(
|
||||
blank=True, to="food.allergen", verbose_name="allergens"
|
||||
),
|
||||
),
|
||||
(
|
||||
"owner",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="+",
|
||||
to="member.club",
|
||||
verbose_name="owner",
|
||||
),
|
||||
),
|
||||
(
|
||||
"polymorphic_ctype",
|
||||
models.ForeignKey(
|
||||
editable=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="polymorphic_%(app_label)s.%(class)s_set+",
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Food",
|
||||
"verbose_name_plural": "Foods",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BasicFood",
|
||||
fields=[
|
||||
(
|
||||
"food_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="food.food",
|
||||
),
|
||||
),
|
||||
(
|
||||
"arrival_date",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="arrival date"
|
||||
),
|
||||
),
|
||||
(
|
||||
"date_type",
|
||||
models.CharField(
|
||||
choices=[("DLC", "DLC"), ("DDM", "DDM")], max_length=255
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Basic food",
|
||||
"verbose_name_plural": "Basic foods",
|
||||
},
|
||||
bases=("food.food",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="QRCode",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"qr_code_number",
|
||||
models.PositiveIntegerField(
|
||||
unique=True, verbose_name="qr code number"
|
||||
),
|
||||
),
|
||||
(
|
||||
"food_container",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="QR_code",
|
||||
to="food.food",
|
||||
verbose_name="food container",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "QR-code",
|
||||
"verbose_name_plural": "QR-codes",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="TransformedFood",
|
||||
fields=[
|
||||
(
|
||||
"food_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="food.food",
|
||||
),
|
||||
),
|
||||
(
|
||||
"creation_date",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="creation date"
|
||||
),
|
||||
),
|
||||
(
|
||||
"shelf_life",
|
||||
models.DurationField(
|
||||
default=datetime.timedelta(days=3), verbose_name="shelf life"
|
||||
),
|
||||
),
|
||||
(
|
||||
"ingredients",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name="transformed_ingredient_inv",
|
||||
to="food.food",
|
||||
verbose_name="transformed ingredient",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Transformed food",
|
||||
"verbose_name_plural": "Transformed foods",
|
||||
},
|
||||
bases=("food.food",),
|
||||
),
|
||||
]
|
0
apps/food/migrations/__init__.py
Normal file
0
apps/food/migrations/__init__.py
Normal file
286
apps/food/models.py
Normal file
286
apps/food/models.py
Normal file
@ -0,0 +1,286 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import models, transaction
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from polymorphic.models import PolymorphicModel
|
||||
from member.models import Club
|
||||
|
||||
|
||||
class Allergen(models.Model):
|
||||
"""
|
||||
Allergen and alimentary restrictions
|
||||
"""
|
||||
name = models.CharField(
|
||||
verbose_name=_('name'),
|
||||
max_length=255,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Allergen")
|
||||
verbose_name_plural = _("Allergens")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Food(PolymorphicModel):
|
||||
"""
|
||||
Describe any type of food
|
||||
"""
|
||||
name = models.CharField(
|
||||
verbose_name=_("name"),
|
||||
max_length=255,
|
||||
)
|
||||
|
||||
owner = models.ForeignKey(
|
||||
Club,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+',
|
||||
verbose_name=_('owner'),
|
||||
)
|
||||
|
||||
allergens = models.ManyToManyField(
|
||||
Allergen,
|
||||
blank=True,
|
||||
verbose_name=_('allergens'),
|
||||
)
|
||||
|
||||
expiry_date = models.DateTimeField(
|
||||
verbose_name=_('expiry date'),
|
||||
null=False,
|
||||
)
|
||||
|
||||
end_of_life = models.CharField(
|
||||
blank=True,
|
||||
verbose_name=_('end of life'),
|
||||
max_length=255,
|
||||
)
|
||||
|
||||
is_ready = models.BooleanField(
|
||||
verbose_name=_('is ready'),
|
||||
max_length=255,
|
||||
)
|
||||
|
||||
order = models.CharField(
|
||||
blank=True,
|
||||
verbose_name=_('order'),
|
||||
max_length=255,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@transaction.atomic
|
||||
def update_allergens(self):
|
||||
# update parents
|
||||
for parent in self.transformed_ingredient_inv.iterator():
|
||||
old_allergens = list(parent.allergens.all()).copy()
|
||||
parent.allergens.clear()
|
||||
for child in parent.ingredients.iterator():
|
||||
if child.pk != self.pk:
|
||||
parent.allergens.set(parent.allergens.union(child.allergens.all()))
|
||||
parent.allergens.set(parent.allergens.union(self.allergens.all()))
|
||||
if old_allergens != list(parent.allergens.all()):
|
||||
parent.save(old_allergens=old_allergens)
|
||||
|
||||
def update_expiry_date(self):
|
||||
# update parents
|
||||
for parent in self.transformed_ingredient_inv.iterator():
|
||||
old_expiry_date = parent.expiry_date
|
||||
parent.expiry_date = parent.shelf_life + parent.creation_date
|
||||
for child in parent.ingredients.iterator():
|
||||
if (child.pk != self.pk
|
||||
and not (child.polymorphic_ctype.model == 'basicfood'
|
||||
and child.date_type == 'DDM')):
|
||||
parent.expiry_date = min(parent.expiry_date, child.expiry_date)
|
||||
|
||||
if self.polymorphic_ctype.model == 'basicfood' and self.date_type == 'DLC':
|
||||
parent.expiry_date = min(parent.expiry_date, self.expiry_date)
|
||||
if old_expiry_date != parent.expiry_date:
|
||||
parent.save()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Food')
|
||||
verbose_name_plural = _('Foods')
|
||||
|
||||
|
||||
class BasicFood(Food):
|
||||
"""
|
||||
A basic food is a food directly buy and stored
|
||||
"""
|
||||
arrival_date = models.DateTimeField(
|
||||
default=timezone.now,
|
||||
verbose_name=_('arrival date'),
|
||||
)
|
||||
|
||||
date_type = models.CharField(
|
||||
max_length=255,
|
||||
choices=(
|
||||
("DLC", "DLC"),
|
||||
("DDM", "DDM"),
|
||||
)
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def save(self, force_insert=False, force_update=False, using=None, update_fields=None, **kwargs):
|
||||
created = self.pk is None
|
||||
if not created:
|
||||
# Check if important fields are updated
|
||||
old_food = Food.objects.select_for_update().get(pk=self.pk)
|
||||
if not hasattr(self, "_force_save"):
|
||||
# Allergens
|
||||
|
||||
if ('old_allergens' in kwargs
|
||||
and list(self.allergens.all()) != kwargs['old_allergens']):
|
||||
self.update_allergens()
|
||||
|
||||
# Expiry date
|
||||
if ((self.expiry_date != old_food.expiry_date
|
||||
and self.date_type == 'DLC')
|
||||
or old_food.date_type != self.date_type):
|
||||
self.update_expiry_date()
|
||||
|
||||
return super().save(force_insert, force_update, using, update_fields)
|
||||
|
||||
@staticmethod
|
||||
def get_lastests_objects(number, distinct_field, order_by_field):
|
||||
"""
|
||||
Get the last object with distinct field and ranked with order_by
|
||||
This methods exist because we can't distinct with one field and
|
||||
order with another
|
||||
"""
|
||||
foods = BasicFood.objects.order_by(order_by_field).all()
|
||||
field = []
|
||||
for food in foods:
|
||||
if getattr(food, distinct_field) in field:
|
||||
continue
|
||||
else:
|
||||
field.append(getattr(food, distinct_field))
|
||||
number -= 1
|
||||
yield food
|
||||
if not number:
|
||||
return
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Basic food')
|
||||
verbose_name_plural = _('Basic foods')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class TransformedFood(Food):
|
||||
"""
|
||||
A transformed food is a food with ingredients
|
||||
"""
|
||||
creation_date = models.DateTimeField(
|
||||
default=timezone.now,
|
||||
verbose_name=_('creation date'),
|
||||
)
|
||||
|
||||
# Without microbiological analyzes, the storage time is 3 days
|
||||
shelf_life = models.DurationField(
|
||||
default=timedelta(days=3),
|
||||
verbose_name=_('shelf life'),
|
||||
)
|
||||
|
||||
ingredients = models.ManyToManyField(
|
||||
Food,
|
||||
blank=True,
|
||||
symmetrical=False,
|
||||
related_name='transformed_ingredient_inv',
|
||||
verbose_name=_('transformed ingredient'),
|
||||
)
|
||||
|
||||
def check_cycle(self, ingredients, origin, checked):
|
||||
for ingredient in ingredients:
|
||||
if ingredient == origin:
|
||||
# We break the cycle
|
||||
self.ingredients.remove(ingredient)
|
||||
if ingredient.polymorphic_ctype.model == 'transformedfood' and ingredient not in checked:
|
||||
ingredient.check_cycle(ingredient.ingredients.all(), origin, checked)
|
||||
checked.append(ingredient)
|
||||
|
||||
@transaction.atomic
|
||||
def save(self, force_insert=False, force_update=False, using=None, update_fields=None, **kwargs):
|
||||
created = self.pk is None
|
||||
if not created:
|
||||
# Check if important fields are updated
|
||||
update = {'allergens': False, 'expiry_date': False}
|
||||
old_food = Food.objects.select_for_update().get(pk=self.pk)
|
||||
if not hasattr(self, "_force_save"):
|
||||
# Allergens
|
||||
# Unfortunately with the many-to-many relation we can't access
|
||||
# to old allergens
|
||||
if ('old_allergens' in kwargs
|
||||
and list(self.allergens.all()) != kwargs['old_allergens']):
|
||||
update['allergens'] = True
|
||||
|
||||
# Expiry date
|
||||
update['expiry_date'] = (self.shelf_life != old_food.shelf_life
|
||||
or self.creation_date != old_food.creation_date)
|
||||
if update['expiry_date']:
|
||||
self.expiry_date = self.creation_date + self.shelf_life
|
||||
# Unfortunately with the set method ingredients are already save,
|
||||
# we check cycle after if possible
|
||||
if ('old_ingredients' in kwargs
|
||||
and list(self.ingredients.all()) != list(kwargs['old_ingredients'])):
|
||||
update['allergens'] = True
|
||||
update['expiry_date'] = True
|
||||
|
||||
# it's preferable to keep a queryset but we allow list too
|
||||
if type(kwargs['old_ingredients']) is list:
|
||||
kwargs['old_ingredients'] = Food.objects.filter(
|
||||
pk__in=[food.pk for food in kwargs['old_ingredients']])
|
||||
self.check_cycle(self.ingredients.all().difference(kwargs['old_ingredients']), self, [])
|
||||
if update['allergens']:
|
||||
self.update_allergens()
|
||||
if update['expiry_date']:
|
||||
self.update_expiry_date()
|
||||
|
||||
if created:
|
||||
self.expiry_date = self.shelf_life + self.creation_date
|
||||
|
||||
# We save here because we need pk for many-to-many relation
|
||||
super().save(force_insert, force_update, using, update_fields)
|
||||
|
||||
for child in self.ingredients.iterator():
|
||||
self.allergens.set(self.allergens.union(child.allergens.all()))
|
||||
if not (child.polymorphic_ctype.model == 'basicfood' and child.date_type == 'DDM'):
|
||||
self.expiry_date = min(self.expiry_date, child.expiry_date)
|
||||
return super().save(force_insert, force_update, using, update_fields)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Transformed food')
|
||||
verbose_name_plural = _('Transformed foods')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class QRCode(models.Model):
|
||||
"""
|
||||
QR-code for register food
|
||||
"""
|
||||
qr_code_number = models.PositiveIntegerField(
|
||||
unique=True,
|
||||
verbose_name=_('qr code number'),
|
||||
)
|
||||
|
||||
food_container = models.ForeignKey(
|
||||
Food,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='QR_code',
|
||||
verbose_name=_('food container'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('QR-code')
|
||||
verbose_name_plural = _('QR-codes')
|
||||
|
||||
def __str__(self):
|
||||
return _('QR-code number') + ' ' + str(self.qr_code_number)
|
21
apps/food/tables.py
Normal file
21
apps/food/tables.py
Normal file
@ -0,0 +1,21 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import django_tables2 as tables
|
||||
|
||||
from .models import Food
|
||||
|
||||
|
||||
class FoodTable(tables.Table):
|
||||
"""
|
||||
List all foods.
|
||||
"""
|
||||
class Meta:
|
||||
model = Food
|
||||
template_name = 'django_tables2/bootstrap4.html'
|
||||
fields = ('name', 'owner', 'allergens', 'expiry_date')
|
||||
row_attrs = {
|
||||
'class': 'table-row',
|
||||
'data-href': lambda record: 'detail/' + str(record.pk),
|
||||
'style': 'cursor:pointer',
|
||||
}
|
48
apps/food/templates/food/food_detail.html
Normal file
48
apps/food/templates/food/food_detail.html
Normal file
@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
Copyright (C) by BDE ENS Paris-Saclay
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load i18n crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-white mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{{ title }} {{ food.name }}
|
||||
</h3>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
{% for field, value in fields %}
|
||||
<li> {{ field }} : {{ value }}</li>
|
||||
{% endfor %}
|
||||
{% if meals %}
|
||||
<li> {% trans "Contained in" %} :
|
||||
{% for meal in meals %}
|
||||
<a href="{% url "food:transformedfood_view" pk=meal.pk %}">{{ meal.name }}</a>{% if not forloop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if foods %}
|
||||
<li> {% trans "Contain" %} :
|
||||
{% for food in foods %}
|
||||
<a href="{% url "food:food_view" pk=food.pk %}">{{ food.name }}</a>{% if not forloop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% if update %}
|
||||
<a class="btn btn-sm btn-secondary" href="{% url "food:food_update" pk=food.pk %}">
|
||||
{% trans "Update" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if add_ingredient %}
|
||||
<a class="btn btn-sm btn-primary" href="{% url "food:add_ingredient" pk=food.pk %}">
|
||||
{% trans "Add to a meal" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-sm btn-primary" href="{% url "food:food_list" %}">
|
||||
{% trans "Return to the food list" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
71
apps/food/templates/food/food_list.html
Normal file
71
apps/food/templates/food/food_list.html
Normal file
@ -0,0 +1,71 @@
|
||||
{% extends "base_search.html" %}
|
||||
{% comment %}
|
||||
Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
{{ block.super }}
|
||||
<br>
|
||||
<div class="card bg-light mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{% trans "Meal served" %}
|
||||
</h3>
|
||||
{% if can_add_meal %}
|
||||
<div class="card-footer">
|
||||
<a class="btn btn-sm btn-primary" href="{% url 'food:transformedfood_create' %}">
|
||||
{% trans "New meal" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if served.data %}
|
||||
{% render_table served %}
|
||||
{% else %}
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
{% trans "There is no meal served." %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card bg-light mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{% trans "Free food" %}
|
||||
</h3>
|
||||
{% if open.data %}
|
||||
{% render_table open %}
|
||||
{% else %}
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
{% trans "There is no free food." %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if club_tables %}
|
||||
<div class="card bg-light mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{% trans "Food of your clubs" %}
|
||||
</h3>
|
||||
</div>
|
||||
{% for table in club_tables %}
|
||||
<div class="card bg-light mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{% trans "Food of club" %} {{ table.prefix }}
|
||||
</h3>
|
||||
{% if table.data %}
|
||||
{% render_table table %}
|
||||
{% else %}
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
{% trans "Yours club has not food yet." %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
21
apps/food/templates/food/food_update.html
Normal file
21
apps/food/templates/food/food_update.html
Normal file
@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
Copyright (C) by BDE ENS Paris-Saclay
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load i18n crispy_forms_tags %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-white mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="card-body" id="form">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form | crispy }}
|
||||
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
52
apps/food/templates/food/qrcode.html
Normal file
52
apps/food/templates/food/qrcode.html
Normal file
@ -0,0 +1,52 @@
|
||||
{% extends "base.html" %}
|
||||
{% comment %}
|
||||
Copyright (C) by BDE ENS Paris-Saclay
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
{% endcomment %}
|
||||
{% load i18n crispy_forms_tags %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card bg-white mb-3">
|
||||
<h3 class="card-header text-center">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="card-body" id="form">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form | crispy }}
|
||||
<button class="btn btn-primary" type="submit">{% trans "Submit"%}</button>
|
||||
</form>
|
||||
<div class="card-body">
|
||||
<h4>
|
||||
{% trans "Copy constructor" %}
|
||||
<a class="btn btn-secondary" href="{% url "food:basicfood_create" slug=slug %}">{% trans "New food" %}</a>
|
||||
</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="orderable">
|
||||
{% trans "Name" %}
|
||||
</th>
|
||||
<th class="orderable">
|
||||
{% trans "Owner" %}
|
||||
</th>
|
||||
<th class="orderable">
|
||||
{% trans "Expiry date" %}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for food in last_items %}
|
||||
<tr>
|
||||
<td><a href="{% url "food:basicfood_create" slug=slug %}?copy={{ food.pk }}">{{ food.name }}</a></td>
|
||||
<td>{{ food.owner }}</td>
|
||||
<td>{{ food.expiry_date }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
170
apps/food/tests/test_food.py
Normal file
170
apps/food/tests/test_food.py
Normal file
@ -0,0 +1,170 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from api.tests import TestAPI
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from ..api.views import AllergenViewSet, BasicFoodViewSet, TransformedFoodViewSet, QRCodeViewSet
|
||||
from ..models import Allergen, BasicFood, TransformedFood, QRCode
|
||||
|
||||
|
||||
class TestFood(TestCase):
|
||||
"""
|
||||
Test food
|
||||
"""
|
||||
fixtures = ('initial',)
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_superuser(
|
||||
username='admintoto',
|
||||
password='toto1234',
|
||||
email='toto@example.com'
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
sess = self.client.session
|
||||
sess['permission_mask'] = 42
|
||||
sess.save()
|
||||
|
||||
self.allergen = Allergen.objects.create(
|
||||
name='allergen',
|
||||
)
|
||||
|
||||
self.basicfood = BasicFood.objects.create(
|
||||
name='basicfood',
|
||||
owner_id=1,
|
||||
expiry_date=timezone.now(),
|
||||
is_ready=False,
|
||||
date_type='DLC',
|
||||
)
|
||||
|
||||
self.transformedfood = TransformedFood.objects.create(
|
||||
name='transformedfood',
|
||||
owner_id=1,
|
||||
expiry_date=timezone.now(),
|
||||
is_ready=False,
|
||||
)
|
||||
|
||||
self.qrcode = QRCode.objects.create(
|
||||
qr_code_number=1,
|
||||
food_container=self.basicfood,
|
||||
)
|
||||
|
||||
def test_food_list(self):
|
||||
"""
|
||||
Display food list
|
||||
"""
|
||||
response = self.client.get(reverse('food:food_list'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_qrcode_create(self):
|
||||
"""
|
||||
Display QRCode creation
|
||||
"""
|
||||
response = self.client.get(reverse('food:qrcode_create'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_basicfood_create(self):
|
||||
"""
|
||||
Display BasicFood creation
|
||||
"""
|
||||
response = self.client.get(reverse('food:basicfood_create'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_transformedfood_create(self):
|
||||
"""
|
||||
Display TransformedFood creation
|
||||
"""
|
||||
response = self.client.get(reverse('food:transformedfood_create'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_food_create(self):
|
||||
"""
|
||||
Display Food update
|
||||
"""
|
||||
response = self.client.get(reverse('food:food_update'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_food_view(self):
|
||||
"""
|
||||
Display Food detail
|
||||
"""
|
||||
response = self.client.get(reverse('food:food_view'))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_basicfood_view(self):
|
||||
"""
|
||||
Display BasicFood detail
|
||||
"""
|
||||
response = self.client.get(reverse('food:basicfood_view'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_transformedfood_view(self):
|
||||
"""
|
||||
Display TransformedFood detail
|
||||
"""
|
||||
response = self.client.get(reverse('food:transformedfood_view'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_add_ingredient(self):
|
||||
"""
|
||||
Display add ingredient view
|
||||
"""
|
||||
response = self.client.get(reverse('food:add_ingredient'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class TestFoodAPI(TestAPI):
|
||||
def setUp(self) -> None:
|
||||
super().setUP()
|
||||
|
||||
self.allergen = Allergen.objects.create(
|
||||
name='name',
|
||||
)
|
||||
|
||||
self.basicfood = BasicFood.objects.create(
|
||||
name='basicfood',
|
||||
owner_id=1,
|
||||
expiry_date=timezone.now(),
|
||||
is_ready=False,
|
||||
date_type='DLC',
|
||||
)
|
||||
|
||||
self.transformedfood = TransformedFood.objects.create(
|
||||
name='transformedfood',
|
||||
owner_id=1,
|
||||
expiry_date=timezone.now(),
|
||||
is_ready=False,
|
||||
)
|
||||
|
||||
self.qrcode = QRCode.objects.create(
|
||||
qr_code_number=1,
|
||||
food_container=self.basicfood,
|
||||
)
|
||||
|
||||
def test_allergen_api(self):
|
||||
"""
|
||||
Load Allergen API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(AllergenViewSet, '/api/food/allergen/')
|
||||
|
||||
def test_basicfood_api(self):
|
||||
"""
|
||||
Load BasicFood API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(BasicFoodViewSet, '/api/food/basicfood/')
|
||||
|
||||
def test_transformedfood_api(self):
|
||||
"""
|
||||
Load TransformedFood API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(TransformedFoodViewSet, '/api/food/transformedfood/')
|
||||
|
||||
def test_qrcode_api(self):
|
||||
"""
|
||||
Load QRCode API page and test all filters and permissions
|
||||
"""
|
||||
self.check_viewset(QRCodeViewSet, '/api/food/qrcode/')
|
20
apps/food/urls.py
Normal file
20
apps/food/urls.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = 'food'
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.FoodListView.as_view(), name='food_list'),
|
||||
path('<int:slug>', views.QRCodeCreateView.as_view(), name='qrcode_create'),
|
||||
path('<int:slug>/add/basic', views.BasicFoodCreateView.as_view(), name='basicfood_create'),
|
||||
path('add/transformed', views.TransformedFoodCreateView.as_view(), name='transformedfood_create'),
|
||||
path('update/<int:pk>', views.FoodUpdateView.as_view(), name='food_update'),
|
||||
path('detail/<int:pk>', views.FoodDetailView.as_view(), name='food_view'),
|
||||
path('detail/basic/<int:pk>', views.BasicFoodDetailView.as_view(), name='basicfood_view'),
|
||||
path('detail/transformed/<int:pk>', views.TransformedFoodDetailView.as_view(), name='transformedfood_view'),
|
||||
path('add/ingredient/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'),
|
||||
]
|
53
apps/food/utils.py
Normal file
53
apps/food/utils.py
Normal file
@ -0,0 +1,53 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
seconds = (_('second'), _('seconds'))
|
||||
minutes = (_('minute'), _('minutes'))
|
||||
hours = (_('hour'), _('hours'))
|
||||
days = (_('day'), _('days'))
|
||||
weeks = (_('week'), _('weeks'))
|
||||
|
||||
|
||||
def plural(x):
|
||||
if x == 1:
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
def pretty_duration(duration):
|
||||
"""
|
||||
I receive datetime.timedelta object
|
||||
You receive string object
|
||||
"""
|
||||
text = []
|
||||
sec = duration.seconds
|
||||
d = duration.days
|
||||
|
||||
if d >= 7:
|
||||
w = d // 7
|
||||
text.append(str(w) + ' ' + weeks[plural(w)])
|
||||
d -= w * 7
|
||||
if d > 0:
|
||||
text.append(str(d) + ' ' + days[plural(d)])
|
||||
|
||||
if sec >= 3600:
|
||||
h = sec // 3600
|
||||
text.append(str(h) + ' ' + hours[plural(h)])
|
||||
sec -= h * 3600
|
||||
|
||||
if sec >= 60:
|
||||
m = sec // 60
|
||||
text.append(str(m) + ' ' + minutes[plural(m)])
|
||||
sec -= m * 60
|
||||
|
||||
if sec > 0:
|
||||
text.append(str(sec) + ' ' + seconds[plural(sec)])
|
||||
|
||||
if len(text) == 0:
|
||||
return ''
|
||||
if len(text) == 1:
|
||||
return text[0]
|
||||
if len(text) >= 2:
|
||||
return ', '.join(t for t in text[:-1]) + ' ' + _('and') + ' ' + text[-1]
|
408
apps/food/views.py
Normal file
408
apps/food/views.py
Normal file
@ -0,0 +1,408 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from api.viewsets import is_regex
|
||||
from django_tables2.views import MultiTableMixin
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.views.generic import DetailView, UpdateView
|
||||
from django.views.generic.list import ListView
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from member.models import Club, Membership
|
||||
from permission.backends import PermissionBackend
|
||||
from permission.views import ProtectQuerysetMixin, ProtectedCreateView, LoginRequiredMixin
|
||||
|
||||
from .models import Food, BasicFood, TransformedFood, QRCode
|
||||
from .forms import AddIngredientForms, BasicFoodForms, TransformedFoodForms, BasicFoodUpdateForms, TransformedFoodUpdateForms, QRCodeForms
|
||||
from .tables import FoodTable
|
||||
from .utils import pretty_duration
|
||||
|
||||
|
||||
class FoodListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
|
||||
"""
|
||||
Display Food
|
||||
"""
|
||||
model = Food
|
||||
tables = [FoodTable, FoodTable, FoodTable, ]
|
||||
extra_context = {"title": _('Food')}
|
||||
template_name = 'food/food_list.html'
|
||||
|
||||
def get_queryset(self, **kwargs):
|
||||
return super().get_queryset(**kwargs).distinct()
|
||||
|
||||
def get_tables(self):
|
||||
bureau_role_pk = 4
|
||||
clubs = Club.objects.filter(membership__in=Membership.objects.filter(
|
||||
user=self.request.user, roles=bureau_role_pk).filter(
|
||||
date_end__gte=timezone.now()))
|
||||
|
||||
tables = [FoodTable] * (clubs.count() + 3)
|
||||
self.tables = tables
|
||||
tables = super().get_tables()
|
||||
tables[0].prefix = 'search-'
|
||||
tables[1].prefix = 'open-'
|
||||
tables[2].prefix = 'served-'
|
||||
for i in range(clubs.count()):
|
||||
tables[i + 3].prefix = clubs[i].name
|
||||
return tables
|
||||
|
||||
def get_tables_data(self):
|
||||
# table search
|
||||
qs = self.get_queryset().order_by('name')
|
||||
if "search" in self.request.GET and self.request.GET['search']:
|
||||
pattern = self.request.GET['search']
|
||||
|
||||
# check regex
|
||||
valid_regex = is_regex(pattern)
|
||||
suffix = '__iregex' if valid_regex else '__istartswith'
|
||||
prefix = '^' if valid_regex else ''
|
||||
qs = qs.filter(Q(**{f'name{suffix}': prefix + pattern}))
|
||||
else:
|
||||
qs = qs.none()
|
||||
search_table = qs.filter(PermissionBackend.filter_queryset(self.request, Food, 'view'))
|
||||
# table open
|
||||
open_table = self.get_queryset().order_by('expiry_date').filter(
|
||||
Q(polymorphic_ctype__model='transformedfood')
|
||||
| Q(polymorphic_ctype__model='basicfood', basicfood__date_type='DLC')).filter(
|
||||
expiry_date__lt=timezone.now()).filter(
|
||||
PermissionBackend.filter_queryset(self.request, Food, 'view'))
|
||||
# table served
|
||||
served_table = self.get_queryset().order_by('-pk').filter(
|
||||
end_of_life='', is_ready=True).filter(
|
||||
Q(polymorphic_ctype__model='basicfood',
|
||||
basicfood__date_type='DLC',
|
||||
expiry_date__lte=timezone.now(),)
|
||||
| Q(polymorphic_ctype__model='transformedfood',
|
||||
expiry_date__lte=timezone.now(),
|
||||
))
|
||||
# tables club
|
||||
bureau_role_pk = 4
|
||||
clubs = Club.objects.filter(membership__in=Membership.objects.filter(
|
||||
user=self.request.user, roles=bureau_role_pk).filter(
|
||||
date_end__gte=timezone.now()))
|
||||
club_table = []
|
||||
for club in clubs:
|
||||
club_table.append(self.get_queryset().order_by('expiry_date').filter(
|
||||
owner=club, end_of_life='').filter(
|
||||
PermissionBackend.filter_queryset(self.request, Food, 'view')
|
||||
))
|
||||
return [search_table, open_table, served_table] + club_table
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
tables = context['tables']
|
||||
# for extends base_search.html we need to name 'search_table' in 'table'
|
||||
for name, table in zip(['table', 'open', 'served'], tables):
|
||||
context[name] = table
|
||||
context['club_tables'] = tables[3:]
|
||||
|
||||
context['can_add_meal'] = PermissionBackend.check_perm(self.request, 'food.transformedfood_add')
|
||||
return context
|
||||
|
||||
|
||||
class QRCodeCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
"""
|
||||
A view to add qrcode
|
||||
"""
|
||||
model = QRCode
|
||||
template_name = 'food/qrcode.html'
|
||||
form_class = QRCodeForms
|
||||
extra_context = {"title": _("Add a new QRCode")}
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
qrcode = kwargs["slug"]
|
||||
if self.model.objects.filter(qr_code_number=qrcode).count() > 0:
|
||||
pk = self.model.objects.get(qr_code_number=qrcode).food_container.pk
|
||||
return HttpResponseRedirect(reverse_lazy("food:food_view", kwargs={"pk": pk}))
|
||||
else:
|
||||
return super().get(*args, **kwargs)
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
qrcode_food_form = QRCodeForms(data=self.request.POST)
|
||||
if not qrcode_food_form.is_valid():
|
||||
return self.form_invalid(form)
|
||||
|
||||
qrcode = form.save(commit=False)
|
||||
qrcode.qr_code_number = self.kwargs['slug']
|
||||
qrcode._force_save = True
|
||||
qrcode.save()
|
||||
qrcode.refresh_from_db()
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['slug'] = self.kwargs['slug']
|
||||
|
||||
# get last 10 BasicFood objects with distincts 'name' ordered by '-pk'
|
||||
# we can't use .distinct and .order_by with differents columns hence the generator
|
||||
context['last_items'] = [food for food in BasicFood.get_lastests_objects(10, 'name', '-pk')]
|
||||
return context
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
self.object.refresh_from_db()
|
||||
return reverse_lazy('food:food_view', kwargs={'pk': self.object.food_container.pk})
|
||||
|
||||
def get_sample_object(self):
|
||||
return QRCode(
|
||||
qr_code_number=self.kwargs['slug'],
|
||||
food_container_id=1,
|
||||
)
|
||||
|
||||
|
||||
class BasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
"""
|
||||
A view to add basicfood
|
||||
"""
|
||||
model = BasicFood
|
||||
form_class = BasicFoodForms
|
||||
extra_context = {"title": _("Add an aliment")}
|
||||
template_name = "food/food_update.html"
|
||||
|
||||
def get_sample_object(self):
|
||||
return BasicFood(
|
||||
name="",
|
||||
owner_id=1,
|
||||
expiry_date=timezone.now(),
|
||||
is_ready=True,
|
||||
arrival_date=timezone.now(),
|
||||
date_type='DLC',
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
if QRCode.objects.filter(qr_code_number=self.kwargs['slug']).count() > 0:
|
||||
return HttpResponseRedirect(reverse_lazy('food:qrcode_create', kwargs={'slug': self.kwargs['slug']}))
|
||||
food_form = BasicFoodForms(data=self.request.POST)
|
||||
if not food_form.is_valid():
|
||||
return self.form_invalid(form)
|
||||
|
||||
food = form.save(commit=False)
|
||||
food.is_ready = False
|
||||
food.save()
|
||||
food.refresh_from_db()
|
||||
|
||||
qrcode = QRCode()
|
||||
qrcode.qr_code_number = self.kwargs['slug']
|
||||
qrcode.food_container = food
|
||||
qrcode.save()
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
self.object.refresh_from_db()
|
||||
return reverse_lazy('food:basicfood_view', kwargs={"pk": self.object.pk})
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
context = super().get_context_data(*args, **kwargs)
|
||||
|
||||
copy = self.request.GET.get('copy', None)
|
||||
if copy is not None:
|
||||
food = BasicFood.objects.get(pk=copy)
|
||||
print(context['form'].fields)
|
||||
for field in context['form'].fields:
|
||||
if field == 'allergens':
|
||||
context['form'].fields[field].initial = getattr(food, field).all()
|
||||
else:
|
||||
context['form'].fields[field].initial = getattr(food, field)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class TransformedFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||
"""
|
||||
A view to add transformedfood
|
||||
"""
|
||||
model = TransformedFood
|
||||
form_class = TransformedFoodForms
|
||||
extra_context = {"title": _("Add a meal")}
|
||||
template_name = "food/food_update.html"
|
||||
|
||||
def get_sample_object(self):
|
||||
return TransformedFood(
|
||||
name="",
|
||||
owner_id=1,
|
||||
expiry_date=timezone.now(),
|
||||
is_ready=True,
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
form.instance.expiry_date = timezone.now() + timedelta(days=3)
|
||||
form.instance.is_ready = False
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
self.object.refresh_from_db()
|
||||
return reverse_lazy('food:transformedfood_view', kwargs={"pk": self.object.pk})
|
||||
|
||||
|
||||
class AddIngredientView(ProtectQuerysetMixin, UpdateView):
|
||||
"""
|
||||
A view to add ingredient to a meal
|
||||
"""
|
||||
model = Food
|
||||
extra_context = {"title": _("Add the ingredient:")}
|
||||
form_class = AddIngredientForms
|
||||
template_name = 'food/food_update.html'
|
||||
|
||||
def get_context_data(self, *args, **kwargs):
|
||||
context = super().get_context_data(*args, **kwargs)
|
||||
context['title'] += ' ' + self.object.name
|
||||
return context
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
meals = TransformedFood.objects.filter(pk__in=form.data.getlist('ingredients')).all()
|
||||
for meal in meals:
|
||||
old_ingredients = list(meal.ingredients.all()).copy()
|
||||
old_allergens = list(meal.allergens.all()).copy()
|
||||
meal.ingredients.add(self.object.pk)
|
||||
# update allergen and expiry date if necessary
|
||||
if not (self.object.polymorphic_ctype.model == 'basicfood'
|
||||
and self.object.date_type == 'DDM'):
|
||||
meal.expiry_date = min(meal.expiry_date, self.object.expiry_date)
|
||||
meal.allergens.set(meal.allergens.union(self.object.allergens.all()))
|
||||
meal.save(old_ingredients=old_ingredients, old_allergens=old_allergens)
|
||||
if 'fully_used' in form.data:
|
||||
if not self.object.end_of_life:
|
||||
self.object.end_of_life = _(f'Food fully used in : {meal.name}')
|
||||
else:
|
||||
self.object.end_of_life += ', ' + meal.name
|
||||
if 'fully_used' in form.data:
|
||||
self.object.is_ready = False
|
||||
self.object.save()
|
||||
# We redirect only the first parent
|
||||
parent_pk = meals[0].pk
|
||||
return HttpResponseRedirect(self.get_success_url(parent_pk=parent_pk))
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
return reverse_lazy('food:transformedfood_view', kwargs={"pk": kwargs['parent_pk']})
|
||||
|
||||
|
||||
class FoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
A view to update Food
|
||||
"""
|
||||
model = Food
|
||||
extra_context = {"title": _("Update an aliment")}
|
||||
template_name = 'food/food_update.html'
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
form.instance.creater = self.request.user
|
||||
food = Food.objects.get(pk=self.kwargs['pk'])
|
||||
old_allergens = list(food.allergens.all()).copy()
|
||||
|
||||
if food.polymorphic_ctype.model == 'transformedfood':
|
||||
old_ingredients = food.ingredients.all()
|
||||
form.instance.shelf_life = timedelta(
|
||||
seconds=int(form.data['shelf_life']) * 60 * 60)
|
||||
|
||||
food_form = self.get_form_class()(data=self.request.POST)
|
||||
if not food_form.is_valid():
|
||||
return self.form_invalid(form)
|
||||
ans = super().form_valid(form)
|
||||
if food.polymorphic_ctype.model == 'transformedfood':
|
||||
form.instance.save(old_ingredients=old_ingredients)
|
||||
else:
|
||||
form.instance.save(old_allergens=old_allergens)
|
||||
return ans
|
||||
|
||||
def get_form_class(self, **kwargs):
|
||||
food = Food.objects.get(pk=self.kwargs['pk'])
|
||||
if food.polymorphic_ctype.model == 'basicfood':
|
||||
return BasicFoodUpdateForms
|
||||
else:
|
||||
return TransformedFoodUpdateForms
|
||||
|
||||
def get_form(self, **kwargs):
|
||||
form = super().get_form(**kwargs)
|
||||
if 'shelf_life' in form.initial:
|
||||
hours = form.initial['shelf_life'].days * 24 + form.initial['shelf_life'].seconds // 3600
|
||||
form.initial['shelf_life'] = hours
|
||||
return form
|
||||
|
||||
def get_success_url(self, **kwargs):
|
||||
self.object.refresh_from_db()
|
||||
return reverse_lazy('food:food_view', kwargs={"pk": self.object.pk})
|
||||
|
||||
|
||||
class FoodDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
A view to see a food
|
||||
"""
|
||||
model = Food
|
||||
extra_context = {"title": _('Details of:')}
|
||||
context_object_name = "food"
|
||||
template_name = "food/food_detail.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
fields = ["name", "owner", "expiry_date", "allergens", "is_ready", "end_of_life", "order"]
|
||||
|
||||
fields = dict([(field, getattr(self.object, field)) for field in fields])
|
||||
if fields["is_ready"]:
|
||||
fields["is_ready"] = _("Yes")
|
||||
else:
|
||||
fields["is_ready"] = _("No")
|
||||
fields["allergens"] = ", ".join(
|
||||
allergen.name for allergen in fields["allergens"].all())
|
||||
|
||||
context["fields"] = [(
|
||||
Food._meta.get_field(field).verbose_name.capitalize(),
|
||||
value) for field, value in fields.items()]
|
||||
context["meals"] = self.object.transformed_ingredient_inv.all()
|
||||
context["update"] = PermissionBackend.check_perm(self.request, "food.change_food")
|
||||
context["add_ingredient"] = (self.object.end_of_life == '' and PermissionBackend.check_perm(self.request, "food.change_transformedfood"))
|
||||
return context
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
model = Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model
|
||||
if 'stop_redirect' in kwargs and kwargs['stop_redirect']:
|
||||
return super().get(*args, **kwargs)
|
||||
kwargs = {'pk': kwargs['pk']}
|
||||
if model == 'basicfood':
|
||||
return HttpResponseRedirect(reverse_lazy("food:basicfood_view", kwargs=kwargs))
|
||||
return HttpResponseRedirect(reverse_lazy("food:transformedfood_view", kwargs=kwargs))
|
||||
|
||||
|
||||
class BasicFoodDetailView(FoodDetailView):
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
fields = ['arrival_date', 'date_type']
|
||||
for field in fields:
|
||||
context["fields"].append((
|
||||
BasicFood._meta.get_field(field).verbose_name.capitalize(),
|
||||
getattr(self.object, field)
|
||||
))
|
||||
return context
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
kwargs['stop_redirect'] = (Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model == 'basicfood')
|
||||
return super().get(*args, **kwargs)
|
||||
|
||||
|
||||
class TransformedFoodDetailView(FoodDetailView):
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["fields"].append((
|
||||
TransformedFood._meta.get_field("creation_date").verbose_name.capitalize(),
|
||||
self.object.creation_date
|
||||
))
|
||||
context["fields"].append((
|
||||
TransformedFood._meta.get_field("shelf_life").verbose_name.capitalize(),
|
||||
pretty_duration(self.object.shelf_life)
|
||||
))
|
||||
context["foods"] = self.object.ingredients.all()
|
||||
return context
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
kwargs['stop_redirect'] = (Food.objects.get(pk=kwargs['pk']).polymorphic_ctype.model == 'transformedfood')
|
||||
return super().get(*args, **kwargs)
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
default_app_config = 'logs.apps.LogsConfig'
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from rest_framework import serializers
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from .views import ChangelogViewSet
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
@ -15,7 +15,7 @@ class ChangelogViewSet(ReadOnlyProtectedModelViewSet):
|
||||
The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/logs/
|
||||
"""
|
||||
queryset = Changelog.objects.all()
|
||||
queryset = Changelog.objects.order_by('id')
|
||||
serializer_class = ChangelogSerializer
|
||||
filter_backends = [DjangoFilterBackend, OrderingFilter]
|
||||
filterset_fields = ['model', 'action', "instance_pk", 'user', 'ip', ]
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.conf import settings
|
||||
@ -76,9 +76,6 @@ class Changelog(models.Model):
|
||||
verbose_name=_('timestamp'),
|
||||
)
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
raise ValidationError(_("Logs cannot be destroyed."))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("changelog")
|
||||
verbose_name_plural = _("changelogs")
|
||||
@ -86,3 +83,6 @@ class Changelog(models.Model):
|
||||
def __str__(self):
|
||||
return _("Changelog of type \"{action}\" for model {model} at {timestamp}").format(
|
||||
action=self.get_action_display(), model=str(self.model), timestamp=str(self.timestamp))
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
raise ValidationError(_("Logs cannot be destroyed."))
|
||||
|
@ -1,11 +1,11 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from rest_framework.renderers import JSONRenderer
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from note.models import NoteUser, Alias
|
||||
from note_kfet.middlewares import get_current_authenticated_user, get_current_ip
|
||||
from note_kfet.middlewares import get_current_request
|
||||
|
||||
from .models import Changelog
|
||||
|
||||
@ -56,13 +56,13 @@ def save_object(sender, instance, **kwargs):
|
||||
# noinspection PyProtectedMember
|
||||
previous = instance._previous
|
||||
|
||||
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
|
||||
user, ip = get_current_authenticated_user(), get_current_ip()
|
||||
# Si un⋅e utilisateur⋅rice est connecté⋅e, on récupère l'utilisateur⋅rice courant⋅e ainsi que son adresse IP
|
||||
request = get_current_request()
|
||||
|
||||
if user is None:
|
||||
if request is None:
|
||||
# Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py`
|
||||
# On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée
|
||||
# IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info
|
||||
# IMPORTANT : l'utilisateur⋅rice dans la VM doit être un des alias note du respo info
|
||||
ip = "127.0.0.1"
|
||||
username = Alias.normalize(getpass.getuser())
|
||||
note = NoteUser.objects.filter(alias__normalized_name=username)
|
||||
@ -71,9 +71,23 @@ def save_object(sender, instance, **kwargs):
|
||||
# else:
|
||||
if note.exists():
|
||||
user = note.get().user
|
||||
else:
|
||||
user = None
|
||||
else:
|
||||
user = request.user
|
||||
if 'HTTP_X_REAL_IP' in request.META:
|
||||
ip = request.META.get('HTTP_X_REAL_IP')
|
||||
elif 'HTTP_X_FORWARDED_FOR' in request.META:
|
||||
ip = request.META.get('HTTP_X_FORWARDED_FOR').split(', ')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
|
||||
if not user.is_authenticated:
|
||||
# For registration and OAuth2 purposes
|
||||
user = None
|
||||
|
||||
# noinspection PyProtectedMember
|
||||
if user is not None and instance._meta.label_lower == "auth.user" and previous:
|
||||
if request is not None and instance._meta.label_lower == "auth.user" and previous:
|
||||
# On n'enregistre pas les connexions
|
||||
if instance.last_login != previous.last_login:
|
||||
return
|
||||
@ -120,13 +134,13 @@ def delete_object(sender, instance, **kwargs):
|
||||
if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal"):
|
||||
return
|
||||
|
||||
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
|
||||
user, ip = get_current_authenticated_user(), get_current_ip()
|
||||
# Si un⋅e utilisateur⋅rice est connecté⋅e, on récupère l'utilisateur⋅rice courant⋅e ainsi que son adresse IP
|
||||
request = get_current_request()
|
||||
|
||||
if user is None:
|
||||
if request is None:
|
||||
# Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py`
|
||||
# On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée
|
||||
# IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info
|
||||
# IMPORTANT : l'utilisateur⋅rice dans la VM doit être un des alias note du respo info
|
||||
ip = "127.0.0.1"
|
||||
username = Alias.normalize(getpass.getuser())
|
||||
note = NoteUser.objects.filter(alias__normalized_name=username)
|
||||
@ -135,6 +149,20 @@ def delete_object(sender, instance, **kwargs):
|
||||
# else:
|
||||
if note.exists():
|
||||
user = note.get().user
|
||||
else:
|
||||
user = None
|
||||
else:
|
||||
user = request.user
|
||||
if 'HTTP_X_REAL_IP' in request.META:
|
||||
ip = request.META.get('HTTP_X_REAL_IP')
|
||||
elif 'HTTP_X_FORWARDED_FOR' in request.META:
|
||||
ip = request.META.get('HTTP_X_FORWARDED_FOR').split(', ')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
|
||||
if not user.is_authenticated:
|
||||
# For registration and OAuth2 purposes
|
||||
user = None
|
||||
|
||||
# On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles
|
||||
class CustomSerializer(ModelSerializer):
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
default_app_config = 'member.apps.MemberConfig'
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.contrib import admin
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from rest_framework import serializers
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from .views import ProfileViewSet, ClubViewSet, MembershipViewSet
|
||||
|
@ -1,7 +1,9 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from rest_framework.filters import SearchFilter
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from api.filters import RegexSafeSearchFilter
|
||||
from api.viewsets import ReadProtectedModelViewSet
|
||||
|
||||
from .serializers import ProfileSerializer, ClubSerializer, MembershipSerializer
|
||||
@ -14,8 +16,15 @@ class ProfileViewSet(ReadProtectedModelViewSet):
|
||||
The djangorestframework plugin will get all `Profile` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/members/profile/
|
||||
"""
|
||||
queryset = Profile.objects.all()
|
||||
queryset = Profile.objects.order_by('id')
|
||||
serializer_class = ProfileSerializer
|
||||
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||
filterset_fields = ['user', 'user__first_name', 'user__last_name', 'user__username', 'user__email',
|
||||
'user__note__alias__name', 'user__note__alias__normalized_name', 'phone_number', "section",
|
||||
'department', 'promotion', 'address', 'paid', 'ml_events_registration', 'ml_sport_registration',
|
||||
'ml_art_registration', 'report_frequency', 'email_confirmed', 'registration_valid', ]
|
||||
search_fields = ['$user__first_name', '$user__last_name', '$user__username', '$user__email',
|
||||
'$user__note__alias__name', '$user__note__alias__normalized_name', ]
|
||||
|
||||
|
||||
class ClubViewSet(ReadProtectedModelViewSet):
|
||||
@ -24,10 +33,13 @@ class ClubViewSet(ReadProtectedModelViewSet):
|
||||
The djangorestframework plugin will get all `Club` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/members/club/
|
||||
"""
|
||||
queryset = Club.objects.all()
|
||||
queryset = Club.objects.order_by('id')
|
||||
serializer_class = ClubSerializer
|
||||
filter_backends = [SearchFilter]
|
||||
search_fields = ['$name', ]
|
||||
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||
filterset_fields = ['name', 'email', 'note__alias__name', 'note__alias__normalized_name', 'parent_club',
|
||||
'parent_club__name', 'require_memberships', 'membership_fee_paid', 'membership_fee_unpaid',
|
||||
'membership_duration', 'membership_start', 'membership_end', ]
|
||||
search_fields = ['$name', '$email', '$note__alias__name', '$note__alias__normalized_name', ]
|
||||
|
||||
|
||||
class MembershipViewSet(ReadProtectedModelViewSet):
|
||||
@ -36,5 +48,14 @@ class MembershipViewSet(ReadProtectedModelViewSet):
|
||||
The djangorestframework plugin will get all `Membership` objects, serialize it to JSON with the given serializer,
|
||||
then render it on /api/members/membership/
|
||||
"""
|
||||
queryset = Membership.objects.all()
|
||||
queryset = Membership.objects.order_by('id')
|
||||
serializer_class = MembershipSerializer
|
||||
filter_backends = [DjangoFilterBackend, OrderingFilter, RegexSafeSearchFilter]
|
||||
filterset_fields = ['club__name', 'club__email', 'club__note__alias__name', 'club__note__alias__normalized_name',
|
||||
'user__username', 'user__last_name', 'user__first_name', 'user__email',
|
||||
'user__note__alias__name', 'user__note__alias__normalized_name',
|
||||
'date_start', 'date_end', 'fee', 'roles', ]
|
||||
ordering_fields = ['id', 'date_start', 'date_end', ]
|
||||
search_fields = ['$club__name', '$club__email', '$club__note__alias__name', '$club__note__alias__normalized_name',
|
||||
'$user__username', '$user__last_name', '$user__first_name', '$user__email',
|
||||
'$user__note__alias__name', '$user__note__alias__normalized_name', '$roles__name', ]
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
17
apps/member/auth.py
Normal file
17
apps/member/auth.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from cas_server.auth import DjangoAuthUser # pragma: no cover
|
||||
from note.models import Alias
|
||||
|
||||
|
||||
class CustomAuthUser(DjangoAuthUser): # pragma: no cover
|
||||
"""
|
||||
Override Django Auth User model to define a custom Matrix username.
|
||||
"""
|
||||
|
||||
def attributs(self):
|
||||
d = super().attributs()
|
||||
if self.user:
|
||||
d["normalized_name"] = Alias.normalize(self.user.username)
|
||||
return d
|
@ -1,9 +1,9 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import io
|
||||
|
||||
from PIL import Image, ImageSequence
|
||||
from bootstrap_datepicker_plus.widgets import DatePickerInput
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.forms import AuthenticationForm
|
||||
@ -13,8 +13,9 @@ from django.forms import CheckboxSelectMultiple
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from note.models import NoteSpecial, Alias
|
||||
from note_kfet.inputs import Autocomplete, AmountInput, DatePickerInput
|
||||
from note_kfet.inputs import Autocomplete, AmountInput
|
||||
from permission.models import PermissionMask, Role
|
||||
from PIL import Image, ImageSequence
|
||||
|
||||
from .models import Profile, Club, Membership
|
||||
|
||||
@ -22,7 +23,7 @@ from .models import Profile, Club, Membership
|
||||
class CustomAuthenticationForm(AuthenticationForm):
|
||||
permission_mask = forms.ModelChoiceField(
|
||||
label=_("Permission mask"),
|
||||
queryset=PermissionMask.objects.order_by("rank"),
|
||||
queryset=PermissionMask.objects.order_by("-rank"),
|
||||
empty_label=None,
|
||||
)
|
||||
|
||||
@ -32,7 +33,7 @@ class UserForm(forms.ModelForm):
|
||||
# Django usernames can only contain letters, numbers, @, ., +, - and _.
|
||||
# We want to allow users to have uncommon and unpractical usernames:
|
||||
# That is their problem, and we have normalized aliases for us.
|
||||
return super()._get_validation_exclusions() + ["username"]
|
||||
return super()._get_validation_exclusions() | {"username"}
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
@ -43,10 +44,18 @@ class ProfileForm(forms.ModelForm):
|
||||
"""
|
||||
A form for the extras field provided by the :model:`member.Profile` model.
|
||||
"""
|
||||
# Remove widget=forms.HiddenInput() if you want to use report frequency.
|
||||
report_frequency = forms.IntegerField(required=False, initial=0, label=_("Report frequency"))
|
||||
|
||||
last_report = forms.DateTimeField(required=False, disabled=True, label=_("Last report date"))
|
||||
|
||||
VSS_charter_read = forms.BooleanField(
|
||||
required=True,
|
||||
label=_("Anti-VSS (<em>Violences Sexistes et Sexuelles</em>) charter read and approved"),
|
||||
help_text=_("Tick after having read and accepted the anti-VSS charter \
|
||||
<a href=https://perso.crans.org/club-bde/Charte-anti-VSS.pdf target=_blank> available here in pdf</a>")
|
||||
)
|
||||
|
||||
def clean_promotion(self):
|
||||
promotion = self.cleaned_data["promotion"]
|
||||
if promotion > timezone.now().year:
|
||||
@ -68,7 +77,8 @@ class ProfileForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Profile
|
||||
fields = '__all__'
|
||||
exclude = ('user', 'email_confirmed', 'registration_valid', )
|
||||
# Remove ml_[asso]_registration from exclude if the concerned association uses nk20 to manage its mailing list.
|
||||
exclude = ('user', 'email_confirmed', 'registration_valid', 'ml_sport_registration', )
|
||||
|
||||
|
||||
class ImageForm(forms.Form):
|
||||
@ -114,7 +124,7 @@ class ImageForm(forms.Form):
|
||||
frame = frame.crop((x, y, x + w, y + h))
|
||||
frame = frame.resize(
|
||||
(settings.PIC_WIDTH, settings.PIC_RATIO * settings.PIC_WIDTH),
|
||||
Image.ANTIALIAS,
|
||||
Image.LANCZOS,
|
||||
)
|
||||
frames.append(frame)
|
||||
|
||||
@ -131,6 +141,9 @@ class ImageForm(forms.Form):
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def is_valid(self):
|
||||
return super().is_valid() or super().clean().get('image') is None
|
||||
|
||||
|
||||
class ClubForm(forms.ModelForm):
|
||||
def clean(self):
|
||||
@ -144,12 +157,13 @@ class ClubForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Club
|
||||
fields = '__all__'
|
||||
exclude = ("add_registration_form",)
|
||||
widgets = {
|
||||
"membership_fee_paid": AmountInput(),
|
||||
"membership_fee_unpaid": AmountInput(),
|
||||
"parent_club": Autocomplete(
|
||||
Club,
|
||||
resetable=True,
|
||||
attrs={
|
||||
'api_url': '/api/members/club/',
|
||||
}
|
||||
@ -199,9 +213,9 @@ class MembershipForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Membership
|
||||
fields = ('user', 'date_start')
|
||||
# Le champ d'utilisateur est remplacé par un champ d'auto-complétion.
|
||||
# Le champ d'utilisateur⋅rice est remplacé par un champ d'auto-complétion.
|
||||
# Quand des lettres sont tapées, une requête est envoyée sur l'API d'auto-complétion
|
||||
# et récupère les noms d'utilisateur valides
|
||||
# et récupère les noms d'utilisateur⋅rices valides
|
||||
widgets = {
|
||||
'user':
|
||||
Autocomplete(
|
||||
|
@ -1,12 +1,14 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import hashlib
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import PBKDF2PasswordHasher
|
||||
from django.contrib.auth.hashers import PBKDF2PasswordHasher, mask_hash
|
||||
from django.utils.crypto import constant_time_compare
|
||||
from note_kfet.middlewares import get_current_authenticated_user, get_current_session
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from note_kfet.middlewares import get_current_request
|
||||
|
||||
|
||||
class CustomNK15Hasher(PBKDF2PasswordHasher):
|
||||
@ -24,16 +26,22 @@ class CustomNK15Hasher(PBKDF2PasswordHasher):
|
||||
|
||||
def must_update(self, encoded):
|
||||
if settings.DEBUG:
|
||||
current_user = get_current_authenticated_user()
|
||||
# Small hack to let superusers to impersonate people.
|
||||
# Don't change their password.
|
||||
request = get_current_request()
|
||||
current_user = request.user
|
||||
if current_user is not None and current_user.is_superuser:
|
||||
return False
|
||||
return True
|
||||
|
||||
def verify(self, password, encoded):
|
||||
if settings.DEBUG:
|
||||
current_user = get_current_authenticated_user()
|
||||
# Small hack to let superusers to impersonate people.
|
||||
# If a superuser is already connected, let him/her log in as another person.
|
||||
request = get_current_request()
|
||||
current_user = request.user
|
||||
if current_user is not None and current_user.is_superuser\
|
||||
and get_current_session().get("permission_mask", -1) >= 42:
|
||||
and request.session.get("permission_mask", -1) >= 42:
|
||||
return True
|
||||
|
||||
if '|' in encoded:
|
||||
@ -41,6 +49,18 @@ class CustomNK15Hasher(PBKDF2PasswordHasher):
|
||||
return constant_time_compare(hashlib.sha256((salt + password).encode("utf-8")).hexdigest(), db_hashed_pass)
|
||||
return super().verify(password, encoded)
|
||||
|
||||
def safe_summary(self, encoded):
|
||||
# Displayed information in Django Admin.
|
||||
if '|' in encoded:
|
||||
salt, db_hashed_pass = encoded.split('$')[2].split('|')
|
||||
return OrderedDict([
|
||||
(_('algorithm'), 'custom_nk15'),
|
||||
(_('iterations'), '1'),
|
||||
(_('salt'), mask_hash(salt)),
|
||||
(_('hash'), mask_hash(db_hashed_pass)),
|
||||
])
|
||||
return super().safe_summary(encoded)
|
||||
|
||||
|
||||
class DebugSuperuserBackdoor(PBKDF2PasswordHasher):
|
||||
"""
|
||||
@ -51,8 +71,11 @@ class DebugSuperuserBackdoor(PBKDF2PasswordHasher):
|
||||
|
||||
def verify(self, password, encoded):
|
||||
if settings.DEBUG:
|
||||
current_user = get_current_authenticated_user()
|
||||
# Small hack to let superusers to impersonate people.
|
||||
# If a superuser is already connected, let him/her log in as another person.
|
||||
request = get_current_request()
|
||||
current_user = request.user
|
||||
if current_user is not None and current_user.is_superuser\
|
||||
and get_current_session().get("permission_mask", -1) >= 42:
|
||||
and request.session.get("permission_mask", -1) >= 42:
|
||||
return True
|
||||
return super().verify(password, encoded)
|
||||
|
@ -7,6 +7,7 @@ def create_bde_and_kfet(apps, schema_editor):
|
||||
"""
|
||||
Club = apps.get_model("member", "club")
|
||||
NoteClub = apps.get_model("note", "noteclub")
|
||||
Alias = apps.get_model("note", "alias")
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
polymorphic_ctype_id = ContentType.objects.get_for_model(NoteClub).id
|
||||
|
||||
@ -18,8 +19,8 @@ def create_bde_and_kfet(apps, schema_editor):
|
||||
membership_fee_paid=500,
|
||||
membership_fee_unpaid=500,
|
||||
membership_duration=396,
|
||||
membership_start="2020-08-01",
|
||||
membership_end="2021-09-30",
|
||||
membership_start="2021-08-01",
|
||||
membership_end="2022-09-30",
|
||||
)
|
||||
Club.objects.get_or_create(
|
||||
id=2,
|
||||
@ -30,8 +31,8 @@ def create_bde_and_kfet(apps, schema_editor):
|
||||
membership_fee_paid=3500,
|
||||
membership_fee_unpaid=3500,
|
||||
membership_duration=396,
|
||||
membership_start="2020-08-01",
|
||||
membership_end="2021-09-30",
|
||||
membership_start="2021-08-01",
|
||||
membership_end="2022-09-30",
|
||||
)
|
||||
|
||||
NoteClub.objects.get_or_create(
|
||||
@ -45,6 +46,19 @@ def create_bde_and_kfet(apps, schema_editor):
|
||||
polymorphic_ctype_id=polymorphic_ctype_id,
|
||||
)
|
||||
|
||||
Alias.objects.get_or_create(
|
||||
id=5,
|
||||
note_id=5,
|
||||
name="BDE",
|
||||
normalized_name="bde",
|
||||
)
|
||||
Alias.objects.get_or_create(
|
||||
id=6,
|
||||
note_id=6,
|
||||
name="Kfet",
|
||||
normalized_name="kfet",
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
|
@ -0,0 +1,50 @@
|
||||
import sys
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def give_note_account_permissions(apps, schema_editor):
|
||||
"""
|
||||
Automatically manage the membership of the Note account.
|
||||
"""
|
||||
User = apps.get_model("auth", "user")
|
||||
Membership = apps.get_model("member", "membership")
|
||||
Role = apps.get_model("permission", "role")
|
||||
|
||||
note = User.objects.filter(username="note")
|
||||
if not note.exists():
|
||||
# We are in a test environment, don't log error message
|
||||
if len(sys.argv) > 1 and sys.argv[1] == 'test':
|
||||
return
|
||||
print("Warning: Note account was not found. The note account was not imported.")
|
||||
print("Make sure you have imported the NK15 database. The new import script handles correctly the permissions.")
|
||||
print("This migration will be ignored, you can re-run it if you forgot the note account or ignore it if you "
|
||||
"don't want this account.")
|
||||
return
|
||||
|
||||
note = note.get()
|
||||
|
||||
# Set for the two clubs a large expiration date and the correct role.
|
||||
for m in Membership.objects.filter(user_id=note.id).all():
|
||||
m.date_end = "3142-12-12"
|
||||
m.roles.set(Role.objects.filter(name="PC Kfet").all())
|
||||
m.save()
|
||||
# By default, the note account is only authorized to be logged from localhost.
|
||||
note.password = "ipbased$127.0.0.1"
|
||||
note.is_active = True
|
||||
note.save()
|
||||
# Ensure that the note of the account is disabled
|
||||
note.note.inactivity_reason = 'forced'
|
||||
note.note.is_active = False
|
||||
note.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('member', '0005_remove_null_tag_on_charfields'),
|
||||
('permission', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(give_note_account_permissions),
|
||||
]
|
23
apps/member/migrations/0007_auto_20210313_1235.py
Normal file
23
apps/member/migrations/0007_auto_20210313_1235.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.2.19 on 2021-03-13 11:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('member', '0006_create_note_account_bde_membership'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='membership',
|
||||
name='roles',
|
||||
field=models.ManyToManyField(related_name='memberships', to='permission.Role', verbose_name='roles'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='profile',
|
||||
name='promotion',
|
||||
field=models.PositiveSmallIntegerField(default=2021, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion'),
|
||||
),
|
||||
]
|
18
apps/member/migrations/0008_auto_20211005_1544.py
Normal file
18
apps/member/migrations/0008_auto_20211005_1544.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.24 on 2021-10-05 13:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('member', '0007_auto_20210313_1235'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='profile',
|
||||
name='department',
|
||||
field=models.CharField(choices=[('A0', 'Informatics (A0)'), ('A1', 'Mathematics (A1)'), ('A2', 'Physics (A2)'), ("A'2", "Applied physics (A'2)"), ("A''2", "Chemistry (A''2)"), ('A3', 'Biology (A3)'), ('B1234', 'SAPHIRE (B1234)'), ('B1', 'Mechanics (B1)'), ('B2', 'Civil engineering (B2)'), ('B3', 'Mechanical engineering (B3)'), ('B4', 'EEA (B4)'), ('C', 'Design (C)'), ('D2', 'Economy-management (D2)'), ('D3', 'Social sciences (D3)'), ('E', 'English (E)'), ('EXT', 'External (EXT)')], max_length=8, verbose_name='department'),
|
||||
),
|
||||
]
|
18
apps/member/migrations/0009_auto_20220904_2325.py
Normal file
18
apps/member/migrations/0009_auto_20220904_2325.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.26 on 2022-09-04 21:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('member', '0008_auto_20211005_1544'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='profile',
|
||||
name='promotion',
|
||||
field=models.PositiveSmallIntegerField(default=2022, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion'),
|
||||
),
|
||||
]
|
18
apps/member/migrations/0010_new_default_year.py
Normal file
18
apps/member/migrations/0010_new_default_year.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.28 on 2023-08-23 21:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('member', '0009_auto_20220904_2325'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='profile',
|
||||
name='promotion',
|
||||
field=models.PositiveSmallIntegerField(default=2023, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion'),
|
||||
),
|
||||
]
|
18
apps/member/migrations/0011_profile_vss_charter_read.py
Normal file
18
apps/member/migrations/0011_profile_vss_charter_read.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.28 on 2023-08-31 09:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('member', '0010_new_default_year'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='VSS_charter_read',
|
||||
field=models.BooleanField(default=False, verbose_name='VSS charter read'),
|
||||
),
|
||||
]
|
18
apps/member/migrations/0012_club_add_registration_form.py
Normal file
18
apps/member/migrations/0012_club_add_registration_form.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.28 on 2024-07-15 09:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('member', '0011_profile_vss_charter_read'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='club',
|
||||
name='add_registration_form',
|
||||
field=models.BooleanField(default=False, verbose_name='add to registration form'),
|
||||
),
|
||||
]
|
18
apps/member/migrations/0013_auto_20240801_1436.py
Normal file
18
apps/member/migrations/0013_auto_20240801_1436.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2.28 on 2024-08-01 12:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('member', '0012_club_add_registration_form'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='profile',
|
||||
name='promotion',
|
||||
field=models.PositiveSmallIntegerField(default=2024, help_text='Year of entry to the school (None if not ENS student)', null=True, verbose_name='promotion'),
|
||||
),
|
||||
]
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import datetime
|
||||
@ -28,7 +28,6 @@ class Profile(models.Model):
|
||||
We do not want to patch the Django Contrib :model:`auth.User`model;
|
||||
so this model add an user profile with additional information.
|
||||
"""
|
||||
|
||||
user = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
@ -57,7 +56,7 @@ class Profile(models.Model):
|
||||
('A1', _("Mathematics (A1)")),
|
||||
('A2', _("Physics (A2)")),
|
||||
("A'2", _("Applied physics (A'2)")),
|
||||
('A''2', _("Chemistry (A''2)")),
|
||||
("A''2", _("Chemistry (A''2)")),
|
||||
('A3', _("Biology (A3)")),
|
||||
('B1234', _("SAPHIRE (B1234)")),
|
||||
('B1', _("Mechanics (B1)")),
|
||||
@ -74,7 +73,7 @@ class Profile(models.Model):
|
||||
|
||||
promotion = models.PositiveSmallIntegerField(
|
||||
null=True,
|
||||
default=datetime.date.today().year,
|
||||
default=datetime.date.today().year if datetime.date.today().month >= 8 else datetime.date.today().year - 1,
|
||||
verbose_name=_("promotion"),
|
||||
help_text=_("Year of entry to the school (None if not ENS student)"),
|
||||
)
|
||||
@ -134,6 +133,22 @@ class Profile(models.Model):
|
||||
default=False,
|
||||
)
|
||||
|
||||
VSS_charter_read = models.BooleanField(
|
||||
verbose_name=_("VSS charter read"),
|
||||
default=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('user profile')
|
||||
verbose_name_plural = _('user profile')
|
||||
indexes = [models.Index(fields=['user'])]
|
||||
|
||||
def __str__(self):
|
||||
return str(self.user)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('member:user_detail', args=(self.user_id,))
|
||||
|
||||
@property
|
||||
def ens_year(self):
|
||||
"""
|
||||
@ -158,17 +173,6 @@ class Profile(models.Model):
|
||||
return SogeCredit.objects.filter(user=self.user, credit_transaction__isnull=False).exists()
|
||||
return False
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('user profile')
|
||||
verbose_name_plural = _('user profile')
|
||||
indexes = [models.Index(fields=['user'])]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('member:user_detail', args=(self.user_id,))
|
||||
|
||||
def __str__(self):
|
||||
return str(self.user)
|
||||
|
||||
def send_email_validation_link(self):
|
||||
subject = "[Note Kfet] " + str(_("Activate your Note Kfet account"))
|
||||
token = email_validation_token.make_token(self.user)
|
||||
@ -200,9 +204,11 @@ class Club(models.Model):
|
||||
max_length=255,
|
||||
unique=True,
|
||||
)
|
||||
|
||||
email = models.EmailField(
|
||||
verbose_name=_('email'),
|
||||
)
|
||||
|
||||
parent_club = models.ForeignKey(
|
||||
'self',
|
||||
null=True,
|
||||
@ -253,23 +259,17 @@ class Club(models.Model):
|
||||
help_text=_('Maximal date of a membership, after which members must renew it.'),
|
||||
)
|
||||
|
||||
def update_membership_dates(self):
|
||||
"""
|
||||
This function is called each time the club detail view is displayed.
|
||||
Update the year of the membership dates.
|
||||
"""
|
||||
if not self.membership_start:
|
||||
return
|
||||
add_registration_form = models.BooleanField(
|
||||
verbose_name=_("add to registration form"),
|
||||
default=False,
|
||||
)
|
||||
|
||||
today = datetime.date.today()
|
||||
class Meta:
|
||||
verbose_name = _("club")
|
||||
verbose_name_plural = _("clubs")
|
||||
|
||||
if (today - self.membership_start).days >= 365:
|
||||
self.membership_start = datetime.date(self.membership_start.year + 1,
|
||||
self.membership_start.month, self.membership_start.day)
|
||||
self.membership_end = datetime.date(self.membership_end.year + 1,
|
||||
self.membership_end.month, self.membership_end.day)
|
||||
self._force_save = True
|
||||
self.save(force_update=True)
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@transaction.atomic
|
||||
def save(self, force_insert=False, force_update=False, using=None,
|
||||
@ -282,16 +282,36 @@ class Club(models.Model):
|
||||
self.membership_end = None
|
||||
super().save(force_insert, force_update, update_fields)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("club")
|
||||
verbose_name_plural = _("clubs")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse_lazy('member:club_detail', args=(self.pk,))
|
||||
|
||||
def update_membership_dates(self):
|
||||
"""
|
||||
This function is called each time the club detail view is displayed.
|
||||
Update the year of the membership dates.
|
||||
"""
|
||||
if not self.membership_start or not self.membership_end:
|
||||
return
|
||||
|
||||
today = datetime.date.today()
|
||||
|
||||
# Avoid any problems on February 29
|
||||
if self.membership_start.month == 2 and self.membership_start.day == 29:
|
||||
self.membership_start -= datetime.timedelta(days=1)
|
||||
if self.membership_end.month == 2 and self.membership_end.day == 29:
|
||||
self.membership_end += datetime.timedelta(days=1)
|
||||
|
||||
while today >= datetime.date(self.membership_start.year + 1,
|
||||
self.membership_start.month, self.membership_start.day):
|
||||
if self.membership_start:
|
||||
self.membership_start = datetime.date(self.membership_start.year + 1,
|
||||
self.membership_start.month, self.membership_start.day)
|
||||
if self.membership_end:
|
||||
self.membership_end = datetime.date(self.membership_end.year + 1,
|
||||
self.membership_end.month, self.membership_end.day)
|
||||
self._force_save = True
|
||||
self.save(force_update=True)
|
||||
|
||||
|
||||
class Membership(models.Model):
|
||||
"""
|
||||
@ -313,6 +333,7 @@ class Membership(models.Model):
|
||||
|
||||
roles = models.ManyToManyField(
|
||||
"permission.Role",
|
||||
related_name="memberships",
|
||||
verbose_name=_("roles"),
|
||||
)
|
||||
|
||||
@ -330,6 +351,66 @@ class Membership(models.Model):
|
||||
verbose_name=_('fee'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('membership')
|
||||
verbose_name_plural = _('memberships')
|
||||
indexes = [models.Index(fields=['user'])]
|
||||
|
||||
def __str__(self):
|
||||
return _("Membership of {user} for the club {club}").format(user=self.user.username, club=self.club.name, )
|
||||
|
||||
@transaction.atomic
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Calculate fee and end date before saving the membership and creating the transaction if needed.
|
||||
"""
|
||||
# Ensure that club membership dates are valid
|
||||
old_membership_start = self.club.membership_start
|
||||
self.club.update_membership_dates()
|
||||
if self.club.membership_start != old_membership_start:
|
||||
self.club.save()
|
||||
|
||||
created = not self.pk
|
||||
if not created:
|
||||
for role in self.roles.all():
|
||||
club = role.for_club
|
||||
if club is not None:
|
||||
if club.pk != self.club_id:
|
||||
raise ValidationError(_('The role {role} does not apply to the club {club}.')
|
||||
.format(role=role.name, club=club.name))
|
||||
else:
|
||||
if Membership.objects.filter(
|
||||
user=self.user,
|
||||
club=self.club,
|
||||
date_start__lte=self.date_start,
|
||||
date_end__gte=self.date_start,
|
||||
).exists():
|
||||
raise ValidationError(_('User is already a member of the club'))
|
||||
|
||||
if self.club.parent_club is not None:
|
||||
# Check that the user is already a member of the parent club if the membership is created
|
||||
if not Membership.objects.filter(
|
||||
user=self.user,
|
||||
club=self.club.parent_club,
|
||||
date_start__gte=self.club.parent_club.membership_start,
|
||||
).exists():
|
||||
if hasattr(self, '_force_renew_parent') and self._force_renew_parent:
|
||||
self.renew_parent()
|
||||
else:
|
||||
raise ValidationError(_('User is not a member of the parent club')
|
||||
+ ' ' + self.club.parent_club.name)
|
||||
|
||||
self.fee = self.club.membership_fee_paid if self.user.profile.paid else self.club.membership_fee_unpaid
|
||||
|
||||
self.date_end = self.date_start + datetime.timedelta(days=self.club.membership_duration) \
|
||||
if self.club.membership_duration is not None else self.date_start + datetime.timedelta(days=424242)
|
||||
if self.club.membership_end is not None and self.date_end > self.club.membership_end:
|
||||
self.date_end = self.club.membership_end
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
self.make_transaction()
|
||||
|
||||
@property
|
||||
def valid(self):
|
||||
"""
|
||||
@ -399,60 +480,14 @@ class Membership(models.Model):
|
||||
|
||||
if self.club.parent_club.name == "BDE":
|
||||
parent_membership.roles.set(
|
||||
Role.objects.filter(Q(name="Adhérent BDE") | Q(name="Membre de club")).all())
|
||||
Role.objects.filter(Q(name="Adhérent⋅e BDE") | Q(name="Membre de club")).all())
|
||||
elif self.club.parent_club.name == "Kfet":
|
||||
parent_membership.roles.set(
|
||||
Role.objects.filter(Q(name="Adhérent Kfet") | Q(name="Membre de club")).all())
|
||||
Role.objects.filter(Q(name="Adhérent⋅e Kfet") | Q(name="Membre de club")).all())
|
||||
else:
|
||||
parent_membership.roles.set(Role.objects.filter(name="Membre de club").all())
|
||||
parent_membership.save()
|
||||
|
||||
@transaction.atomic
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Calculate fee and end date before saving the membership and creating the transaction if needed.
|
||||
"""
|
||||
created = not self.pk
|
||||
if not created:
|
||||
for role in self.roles.all():
|
||||
club = role.for_club
|
||||
if club is not None:
|
||||
if club.pk != self.club_id:
|
||||
raise ValidationError(_('The role {role} does not apply to the club {club}.')
|
||||
.format(role=role.name, club=club.name))
|
||||
else:
|
||||
if Membership.objects.filter(
|
||||
user=self.user,
|
||||
club=self.club,
|
||||
date_start__lte=self.date_start,
|
||||
date_end__gte=self.date_start,
|
||||
).exists():
|
||||
raise ValidationError(_('User is already a member of the club'))
|
||||
|
||||
if self.club.parent_club is not None:
|
||||
# Check that the user is already a member of the parent club if the membership is created
|
||||
if not Membership.objects.filter(
|
||||
user=self.user,
|
||||
club=self.club.parent_club,
|
||||
date_start__gte=self.club.parent_club.membership_start,
|
||||
).exists():
|
||||
if hasattr(self, '_force_renew_parent') and self._force_renew_parent:
|
||||
self.renew_parent()
|
||||
else:
|
||||
raise ValidationError(_('User is not a member of the parent club')
|
||||
+ ' ' + self.club.parent_club.name)
|
||||
|
||||
self.fee = self.club.membership_fee_paid if self.user.profile.paid else self.club.membership_fee_unpaid
|
||||
|
||||
self.date_end = self.date_start + datetime.timedelta(days=self.club.membership_duration) \
|
||||
if self.club.membership_duration is not None else self.date_start + datetime.timedelta(days=424242)
|
||||
if self.club.membership_end is not None and self.date_end > self.club.membership_end:
|
||||
self.date_end = self.club.membership_end
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
self.make_transaction()
|
||||
|
||||
def make_transaction(self):
|
||||
"""
|
||||
Create Membership transaction associated to this membership.
|
||||
@ -490,11 +525,3 @@ class Membership(models.Model):
|
||||
soge_credit.save()
|
||||
else:
|
||||
transaction.save(force_insert=True)
|
||||
|
||||
def __str__(self):
|
||||
return _("Membership of {user} for the club {club}").format(user=self.user.username, club=self.club.name, )
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('membership')
|
||||
verbose_name_plural = _('memberships')
|
||||
indexes = [models.Index(fields=['user'])]
|
||||
|
@ -1,4 +1,4 @@
|
||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
||||
# Copyright (C) 2018-2025 by BDE ENS Paris-Saclay
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
|
||||
|
@ -14,7 +14,7 @@ function create_alias (e) {
|
||||
}).done(function () {
|
||||
// Reload table
|
||||
$('#alias_table').load(location.pathname + ' #alias_table')
|
||||
addMsg('Alias ajouté', 'success')
|
||||
addMsg(gettext('Alias successfully added'), 'success')
|
||||
}).fail(function (xhr, _textStatus, _error) {
|
||||
errMsg(xhr.responseJSON)
|
||||
})
|
||||
@ -22,7 +22,7 @@ function create_alias (e) {
|
||||
|
||||
/**
|
||||
* On click of "delete", delete the alias
|
||||
* @param Integer button_id Alias id to remove
|
||||
* @param button_id:Integer Alias id to remove
|
||||
*/
|
||||
function delete_button (button_id) {
|
||||
$.ajax({
|
||||
@ -30,7 +30,7 @@ function delete_button (button_id) {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRFTOKEN': CSRF_TOKEN }
|
||||
}).done(function () {
|
||||
addMsg('Alias supprimé', 'success')
|
||||
addMsg(gettext('Alias successfully deleted'), 'success')
|
||||
$('#alias_table').load(location.pathname + ' #alias_table')
|
||||
}).fail(function (xhr, _textStatus, _error) {
|
||||
errMsg(xhr.responseJSON)
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user