mirror of
https://gitlab.crans.org/bde/nk20
synced 2025-06-21 18:08:21 +02:00
Compare commits
1407 Commits
7597ad859a
...
permission
Author | SHA1 | Date | |
---|---|---|---|
767e98c2a3 | |||
1bdad76fe9 | |||
0196db7fff | |||
1f53ad4407 | |||
018f6e3f13 | |||
9752a030d9 | |||
b27bdb090d | |||
55a0fbb6cb | |||
c356534309 | |||
51315a0555 | |||
e5f9fe2cf5 | |||
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 | |||
5c702187e5 | |||
905d65371f | |||
180cd3e1ec | |||
73ca65aa91 | |||
5ed0560953 | |||
dbc6fbbf71 | |||
872fd8f86d | |||
f89234b69a | |||
36a980555b | |||
826cd4d87f | |||
e8005a6c58 | |||
2270a0aa82 | |||
0f53ac45f7 | |||
670556c59e | |||
5b02ba48e0 | |||
f3f18bc25e | |||
03124e124c | |||
6308964e93 | |||
ed79097288 | |||
d7eaef8cee | |||
01d405e54b | |||
80e3cba4c6 | |||
f190053e84 | |||
218960adb5 | |||
88a1eae631 | |||
2a2ecb2acc | |||
f5486bdb63 | |||
9b090a145c | |||
860c7b50e5 | |||
afdc75c0bd | |||
c6603e8aa7 | |||
72cc1638e6 | |||
6a0dc4cb10 | |||
0f1f3b9560 | |||
c720e5483e | |||
0fd3e9db78 | |||
c34296c923 | |||
ce4c22a4a1 | |||
3e0f665ef8 | |||
be8751c815 | |||
8225445c3e | |||
f333e6a875 | |||
e5835b46a5 | |||
fe937405a6 | |||
0741c8ad2b | |||
3191dba31f | |||
428de69d93 | |||
9b8caa7fa1 | |||
fa3c723140 | |||
dc6a5f56f6 | |||
6b06853678 | |||
346aa94ead | |||
78586b9343 | |||
353416618a | |||
9eff3d8850 | |||
7a32c30b8c | |||
0183ba193c | |||
f3f746aba8 | |||
53c4e38771 | |||
4a9c37905c | |||
4452d112e3 | |||
27aa2e9da8 | |||
89b2ff52e3 | |||
48407cacf8 | |||
b6901ea1e5 | |||
012b84614c | |||
3988261a64 | |||
c06354211b | |||
1023c6c502 | |||
cc5996121b | |||
40a3405f47 | |||
82924c999a | |||
f1dac73c08 | |||
72c004cb56 | |||
1ed74021a2 | |||
b1fed3d476 | |||
d5f324c2d5 | |||
dcdd8e56e8 | |||
ae028b7d06 | |||
5ebdb015ad | |||
69f87c0f64 | |||
eb58db7df9 | |||
81ad38927d | |||
8aac738c4a | |||
eb4641ed35 | |||
ae31cdf15e | |||
fcd1bb98a8 | |||
15ed9d81d5 | |||
de3660b23c | |||
487c3ef0da | |||
af48eeeaec | |||
c503e77b23 | |||
1a28e876b8 | |||
2a824cadf6 | |||
2a1bfa9735 | |||
a64dc9ffc2 | |||
b63fa19644 | |||
2f6c7ed156 | |||
00bc9550f2 | |||
be8e74d056 | |||
2b2dde85dc | |||
9f619a9df8 | |||
96954b1afd | |||
2a8a5cd736 | |||
e73b3cf69d | |||
2e13356e39 | |||
d273193b1d | |||
3e9b3d690f | |||
863150d200 | |||
f96b1f26a4 | |||
466db42318 | |||
3af083fb6b | |||
bcb2398d68 | |||
8c23726f88 | |||
751a4291ab | |||
77b0241406 | |||
afc367cfb8 | |||
bad5fe3c22 | |||
a97a36bc9e | |||
94706328ff | |||
2fc13e5418 | |||
2e80233cbc | |||
ebe6ce61e4 | |||
0f47412c38 | |||
3c636e9f71 | |||
4ddd763886 | |||
0888afe439 | |||
6d1b75b9b6 | |||
70e1a611dd | |||
3111c30e56 | |||
5c7fe716ad | |||
9b4923fc04 | |||
c93c81861d | |||
f71fb1fa81 | |||
c03c18e93a | |||
b6847415b5 | |||
cb545417ac | |||
f8a0e20772 | |||
43fffdf56f | |||
c66d66bc64 | |||
d29e1d69d1 | |||
ff187581c9 | |||
f02efd3b39 | |||
4b85a35a9d | |||
f7f6f053f7 | |||
22bae51808 | |||
76aacaf048 | |||
cc7ebd2d8a | |||
42778baf20 | |||
fed9567522 | |||
177128f593 | |||
7bdf5a4366 | |||
4b149213f9 | |||
7fc2559530 | |||
be6cf93cdb | |||
bf7f5b9cd6 | |||
1b8cb7abb0 | |||
3d20987b18 | |||
06679b2e6a | |||
9d1a355ea1 | |||
6a2b46be72 | |||
4da5c41f40 | |||
8db9e92986 | |||
3e42f4fffb | |||
cde35ea9f9 | |||
f1fe6c4996 | |||
d73d9f8bda | |||
e85ec1fa05 | |||
d74007d523 | |||
cc5f04e2b3 | |||
d054d58661 | |||
cf7101fc0f | |||
fb47e22ae1 | |||
980032bfbf | |||
31585a9c7e | |||
9f42ecb97a | |||
b5028b9814 | |||
0e8557404d | |||
0f56e90e48 | |||
9bd7569935 | |||
a3fe13aeb4 | |||
22140a1428 | |||
cdae654034 | |||
ebe9c62823 | |||
d76aa3fec9 | |||
05164636a1 | |||
361ea8cad3 | |||
819795c1f9 | |||
b5c1289358 | |||
0395717d19 | |||
4bb8a443d5 | |||
646c23ccb8 | |||
cf4e1f33b4 | |||
5efb150583 | |||
08defd84e6 | |||
7c9287e387 | |||
c6abad107a | |||
81e418e17e | |||
1977e403e3 | |||
eaf256b1b6 | |||
2b70a05a9e | |||
be08c12dca | |||
aa247c281f | |||
739da3a090 | |||
85c9c4b2ba | |||
27845303b8 | |||
e3fc79231d | |||
9b2a8c4f6f | |||
d89f6dcf5c | |||
5feb23ad51 | |||
b4ef4b8089 | |||
6de46a9264 | |||
bfd08fec09 | |||
55be6be6c5 | |||
bf7b187048 | |||
09853ce990 | |||
2acb47c516 | |||
ff7e954652 | |||
9c794205f0 | |||
3922fcd93a | |||
534831f380 | |||
dd9ca315fa | |||
d9e003a8f4 | |||
dbca5db7d7 | |||
c1c211629d | |||
b787c8cfe2 | |||
affa2b1a4d | |||
e0c1a5f590 | |||
e8dcf295ad | |||
5642c268e9 | |||
e74f92cf8d | |||
ee26850e34 | |||
08c8792aed | |||
a9da4a38e1 | |||
b8c1cfba40 | |||
ca6f7cac9a | |||
5e65e2d74a | |||
0c753c3288 | |||
1bbe7df797 | |||
abbe74cc55 | |||
8744455cbe | |||
56c41258b9 | |||
48eb0749e0 | |||
8ac551e1bc | |||
805ceda249 | |||
a9258c332a | |||
ca7f4791ed | |||
b454ad8dad | |||
7d539d44e5 | |||
227cb2a801 | |||
ef1e805538 | |||
374e6ed7f8 | |||
c5f40e0952 | |||
4cb162de87 | |||
bca301700d | |||
5ba18a2d89 | |||
22a0af640e | |||
1712d1725a | |||
93e5e4c8cd | |||
1fd37bb1ce | |||
e3785e11f1 | |||
2e659c63cd | |||
63dc184ce4 | |||
b25935e579 | |||
550242226e | |||
bac14521ae | |||
e14c8734c2 | |||
c64de202a6 | |||
44b7fe8f52 | |||
cbc3e39bd6 | |||
1c16d6ef18 | |||
e3898d0b1e | |||
ac0e9b9da2 | |||
0ba77fb8f0 | |||
342d3910c7 | |||
2c1cf148fa | |||
196f796570 | |||
8691421ce3 | |||
9cad8fcc65 | |||
891955cedf | |||
8063354e0f | |||
f077a5d72f | |||
2272cf5294 | |||
8465b24d7d | |||
aa98c4848d | |||
00b07147f6 | |||
26775aa561 | |||
83d2c18d1e | |||
5ea1eed76d | |||
b7d4a17ffd | |||
5c3451bda7 | |||
8c46321c95 | |||
a3af2b0d9a | |||
501d02d05c | |||
310f55a28e | |||
197bd28ceb | |||
e03a3f7fd2 | |||
51230e029d | |||
bd49a36bcc | |||
2672721235 | |||
ba636fc401 | |||
c090b4af76 | |||
a1dc8fe530 | |||
6ea92cdcde | |||
9c9214b5df | |||
00935a8c02 | |||
b0ebc7c0a4 | |||
60b1cdbcf8 | |||
f324965f1a | |||
7c291b115a | |||
6217f35f67 | |||
448d379315 | |||
e974eaa1fe | |||
61ace4af74 | |||
b8c3dda95b | |||
da23df05cb | |||
9c061d9837 | |||
5abb155287 | |||
9f258e39b6 | |||
4997a37058 | |||
b16871d925 | |||
1186b0f9a9 | |||
5abbb84254 | |||
5f8c4a2857 | |||
14b969b2dd | |||
f95a0875db | |||
430036bfc2 | |||
d6fd925fdd | |||
89c15cbe3e | |||
75cd34f5dd | |||
6927f5fbb6 | |||
482a04d37c | |||
0bf5067b60 | |||
fe2af5ac2b | |||
d4090a4043 | |||
242b85676d | |||
eca4767155 | |||
21ba46c1bc | |||
74097ecc44 | |||
d962763987 | |||
a43abee00b | |||
912ce5da2e | |||
29f8b9215d | |||
f5f379e6ad | |||
c50fdd6689 | |||
1e4cbf60c5 | |||
dfe4bf2175 | |||
a25e663a26 | |||
721da093e9 | |||
d98e46ffc2 | |||
2d69e36adf | |||
bb2704323a | |||
c466715e8a | |||
71f6436d06 | |||
106e97f5df | |||
b7a88a387c | |||
25e26fe8cf | |||
0fae5b3e62 | |||
3784e97d60 | |||
6567d2f8cc | |||
999cc0a6b2 | |||
60de58b78a | |||
9c816a288d | |||
c277d8bccd | |||
4a4c3d33b0 | |||
9c679d5bc9 | |||
3b49b7f4c1 | |||
747a878cca | |||
c612e159cf | |||
1b84c8c603 | |||
3a52af33a2 | |||
ccfc1e74ac | |||
7719ff41ad | |||
8933fddaf3 | |||
eadc8fa193 | |||
f74b19b2af | |||
c3081d9cc3 | |||
8e886f1431 | |||
bf7c253607 | |||
027ae5b97f | |||
63562d3fbb | |||
bba69f0a60 | |||
beff848796 | |||
e78ba49252 | |||
ce35e8f7e8 | |||
50f4a43343 | |||
b66d6635fc | |||
9a52c81bff | |||
48d3e8960a | |||
f6dfbb0b6c | |||
c6e3a57801 | |||
40b826a375 | |||
f0089d0bc5 | |||
5e75a56eda | |||
d73f7c31a1 | |||
31f4105c9a | |||
e9ae8531b8 | |||
7b40ee1ca4 | |||
53b496546d | |||
8c1cf754ed | |||
efe833cec3 | |||
ccfc37d226 | |||
764eaafb95 | |||
5846f03220 | |||
52e8b46aa2 | |||
29f84ea007 | |||
49bda926c6 | |||
11fbbca2a8 | |||
901af1a86a | |||
5f87e76be8 | |||
8c885d372b | |||
255e4dd0aa | |||
4afb849aec | |||
872456df20 | |||
963ba05d01 | |||
18eaf4477e | |||
e4998cb6e3 | |||
ad59b5c81e | |||
88917dde23 | |||
aab194b987 | |||
9751a5ad92 | |||
679ac3a652 | |||
1fb14ea33d | |||
e23eafd56c | |||
3e28ed8716 | |||
5c01c0bb6c | |||
979628b02d | |||
bb8e3aaccf | |||
86ff23357c | |||
fd2f426f55 | |||
48a7128370 | |||
f222ba134d | |||
d95cd8c7c7 | |||
5b3361f086 | |||
9c7cb07dec | |||
dd4b24d999 | |||
eb3d426947 | |||
d8c7018b9a | |||
f47a0b8c9d | |||
c859fc7821 | |||
a4702fca86 | |||
de5e0c958e | |||
434a393f3b | |||
cba6a35b6c | |||
0de69cbfaf | |||
d9cf812074 | |||
252ddb832d | |||
6dcb82855d | |||
1247818033 | |||
9439b3cb2d | |||
a07b942738 | |||
b7ae411f96 | |||
315af75c45 | |||
fd7e314ca3 | |||
0b46140771 | |||
547fbf564b | |||
2aebeb8927 | |||
93f7e1d45b | |||
199219861c | |||
8b66bcc3d5 | |||
6759586ef3 | |||
33806967c8 | |||
24ac3ce45f | |||
018ca84e2d | |||
2851d7764c | |||
c205219d47 | |||
b0398e59b8 | |||
9c3e978a41 | |||
21f1347a60 | |||
af857d6fae | |||
acf7ecc4ae | |||
6c9cf73848 | |||
2222175d4e | |||
a096dc4427 | |||
358691aaa9 | |||
20ce817b16 | |||
ba067f050e | |||
2a744a8610 | |||
0e8058ab0d | |||
655390b265 | |||
985a5ca876 | |||
55580bc11e | |||
5ea8d8f870 | |||
0a2c9d9c87 | |||
208dc7f865 | |||
fbf3a0bcf6 | |||
66defee3ea | |||
f8a4087e56 | |||
94086505e6 | |||
6c8843e5fc | |||
0e8174aacd | |||
0e3c4fcaf6 | |||
58fe8914cf | |||
f870af139e | |||
7742358b8f | |||
8de7ba14bd | |||
8497dbb25c | |||
f148c8dacb | |||
2f018f8c9d | |||
0ae61f3643 | |||
b706efe463 | |||
37dc535d6d | |||
5ccbad8359 | |||
c0cdb13130 | |||
8434841ec5 | |||
cadf981013 | |||
efc2b6b0b0 | |||
1fd7d76412 | |||
51fbde23b9 | |||
36f1a3f0d4 | |||
cc04fa5555 | |||
f38b9801d0 | |||
43a22cbed4 | |||
5c2df41640 | |||
e50bff8e14 | |||
b5586c647b | |||
377397b319 | |||
1abb40953f | |||
f114265662 | |||
7c369bd264 | |||
b6453ce03d | |||
59bfdbbfc7 | |||
82aa0182e3 | |||
23b775447c | |||
d7b834d908 | |||
dca655949e | |||
932a546213 | |||
d8127e8936 | |||
8409ee4cc4 | |||
9008baad3a | |||
fd705adb05 | |||
bd35e4e21e | |||
72dcc93136 | |||
cb38ceb2c6 | |||
ad19b64b3a | |||
2642ae3a1a | |||
ad2cc22964 | |||
ae629b55ad | |||
a5e50e5de6 | |||
9da8d49223 | |||
aa66361ac7 | |||
c14d37eaeb | |||
e9cbc8e623 | |||
9d8c588b78 | |||
484560fe4b | |||
9361f3f2f0 | |||
e63219f7ad | |||
0c0aed0234 | |||
fb775de923 | |||
b49db39080 | |||
da1063862e | |||
224ef5b2f0 | |||
cbd36f110a | |||
c9e68ca66b | |||
20011db37e | |||
5a91cac08d | |||
fa9159bb28 | |||
4549255198 | |||
750bdcb2c5 | |||
5c93301358 | |||
b8a88eeda4 | |||
d455c5c533 | |||
f597b6dbd8 | |||
3a4145e4d9 | |||
54ce157019 | |||
7c6bab88f4 | |||
12ebf9d12a | |||
76a6260b18 | |||
7b3512c0be | |||
0bfc3b9454 | |||
84e8b02594 | |||
09027ea35e | |||
2f334e0707 | |||
e163f86f69 | |||
0335a47667 | |||
b8d4fb9df1 | |||
8cde94c5f5 | |||
ab5d5a6e94 | |||
4c29d855d2 | |||
55bc288deb | |||
fb5e2578af | |||
0b6cb4ef19 | |||
18bdc8044b | |||
f0bca69825 | |||
b2e1777fe0 | |||
afb35d7ae0 | |||
b212bf4093 | |||
3fea17c555 | |||
2eb601bd66 | |||
50024dc03d | |||
e3045522d1 | |||
a098f70424 | |||
1735ba25a8 | |||
5d70a809c2 | |||
4761d46696 | |||
084d22d33f | |||
3f0208a664 | |||
3dfed70eb1 | |||
cdc053718f | |||
71f6daf0e8 | |||
2c7995a79e | |||
ac5041f3ec | |||
b46854e479 | |||
a90eb2a6eb | |||
f2ac0cd8cf | |||
8d9e05929b | |||
518de596bb | |||
c8fe3435de | |||
8751b3936e | |||
302f9e752c | |||
7bda0bb31f | |||
8d704d0730 | |||
a9ccf46010 | |||
f567b1a343 | |||
155b2df330 | |||
716232e27f | |||
c62b5f935a | |||
f468c2f939 | |||
f7b68678d8 | |||
b0efe08a13 | |||
4108babdb4 | |||
fdf373d1d5 | |||
6d27193ac4 | |||
038ddc2ab8 | |||
cc97948c24 | |||
53748cd534 |
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
__pycache__
|
||||||
|
media
|
||||||
|
db.sqlite3
|
||||||
|
.tox
|
||||||
|
.coverage
|
16
.env_example
16
.env_example
@ -1,6 +1,6 @@
|
|||||||
DJANGO_APP_STAGE=dev
|
DJANGO_APP_STAGE=prod
|
||||||
# Only used in dev mode, change to "postgresql" if you want to use PostgreSQL in dev
|
# Only used in dev mode, change to "postgresql" if you want to use PostgreSQL in dev
|
||||||
DJANGO_DEV_STORE_METHOD=sqllite
|
DJANGO_DEV_STORE_METHOD=sqlite
|
||||||
DJANGO_DB_HOST=localhost
|
DJANGO_DB_HOST=localhost
|
||||||
DJANGO_DB_NAME=note_db
|
DJANGO_DB_NAME=note_db
|
||||||
DJANGO_DB_USER=note
|
DJANGO_DB_USER=note
|
||||||
@ -8,8 +8,16 @@ DJANGO_DB_PASSWORD=CHANGE_ME
|
|||||||
DJANGO_DB_PORT=
|
DJANGO_DB_PORT=
|
||||||
DJANGO_SECRET_KEY=CHANGE_ME
|
DJANGO_SECRET_KEY=CHANGE_ME
|
||||||
DJANGO_SETTINGS_MODULE=note_kfet.settings
|
DJANGO_SETTINGS_MODULE=note_kfet.settings
|
||||||
DOMAIN=localhost
|
|
||||||
CONTACT_EMAIL=tresorerie.bde@localhost
|
CONTACT_EMAIL=tresorerie.bde@localhost
|
||||||
NOTE_URL=localhost
|
NOTE_URL=localhost
|
||||||
|
|
||||||
|
# Config for mails. Only used in production
|
||||||
NOTE_MAIL=notekfet@localhost
|
NOTE_MAIL=notekfet@localhost
|
||||||
WEBMASTER_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=
|
||||||
|
15
.gitignore
vendored
15
.gitignore
vendored
@ -36,12 +36,21 @@ coverage
|
|||||||
|
|
||||||
# Local data
|
# Local data
|
||||||
secrets.py
|
secrets.py
|
||||||
|
.env
|
||||||
|
map.json
|
||||||
*.log
|
*.log
|
||||||
media/
|
backups/
|
||||||
|
/static/
|
||||||
|
/media/
|
||||||
|
/tmp/
|
||||||
|
|
||||||
# Virtualenv
|
# Virtualenv
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
|
shell.nix
|
||||||
|
|
||||||
# Ignore migrations during first phase dev
|
# ansibles customs host
|
||||||
migrations/
|
ansible/host_vars/*.yaml
|
||||||
|
!ansible/host_vars/bde*
|
||||||
|
ansible/hosts
|
||||||
|
@ -1,26 +1,64 @@
|
|||||||
image: python:3.6
|
|
||||||
|
|
||||||
stages:
|
stages:
|
||||||
- test
|
- test
|
||||||
- quality-assurance
|
- quality-assurance
|
||||||
|
- docs
|
||||||
|
|
||||||
|
# Also fetch submodules
|
||||||
|
variables:
|
||||||
|
GIT_SUBMODULE_STRATEGY: recursive
|
||||||
|
|
||||||
|
# Ubuntu 22.04
|
||||||
|
py310-django42:
|
||||||
|
stage: test
|
||||||
|
image: ubuntu:22.04
|
||||||
before_script:
|
before_script:
|
||||||
- pip install tox
|
# Fix tzdata prompt
|
||||||
|
- ln -sf /usr/share/zoneinfo/Europe/Paris /etc/localtime && echo Europe/Paris > /etc/timezone
|
||||||
|
- >
|
||||||
|
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 py310-django42
|
||||||
|
|
||||||
py36-django22:
|
# Debian Bookworm
|
||||||
image: python:3.6
|
py311-django42:
|
||||||
stage: test
|
stage: test
|
||||||
script: tox -e py36-django22
|
image: debian:bookworm
|
||||||
|
before_script:
|
||||||
py37-django22:
|
- >
|
||||||
image: python:3.7
|
apt-get update &&
|
||||||
stage: test
|
apt-get install --no-install-recommends -y
|
||||||
script: tox -e py37-django22
|
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:
|
linters:
|
||||||
image: python:3.6
|
|
||||||
stage: quality-assurance
|
stage: quality-assurance
|
||||||
|
image: debian:bookworm
|
||||||
|
before_script:
|
||||||
|
- apt-get update && apt-get install -y tox
|
||||||
script: tox -e linters
|
script: tox -e linters
|
||||||
|
|
||||||
# Be nice to new contributors, but please use `tox`
|
# Be nice to new contributors, but please use `tox`
|
||||||
allow_failure: true
|
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"]
|
[submodule "apps/scripts"]
|
||||||
path = apps/scripts
|
path = apps/scripts
|
||||||
url = git@gitlab.crans.org:bde/nk20-scripts.git
|
url = https://gitlab.crans.org/bde/nk20-scripts
|
||||||
|
38
Dockerfile
38
Dockerfile
@ -1,25 +1,27 @@
|
|||||||
FROM python:3-buster
|
FROM debian:buster-backports
|
||||||
|
|
||||||
|
# Force the stdout and stderr streams to be unbuffered
|
||||||
ENV PYTHONUNBUFFERED 1
|
ENV PYTHONUNBUFFERED 1
|
||||||
|
|
||||||
RUN mkdir /code
|
# Install Django, external apps, LaTeX and dependencies
|
||||||
WORKDIR /code
|
RUN apt-get update && \
|
||||||
|
apt-get install --no-install-recommends -t buster-backports -y \
|
||||||
RUN apt update && \
|
python3-django python3-django-crispy-forms \
|
||||||
apt install -y gettext nginx uwsgi uwsgi-plugin-python3 && \
|
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 ipython3 \
|
||||||
|
python3-bs4 python3-setuptools \
|
||||||
|
uwsgi uwsgi-plugin-python3 \
|
||||||
|
texlive-xetex gettext libjs-bootstrap4 fonts-font-awesome && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install LaTeX requirements
|
# Instal PyPI requirements
|
||||||
RUN apt update && \
|
COPY requirements.txt /var/www/note_kfet/
|
||||||
apt install -y texlive-latex-extra texlive-fonts-extra texlive-lang-french && \
|
RUN pip3 install -r /var/www/note_kfet/requirements.txt --no-cache-dir
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
COPY . /code/
|
# Copy code
|
||||||
|
WORKDIR /var/www/note_kfet
|
||||||
|
COPY . /var/www/note_kfet/
|
||||||
|
|
||||||
# Comment what is not needed
|
EXPOSE 8080
|
||||||
RUN pip install -r requirements/base.txt
|
ENTRYPOINT ["/var/www/note_kfet/entrypoint.sh"]
|
||||||
RUN pip install -r requirements/cas.txt
|
|
||||||
RUN pip install -r requirements/production.txt
|
|
||||||
|
|
||||||
ENTRYPOINT ["/code/entrypoint.sh"]
|
|
||||||
EXPOSE 8000
|
|
||||||
|
674
LICENSE
674
LICENSE
@ -1,674 +0,0 @@
|
|||||||
GNU GENERAL PUBLIC LICENSE
|
|
||||||
Version 3, 29 June 2007
|
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
|
||||||
of this license document, but changing it is not allowed.
|
|
||||||
|
|
||||||
Preamble
|
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for
|
|
||||||
software and other kinds of works.
|
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
|
||||||
to take away your freedom to share and change the works. By contrast,
|
|
||||||
the GNU General Public License is intended to guarantee your freedom to
|
|
||||||
share and change all versions of a program--to make sure it remains free
|
|
||||||
software for all its users. We, the Free Software Foundation, use the
|
|
||||||
GNU General Public License for most of our software; it applies also to
|
|
||||||
any other work released this way by its authors. You can apply it to
|
|
||||||
your programs, too.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
|
||||||
have the freedom to distribute copies of free software (and charge for
|
|
||||||
them if you wish), that you receive source code or can get it if you
|
|
||||||
want it, that you can change the software or use pieces of it in new
|
|
||||||
free programs, and that you know you can do these things.
|
|
||||||
|
|
||||||
To protect your rights, we need to prevent others from denying you
|
|
||||||
these rights or asking you to surrender the rights. Therefore, you have
|
|
||||||
certain responsibilities if you distribute copies of the software, or if
|
|
||||||
you modify it: responsibilities to respect the freedom of others.
|
|
||||||
|
|
||||||
For example, if you distribute copies of such a program, whether
|
|
||||||
gratis or for a fee, you must pass on to the recipients the same
|
|
||||||
freedoms that you received. You must make sure that they, too, receive
|
|
||||||
or can get the source code. And you must show them these terms so they
|
|
||||||
know their rights.
|
|
||||||
|
|
||||||
Developers that use the GNU GPL protect your rights with two steps:
|
|
||||||
(1) assert copyright on the software, and (2) offer you this License
|
|
||||||
giving you legal permission to copy, distribute and/or modify it.
|
|
||||||
|
|
||||||
For the developers' and authors' protection, the GPL clearly explains
|
|
||||||
that there is no warranty for this free software. For both users' and
|
|
||||||
authors' sake, the GPL requires that modified versions be marked as
|
|
||||||
changed, so that their problems will not be attributed erroneously to
|
|
||||||
authors of previous versions.
|
|
||||||
|
|
||||||
Some devices are designed to deny users access to install or run
|
|
||||||
modified versions of the software inside them, although the manufacturer
|
|
||||||
can do so. This is fundamentally incompatible with the aim of
|
|
||||||
protecting users' freedom to change the software. The systematic
|
|
||||||
pattern of such abuse occurs in the area of products for individuals to
|
|
||||||
use, which is precisely where it is most unacceptable. Therefore, we
|
|
||||||
have designed this version of the GPL to prohibit the practice for those
|
|
||||||
products. If such problems arise substantially in other domains, we
|
|
||||||
stand ready to extend this provision to those domains in future versions
|
|
||||||
of the GPL, as needed to protect the freedom of users.
|
|
||||||
|
|
||||||
Finally, every program is threatened constantly by software patents.
|
|
||||||
States should not allow patents to restrict development and use of
|
|
||||||
software on general-purpose computers, but in those that do, we wish to
|
|
||||||
avoid the special danger that patents applied to a free program could
|
|
||||||
make it effectively proprietary. To prevent this, the GPL assures that
|
|
||||||
patents cannot be used to render the program non-free.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
|
||||||
modification follow.
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
0. Definitions.
|
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU General Public License.
|
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
|
||||||
works, such as semiconductor masks.
|
|
||||||
|
|
||||||
"The Program" refers to any copyrightable work licensed under this
|
|
||||||
License. Each licensee is addressed as "you". "Licensees" and
|
|
||||||
"recipients" may be individuals or organizations.
|
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work
|
|
||||||
in a fashion requiring copyright permission, other than the making of an
|
|
||||||
exact copy. The resulting work is called a "modified version" of the
|
|
||||||
earlier work or a work "based on" the earlier work.
|
|
||||||
|
|
||||||
A "covered work" means either the unmodified Program or a work based
|
|
||||||
on the Program.
|
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without
|
|
||||||
permission, would make you directly or secondarily liable for
|
|
||||||
infringement under applicable copyright law, except executing it on a
|
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
|
||||||
distribution (with or without modification), making available to the
|
|
||||||
public, and in some countries other activities as well.
|
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other
|
|
||||||
parties to make or receive copies. Mere interaction with a user through
|
|
||||||
a computer network, with no transfer of a copy, is not conveying.
|
|
||||||
|
|
||||||
An interactive user interface displays "Appropriate Legal Notices"
|
|
||||||
to the extent that it includes a convenient and prominently visible
|
|
||||||
feature that (1) displays an appropriate copyright notice, and (2)
|
|
||||||
tells the user that there is no warranty for the work (except to the
|
|
||||||
extent that warranties are provided), that licensees may convey the
|
|
||||||
work under this License, and how to view a copy of this License. If
|
|
||||||
the interface presents a list of user commands or options, such as a
|
|
||||||
menu, a prominent item in the list meets this criterion.
|
|
||||||
|
|
||||||
1. Source Code.
|
|
||||||
|
|
||||||
The "source code" for a work means the preferred form of the work
|
|
||||||
for making modifications to it. "Object code" means any non-source
|
|
||||||
form of a work.
|
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official
|
|
||||||
standard defined by a recognized standards body, or, in the case of
|
|
||||||
interfaces specified for a particular programming language, one that
|
|
||||||
is widely used among developers working in that language.
|
|
||||||
|
|
||||||
The "System Libraries" of an executable work include anything, other
|
|
||||||
than the work as a whole, that (a) is included in the normal form of
|
|
||||||
packaging a Major Component, but which is not part of that Major
|
|
||||||
Component, and (b) serves only to enable use of the work with that
|
|
||||||
Major Component, or to implement a Standard Interface for which an
|
|
||||||
implementation is available to the public in source code form. A
|
|
||||||
"Major Component", in this context, means a major essential component
|
|
||||||
(kernel, window system, and so on) of the specific operating system
|
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
|
||||||
produce the work, or an object code interpreter used to run it.
|
|
||||||
|
|
||||||
The "Corresponding Source" for a work in object code form means all
|
|
||||||
the source code needed to generate, install, and (for an executable
|
|
||||||
work) run the object code and to modify the work, including scripts to
|
|
||||||
control those activities. However, it does not include the work's
|
|
||||||
System Libraries, or general-purpose tools or generally available free
|
|
||||||
programs which are used unmodified in performing those activities but
|
|
||||||
which are not part of the work. For example, Corresponding Source
|
|
||||||
includes interface definition files associated with source files for
|
|
||||||
the work, and the source code for shared libraries and dynamically
|
|
||||||
linked subprograms that the work is specifically designed to require,
|
|
||||||
such as by intimate data communication or control flow between those
|
|
||||||
subprograms and other parts of the work.
|
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users
|
|
||||||
can regenerate automatically from other parts of the Corresponding
|
|
||||||
Source.
|
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that
|
|
||||||
same work.
|
|
||||||
|
|
||||||
2. Basic Permissions.
|
|
||||||
|
|
||||||
All rights granted under this License are granted for the term of
|
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
|
||||||
permission to run the unmodified Program. The output from running a
|
|
||||||
covered work is covered by this License only if the output, given its
|
|
||||||
content, constitutes a covered work. This License acknowledges your
|
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not
|
|
||||||
convey, without conditions so long as your license otherwise remains
|
|
||||||
in force. You may convey covered works to others for the sole purpose
|
|
||||||
of having them make modifications exclusively for you, or provide you
|
|
||||||
with facilities for running those works, provided that you comply with
|
|
||||||
the terms of this License in conveying all material for which you do
|
|
||||||
not control copyright. Those thus making or running the covered works
|
|
||||||
for you must do so exclusively on your behalf, under your direction
|
|
||||||
and control, on terms that prohibit them from making any copies of
|
|
||||||
your copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under
|
|
||||||
the conditions stated below. Sublicensing is not allowed; section 10
|
|
||||||
makes it unnecessary.
|
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
|
|
||||||
No covered work shall be deemed part of an effective technological
|
|
||||||
measure under any applicable law fulfilling obligations under article
|
|
||||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
|
||||||
similar laws prohibiting or restricting circumvention of such
|
|
||||||
measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid
|
|
||||||
circumvention of technological measures to the extent such circumvention
|
|
||||||
is effected by exercising rights under this License with respect to
|
|
||||||
the covered work, and you disclaim any intention to limit operation or
|
|
||||||
modification of the work as a means of enforcing, against the work's
|
|
||||||
users, your or third parties' legal rights to forbid circumvention of
|
|
||||||
technological measures.
|
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
|
||||||
|
|
||||||
You may convey verbatim copies of the Program's source code as you
|
|
||||||
receive it, in any medium, provided that you conspicuously and
|
|
||||||
appropriately publish on each copy an appropriate copyright notice;
|
|
||||||
keep intact all notices stating that this License and any
|
|
||||||
non-permissive terms added in accord with section 7 apply to the code;
|
|
||||||
keep intact all notices of the absence of any warranty; and give all
|
|
||||||
recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey,
|
|
||||||
and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
|
|
||||||
You may convey a work based on the Program, or the modifications to
|
|
||||||
produce it from the Program, in the form of source code under the
|
|
||||||
terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified
|
|
||||||
it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is
|
|
||||||
released under this License and any conditions added under section
|
|
||||||
7. This requirement modifies the requirement in section 4 to
|
|
||||||
"keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this
|
|
||||||
License to anyone who comes into possession of a copy. This
|
|
||||||
License will therefore apply, along with any applicable section 7
|
|
||||||
additional terms, to the whole of the work, and all its parts,
|
|
||||||
regardless of how they are packaged. This License gives no
|
|
||||||
permission to license the work in any other way, but it does not
|
|
||||||
invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display
|
|
||||||
Appropriate Legal Notices; however, if the Program has interactive
|
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
|
||||||
work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent
|
|
||||||
works, which are not by their nature extensions of the covered work,
|
|
||||||
and which are not combined with it such as to form a larger program,
|
|
||||||
in or on a volume of a storage or distribution medium, is called an
|
|
||||||
"aggregate" if the compilation and its resulting copyright are not
|
|
||||||
used to limit the access or legal rights of the compilation's users
|
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
|
||||||
in an aggregate does not cause this License to apply to the other
|
|
||||||
parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
|
|
||||||
You may convey a covered work in object code form under the terms
|
|
||||||
of sections 4 and 5, provided that you also convey the
|
|
||||||
machine-readable Corresponding Source under the terms of this License,
|
|
||||||
in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by the
|
|
||||||
Corresponding Source fixed on a durable physical medium
|
|
||||||
customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by a
|
|
||||||
written offer, valid for at least three years and valid for as
|
|
||||||
long as you offer spare parts or customer support for that product
|
|
||||||
model, to give anyone who possesses the object code either (1) a
|
|
||||||
copy of the Corresponding Source for all the software in the
|
|
||||||
product that is covered by this License, on a durable physical
|
|
||||||
medium customarily used for software interchange, for a price no
|
|
||||||
more than your reasonable cost of physically performing this
|
|
||||||
conveying of source, or (2) access to copy the
|
|
||||||
Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the
|
|
||||||
written offer to provide the Corresponding Source. This
|
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
|
||||||
only if you received the object code with such an offer, in accord
|
|
||||||
with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated
|
|
||||||
place (gratis or for a charge), and offer equivalent access to the
|
|
||||||
Corresponding Source in the same way through the same place at no
|
|
||||||
further charge. You need not require recipients to copy the
|
|
||||||
Corresponding Source along with the object code. If the place to
|
|
||||||
copy the object code is a network server, the Corresponding Source
|
|
||||||
may be on a different server (operated by you or a third party)
|
|
||||||
that supports equivalent copying facilities, provided you maintain
|
|
||||||
clear directions next to the object code saying where to find the
|
|
||||||
Corresponding Source. Regardless of what server hosts the
|
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
|
||||||
available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided
|
|
||||||
you inform other peers where the object code and Corresponding
|
|
||||||
Source of the work are being offered to the general public at no
|
|
||||||
charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded
|
|
||||||
from the Corresponding Source as a System Library, need not be
|
|
||||||
included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any
|
|
||||||
tangible personal property which is normally used for personal, family,
|
|
||||||
or household purposes, or (2) anything designed or sold for incorporation
|
|
||||||
into a dwelling. In determining whether a product is a consumer product,
|
|
||||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
|
||||||
product received by a particular user, "normally used" refers to a
|
|
||||||
typical or common use of that class of product, regardless of the status
|
|
||||||
of the particular user or of the way in which the particular user
|
|
||||||
actually uses, or expects or is expected to use, the product. A product
|
|
||||||
is a consumer product regardless of whether the product has substantial
|
|
||||||
commercial, industrial or non-consumer uses, unless such uses represent
|
|
||||||
the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods,
|
|
||||||
procedures, authorization keys, or other information required to install
|
|
||||||
and execute modified versions of a covered work in that User Product from
|
|
||||||
a modified version of its Corresponding Source. The information must
|
|
||||||
suffice to ensure that the continued functioning of the modified object
|
|
||||||
code is in no case prevented or interfered with solely because
|
|
||||||
modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or
|
|
||||||
specifically for use in, a User Product, and the conveying occurs as
|
|
||||||
part of a transaction in which the right of possession and use of the
|
|
||||||
User Product is transferred to the recipient in perpetuity or for a
|
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
|
||||||
by the Installation Information. But this requirement does not apply
|
|
||||||
if neither you nor any third party retains the ability to install
|
|
||||||
modified object code on the User Product (for example, the work has
|
|
||||||
been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a
|
|
||||||
requirement to continue to provide support service, warranty, or updates
|
|
||||||
for a work that has been modified or installed by the recipient, or for
|
|
||||||
the User Product in which it has been modified or installed. Access to a
|
|
||||||
network may be denied when the modification itself materially and
|
|
||||||
adversely affects the operation of the network or violates the rules and
|
|
||||||
protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided,
|
|
||||||
in accord with this section must be in a format that is publicly
|
|
||||||
documented (and with an implementation available to the public in
|
|
||||||
source code form), and must require no special password or key for
|
|
||||||
unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
|
|
||||||
"Additional permissions" are terms that supplement the terms of this
|
|
||||||
License by making exceptions from one or more of its conditions.
|
|
||||||
Additional permissions that are applicable to the entire Program shall
|
|
||||||
be treated as though they were included in this License, to the extent
|
|
||||||
that they are valid under applicable law. If additional permissions
|
|
||||||
apply only to part of the Program, that part may be used separately
|
|
||||||
under those permissions, but the entire Program remains governed by
|
|
||||||
this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
|
||||||
remove any additional permissions from that copy, or from any part of
|
|
||||||
it. (Additional permissions may be written to require their own
|
|
||||||
removal in certain cases when you modify the work.) You may place
|
|
||||||
additional permissions on material, added by you to a covered work,
|
|
||||||
for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you
|
|
||||||
add to a covered work, you may (if authorized by the copyright holders of
|
|
||||||
that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the
|
|
||||||
terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or
|
|
||||||
author attributions in that material or in the Appropriate Legal
|
|
||||||
Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or
|
|
||||||
requiring that modified versions of such material be marked in
|
|
||||||
reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or
|
|
||||||
authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some
|
|
||||||
trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that
|
|
||||||
material by anyone who conveys the material (or modified versions of
|
|
||||||
it) with contractual assumptions of liability to the recipient, for
|
|
||||||
any liability that these contractual assumptions directly impose on
|
|
||||||
those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further
|
|
||||||
restrictions" within the meaning of section 10. If the Program as you
|
|
||||||
received it, or any part of it, contains a notice stating that it is
|
|
||||||
governed by this License along with a term that is a further
|
|
||||||
restriction, you may remove that term. If a license document contains
|
|
||||||
a further restriction but permits relicensing or conveying under this
|
|
||||||
License, you may add to a covered work material governed by the terms
|
|
||||||
of that license document, provided that the further restriction does
|
|
||||||
not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you
|
|
||||||
must place, in the relevant source files, a statement of the
|
|
||||||
additional terms that apply to those files, or a notice indicating
|
|
||||||
where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the
|
|
||||||
form of a separately written license, or stated as exceptions;
|
|
||||||
the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
|
||||||
modify it is void, and will automatically terminate your rights under
|
|
||||||
this License (including any patent licenses granted under the third
|
|
||||||
paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your
|
|
||||||
license from a particular copyright holder is reinstated (a)
|
|
||||||
provisionally, unless and until the copyright holder explicitly and
|
|
||||||
finally terminates your license, and (b) permanently, if the copyright
|
|
||||||
holder fails to notify you of the violation by some reasonable means
|
|
||||||
prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is
|
|
||||||
reinstated permanently if the copyright holder notifies you of the
|
|
||||||
violation by some reasonable means, this is the first time you have
|
|
||||||
received notice of violation of this License (for any work) from that
|
|
||||||
copyright holder, and you cure the violation prior to 30 days after
|
|
||||||
your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
|
||||||
licenses of parties who have received copies or rights from you under
|
|
||||||
this License. If your rights have been terminated and not permanently
|
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
|
||||||
material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or
|
|
||||||
run a copy of the Program. Ancillary propagation of a covered work
|
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
|
||||||
to receive a copy likewise does not require acceptance. However,
|
|
||||||
nothing other than this License grants you permission to propagate or
|
|
||||||
modify any covered work. These actions infringe copyright if you do
|
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
|
||||||
receives a license from the original licensors, to run, modify and
|
|
||||||
propagate that work, subject to this License. You are not responsible
|
|
||||||
for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an
|
|
||||||
organization, or substantially all assets of one, or subdividing an
|
|
||||||
organization, or merging organizations. If propagation of a covered
|
|
||||||
work results from an entity transaction, each party to that
|
|
||||||
transaction who receives a copy of the work also receives whatever
|
|
||||||
licenses to the work the party's predecessor in interest had or could
|
|
||||||
give under the previous paragraph, plus a right to possession of the
|
|
||||||
Corresponding Source of the work from the predecessor in interest, if
|
|
||||||
the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
|
||||||
rights granted or affirmed under this License. For example, you may
|
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
|
||||||
rights granted under this License, and you may not initiate litigation
|
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
|
||||||
any patent claim is infringed by making, using, selling, offering for
|
|
||||||
sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this
|
|
||||||
License of the Program or a work on which the Program is based. The
|
|
||||||
work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims
|
|
||||||
owned or controlled by the contributor, whether already acquired or
|
|
||||||
hereafter acquired, that would be infringed by some manner, permitted
|
|
||||||
by this License, of making, using, or selling its contributor version,
|
|
||||||
but do not include claims that would be infringed only as a
|
|
||||||
consequence of further modification of the contributor version. For
|
|
||||||
purposes of this definition, "control" includes the right to grant
|
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
|
||||||
this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
|
||||||
patent license under the contributor's essential patent claims, to
|
|
||||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
|
||||||
propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express
|
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
|
||||||
(such as an express permission to practice a patent or covenant not to
|
|
||||||
sue for patent infringement). To "grant" such a patent license to a
|
|
||||||
party means to make such an agreement or commitment not to enforce a
|
|
||||||
patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license,
|
|
||||||
and the Corresponding Source of the work is not available for anyone
|
|
||||||
to copy, free of charge and under the terms of this License, through a
|
|
||||||
publicly available network server or other readily accessible means,
|
|
||||||
then you must either (1) cause the Corresponding Source to be so
|
|
||||||
available, or (2) arrange to deprive yourself of the benefit of the
|
|
||||||
patent license for this particular work, or (3) arrange, in a manner
|
|
||||||
consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have
|
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
|
||||||
covered work in a country, or your recipient's use of the covered work
|
|
||||||
in a country, would infringe one or more identifiable patents in that
|
|
||||||
country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or
|
|
||||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
|
||||||
covered work, and grant a patent license to some of the parties
|
|
||||||
receiving the covered work authorizing them to use, propagate, modify
|
|
||||||
or convey a specific copy of the covered work, then the patent license
|
|
||||||
you grant is automatically extended to all recipients of the covered
|
|
||||||
work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within
|
|
||||||
the scope of its coverage, prohibits the exercise of, or is
|
|
||||||
conditioned on the non-exercise of one or more of the rights that are
|
|
||||||
specifically granted under this License. You may not convey a covered
|
|
||||||
work if you are a party to an arrangement with a third party that is
|
|
||||||
in the business of distributing software, under which you make payment
|
|
||||||
to the third party based on the extent of your activity of conveying
|
|
||||||
the work, and under which the third party grants, to any of the
|
|
||||||
parties who would receive the covered work from you, a discriminatory
|
|
||||||
patent license (a) in connection with copies of the covered work
|
|
||||||
conveyed by you (or copies made from those copies), or (b) primarily
|
|
||||||
for and in connection with specific products or compilations that
|
|
||||||
contain the covered work, unless you entered into that arrangement,
|
|
||||||
or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting
|
|
||||||
any implied license or other defenses to infringement that may
|
|
||||||
otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
|
||||||
covered work so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you may
|
|
||||||
not convey it at all. For example, if you agree to terms that obligate you
|
|
||||||
to collect a royalty for further conveying from those to whom you convey
|
|
||||||
the Program, the only way you could satisfy both those terms and this
|
|
||||||
License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Use with the GNU Affero General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
|
||||||
permission to link or combine any covered work with a work licensed
|
|
||||||
under version 3 of the GNU Affero General Public License into a single
|
|
||||||
combined work, and to convey the resulting work. The terms of this
|
|
||||||
License will continue to apply to the part which is the covered work,
|
|
||||||
but the special requirements of the GNU Affero General Public License,
|
|
||||||
section 13, concerning interaction through a network will apply to the
|
|
||||||
combination as such.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
|
||||||
the GNU General Public License from time to time. Such new versions will
|
|
||||||
be similar in spirit to the present version, but may differ in detail to
|
|
||||||
address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
|
||||||
Program specifies that a certain numbered version of the GNU General
|
|
||||||
Public License "or any later version" applies to it, you have the
|
|
||||||
option of following the terms and conditions either of that numbered
|
|
||||||
version or of any later version published by the Free Software
|
|
||||||
Foundation. If the Program does not specify a version number of the
|
|
||||||
GNU General Public License, you may choose any version ever published
|
|
||||||
by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
|
||||||
versions of the GNU General Public License can be used, that proxy's
|
|
||||||
public statement of acceptance of a version permanently authorizes you
|
|
||||||
to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different
|
|
||||||
permissions. However, no additional obligations are imposed on any
|
|
||||||
author or copyright holder as a result of your choosing to follow a
|
|
||||||
later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
|
||||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
|
||||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
|
||||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
|
||||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
|
||||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
|
||||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
|
||||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
|
||||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
|
||||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
|
||||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
|
||||||
SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided
|
|
||||||
above cannot be given local legal effect according to their terms,
|
|
||||||
reviewing courts shall apply local law that most closely approximates
|
|
||||||
an absolute waiver of all civil liability in connection with the
|
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
|
||||||
copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
state the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short
|
|
||||||
notice like this when it starts in an interactive mode:
|
|
||||||
|
|
||||||
<program> Copyright (C) <year> <name of author>
|
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
|
||||||
This is free software, and you are welcome to redistribute it
|
|
||||||
under certain conditions; type `show c' for details.
|
|
||||||
|
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
|
||||||
parts of the General Public License. Of course, your program's commands
|
|
||||||
might be different; for a GUI interface, you would use an "about box".
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
|
||||||
For more information on this, and how to apply and follow the GNU GPL, see
|
|
||||||
<https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program
|
|
||||||
into proprietary programs. If your program is a subroutine library, you
|
|
||||||
may consider it more useful to permit linking proprietary applications with
|
|
||||||
the library. If this is what you want to do, use the GNU Lesser General
|
|
||||||
Public License instead of this License. But first, please read
|
|
||||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
|
308
README.md
308
README.md
@ -1,72 +1,167 @@
|
|||||||
# NoteKfet 2020
|
# NoteKfet 2020
|
||||||
|
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0.txt)
|
[](https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
[](https://gitlab.crans.org/bde/nk20/nk20/commits/master)
|
[](https://gitlab.crans.org/bde/nk20/commits/main)
|
||||||
[](https://gitlab.crans.org/bde/nk20/commits/master)
|
[](https://gitlab.crans.org/bde/nk20/commits/main)
|
||||||
|
|
||||||
## Installation sur un serveur
|
## Table des matières
|
||||||
|
|
||||||
On supposera pour la suite que vous utilisez Debian/Ubuntu sur un serveur tout nu ou bien configuré.
|
- [Installation d'une instance de développement](#installation-dune-instance-de-développement)
|
||||||
|
- [Installation d'une instance de production](#installation-dune-instance-de-production)
|
||||||
|
|
||||||
1. Paquets nécessaires
|
## Installation d'une instance de développement
|
||||||
|
|
||||||
$ sudo apt install nginx python3 python3-pip python3-dev uwsgi
|
L'instance de développement installe la majorité des dépendances dans un environnement Python isolé.
|
||||||
$ sudo apt install uwsgi-plugin-python3 python3-venv git acl
|
Bien que cela permette de créer une instance sur toutes les distributions,
|
||||||
|
**cela veut dire que vos dépendances ne seront pas mises à jour automatiquement.**
|
||||||
|
|
||||||
La génération des factures de l'application trésorerie nécessite une installation de LaTeX suffisante :
|
1. **Installation des dépendances de la distribution.**
|
||||||
|
Il y a quelques dépendances qui ne sont pas trouvable dans PyPI.
|
||||||
|
On donne ci-dessous l'exemple pour une distribution basée sur Debian, mais vous pouvez facilement adapter pour ArchLinux ou autre.
|
||||||
|
|
||||||
$ sudo apt install texlive-latex-extra texlive-fonts-extra texlive-lang-french
|
```bash
|
||||||
|
$ sudo apt update
|
||||||
|
$ sudo apt install --no-install-recommends -y \
|
||||||
|
ipython3 python3-setuptools python3-venv python3-dev \
|
||||||
|
texlive-xetex gettext libjs-bootstrap4 fonts-font-awesome git
|
||||||
|
```
|
||||||
|
|
||||||
2. Clonage du dépot
|
2. **Clonage du dépot** là où vous voulez :
|
||||||
|
|
||||||
on se met au bon endroit :
|
```bash
|
||||||
|
$ git clone git@gitlab.crans.org:bde/nk20.git --recursive && cd nk20
|
||||||
|
```
|
||||||
|
|
||||||
$ cd /var/www/
|
3. **Création d'un environment de travail Python décorrélé du système.**
|
||||||
$ mkdir note_kfet
|
On n'utilise pas `--system-site-packages` ici pour ne pas avoir des clashs de versions de modules avec le système.
|
||||||
$ sudo chown www-data:www-data note_kfet
|
|
||||||
$ sudo usermod -a -G www-data $USER
|
|
||||||
$ sudo chmod g+ws note_kfet
|
|
||||||
$ sudo setfacl -d -m "g::rwx" note_kfet
|
|
||||||
$ cd note_kfet
|
|
||||||
$ git clone git@gitlab.crans.org:bde/nk20.git .
|
|
||||||
3. Environment Virtuel
|
|
||||||
|
|
||||||
À la racine du projet:
|
|
||||||
|
|
||||||
|
```bash
|
||||||
$ python3 -m venv env
|
$ python3 -m venv env
|
||||||
$ source env/bin/activate
|
$ source env/bin/activate # entrer dans l'environnement
|
||||||
(env)$ pip3 install -r requirements/base.txt
|
(env)$ pip3 install -r requirements.txt
|
||||||
(env)$ pip3 install -r requirements/prod.txt # uniquement en prod, nécessite un base postgres
|
(env)$ deactivate # sortir de l'environnement
|
||||||
(env)$ deactivate
|
```
|
||||||
|
|
||||||
4. uwsgi et Nginx
|
4. **Variable d'environnement.**
|
||||||
|
Copier le fichier `.env_example` vers `.env` à la racine du projet et mettre à jour
|
||||||
|
ce qu'il faut.
|
||||||
|
|
||||||
Un exemple de conf est disponible :
|
5. **Migrations et chargement des données initiales.**
|
||||||
|
Pour initialiser la base de données avec de quoi travailler.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(env)$ ./manage.py collectstatic --noinput
|
||||||
|
(env)$ ./manage.py compilemessages
|
||||||
|
(env)$ ./manage.py makemigrations
|
||||||
|
(env)$ ./manage.py migrate
|
||||||
|
(env)$ ./manage.py loaddata initial
|
||||||
|
(env)$ ./manage.py createsuperuser # Création d'un⋅e utilisateur⋅rice initial
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Enjoy :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(env)$ ./manage.py runserver 0.0.0.0:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
En mettant `0.0.0.0:8000` après `runserver`, vous rendez votre instance Django
|
||||||
|
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**.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ echo "deb http://deb.debian.org/debian buster-backports main" | sudo tee /etc/apt/sources.list.d/deb_debian_org_debian.list
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Installation des dépendances APT.**
|
||||||
|
On tire les dépendances le plus possible à partir des dépôts de Debian.
|
||||||
|
On a besoin d'un environnement LaTeX pour générer les factures.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ sudo apt update
|
||||||
|
$ 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-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
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Clonage du dépot** dans `/var/www/note_kfet`,
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ sudo mkdir -p /var/www/note_kfet && cd /var/www/note_kfet
|
||||||
|
$ sudo chown www-data:www-data .
|
||||||
|
$ sudo chmod g+rwx .
|
||||||
|
$ sudo -u www-data git clone https://gitlab.crans.org/bde/nk20.git --recursive
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Création d'un environment de travail Python décorrélé du système.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ python3 -m venv env --system-site-packages
|
||||||
|
$ source env/bin/activate # entrer dans l'environnement
|
||||||
|
(env)$ pip3 install -r requirements.txt
|
||||||
|
(env)$ deactivate # sortir de l'environnement
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Pour configurer UWSGI et NGINX**, des exemples de conf sont disponibles.
|
||||||
|
**_Modifier le fichier pour être en accord avec le reste de votre config_**
|
||||||
|
|
||||||
|
```bash
|
||||||
$ cp nginx_note.conf_example nginx_note.conf
|
$ cp nginx_note.conf_example nginx_note.conf
|
||||||
|
|
||||||
***Modifier le fichier pour être en accord avec le reste de votre config***
|
|
||||||
|
|
||||||
On utilise uwsgi et Nginx pour gérer le coté serveur :
|
|
||||||
|
|
||||||
$ sudo ln -sf /var/www/note_kfet/nginx_note.conf /etc/nginx/sites-enabled/
|
$ sudo ln -sf /var/www/note_kfet/nginx_note.conf /etc/nginx/sites-enabled/
|
||||||
|
```
|
||||||
|
|
||||||
Si l'on a un emperor (plusieurs instance uwsgi):
|
Si l'on a un emperor (plusieurs instance uwsgi):
|
||||||
|
|
||||||
|
```bash
|
||||||
$ sudo ln -sf /var/www/note_kfet/uwsgi_note.ini /etc/uwsgi/sites/
|
$ sudo ln -sf /var/www/note_kfet/uwsgi_note.ini /etc/uwsgi/sites/
|
||||||
|
```
|
||||||
|
|
||||||
Sinon:
|
Sinon si on est dans le cas habituel :
|
||||||
|
|
||||||
|
```bash
|
||||||
$ sudo ln -sf /var/www/note_kfet/uwsgi_note.ini /etc/uwsgi/apps-enabled/
|
$ sudo ln -sf /var/www/note_kfet/uwsgi_note.ini /etc/uwsgi/apps-enabled/
|
||||||
|
```
|
||||||
|
|
||||||
Le touch-reload est activé par défault, pour redémarrer la note il suffit donc de faire `touch uwsgi_note.ini`.
|
Le touch-reload est activé par défault, pour redémarrer la note il suffit donc de faire `touch uwsgi_note.ini`.
|
||||||
|
|
||||||
5. Base de données
|
5. **Base de données.** En production on utilise PostgreSQL.
|
||||||
|
|
||||||
En prod on utilise postgresql.
|
$ sudo apt-get install postgresql postgresql-contrib
|
||||||
|
|
||||||
$ sudo apt-get install postgresql postgresql-contrib libpq-dev
|
|
||||||
(env)$ pip3 install psycopg2
|
|
||||||
|
|
||||||
La config de la base de donnée se fait comme suit:
|
La config de la base de donnée se fait comme suit:
|
||||||
|
|
||||||
@ -107,7 +202,7 @@ On supposera pour la suite que vous utilisez Debian/Ubuntu sur un serveur tout n
|
|||||||
et on renseigne des secrets et des paramètres :
|
et on renseigne des secrets et des paramètres :
|
||||||
|
|
||||||
DJANGO_APP_STAGE=dev # ou "prod"
|
DJANGO_APP_STAGE=dev # ou "prod"
|
||||||
DJANGO_DEV_STORE_METHOD=sqllite # ou "postgres"
|
DJANGO_DEV_STORE_METHOD=sqlite # ou "postgres"
|
||||||
DJANGO_DB_HOST=localhost
|
DJANGO_DB_HOST=localhost
|
||||||
DJANGO_DB_NAME=note_db
|
DJANGO_DB_NAME=note_db
|
||||||
DJANGO_DB_USER=note
|
DJANGO_DB_USER=note
|
||||||
@ -115,93 +210,94 @@ On supposera pour la suite que vous utilisez Debian/Ubuntu sur un serveur tout n
|
|||||||
DJANGO_DB_PORT=
|
DJANGO_DB_PORT=
|
||||||
DJANGO_SECRET_KEY=CHANGE_ME
|
DJANGO_SECRET_KEY=CHANGE_ME
|
||||||
DJANGO_SETTINGS_MODULE="note_kfet.settings
|
DJANGO_SETTINGS_MODULE="note_kfet.settings
|
||||||
DOMAIN=localhost # note.example.com
|
NOTE_URL=localhost # URL où accéder à la note
|
||||||
CONTACT_EMAIL=tresorerie.bde@localhost
|
CONTACT_EMAIL=tresorerie.bde@localhost
|
||||||
NOTE_URL=localhost # serveur cas note.example.com si auto-hébergé.
|
# Le reste n'est utile qu'en production, pour configurer l'envoi des mails
|
||||||
NOTE_MAIL=notekfet@localhost # Adresse expéditrice des mails
|
NOTE_MAIL=notekfet@localhost
|
||||||
WEBMASTER_MAIL=notekfet@localhost # Adresse sur laquelle contacter les webmasters de la note
|
EMAIL_HOST=smtp.localhost
|
||||||
|
EMAIL_PORT=25
|
||||||
|
EMAIL_USER=notekfet@localhost
|
||||||
|
EMAIL_PASSWORD=CHANGE_ME
|
||||||
|
WIKI_USER=NoteKfet2020
|
||||||
|
WIKI_PASSWORD=CHANGE_ME
|
||||||
|
|
||||||
|
|
||||||
Ensuite on (re)bascule dans l'environement virtuel et on lance les migrations
|
Ensuite on (re)bascule dans l'environement virtuel et on lance les migrations
|
||||||
|
|
||||||
$ source /env/bin/activate
|
$ source /env/bin/activate
|
||||||
(env)$ ./manage.py check # pas de bêtise qui traine
|
(env)$ ./manage.py check # pas de bêtise qui traine
|
||||||
(env)$ ./manage.py makemigrations
|
|
||||||
(env)$ ./manage.py migrate
|
(env)$ ./manage.py migrate
|
||||||
|
|
||||||
7. Enjoy
|
7. *Enjoy \o/*
|
||||||
|
|
||||||
|
### Installation avec Docker
|
||||||
## Installer avec Docker
|
|
||||||
|
|
||||||
Il est possible de travailler sur une instance Docker.
|
Il est possible de travailler sur une instance Docker.
|
||||||
|
|
||||||
1. Cloner le dépôt là où vous voulez :
|
Pour construire l'image Docker `nk20`,
|
||||||
|
|
||||||
$ git clone git@gitlab.crans.org:bde/nk20.git
|
```
|
||||||
|
git clone https://gitlab.crans.org/bde/nk20/ --recursive && cd nk20
|
||||||
|
docker build . -t nk20
|
||||||
|
```
|
||||||
|
|
||||||
2. Copiez le fichier `.env_example` à la racine du projet vers le fichier `.env`,
|
Ensuite pour lancer la note Kfet en tant que vous (option `-u`),
|
||||||
et mettez à jour vos variables d'environnement
|
l'exposer sur son port 80 (option `-p`) et monter le code en écriture (option `-v`),
|
||||||
|
|
||||||
3. Dans le fichier `docker_compose.yml`, qu'on suppose déjà configuré,
|
```
|
||||||
ajouter les lignes suivantes, en les adaptant à la configuration voulue :
|
docker run -it --rm -u $(id -u):$(id -g) -v "$(pwd):/var/www/note_kfet/" -p 80:8080 nk20
|
||||||
|
```
|
||||||
|
|
||||||
|
Si vous souhaitez lancer une commande spéciale, vous pouvez l'ajouter à la fin, par exemple,
|
||||||
|
|
||||||
|
```
|
||||||
|
docker run -it --rm -u $(id -u):$(id -g) -v "$(pwd):/var/www/note_kfet/" -p 80:8080 nk20 python3 ./manage.py createsuperuser
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Avec Docker Compose
|
||||||
|
|
||||||
|
On vous conseilles de faire un fichier d'environnement `.env` en prenant exemple sur `.env_example`.
|
||||||
|
|
||||||
|
Pour par exemple utiliser le Docker de la note Kfet avec Traefik pour réaliser le HTTPS,
|
||||||
|
|
||||||
|
```YAML
|
||||||
nk20:
|
nk20:
|
||||||
build: /chemin/vers/nk20
|
build: /chemin/vers/le/code/nk20
|
||||||
volumes:
|
volumes:
|
||||||
- /chemin/vers/nk20:/code/
|
- /chemin/vers/le/code/nk20:/var/www/note_kfet/
|
||||||
env_file: /chemin/vers/nk20/.env
|
env_file: /chemin/vers/le/code/nk20/.env
|
||||||
restart: always
|
restart: always
|
||||||
labels:
|
labels:
|
||||||
- traefik.domain=ndd.example.com
|
- "traefik.http.routers.nk20.rule=Host(`ndd.example.com`)"
|
||||||
- traefik.frontend.rule=Host:ndd.example.com
|
- "traefik.http.services.nk20.loadbalancer.server.port=8080"
|
||||||
- traefik.port=8000
|
```
|
||||||
|
|
||||||
3. Enjoy :
|
|
||||||
|
|
||||||
$ docker-compose up -d nk20
|
|
||||||
|
|
||||||
## Installer un serveur de développement
|
|
||||||
|
|
||||||
Avec `./manage.py runserver` il est très rapide de mettre en place
|
|
||||||
un serveur de développement par exemple sur son ordinateur.
|
|
||||||
|
|
||||||
1. Cloner le dépôt là où vous voulez :
|
|
||||||
|
|
||||||
$ git clone git@gitlab.crans.org:bde/nk20.git && cd nk20
|
|
||||||
|
|
||||||
2. Créer un environnement Python isolé
|
|
||||||
pour ne pas interférer avec les versions de paquets systèmes :
|
|
||||||
|
|
||||||
$ python3 -m venv venv
|
|
||||||
$ source venv/bin/activate
|
|
||||||
(env)$ pip install -r requirements/base.txt
|
|
||||||
|
|
||||||
3. Copier le fichier `.env_example` vers `.env` à la racine du projet et mettre à jour
|
|
||||||
ce qu'il faut
|
|
||||||
|
|
||||||
4. Migrations et chargement des données initiales :
|
|
||||||
|
|
||||||
(env)$ ./manage.py makemigrations
|
|
||||||
(env)$ ./manage.py migrate
|
|
||||||
(env)$ ./manage.py loaddata initial
|
|
||||||
|
|
||||||
5. Créer un super-utilisateur :
|
|
||||||
|
|
||||||
(env)$ ./manage.py createsuperuser
|
|
||||||
|
|
||||||
6. Enjoy :
|
|
||||||
|
|
||||||
(env)$ ./manage.py runserver 0.0.0.0:8000
|
|
||||||
|
|
||||||
En mettant `0.0.0.0:8000` après `runserver`, vous rendez votre instance Django
|
|
||||||
accessible depuis l'ensemble de votre réseau, pratique pour tester le rendu
|
|
||||||
de la note sur un téléphone !
|
|
||||||
|
|
||||||
## Cahier des Charges
|
|
||||||
|
|
||||||
Il est disponible [ici](https://wiki.crans.org/NoteKfet/NoteKfet2018/CdC).
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
La documentation est générée par django et son module admindocs.
|
Le cahier des charges initial est disponible [sur le Wiki Crans](https://wiki.crans.org/NoteKfet/NoteKfet2018/CdC).
|
||||||
|
|
||||||
|
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 !**
|
**Commentez votre code !**
|
||||||
|
|
||||||
|
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.
|
||||||
|
De plus, il faut aussi extraire les variables des fichiers JavaScript.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
python3 manage.py compilemessages
|
||||||
|
python3 manage.py compilejsmessages
|
||||||
|
```
|
||||||
|
14
ansible/ansible.cfg
Normal file
14
ansible/ansible.cfg
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
[defaults]
|
||||||
|
inventory = ./hosts
|
||||||
|
timeout = 42
|
||||||
|
|
||||||
|
[privilege_escalation]
|
||||||
|
become = True
|
||||||
|
become_ask_pass = True
|
||||||
|
|
||||||
|
[ssh_connection]
|
||||||
|
pipelining = True
|
||||||
|
retries = 3
|
||||||
|
|
||||||
|
[diff]
|
||||||
|
always = yes
|
19
ansible/base.yml
Executable file
19
ansible/base.yml
Executable file
@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env ansible-playbook
|
||||||
|
---
|
||||||
|
|
||||||
|
- hosts: all
|
||||||
|
vars_prompt:
|
||||||
|
- name: DB_PASSWORD
|
||||||
|
prompt: "Password of the database (leave it blank to skip database init)"
|
||||||
|
private: yes
|
||||||
|
vars:
|
||||||
|
mirror: eclats.crans.org
|
||||||
|
roles:
|
||||||
|
- 1-apt-basic
|
||||||
|
- 2-nk20
|
||||||
|
- 3-pip
|
||||||
|
- 4-certbot
|
||||||
|
- 5-nginx
|
||||||
|
- 6-psql
|
||||||
|
- 7-postinstall
|
||||||
|
- 8-docs
|
7
ansible/host_vars/bde-note-dev.adh.crans.org.yml
Normal file
7
ansible/host_vars/bde-note-dev.adh.crans.org.yml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
note:
|
||||||
|
server_name: note-dev.crans.org
|
||||||
|
git_branch: beta
|
||||||
|
serve_static: false
|
||||||
|
cron_enabled: false
|
||||||
|
email: notekfet2020@lists.crans.org
|
7
ansible/host_vars/bde-note.adh.crans.org.yml
Normal file
7
ansible/host_vars/bde-note.adh.crans.org.yml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
note:
|
||||||
|
server_name: note.crans.org
|
||||||
|
git_branch: main
|
||||||
|
serve_static: true
|
||||||
|
cron_enabled: true
|
||||||
|
email: notekfet2020@lists.crans.org
|
8
ansible/hosts_example
Normal file
8
ansible/hosts_example
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
[dev]
|
||||||
|
bde-note-dev.adh.crans.org
|
||||||
|
|
||||||
|
[prod]
|
||||||
|
bde-note.adh.crans.org
|
||||||
|
|
||||||
|
[all:vars]
|
||||||
|
ansible_python_interpreter=/usr/bin/python3
|
54
ansible/roles/1-apt-basic/tasks/main.yml
Normal file
54
ansible/roles/1-apt-basic/tasks/main.yml
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
- 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
|
||||||
|
install_recommends: false
|
||||||
|
name:
|
||||||
|
# Common tools
|
||||||
|
- gettext
|
||||||
|
- git
|
||||||
|
- ipython3
|
||||||
|
|
||||||
|
# Front-end dependencies
|
||||||
|
- fonts-font-awesome
|
||||||
|
- libjs-bootstrap4
|
||||||
|
|
||||||
|
# Python dependencies
|
||||||
|
- python3-babel
|
||||||
|
- python3-bs4
|
||||||
|
- python3-django
|
||||||
|
- 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
|
||||||
|
- python3-psycopg2
|
||||||
|
- python3-setuptools
|
||||||
|
- python3-venv
|
||||||
|
|
||||||
|
# LaTeX (PDF generation)
|
||||||
|
- texlive-xetex
|
||||||
|
|
||||||
|
# Cache server
|
||||||
|
- memcached
|
||||||
|
|
||||||
|
# WSGI server
|
||||||
|
- uwsgi
|
||||||
|
- uwsgi-plugin-python3
|
||||||
|
register: pkg_result
|
||||||
|
retries: 3
|
||||||
|
until: pkg_result is succeeded
|
48
ansible/roles/2-nk20/tasks/main.yml
Normal file
48
ansible/roles/2-nk20/tasks/main.yml
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
- name: Create note_kfet dir with good permissions
|
||||||
|
file:
|
||||||
|
path: /var/www/note_kfet
|
||||||
|
state: directory
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
mode: u=rwx,g=rwxs,o=rx
|
||||||
|
|
||||||
|
- name: Clone Note Kfet
|
||||||
|
git:
|
||||||
|
repo: https://gitlab.crans.org/bde/nk20.git
|
||||||
|
dest: /var/www/note_kfet
|
||||||
|
version: "{{ note.git_branch }}"
|
||||||
|
force: true
|
||||||
|
|
||||||
|
- name: Use default env vars (should be updated!)
|
||||||
|
template:
|
||||||
|
src: "env.j2"
|
||||||
|
dest: "/var/www/note_kfet/.env"
|
||||||
|
mode: 0644
|
||||||
|
force: false
|
||||||
|
|
||||||
|
- name: Update permissions for note_kfet dir
|
||||||
|
file:
|
||||||
|
path: /var/www/note_kfet
|
||||||
|
state: directory
|
||||||
|
recurse: yes
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
|
||||||
|
- name: Setup cron jobs
|
||||||
|
when: "note.cron_enabled"
|
||||||
|
template:
|
||||||
|
src: note.cron.j2
|
||||||
|
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=
|
1
ansible/roles/2-nk20/templates/env_example
Symbolic link
1
ansible/roles/2-nk20/templates/env_example
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
../../../../.env_example
|
1
ansible/roles/2-nk20/templates/note.cron.j2
Symbolic link
1
ansible/roles/2-nk20/templates/note.cron.j2
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
../../../../note.cron
|
8
ansible/roles/3-pip/tasks/main.yml
Normal file
8
ansible/roles/3-pip/tasks/main.yml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
- name: Install PIP basic dependencies
|
||||||
|
pip:
|
||||||
|
requirements: /var/www/note_kfet/requirements.txt
|
||||||
|
virtualenv: /var/www/note_kfet/env
|
||||||
|
virtualenv_command: /usr/bin/python3 -m venv
|
||||||
|
virtualenv_site_packages: true
|
||||||
|
become_user: www-data
|
40
ansible/roles/4-certbot/tasks/main.yml
Normal file
40
ansible/roles/4-certbot/tasks/main.yml
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
- name: Install basic APT packages
|
||||||
|
apt:
|
||||||
|
update_cache: true
|
||||||
|
name:
|
||||||
|
- certbot
|
||||||
|
- python3-certbot-nginx
|
||||||
|
register: pkg_result
|
||||||
|
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
|
||||||
|
state: directory
|
||||||
|
|
||||||
|
- name: Add Certbot configuration
|
||||||
|
template:
|
||||||
|
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
|
@ -0,0 +1,20 @@
|
|||||||
|
{{ ansible_managed | comment }}
|
||||||
|
|
||||||
|
# To generate the certificate, please use the following command
|
||||||
|
# certbot --config /etc/letsencrypt/conf.d/nk20.ini certonly
|
||||||
|
|
||||||
|
# Use a 4096 bit RSA key instead of 2048
|
||||||
|
rsa-key-size = 4096
|
||||||
|
|
||||||
|
# Always use the staging/testing server
|
||||||
|
# server = https://acme-staging.api.letsencrypt.org/directory
|
||||||
|
|
||||||
|
# Uncomment and update to register with the specified e-mail address
|
||||||
|
email = {{ note.email }}
|
||||||
|
|
||||||
|
# Uncomment to use a text interface instead of ncurses
|
||||||
|
text = True
|
||||||
|
|
||||||
|
# Use DNS-01 challenge
|
||||||
|
authenticator = nginx
|
||||||
|
|
44
ansible/roles/5-nginx/tasks/main.yml
Normal file
44
ansible/roles/5-nginx/tasks/main.yml
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
- name: Install NGINX
|
||||||
|
apt:
|
||||||
|
name: nginx
|
||||||
|
register: pkg_result
|
||||||
|
retries: 3
|
||||||
|
until: pkg_result is succeeded
|
||||||
|
|
||||||
|
- name: Copy conf of Nginx
|
||||||
|
template:
|
||||||
|
src: "nginx_note.conf"
|
||||||
|
dest: /etc/nginx/sites-available/nginx_note.conf
|
||||||
|
mode: 0644
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
|
||||||
|
- name: Enable Nginx site
|
||||||
|
file:
|
||||||
|
src: /etc/nginx/sites-available/nginx_note.conf
|
||||||
|
dest: /etc/nginx/sites-enabled/nginx_note.conf
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
state: link
|
||||||
|
|
||||||
|
- name: Disable default Nginx site
|
||||||
|
file:
|
||||||
|
dest: /etc/nginx/sites-enabled/default
|
||||||
|
state: absent
|
||||||
|
|
||||||
|
- name: Copy conf of UWSGI
|
||||||
|
file:
|
||||||
|
src: /var/www/note_kfet/uwsgi_note.ini
|
||||||
|
dest: /etc/uwsgi/apps-enabled/uwsgi_note.ini
|
||||||
|
state: link
|
||||||
|
|
||||||
|
- name: Reload Nginx
|
||||||
|
systemd:
|
||||||
|
name: nginx
|
||||||
|
state: reloaded
|
||||||
|
|
||||||
|
- name: Restart UWSGI
|
||||||
|
systemd:
|
||||||
|
name: uwsgi
|
||||||
|
state: restarted
|
69
ansible/roles/5-nginx/templates/nginx_note.conf
Normal file
69
ansible/roles/5-nginx/templates/nginx_note.conf
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# the upstream component nginx needs to connect to
|
||||||
|
upstream note {
|
||||||
|
server unix:///var/www/note_kfet/note_kfet.sock; # file socket
|
||||||
|
}
|
||||||
|
|
||||||
|
# Redirect HTTP to nk20 HTTPS
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
listen [::]:80 default_server;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://{{ note.server_name }}$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Redirect all HTTPS to nk20 HTTPS
|
||||||
|
server {
|
||||||
|
listen 443 ssl default_server;
|
||||||
|
listen [::]:443 ssl default_server;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://{{ note.server_name }}$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/{{ note.server_name }}/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/{{ note.server_name }}/privkey.pem;
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
}
|
||||||
|
|
||||||
|
# configuration of the server
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
|
||||||
|
# the port your site will be served on
|
||||||
|
# the domain name it will serve for
|
||||||
|
server_name {{ note.server_name }}; # substitute your machine's IP address or FQDN
|
||||||
|
charset utf-8;
|
||||||
|
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static {
|
||||||
|
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;
|
||||||
|
include /etc/nginx/uwsgi_params;
|
||||||
|
}
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/{{ note.server_name }}/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/{{ note.server_name }}/privkey.pem;
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
}
|
25
ansible/roles/6-psql/tasks/main.yml
Normal file
25
ansible/roles/6-psql/tasks/main.yml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
- name: Install PostgreSQL APT packages
|
||||||
|
apt:
|
||||||
|
update_cache: true
|
||||||
|
name:
|
||||||
|
- postgresql
|
||||||
|
- postgresql-contrib
|
||||||
|
- libpq-dev
|
||||||
|
register: pkg_result
|
||||||
|
retries: 3
|
||||||
|
until: pkg_result is succeeded
|
||||||
|
|
||||||
|
- name: Create role note
|
||||||
|
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|length >0
|
||||||
|
postgresql_db:
|
||||||
|
name: note_db
|
||||||
|
owner: note
|
||||||
|
become_user: postgres
|
30
ansible/roles/7-postinstall/tasks/main.yml
Normal file
30
ansible/roles/7-postinstall/tasks/main.yml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
- 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:
|
||||||
|
chdir: /var/www/note_kfet
|
||||||
|
become_user: postgres
|
||||||
|
|
||||||
|
- name: Compile messages
|
||||||
|
command: /var/www/note_kfet/env/bin/python manage.py compilemessages
|
||||||
|
args:
|
||||||
|
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
|
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-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
default_app_config = 'activity.apps.ActivityConfig'
|
default_app_config = 'activity.apps.ActivityConfig'
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from note_kfet.admin import admin_site
|
||||||
|
|
||||||
from .models import Activity, ActivityType, Guest
|
from .forms import GuestForm
|
||||||
|
from .models import Activity, ActivityType, Entry, Guest, Opener
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Activity, site=admin_site)
|
||||||
class ActivityAdmin(admin.ModelAdmin):
|
class ActivityAdmin(admin.ModelAdmin):
|
||||||
"""
|
"""
|
||||||
Admin customisation for Activity
|
Admin customisation for Activity
|
||||||
@ -19,6 +22,7 @@ class ActivityAdmin(admin.ModelAdmin):
|
|||||||
ordering = ['-date_start']
|
ordering = ['-date_start']
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ActivityType, site=admin_site)
|
||||||
class ActivityTypeAdmin(admin.ModelAdmin):
|
class ActivityTypeAdmin(admin.ModelAdmin):
|
||||||
"""
|
"""
|
||||||
Admin customisation for ActivityType
|
Admin customisation for ActivityType
|
||||||
@ -26,7 +30,26 @@ class ActivityTypeAdmin(admin.ModelAdmin):
|
|||||||
list_display = ('name', 'can_invite', 'guest_entry_fee')
|
list_display = ('name', 'can_invite', 'guest_entry_fee')
|
||||||
|
|
||||||
|
|
||||||
# Register your models here.
|
@admin.register(Guest, site=admin_site)
|
||||||
admin.site.register(Activity, ActivityAdmin)
|
class GuestAdmin(admin.ModelAdmin):
|
||||||
admin.site.register(ActivityType, ActivityTypeAdmin)
|
"""
|
||||||
admin.site.register(Guest)
|
Admin customisation for Guest
|
||||||
|
"""
|
||||||
|
list_display = ('last_name', 'first_name', 'activity', 'inviter')
|
||||||
|
form = GuestForm
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Entry, site=admin_site)
|
||||||
|
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-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from rest_framework.validators import UniqueTogetherValidator
|
||||||
|
|
||||||
from ..models import ActivityType, Activity, Guest, Entry, GuestTransaction
|
from ..models import Activity, ActivityType, Entry, Guest, GuestTransaction, Opener
|
||||||
|
|
||||||
|
|
||||||
class ActivityTypeSerializer(serializers.ModelSerializer):
|
class ActivityTypeSerializer(serializers.ModelSerializer):
|
||||||
@ -59,3 +61,17 @@ class GuestTransactionSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = GuestTransaction
|
model = GuestTransaction
|
||||||
fields = '__all__'
|
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-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from .views import ActivityTypeViewSet, ActivityViewSet, GuestViewSet, EntryViewSet
|
from .views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet, OpenerViewSet
|
||||||
|
|
||||||
|
|
||||||
def register_activity_urls(router, path):
|
def register_activity_urls(router, path):
|
||||||
@ -12,3 +12,4 @@ def register_activity_urls(router, path):
|
|||||||
router.register(path + '/type', ActivityTypeViewSet)
|
router.register(path + '/type', ActivityTypeViewSet)
|
||||||
router.register(path + '/guest', GuestViewSet)
|
router.register(path + '/guest', GuestViewSet)
|
||||||
router.register(path + '/entry', EntryViewSet)
|
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-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from api.filters import RegexSafeSearchFilter
|
||||||
from rest_framework.filters import SearchFilter
|
|
||||||
from api.viewsets import ReadProtectedModelViewSet
|
from api.viewsets import ReadProtectedModelViewSet
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status
|
||||||
|
|
||||||
from .serializers import ActivityTypeSerializer, ActivitySerializer, GuestSerializer, EntrySerializer
|
from .serializers import ActivitySerializer, ActivityTypeSerializer, EntrySerializer, GuestSerializer, OpenerSerializer
|
||||||
from ..models import ActivityType, Activity, Guest, Entry
|
from ..models import Activity, ActivityType, Entry, Guest, Opener
|
||||||
|
|
||||||
|
|
||||||
class ActivityTypeViewSet(ReadProtectedModelViewSet):
|
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,
|
The djangorestframework plugin will get all `ActivityType` objects, serialize it to JSON with the given serializer,
|
||||||
then render it on /api/activity/type/
|
then render it on /api/activity/type/
|
||||||
"""
|
"""
|
||||||
queryset = ActivityType.objects.all()
|
queryset = ActivityType.objects.order_by('id')
|
||||||
serializer_class = ActivityTypeSerializer
|
serializer_class = ActivityTypeSerializer
|
||||||
filter_backends = [DjangoFilterBackend]
|
filter_backends = [DjangoFilterBackend]
|
||||||
filterset_fields = ['name', 'can_invite', ]
|
filterset_fields = ['name', 'manage_entries', 'can_invite', 'guest_entry_fee', ]
|
||||||
|
|
||||||
|
|
||||||
class ActivityViewSet(ReadProtectedModelViewSet):
|
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,
|
The djangorestframework plugin will get all `Activity` objects, serialize it to JSON with the given serializer,
|
||||||
then render it on /api/activity/activity/
|
then render it on /api/activity/activity/
|
||||||
"""
|
"""
|
||||||
queryset = Activity.objects.all()
|
queryset = Activity.objects.order_by('id')
|
||||||
serializer_class = ActivitySerializer
|
serializer_class = ActivitySerializer
|
||||||
filter_backends = [DjangoFilterBackend]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
filterset_fields = ['name', 'description', 'activity_type', ]
|
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):
|
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,
|
The djangorestframework plugin will get all `Guest` objects, serialize it to JSON with the given serializer,
|
||||||
then render it on /api/activity/guest/
|
then render it on /api/activity/guest/
|
||||||
"""
|
"""
|
||||||
queryset = Guest.objects.all()
|
queryset = Guest.objects.order_by('id')
|
||||||
serializer_class = GuestSerializer
|
serializer_class = GuestSerializer
|
||||||
filter_backends = [SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
search_fields = ['$last_name', '$first_name', '$inviter__alias__name', '$inviter__alias__normalized_name', ]
|
filterset_fields = ['activity', 'activity__name', 'last_name', 'first_name', 'inviter', 'inviter__alias__name',
|
||||||
|
'inviter__alias__normalized_name', ]
|
||||||
|
search_fields = ['$activity__name', '$last_name', '$first_name', '$inviter__user__email', '$inviter__alias__name',
|
||||||
|
'$inviter__alias__normalized_name', ]
|
||||||
|
|
||||||
|
|
||||||
class EntryViewSet(ReadProtectedModelViewSet):
|
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,
|
The djangorestframework plugin will get all `Entry` objects, serialize it to JSON with the given serializer,
|
||||||
then render it on /api/activity/entry/
|
then render it on /api/activity/entry/
|
||||||
"""
|
"""
|
||||||
queryset = Entry.objects.all()
|
queryset = Entry.objects.order_by('id')
|
||||||
serializer_class = EntrySerializer
|
serializer_class = EntrySerializer
|
||||||
filter_backends = [SearchFilter]
|
filter_backends = [DjangoFilterBackend, RegexSafeSearchFilter]
|
||||||
search_fields = ['$last_name', '$first_name', '$inviter__alias__name', '$inviter__alias__normalized_name', ]
|
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-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
@ -4,8 +4,9 @@
|
|||||||
"pk": 1,
|
"pk": 1,
|
||||||
"fields": {
|
"fields": {
|
||||||
"name": "Pot",
|
"name": "Pot",
|
||||||
|
"manage_entries": true,
|
||||||
"can_invite": true,
|
"can_invite": true,
|
||||||
"guest_entry_fee": 500
|
"guest_entry_fee": 1000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -13,8 +14,39 @@
|
|||||||
"pk": 2,
|
"pk": 2,
|
||||||
"fields": {
|
"fields": {
|
||||||
"name": "Soir\u00e9e de club",
|
"name": "Soir\u00e9e de club",
|
||||||
|
"manage_entries": false,
|
||||||
"can_invite": false,
|
"can_invite": false,
|
||||||
"guest_entry_fee": 0
|
"guest_entry_fee": 0
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "activity.activitytype",
|
||||||
|
"pk": 3,
|
||||||
|
"fields": {
|
||||||
|
"name": "Autre",
|
||||||
|
"manage_entries": false,
|
||||||
|
"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,21 +1,50 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
from datetime import timedelta, datetime
|
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from random import shuffle
|
||||||
|
|
||||||
|
from bootstrap_datepicker_plus.widgets import DateTimePickerInput
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from member.models import Club
|
from member.models import Club
|
||||||
from note.models import NoteUser, Note
|
from note.models import Note, NoteUser
|
||||||
from note_kfet.inputs import DateTimePickerInput, Autocomplete
|
from note_kfet.inputs import Autocomplete
|
||||||
|
from note_kfet.middlewares import get_current_request
|
||||||
|
from permission.backends import PermissionBackend
|
||||||
|
|
||||||
from .models import Activity, Guest
|
from .models import Activity, Guest
|
||||||
|
|
||||||
|
|
||||||
class ActivityForm(forms.ModelForm):
|
class ActivityForm(forms.ModelForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
# By default, the Kfet club is attended
|
||||||
|
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_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"]
|
||||||
|
if date_end < date_start:
|
||||||
|
self.add_error("date_end", _("The end date must be after the start date."))
|
||||||
|
return date_end
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Activity
|
model = Activity
|
||||||
exclude = ('creater', 'valid', 'open', )
|
exclude = ('creater', 'valid', 'open', 'opener', )
|
||||||
widgets = {
|
widgets = {
|
||||||
"organizer": Autocomplete(
|
"organizer": Autocomplete(
|
||||||
model=Club,
|
model=Club,
|
||||||
@ -39,9 +68,18 @@ class ActivityForm(forms.ModelForm):
|
|||||||
|
|
||||||
class GuestForm(forms.ModelForm):
|
class GuestForm(forms.ModelForm):
|
||||||
def clean(self):
|
def clean(self):
|
||||||
|
"""
|
||||||
|
Someone can be invited as a Guest to an Activity if:
|
||||||
|
- the activity has not already started.
|
||||||
|
- the activity is validated.
|
||||||
|
- the Guest has not already been invited more than 5 times.
|
||||||
|
- the Guest is already invited.
|
||||||
|
- the inviter already invited 3 peoples.
|
||||||
|
"""
|
||||||
|
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
|
|
||||||
if self.activity.date_start > datetime.now():
|
if timezone.now() > timezone.localtime(self.activity.date_start):
|
||||||
self.add_error("inviter", _("You can't invite someone once the activity is started."))
|
self.add_error("inviter", _("You can't invite someone once the activity is started."))
|
||||||
|
|
||||||
if not self.activity.valid:
|
if not self.activity.valid:
|
||||||
@ -50,19 +88,19 @@ class GuestForm(forms.ModelForm):
|
|||||||
one_year = timedelta(days=365)
|
one_year = timedelta(days=365)
|
||||||
|
|
||||||
qs = Guest.objects.filter(
|
qs = Guest.objects.filter(
|
||||||
first_name=cleaned_data["first_name"],
|
first_name__iexact=cleaned_data["first_name"],
|
||||||
last_name=cleaned_data["last_name"],
|
last_name__iexact=cleaned_data["last_name"],
|
||||||
activity__date_start__gte=self.activity.date_start - one_year,
|
activity__date_start__gte=self.activity.date_start - one_year,
|
||||||
)
|
)
|
||||||
if len(qs) >= 5:
|
if qs.filter(entry__isnull=False).count() >= 5:
|
||||||
self.add_error("last_name", _("This person has been already invited 5 times this year."))
|
self.add_error("last_name", _("This person has been already invited 5 times this year."))
|
||||||
|
|
||||||
qs = qs.filter(activity=self.activity)
|
qs = qs.filter(activity=self.activity)
|
||||||
if qs.exists():
|
if qs.exists():
|
||||||
self.add_error("last_name", _("This person is already invited."))
|
self.add_error("last_name", _("This person is already invited."))
|
||||||
|
|
||||||
qs = Guest.objects.filter(inviter=cleaned_data["inviter"], activity=self.activity)
|
if "inviter" in cleaned_data:
|
||||||
if len(qs) >= 3:
|
if Guest.objects.filter(inviter=cleaned_data["inviter"], activity=self.activity).count() >= 3:
|
||||||
self.add_error("inviter", _("You can't invite more than 3 people to this activity."))
|
self.add_error("inviter", _("You can't invite more than 3 people to this activity."))
|
||||||
|
|
||||||
return cleaned_data
|
return cleaned_data
|
||||||
|
69
apps/activity/migrations/0001_initial.py
Normal file
69
apps/activity/migrations/0001_initial.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# Generated by Django 2.2.16 on 2020-09-04 21:41
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.utils.timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Activity',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||||
|
('description', models.TextField(verbose_name='description')),
|
||||||
|
('location', models.CharField(blank=True, default='', help_text='Place where the activity is organized, eg. Kfet.', max_length=255, verbose_name='location')),
|
||||||
|
('date_start', models.DateTimeField(verbose_name='start date')),
|
||||||
|
('date_end', models.DateTimeField(verbose_name='end date')),
|
||||||
|
('valid', models.BooleanField(default=False, verbose_name='valid')),
|
||||||
|
('open', models.BooleanField(default=False, verbose_name='open')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'activity',
|
||||||
|
'verbose_name_plural': 'activities',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ActivityType',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=255, verbose_name='name')),
|
||||||
|
('manage_entries', models.BooleanField(default=False, help_text='Enable the support of entries for this activity.', verbose_name='manage entries')),
|
||||||
|
('can_invite', models.BooleanField(default=False, verbose_name='can invite')),
|
||||||
|
('guest_entry_fee', models.PositiveIntegerField(default=0, verbose_name='guest entry fee')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'activity type',
|
||||||
|
'verbose_name_plural': 'activity types',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Entry',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='entry time')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'entry',
|
||||||
|
'verbose_name_plural': 'entries',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Guest',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('last_name', models.CharField(max_length=255, verbose_name='last name')),
|
||||||
|
('first_name', models.CharField(max_length=255, verbose_name='first name')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'guest',
|
||||||
|
'verbose_name_plural': 'guests',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
89
apps/activity/migrations/0002_auto_20200904_2341.py
Normal file
89
apps/activity/migrations/0002_auto_20200904_2341.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
# Generated by Django 2.2.16 on 2020-09-04 21:41
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('activity', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('member', '0001_initial'),
|
||||||
|
('note', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='GuestTransaction',
|
||||||
|
fields=[
|
||||||
|
('transaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='note.Transaction')),
|
||||||
|
('entry', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, to='activity.Entry')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
'base_manager_name': 'objects',
|
||||||
|
},
|
||||||
|
bases=('note.transaction',),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='guest',
|
||||||
|
name='activity',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='activity.Activity'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='guest',
|
||||||
|
name='inviter',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='guests', to='note.NoteUser', verbose_name='inviter'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='entry',
|
||||||
|
name='activity',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='entries', to='activity.Activity', verbose_name='activity'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='entry',
|
||||||
|
name='guest',
|
||||||
|
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.PROTECT, to='activity.Guest'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='entry',
|
||||||
|
name='note',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='note.NoteUser', verbose_name='note'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='activity',
|
||||||
|
name='activity_type',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='activity.ActivityType', verbose_name='type'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='activity',
|
||||||
|
name='attendees_club',
|
||||||
|
field=models.ForeignKey(help_text='Club that is authorized to join the activity. Mostly the Kfet club.', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='member.Club', verbose_name='attendees club'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='activity',
|
||||||
|
name='creater',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='user'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='activity',
|
||||||
|
name='organizer',
|
||||||
|
field=models.ForeignKey(help_text='Club that organizes the activity. The entry fees will go to this club.', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='member.Club', verbose_name='organizer'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='guest',
|
||||||
|
unique_together={('activity', 'last_name', 'first_name')},
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='entry',
|
||||||
|
unique_together={('activity', 'note', 'guest')},
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='activity',
|
||||||
|
unique_together={('name', 'date_start', 'date_end')},
|
||||||
|
),
|
||||||
|
]
|
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'),
|
||||||
|
),
|
||||||
|
]
|
@ -1,13 +1,18 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
from datetime import timedelta, datetime
|
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
from threading import Thread
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db import models
|
from django.db import models, transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from note.models import NoteUser, Transaction, Note
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from note.models import NoteUser, Transaction
|
|
||||||
|
|
||||||
|
|
||||||
class ActivityType(models.Model):
|
class ActivityType(models.Model):
|
||||||
@ -23,11 +28,21 @@ class ActivityType(models.Model):
|
|||||||
verbose_name=_('name'),
|
verbose_name=_('name'),
|
||||||
max_length=255,
|
max_length=255,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
manage_entries = models.BooleanField(
|
||||||
|
verbose_name=_('manage entries'),
|
||||||
|
help_text=_('Enable the support of entries for this activity.'),
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
can_invite = models.BooleanField(
|
can_invite = models.BooleanField(
|
||||||
verbose_name=_('can invite'),
|
verbose_name=_('can invite'),
|
||||||
|
default=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
guest_entry_fee = models.PositiveIntegerField(
|
guest_entry_fee = models.PositiveIntegerField(
|
||||||
verbose_name=_('guest entry fee'),
|
verbose_name=_('guest entry fee'),
|
||||||
|
default=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -51,6 +66,16 @@ class Activity(models.Model):
|
|||||||
|
|
||||||
description = models.TextField(
|
description = models.TextField(
|
||||||
verbose_name=_('description'),
|
verbose_name=_('description'),
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
|
||||||
|
location = models.CharField(
|
||||||
|
verbose_name=_('location'),
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text=_("Place where the activity is organized, eg. Kfet."),
|
||||||
)
|
)
|
||||||
|
|
||||||
activity_type = models.ForeignKey(
|
activity_type = models.ForeignKey(
|
||||||
@ -71,6 +96,7 @@ class Activity(models.Model):
|
|||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name='+',
|
related_name='+',
|
||||||
verbose_name=_('organizer'),
|
verbose_name=_('organizer'),
|
||||||
|
help_text=_("Club that organizes the activity. The entry fees will go to this club."),
|
||||||
)
|
)
|
||||||
|
|
||||||
attendees_club = models.ForeignKey(
|
attendees_club = models.ForeignKey(
|
||||||
@ -78,6 +104,7 @@ class Activity(models.Model):
|
|||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name='+',
|
related_name='+',
|
||||||
verbose_name=_('attendees club'),
|
verbose_name=_('attendees club'),
|
||||||
|
help_text=_("Club that is authorized to join the activity. Mostly the Kfet club."),
|
||||||
)
|
)
|
||||||
|
|
||||||
date_start = models.DateTimeField(
|
date_start = models.DateTimeField(
|
||||||
@ -101,6 +128,31 @@ class Activity(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("activity")
|
verbose_name = _("activity")
|
||||||
verbose_name_plural = _("activities")
|
verbose_name_plural = _("activities")
|
||||||
|
unique_together = ("name", "date_start", "date_end",)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Update the activity wiki page each time the activity is updated (validation, change description, ...)
|
||||||
|
"""
|
||||||
|
if self.date_end < self.date_start:
|
||||||
|
raise ValidationError(_("The end date must be after the start date."))
|
||||||
|
|
||||||
|
ret = super().save(*args, **kwargs)
|
||||||
|
if not settings.DEBUG and self.pk and "scripts" in settings.INSTALLED_APPS:
|
||||||
|
def refresh_activities():
|
||||||
|
from scripts.management.commands.refresh_activities import Command as RefreshActivitiesCommand
|
||||||
|
# Consider that we can update the wiki iff the WIKI_PASSWORD env var is not empty
|
||||||
|
RefreshActivitiesCommand.refresh_human_readable_wiki_page("Modification de l'activité " + self.name,
|
||||||
|
False, os.getenv("WIKI_PASSWORD"))
|
||||||
|
RefreshActivitiesCommand.refresh_raw_wiki_page("Modification de l'activité " + self.name,
|
||||||
|
False, os.getenv("WIKI_PASSWORD"))
|
||||||
|
Thread(daemon=True, target=refresh_activities).start()\
|
||||||
|
if settings.DATABASES["default"]["ENGINE"] == 'django.db.backends.postgresql' else refresh_activities()
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
class Entry(models.Model):
|
class Entry(models.Model):
|
||||||
@ -118,7 +170,7 @@ class Entry(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
time = models.DateTimeField(
|
time = models.DateTimeField(
|
||||||
auto_now_add=True,
|
default=timezone.now,
|
||||||
verbose_name=_("entry time"),
|
verbose_name=_("entry time"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -139,8 +191,14 @@ class Entry(models.Model):
|
|||||||
verbose_name = _("entry")
|
verbose_name = _("entry")
|
||||||
verbose_name_plural = _("entries")
|
verbose_name_plural = _("entries")
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def __str__(self):
|
||||||
|
return _("Entry for {guest}, invited by {note} to the activity {activity}").format(
|
||||||
|
guest=str(self.guest), note=str(self.note), activity=str(self.activity)) if self.guest \
|
||||||
|
else _("Entry for {note} to the activity {activity}").format(
|
||||||
|
guest=str(self.guest), note=str(self.note), activity=str(self.activity))
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
qs = Entry.objects.filter(~Q(pk=self.pk), activity=self.activity, note=self.note, guest=self.guest)
|
qs = Entry.objects.filter(~Q(pk=self.pk), activity=self.activity, note=self.note, guest=self.guest)
|
||||||
if qs.exists():
|
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(qs.get().time, ))
|
||||||
@ -163,7 +221,7 @@ class Entry(models.Model):
|
|||||||
amount=self.activity.activity_type.guest_entry_fee,
|
amount=self.activity.activity_type.guest_entry_fee,
|
||||||
reason="Invitation " + self.activity.name + " " + self.guest.first_name + " " + self.guest.last_name,
|
reason="Invitation " + self.activity.name + " " + self.guest.first_name + " " + self.guest.last_name,
|
||||||
valid=True,
|
valid=True,
|
||||||
guest=self.guest,
|
entry=self,
|
||||||
).save()
|
).save()
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
@ -196,6 +254,43 @@ class Guest(models.Model):
|
|||||||
verbose_name=_("inviter"),
|
verbose_name=_("inviter"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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):
|
||||||
|
one_year = timedelta(days=365)
|
||||||
|
|
||||||
|
if not force_insert:
|
||||||
|
if timezone.now() > timezone.localtime(self.activity.date_start):
|
||||||
|
raise ValidationError(_("You can't invite someone once the activity is started."))
|
||||||
|
|
||||||
|
if not self.activity.valid:
|
||||||
|
raise ValidationError(_("This activity is not validated yet."))
|
||||||
|
|
||||||
|
qs = Guest.objects.filter(
|
||||||
|
first_name__iexact=self.first_name,
|
||||||
|
last_name__iexact=self.last_name,
|
||||||
|
activity__date_start__gte=self.activity.date_start - one_year,
|
||||||
|
)
|
||||||
|
if qs.filter(entry__isnull=False).count() >= 5:
|
||||||
|
raise ValidationError(_("This person has been already invited 5 times this year."))
|
||||||
|
|
||||||
|
qs = qs.filter(activity=self.activity)
|
||||||
|
if qs.exists():
|
||||||
|
raise ValidationError(_("This person is already invited."))
|
||||||
|
|
||||||
|
qs = Guest.objects.filter(inviter=self.inviter, activity=self.activity)
|
||||||
|
if qs.count() >= 3:
|
||||||
|
raise ValidationError(_("You can't invite more than 3 people to this activity."))
|
||||||
|
|
||||||
|
return super().save(force_insert, force_update, using, update_fields)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_entry(self):
|
def has_entry(self):
|
||||||
try:
|
try:
|
||||||
@ -205,46 +300,41 @@ class Guest(models.Model):
|
|||||||
except AttributeError:
|
except AttributeError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
|
|
||||||
one_year = timedelta(days=365)
|
|
||||||
|
|
||||||
if not force_insert:
|
|
||||||
if self.activity.date_start > datetime.now():
|
|
||||||
raise ValidationError(_("You can't invite someone once the activity is started."))
|
|
||||||
|
|
||||||
if not self.activity.valid:
|
|
||||||
raise ValidationError(_("This activity is not validated yet."))
|
|
||||||
|
|
||||||
qs = Guest.objects.filter(
|
|
||||||
first_name=self.first_name,
|
|
||||||
last_name=self.last_name,
|
|
||||||
activity__date_start__gte=self.activity.date_start - one_year,
|
|
||||||
)
|
|
||||||
if len(qs) >= 5:
|
|
||||||
raise ValidationError(_("This person has been already invited 5 times this year."))
|
|
||||||
|
|
||||||
qs = qs.filter(activity=self.activity)
|
|
||||||
if qs.exists():
|
|
||||||
raise ValidationError(_("This person is already invited."))
|
|
||||||
|
|
||||||
qs = Guest.objects.filter(inviter=self.inviter, activity=self.activity)
|
|
||||||
if len(qs) >= 3:
|
|
||||||
raise ValidationError(_("You can't invite more than 3 people to this activity."))
|
|
||||||
|
|
||||||
return super().save(force_insert, force_update, using, update_fields)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = _("guest")
|
|
||||||
verbose_name_plural = _("guests")
|
|
||||||
unique_together = ("activity", "last_name", "first_name", )
|
|
||||||
|
|
||||||
|
|
||||||
class GuestTransaction(Transaction):
|
class GuestTransaction(Transaction):
|
||||||
guest = models.OneToOneField(
|
entry = models.OneToOneField(
|
||||||
Guest,
|
Entry,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def type(self):
|
def type(self):
|
||||||
return _('Invitation')
|
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-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django.utils.html import format_html
|
from django.utils import timezone
|
||||||
|
from django.utils.html import escape
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from note_kfet.middlewares import get_current_request
|
||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django_tables2 import A
|
from django_tables2 import A
|
||||||
|
from permission.backends import PermissionBackend
|
||||||
from note.templatetags.pretty_money import pretty_money
|
from note.templatetags.pretty_money import pretty_money
|
||||||
|
|
||||||
from .models import Activity, Guest, Entry
|
from .models import Activity, Entry, Guest, Opener
|
||||||
|
|
||||||
|
|
||||||
class ActivityTable(tables.Table):
|
class ActivityTable(tables.Table):
|
||||||
@ -20,6 +24,11 @@ class ActivityTable(tables.Table):
|
|||||||
attrs = {
|
attrs = {
|
||||||
'class': 'table table-condensed table-striped table-hover'
|
'class': 'table table-condensed table-striped table-hover'
|
||||||
}
|
}
|
||||||
|
row_attrs = {
|
||||||
|
'class': lambda record: 'bg-success' if record.open else ('' if record.valid else 'bg-warning'),
|
||||||
|
'title': lambda record: _("The activity is currently open.") if record.open else
|
||||||
|
('' if record.valid else _("The validation of the activity is pending.")),
|
||||||
|
}
|
||||||
model = Activity
|
model = Activity
|
||||||
template_name = 'django_tables2/bootstrap4.html'
|
template_name = 'django_tables2/bootstrap4.html'
|
||||||
fields = ('name', 'activity_type', 'organizer', 'attendees_club', 'date_start', 'date_end', )
|
fields = ('name', 'activity_type', 'organizer', 'attendees_club', 'date_start', 'date_end', )
|
||||||
@ -28,22 +37,17 @@ class ActivityTable(tables.Table):
|
|||||||
class GuestTable(tables.Table):
|
class GuestTable(tables.Table):
|
||||||
inviter = tables.LinkColumn(
|
inviter = tables.LinkColumn(
|
||||||
'member:user_detail',
|
'member:user_detail',
|
||||||
args=[A('inviter.user.pk'), ],
|
args=[A('inviter__user__pk'), ],
|
||||||
)
|
)
|
||||||
|
|
||||||
entry = tables.Column(
|
entry = tables.Column(
|
||||||
empty_values=(),
|
empty_values=(),
|
||||||
attrs={
|
verbose_name=_("Remove"),
|
||||||
"td": {
|
|
||||||
"class": lambda record: "" if record.has_entry else "validate btn btn-danger",
|
|
||||||
"onclick": lambda record: "" if record.has_entry else "remove_guest(" + str(record.pk) + ")"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
attrs = {
|
attrs = {
|
||||||
'class': 'table table-condensed table-striped table-hover'
|
'class': 'table table-condensed table-striped'
|
||||||
}
|
}
|
||||||
model = Guest
|
model = Guest
|
||||||
template_name = 'django_tables2/bootstrap4.html'
|
template_name = 'django_tables2/bootstrap4.html'
|
||||||
@ -52,7 +56,8 @@ class GuestTable(tables.Table):
|
|||||||
def render_entry(self, record):
|
def render_entry(self, record):
|
||||||
if record.has_entry:
|
if record.has_entry:
|
||||||
return str(_("Entered on ") + str(_("{:%Y-%m-%d %H:%M:%S}").format(record.entry.time, )))
|
return str(_("Entered on ") + str(_("{:%Y-%m-%d %H:%M:%S}").format(record.entry.time, )))
|
||||||
return _("remove").capitalize()
|
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):
|
def get_row_class(record):
|
||||||
@ -66,6 +71,10 @@ def get_row_class(record):
|
|||||||
qs = Entry.objects.filter(note=record.note, activity=record.activity, guest=None)
|
qs = Entry.objects.filter(note=record.note, activity=record.activity, guest=None)
|
||||||
if qs.exists():
|
if qs.exists():
|
||||||
c += " table-success"
|
c += " table-success"
|
||||||
|
elif not record.note.user.memberships.filter(club=record.activity.attendees_club,
|
||||||
|
date_start__lte=timezone.now(),
|
||||||
|
date_end__gte=timezone.now()).exists():
|
||||||
|
c += " table-info"
|
||||||
elif record.note.balance < 0:
|
elif record.note.balance < 0:
|
||||||
c += " table-danger"
|
c += " table-danger"
|
||||||
return c
|
return c
|
||||||
@ -86,7 +95,7 @@ class EntryTable(tables.Table):
|
|||||||
if hasattr(record, 'username'):
|
if hasattr(record, 'username'):
|
||||||
username = record.username
|
username = record.username
|
||||||
if username != value:
|
if username != value:
|
||||||
return format_html(value + " <em>aka.</em> " + username)
|
return mark_safe(escape(value) + " <em>aka.</em> " + escape(username))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def render_balance(self, value):
|
def render_balance(self, value):
|
||||||
@ -106,3 +115,34 @@ class EntryTable(tables.Table):
|
|||||||
'data-last-name': lambda record: record.last_name,
|
'data-last-name': lambda record: record.last_name,
|
||||||
'data-first-name': lambda record: record.first_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"),)
|
||||||
|
99
apps/activity/templates/activity/activity_detail.html
Normal file
99
apps/activity/templates/activity/activity_detail.html
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
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">
|
||||||
|
{% trans "Guests list" %}
|
||||||
|
</h3>
|
||||||
|
<div id="guests_table">
|
||||||
|
{% render_table guests %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% 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({
|
||||||
|
url:"/api/activity/guest/" + guest_id + "/",
|
||||||
|
method:"DELETE",
|
||||||
|
headers: {"X-CSRFTOKEN": CSRF_TOKEN}
|
||||||
|
})
|
||||||
|
.done(function() {
|
||||||
|
addMsg('{% trans "Guest deleted" %}', 'success');
|
||||||
|
$("#guests_table").load(location.pathname + " #guests_table");
|
||||||
|
})
|
||||||
|
.fail(function(xhr, textStatus, error) {
|
||||||
|
errMsg(xhr.responseJSON);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#open_activity").click(function() {
|
||||||
|
$.ajax({
|
||||||
|
url: "/api/activity/activity/{{ activity.pk }}/",
|
||||||
|
type: "PATCH",
|
||||||
|
dataType: "json",
|
||||||
|
headers: {
|
||||||
|
"X-CSRFTOKEN": CSRF_TOKEN
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
open: {{ activity.open|yesno:'false,true' }}
|
||||||
|
}
|
||||||
|
}).done(function () {
|
||||||
|
reloadWithTurbolinks();
|
||||||
|
}).fail(function (xhr) {
|
||||||
|
errMsg(xhr.responseJSON);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#validate_activity").click(function () {
|
||||||
|
$.ajax({
|
||||||
|
url: "/api/activity/activity/{{ activity.pk }}/",
|
||||||
|
type: "PATCH",
|
||||||
|
dataType: "json",
|
||||||
|
headers: {
|
||||||
|
"X-CSRFTOKEN": CSRF_TOKEN
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
valid: {{ activity.valid|yesno:'false,true' }}
|
||||||
|
}
|
||||||
|
}).done(function () {
|
||||||
|
reloadWithTurbolinks();
|
||||||
|
}).fail(function (xhr) {
|
||||||
|
errMsg(xhr.responseJSON);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
171
apps/activity/templates/activity/activity_entry.html
Normal file
171
apps/activity/templates/activity/activity_entry.html
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load static i18n pretty_money perms %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="text-white">{{ title }}</h1>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-12">
|
||||||
|
<div class="btn-group btn-group-toggle bg-light" style="width: 100%">
|
||||||
|
<a href="{% url "note:transfer" %}#transfer" class="btn btn-sm btn-outline-primary">
|
||||||
|
{% trans "Transfer" %}
|
||||||
|
</a>
|
||||||
|
{% if "note.notespecial"|not_empty_model_list %}
|
||||||
|
<a href="{% url "note:transfer" %}#credit" class="btn btn-sm btn-outline-primary">
|
||||||
|
{% trans "Credit" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url "note:transfer" %}#debit" class="btn btn-sm btn-outline-primary">
|
||||||
|
{% trans "Debit" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% for a in activities_open %}
|
||||||
|
<a href="{% url "activity:activity_entry" pk=a.pk %}"
|
||||||
|
class="btn btn-sm btn-outline-primary{% if a.pk == activity.pk %} active{% endif %}">
|
||||||
|
{% trans "Entries" %} {{ a.name }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<a href="{% url "activity:activity_detail" pk=activity.pk %}">
|
||||||
|
<button class="btn btn-light">{% trans "Return to activity page" %}</button>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<input id="alias" type="text" class="form-control" placeholder="Nom/note ...">
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="card" id="entry_table">
|
||||||
|
<h2 class="text-center">{{ entries.count }}
|
||||||
|
{% if entries.count >= 2 %}{% trans "entries" %}{% else %}{% trans "entry" %}{% endif %}</h2>
|
||||||
|
{% render_table table %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extrajavascript %}
|
||||||
|
<script>
|
||||||
|
old_pattern = null;
|
||||||
|
alias_obj = $("#alias");
|
||||||
|
|
||||||
|
function reloadTable(force = false) {
|
||||||
|
let pattern = alias_obj.val();
|
||||||
|
|
||||||
|
if ((pattern === old_pattern || pattern === "") && !force)
|
||||||
|
return;
|
||||||
|
|
||||||
|
$("#entry_table").load(location.pathname + "?search=" + pattern.replace(" ", "%20") + " #entry_table", init);
|
||||||
|
refreshBalance();
|
||||||
|
}
|
||||||
|
|
||||||
|
alias_obj.keyup(function(event) {
|
||||||
|
let code = event.originalEvent.keyCode
|
||||||
|
if (65 <= code <= 122 || code === 13) {
|
||||||
|
debounce(reloadTable)()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).ready(init);
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
$(".table-row").click(function (e) {
|
||||||
|
let target = e.target.parentElement;
|
||||||
|
target = $("#" + target.id);
|
||||||
|
|
||||||
|
let type = target.attr("data-type");
|
||||||
|
let id = target.attr("data-id");
|
||||||
|
let last_name = target.attr("data-last-name");
|
||||||
|
let first_name = target.attr("data-first-name");
|
||||||
|
|
||||||
|
if (type === "membership") {
|
||||||
|
$.post("/api/activity/entry/?format=json", {
|
||||||
|
csrfmiddlewaretoken: CSRF_TOKEN,
|
||||||
|
activity: {{ activity.id }},
|
||||||
|
note: id,
|
||||||
|
guest: null
|
||||||
|
}).done(function () {
|
||||||
|
if (target.hasClass("table-info"))
|
||||||
|
addMsg(
|
||||||
|
"{% trans "Entry done, but caution: the user is not a Kfet member." %}",
|
||||||
|
"warning", 10000);
|
||||||
|
else
|
||||||
|
addMsg("Entry made!", "success", 4000);
|
||||||
|
reloadTable(true);
|
||||||
|
}).fail(function (xhr) {
|
||||||
|
errMsg(xhr.responseJSON, 4000);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let line_obj = $("#buttons_guest_" + id);
|
||||||
|
if (line_obj.length || target.attr('class').includes("table-success")) {
|
||||||
|
line_obj.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tr = "<tr class='text-center'>" +
|
||||||
|
"<td id='buttons_guest_" + id + "' style='table-danger center' colspan='5'>" +
|
||||||
|
"<button id='transaction_guest_" + id +
|
||||||
|
"' class='btn btn-secondary'>Payer avec la note de l'hôte</button> " +
|
||||||
|
"<button id='transaction_guest_" + id +
|
||||||
|
"_especes' class='btn btn-secondary'>Payer en espèces</button> " +
|
||||||
|
"<button id='transaction_guest_" + id +
|
||||||
|
"_cb' class='btn btn-secondary'>Payer en CB</button></td>" +
|
||||||
|
"<tr>";
|
||||||
|
$(tr).insertAfter(target);
|
||||||
|
|
||||||
|
let makeTransaction = function () {
|
||||||
|
$.post("/api/activity/entry/?format=json", {
|
||||||
|
csrfmiddlewaretoken: CSRF_TOKEN,
|
||||||
|
activity: {{ activity.id }},
|
||||||
|
note: target.attr("data-inviter"),
|
||||||
|
guest: id
|
||||||
|
}).done(function () {
|
||||||
|
if (target.hasClass("table-info"))
|
||||||
|
addMsg(
|
||||||
|
"{% trans "Entry done, but caution: the user is not a Kfet member." %}",
|
||||||
|
"warning", 10000);
|
||||||
|
else
|
||||||
|
addMsg("{% trans "Entry done!" %}", "success", 4000);
|
||||||
|
reloadTable(true);
|
||||||
|
}).fail(function (xhr) {
|
||||||
|
errMsg(xhr.responseJSON, 4000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let credit = function (credit_id, credit_name) {
|
||||||
|
return function () {
|
||||||
|
$.post("/api/note/transaction/transaction/", {
|
||||||
|
"csrfmiddlewaretoken": CSRF_TOKEN,
|
||||||
|
"quantity": 1,
|
||||||
|
"amount": {{ activity.activity_type.guest_entry_fee }},
|
||||||
|
"reason": "Crédit " + credit_name +
|
||||||
|
" (invitation {{ activity.name }})",
|
||||||
|
"valid": true,
|
||||||
|
"polymorphic_ctype": {{ notespecial_ctype }},
|
||||||
|
"resourcetype": "SpecialTransaction",
|
||||||
|
"source": credit_id,
|
||||||
|
"destination": target.attr('data-inviter'),
|
||||||
|
"last_name": last_name,
|
||||||
|
"first_name": first_name,
|
||||||
|
"bank": ""
|
||||||
|
}).done(function () {
|
||||||
|
makeTransaction();
|
||||||
|
reset();
|
||||||
|
}).fail(function (xhr) {
|
||||||
|
errMsg(xhr.responseJSON, 4000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
$("#transaction_guest_" + id).click(makeTransaction);
|
||||||
|
$("#transaction_guest_" + id + "_especes").click(credit(1, "espèces"));
|
||||||
|
$("#transaction_guest_" + id + "_cb").click(credit(2, "carte bancaire"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
43
apps/activity/templates/activity/activity_form.html
Normal file
43
apps/activity/templates/activity/activity_form.html
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
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">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form|crispy }}
|
||||||
|
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% 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 %}
|
49
apps/activity/templates/activity/activity_list.html
Normal file
49
apps/activity/templates/activity/activity_list.html
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if started_activities %}
|
||||||
|
<div class="card bg-secondary text-white mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{% trans "Current activity" %}
|
||||||
|
</h3>
|
||||||
|
<div class="card-body text-dark">
|
||||||
|
{% for activity in started_activities %}
|
||||||
|
{% include "activity/includes/activity_info.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card bg-light mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{% trans "Upcoming activities" %}
|
||||||
|
</h3>
|
||||||
|
{% if upcoming.data %}
|
||||||
|
{% render_table upcoming %}
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
{% trans "There is no planned activity." %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="card-footer">
|
||||||
|
<a class="btn btn-sm btn-success" href="{% url 'activity:activity_create' %}" data-turbolinks="false">
|
||||||
|
<i class="fa fa-calendar-plus-o" aria-hidden="true"></i>
|
||||||
|
{% trans 'New activity' %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-light mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{% trans "All activities" %}
|
||||||
|
</h3>
|
||||||
|
{% render_table table %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
78
apps/activity/templates/activity/includes/activity_info.html
Normal file
78
apps/activity/templates/activity/includes/activity_info.html
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
{% comment %}
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load i18n perms pretty_money %}
|
||||||
|
{% url 'activity:activity_detail' activity.pk as activity_detail_url %}
|
||||||
|
|
||||||
|
<div id="activity_info" class="card bg-light shadow mb-3">
|
||||||
|
<div class="card-header text-center">
|
||||||
|
<h4>
|
||||||
|
{% if request.path_info != activity_detail_url %}
|
||||||
|
<a href="{{ activity_detail_url }}">{{ activity.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ activity.name }}
|
||||||
|
{% endif %}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="profile_infos">
|
||||||
|
<dl class="row">
|
||||||
|
<dt class="col-xl-6">{% trans 'description'|capfirst %}</dt>
|
||||||
|
<dd class="col-xl-6"> {{ activity.description|linebreaks }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-xl-6">{% trans 'type'|capfirst %}</dt>
|
||||||
|
<dd class="col-xl-6"> {{ activity.activity_type }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-xl-6">{% trans 'start date'|capfirst %}</dt>
|
||||||
|
<dd class="col-xl-6">{{ activity.date_start }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-xl-6">{% trans 'end date'|capfirst %}</dt>
|
||||||
|
<dd class="col-xl-6">{{ activity.date_end }}</dd>
|
||||||
|
|
||||||
|
{% if "activity.change_activity_valid"|has_perm:activity %}
|
||||||
|
<dt class="col-xl-6">{% trans 'creater'|capfirst %}</dt>
|
||||||
|
<dd class="col-xl-6"><a href="{% url "member:user_detail" pk=activity.creater.pk %}">{{ activity.creater }}</a></dd>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<dt class="col-xl-6">{% trans 'organizer'|capfirst %}</dt>
|
||||||
|
<dd class="col-xl-6"><a href="{% url "member:club_detail" pk=activity.organizer.pk %}">{{ activity.organizer }}</a></dd>
|
||||||
|
|
||||||
|
<dt class="col-xl-6">{% trans 'attendees club'|capfirst %}</dt>
|
||||||
|
<dd class="col-xl-6"><a href="{% url "member:club_detail" pk=activity.attendees_club.pk %}">{{ activity.attendees_club }}</a></dd>
|
||||||
|
|
||||||
|
<dt class="col-xl-6">{% trans 'can invite'|capfirst %}</dt>
|
||||||
|
<dd class="col-xl-6">{{ activity.activity_type.can_invite|yesno }}</dd>
|
||||||
|
|
||||||
|
{% if activity.activity_type.can_invite %}
|
||||||
|
<dt class="col-xl-6">{% trans 'guest entry fee'|capfirst %}</dt>
|
||||||
|
<dd class="col-xl-6">{{ activity.activity_type.guest_entry_fee|pretty_money }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<dt class="col-xl-6">{% trans 'valid'|capfirst %}</dt>
|
||||||
|
<dd class="col-xl-6">{{ activity.valid|yesno }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-xl-6">{% trans 'opened'|capfirst %}</dt>
|
||||||
|
<dd class="col-xl-6">{{ activity.open|yesno }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer text-center">
|
||||||
|
{% if activity.open and activity.activity_type.manage_entries and ".change__open"|has_perm:activity %}
|
||||||
|
<a class="btn btn-warning btn-sm my-1" href="{% url 'activity:activity_entry' pk=activity.pk %}"> {% trans "Entry page" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if request.path_info == activity_detail_url %}
|
||||||
|
{% if activity.valid and ".change__open"|has_perm:activity %}
|
||||||
|
<a class="btn btn-warning btn-sm my-1" id="open_activity"> {% if activity.open %}{% trans "close"|capfirst %}{% else %}{% trans "open"|capfirst %}{% endif %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if not activity.open and ".change__valid"|has_perm:activity %}
|
||||||
|
<a class="btn btn-success btn-sm my-1" id="validate_activity"> {% if activity.valid %}{% trans "invalidate"|capfirst %}{% else %}{% trans "validate"|capfirst %}{% endif %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if ".change_"|has_perm:activity %}
|
||||||
|
<a class="btn btn-primary btn-sm my-1" href="{% url 'activity:activity_update' pk=activity.pk %}" data-turbolinks="false"> {% trans "edit"|capfirst %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if activity.activity_type.can_invite and not activity_started %}
|
||||||
|
<a class="btn btn-primary btn-sm my-1" href="{% url 'activity:activity_invite' pk=activity.pk %}" data-turbolinks="false"> {% trans "Invite" %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
233
apps/activity/tests/test_activities.py
Normal file
233
apps/activity/tests/test_activities.py
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
# Copyright (C) 2018-2024 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 member.models import Club
|
||||||
|
|
||||||
|
from ..api.views import ActivityTypeViewSet, ActivityViewSet, EntryViewSet, GuestViewSet
|
||||||
|
from ..models import Activity, ActivityType, Guest, Entry
|
||||||
|
|
||||||
|
|
||||||
|
class TestActivities(TestCase):
|
||||||
|
"""
|
||||||
|
Test activities
|
||||||
|
"""
|
||||||
|
fixtures = ('initial',)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_superuser(
|
||||||
|
username="admintoto",
|
||||||
|
password="tototototo",
|
||||||
|
email="toto@example.com"
|
||||||
|
)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
sess = self.client.session
|
||||||
|
sess["permission_mask"] = 42
|
||||||
|
sess.save()
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_activity_list(self):
|
||||||
|
"""
|
||||||
|
Display the list of all activities
|
||||||
|
"""
|
||||||
|
response = self.client.get(reverse("activity:activity_list"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_activity_create(self):
|
||||||
|
"""
|
||||||
|
Create a new activity
|
||||||
|
"""
|
||||||
|
response = self.client.get(reverse("activity:activity_create"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("activity:activity_create"), data=dict(
|
||||||
|
name="Activity created",
|
||||||
|
description="This activity was successfully created.",
|
||||||
|
location="Earth",
|
||||||
|
activity_type=ActivityType.objects.get(name="Soirée de club").id,
|
||||||
|
creater=self.user.id,
|
||||||
|
organizer=Club.objects.get(name="Kfet").id,
|
||||||
|
attendees_club=Club.objects.get(name="Kfet").id,
|
||||||
|
date_start="{:%Y-%m-%d %H:%M}".format(timezone.now()),
|
||||||
|
date_end="{:%Y-%m-%d %H:%M}".format(timezone.now() + timedelta(days=2)),
|
||||||
|
valid=True,
|
||||||
|
))
|
||||||
|
self.assertTrue(Activity.objects.filter(name="Activity created").exists())
|
||||||
|
activity = Activity.objects.get(name="Activity created")
|
||||||
|
self.assertRedirects(response, reverse("activity:activity_detail", args=(activity.pk,)), 302, 200)
|
||||||
|
|
||||||
|
def test_activity_detail(self):
|
||||||
|
"""
|
||||||
|
Display the detail of an activity
|
||||||
|
"""
|
||||||
|
response = self.client.get(reverse("activity:activity_detail", args=(self.activity.pk,)))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_activity_update(self):
|
||||||
|
"""
|
||||||
|
Update an activity
|
||||||
|
"""
|
||||||
|
response = self.client.get(reverse("activity:activity_update", args=(self.activity.pk,)))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = self.client.post(reverse("activity:activity_update", args=(self.activity.pk,)), data=dict(
|
||||||
|
name=str(self.activity) + " updated",
|
||||||
|
description="This activity was successfully updated.",
|
||||||
|
location="Earth",
|
||||||
|
activity_type=ActivityType.objects.get(name="Autre").id,
|
||||||
|
creater=self.user.id,
|
||||||
|
organizer=Club.objects.get(name="Kfet").id,
|
||||||
|
attendees_club=Club.objects.get(name="Kfet").id,
|
||||||
|
date_start="{:%Y-%m-%d %H:%M}".format(timezone.now()),
|
||||||
|
date_end="{:%Y-%m-%d %H:%M}".format(timezone.now() + timedelta(days=2)),
|
||||||
|
valid=True,
|
||||||
|
))
|
||||||
|
self.assertTrue(Activity.objects.filter(name="Activity updated").exists())
|
||||||
|
self.assertRedirects(response, reverse("activity:activity_detail", args=(self.activity.pk,)), 302, 200)
|
||||||
|
|
||||||
|
def test_activity_entry(self):
|
||||||
|
"""
|
||||||
|
Create some entries
|
||||||
|
"""
|
||||||
|
self.activity.open = True
|
||||||
|
self.activity.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("activity:activity_entry", args=(self.activity.pk,)))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
response = self.client.get(reverse("activity:activity_entry", args=(self.activity.pk,)) + "?search=guest")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
response = self.client.get(reverse("activity:activity_entry", args=(self.activity.pk,)) + "?search=admin")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# User entry
|
||||||
|
response = self.client.post("/api/activity/entry/", data=dict(
|
||||||
|
activity=self.activity.id,
|
||||||
|
note=self.user.note.id,
|
||||||
|
guest="",
|
||||||
|
))
|
||||||
|
self.assertEqual(response.status_code, 201) # 201 = Created
|
||||||
|
self.assertTrue(Entry.objects.filter(note=self.user.note, guest=None, activity=self.activity).exists())
|
||||||
|
|
||||||
|
# Guest entry
|
||||||
|
response = self.client.post("/api/activity/entry/", data=dict(
|
||||||
|
activity=self.activity.id,
|
||||||
|
note=self.user.note.id,
|
||||||
|
guest=self.guest.id,
|
||||||
|
))
|
||||||
|
self.assertEqual(response.status_code, 201) # 201 = Created
|
||||||
|
self.assertTrue(Entry.objects.filter(note=self.user.note, guest=self.guest.id, activity=self.activity).exists())
|
||||||
|
|
||||||
|
def test_activity_invite(self):
|
||||||
|
"""
|
||||||
|
Try to invite people to an activity
|
||||||
|
"""
|
||||||
|
response = self.client.get(reverse("activity:activity_invite", args=(self.activity.pk,)))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# The activity is started, can't invite
|
||||||
|
response = self.client.post(reverse("activity:activity_invite", args=(self.activity.pk,)), data=dict(
|
||||||
|
activity=self.activity.id,
|
||||||
|
inviter=self.user.note.id,
|
||||||
|
last_name="GUEST2",
|
||||||
|
first_name="Guest",
|
||||||
|
))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
self.activity.date_start += timedelta(days=1)
|
||||||
|
self.activity.save()
|
||||||
|
|
||||||
|
response = self.client.post(reverse("activity:activity_invite", args=(self.activity.pk,)), data=dict(
|
||||||
|
activity=self.activity.id,
|
||||||
|
inviter=self.user.note.id,
|
||||||
|
last_name="GUEST2",
|
||||||
|
first_name="Guest",
|
||||||
|
))
|
||||||
|
self.assertRedirects(response, reverse("activity:activity_detail", args=(self.activity.pk,)), 302, 200)
|
||||||
|
|
||||||
|
def test_activity_ics(self):
|
||||||
|
"""
|
||||||
|
Render the ICS calendar
|
||||||
|
"""
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
|
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-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
@ -14,4 +14,5 @@ urlpatterns = [
|
|||||||
path('<int:pk>/entry/', views.ActivityEntryView.as_view(), name='activity_entry'),
|
path('<int:pk>/entry/', views.ActivityEntryView.as_view(), name='activity_entry'),
|
||||||
path('<int:pk>/update/', views.ActivityUpdateView.as_view(), name='activity_update'),
|
path('<int:pk>/update/', views.ActivityUpdateView.as_view(), name='activity_update'),
|
||||||
path('new/', views.ActivityCreateView.as_view(), name='activity_create'),
|
path('new/', views.ActivityCreateView.as_view(), name='activity_create'),
|
||||||
|
path('calendar.ics', views.CalendarView.as_view(), name='calendar_ics'),
|
||||||
]
|
]
|
||||||
|
@ -1,29 +1,55 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from hashlib import md5
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.db import transaction
|
||||||
from django.db.models import F, Q
|
from django.db.models import F, Q
|
||||||
|
from django.http import HttpResponse
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse_lazy
|
||||||
from django.views.generic import CreateView, DetailView, UpdateView, TemplateView
|
from django.utils import timezone
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_tables2.views import SingleTableView
|
from django.views import View
|
||||||
from note.models import NoteUser, Alias, NoteSpecial
|
from django.views.decorators.cache import cache_page
|
||||||
|
from django.views.generic import DetailView, TemplateView, UpdateView
|
||||||
|
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.backends import PermissionBackend
|
||||||
from permission.views import ProtectQuerysetMixin
|
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
|
||||||
|
|
||||||
from .forms import ActivityForm, GuestForm
|
from .forms import ActivityForm, GuestForm
|
||||||
from .models import Activity, Guest, Entry
|
from .models import Activity, Entry, Guest, Opener
|
||||||
from .tables import ActivityTable, GuestTable, EntryTable
|
from .tables import ActivityTable, EntryTable, GuestTable, OpenerTable
|
||||||
|
|
||||||
|
|
||||||
class ActivityCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
|
class ActivityCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||||
|
"""
|
||||||
|
View to create a new Activity
|
||||||
|
"""
|
||||||
model = Activity
|
model = Activity
|
||||||
form_class = ActivityForm
|
form_class = ActivityForm
|
||||||
|
extra_context = {"title": _("Create new activity")}
|
||||||
|
|
||||||
|
def get_sample_object(self):
|
||||||
|
return Activity(
|
||||||
|
name="",
|
||||||
|
description="",
|
||||||
|
creater=self.request.user,
|
||||||
|
activity_type_id=1,
|
||||||
|
organizer_id=1,
|
||||||
|
attendees_club_id=1,
|
||||||
|
date_start=timezone.now(),
|
||||||
|
date_end=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
form.instance.creater = self.request.user
|
form.instance.creater = self.request.user
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
@ -33,127 +59,269 @@ class ActivityCreateView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
|
|||||||
return reverse_lazy('activity:activity_detail', kwargs={"pk": self.object.pk})
|
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
|
model = Activity
|
||||||
table_class = ActivityTable
|
tables = [
|
||||||
|
lambda data: ActivityTable(data, prefix="all-"),
|
||||||
|
lambda data: ActivityTable(data, prefix="upcoming-"),
|
||||||
|
]
|
||||||
|
extra_context = {"title": _("Activities")}
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self, **kwargs):
|
||||||
return super().get_queryset().reverse()
|
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):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
context['title'] = _("Activities")
|
tables = context["tables"]
|
||||||
|
for name, table in zip(["table", "upcoming"], tables):
|
||||||
|
context[name] = table
|
||||||
|
|
||||||
upcoming_activities = Activity.objects.filter(date_end__gt=datetime.now())
|
started_activities = self.get_queryset().filter(open=True, valid=True).distinct().all()
|
||||||
context['upcoming'] = ActivityTable(
|
context["started_activities"] = started_activities
|
||||||
data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")))
|
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, DetailView):
|
||||||
|
"""
|
||||||
|
Shows details about one activity. Add guest to context
|
||||||
|
"""
|
||||||
model = Activity
|
model = Activity
|
||||||
context_object_name = "activity"
|
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):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data()
|
context = super().get_context_data()
|
||||||
|
|
||||||
table = GuestTable(data=Guest.objects.filter(activity=self.object)
|
tables = context["tables"]
|
||||||
.filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view")))
|
for name, table in zip(["guests", "opener"], tables):
|
||||||
context["guests"] = table
|
context[name] = table
|
||||||
|
|
||||||
context["activity_started"] = datetime.now(timezone.utc) > self.object.date_start
|
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
|
return context
|
||||||
|
|
||||||
|
|
||||||
class ActivityUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
class ActivityUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||||
|
"""
|
||||||
|
Updates one Activity
|
||||||
|
"""
|
||||||
model = Activity
|
model = Activity
|
||||||
form_class = ActivityForm
|
form_class = ActivityForm
|
||||||
|
extra_context = {"title": _("Update activity")}
|
||||||
|
|
||||||
def get_success_url(self, **kwargs):
|
def get_success_url(self, **kwargs):
|
||||||
return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]})
|
return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]})
|
||||||
|
|
||||||
|
|
||||||
class ActivityInviteView(ProtectQuerysetMixin, LoginRequiredMixin, CreateView):
|
class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||||
|
"""
|
||||||
|
Invite a Guest, The rules to invites someone are defined in `forms:activity.GuestForm`
|
||||||
|
"""
|
||||||
model = Guest
|
model = Guest
|
||||||
form_class = GuestForm
|
form_class = GuestForm
|
||||||
template_name = "activity/activity_invite.html"
|
template_name = "activity/activity_form.html"
|
||||||
|
|
||||||
|
def get_sample_object(self):
|
||||||
|
""" Creates a standart Guest binds to the Activity"""
|
||||||
|
activity = Activity.objects.get(pk=self.kwargs["pk"])
|
||||||
|
return Guest(
|
||||||
|
activity=activity,
|
||||||
|
first_name="",
|
||||||
|
last_name="",
|
||||||
|
inviter=self.request.user.note,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
activity = context["form"].activity
|
||||||
|
context["title"] = _('Invite guest to the activity "{}"').format(activity.name)
|
||||||
|
return context
|
||||||
|
|
||||||
def get_form(self, form_class=None):
|
def get_form(self, form_class=None):
|
||||||
form = super().get_form(form_class)
|
form = super().get_form(form_class)
|
||||||
form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
|
form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
|
||||||
.get(pk=self.kwargs["pk"])
|
.filter(pk=self.kwargs["pk"]).first()
|
||||||
|
form.fields["inviter"].initial = self.request.user.note
|
||||||
return form
|
return form
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
form.instance.activity = Activity.objects\
|
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)
|
return super().form_valid(form)
|
||||||
|
|
||||||
def get_success_url(self, **kwargs):
|
def get_success_url(self, **kwargs):
|
||||||
return reverse_lazy('activity:activity_detail', kwargs={"pk": self.kwargs["pk"]})
|
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"
|
template_name = "activity/activity_entry.html"
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
table_class = EntryTable
|
||||||
context = super().get_context_data(**kwargs)
|
|
||||||
|
|
||||||
activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\
|
def dispatch(self, request, *args, **kwargs):
|
||||||
.get(pk=self.kwargs["pk"])
|
"""
|
||||||
context["activity"] = activity
|
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()
|
||||||
|
|
||||||
matched = []
|
activity = Activity.objects.get(pk=self.kwargs["pk"])
|
||||||
|
|
||||||
pattern = "^$"
|
sample_entry = Entry(activity=activity, note=self.request.user.note)
|
||||||
if "search" in self.request.GET:
|
if not PermissionBackend.check_perm(self.request, "activity.add_entry", sample_entry):
|
||||||
pattern = self.request.GET["search"]
|
raise PermissionDenied(_("You are not allowed to display the entry interface for this activity."))
|
||||||
|
|
||||||
if not pattern:
|
if not activity.activity_type.manage_entries:
|
||||||
pattern = "^$"
|
raise PermissionDenied(_("This activity does not support activity entries."))
|
||||||
|
|
||||||
if pattern[0] != "^":
|
if not activity.open:
|
||||||
pattern = "^" + pattern
|
raise PermissionDenied(_("This activity is closed."))
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def get_invited_guest(self, activity):
|
||||||
|
"""
|
||||||
|
Retrieves all Guests to the activity
|
||||||
|
"""
|
||||||
|
|
||||||
guest_qs = Guest.objects\
|
guest_qs = Guest.objects\
|
||||||
.annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\
|
.annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\
|
||||||
.filter(Q(first_name__regex=pattern) | Q(last_name__regex=pattern)
|
.filter(activity=activity)\
|
||||||
| Q(inviter__alias__name__regex=pattern)
|
.filter(PermissionBackend.filter_queryset(self.request, Guest, "view"))\
|
||||||
| Q(inviter__alias__normalized_name__regex=Alias.normalize(pattern))) \
|
.order_by('last_name', 'first_name')
|
||||||
.filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))\
|
|
||||||
.distinct()[:20]
|
|
||||||
for guest in guest_qs:
|
|
||||||
guest.type = "Invité"
|
|
||||||
matched.append(guest)
|
|
||||||
|
|
||||||
|
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 "__istartswith"
|
||||||
|
pattern = "^" + pattern if valid_regex and pattern[0] != "^" else pattern
|
||||||
|
guest_qs = guest_qs.filter(
|
||||||
|
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.distinct()
|
||||||
|
|
||||||
|
def get_invited_note(self, activity):
|
||||||
|
"""
|
||||||
|
Retrieves all Note that can attend the activity,
|
||||||
|
they need to have an up-to-date membership in the attendees_club.
|
||||||
|
"""
|
||||||
note_qs = Alias.objects.annotate(last_name=F("note__noteuser__user__last_name"),
|
note_qs = Alias.objects.annotate(last_name=F("note__noteuser__user__last_name"),
|
||||||
first_name=F("note__noteuser__user__first_name"),
|
first_name=F("note__noteuser__user__first_name"),
|
||||||
username=F("note__noteuser__user__username"),
|
username=F("note__noteuser__user__username"),
|
||||||
note_name=F("name"),
|
note_name=F("name"),
|
||||||
balance=F("note__balance"))\
|
balance=F("note__balance"))
|
||||||
.filter(Q(note__polymorphic_ctype__model="noteuser")
|
|
||||||
& (Q(note__noteuser__user__first_name__regex=pattern)
|
# Keep only users that have a note
|
||||||
| Q(note__noteuser__user__last_name__regex=pattern)
|
note_qs = note_qs.filter(note__noteuser__isnull=False)
|
||||||
| Q(name__regex=pattern)
|
|
||||||
| Q(normalized_name__regex=Alias.normalize(pattern)))) \
|
# Keep only valid members
|
||||||
.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view"))
|
note_qs = note_qs.filter(
|
||||||
if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql_psycopg2':
|
note__noteuser__user__memberships__club=activity.attendees_club,
|
||||||
note_qs = note_qs.distinct('note__pk')[:20]
|
note__noteuser__user__memberships__date_start__lte=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, 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(**{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:
|
else:
|
||||||
|
note_qs = note_qs.none()
|
||||||
|
|
||||||
# SQLite doesn't support distinct fields. For compatibility reason (in dev mode), the note list will only
|
# SQLite doesn't support distinct fields. For compatibility reason (in dev mode), the note list will only
|
||||||
# have distinct aliases rather than distinct notes with a SQLite DB, but it can fill the result page.
|
# have distinct aliases rather than distinct notes with a SQLite DB, but it can fill the result page.
|
||||||
# In production mode, please use PostgreSQL.
|
# In production mode, please use PostgreSQL.
|
||||||
note_qs = note_qs.distinct()[:20]
|
note_qs = note_qs.distinct('note__pk')[:20]\
|
||||||
for note in note_qs:
|
if settings.DATABASES[note_qs.db]["ENGINE"] == 'django.db.backends.postgresql' else note_qs.distinct()[:20]
|
||||||
|
return note_qs
|
||||||
|
|
||||||
|
def get_table_data(self):
|
||||||
|
activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\
|
||||||
|
.distinct().get(pk=self.kwargs["pk"])
|
||||||
|
|
||||||
|
matched = []
|
||||||
|
|
||||||
|
for guest in self.get_invited_guest(activity):
|
||||||
|
guest.type = "Invité"
|
||||||
|
matched.append(guest)
|
||||||
|
|
||||||
|
for note in self.get_invited_note(activity):
|
||||||
note.type = "Adhérent"
|
note.type = "Adhérent"
|
||||||
note.activity = activity
|
note.activity = activity
|
||||||
matched.append(note)
|
matched.append(note)
|
||||||
|
|
||||||
table = EntryTable(data=matched)
|
return matched
|
||||||
context["table"] = table
|
|
||||||
|
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)
|
context["entries"] = Entry.objects.filter(activity=activity)
|
||||||
|
|
||||||
@ -161,8 +329,70 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView):
|
|||||||
context["noteuser_ctype"] = ContentType.objects.get_for_model(NoteUser).pk
|
context["noteuser_ctype"] = ContentType.objects.get_for_model(NoteUser).pk
|
||||||
context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk
|
context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk
|
||||||
|
|
||||||
context["activities_open"] = Activity.objects.filter(open=True).filter(
|
activities_open = Activity.objects.filter(open=True, activity_type__manage_entries=True).filter(
|
||||||
PermissionBackend.filter_queryset(self.request.user, Activity, "view")).filter(
|
PermissionBackend.filter_queryset(self.request, Activity, "view")).distinct().all()
|
||||||
PermissionBackend.filter_queryset(self.request.user, Activity, "change")).all()
|
context["activities_open"] = [a for a in activities_open
|
||||||
|
if PermissionBackend.check_perm(self.request,
|
||||||
|
"activity.add_entry",
|
||||||
|
Entry(activity=a, note=self.request.user.note,))]
|
||||||
|
|
||||||
return context
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def multilines(self, string, maxlength, offset=0):
|
||||||
|
newstring = string[:maxlength - offset]
|
||||||
|
string = string[maxlength - offset:]
|
||||||
|
while string:
|
||||||
|
newstring += "\r\n "
|
||||||
|
newstring += string[:maxlength - 1]
|
||||||
|
string = string[maxlength - 1:]
|
||||||
|
return newstring
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
ics = """BEGIN:VCALENDAR
|
||||||
|
VERSION: 2.0
|
||||||
|
PRODID:Note Kfet 2020
|
||||||
|
X-WR-CALNAME:Kfet Calendar
|
||||||
|
NAME:Kfet Calendar
|
||||||
|
CALSCALE:GREGORIAN
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Europe/Paris
|
||||||
|
X-LIC-LOCATION:Europe/Paris
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETFROM:+0100
|
||||||
|
TZOFFSETTO:+0200
|
||||||
|
TZNAME:CEST
|
||||||
|
DTSTART:19700329T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZOFFSETFROM:+0200
|
||||||
|
TZOFFSETTO:+0100
|
||||||
|
TZNAME:CET
|
||||||
|
DTSTART:19701025T030000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
|
||||||
|
END:STANDARD
|
||||||
|
END:VTIMEZONE
|
||||||
|
"""
|
||||||
|
for activity in Activity.objects.filter(valid=True).order_by("-date_start").all():
|
||||||
|
ics += f"""BEGIN:VEVENT
|
||||||
|
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:{"{:%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) + f"""
|
||||||
|
-- {activity.organizer.name}
|
||||||
|
END:VEVENT
|
||||||
|
"""
|
||||||
|
ics += "END:VCALENDAR"
|
||||||
|
ics = ics.replace("\r", "").replace("\n", "\r\n")
|
||||||
|
return HttpResponse(ics, content_type="text/calendar; charset=UTF-8")
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
default_app_config = 'api.apps.APIConfig'
|
default_app_config = 'api.apps.APIConfig'
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django.apps import AppConfig
|
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'
|
91
apps/api/serializers.py
Normal file
91
apps/api/serializers.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
# Copyright (C) 2018-2024 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 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(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
REST API Serializer for Users.
|
||||||
|
The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
exclude = (
|
||||||
|
'password',
|
||||||
|
'groups',
|
||||||
|
'user_permissions',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContentTypeSerializer(serializers.ModelSerializer):
|
||||||
|
"""
|
||||||
|
REST API Serializer for Users.
|
||||||
|
The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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-2024 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/")
|
115
apps/api/urls.py
115
apps/api/urls.py
@ -1,91 +1,62 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django.conf.urls import url, include
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.conf.urls import include
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.urls import re_path
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from rest_framework import routers
|
||||||
from rest_framework import routers, serializers
|
|
||||||
from rest_framework.filters import SearchFilter
|
|
||||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
|
||||||
from activity.api.urls import register_activity_urls
|
|
||||||
from api.viewsets import ReadProtectedModelViewSet
|
|
||||||
from member.api.urls import register_members_urls
|
|
||||||
from note.api.urls import register_note_urls
|
|
||||||
from treasury.api.urls import register_treasury_urls
|
|
||||||
from logs.api.urls import register_logs_urls
|
|
||||||
from permission.api.urls import register_permission_urls
|
|
||||||
from wei.api.urls import register_wei_urls
|
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
|
||||||
"""
|
|
||||||
REST API Serializer for Users.
|
|
||||||
The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = User
|
|
||||||
exclude = (
|
|
||||||
'password',
|
|
||||||
'groups',
|
|
||||||
'user_permissions',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ContentTypeSerializer(serializers.ModelSerializer):
|
|
||||||
"""
|
|
||||||
REST API Serializer for Users.
|
|
||||||
The djangorestframework plugin will analyse the model `User` and parse all fields in the API.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = ContentType
|
|
||||||
fields = '__all__'
|
|
||||||
|
|
||||||
|
|
||||||
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/
|
|
||||||
"""
|
|
||||||
queryset = User.objects.all()
|
|
||||||
serializer_class = UserSerializer
|
|
||||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
|
||||||
filterset_fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active', ]
|
|
||||||
search_fields = ['$username', '$first_name', '$last_name', ]
|
|
||||||
|
|
||||||
|
|
||||||
# This ViewSet is the only one that is accessible from all authenticated users!
|
|
||||||
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/
|
|
||||||
"""
|
|
||||||
queryset = ContentType.objects.all()
|
|
||||||
serializer_class = ContentTypeSerializer
|
|
||||||
|
|
||||||
|
from .views import UserInformationView
|
||||||
|
from .viewsets import ContentTypeViewSet, UserViewSet
|
||||||
|
|
||||||
# Routers provide an easy way of automatically determining the URL conf.
|
# Routers provide an easy way of automatically determining the URL conf.
|
||||||
# Register each app API router and user viewset
|
# Register each app API router and user viewset
|
||||||
router = routers.DefaultRouter()
|
router = routers.DefaultRouter()
|
||||||
router.register('models', ContentTypeViewSet)
|
router.register('models', ContentTypeViewSet)
|
||||||
router.register('user', UserViewSet)
|
router.register('user', UserViewSet)
|
||||||
register_members_urls(router, 'members')
|
|
||||||
|
if "activity" in settings.INSTALLED_APPS:
|
||||||
|
from activity.api.urls import register_activity_urls
|
||||||
register_activity_urls(router, 'activity')
|
register_activity_urls(router, 'activity')
|
||||||
register_note_urls(router, 'note')
|
|
||||||
register_treasury_urls(router, 'treasury')
|
if "food" in settings.INSTALLED_APPS:
|
||||||
register_permission_urls(router, 'permission')
|
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')
|
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')
|
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'
|
app_name = 'api'
|
||||||
|
|
||||||
# Wire up our API using automatic URL routing.
|
# Wire up our API using automatic URL routing.
|
||||||
# Additionally, we include login URLs for the browsable API.
|
# Additionally, we include login URLs for the browsable API.
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url('^', include(router.urls)),
|
re_path('^', include(router.urls)),
|
||||||
url('^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
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-2024 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,31 +1,126 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from django.db.models import Q
|
||||||
|
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 permission.backends import PermissionBackend
|
||||||
from rest_framework import viewsets
|
from note.models import Alias
|
||||||
from note_kfet.middlewares import get_current_authenticated_user
|
|
||||||
|
from .filters import RegexSafeSearchFilter
|
||||||
|
from .serializers import UserSerializer, ContentTypeSerializer
|
||||||
|
|
||||||
|
|
||||||
class ReadProtectedModelViewSet(viewsets.ModelViewSet):
|
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.
|
Protect a ModelViewSet by filtering the objects that the user cannot see.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
|
self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
|
||||||
user = get_current_authenticated_user()
|
|
||||||
self.queryset = model.objects.filter(PermissionBackend.filter_queryset(user, model, "view"))
|
def get_queryset(self):
|
||||||
|
return self.queryset.filter(PermissionBackend.filter_queryset(self.request, self.model, "view")).distinct()
|
||||||
|
|
||||||
|
|
||||||
class ReadOnlyProtectedModelViewSet(viewsets.ReadOnlyModelViewSet):
|
class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet):
|
||||||
"""
|
"""
|
||||||
Protect a ReadOnlyModelViewSet by filtering the objects that the user cannot see.
|
Protect a ReadOnlyModelViewSet by filtering the objects that the user cannot see.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
|
self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class()
|
||||||
user = get_current_authenticated_user()
|
|
||||||
self.queryset = model.objects.filter(PermissionBackend.filter_queryset(user, model, "view"))
|
def get_queryset(self):
|
||||||
|
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/user/
|
||||||
|
"""
|
||||||
|
queryset = User.objects
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
filter_backends = [DjangoFilterBackend]
|
||||||
|
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()
|
||||||
|
# Sqlite doesn't support ORDER BY in subqueries
|
||||||
|
queryset = queryset.order_by("username") \
|
||||||
|
if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' else queryset
|
||||||
|
|
||||||
|
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
|
||||||
|
Q(**{f"note__alias__name{suffix}": prefix + pattern})
|
||||||
|
).union(
|
||||||
|
queryset.filter(
|
||||||
|
# Match with normalization
|
||||||
|
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(**{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(**{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,
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = queryset if settings.DATABASES[queryset.db]["ENGINE"] == 'django.db.backends.postgresql' \
|
||||||
|
else queryset.order_by("username")
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
# This ViewSet is the only one that is accessible from all authenticated users!
|
||||||
|
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/models/
|
||||||
|
"""
|
||||||
|
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
37
apps/food/admin.py
Normal file
37
apps/food/admin.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.db import transaction
|
||||||
|
from note_kfet.admin import admin_site
|
||||||
|
|
||||||
|
from .models import Allergen, BasicFood, QRCode, TransformedFood
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(QRCode, site=admin_site)
|
||||||
|
class QRCodeAdmin(admin.ModelAdmin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(BasicFood, site=admin_site)
|
||||||
|
class BasicFoodAdmin(admin.ModelAdmin):
|
||||||
|
@transaction.atomic
|
||||||
|
def save_related(self, *args, **kwargs):
|
||||||
|
ans = super().save_related(*args, **kwargs)
|
||||||
|
args[1].instance.update()
|
||||||
|
return ans
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TransformedFood, site=admin_site)
|
||||||
|
class TransformedFoodAdmin(admin.ModelAdmin):
|
||||||
|
exclude = ["allergens", "expiry_date"]
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def save_related(self, request, form, *args, **kwargs):
|
||||||
|
super().save_related(request, form, *args, **kwargs)
|
||||||
|
form.instance.update()
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Allergen, site=admin_site)
|
||||||
|
class AllergenAdmin(admin.ModelAdmin):
|
||||||
|
pass
|
0
apps/food/api/__init__.py
Normal file
0
apps/food/api/__init__.py
Normal file
50
apps/food/api/serializers.py
Normal file
50
apps/food/api/serializers.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from ..models import Allergen, BasicFood, QRCode, TransformedFood
|
||||||
|
|
||||||
|
|
||||||
|
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 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__'
|
||||||
|
|
||||||
|
|
||||||
|
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__'
|
14
apps/food/api/urls.py
Normal file
14
apps/food/api/urls.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from .views import AllergenViewSet, BasicFoodViewSet, QRCodeViewSet, TransformedFoodViewSet
|
||||||
|
|
||||||
|
|
||||||
|
def register_food_urls(router, path):
|
||||||
|
"""
|
||||||
|
Configure router for Food REST API.
|
||||||
|
"""
|
||||||
|
router.register(path + '/allergen', AllergenViewSet)
|
||||||
|
router.register(path + '/basic_food', BasicFoodViewSet)
|
||||||
|
router.register(path + '/qrcode', QRCodeViewSet)
|
||||||
|
router.register(path + '/transformed_food', TransformedFoodViewSet)
|
61
apps/food/api/views.py
Normal file
61
apps/food/api/views.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
# Copyright (C) 2018-2024 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, QRCodeSerializer, TransformedFoodSerializer
|
||||||
|
from ..models import Allergen, BasicFood, QRCode, TransformedFood
|
||||||
|
|
||||||
|
|
||||||
|
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/basic_food/
|
||||||
|
"""
|
||||||
|
queryset = BasicFood.objects.order_by('id')
|
||||||
|
serializer_class = BasicFoodSerializer
|
||||||
|
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', ]
|
||||||
|
|
||||||
|
|
||||||
|
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/transformed_food/
|
||||||
|
"""
|
||||||
|
queryset = TransformedFood.objects.order_by('id')
|
||||||
|
serializer_class = TransformedFoodSerializer
|
||||||
|
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||||
|
filterset_fields = ['name', ]
|
||||||
|
search_fields = ['$name', ]
|
11
apps/food/apps.py
Normal file
11
apps/food/apps.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Copyright (C) 2018-2024 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')
|
114
apps/food/forms.py
Normal file
114
apps/food/forms.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from random import shuffle
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.utils import timezone
|
||||||
|
from member.models import Club
|
||||||
|
from bootstrap_datepicker_plus.widgets import DateTimePickerInput
|
||||||
|
from note_kfet.inputs import Autocomplete
|
||||||
|
from note_kfet.middlewares import get_current_request
|
||||||
|
from permission.backends import PermissionBackend
|
||||||
|
|
||||||
|
from .models import BasicFood, QRCode, TransformedFood
|
||||||
|
|
||||||
|
|
||||||
|
class AddIngredientForms(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for add an ingredient
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['ingredient'].queryset = self.fields['ingredient'].queryset.filter(
|
||||||
|
polymorphic_ctype__model='transformedfood',
|
||||||
|
is_ready=False,
|
||||||
|
is_active=True,
|
||||||
|
was_eaten=False,
|
||||||
|
)
|
||||||
|
# Caution, the logic is inverted here, we flip the logic on saving in AddIngredientView
|
||||||
|
self.fields['is_active'].initial = True
|
||||||
|
self.fields['is_active'].label = _("Fully used")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TransformedFood
|
||||||
|
fields = ('ingredient', 'is_active')
|
||||||
|
|
||||||
|
|
||||||
|
class BasicFoodForms(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for add non-transformed food
|
||||||
|
"""
|
||||||
|
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]) + ", ..."
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = BasicFood
|
||||||
|
fields = ('name', 'owner', 'date_type', 'expiry_date', 'is_active', 'was_eaten', 'allergens',)
|
||||||
|
widgets = {
|
||||||
|
"owner": Autocomplete(
|
||||||
|
model=Club,
|
||||||
|
attrs={"api_url": "/api/members/club/"},
|
||||||
|
),
|
||||||
|
'expiry_date': DateTimePickerInput(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class QRCodeForms(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for create QRCode
|
||||||
|
"""
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['food_container'].queryset = self.fields['food_container'].queryset.filter(
|
||||||
|
is_active=True,
|
||||||
|
was_eaten=False,
|
||||||
|
polymorphic_ctype__model='transformedfood',
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = QRCode
|
||||||
|
fields = ('food_container',)
|
||||||
|
|
||||||
|
|
||||||
|
class TransformedFoodForms(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for add transformed food
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
self.fields['creation_date'].required = True
|
||||||
|
self.fields['creation_date'].initial = timezone.now
|
||||||
|
self.fields['is_active'].initial = True
|
||||||
|
self.fields['is_ready'].initial = False
|
||||||
|
self.fields['was_eaten'].initial = False
|
||||||
|
|
||||||
|
# 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]) + ", ..."
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TransformedFood
|
||||||
|
fields = ('name', 'creation_date', 'owner', 'is_active', 'is_ready', 'was_eaten', 'shelf_life')
|
||||||
|
widgets = {
|
||||||
|
"owner": Autocomplete(
|
||||||
|
model=Club,
|
||||||
|
attrs={"api_url": "/api/members/club/"},
|
||||||
|
),
|
||||||
|
'creation_date': DateTimePickerInput(),
|
||||||
|
}
|
84
apps/food/migrations/0001_initial.py
Normal file
84
apps/food/migrations/0001_initial.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
# Generated by Django 2.2.28 on 2024-07-05 08:57
|
||||||
|
|
||||||
|
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', '0011_profile_vss_charter_read'),
|
||||||
|
]
|
||||||
|
|
||||||
|
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')),
|
||||||
|
('was_eaten', models.BooleanField(default=False, verbose_name='was eaten')),
|
||||||
|
('is_ready', models.BooleanField(default=False, verbose_name='is ready')),
|
||||||
|
('allergens', models.ManyToManyField(blank=True, to='food.Allergen', verbose_name='allergen')),
|
||||||
|
('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_food.food_set+', to='contenttypes.ContentType')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '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')),
|
||||||
|
('date_type', models.CharField(choices=[('DLC', 'DLC'), ('DDM', 'DDM')], max_length=255)),
|
||||||
|
('arrival_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name='arrival date')),
|
||||||
|
],
|
||||||
|
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.OneToOneField(on_delete=django.db.models.deletion.PROTECT, 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(verbose_name='creation date')),
|
||||||
|
('is_active', models.BooleanField(default=True, verbose_name='is active')),
|
||||||
|
('ingredient', 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',),
|
||||||
|
),
|
||||||
|
]
|
19
apps/food/migrations/0002_transformedfood_shelf_life.py
Normal file
19
apps/food/migrations/0002_transformedfood_shelf_life.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 2.2.28 on 2024-07-06 20:37
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('food', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='transformedfood',
|
||||||
|
name='shelf_life',
|
||||||
|
field=models.DurationField(default=datetime.timedelta(days=3), verbose_name='shelf life'),
|
||||||
|
),
|
||||||
|
]
|
62
apps/food/migrations/0003_create_14_allergens_mandatory.py
Normal file
62
apps/food/migrations/0003_create_14_allergens_mandatory.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
def create_14_mandatory_allergens(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
There are 14 mandatory allergens, they are pre-injected
|
||||||
|
"""
|
||||||
|
|
||||||
|
Allergen = apps.get_model("food", "allergen")
|
||||||
|
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Gluten",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Fruits à coques",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Crustacés",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Céléri",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Oeufs",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Moutarde",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Poissons",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Soja",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Lait",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Sulfites",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Sésame",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Lupin",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Arachides",
|
||||||
|
)
|
||||||
|
Allergen.objects.get_or_create(
|
||||||
|
name="Mollusques",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('food', '0002_transformedfood_shelf_life'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(create_14_mandatory_allergens),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
28
apps/food/migrations/0004_auto_20240813_2358.py
Normal file
28
apps/food/migrations/0004_auto_20240813_2358.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 2.2.28 on 2024-08-13 21:58
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('food', '0003_create_14_allergens_mandatory'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='transformedfood',
|
||||||
|
name='is_active',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='food',
|
||||||
|
name='is_active',
|
||||||
|
field=models.BooleanField(default=True, verbose_name='is active'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='qrcode',
|
||||||
|
name='food_container',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='QR_code', to='food.Food', verbose_name='food container'),
|
||||||
|
),
|
||||||
|
]
|
20
apps/food/migrations/0005_alter_food_polymorphic_ctype.py
Normal file
20
apps/food/migrations/0005_alter_food_polymorphic_ctype.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# 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 = [
|
||||||
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
|
('food', '0004_auto_20240813_2358'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='food',
|
||||||
|
name='polymorphic_ctype',
|
||||||
|
field=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'),
|
||||||
|
),
|
||||||
|
]
|
0
apps/food/migrations/__init__.py
Normal file
0
apps/food/migrations/__init__.py
Normal file
226
apps/food/models.py
Normal file
226
apps/food/models.py
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
# Copyright (C) 2018-2024 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 member.models import Club
|
||||||
|
from polymorphic.models import PolymorphicModel
|
||||||
|
|
||||||
|
|
||||||
|
class QRCode(models.Model):
|
||||||
|
"""
|
||||||
|
An QRCode model
|
||||||
|
"""
|
||||||
|
qr_code_number = models.PositiveIntegerField(
|
||||||
|
verbose_name=_("QR-code number"),
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 {qr_code_number}").format(qr_code_number=self.qr_code_number)
|
||||||
|
|
||||||
|
|
||||||
|
class Allergen(models.Model):
|
||||||
|
"""
|
||||||
|
A list of 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):
|
||||||
|
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=_('allergen'),
|
||||||
|
)
|
||||||
|
|
||||||
|
expiry_date = models.DateTimeField(
|
||||||
|
verbose_name=_('expiry date'),
|
||||||
|
null=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
was_eaten = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_('was eaten'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# is_ready != is_active : is_ready signifie que la nourriture est prête à être manger,
|
||||||
|
# is_active signifie que la nourriture n'est pas encore archivé
|
||||||
|
# il sert dans les cas où il est plus intéressant que de l'open soit conservé (confiture par ex)
|
||||||
|
|
||||||
|
is_ready = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_('is ready'),
|
||||||
|
)
|
||||||
|
|
||||||
|
is_active = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
verbose_name=_('is active'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
|
||||||
|
return super().save(force_insert, force_update, using, update_fields)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('food')
|
||||||
|
verbose_name = _('foods')
|
||||||
|
|
||||||
|
|
||||||
|
class BasicFood(Food):
|
||||||
|
"""
|
||||||
|
Food which has been directly buy on supermarket
|
||||||
|
"""
|
||||||
|
date_type = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
choices=(
|
||||||
|
("DLC", "DLC"),
|
||||||
|
("DDM", "DDM"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
arrival_date = models.DateTimeField(
|
||||||
|
verbose_name=_('arrival date'),
|
||||||
|
default=timezone.now,
|
||||||
|
)
|
||||||
|
|
||||||
|
# label = models.ImageField(
|
||||||
|
# verbose_name=_('food label'),
|
||||||
|
# max_length=255,
|
||||||
|
# blank=False,
|
||||||
|
# null=False,
|
||||||
|
# upload_to='label/',
|
||||||
|
# )
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def update_allergens(self):
|
||||||
|
# update parents
|
||||||
|
for parent in self.transformed_ingredient_inv.iterator():
|
||||||
|
parent.update_allergens()
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def update_expiry_date(self):
|
||||||
|
# update parents
|
||||||
|
for parent in self.transformed_ingredient_inv.iterator():
|
||||||
|
parent.update_expiry_date()
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def update(self):
|
||||||
|
self.update_allergens()
|
||||||
|
self.update_expiry_date()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('Basic food')
|
||||||
|
verbose_name_plural = _('Basic foods')
|
||||||
|
|
||||||
|
|
||||||
|
class TransformedFood(Food):
|
||||||
|
"""
|
||||||
|
Transformed food are a mix between basic food and meal
|
||||||
|
"""
|
||||||
|
creation_date = models.DateTimeField(
|
||||||
|
verbose_name=_('creation date'),
|
||||||
|
)
|
||||||
|
|
||||||
|
ingredient = models.ManyToManyField(
|
||||||
|
Food,
|
||||||
|
blank=True,
|
||||||
|
symmetrical=False,
|
||||||
|
related_name='transformed_ingredient_inv',
|
||||||
|
verbose_name=_('transformed ingredient'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Without microbiological analyzes, the storage time is 3 days
|
||||||
|
shelf_life = models.DurationField(
|
||||||
|
verbose_name=_("shelf life"),
|
||||||
|
default=timedelta(days=3),
|
||||||
|
)
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def archive(self):
|
||||||
|
# When a meal are archived, if it was eaten, update ingredient fully used for this meal
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def update_allergens(self):
|
||||||
|
# When allergens are changed, simply update the parents' allergens
|
||||||
|
old_allergens = list(self.allergens.all())
|
||||||
|
self.allergens.clear()
|
||||||
|
for ingredient in self.ingredient.iterator():
|
||||||
|
self.allergens.set(self.allergens.union(ingredient.allergens.all()))
|
||||||
|
|
||||||
|
if old_allergens == list(self.allergens.all()):
|
||||||
|
return
|
||||||
|
super().save()
|
||||||
|
|
||||||
|
# update parents
|
||||||
|
for parent in self.transformed_ingredient_inv.iterator():
|
||||||
|
parent.update_allergens()
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def update_expiry_date(self):
|
||||||
|
# When expiry_date is changed, simply update the parents' expiry_date
|
||||||
|
old_expiry_date = self.expiry_date
|
||||||
|
self.expiry_date = self.creation_date + self.shelf_life
|
||||||
|
for ingredient in self.ingredient.iterator():
|
||||||
|
self.expiry_date = min(self.expiry_date, ingredient.expiry_date)
|
||||||
|
|
||||||
|
if old_expiry_date == self.expiry_date:
|
||||||
|
return
|
||||||
|
super().save()
|
||||||
|
|
||||||
|
# update parents
|
||||||
|
for parent in self.transformed_ingredient_inv.iterator():
|
||||||
|
parent.update_expiry_date()
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def update(self):
|
||||||
|
self.update_allergens()
|
||||||
|
self.update_expiry_date()
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('Transformed food')
|
||||||
|
verbose_name_plural = _('Transformed foods')
|
19
apps/food/tables.py
Normal file
19
apps/food/tables.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import django_tables2 as tables
|
||||||
|
from django_tables2 import A
|
||||||
|
|
||||||
|
from .models import TransformedFood
|
||||||
|
|
||||||
|
|
||||||
|
class TransformedFoodTable(tables.Table):
|
||||||
|
name = tables.LinkColumn(
|
||||||
|
'food:food_view',
|
||||||
|
args=[A('pk'), ],
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TransformedFood
|
||||||
|
template_name = 'django_tables2/bootstrap4.html'
|
||||||
|
fields = ('name', "owner", "allergens", "expiry_date")
|
20
apps/food/templates/food/add_ingredient_form.html
Normal file
20
apps/food/templates/food/add_ingredient_form.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
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 %}
|
37
apps/food/templates/food/basicfood_detail.html
Normal file
37
apps/food/templates/food/basicfood_detail.html
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
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>
|
||||||
|
<li><p>{% trans 'Owner' %} : {{ food.owner }}</p></li>
|
||||||
|
<li><p>{% trans 'Arrival date' %} : {{ food.arrival_date }}</p></li>
|
||||||
|
<li><p>{% trans 'Expiry date' %} : {{ food.expiry_date }} ({{ food.date_type }})</p></li>
|
||||||
|
<li>{% trans 'Allergens' %} :</li>
|
||||||
|
<ul>
|
||||||
|
{% for allergen in food.allergens.iterator %}
|
||||||
|
<li>{{ allergen.name }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
<li><p>{% trans 'Active' %} : {{ food.is_active }}<p></li>
|
||||||
|
<li><p>{% trans 'Eaten' %} : {{ food.was_eaten }}<p></li>
|
||||||
|
</ul>
|
||||||
|
{% if can_update %}
|
||||||
|
<a class="btn btn-sm btn-warning" href="{% url "food:basic_update" pk=food.pk %}">{% trans 'Update' %}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_add_ingredient %}
|
||||||
|
<a class="btn btn-sm btn-success" href="{% url "food:add_ingredient" pk=food.pk %}">
|
||||||
|
{% trans 'Add to a meal' %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
20
apps/food/templates/food/basicfood_form.html
Normal file
20
apps/food/templates/food/basicfood_form.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
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 %}
|
55
apps/food/templates/food/create_qrcode_form.html
Normal file
55
apps/food/templates/food/create_qrcode_form.html
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
{% 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">
|
||||||
|
<a class="btn btn-sm btn-success" href="{% url "food:qrcode_basic_create" slug=slug %}">
|
||||||
|
{% trans 'New basic food' %}
|
||||||
|
</a>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form|crispy }}
|
||||||
|
<button class="btn btn-primary" type="submit">{% trans "Submit" %}</button>
|
||||||
|
</form>
|
||||||
|
<div class="card-body" id="profile_infos">
|
||||||
|
<h4>{% trans "Copy constructor" %}</h4>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="orderable">
|
||||||
|
{% trans "Name" %}
|
||||||
|
</th>
|
||||||
|
<th class="orderable">
|
||||||
|
{% trans "Owner" %}
|
||||||
|
</th>
|
||||||
|
<th class="orderable">
|
||||||
|
{% trans "Arrival date" %}
|
||||||
|
</th>
|
||||||
|
<th class="orderable">
|
||||||
|
{% trans "Expiry date" %}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for basic in last_basic %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{% url "food:qrcode_basic_create" slug=slug %}?copy={{ basic.pk }}">{{ basic.name }}</a></td>
|
||||||
|
<td>{{ basic.owner }}</td>
|
||||||
|
<td>{{ basic.arrival_date }}</td>
|
||||||
|
<td>{{ basic.expiry_date }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
39
apps/food/templates/food/qrcode_detail.html
Normal file
39
apps/food/templates/food/qrcode_detail.html
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
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 }} {% trans 'number' %} {{ qrcode.qr_code_number }}
|
||||||
|
</h3>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul>
|
||||||
|
<li><p>{% trans 'Name' %} : {{ qrcode.food_container.name }}</p></li>
|
||||||
|
<li><p>{% trans 'Owner' %} : {{ qrcode.food_container.owner }}</p></li>
|
||||||
|
<li><p>{% trans 'Expiry date' %} : {{ qrcode.food_container.expiry_date }}</p></li>
|
||||||
|
</ul>
|
||||||
|
{% if qrcode.food_container.polymorphic_ctype.model == 'basicfood' and can_update_basic %}
|
||||||
|
<a class="btn btn-sm btn-warning" href="{% url "food:basic_update" pk=qrcode.food_container.pk %}" data-turbolinks="false">
|
||||||
|
{% trans 'Update' %}
|
||||||
|
</a>
|
||||||
|
{% elif can_update_transformed %}
|
||||||
|
<a class="btn btn-sm btn-warning" href="{% url "food:transformed_update" pk=qrcode.food_container.pk %}">
|
||||||
|
{% trans 'Update' %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_view_detail %}
|
||||||
|
<a class="btn btn-sm btn-primary" href="{% url "food:food_view" pk=qrcode.food_container.pk %}">
|
||||||
|
{% trans 'View details' %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_add_ingredient %}
|
||||||
|
<a class="btn btn-sm btn-success" href="{% url "food:add_ingredient" pk=qrcode.food_container.pk %}">
|
||||||
|
{% trans 'Add to a meal' %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
51
apps/food/templates/food/transformedfood_detail.html
Normal file
51
apps/food/templates/food/transformedfood_detail.html
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
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>
|
||||||
|
<li><p>{% trans 'Owner' %} : {{ food.owner }}</p></li>
|
||||||
|
{% if can_see_ready %}
|
||||||
|
<li><p>{% trans 'Ready' %} : {{ food.is_ready }}</p></li>
|
||||||
|
{% endif %}
|
||||||
|
<li><p>{% trans 'Creation date' %} : {{ food.creation_date }}</p></li>
|
||||||
|
<li><p>{% trans 'Expiry date' %} : {{ food.expiry_date }}</p></li>
|
||||||
|
<li>{% trans 'Allergens' %} :</li>
|
||||||
|
<ul>
|
||||||
|
{% for allergen in food.allergens.iterator %}
|
||||||
|
<li>{{ allergen.name }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
<li>{% trans 'Ingredients' %} :</li>
|
||||||
|
<ul>
|
||||||
|
{% for ingredient in food.ingredient.iterator %}
|
||||||
|
<li><a href="{% url "food:food_view" pk=ingredient.pk %}">{{ ingredient.name }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
<li><p>{% trans 'Shelf life' %} : {{ food.shelf_life }}</p></li>
|
||||||
|
<li><p>{% trans 'Ready' %} : {{ food.is_ready }}</p></li>
|
||||||
|
<li><p>{% trans 'Active' %} : {{ food.is_active }}</p></li>
|
||||||
|
<li><p>{% trans 'Eaten' %} : {{ food.was_eaten }}</p></li>
|
||||||
|
</ul>
|
||||||
|
{% if can_update %}
|
||||||
|
<a class="btn btn-sm btn-warning" href="{% url "food:transformed_update" pk=food.pk %}">
|
||||||
|
{% trans 'Update' %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if can_add_ingredient %}
|
||||||
|
<a class="btn btn-sm btn-success" href="{% url "food:add_ingredient" pk=food.pk %}">
|
||||||
|
{% trans 'Add to a meal' %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
20
apps/food/templates/food/transformedfood_form.html
Normal file
20
apps/food/templates/food/transformedfood_form.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
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 %}
|
60
apps/food/templates/food/transformedfood_list.html
Normal file
60
apps/food/templates/food/transformedfood_list.html
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
{% endcomment %}
|
||||||
|
{% load render_table from django_tables2 %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="card bg-light mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{% trans "Meal served" %}
|
||||||
|
</h3>
|
||||||
|
{% if can_create_meal %}
|
||||||
|
<div class="card-footer">
|
||||||
|
<a class="btn btn-sm btn-success" href="{% url 'food:transformed_create' %}" data-turbolinks="false">
|
||||||
|
{% 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>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-light mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{% trans "Open" %}
|
||||||
|
</h3>
|
||||||
|
{% if open.data %}
|
||||||
|
{% render_table open %}
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
{% trans "There is no free meal." %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-light mb-3">
|
||||||
|
<h3 class="card-header text-center">
|
||||||
|
{% trans "All meals" %}
|
||||||
|
</h3>
|
||||||
|
{% if table.data %}
|
||||||
|
{% render_table table %}
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
{% trans "There is no meal." %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
3
apps/food/tests.py
Normal file
3
apps/food/tests.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
21
apps/food/urls.py
Normal file
21
apps/food/urls.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Copyright (C) 2018-2024 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.TransformedListView.as_view(), name='food_list'),
|
||||||
|
path('<int:slug>', views.QRCodeView.as_view(), name='qrcode_view'),
|
||||||
|
path('detail/<int:pk>', views.FoodView.as_view(), name='food_view'),
|
||||||
|
|
||||||
|
path('<int:slug>/create_qrcode', views.QRCodeCreateView.as_view(), name='qrcode_create'),
|
||||||
|
path('<int:slug>/create_qrcode/basic', views.QRCodeBasicFoodCreateView.as_view(), name='qrcode_basic_create'),
|
||||||
|
path('create/transformed', views.TransformedFoodCreateView.as_view(), name='transformed_create'),
|
||||||
|
path('update/basic/<int:pk>', views.BasicFoodUpdateView.as_view(), name='basic_update'),
|
||||||
|
path('update/transformed/<int:pk>', views.TransformedFoodUpdateView.as_view(), name='transformed_update'),
|
||||||
|
path('add/<int:pk>', views.AddIngredientView.as_view(), name='add_ingredient'),
|
||||||
|
]
|
421
apps/food/views.py
Normal file
421
apps/food/views.py
Normal file
@ -0,0 +1,421 @@
|
|||||||
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
|
from django_tables2.views import MultiTableMixin
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views.generic import DetailView, UpdateView
|
||||||
|
from django.views.generic.list import ListView
|
||||||
|
from django.forms import HiddenInput
|
||||||
|
from permission.backends import PermissionBackend
|
||||||
|
from permission.views import ProtectQuerysetMixin, ProtectedCreateView
|
||||||
|
|
||||||
|
from .forms import AddIngredientForms, BasicFoodForms, QRCodeForms, TransformedFoodForms
|
||||||
|
from .models import BasicFood, Food, QRCode, TransformedFood
|
||||||
|
from .tables import TransformedFoodTable
|
||||||
|
|
||||||
|
|
||||||
|
class AddIngredientView(ProtectQuerysetMixin, UpdateView):
|
||||||
|
"""
|
||||||
|
A view to add an ingredient
|
||||||
|
"""
|
||||||
|
model = Food
|
||||||
|
template_name = 'food/add_ingredient_form.html'
|
||||||
|
extra_context = {"title": _("Add the ingredient")}
|
||||||
|
form_class = AddIngredientForms
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["pk"] = self.kwargs["pk"]
|
||||||
|
return context
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def form_valid(self, form):
|
||||||
|
form.instance.creater = self.request.user
|
||||||
|
food = Food.objects.get(pk=self.kwargs['pk'])
|
||||||
|
add_ingredient_form = AddIngredientForms(data=self.request.POST)
|
||||||
|
if food.is_ready:
|
||||||
|
form.add_error(None, _("The product is already prepared"))
|
||||||
|
return self.form_invalid(form)
|
||||||
|
if not add_ingredient_form.is_valid():
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
# We flip logic ""fully used = not is_active""
|
||||||
|
food.is_active = not food.is_active
|
||||||
|
# Save the aliment and the allergens associed
|
||||||
|
for transformed_pk in self.request.POST.getlist('ingredient'):
|
||||||
|
transformed = TransformedFood.objects.get(pk=transformed_pk)
|
||||||
|
if not transformed.is_ready:
|
||||||
|
transformed.ingredient.add(food)
|
||||||
|
transformed.update()
|
||||||
|
food.save()
|
||||||
|
|
||||||
|
return HttpResponseRedirect(self.get_success_url())
|
||||||
|
|
||||||
|
def get_success_url(self, **kwargs):
|
||||||
|
return reverse('food:food_list')
|
||||||
|
|
||||||
|
|
||||||
|
class BasicFoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||||
|
"""
|
||||||
|
A view to update a basic food
|
||||||
|
"""
|
||||||
|
model = BasicFood
|
||||||
|
form_class = BasicFoodForms
|
||||||
|
template_name = 'food/basicfood_form.html'
|
||||||
|
extra_context = {"title": _("Update an aliment")}
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def form_valid(self, form):
|
||||||
|
form.instance.creater = self.request.user
|
||||||
|
basic_food_form = BasicFoodForms(data=self.request.POST)
|
||||||
|
if not basic_food_form.is_valid():
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
ans = super().form_valid(form)
|
||||||
|
form.instance.update()
|
||||||
|
return ans
|
||||||
|
|
||||||
|
def get_success_url(self, **kwargs):
|
||||||
|
self.object.refresh_from_db()
|
||||||
|
return reverse('food:food_view', kwargs={"pk": self.object.pk})
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class FoodView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||||
|
"""
|
||||||
|
A view to see a food
|
||||||
|
"""
|
||||||
|
model = Food
|
||||||
|
extra_context = {"title": _("Details of:")}
|
||||||
|
context_object_name = "food"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
context["can_update"] = PermissionBackend.check_perm(self.request, "food.change_food")
|
||||||
|
context["can_add_ingredient"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood")
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class QRCodeBasicFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||||
|
#####################################################################
|
||||||
|
# TO DO
|
||||||
|
# - this feature is very pratical for meat or fish, nevertheless we can implement this later
|
||||||
|
# - fix picture save
|
||||||
|
# - implement solution crop and convert image (reuse or recode ImageForm from members apps)
|
||||||
|
#####################################################################
|
||||||
|
"""
|
||||||
|
A view to add a basic food with a qrcode
|
||||||
|
"""
|
||||||
|
model = BasicFood
|
||||||
|
form_class = BasicFoodForms
|
||||||
|
template_name = 'food/basicfood_form.html'
|
||||||
|
extra_context = {"title": _("Add a new basic food with QRCode")}
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def form_valid(self, form):
|
||||||
|
form.instance.creater = self.request.user
|
||||||
|
basic_food_form = BasicFoodForms(data=self.request.POST)
|
||||||
|
if not basic_food_form.is_valid():
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
# Save the aliment and the allergens associed
|
||||||
|
basic_food = form.save(commit=False)
|
||||||
|
# We assume the date of labeling and the same as the date of arrival
|
||||||
|
basic_food.arrival_date = timezone.now()
|
||||||
|
basic_food.is_ready = False
|
||||||
|
basic_food.is_active = True
|
||||||
|
basic_food.was_eaten = False
|
||||||
|
basic_food._force_save = True
|
||||||
|
basic_food.save()
|
||||||
|
basic_food.refresh_from_db()
|
||||||
|
|
||||||
|
qrcode = QRCode()
|
||||||
|
qrcode.qr_code_number = self.kwargs['slug']
|
||||||
|
qrcode.food_container = basic_food
|
||||||
|
qrcode.save()
|
||||||
|
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
def get_success_url(self, **kwargs):
|
||||||
|
self.object.refresh_from_db()
|
||||||
|
return reverse('food:qrcode_view', kwargs={"slug": self.kwargs['slug']})
|
||||||
|
|
||||||
|
def get_sample_object(self):
|
||||||
|
|
||||||
|
# We choose a club which may work or BDE else
|
||||||
|
owner_id = 1
|
||||||
|
for membership in self.request.user.memberships.all():
|
||||||
|
club_id = membership.club.id
|
||||||
|
food = BasicFood(name="", expiry_date=timezone.now(), owner_id=club_id)
|
||||||
|
if PermissionBackend.check_perm(self.request, "food.add_basicfood", food):
|
||||||
|
owner_id = club_id
|
||||||
|
|
||||||
|
return BasicFood(
|
||||||
|
name="",
|
||||||
|
expiry_date=timezone.now(),
|
||||||
|
owner_id=owner_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
# Some field are hidden on create
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
form = context['form']
|
||||||
|
form.fields['is_active'].widget = HiddenInput()
|
||||||
|
form.fields['was_eaten'].widget = HiddenInput()
|
||||||
|
|
||||||
|
copy = self.request.GET.get('copy', None)
|
||||||
|
if copy is not None:
|
||||||
|
basic = BasicFood.objects.get(pk=copy)
|
||||||
|
for field in ['date_type', 'expiry_date', 'name', 'owner']:
|
||||||
|
form.fields[field].initial = getattr(basic, field)
|
||||||
|
for field in ['allergens']:
|
||||||
|
form.fields[field].initial = getattr(basic, field).all()
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class QRCodeCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||||
|
"""
|
||||||
|
A view to add a new qrcode
|
||||||
|
"""
|
||||||
|
model = QRCode
|
||||||
|
template_name = 'food/create_qrcode_form.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:
|
||||||
|
return HttpResponseRedirect(reverse("food:qrcode_view", kwargs=kwargs))
|
||||||
|
else:
|
||||||
|
return super().get(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context["slug"] = self.kwargs["slug"]
|
||||||
|
|
||||||
|
context["last_basic"] = BasicFood.objects.order_by('-pk').all()[:10]
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def form_valid(self, form):
|
||||||
|
form.instance.creater = self.request.user
|
||||||
|
qrcode_food_form = QRCodeForms(data=self.request.POST)
|
||||||
|
if not qrcode_food_form.is_valid():
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
# Save the qrcode
|
||||||
|
qrcode = form.save(commit=False)
|
||||||
|
qrcode.qr_code_number = self.kwargs["slug"]
|
||||||
|
qrcode._force_save = True
|
||||||
|
qrcode.save()
|
||||||
|
qrcode.refresh_from_db()
|
||||||
|
|
||||||
|
qrcode.food_container.save()
|
||||||
|
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
def get_success_url(self, **kwargs):
|
||||||
|
self.object.refresh_from_db()
|
||||||
|
return reverse('food:qrcode_view', kwargs={"slug": self.kwargs['slug']})
|
||||||
|
|
||||||
|
def get_sample_object(self):
|
||||||
|
return QRCode(
|
||||||
|
qr_code_number=self.kwargs["slug"],
|
||||||
|
food_container_id=1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class QRCodeView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView):
|
||||||
|
"""
|
||||||
|
A view to see a qrcode
|
||||||
|
"""
|
||||||
|
model = QRCode
|
||||||
|
extra_context = {"title": _("QRCode")}
|
||||||
|
context_object_name = "qrcode"
|
||||||
|
slug_field = "qr_code_number"
|
||||||
|
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
qrcode = kwargs["slug"]
|
||||||
|
if self.model.objects.filter(qr_code_number=qrcode).count() > 0:
|
||||||
|
return super().get(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
return HttpResponseRedirect(reverse("food:qrcode_create", kwargs=kwargs))
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
qr_code_number = self.kwargs['slug']
|
||||||
|
qrcode = self.model.objects.get(qr_code_number=qr_code_number)
|
||||||
|
|
||||||
|
model = qrcode.food_container.polymorphic_ctype.model
|
||||||
|
|
||||||
|
if model == "basicfood":
|
||||||
|
context["can_update_basic"] = PermissionBackend.check_perm(self.request, "food.change_basicfood")
|
||||||
|
context["can_view_detail"] = PermissionBackend.check_perm(self.request, "food.view_basicfood")
|
||||||
|
if model == "transformedfood":
|
||||||
|
context["can_update_transformed"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood")
|
||||||
|
context["can_view_detail"] = PermissionBackend.check_perm(self.request, "food.view_transformedfood")
|
||||||
|
context["can_add_ingredient"] = PermissionBackend.check_perm(self.request, "food.change_transformedfood")
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class TransformedFoodCreateView(ProtectQuerysetMixin, ProtectedCreateView):
|
||||||
|
"""
|
||||||
|
A view to add a tranformed food
|
||||||
|
"""
|
||||||
|
model = TransformedFood
|
||||||
|
template_name = 'food/transformedfood_form.html'
|
||||||
|
form_class = TransformedFoodForms
|
||||||
|
extra_context = {"title": _("Add a new meal")}
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def form_valid(self, form):
|
||||||
|
form.instance.creater = self.request.user
|
||||||
|
transformed_food_form = TransformedFoodForms(data=self.request.POST)
|
||||||
|
if not transformed_food_form.is_valid():
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
# Save the aliment and allergens associated
|
||||||
|
transformed_food = form.save(commit=False)
|
||||||
|
transformed_food.expiry_date = transformed_food.creation_date
|
||||||
|
transformed_food.is_active = True
|
||||||
|
transformed_food.is_ready = False
|
||||||
|
transformed_food.was_eaten = False
|
||||||
|
transformed_food._force_save = True
|
||||||
|
transformed_food.save()
|
||||||
|
transformed_food.refresh_from_db()
|
||||||
|
ans = super().form_valid(form)
|
||||||
|
transformed_food.update()
|
||||||
|
return ans
|
||||||
|
|
||||||
|
def get_success_url(self, **kwargs):
|
||||||
|
self.object.refresh_from_db()
|
||||||
|
return reverse('food:food_view', kwargs={"pk": self.object.pk})
|
||||||
|
|
||||||
|
def get_sample_object(self):
|
||||||
|
# We choose a club which may work or BDE else
|
||||||
|
owner_id = 1
|
||||||
|
for membership in self.request.user.memberships.all():
|
||||||
|
club_id = membership.club.id
|
||||||
|
food = TransformedFood(name="",
|
||||||
|
creation_date=timezone.now(),
|
||||||
|
expiry_date=timezone.now(),
|
||||||
|
owner_id=club_id)
|
||||||
|
if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food):
|
||||||
|
owner_id = club_id
|
||||||
|
break
|
||||||
|
|
||||||
|
return TransformedFood(
|
||||||
|
name="",
|
||||||
|
owner_id=owner_id,
|
||||||
|
creation_date=timezone.now(),
|
||||||
|
expiry_date=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
# Some field are hidden on create
|
||||||
|
form = context['form']
|
||||||
|
form.fields['is_active'].widget = HiddenInput()
|
||||||
|
form.fields['is_ready'].widget = HiddenInput()
|
||||||
|
form.fields['was_eaten'].widget = HiddenInput()
|
||||||
|
form.fields['shelf_life'].widget = HiddenInput()
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class TransformedFoodUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView):
|
||||||
|
"""
|
||||||
|
A view to update transformed product
|
||||||
|
"""
|
||||||
|
model = TransformedFood
|
||||||
|
template_name = 'food/transformedfood_form.html'
|
||||||
|
form_class = TransformedFoodForms
|
||||||
|
extra_context = {'title': _('Update a meal')}
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def form_valid(self, form):
|
||||||
|
form.instance.creater = self.request.user
|
||||||
|
transformedfood_form = TransformedFoodForms(data=self.request.POST)
|
||||||
|
if not transformedfood_form.is_valid():
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
ans = super().form_valid(form)
|
||||||
|
form.instance.update()
|
||||||
|
return ans
|
||||||
|
|
||||||
|
def get_success_url(self, **kwargs):
|
||||||
|
self.object.refresh_from_db()
|
||||||
|
return reverse('food:food_view', kwargs={"pk": self.object.pk})
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class TransformedListView(ProtectQuerysetMixin, LoginRequiredMixin, MultiTableMixin, ListView):
|
||||||
|
"""
|
||||||
|
Displays ready TransformedFood
|
||||||
|
"""
|
||||||
|
model = TransformedFood
|
||||||
|
tables = [TransformedFoodTable, TransformedFoodTable, TransformedFoodTable]
|
||||||
|
extra_context = {"title": _("Transformed food")}
|
||||||
|
|
||||||
|
def get_queryset(self, **kwargs):
|
||||||
|
return super().get_queryset(**kwargs).distinct()
|
||||||
|
|
||||||
|
def get_tables(self):
|
||||||
|
tables = super().get_tables()
|
||||||
|
|
||||||
|
tables[0].prefix = "all-"
|
||||||
|
tables[1].prefix = "open-"
|
||||||
|
tables[2].prefix = "served-"
|
||||||
|
return tables
|
||||||
|
|
||||||
|
def get_tables_data(self):
|
||||||
|
# first table = all transformed food, second table = free, third = served
|
||||||
|
return [
|
||||||
|
self.get_queryset().order_by("-creation_date"),
|
||||||
|
TransformedFood.objects.filter(is_ready=True, is_active=True, was_eaten=False, expiry_date__lt=timezone.now())
|
||||||
|
.filter(PermissionBackend.filter_queryset(self.request, TransformedFood, "view"))
|
||||||
|
.distinct()
|
||||||
|
.order_by("-creation_date"),
|
||||||
|
TransformedFood.objects.filter(is_ready=True, is_active=True, was_eaten=False, expiry_date__gte=timezone.now())
|
||||||
|
.filter(PermissionBackend.filter_queryset(self.request, TransformedFood, "view"))
|
||||||
|
.distinct()
|
||||||
|
.order_by("-creation_date")
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
# We choose a club which should work
|
||||||
|
for membership in self.request.user.memberships.all():
|
||||||
|
club_id = membership.club.id
|
||||||
|
food = TransformedFood(
|
||||||
|
name="",
|
||||||
|
owner_id=club_id,
|
||||||
|
creation_date=timezone.now(),
|
||||||
|
expiry_date=timezone.now(),
|
||||||
|
)
|
||||||
|
if PermissionBackend.check_perm(self.request, "food.add_transformedfood", food):
|
||||||
|
context['can_create_meal'] = True
|
||||||
|
break
|
||||||
|
|
||||||
|
tables = context["tables"]
|
||||||
|
for name, table in zip(["table", "open", "served"], tables):
|
||||||
|
context[name] = table
|
||||||
|
return context
|
@ -1,4 +1,4 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
default_app_config = 'logs.apps.LogsConfig'
|
default_app_config = 'logs.apps.LogsConfig'
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from .views import ChangelogViewSet
|
from .views import ChangelogViewSet
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
@ -15,9 +15,9 @@ class ChangelogViewSet(ReadOnlyProtectedModelViewSet):
|
|||||||
The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer,
|
The djangorestframework plugin will get all `Changelog` objects, serialize it to JSON with the given serializer,
|
||||||
then render it on /api/logs/
|
then render it on /api/logs/
|
||||||
"""
|
"""
|
||||||
queryset = Changelog.objects.all()
|
queryset = Changelog.objects.order_by('id')
|
||||||
serializer_class = ChangelogSerializer
|
serializer_class = ChangelogSerializer
|
||||||
filter_backends = [DjangoFilterBackend, OrderingFilter]
|
filter_backends = [DjangoFilterBackend, OrderingFilter]
|
||||||
filterset_fields = ['model', 'action', "instance_pk", 'user', 'ip', ]
|
filterset_fields = ['model', 'action', "instance_pk", 'user', 'ip', ]
|
||||||
ordering_fields = ['timestamp', ]
|
ordering_fields = ['timestamp', 'id', ]
|
||||||
ordering = ['-timestamp', ]
|
ordering = ['-id', ]
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
37
apps/logs/migrations/0001_initial.py
Normal file
37
apps/logs/migrations/0001_initial.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Generated by Django 2.2.16 on 2020-09-04 21:41
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Changelog',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('ip', models.GenericIPAddressField(blank=True, null=True, verbose_name='IP Address')),
|
||||||
|
('instance_pk', models.CharField(max_length=255, verbose_name='identifier')),
|
||||||
|
('previous', models.TextField(null=True, verbose_name='previous data')),
|
||||||
|
('data', models.TextField(null=True, verbose_name='new data')),
|
||||||
|
('action', models.CharField(choices=[('create', 'create'), ('edit', 'edit'), ('delete', 'delete')], default='edit', max_length=16, verbose_name='action')),
|
||||||
|
('timestamp', models.DateTimeField(default=django.utils.timezone.now, verbose_name='timestamp')),
|
||||||
|
('model', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.ContentType', verbose_name='model')),
|
||||||
|
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='user')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'changelog',
|
||||||
|
'verbose_name_plural': 'changelogs',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
17
apps/logs/migrations/0002_replace_null_by_blank.py
Normal file
17
apps/logs/migrations/0002_replace_null_by_blank.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('logs', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunSQL(
|
||||||
|
"UPDATE logs_changelog SET previous = '' WHERE previous IS NULL;"
|
||||||
|
),
|
||||||
|
migrations.RunSQL(
|
||||||
|
"UPDATE logs_changelog SET data = '' WHERE data IS NULL;"
|
||||||
|
),
|
||||||
|
]
|
23
apps/logs/migrations/0003_remove_null_tag_on_charfields.py
Normal file
23
apps/logs/migrations/0003_remove_null_tag_on_charfields.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 2.2.16 on 2020-09-06 19:17
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('logs', '0002_replace_null_by_blank'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='changelog',
|
||||||
|
name='data',
|
||||||
|
field=models.TextField(blank=True, default='', verbose_name='new data'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='changelog',
|
||||||
|
name='previous',
|
||||||
|
field=models.TextField(blank=True, default='', verbose_name='previous data'),
|
||||||
|
),
|
||||||
|
]
|
@ -1,10 +1,11 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
@ -43,12 +44,14 @@ class Changelog(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
previous = models.TextField(
|
previous = models.TextField(
|
||||||
null=True,
|
blank=True,
|
||||||
|
default="",
|
||||||
verbose_name=_('previous data'),
|
verbose_name=_('previous data'),
|
||||||
)
|
)
|
||||||
|
|
||||||
data = models.TextField(
|
data = models.TextField(
|
||||||
null=True,
|
blank=True,
|
||||||
|
default="",
|
||||||
verbose_name=_('new data'),
|
verbose_name=_('new data'),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -68,14 +71,18 @@ class Changelog(models.Model):
|
|||||||
timestamp = models.DateTimeField(
|
timestamp = models.DateTimeField(
|
||||||
null=False,
|
null=False,
|
||||||
blank=False,
|
blank=False,
|
||||||
auto_now_add=True,
|
default=timezone.now,
|
||||||
name='timestamp',
|
name='timestamp',
|
||||||
verbose_name=_('timestamp'),
|
verbose_name=_('timestamp'),
|
||||||
)
|
)
|
||||||
|
|
||||||
def delete(self, using=None, keep_parents=False):
|
|
||||||
raise ValidationError(_("Logs cannot be destroyed."))
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("changelog")
|
verbose_name = _("changelog")
|
||||||
verbose_name_plural = _("changelogs")
|
verbose_name_plural = _("changelogs")
|
||||||
|
|
||||||
|
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-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from rest_framework.renderers import JSONRenderer
|
from rest_framework.renderers import JSONRenderer
|
||||||
from rest_framework.serializers import ModelSerializer
|
from rest_framework.serializers import ModelSerializer
|
||||||
from note.models import NoteUser, Alias
|
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
|
from .models import Changelog
|
||||||
|
|
||||||
@ -23,6 +23,9 @@ EXCLUDED = [
|
|||||||
'cas_server.userattributes',
|
'cas_server.userattributes',
|
||||||
'contenttypes.contenttype',
|
'contenttypes.contenttype',
|
||||||
'logs.changelog', # Never remove this line
|
'logs.changelog', # Never remove this line
|
||||||
|
'mailer.dontsendentry',
|
||||||
|
'mailer.message',
|
||||||
|
'mailer.messagelog',
|
||||||
'migrations.migration',
|
'migrations.migration',
|
||||||
'note.note' # We only store the subclasses
|
'note.note' # We only store the subclasses
|
||||||
'note.transaction',
|
'note.transaction',
|
||||||
@ -47,22 +50,19 @@ def save_object(sender, instance, **kwargs):
|
|||||||
in order to store each modification made
|
in order to store each modification made
|
||||||
"""
|
"""
|
||||||
# noinspection PyProtectedMember
|
# noinspection PyProtectedMember
|
||||||
if instance._meta.label_lower in EXCLUDED:
|
if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal"):
|
||||||
return
|
|
||||||
|
|
||||||
if hasattr(instance, "_no_log"):
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# noinspection PyProtectedMember
|
# noinspection PyProtectedMember
|
||||||
previous = instance._previous
|
previous = instance._previous
|
||||||
|
|
||||||
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
|
# Si un⋅e utilisateur⋅rice est connecté⋅e, on récupère l'utilisateur⋅rice courant⋅e ainsi que son adresse IP
|
||||||
user, ip = get_current_authenticated_user(), get_current_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`
|
# 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
|
# 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"
|
ip = "127.0.0.1"
|
||||||
username = Alias.normalize(getpass.getuser())
|
username = Alias.normalize(getpass.getuser())
|
||||||
note = NoteUser.objects.filter(alias__normalized_name=username)
|
note = NoteUser.objects.filter(alias__normalized_name=username)
|
||||||
@ -71,26 +71,51 @@ def save_object(sender, instance, **kwargs):
|
|||||||
# else:
|
# else:
|
||||||
if note.exists():
|
if note.exists():
|
||||||
user = note.get().user
|
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
|
# 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
|
# On n'enregistre pas les connexions
|
||||||
if instance.last_login != previous.last_login:
|
if instance.last_login != previous.last_login:
|
||||||
return
|
return
|
||||||
|
|
||||||
# On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles
|
changed_fields = '__all__'
|
||||||
|
if previous:
|
||||||
|
# On ne garde que les champs modifiés
|
||||||
|
changed_fields = []
|
||||||
|
for field in instance._meta.fields:
|
||||||
|
if field.name.endswith("_ptr"):
|
||||||
|
# A field ending with _ptr is a OneToOneRel with a subclass, e.g. NoteClub.note_ptr -> Note
|
||||||
|
continue
|
||||||
|
if getattr(instance, field.name) != getattr(previous, field.name):
|
||||||
|
changed_fields.append(field.name)
|
||||||
|
|
||||||
|
if len(changed_fields) == 0:
|
||||||
|
# Pas de log s'il n'y a pas de modification
|
||||||
|
return
|
||||||
|
|
||||||
|
# On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles avec uniquement les champs modifiés
|
||||||
class CustomSerializer(ModelSerializer):
|
class CustomSerializer(ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = instance.__class__
|
model = instance.__class__
|
||||||
fields = '__all__'
|
fields = changed_fields
|
||||||
|
|
||||||
previous_json = JSONRenderer().render(CustomSerializer(previous).data).decode("UTF-8") if previous else None
|
previous_json = JSONRenderer().render(CustomSerializer(previous).data).decode("UTF-8") if previous else ""
|
||||||
instance_json = JSONRenderer().render(CustomSerializer(instance).data).decode("UTF-8")
|
instance_json = JSONRenderer().render(CustomSerializer(instance).data).decode("UTF-8")
|
||||||
|
|
||||||
if previous_json == instance_json:
|
|
||||||
# Pas de log s'il n'y a pas de modification
|
|
||||||
return
|
|
||||||
|
|
||||||
Changelog.objects.create(user=user,
|
Changelog.objects.create(user=user,
|
||||||
ip=ip,
|
ip=ip,
|
||||||
model=ContentType.objects.get_for_model(instance),
|
model=ContentType.objects.get_for_model(instance),
|
||||||
@ -106,19 +131,16 @@ def delete_object(sender, instance, **kwargs):
|
|||||||
Each time a model is deleted, an entry in the table `Changelog` is added in the database
|
Each time a model is deleted, an entry in the table `Changelog` is added in the database
|
||||||
"""
|
"""
|
||||||
# noinspection PyProtectedMember
|
# noinspection PyProtectedMember
|
||||||
if instance._meta.label_lower in EXCLUDED:
|
if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal"):
|
||||||
return
|
return
|
||||||
|
|
||||||
if hasattr(instance, "_no_log"):
|
# Si un⋅e utilisateur⋅rice est connecté⋅e, on récupère l'utilisateur⋅rice courant⋅e ainsi que son adresse IP
|
||||||
return
|
request = get_current_request()
|
||||||
|
|
||||||
# Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP
|
if request is None:
|
||||||
user, ip = get_current_authenticated_user(), get_current_ip()
|
|
||||||
|
|
||||||
if user is None:
|
|
||||||
# Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py`
|
# 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
|
# 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"
|
ip = "127.0.0.1"
|
||||||
username = Alias.normalize(getpass.getuser())
|
username = Alias.normalize(getpass.getuser())
|
||||||
note = NoteUser.objects.filter(alias__normalized_name=username)
|
note = NoteUser.objects.filter(alias__normalized_name=username)
|
||||||
@ -127,6 +149,20 @@ def delete_object(sender, instance, **kwargs):
|
|||||||
# else:
|
# else:
|
||||||
if note.exists():
|
if note.exists():
|
||||||
user = note.get().user
|
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
|
# On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles
|
||||||
class CustomSerializer(ModelSerializer):
|
class CustomSerializer(ModelSerializer):
|
||||||
@ -141,6 +177,6 @@ def delete_object(sender, instance, **kwargs):
|
|||||||
model=ContentType.objects.get_for_model(instance),
|
model=ContentType.objects.get_for_model(instance),
|
||||||
instance_pk=instance.pk,
|
instance_pk=instance.pk,
|
||||||
previous=instance_json,
|
previous=instance_json,
|
||||||
data=None,
|
data="",
|
||||||
action="delete"
|
action="delete"
|
||||||
).save()
|
).save()
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
default_app_config = 'member.apps.MemberConfig'
|
default_app_config = 'member.apps.MemberConfig'
|
||||||
|
@ -1,12 +1,15 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from note.templatetags.pretty_money import pretty_money
|
||||||
|
from note_kfet.admin import admin_site
|
||||||
|
|
||||||
from .forms import ProfileForm
|
from .forms import ProfileForm
|
||||||
from .models import Club, Membership, Profile, Role
|
from .models import Club, Membership, Profile
|
||||||
|
|
||||||
|
|
||||||
class ProfileInline(admin.StackedInline):
|
class ProfileInline(admin.StackedInline):
|
||||||
@ -14,29 +17,50 @@ class ProfileInline(admin.StackedInline):
|
|||||||
Inline user profile in user admin
|
Inline user profile in user admin
|
||||||
"""
|
"""
|
||||||
model = Profile
|
model = Profile
|
||||||
|
form = ProfileForm
|
||||||
can_delete = False
|
can_delete = False
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(User, site=admin_site)
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(UserAdmin):
|
||||||
inlines = (ProfileInline,)
|
inlines = (ProfileInline,)
|
||||||
list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff')
|
list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff')
|
||||||
list_select_related = ('profile',)
|
list_select_related = ('profile',)
|
||||||
form = ProfileForm
|
|
||||||
|
|
||||||
def get_inline_instances(self, request, obj=None):
|
def get_inline_instances(self, request, obj=None):
|
||||||
"""
|
"""
|
||||||
When creating a new user don't show profile one the first step
|
When creating a new user don't show profile one the first step
|
||||||
"""
|
"""
|
||||||
if not obj:
|
return super().get_inline_instances(request, obj) if obj else []
|
||||||
return list()
|
|
||||||
return super().get_inline_instances(request, obj)
|
|
||||||
|
|
||||||
|
|
||||||
# Update Django User with profile
|
@admin.register(Club, site=admin_site)
|
||||||
admin.site.unregister(User)
|
class ClubAdmin(admin.ModelAdmin):
|
||||||
admin.site.register(User, CustomUserAdmin)
|
list_display = ('name', 'parent_club', 'email', 'require_memberships', 'pretty_fee_paid',
|
||||||
|
'pretty_fee_unpaid', 'membership_start', 'membership_end',)
|
||||||
|
ordering = ('name',)
|
||||||
|
search_fields = ('name', 'email',)
|
||||||
|
|
||||||
# Add other models
|
def pretty_fee_paid(self, obj):
|
||||||
admin.site.register(Club)
|
return pretty_money(obj.membership_fee_paid)
|
||||||
admin.site.register(Membership)
|
|
||||||
admin.site.register(Role)
|
def pretty_fee_unpaid(self, obj):
|
||||||
|
return pretty_money(obj.membership_fee_unpaid)
|
||||||
|
|
||||||
|
pretty_fee_paid.short_description = _("membership fee (paid students)")
|
||||||
|
pretty_fee_unpaid.short_description = _("membership fee (unpaid students)")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Membership, site=admin_site)
|
||||||
|
class MembershipAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('user', 'club', 'date_start', 'date_end', 'view_roles', 'pretty_fee',)
|
||||||
|
ordering = ('-date_start', 'club')
|
||||||
|
|
||||||
|
def view_roles(self, obj):
|
||||||
|
return ", ".join(role.name for role in obj.roles.all())
|
||||||
|
|
||||||
|
def pretty_fee(self, obj):
|
||||||
|
return pretty_money(obj.fee)
|
||||||
|
|
||||||
|
view_roles.short_description = _("roles")
|
||||||
|
pretty_fee.short_description = _("fee")
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from ..models import Profile, Club, Role, Membership
|
from ..models import Profile, Club, Membership
|
||||||
|
|
||||||
|
|
||||||
class ProfileSerializer(serializers.ModelSerializer):
|
class ProfileSerializer(serializers.ModelSerializer):
|
||||||
@ -29,17 +29,6 @@ class ClubSerializer(serializers.ModelSerializer):
|
|||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
class RoleSerializer(serializers.ModelSerializer):
|
|
||||||
"""
|
|
||||||
REST API Serializer for Roles.
|
|
||||||
The djangorestframework plugin will analyse the model `Role` and parse all fields in the API.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Role
|
|
||||||
fields = '__all__'
|
|
||||||
|
|
||||||
|
|
||||||
class MembershipSerializer(serializers.ModelSerializer):
|
class MembershipSerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
REST API Serializer for Memberships.
|
REST API Serializer for Memberships.
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# Copyright (C) 2018-2020 by BDE ENS Paris-Saclay
|
# Copyright (C) 2018-2024 by BDE ENS Paris-Saclay
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from .views import ProfileViewSet, ClubViewSet, RoleViewSet, MembershipViewSet
|
from .views import ProfileViewSet, ClubViewSet, MembershipViewSet
|
||||||
|
|
||||||
|
|
||||||
def register_members_urls(router, path):
|
def register_members_urls(router, path):
|
||||||
@ -10,5 +10,4 @@ def register_members_urls(router, path):
|
|||||||
"""
|
"""
|
||||||
router.register(path + '/profile', ProfileViewSet)
|
router.register(path + '/profile', ProfileViewSet)
|
||||||
router.register(path + '/club', ClubViewSet)
|
router.register(path + '/club', ClubViewSet)
|
||||||
router.register(path + '/role', RoleViewSet)
|
|
||||||
router.register(path + '/membership', MembershipViewSet)
|
router.register(path + '/membership', MembershipViewSet)
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user