Compare commits
895 Commits
b16fe7d68e
...
main
Author | SHA1 | Date | |
---|---|---|---|
905b96fbcf
|
|||
be2e258948
|
|||
882570800c
|
|||
df31968a77
|
|||
df6fb3b3f3
|
|||
3807fbcf45
|
|||
8433390e19
|
|||
ec85f62ab6
|
|||
74b2a0c095
|
|||
67958335ab
|
|||
20410cc17f
|
|||
a5aff5ff21
|
|||
196dbc8275
|
|||
0847e5a308
|
|||
e5aa3ef059
|
|||
e1b4e1bb6b
|
|||
ecc59a6c8c
|
|||
b053a47a19
|
|||
ab2e49e8fb
|
|||
fe399c869d
|
|||
9de8a2ed0e
|
|||
d24f8cab16
|
|||
6cdf6331db
|
|||
65c6158b52
|
|||
4a5f48a834
|
|||
4ab706d219
|
|||
70f2be8b17
|
|||
4317947501
|
|||
f327a4c9c4
|
|||
1b24e90635
|
|||
338f0d456a
|
|||
2c4de8cec3
|
|||
6b7d52c79b
|
|||
f398bedcf3
|
|||
fdffe2331f
|
|||
42425c392d
|
|||
18f3ce4023
|
|||
620bbe7817
|
|||
12205f953b
|
|||
696863f6c3
|
|||
748720df50
|
|||
40db20a471
|
|||
2e99b3ea8e
|
|||
9721898731
|
|||
5c3b3d26c8
|
|||
d13ae89267
|
|||
44302a9ff4
|
|||
8b3f3af2b9
|
|||
dd397ae7c0
|
|||
3f2a757414
|
|||
d20d5f6266
|
|||
05a6570bed
|
|||
2a298a3ee4
|
|||
05c6333c5e
|
|||
d84db949c6
|
|||
2627b3a9b8
|
|||
2c8f6f22f2
|
|||
e258e6a337
|
|||
109748ffc6 | |||
4201a2dbe6 | |||
17c7d0ccc3
|
|||
dd45f77a5e
|
|||
eacebf1aa6
|
|||
21d4ac9d8d | |||
7c83ae8730 | |||
1977ffdbc9 | |||
a0a282df15
|
|||
603ee76664
|
|||
147cbff7f5
|
|||
8878ae8d8d
|
|||
4c8347072c
|
|||
73ea3d1717
|
|||
e026f49f8d
|
|||
ea03bd314b
|
|||
c12972b718
|
|||
2a775cedc1
|
|||
9bf3b7dff0
|
|||
cf92c78d03
|
|||
38ceef7a54
|
|||
ec2fa43e20
|
|||
85b3da09f6
|
|||
2c15774185
|
|||
08ad4f3888
|
|||
872009894d
|
|||
fd7fe90fce
|
|||
2ad538f5cc
|
|||
5e2add90a8
|
|||
635606eb13
|
|||
b828631106
|
|||
8216e0943f
|
|||
1138885fb4
|
|||
a43dc9c12a
|
|||
70050827d8
|
|||
f687deed14
|
|||
7a0341e7cf
|
|||
0129e32643
|
|||
64a2ea007e
|
|||
531eecf4b8
|
|||
bd416318ac
|
|||
90bec6bf5e
|
|||
ed5944e044
|
|||
a41c17576f
|
|||
80456f4da8
|
|||
1a641cb2d7
|
|||
8f3929875f
|
|||
f26f102650
|
|||
1e5d0ebcfc
|
|||
0cab21f344
|
|||
a771710094
|
|||
3b3dcff28b
|
|||
d6aa5eb0cc
|
|||
c6b9a84def
|
|||
675f19492c
|
|||
a5c210e9b6
|
|||
784002c085
|
|||
e77cc558de
|
|||
7bb0f78f34
|
|||
bfd1a76a2d
|
|||
b86dfe7351
|
|||
d36e97fa2e
|
|||
181bb86e49
|
|||
a121d1042b
|
|||
2d706b2b81
|
|||
ca91842c2d
|
|||
d617dd77c1
|
|||
d59bb75dce
|
|||
4a78e80399
|
|||
f3a4a99b78
|
|||
46fc5f39c8
|
|||
b464e7df1d
|
|||
7498677bbd
|
|||
ea8007aa07
|
|||
d9bb0a0860
|
|||
a594b268ea
|
|||
0bc5ef0a7f
|
|||
943276ef71
|
|||
13c815c62c
|
|||
35e3be8af3
|
|||
720de380d1
|
|||
ecf80f8b81
|
|||
3ca0148934
|
|||
58608ea5ff
|
|||
68da61a33b
|
|||
86e978faf2
|
|||
0845d0bfb6
|
|||
f457a2355e
|
|||
bacdd5cfcf
|
|||
3e24e10780
|
|||
adc4634f3e
|
|||
266afaf5c9
|
|||
059cae75c5
|
|||
91a1837c99
|
|||
b24201c529
|
|||
53302db56a
|
|||
49fda3df49
|
|||
3a0a98a331
|
|||
21c4d5d7f5
|
|||
338a19ec32
|
|||
5bfcaab831
|
|||
49e5d97ec9
|
|||
0e185f5046
|
|||
ab7cdd56cc
|
|||
7edd43f626
|
|||
aca23eaf8b
|
|||
a02697a3a7
|
|||
d3d72e090c
|
|||
6c76f1e633
|
|||
4a094002f0
|
|||
3045857897
|
|||
7a0b93b151
|
|||
7073f64aa6
|
|||
b4fc976197
|
|||
7a004596ca
|
|||
1493df0078
|
|||
7732a737bb
|
|||
b942baea17
|
|||
188b83ce2d
|
|||
29d9432ca2
|
|||
0181a1392d
|
|||
ec0419a6d7
|
|||
54016a1fbf
|
|||
7ae015cef9
|
|||
ea264fbca6
|
|||
758f714096
|
|||
40d24740ed
|
|||
b7344566ef
|
|||
0f5d0c8b40
|
|||
c45071c038
|
|||
aac4fc59e6
|
|||
78a43148a8
|
|||
ceedd0678c
|
|||
d13385fa01
|
|||
8996fc2cca
|
|||
65dcc978c1
|
|||
923b07b97e
|
|||
84860a2875
|
|||
6add9a1419
|
|||
eddb741eb7
|
|||
a763abf781
|
|||
78e8a92c3a
|
|||
424dee4aea
|
|||
a381b5583c
|
|||
867ee7efe1
|
|||
32b2d7239c
|
|||
6ce179bd60
|
|||
dba937fb03
|
|||
4efce6e325
|
|||
10a42d3633
|
|||
bb579d640c
|
|||
d7b4233282
|
|||
9092cf1846
|
|||
37b86d4ea0
|
|||
40988348d3
|
|||
1cbf95e6e1
|
|||
c4ec6a6f29
|
|||
779aec5e55
|
|||
bf5c673739
|
|||
a62e906b0e
|
|||
630633bab4
|
|||
8d7d7cd645
|
|||
e53575d31d
|
|||
412ff4e067
|
|||
29b01ebb13
|
|||
30b9a73df8
|
|||
572a6c3299
|
|||
c135da1f47
|
|||
6867c2cc2d
|
|||
1e7bd209a1
|
|||
109b603b7a
|
|||
6595409df0
|
|||
f1012efcaa
|
|||
5261a52401
|
|||
a914237f66
|
|||
2019c5c434
|
|||
234b84ef60
|
|||
b9295cc199
|
|||
3fae6a00dd
|
|||
37ad3cf8a6
|
|||
c522387482
|
|||
0006ecc90d
|
|||
6b16ed3cc8
|
|||
a44439671e
|
|||
5084bb65d9
|
|||
4583cf46b1
|
|||
a865361117
|
|||
4ea93d3426
|
|||
8777c562dd
|
|||
4ea70e5ab9
|
|||
df036ba384
|
|||
e9ae1fcb60
|
|||
bee04b0522
|
|||
b6d54d27cd
|
|||
3465da4c36
|
|||
4f129280c3
|
|||
d2c1a826a8
|
|||
0b9079b431
|
|||
6fa3a08a72
|
|||
64b7644e5e
|
|||
50d8bc2aed
|
|||
7f7ac5d5e6
|
|||
1dd9a5cf94
|
|||
40aa2e520f
|
|||
0ebee1910b
|
|||
81c2df7f10
|
|||
833b300fde
|
|||
12d25b64fe
|
|||
afbc67c413
|
|||
71e33b2177
|
|||
f95309be08
|
|||
0530441452
|
|||
4ff53e08db
|
|||
f9645b016a
|
|||
6b7b802d14
|
|||
1684c079e3
|
|||
0c45a88246
|
|||
de22a12e85
|
|||
415d83acc7
|
|||
eb7e7c1579
|
|||
348004320c
|
|||
9829541289
|
|||
1e1fef7a7b
|
|||
d0c9256c5b
|
|||
83300ad4b7
|
|||
92408b359b
|
|||
01ba0a1df9
|
|||
207af441a0
|
|||
2a2786ba6d
|
|||
1d01376703
|
|||
6e35bdc0b3
|
|||
9380fbaaf7
|
|||
295717256f
|
|||
87038dd6f4
|
|||
2155275627
|
|||
7b4e867e33
|
|||
2c54f315f6
|
|||
5cbc72b41f
|
|||
de504398d2
|
|||
cae1c6fdb8
|
|||
6a928ee35b
|
|||
bc535f4075
|
|||
64b91cf7e0
|
|||
54dafe1cec
|
|||
b16b6e422f
|
|||
8d08b18d08
|
|||
8c7e9648dd
|
|||
b3555a7807
|
|||
98d04b9093
|
|||
4d157b2bd7
|
|||
7c9083a6b8
|
|||
ece128836a
|
|||
2e574d0659
|
|||
850659bf48
|
|||
672529382d
|
|||
c1ce7cb70f
|
|||
bc67d1cf1f
|
|||
652e913f49
|
|||
089374b937
|
|||
226e5620f9
|
|||
ca9652cc60
|
|||
acd1d80c75
|
|||
e7c207d2af
|
|||
196ccb69ad
|
|||
2b941cb30f
|
|||
21ff044044
|
|||
2a85d4ff38
|
|||
037b22fcaa
|
|||
0474615746
|
|||
17057a5fe5
|
|||
a738a5a58d
|
|||
b35bebc7c2
|
|||
99f4aed360
|
|||
bd2cead945
|
|||
62ab0a4c47
|
|||
fd726f4121
|
|||
2c02951a0d
|
|||
9ec35c917f
|
|||
7919b34d2b
|
|||
c5a8581a80
|
|||
e031e143c2
|
|||
3964aaf595
|
|||
202f979403
|
|||
cf561c4584
|
|||
e2679cf5e8
|
|||
122edeef48
|
|||
4ff9f44eae
|
|||
5d13d9bc16
|
|||
121e1da37d
|
|||
8222f3b781
|
|||
dc56396012
|
|||
f1d2acdc25
|
|||
50e95ad3f2
|
|||
7848a90d5d
|
|||
f08cb229ca
|
|||
b0fbb406f6
|
|||
0f2f34175c
|
|||
6226f06d97
|
|||
a853be73c5
|
|||
93a2e2436d
|
|||
2f4755ffc7
|
|||
230dc545f4
|
|||
20daecf619
|
|||
3333add7e0
|
|||
777ae059f9
|
|||
310ac70a74
|
|||
29074c4bfd
|
|||
9bc0e99d6d
|
|||
b38302449c
|
|||
feee5069b1
|
|||
6b962a74b3
|
|||
0c80385958
|
|||
8c41684993
|
|||
8245ba0063
|
|||
0e7a275a28
|
|||
59268f2d1e
|
|||
2ad7799b38
|
|||
3b7f2130f3
|
|||
d75c800275
|
|||
41e69992c0
|
|||
43af14ad77
|
|||
acf906b284
|
|||
80f0baac1e
|
|||
3d7a39a593
|
|||
a240d7cad5
|
|||
b40dce27df
|
|||
9734b51f53
|
|||
80cfe874f5
|
|||
bcf4e294e0
|
|||
a27a115d66
|
|||
6ac36fdb69
|
|||
505a94e3aa
|
|||
b921ca045e
|
|||
a382e089ae
|
|||
9eed5ca2a0
|
|||
cbf34fe90e
|
|||
7dc812984b
|
|||
1ed4e9c17a
|
|||
5f09c35dee
|
|||
ae62e3daf7
|
|||
8778f58fe4
|
|||
751e35ac62
|
|||
f41b2e16ab
|
|||
1f6ce072bf
|
|||
746aae464a
|
|||
7e212d011e
|
|||
2840a15fd5
|
|||
c1482d4802
|
|||
16c4376941
|
|||
dfc45dbc93
|
|||
31f5373652
|
|||
ca7cf5987c
|
|||
34390a541a
|
|||
b8b4891e9b
|
|||
9cfab53bd2
|
|||
82cda0b279
|
|||
4357d51b9a
|
|||
90bfc45858
|
|||
bb9f0dab22
|
|||
b0a248e81a
|
|||
b3c26b8c1c
|
|||
073d761a03
|
|||
bd31375bf3
|
|||
7605b9cc00
|
|||
0fa76d6f25
|
|||
14505260ff
|
|||
cf8892ee1a
|
|||
7f7d921c53
|
|||
8668430760
|
|||
45818eae24
|
|||
b154c4985d
|
|||
ac039c1073
|
|||
3717cd8b3f
|
|||
7855ec2225
|
|||
fbaca32615
|
|||
5b1374bf1b
|
|||
18bd2c7c18
|
|||
a4c7951475
|
|||
c299ff6634
|
|||
7d8975339e
|
|||
1bd9cea458
|
|||
b838f1b3f0
|
|||
e95d511017
|
|||
942c96dbfa
|
|||
3cd40ee192
|
|||
cebe977d49
|
|||
e90005b192
|
|||
6b5c630048
|
|||
c9fcfcf498
|
|||
dec9f9be11
|
|||
f85a563cf3
|
|||
5399a875c6
|
|||
eb8ad4e771
|
|||
93a71fb561
|
|||
bde3758c50
|
|||
88823b5252
|
|||
9aa19ad3ca
|
|||
ad4593a2f6
|
|||
849194414d
|
|||
b9ce4c737c
|
|||
30efff0d9d
|
|||
7364d27b4b
|
|||
19f41152ee
|
|||
f3d611913e
|
|||
1d81213773
|
|||
2a545dae10
|
|||
fc6e2593b4
|
|||
ce25341496
|
|||
57bddc5628
|
|||
d7b293dc87
|
|||
ff414ea046
|
|||
91d39b44a2
|
|||
d3631877c4
|
|||
502b066311
|
|||
3efe5a2226
|
|||
a2201e36fa
|
|||
69b94c9493
|
|||
a8f24b6581
|
|||
e156ed6111
|
|||
ea00657405
|
|||
5abca36498
|
|||
731dfc049f
|
|||
4075f6cf78
|
|||
0f2c44331c
|
|||
fae4ee7105
|
|||
600ebd087e
|
|||
4a39d206d5
|
|||
2faade0156
|
|||
e17273391d
|
|||
0e7be7e27c | |||
b95b41a2ed
|
|||
444bea2440
|
|||
7bb4e2c8eb
|
|||
0f176ea4c6
|
|||
63a10c1be5
|
|||
f7eddd289b
|
|||
6b4553b76b
|
|||
ccfd2c155b | |||
814cb10439 | |||
df8f6cff2b | |||
7f8934a647 | |||
815206a0a5 | |||
8350960d5f
|
|||
968162f34e
|
|||
e848855072
|
|||
50409931cf
|
|||
d18f76cf80
|
|||
5f2cd16071
|
|||
c686584e74
|
|||
3a650a1e89
|
|||
51beb47191
|
|||
e3f5541774
|
|||
14de6cf824
|
|||
3e46d06817
|
|||
0fd9222055
|
|||
b67308065a
|
|||
644afc6a0d
|
|||
1ef981571d
|
|||
30a8676555
|
|||
cdf279bb02
|
|||
7515c2bec6
|
|||
cce5e7c33c
|
|||
f9e85dd63e
|
|||
cb86fd43ac
|
|||
be0662420d
|
|||
da1d7a83fa
|
|||
d37354dc24
|
|||
d210b2a221
|
|||
e9958faace
|
|||
ab1f4c2eba
|
|||
1ba5cfa3f8
|
|||
e9cfae99da
|
|||
700df123b7
|
|||
582a634da7
|
|||
837800345b
|
|||
384fbfd0b2
|
|||
d8f2e56d45
|
|||
ba6a6338f5
|
|||
9a1006b341
|
|||
e21c3bb413
|
|||
afde1d35d5
|
|||
9e885153c2
|
|||
ffaa6e8116
|
|||
9797268736
|
|||
fb4edccc40
|
|||
f8297eebe1
|
|||
e41ad64b54
|
|||
13c4c834d4
|
|||
d6aa285bc5
|
|||
bbd8ad43cd
|
|||
ef8d124ade
|
|||
bb01e1b0b5
|
|||
f9af52ce6a
|
|||
ef2911ab07
|
|||
3bd6d2e647
|
|||
9d741d76f2
|
|||
de504a1706
|
|||
30a0e63eb9
|
|||
de76abab5f
|
|||
833249191c
|
|||
0a99f10899
|
|||
5101746d29
|
|||
aa69e6eadb
|
|||
7dd85d7402
|
|||
6b2ca1d2e1
|
|||
fbedb941be
|
|||
46e75c7ae8
|
|||
d26dee3bcf
|
|||
4084f7abb5
|
|||
d4c7b39f46
|
|||
0576f3e32b
|
|||
d093414ec7
|
|||
cba4a01117
|
|||
fde2fdba63
|
|||
aff1bbda0b
|
|||
4f9dfadb71
|
|||
1df1766753
|
|||
9359aa7606
|
|||
a45d57e51a
|
|||
35863c4bda
|
|||
13414ee0c5
|
|||
cdacbe2ea1
|
|||
69325bff9a
|
|||
049234caae
|
|||
f8d38738ea
|
|||
f7d52aa6da
|
|||
99a2134a57
|
|||
8fc99803c1
|
|||
7984ce8e1d
|
|||
3f46e23588
|
|||
a7665d41b7
|
|||
6c064d6570
|
|||
140048bcdb | |||
73cadd8cfd | |||
7a0cb64fb6
|
|||
c9067d5202
|
|||
200848816d
|
|||
2b02c250a2
|
|||
c32f9d2b17
|
|||
36d8d993e3
|
|||
0d758d2b08
|
|||
adb64dec51
|
|||
12acb0ca26 | |||
d9fbd5564e
|
|||
a846750911 | |||
7d9e80bf9f
|
|||
a8a69c766c | |||
f4e0d0a95e
|
|||
9c4e68d0ea | |||
2367131316
|
|||
67540df334
|
|||
a6000aec2a
|
|||
e2d5a55173 | |||
55c3a5fcc8
|
|||
d4111126c7
|
|||
8212568fee
|
|||
6898e9413a
|
|||
1b117e9289 | |||
c500a735d8
|
|||
f53f9fbc6c
|
|||
629c4d2367 | |||
ab1c5a276a
|
|||
2bd6988c6a
|
|||
f83b4c094e | |||
4dd3c105fe
|
|||
a0266c691b
|
|||
b5136ffa91
|
|||
d9a2b31606
|
|||
01a6e28623
|
|||
8162a48754 | |||
ea38c06631
|
|||
68a5467a35 | |||
0cd7ff512f
|
|||
a9f3cb7d3a
|
|||
f36c36b96e
|
|||
b222a71d45
|
|||
756f94cbd9
|
|||
4c476a50ea | |||
ea9d7cdd50
|
|||
641e53e617 | |||
c06ae694cd
|
|||
1d25f7f824
|
|||
ce206998f0
|
|||
628f69e772
|
|||
74c0260593
|
|||
384de5758b
|
|||
48107943f9
|
|||
5d524b263b
|
|||
75db278a97 | |||
0da0165ce2
|
|||
214d422ee2
|
|||
1677731b4a | |||
7a4cc8843f
|
|||
9e559db4b4
|
|||
fd4280426b
|
|||
1a63c1f399
|
|||
b40c06fe9e
|
|||
416135ca3a
|
|||
497b3ad8aa
|
|||
72fe279f15
|
|||
ae520f791c
|
|||
35042f077f
|
|||
56ad352e64
|
|||
97761e07a9
|
|||
fd587099cb
|
|||
ddaf5e82bd
|
|||
01e6ab2279
|
|||
e4fa6c0321
|
|||
4821b090ae
|
|||
0522db0f63
|
|||
3e0e6ae7b4
|
|||
d5e7295981
|
|||
8515153be7
|
|||
5a865efd18
|
|||
a55eea7c10
|
|||
cb5f597547
|
|||
96adb01edb
|
|||
40fd5a56c1
|
|||
00c936f909
|
|||
0346df11c2
|
|||
d02db9b858
|
|||
ef4d74545a
|
|||
d05a8339fe
|
|||
38dc00b2c9
|
|||
4cd1e43564
|
|||
53a55ee898
|
|||
d5ba7a08a9
|
|||
b0e43959eb
|
|||
70d2ade6a3
|
|||
364025b195
|
|||
e0f230b8c7
|
|||
47b14c3e47
|
|||
0607398491
|
|||
a454441097
|
|||
b4da740fb6
|
|||
64e2d8d264
|
|||
46ba112612
|
|||
392ab86123
|
|||
7decc18ad5
|
|||
daac77ba57
|
|||
9b5ad96aaa
|
|||
c151ff3611
|
|||
1e413229a1
|
|||
71169048fb
|
|||
3e7ff21746
|
|||
1a7a411e10
|
|||
7397afd236
|
|||
61703b130d
|
|||
a97541064e
|
|||
ef785a5eb8
|
|||
be8904079d
|
|||
c8780a6d9d
|
|||
6f26b24359
|
|||
d912c8aab4
|
|||
f3f862c1ab
|
|||
7a6aaa3f58
|
|||
4d83664c0d
|
|||
4faec03efb
|
|||
170326d503
|
|||
d75ba1f890
|
|||
e51674e76c
|
|||
ead59e28b8
|
|||
2ca0444053
|
|||
09e5a72470
|
|||
b4e7ec6550
|
|||
6bcb050754
|
|||
1805f48fa0
|
|||
9473e101b8
|
|||
0f65bc4561
|
|||
bf6f87ee89
|
|||
52f0d442cd
|
|||
4e29b4830a
|
|||
03144ae58e
|
|||
e2e2c97584
|
|||
6611c1c896
|
|||
e3a32a41f9
|
|||
72753edf64
|
|||
95fec7c0da
|
|||
c86ff67884
|
|||
b6c2a43a1b
|
|||
997b21e26a
|
|||
f1dbdde78d
|
|||
63f139be45
|
|||
0079b6d96d
|
|||
67b01ea0b3
|
|||
7ef602c6cd
|
|||
f5ec9d1054
|
|||
ad1337209d
|
|||
18b4fa81d3
|
|||
d9a85948b3
|
|||
03eca29316
|
|||
3d9bd88a41
|
|||
30fa8b7840 | |||
83d396a6dc | |||
f6c209df03 | |||
d414f1a920 | |||
b7cb5aa776 | |||
f2b498c352 | |||
dac4460c68 | |||
067a266997 | |||
2ecb13a68a | |||
1ae6049974 | |||
db2ee8f78c | |||
c7f753cf09 | |||
c30a0cdf86 | |||
dd62a32e08 | |||
a1d02ce657 | |||
3f83fd6ef3 | |||
5ec2cf5acd | |||
dd4171b0e8 | |||
1ef48fc3b4 | |||
6d01298e24 | |||
4a7d3c5604 | |||
3d9e7136ac | |||
522ed088ef | |||
132481fda0 | |||
a064cc1817 | |||
85f16ebd07 | |||
a322ce4dfb | |||
50aec3c105 | |||
a86bc3f124 | |||
45426e6835 | |||
88dcb68aa8 | |||
d4fa8d9054 | |||
0018ce05ec | |||
0dab65d82b | |||
2c3e3ffcba | |||
826e7f7c04 | |||
59985f8fc8 | |||
61d5af0651 | |||
2ee1c75d0c | |||
c64ef0646e | |||
cd584f8bb6 | |||
b16e40e15b | |||
f8f3e7b41a | |||
939536a567 | |||
48de59f630 | |||
94f907abf2 | |||
6fe398d965 | |||
cd70de049a | |||
7096f6fee1 | |||
eee1e9d68a | |||
a36a4cc728 | |||
9213258df4 | |||
1909dd3835 | |||
7d6e899f76 | |||
b9299a31d0 | |||
9e7a7308be | |||
95ab142702 | |||
606ad5886f | |||
b86675ba98 | |||
4b6d6f24ea | |||
64ffcedbcc | |||
d81ad02235 | |||
69c453c408 | |||
1c6c480d4c | |||
da8efde057 | |||
e9f10ca14f | |||
7db606e6eb | |||
a368dfbead | |||
7c935d067c | |||
bf83e6534d | |||
cca62a99d0 | |||
44e3e3f639 | |||
f5e73ae2ed | |||
98fb682c66 | |||
fffdaabe7c | |||
fac2b29f4a | |||
57d2bb9bec | |||
8eef72b104 | |||
49a2fbe83e | |||
eb2fb734c6 | |||
5004e1fb14 | |||
d691b3c849 | |||
190039a5e8 | |||
fbabdff69c | |||
722fad4e6f | |||
60344b896a | |||
a4be91c8ee | |||
1bf0316f2b | |||
08d0726af4 | |||
f4fd072c0f | |||
5771a15a32 | |||
bd73e82cb0 | |||
ca1a9e4415 | |||
228c683dc8 | |||
a25ec69ae9 | |||
3cc66ef783 | |||
5a93a0a754 | |||
44e91a1f8b | |||
8606ae7b95 | |||
0f0c082437 | |||
945e1105b8 | |||
70cece8694 | |||
e5e197dd38 | |||
7266fe8e24 | |||
b8dfe1a607 | |||
c6045a122f | |||
25a31b7f40 | |||
cb760cb059 | |||
4d3f6d1847 | |||
ae648d7615 | |||
977f22af27 | |||
fd861ca8c9 | |||
bffaf4b360 | |||
b5d567e364 | |||
a1ef162bdb | |||
a1b4c42707 | |||
3018d4c849 | |||
8e2ad0d15b | |||
9c62d676e9 | |||
d2a0d0fbec | |||
39abeec4e6 | |||
10da20f2c0 | |||
2ce1f83873 | |||
8590d8f730 | |||
683b8c71b7 | |||
946d261c71 | |||
273bd05944 | |||
35aed16e10 | |||
fc6f039212 | |||
885723af5f | |||
864d94c51d | |||
c2eba2bb2e | |||
7adba3f047 | |||
4f4a3aaf4d | |||
7b678e4683 | |||
e9579e7e94 | |||
0b1c3cb86e | |||
c3de4a9914 | |||
8b90877088 | |||
7a81d09b88 | |||
2272a8b45d | |||
369fb4fd5b | |||
1f186b43f7 | |||
4604ddd758 | |||
311cb66cdd |
6
.bashrc
Normal file
@ -0,0 +1,6 @@
|
||||
PS1='\[\033[01;31m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '
|
||||
|
||||
alias ls='ls --color=auto'
|
||||
alias ll='ls -l'
|
||||
alias la='ls -A'
|
||||
alias l='ls -lACF'
|
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
||||
__pycache__
|
||||
media
|
||||
db.sqlite3
|
24
.gitignore
vendored
@ -1,6 +1,3 @@
|
||||
# Server config files
|
||||
nginx_note.conf
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
dist
|
||||
build
|
||||
@ -18,16 +15,6 @@ coverage
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# PyCharm project settings
|
||||
.idea
|
||||
|
||||
@ -35,16 +22,13 @@ coverage
|
||||
.vscode
|
||||
|
||||
# Local data
|
||||
secrets.py
|
||||
settings_local.py
|
||||
*.log
|
||||
media/
|
||||
output/
|
||||
/static/
|
||||
|
||||
# Virtualenv
|
||||
env/
|
||||
venv/
|
||||
db.sqlite3
|
||||
|
||||
# Ignore migrations during first phase dev
|
||||
migrations/
|
||||
|
||||
# Don't git personal data
|
||||
import_olddb/
|
||||
|
29
.gitlab-ci.yml
Normal file
@ -0,0 +1,29 @@
|
||||
stages:
|
||||
- test
|
||||
- quality-assurance
|
||||
|
||||
py312:
|
||||
stage: test
|
||||
image: python:3.12-alpine
|
||||
before_script:
|
||||
- apk add --no-cache libmagic
|
||||
- apk add --no-cache gettext
|
||||
- pip install tox --no-cache-dir
|
||||
script: tox -e py312
|
||||
|
||||
py313:
|
||||
stage: test
|
||||
image: python:3.13-alpine
|
||||
before_script:
|
||||
- apk add --no-cache libmagic
|
||||
- apk add --no-cache gettext
|
||||
- pip install tox --no-cache-dir
|
||||
script: tox -e py313
|
||||
|
||||
linters:
|
||||
stage: quality-assurance
|
||||
image: python:3-alpine
|
||||
before_script:
|
||||
- pip install tox --no-cache-dir
|
||||
script: tox -e linters
|
||||
allow_failure: true
|
40
Dockerfile
@ -1,18 +1,40 @@
|
||||
FROM python:3-buster
|
||||
FROM python:3.13-alpine
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV DJANGO_ALLOW_ASYNC_UNSAFE 1
|
||||
|
||||
RUN mkdir /code
|
||||
RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libpq-dev libxml2-dev libxslt-dev \
|
||||
npm libmagic texlive texmf-dist-fontsrecommended texmf-dist-lang texmf-dist-latexextra
|
||||
|
||||
RUN apk add --no-cache bash
|
||||
|
||||
RUN npm install -g yuglify
|
||||
|
||||
RUN mkdir /code /code/docs
|
||||
WORKDIR /code
|
||||
|
||||
# Install LaTeX requirements
|
||||
RUN apt update && \
|
||||
apt install -y gettext texlive-latex-extra texlive-fonts-extra texlive-lang-french && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt /code/requirements.txt
|
||||
COPY docs/requirements.txt /code/docs/requirements.txt
|
||||
RUN pip install -r requirements.txt --no-cache-dir
|
||||
RUN pip install -r docs/requirements.txt --no-cache-dir
|
||||
|
||||
COPY . /code/
|
||||
|
||||
RUN pip install -r requirements.txt
|
||||
# Compile documentation
|
||||
RUN sphinx-build -M html docs docs/_build
|
||||
|
||||
RUN python manage.py collectstatic --noinput && \
|
||||
python manage.py compilemessages
|
||||
|
||||
# Configure nginx
|
||||
RUN ln -sf /dev/stdout /var/log/nginx/access.log && ln -sf /dev/stderr /var/log/nginx/error.log
|
||||
RUN ln -sf /code/nginx_tfjm.conf /etc/nginx/http.d/tfjm.conf && rm /etc/nginx/http.d/default.conf
|
||||
|
||||
RUN crontab /code/tfjm.cron
|
||||
|
||||
# With a bashrc, the shell is better
|
||||
RUN ln -s /code/.bashrc /root/.bashrc
|
||||
|
||||
ENTRYPOINT ["/code/entrypoint.sh"]
|
||||
EXPOSE 8000
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["./manage.py", "shell_plus", "--ipython"]
|
||||
|
674
LICENSE
Normal file
@ -0,0 +1,674 @@
|
||||
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) 2020 Animath
|
||||
|
||||
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) 2020 Animath
|
||||
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>.
|
69
README.md
Normal file
@ -0,0 +1,69 @@
|
||||
# Plateforme du TFJM²
|
||||
|
||||
[](https://gitlab.com/animath/si/plateforme-tfjm/-/commits/master)
|
||||
[](https://gitlab.com/animath/si/plateforme-tfjm/-/commits/master)
|
||||
|
||||
La plateforme du TFJM² est née pour la dixième édition en 2019 de l'action.
|
||||
D'abord codée en PHP, elle a subi une refonte totale en Python, à l'aide du framework Web [Django](https://www.djangoproject.com/).
|
||||
|
||||
Cette plateforme permet aux participants et encadrants de s'inscrire et de déposer leurs autorisations nécessaires.
|
||||
Ils pourront ensuite déposer leurs solutions et notes de synthèse pour le premier tour en temps voulu. La plateforme
|
||||
offre également un accès pour les organisateurs et les jurys leur permettant de communiquer avec les équipes et de
|
||||
récupérer les documents nécessaires.
|
||||
|
||||
Un wiki plus détaillé arrivera ultérieurement. L'interface organisateur et jury est vouée à être plus poussée.
|
||||
|
||||
L'instance de production est disponible à l'adresse [inscription.tfjm.org](https://inscription.tfjm.org).
|
||||
|
||||
## Installation
|
||||
|
||||
Le plus simple pour installer la plateforme est d'utiliser l'image Docker incluse, qui fait tourner un serveur Nginx
|
||||
exposé sur le port 80 avec le serveur Django. Ci-dessous une configuration Docker-Compose, à adapter selon vos besoins :
|
||||
|
||||
```yaml
|
||||
plateforme-tfjm:
|
||||
build: https://gitlab.com/animath/si/plateforme-tfjm.git
|
||||
links:
|
||||
- postgres
|
||||
ports:
|
||||
- "80:80"
|
||||
env_file:
|
||||
- ./inscription-tfjm.env
|
||||
volumes:
|
||||
# - ./inscription-tfjm:/code
|
||||
- ./inscription-tfjm/media:/code/media
|
||||
```
|
||||
|
||||
Le volume `/code` n'est à ajouter uniquement en développement, et jamais en production.
|
||||
|
||||
Il faut remplir les variables d'environnement suivantes :
|
||||
|
||||
```env
|
||||
TFJM_STAGE= # dev ou prod
|
||||
DJANGO_DB_TYPE= # MySQL, PostgreSQL ou SQLite (par défaut)
|
||||
DJANGO_DB_HOST= # Hôte de la base de données
|
||||
DJANGO_DB_NAME= # Nom de la base de données
|
||||
DJANGO_DB_USER= # Utilisateur de la base de données
|
||||
DJANGO_DB_PASSWORD= # Mot de passe pour accéder à la base de données
|
||||
SMTP_HOST= # Hôte SMTP pour l'envoi de mails
|
||||
SMTP_PORT=465 # Port du serveur SMTP
|
||||
SMTP_HOST_USER= # Utilisateur du compte SMTP
|
||||
SMTP_HOST_PASSWORD= # Mot de passe du compte SMTP
|
||||
FROM_EMAIL=contact@tfjm.org # Nom de l'expéditeur des mails
|
||||
SERVER_EMAIL=contact@tfjm.org # Adresse e-mail expéditrice
|
||||
SYMPA_URL=lists.example.com # Serveur Sympa à utiliser
|
||||
SYMPA_EMAIL= # Adresse e-mail du compte administrateur de Sympa
|
||||
SYMPA_PASSWORD= # Mot de passe du compte administrateur de Sympa
|
||||
```
|
||||
|
||||
Si le type de base de données sélectionné est SQLite, la variable `DJANGO_DB_HOST` sera utilisée en guise de chemin vers
|
||||
le fichier de base de données (par défaut, `db.sqlite3`).
|
||||
|
||||
En développement, il est recommandé d'utiliser SQLite pour des raisons de simplicité. Les paramètres de mail ne seront
|
||||
pas utilisés, et les mails qui doivent être envoyés seront envoyés dans la console. L'intégration mail
|
||||
seront également désactivées.
|
||||
|
||||
En production, il est recommandé de ne pas utiliser SQLite pour des raisons de performances.
|
||||
|
||||
La dernière différence entre le développment et la production est qu'en développement, chaque modification d'un fichier
|
||||
est détectée et le serveur se relance automatiquement dès lors.
|
4
api/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
# Copyright (C) 2020 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
default_app_config = 'api.apps.APIConfig'
|
@ -1,3 +1,6 @@
|
||||
# Copyright (C) 2020 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
19
api/serializers.py
Normal file
@ -0,0 +1,19 @@
|
||||
# Copyright (C) 2020 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serialize a User object into JSON.
|
||||
"""
|
||||
class Meta:
|
||||
model = User
|
||||
exclude = (
|
||||
'username',
|
||||
'password',
|
||||
'groups',
|
||||
'user_permissions',
|
||||
)
|
27
api/tests.py
Normal file
@ -0,0 +1,27 @@
|
||||
# Copyright (C) 2020 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from unittest.case import skipIf
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
class TestAPIPages(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_superuser(
|
||||
username="admin",
|
||||
password="apitest",
|
||||
email="",
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_user_page(self):
|
||||
response = self.client.get("/api/user/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@skipIf("logs" not in settings.INSTALLED_APPS, reason="logs app is not used")
|
||||
def test_logs_page(self):
|
||||
response = self.client.get("/api/logs/")
|
||||
self.assertEqual(response.status_code, 200)
|
34
api/urls.py
Normal file
@ -0,0 +1,34 @@
|
||||
# Copyright (C) 2020 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import include, path
|
||||
from rest_framework import routers
|
||||
|
||||
from .viewsets import UserViewSet
|
||||
|
||||
# Routers provide an easy way of automatically determining the URL conf.
|
||||
# Register each app API router and user viewset
|
||||
router = routers.DefaultRouter()
|
||||
router.register('user', UserViewSet)
|
||||
|
||||
if "logs" in settings.INSTALLED_APPS:
|
||||
from logs.api.urls import register_logs_urls
|
||||
register_logs_urls(router, "logs")
|
||||
|
||||
if "participation" in settings.INSTALLED_APPS:
|
||||
from participation.api.urls import register_participation_urls
|
||||
register_participation_urls(router, "participation")
|
||||
|
||||
if "registration" in settings.INSTALLED_APPS:
|
||||
from registration.api.urls import register_registration_urls
|
||||
register_registration_urls(router, "registration")
|
||||
|
||||
app_name = 'api'
|
||||
|
||||
# Wire up our API using automatic URL routing.
|
||||
# Additionally, we include login URLs for the browsable API.
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||
]
|
20
api/viewsets.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Copyright (C) 2020 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.filters import SearchFilter
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from .serializers import UserSerializer
|
||||
|
||||
|
||||
class UserViewSet(ModelViewSet):
|
||||
"""
|
||||
Display list of users.
|
||||
"""
|
||||
queryset = User.objects.order_by("id").all()
|
||||
serializer_class = UserSerializer
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
filterset_fields = ['id', 'first_name', 'last_name', 'email', 'is_superuser', 'is_staff', 'is_active', ]
|
||||
search_fields = ['$first_name', '$last_name', ]
|
@ -1 +0,0 @@
|
||||
default_app_config = 'api.apps.APIConfig'
|
@ -1,80 +0,0 @@
|
||||
from rest_framework import serializers
|
||||
from member.models import TFJMUser, Authorization, MotivationLetter, Solution, Synthesis
|
||||
from tournament.models import Team, Tournament, Pool
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serialize a User object into JSON.
|
||||
"""
|
||||
class Meta:
|
||||
model = TFJMUser
|
||||
exclude = (
|
||||
'username',
|
||||
'password',
|
||||
'groups',
|
||||
'user_permissions',
|
||||
)
|
||||
|
||||
|
||||
class TeamSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serialize a Team object into JSON.
|
||||
"""
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class TournamentSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serialize a Tournament object into JSON.
|
||||
"""
|
||||
class Meta:
|
||||
model = Tournament
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class AuthorizationSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serialize an Authorization object into JSON.
|
||||
"""
|
||||
class Meta:
|
||||
model = Authorization
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class MotivationLetterSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serialize a MotivationLetter object into JSON.
|
||||
"""
|
||||
class Meta:
|
||||
model = MotivationLetter
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class SolutionSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serialize a Solution object into JSON.
|
||||
"""
|
||||
class Meta:
|
||||
model = Solution
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class SynthesisSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serialize a Synthesis object into JSON.
|
||||
"""
|
||||
class Meta:
|
||||
model = Synthesis
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class PoolSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serialize a Pool object into JSON.
|
||||
"""
|
||||
class Meta:
|
||||
model = Pool
|
||||
fields = "__all__"
|
@ -1,26 +0,0 @@
|
||||
from django.conf.urls import url, include
|
||||
from rest_framework import routers
|
||||
|
||||
from .viewsets import UserViewSet, TeamViewSet, TournamentViewSet, AuthorizationViewSet, MotivationLetterViewSet, \
|
||||
SolutionViewSet, SynthesisViewSet, PoolViewSet
|
||||
|
||||
# Routers provide an easy way of automatically determining the URL conf.
|
||||
# Register each app API router and user viewset
|
||||
router = routers.DefaultRouter()
|
||||
router.register('user', UserViewSet)
|
||||
router.register('team', TeamViewSet)
|
||||
router.register('tournament', TournamentViewSet)
|
||||
router.register('authorization', AuthorizationViewSet)
|
||||
router.register('motivation_letter', MotivationLetterViewSet)
|
||||
router.register('solution', SolutionViewSet)
|
||||
router.register('synthesis', SynthesisViewSet)
|
||||
router.register('pool', PoolViewSet)
|
||||
|
||||
app_name = 'api'
|
||||
|
||||
# Wire up our API using automatic URL routing.
|
||||
# Additionally, we include login URLs for the browsable API.
|
||||
urlpatterns = [
|
||||
url('^', include(router.urls)),
|
||||
url('^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||
]
|
@ -1,124 +0,0 @@
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import status
|
||||
from rest_framework.filters import SearchFilter
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from member.models import TFJMUser, Authorization, MotivationLetter, Solution, Synthesis
|
||||
from tournament.models import Team, Tournament, Pool
|
||||
|
||||
from .serializers import UserSerializer, TeamSerializer, TournamentSerializer, AuthorizationSerializer, \
|
||||
MotivationLetterSerializer, SolutionSerializer, SynthesisSerializer, PoolSerializer
|
||||
|
||||
|
||||
class UserViewSet(ModelViewSet):
|
||||
"""
|
||||
Display list of users.
|
||||
"""
|
||||
queryset = TFJMUser.objects.all()
|
||||
serializer_class = UserSerializer
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
filterset_fields = ['id', 'first_name', 'last_name', 'email', 'gender', 'student_class', 'role', 'year', 'team',
|
||||
'team__trigram', 'is_superuser', 'is_staff', 'is_active', ]
|
||||
search_fields = ['$first_name', '$last_name', ]
|
||||
|
||||
|
||||
class TeamViewSet(ModelViewSet):
|
||||
"""
|
||||
Display list of teams.
|
||||
"""
|
||||
queryset = Team.objects.all()
|
||||
serializer_class = TeamSerializer
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
filterset_fields = ['name', 'trigram', 'validation_status', 'selected_for_final', 'access_code', 'tournament',
|
||||
'year', ]
|
||||
search_fields = ['$name', 'trigram', ]
|
||||
|
||||
|
||||
class TournamentViewSet(ModelViewSet):
|
||||
"""
|
||||
Display list of tournaments.
|
||||
"""
|
||||
queryset = Tournament.objects.all()
|
||||
serializer_class = TournamentSerializer
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter]
|
||||
filterset_fields = ['name', 'size', 'price', 'date_start', 'date_end', 'final', 'organizers', 'year', ]
|
||||
search_fields = ['$name', ]
|
||||
|
||||
|
||||
class AuthorizationViewSet(ModelViewSet):
|
||||
"""
|
||||
Display list of authorizations.
|
||||
"""
|
||||
queryset = Authorization.objects.all()
|
||||
serializer_class = AuthorizationSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = ['user', 'type', ]
|
||||
|
||||
|
||||
class MotivationLetterViewSet(ModelViewSet):
|
||||
"""
|
||||
Display list of motivation letters.
|
||||
"""
|
||||
queryset = MotivationLetter.objects.all()
|
||||
serializer_class = MotivationLetterSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = ['team', 'team__trigram', ]
|
||||
|
||||
|
||||
class SolutionViewSet(ModelViewSet):
|
||||
"""
|
||||
Display list of solutions.
|
||||
"""
|
||||
queryset = Solution.objects.all()
|
||||
serializer_class = SolutionSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = ['team', 'team__trigram', 'problem', ]
|
||||
|
||||
|
||||
class SynthesisViewSet(ModelViewSet):
|
||||
"""
|
||||
Display list of syntheses.
|
||||
"""
|
||||
queryset = Synthesis.objects.all()
|
||||
serializer_class = SynthesisSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = ['team', 'team__trigram', 'source', 'round', ]
|
||||
|
||||
|
||||
class PoolViewSet(ModelViewSet):
|
||||
"""
|
||||
Display list of pools.
|
||||
If the request is a POST request and the format is "A;X;x;Y;y;Z;z;..." where A = 1 or 1 = 2,
|
||||
X, Y, Z, ... are team trigrams, x, y, z, ... are numbers of problems, then this is interpreted as a
|
||||
creation a pool for the round A with the solutions of problems x, y, z, ... of the teams X, Y, Z, ... respectively.
|
||||
"""
|
||||
queryset = Pool.objects.all()
|
||||
serializer_class = PoolSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_fields = ['teams', 'teams__trigram', 'round', ]
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
data = request.data
|
||||
try:
|
||||
spl = data.split(";")
|
||||
if len(spl) >= 7:
|
||||
round = int(spl[0])
|
||||
teams = []
|
||||
solutions = []
|
||||
for i in range((len(spl) - 1) // 2):
|
||||
trigram = spl[1 + 2 * i]
|
||||
pb = int(spl[2 + 2 * i])
|
||||
team = Team.objects.get(trigram=trigram)
|
||||
solution = Solution.objects.get(team=team, problem=pb, final=team.selected_for_final)
|
||||
teams.append(team)
|
||||
solutions.append(solution)
|
||||
pool = Pool.objects.create(round=round)
|
||||
pool.teams.set(teams)
|
||||
pool.solutions.set(solutions)
|
||||
pool.save()
|
||||
serializer = PoolSerializer(pool)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
except BaseException: # JSON data
|
||||
pass
|
||||
return super().create(request, *args, **kwargs)
|
@ -1 +0,0 @@
|
||||
default_app_config = 'member.apps.MemberConfig'
|
@ -1,56 +0,0 @@
|
||||
from django.contrib.auth.admin import admin
|
||||
from polymorphic.admin import PolymorphicParentModelAdmin, PolymorphicChildModelAdmin
|
||||
from member.models import TFJMUser, Document, Solution, Synthesis, MotivationLetter, Authorization, Config
|
||||
|
||||
|
||||
@admin.register(TFJMUser)
|
||||
class TFJMUserAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Django admin page for users.
|
||||
"""
|
||||
list_display = ('email', 'first_name', 'last_name', 'role', )
|
||||
search_fields = ('last_name', 'first_name',)
|
||||
|
||||
|
||||
@admin.register(Document)
|
||||
class DocumentAdmin(PolymorphicParentModelAdmin):
|
||||
"""
|
||||
Django admin page for any documents.
|
||||
"""
|
||||
child_models = (Authorization, MotivationLetter, Solution, Synthesis,)
|
||||
polymorphic_list = True
|
||||
|
||||
|
||||
@admin.register(Authorization)
|
||||
class AuthorizationAdmin(PolymorphicChildModelAdmin):
|
||||
"""
|
||||
Django admin page for Authorization.
|
||||
"""
|
||||
|
||||
|
||||
@admin.register(MotivationLetter)
|
||||
class MotivationLetterAdmin(PolymorphicChildModelAdmin):
|
||||
"""
|
||||
Django admin page for Motivation letters.
|
||||
"""
|
||||
|
||||
|
||||
@admin.register(Solution)
|
||||
class SolutionAdmin(PolymorphicChildModelAdmin):
|
||||
"""
|
||||
Django admin page for solutions.
|
||||
"""
|
||||
|
||||
|
||||
@admin.register(Synthesis)
|
||||
class SynthesisAdmin(PolymorphicChildModelAdmin):
|
||||
"""
|
||||
Django admin page for syntheses.
|
||||
"""
|
||||
|
||||
|
||||
@admin.register(Config)
|
||||
class ConfigAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Django admin page for configurations.
|
||||
"""
|
@ -1,10 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class MemberConfig(AppConfig):
|
||||
"""
|
||||
The member app handles the information that concern a user, its documents, ...
|
||||
"""
|
||||
name = 'member'
|
||||
verbose_name = _('member')
|
@ -1,73 +0,0 @@
|
||||
from django.contrib.auth.forms import UserCreationForm
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import TFJMUser
|
||||
|
||||
|
||||
class SignUpForm(UserCreationForm):
|
||||
"""
|
||||
Coaches and participants register on the website through this form.
|
||||
TODO: Check if this form works, render it better
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["first_name"].required = True
|
||||
self.fields["last_name"].required = True
|
||||
self.fields["role"].choices = [
|
||||
('', _("Choose a role...")),
|
||||
('3participant', _("Participant")),
|
||||
('2coach', _("Coach")),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
model = TFJMUser
|
||||
fields = (
|
||||
'role',
|
||||
'email',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'birth_date',
|
||||
'gender',
|
||||
'address',
|
||||
'postal_code',
|
||||
'city',
|
||||
'country',
|
||||
'phone_number',
|
||||
'school',
|
||||
'student_class',
|
||||
'responsible_name',
|
||||
'responsible_phone',
|
||||
'responsible_email',
|
||||
'description',
|
||||
)
|
||||
|
||||
|
||||
class TFJMUserForm(forms.ModelForm):
|
||||
"""
|
||||
Form to update our own information when we are participant.
|
||||
"""
|
||||
class Meta:
|
||||
model = TFJMUser
|
||||
fields = ('last_name', 'first_name', 'email', 'phone_number', 'gender', 'birth_date', 'address', 'postal_code',
|
||||
'city', 'country', 'school', 'student_class', 'responsible_name', 'responsible_phone',
|
||||
'responsible_email',)
|
||||
|
||||
|
||||
class CoachUserForm(forms.ModelForm):
|
||||
"""
|
||||
Form to update our own information when we are coach.
|
||||
"""
|
||||
class Meta:
|
||||
model = TFJMUser
|
||||
fields = ('last_name', 'first_name', 'email', 'phone_number', 'gender', 'birth_date', 'address', 'postal_code',
|
||||
'city', 'country', 'description',)
|
||||
|
||||
|
||||
class AdminUserForm(forms.ModelForm):
|
||||
"""
|
||||
Form to update our own information when we are organizer or admin.
|
||||
"""
|
||||
class Meta:
|
||||
model = TFJMUser
|
||||
fields = ('last_name', 'first_name', 'email', 'phone_number', 'description',)
|
@ -1,32 +0,0 @@
|
||||
import os
|
||||
from datetime import date
|
||||
from getpass import getpass
|
||||
from django.core.management import BaseCommand
|
||||
from member.models import TFJMUser
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
Little script that generate a superuser.
|
||||
"""
|
||||
email = input("Email: ")
|
||||
password = "1"
|
||||
confirm_password = "2"
|
||||
while password != confirm_password:
|
||||
password = getpass("Password: ")
|
||||
confirm_password = getpass("Confirm password: ")
|
||||
if password != confirm_password:
|
||||
self.stderr.write(self.style.ERROR("Passwords don't match."))
|
||||
|
||||
user = TFJMUser.objects.create(
|
||||
email=email,
|
||||
password="",
|
||||
role="admin",
|
||||
year=os.getenv("TFJM_YEAR", date.today().year),
|
||||
is_active=True,
|
||||
is_staff=True,
|
||||
is_superuser=True,
|
||||
)
|
||||
user.set_password(password)
|
||||
user.save()
|
@ -1,309 +0,0 @@
|
||||
import os
|
||||
|
||||
from django.core.management import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from member.models import TFJMUser, Document, Solution, Synthesis, Authorization, MotivationLetter
|
||||
from tournament.models import Team, Tournament
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Import the old database.
|
||||
Tables must be found into the import_olddb folder, as CSV files.
|
||||
"""
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--tournaments', '-t', action="store", help="Import tournaments")
|
||||
parser.add_argument('--teams', '-T', action="store", help="Import teams")
|
||||
parser.add_argument('--users', '-u', action="store", help="Import users")
|
||||
parser.add_argument('--documents', '-d', action="store", help="Import all documents")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if "tournaments" in options:
|
||||
self.import_tournaments()
|
||||
|
||||
if "teams" in options:
|
||||
self.import_teams()
|
||||
|
||||
if "users" in options:
|
||||
self.import_users()
|
||||
|
||||
if "documents" in options:
|
||||
self.import_documents()
|
||||
|
||||
@transaction.atomic
|
||||
def import_tournaments(self):
|
||||
"""
|
||||
Import tournaments into the new database.
|
||||
"""
|
||||
print("Importing tournaments...")
|
||||
with open("import_olddb/tournaments.csv") as f:
|
||||
first_line = True
|
||||
for line in f:
|
||||
if first_line:
|
||||
first_line = False
|
||||
continue
|
||||
|
||||
line = line[:-1].replace("\"", "")
|
||||
args = line.split(";")
|
||||
args = [arg if arg and arg != "NULL" else None for arg in args]
|
||||
|
||||
if Tournament.objects.filter(pk=args[0]).exists():
|
||||
continue
|
||||
|
||||
obj_dict = {
|
||||
"id": args[0],
|
||||
"name": args[1],
|
||||
"size": args[2],
|
||||
"place": args[3],
|
||||
"price": args[4],
|
||||
"description": args[5],
|
||||
"date_start": args[6],
|
||||
"date_end": args[7],
|
||||
"date_inscription": args[8],
|
||||
"date_solutions": args[9],
|
||||
"date_syntheses": args[10],
|
||||
"date_solutions_2": args[11],
|
||||
"date_syntheses_2": args[12],
|
||||
"final": args[13],
|
||||
"year": args[14],
|
||||
}
|
||||
with transaction.atomic():
|
||||
Tournament.objects.create(**obj_dict)
|
||||
print(self.style.SUCCESS("Tournaments imported"))
|
||||
|
||||
@staticmethod
|
||||
def validation_status(status):
|
||||
if status == "NOT_READY":
|
||||
return "0invalid"
|
||||
elif status == "WAITING":
|
||||
return "1waiting"
|
||||
elif status == "VALIDATED":
|
||||
return "2valid"
|
||||
else:
|
||||
raise CommandError("Unknown status: {}".format(status))
|
||||
|
||||
@transaction.atomic
|
||||
def import_teams(self):
|
||||
"""
|
||||
Import teams into new database.
|
||||
"""
|
||||
self.stdout.write("Importing teams...")
|
||||
with open("import_olddb/teams.csv") as f:
|
||||
first_line = True
|
||||
for line in f:
|
||||
if first_line:
|
||||
first_line = False
|
||||
continue
|
||||
|
||||
line = line[:-1].replace("\"", "")
|
||||
args = line.split(";")
|
||||
args = [arg if arg and arg != "NULL" else None for arg in args]
|
||||
|
||||
if Team.objects.filter(pk=args[0]).exists():
|
||||
continue
|
||||
|
||||
obj_dict = {
|
||||
"id": args[0],
|
||||
"name": args[1],
|
||||
"trigram": args[2],
|
||||
"tournament": Tournament.objects.get(pk=args[3]),
|
||||
"inscription_date": args[13],
|
||||
"validation_status": Command.validation_status(args[14]),
|
||||
"selected_for_final": args[15],
|
||||
"access_code": args[16],
|
||||
"year": args[17],
|
||||
}
|
||||
with transaction.atomic():
|
||||
Team.objects.create(**obj_dict)
|
||||
print(self.style.SUCCESS("Teams imported"))
|
||||
|
||||
@staticmethod
|
||||
def role(role):
|
||||
if role == "ADMIN":
|
||||
return "0admin"
|
||||
elif role == "ORGANIZER":
|
||||
return "1volunteer"
|
||||
elif role == "ENCADRANT":
|
||||
return "2coach"
|
||||
elif role == "PARTICIPANT":
|
||||
return "3participant"
|
||||
else:
|
||||
raise CommandError("Unknown role: {}".format(role))
|
||||
|
||||
@transaction.atomic
|
||||
def import_users(self):
|
||||
"""
|
||||
Import users into the new database.
|
||||
:return:
|
||||
"""
|
||||
self.stdout.write("Importing users...")
|
||||
with open("import_olddb/users.csv") as f:
|
||||
first_line = True
|
||||
for line in f:
|
||||
if first_line:
|
||||
first_line = False
|
||||
continue
|
||||
|
||||
line = line[:-1].replace("\"", "")
|
||||
args = line.split(";")
|
||||
args = [arg if arg and arg != "NULL" else None for arg in args]
|
||||
|
||||
if TFJMUser.objects.filter(pk=args[0]).exists():
|
||||
continue
|
||||
|
||||
obj_dict = {
|
||||
"id": args[0],
|
||||
"email": args[1],
|
||||
"username": args[1],
|
||||
"password": "bcrypt$" + args[2],
|
||||
"last_name": args[3],
|
||||
"first_name": args[4],
|
||||
"birth_date": args[5],
|
||||
"gender": "male" if args[6] == "M" else "female",
|
||||
"address": args[7],
|
||||
"postal_code": args[8],
|
||||
"city": args[9],
|
||||
"country": args[10],
|
||||
"phone_number": args[11],
|
||||
"school": args[12],
|
||||
"student_class": args[13].lower().replace('premiere', 'première') if args[13] else None,
|
||||
"responsible_name": args[14],
|
||||
"responsible_phone": args[15],
|
||||
"responsible_email": args[16],
|
||||
"description": args[17].replace("\\n", "\n") if args[17] else None,
|
||||
"role": Command.role(args[18]),
|
||||
"team": Team.objects.get(pk=args[19]) if args[19] else None,
|
||||
"year": args[20],
|
||||
"date_joined": args[23],
|
||||
"is_active": args[18] == "ADMIN" or os.getenv("TFJM_STAGE", "dev") == "prod",
|
||||
"is_staff": args[18] == "ADMIN",
|
||||
"is_superuser": args[18] == "ADMIN",
|
||||
}
|
||||
with transaction.atomic():
|
||||
TFJMUser.objects.create(**obj_dict)
|
||||
self.stdout.write(self.style.SUCCESS("Users imported"))
|
||||
|
||||
self.stdout.write("Importing organizers...")
|
||||
# We also import the information about the organizers of a tournament.
|
||||
with open("import_olddb/organizers.csv") as f:
|
||||
first_line = True
|
||||
for line in f:
|
||||
if first_line:
|
||||
first_line = False
|
||||
continue
|
||||
|
||||
line = line[:-1].replace("\"", "")
|
||||
args = line.split(";")
|
||||
args = [arg if arg and arg != "NULL" else None for arg in args]
|
||||
|
||||
with transaction.atomic():
|
||||
tournament = Tournament.objects.get(pk=args[2])
|
||||
organizer = TFJMUser.objects.get(pk=args[1])
|
||||
tournament.organizers.add(organizer)
|
||||
tournament.save()
|
||||
self.stdout.write(self.style.SUCCESS("Organizers imported"))
|
||||
|
||||
@transaction.atomic
|
||||
def import_documents(self):
|
||||
"""
|
||||
Import the documents (authorizations, motivation letters, solutions, syntheses) from the old database.
|
||||
"""
|
||||
self.stdout.write("Importing documents...")
|
||||
with open("import_olddb/documents.csv") as f:
|
||||
first_line = True
|
||||
for line in f:
|
||||
if first_line:
|
||||
first_line = False
|
||||
continue
|
||||
|
||||
line = line[:-1].replace("\"", "")
|
||||
args = line.split(";")
|
||||
args = [arg if arg and arg != "NULL" else None for arg in args]
|
||||
|
||||
if Document.objects.filter(file=args[0]).exists():
|
||||
doc = Document.objects.get(file=args[0])
|
||||
doc.uploaded_at = args[5].replace(" ", "T")
|
||||
doc.save()
|
||||
continue
|
||||
|
||||
obj_dict = {
|
||||
"file": args[0],
|
||||
"uploaded_at": args[5],
|
||||
}
|
||||
if args[4] != "MOTIVATION_LETTER":
|
||||
obj_dict["user"] = TFJMUser.objects.get(args[1]),
|
||||
obj_dict["type"] = args[4].lower()
|
||||
else:
|
||||
try:
|
||||
obj_dict["team"] = Team.objects.get(pk=args[2])
|
||||
except Team.DoesNotExist:
|
||||
print("Team with pk {} does not exist, ignoring".format(args[2]))
|
||||
continue
|
||||
with transaction.atomic():
|
||||
if args[4] != "MOTIVATION_LETTER":
|
||||
Authorization.objects.create(**obj_dict)
|
||||
else:
|
||||
MotivationLetter.objects.create(**obj_dict)
|
||||
self.stdout.write(self.style.SUCCESS("Authorizations imported"))
|
||||
|
||||
with open("import_olddb/solutions.csv") as f:
|
||||
first_line = True
|
||||
for line in f:
|
||||
if first_line:
|
||||
first_line = False
|
||||
continue
|
||||
|
||||
line = line[:-1].replace("\"", "")
|
||||
args = line.split(";")
|
||||
args = [arg if arg and arg != "NULL" else None for arg in args]
|
||||
|
||||
if Document.objects.filter(file=args[0]).exists():
|
||||
doc = Document.objects.get(file=args[0])
|
||||
doc.uploaded_at = args[4].replace(" ", "T")
|
||||
doc.save()
|
||||
continue
|
||||
|
||||
obj_dict = {
|
||||
"file": args[0],
|
||||
"team": Team.objects.get(pk=args[1]),
|
||||
"problem": args[3],
|
||||
"uploaded_at": args[4],
|
||||
}
|
||||
with transaction.atomic():
|
||||
try:
|
||||
Solution.objects.create(**obj_dict)
|
||||
except:
|
||||
print("Solution exists")
|
||||
self.stdout.write(self.style.SUCCESS("Solutions imported"))
|
||||
|
||||
with open("import_olddb/syntheses.csv") as f:
|
||||
first_line = True
|
||||
for line in f:
|
||||
if first_line:
|
||||
first_line = False
|
||||
continue
|
||||
|
||||
line = line[:-1].replace("\"", "")
|
||||
args = line.split(";")
|
||||
args = [arg if arg and arg != "NULL" else None for arg in args]
|
||||
|
||||
if Document.objects.filter(file=args[0]).exists():
|
||||
doc = Document.objects.get(file=args[0])
|
||||
doc.uploaded_at = args[5].replace(" ", "T")
|
||||
doc.save()
|
||||
continue
|
||||
|
||||
obj_dict = {
|
||||
"file": args[0],
|
||||
"team": Team.objects.get(pk=args[1]),
|
||||
"source": "opponent" if args[3] == "1" else "rapporteur",
|
||||
"round": args[4],
|
||||
"uploaded_at": args[5],
|
||||
}
|
||||
with transaction.atomic():
|
||||
try:
|
||||
Synthesis.objects.create(**obj_dict)
|
||||
except:
|
||||
print("Synthesis exists")
|
||||
self.stdout.write(self.style.SUCCESS("Syntheses imported"))
|
@ -1,368 +0,0 @@
|
||||
import os
|
||||
from datetime import date
|
||||
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from polymorphic.models import PolymorphicModel
|
||||
|
||||
from tournament.models import Team, Tournament
|
||||
|
||||
|
||||
class TFJMUser(AbstractUser):
|
||||
"""
|
||||
The model of registered users (organizers/juries/admins/coachs/participants)
|
||||
"""
|
||||
USERNAME_FIELD = 'email'
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
email = models.EmailField(
|
||||
unique=True,
|
||||
verbose_name=_("email"),
|
||||
help_text=_("This should be valid and will be controlled."),
|
||||
)
|
||||
|
||||
team = models.ForeignKey(
|
||||
Team,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="users",
|
||||
verbose_name=_("team"),
|
||||
help_text=_("Concerns only coaches and participants."),
|
||||
)
|
||||
|
||||
birth_date = models.DateField(
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("birth date"),
|
||||
)
|
||||
|
||||
gender = models.CharField(
|
||||
max_length=16,
|
||||
null=True,
|
||||
default=None,
|
||||
choices=[
|
||||
("male", _("Male")),
|
||||
("female", _("Female")),
|
||||
("non-binary", _("Non binary")),
|
||||
],
|
||||
verbose_name=_("gender"),
|
||||
)
|
||||
|
||||
address = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("address"),
|
||||
)
|
||||
|
||||
postal_code = models.PositiveIntegerField(
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("postal code"),
|
||||
)
|
||||
|
||||
city = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("city"),
|
||||
)
|
||||
|
||||
country = models.CharField(
|
||||
max_length=255,
|
||||
default="France",
|
||||
null=True,
|
||||
verbose_name=_("country"),
|
||||
)
|
||||
|
||||
phone_number = models.CharField(
|
||||
max_length=20,
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
verbose_name=_("phone number"),
|
||||
)
|
||||
|
||||
school = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("school"),
|
||||
)
|
||||
|
||||
student_class = models.CharField(
|
||||
max_length=16,
|
||||
choices=[
|
||||
('seconde', _("Seconde or less")),
|
||||
('première', _("Première")),
|
||||
('terminale', _("Terminale")),
|
||||
],
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name="class",
|
||||
)
|
||||
|
||||
responsible_name = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("responsible name"),
|
||||
)
|
||||
|
||||
responsible_phone = models.CharField(
|
||||
max_length=20,
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("responsible phone"),
|
||||
)
|
||||
|
||||
responsible_email = models.EmailField(
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("responsible email"),
|
||||
)
|
||||
|
||||
description = models.TextField(
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("description"),
|
||||
)
|
||||
|
||||
role = models.CharField(
|
||||
max_length=16,
|
||||
choices=[
|
||||
("0admin", _("Admin")),
|
||||
("1volunteer", _("Organizer")),
|
||||
("2coach", _("Coach")),
|
||||
("3participant", _("Participant")),
|
||||
]
|
||||
)
|
||||
|
||||
year = models.PositiveIntegerField(
|
||||
default=os.getenv("TFJM_YEAR", date.today().year),
|
||||
verbose_name=_("year"),
|
||||
)
|
||||
|
||||
@property
|
||||
def participates(self):
|
||||
"""
|
||||
Return True iff this user is a participant or a coach, ie. if the user is a member of a team that worked
|
||||
for the tournament.
|
||||
"""
|
||||
return self.role == "3participant" or self.role == "2coach"
|
||||
|
||||
@property
|
||||
def organizes(self):
|
||||
"""
|
||||
Return True iff this user is a local or global organizer of the tournament. This includes juries.
|
||||
"""
|
||||
return self.role == "1volunteer" or self.role == "0admin"
|
||||
|
||||
@property
|
||||
def admin(self):
|
||||
"""
|
||||
Return True iff this user is a global organizer, ie. an administrator. This should be equivalent to be
|
||||
a superuser.
|
||||
"""
|
||||
return self.role == "0admin"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("user")
|
||||
verbose_name_plural = _("users")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# We ensure that the username is the email of the user.
|
||||
self.username = self.email
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return self.first_name + " " + self.last_name
|
||||
|
||||
|
||||
class Document(PolymorphicModel):
|
||||
"""
|
||||
Abstract model of any saved document (solution, synthesis, motivation letter, authorization)
|
||||
"""
|
||||
file = models.FileField(
|
||||
unique=True,
|
||||
verbose_name=_("file"),
|
||||
)
|
||||
|
||||
uploaded_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name=_("uploaded at"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("document")
|
||||
verbose_name_plural = _("documents")
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
self.file.delete(True)
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
|
||||
class Authorization(Document):
|
||||
"""
|
||||
Model for authorization papers (parental consent, photo consent, sanitary plug, ...)
|
||||
"""
|
||||
user = models.ForeignKey(
|
||||
TFJMUser,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="authorizations",
|
||||
verbose_name=_("user"),
|
||||
)
|
||||
|
||||
type = models.CharField(
|
||||
max_length=32,
|
||||
choices=[
|
||||
("parental_consent", _("Parental consent")),
|
||||
("photo_consent", _("Photo consent")),
|
||||
("sanitary_plug", _("Sanitary plug")),
|
||||
("scholarship", _("Scholarship")),
|
||||
],
|
||||
verbose_name=_("type"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("authorization")
|
||||
verbose_name_plural = _("authorizations")
|
||||
|
||||
def __str__(self):
|
||||
return _("{authorization} for user {user}").format(authorization=self.type, user=str(self.user))
|
||||
|
||||
|
||||
class MotivationLetter(Document):
|
||||
"""
|
||||
Model for motivation letters of a team.
|
||||
"""
|
||||
team = models.ForeignKey(
|
||||
Team,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="motivation_letters",
|
||||
verbose_name=_("team"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("motivation letter")
|
||||
verbose_name_plural = _("motivation letters")
|
||||
|
||||
def __str__(self):
|
||||
return _("Motivation letter of team {team} ({trigram})").format(team=self.team.name, trigram=self.team.trigram)
|
||||
|
||||
|
||||
class Solution(Document):
|
||||
"""
|
||||
Model for solutions of team for a given problem, for the regional or final tournament.
|
||||
"""
|
||||
team = models.ForeignKey(
|
||||
Team,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="solutions",
|
||||
verbose_name=_("team"),
|
||||
)
|
||||
|
||||
problem = models.PositiveSmallIntegerField(
|
||||
verbose_name=_("problem"),
|
||||
)
|
||||
|
||||
final = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("final solution"),
|
||||
)
|
||||
|
||||
@property
|
||||
def tournament(self):
|
||||
"""
|
||||
Get the concerned tournament of a solution.
|
||||
Generally the local tournament of a team, but it can be the final tournament if this is a solution for the
|
||||
final tournament.
|
||||
"""
|
||||
return Tournament.get_final() if self.final else self.team.tournament
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("solution")
|
||||
verbose_name_plural = _("solutions")
|
||||
unique_together = ('team', 'problem', 'final',)
|
||||
|
||||
def __str__(self):
|
||||
if self.final:
|
||||
return _("Solution of team {trigram} for problem {problem} for final")\
|
||||
.format(trigram=self.team.trigram, problem=self.problem)
|
||||
else:
|
||||
return _("Solution of team {trigram} for problem {problem}")\
|
||||
.format(trigram=self.team.trigram, problem=self.problem)
|
||||
|
||||
|
||||
class Synthesis(Document):
|
||||
"""
|
||||
Model for syntheses of a team for a given round and for a given role, for the regional or final tournament.
|
||||
"""
|
||||
team = models.ForeignKey(
|
||||
Team,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="syntheses",
|
||||
verbose_name=_("team"),
|
||||
)
|
||||
|
||||
source = models.CharField(
|
||||
max_length=16,
|
||||
choices=[
|
||||
("opponent", _("Opponent")),
|
||||
("rapporteur", _("Rapporteur")),
|
||||
],
|
||||
verbose_name=_("source"),
|
||||
)
|
||||
|
||||
round = models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(1, _("Round 1")),
|
||||
(2, _("Round 2")),
|
||||
],
|
||||
verbose_name=_("round"),
|
||||
)
|
||||
|
||||
final = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("final synthesis"),
|
||||
)
|
||||
|
||||
@property
|
||||
def tournament(self):
|
||||
"""
|
||||
Get the concerned tournament of a solution.
|
||||
Generally the local tournament of a team, but it can be the final tournament if this is a solution for the
|
||||
final tournament.
|
||||
"""
|
||||
return Tournament.get_final() if self.final else self.team.tournament
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("synthesis")
|
||||
verbose_name_plural = _("syntheses")
|
||||
unique_together = ('team', 'source', 'round', 'final',)
|
||||
|
||||
def __str__(self):
|
||||
return _("Synthesis of team {trigram} that is {source} for the round {round} of tournament {tournament}")\
|
||||
.format(trigram=self.team.trigram, source=self.get_source_display().lower(), round=self.round,
|
||||
tournament=self.tournament)
|
||||
|
||||
|
||||
class Config(models.Model):
|
||||
"""
|
||||
Dictionary of configuration variables.
|
||||
"""
|
||||
key = models.CharField(
|
||||
max_length=255,
|
||||
primary_key=True,
|
||||
verbose_name=_("key"),
|
||||
)
|
||||
|
||||
value = models.TextField(
|
||||
default="",
|
||||
verbose_name=_("value"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("configuration")
|
||||
verbose_name_plural = _("configurations")
|
@ -1,26 +0,0 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2 import A
|
||||
|
||||
from .models import TFJMUser
|
||||
|
||||
|
||||
class UserTable(tables.Table):
|
||||
"""
|
||||
Table of users that are matched with a given queryset.
|
||||
"""
|
||||
last_name = tables.LinkColumn(
|
||||
"member:information",
|
||||
args=[A("pk")],
|
||||
)
|
||||
|
||||
first_name = tables.LinkColumn(
|
||||
"member:information",
|
||||
args=[A("pk")],
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = TFJMUser
|
||||
fields = ("last_name", "first_name", "role", "date_joined", )
|
||||
attrs = {
|
||||
'class': 'table table-condensed table-striped table-hover'
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
from django import template
|
||||
|
||||
import os
|
||||
|
||||
from member.models import Config
|
||||
|
||||
|
||||
def get_config(value):
|
||||
"""
|
||||
Return a value stored into the config table in the database with a given key.
|
||||
"""
|
||||
config = Config.objects.get_or_create(key=value)[0]
|
||||
return config.value
|
||||
|
||||
|
||||
def get_env(value):
|
||||
"""
|
||||
Get a specified environment variable.
|
||||
"""
|
||||
return os.getenv(value)
|
||||
|
||||
|
||||
register = template.Library()
|
||||
register.filter('get_config', get_config)
|
||||
register.filter('get_env', get_env)
|
@ -1,19 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import CreateUserView, MyAccountView, UserDetailView, AddTeamView, JoinTeamView, MyTeamView,\
|
||||
ProfileListView, OrphanedProfileListView, OrganizersListView, ResetAdminView
|
||||
|
||||
app_name = "member"
|
||||
|
||||
urlpatterns = [
|
||||
path('signup/', CreateUserView.as_view(), name="signup"),
|
||||
path("my-account/", MyAccountView.as_view(), name="my_account"),
|
||||
path("information/<int:pk>/", UserDetailView.as_view(), name="information"),
|
||||
path("add-team/", AddTeamView.as_view(), name="add_team"),
|
||||
path("join-team/", JoinTeamView.as_view(), name="join_team"),
|
||||
path("my-team/", MyTeamView.as_view(), name="my_team"),
|
||||
path("profiles/", ProfileListView.as_view(), name="all_profiles"),
|
||||
path("orphaned-profiles/", OrphanedProfileListView.as_view(), name="orphaned_profiles"),
|
||||
path("organizers/", OrganizersListView.as_view(), name="organizers"),
|
||||
path("reset-admin/", ResetAdminView.as_view(), name="reset_admin"),
|
||||
]
|
@ -1,265 +0,0 @@
|
||||
import random
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import Q
|
||||
from django.http import FileResponse, Http404
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.generic import CreateView, UpdateView, DetailView, FormView
|
||||
from django_tables2 import SingleTableView
|
||||
from tournament.forms import TeamForm, JoinTeam
|
||||
from tournament.models import Team, Tournament
|
||||
from tournament.views import AdminMixin, TeamMixin, OrgaMixin
|
||||
|
||||
from .forms import SignUpForm, TFJMUserForm, AdminUserForm, CoachUserForm
|
||||
from .models import TFJMUser, Document, Solution, MotivationLetter, Synthesis
|
||||
from .tables import UserTable
|
||||
|
||||
|
||||
class CreateUserView(CreateView):
|
||||
"""
|
||||
Signup form view.
|
||||
"""
|
||||
model = TFJMUser
|
||||
form_class = SignUpForm
|
||||
template_name = "registration/signup.html"
|
||||
|
||||
|
||||
class MyAccountView(LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
Update our personal data.
|
||||
"""
|
||||
model = TFJMUser
|
||||
template_name = "member/my_account.html"
|
||||
|
||||
def get_form_class(self):
|
||||
# The used form can change according to the role of the user.
|
||||
return AdminUserForm if self.request.user.organizes else TFJMUserForm \
|
||||
if self.request.user.role == "3participant" else CoachUserForm
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
return self.request.user
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy('member:my_account')
|
||||
|
||||
|
||||
class UserDetailView(LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
View the personal information of a given user.
|
||||
Only organizers can see this page, since there are personal data.
|
||||
"""
|
||||
model = TFJMUser
|
||||
form_class = TFJMUserForm
|
||||
context_object_name = "tfjmuser"
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if isinstance(request.user, AnonymousUser):
|
||||
raise PermissionDenied
|
||||
|
||||
self.object = self.get_object()
|
||||
|
||||
if not request.user.admin \
|
||||
and (self.object.team is not None and request.user not in self.object.team.tournament.organizers.all())\
|
||||
and (self.object.team is not None and self.object.team.selected_for_final
|
||||
and request.user not in Tournament.get_final().organizers.all())\
|
||||
and self.request.user != self.object:
|
||||
raise PermissionDenied
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
An administrator can log in through this page as someone else, and act as this other person.
|
||||
"""
|
||||
if "view_as" in request.POST and self.request.user.admin:
|
||||
session = request.session
|
||||
session["admin"] = request.user.pk
|
||||
obj = self.get_object()
|
||||
session["_fake_user_id"] = obj.pk
|
||||
return redirect(request.path)
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
context["title"] = str(self.object)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class AddTeamView(LoginRequiredMixin, CreateView):
|
||||
"""
|
||||
Register a new team.
|
||||
Users can choose the name, the trigram and a preferred tournament.
|
||||
"""
|
||||
model = Team
|
||||
form_class = TeamForm
|
||||
|
||||
def form_valid(self, form):
|
||||
if self.request.user.organizes:
|
||||
form.add_error('name', _("You can't organize and participate at the same time."))
|
||||
return self.form_invalid(form)
|
||||
|
||||
if self.request.user.team:
|
||||
form.add_error('name', _("You are already in a team."))
|
||||
return self.form_invalid(form)
|
||||
|
||||
# Generate a random access code
|
||||
team = form.instance
|
||||
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
code = ""
|
||||
for i in range(6):
|
||||
code += random.choice(alphabet)
|
||||
team.access_code = code
|
||||
team.validation_status = "0invalid"
|
||||
|
||||
team.save()
|
||||
team.refresh_from_db()
|
||||
|
||||
self.request.user.team = team
|
||||
self.request.user.save()
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy("member:my_team")
|
||||
|
||||
|
||||
class JoinTeamView(LoginRequiredMixin, FormView):
|
||||
"""
|
||||
Join a team with a given access code.
|
||||
"""
|
||||
model = Team
|
||||
form_class = JoinTeam
|
||||
template_name = "tournament/team_form.html"
|
||||
|
||||
def form_valid(self, form):
|
||||
team = form.cleaned_data["team"]
|
||||
|
||||
if self.request.user.organizes:
|
||||
form.add_error('access_code', _("You can't organize and participate at the same time."))
|
||||
return self.form_invalid(form)
|
||||
|
||||
if self.request.user.team:
|
||||
form.add_error('access_code', _("You are already in a team."))
|
||||
return self.form_invalid(form)
|
||||
|
||||
if self.request.user.role == '2coach' and len(team.coaches) == 3:
|
||||
form.add_error('access_code', _("This team is full of coachs."))
|
||||
return self.form_invalid(form)
|
||||
|
||||
if self.request.user.role == '3participant' and len(team.participants) == 6:
|
||||
form.add_error('access_code', _("This team is full of participants."))
|
||||
return self.form_invalid(form)
|
||||
|
||||
if not team.invalid:
|
||||
form.add_error('access_code', _("This team is already validated or waiting for validation."))
|
||||
|
||||
self.request.user.team = team
|
||||
self.request.user.save()
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy("member:my_team")
|
||||
|
||||
|
||||
class MyTeamView(TeamMixin, View):
|
||||
"""
|
||||
Redirect to the page of the information of our personal team.
|
||||
"""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return redirect("tournament:team_detail", pk=request.user.team.pk)
|
||||
|
||||
|
||||
class DocumentView(LoginRequiredMixin, View):
|
||||
"""
|
||||
View a PDF document, if we have the right.
|
||||
|
||||
- Everyone can see the documents that concern itself.
|
||||
- An administrator can see anything.
|
||||
- An organizer can see documents that are related to its tournament.
|
||||
- A jury can see solutions and syntheses that are evaluated in their pools.
|
||||
"""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
try:
|
||||
doc = Document.objects.get(file=self.kwargs["file"])
|
||||
except Document.DoesNotExist:
|
||||
raise Http404(_("No %(verbose_name)s found matching the query") %
|
||||
{'verbose_name': Document._meta.verbose_name})
|
||||
|
||||
grant = request.user.admin
|
||||
|
||||
if isinstance(doc, Solution) or isinstance(doc, Synthesis) or isinstance(doc, MotivationLetter):
|
||||
grant = grant or doc.team == request.user.team or request.user in doc.team.tournament.organizers.all()
|
||||
grant = grant or (doc.team.selected_for_final and request.user in Tournament.get_final().organizers.all())
|
||||
|
||||
if isinstance(doc, Synthesis) and request.user.organizes:
|
||||
grant = True
|
||||
|
||||
if isinstance(doc, Solution):
|
||||
for pool in doc.pools.all():
|
||||
if request.user in pool.juries.all():
|
||||
grant = True
|
||||
break
|
||||
if pool.round == 2 and timezone.now() < doc.tournament.date_solutions_2:
|
||||
continue
|
||||
if self.request.user.team in pool.teams.all():
|
||||
grant = True
|
||||
|
||||
if not grant:
|
||||
raise PermissionDenied
|
||||
|
||||
return FileResponse(doc.file, content_type="application/pdf", filename=str(doc) + ".pdf")
|
||||
|
||||
|
||||
class ProfileListView(AdminMixin, SingleTableView):
|
||||
"""
|
||||
List all registered profiles.
|
||||
"""
|
||||
model = TFJMUser
|
||||
queryset = TFJMUser.objects.order_by("role", "last_name", "first_name")
|
||||
table_class = UserTable
|
||||
template_name = "member/profile_list.html"
|
||||
extra_context = dict(title=_("All profiles"), type="all")
|
||||
|
||||
|
||||
class OrphanedProfileListView(AdminMixin, SingleTableView):
|
||||
"""
|
||||
List all orphaned profiles, ie. participants that have no team.
|
||||
"""
|
||||
model = TFJMUser
|
||||
queryset = TFJMUser.objects.filter((Q(role="2coach") | Q(role="3participant")) & Q(team__isnull=True))\
|
||||
.order_by("role", "last_name", "first_name")
|
||||
table_class = UserTable
|
||||
template_name = "member/profile_list.html"
|
||||
extra_context = dict(title=_("Orphaned profiles"), type="orphaned")
|
||||
|
||||
|
||||
class OrganizersListView(OrgaMixin, SingleTableView):
|
||||
"""
|
||||
List all organizers.
|
||||
"""
|
||||
model = TFJMUser
|
||||
queryset = TFJMUser.objects.filter(Q(role="0admin") | Q(role="1volunteer"))\
|
||||
.order_by("role", "last_name", "first_name")
|
||||
table_class = UserTable
|
||||
template_name = "member/profile_list.html"
|
||||
extra_context = dict(title=_("Organizers"), type="organizers")
|
||||
|
||||
|
||||
class ResetAdminView(AdminMixin, View):
|
||||
"""
|
||||
Return to admin view, clear the session field that let an administrator to log in as someone else.
|
||||
"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if "_fake_user_id" in request.session:
|
||||
del request.session["_fake_user_id"]
|
||||
return redirect(request.GET["path"])
|
@ -1 +0,0 @@
|
||||
default_app_config = 'tournament.apps.TournamentConfig'
|
@ -1,31 +0,0 @@
|
||||
from django.contrib.auth.admin import admin
|
||||
|
||||
from .models import Team, Tournament, Pool, Payment
|
||||
|
||||
|
||||
@admin.register(Team)
|
||||
class TeamAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Django admin page for teams.
|
||||
"""
|
||||
|
||||
|
||||
@admin.register(Tournament)
|
||||
class TournamentAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Django admin page for tournaments.
|
||||
"""
|
||||
|
||||
|
||||
@admin.register(Pool)
|
||||
class PoolAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Django admin page for pools.
|
||||
"""
|
||||
|
||||
|
||||
@admin.register(Payment)
|
||||
class PaymentAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Django admin page for payments.
|
||||
"""
|
@ -1,10 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class TournamentConfig(AppConfig):
|
||||
"""
|
||||
The tournament app handles all that is related to the tournaments.
|
||||
"""
|
||||
name = 'tournament'
|
||||
verbose_name = _('tournament')
|
@ -1,262 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.template.defaultfilters import filesizeformat
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from member.models import TFJMUser, Solution, Synthesis
|
||||
from tfjm.inputs import DatePickerInput, DateTimePickerInput, AmountInput
|
||||
from tournament.models import Tournament, Team, Pool
|
||||
|
||||
|
||||
class TournamentForm(forms.ModelForm):
|
||||
"""
|
||||
Create and update tournaments.
|
||||
"""
|
||||
|
||||
# Only organizers can organize tournaments. Well, that's pretty normal...
|
||||
organizers = forms.ModelMultipleChoiceField(
|
||||
TFJMUser.objects.filter(Q(role="0admin") | Q(role="1volunteer")).order_by('role'),
|
||||
label=_("Organizers"),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
if not self.instance.pk:
|
||||
if Tournament.objects.filter(name=cleaned_data["data"], year=os.getenv("TFJM_YEAR")):
|
||||
self.add_error("name", _("This tournament already exists."))
|
||||
if cleaned_data["final"] and Tournament.objects.filter(final=True, year=os.getenv("TFJM_YEAR")):
|
||||
self.add_error("name", _("The final tournament was already defined."))
|
||||
|
||||
return cleaned_data
|
||||
|
||||
class Meta:
|
||||
model = Tournament
|
||||
exclude = ('year',)
|
||||
widgets = {
|
||||
"price": AmountInput(),
|
||||
"date_start": DatePickerInput(),
|
||||
"date_end": DatePickerInput(),
|
||||
"date_inscription": DateTimePickerInput(),
|
||||
"date_solutions": DateTimePickerInput(),
|
||||
"date_syntheses": DateTimePickerInput(),
|
||||
"date_solutions_2": DateTimePickerInput(),
|
||||
"date_syntheses_2": DateTimePickerInput(),
|
||||
}
|
||||
|
||||
|
||||
class OrganizerForm(forms.ModelForm):
|
||||
"""
|
||||
Register an organizer in the website.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = TFJMUser
|
||||
fields = ('last_name', 'first_name', 'email', 'is_superuser',)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
if TFJMUser.objects.filter(email=cleaned_data["email"], year=os.getenv("TFJM_YEAR")).exists():
|
||||
self.add_error("email", _("This organizer already exist."))
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def save(self, commit=True):
|
||||
user = self.instance
|
||||
user.role = '0admin' if user.is_superuser else '1volunteer'
|
||||
user.save()
|
||||
super().save(commit)
|
||||
|
||||
|
||||
class TeamForm(forms.ModelForm):
|
||||
"""
|
||||
Add and update a team.
|
||||
"""
|
||||
tournament = forms.ModelChoiceField(
|
||||
Tournament.objects.filter(date_inscription__gte=timezone.now(), final=False),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = ('name', 'trigram', 'tournament',)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
cleaned_data["trigram"] = cleaned_data["trigram"].upper()
|
||||
|
||||
if not re.match("[A-Z]{3}", cleaned_data["trigram"]):
|
||||
self.add_error("trigram", _("The trigram must be composed of three upcase letters."))
|
||||
|
||||
if not self.instance.pk:
|
||||
if Team.objects.filter(trigram=cleaned_data["trigram"], year=os.getenv("TFJM_YEAR")).exists():
|
||||
self.add_error("trigram", _("This trigram is already used."))
|
||||
|
||||
if Team.objects.filter(name=cleaned_data["name"], year=os.getenv("TFJM_YEAR")).exists():
|
||||
self.add_error("name", _("This name is already used."))
|
||||
|
||||
if cleaned_data["tournament"].date_inscription < timezone.now:
|
||||
self.add_error("tournament", _("This tournament is already closed."))
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class JoinTeam(forms.Form):
|
||||
"""
|
||||
Form to join a team with an access code.
|
||||
"""
|
||||
|
||||
access_code = forms.CharField(
|
||||
label=_("Access code"),
|
||||
max_length=6,
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
if not re.match("[a-z0-9]{6}", cleaned_data["access_code"]):
|
||||
self.add_error('access_code', _("The access code must be composed of 6 alphanumeric characters."))
|
||||
|
||||
team = Team.objects.filter(access_code=cleaned_data["access_code"])
|
||||
if not team.exists():
|
||||
self.add_error('access_code', _("This access code is invalid."))
|
||||
team = team.get()
|
||||
if not team.invalid:
|
||||
self.add_error('access_code', _("The team is already validated."))
|
||||
cleaned_data["team"] = team
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class SolutionForm(forms.ModelForm):
|
||||
"""
|
||||
Form to upload a solution.
|
||||
"""
|
||||
|
||||
problem = forms.ChoiceField(
|
||||
label=_("Problem"),
|
||||
choices=[(str(i), _("Problem #%(problem)d") % {"problem": i}) for i in range(1, 9)],
|
||||
)
|
||||
|
||||
def clean_file(self):
|
||||
content = self.cleaned_data['file']
|
||||
content_type = content.content_type
|
||||
if content_type in ["application/pdf"]:
|
||||
if content.size > 5 * 2 ** 20:
|
||||
raise forms.ValidationError(
|
||||
_('Please keep filesize under %(max_size)s. Current filesize %(current_size)s') % {
|
||||
"max_size": filesizeformat(2 * 2 ** 20),
|
||||
"current_size": filesizeformat(content.size)
|
||||
})
|
||||
else:
|
||||
raise forms.ValidationError(_('The file should be a PDF file.'))
|
||||
return content
|
||||
|
||||
class Meta:
|
||||
model = Solution
|
||||
fields = ('file', 'problem',)
|
||||
|
||||
|
||||
class SynthesisForm(forms.ModelForm):
|
||||
"""
|
||||
Form to upload a synthesis.
|
||||
"""
|
||||
|
||||
def clean_file(self):
|
||||
content = self.cleaned_data['file']
|
||||
content_type = content.content_type
|
||||
if content_type in ["application/pdf"]:
|
||||
if content.size > 5 * 2 ** 20:
|
||||
raise forms.ValidationError(
|
||||
_('Please keep filesize under %(max_size)s. Current filesize %(current_size)s') % {
|
||||
"max_size": filesizeformat(2 * 2 ** 20),
|
||||
"current_size": filesizeformat(content.size)
|
||||
})
|
||||
else:
|
||||
raise forms.ValidationError(_('The file should be a PDF file.'))
|
||||
return content
|
||||
|
||||
class Meta:
|
||||
model = Synthesis
|
||||
fields = ('file', 'source', 'round',)
|
||||
|
||||
|
||||
class PoolForm(forms.ModelForm):
|
||||
"""
|
||||
Form to add a pool.
|
||||
Should not be used: prefer to pass by API and auto-add pools with the results of the draw.
|
||||
"""
|
||||
|
||||
team1 = forms.ModelChoiceField(
|
||||
Team.objects.filter(validation_status="2valid").all(),
|
||||
empty_label=_("Choose a team..."),
|
||||
label=_("Team 1"),
|
||||
)
|
||||
|
||||
problem1 = forms.IntegerField(
|
||||
min_value=1,
|
||||
max_value=8,
|
||||
initial=1,
|
||||
label=_("Problem defended by team 1"),
|
||||
)
|
||||
|
||||
team2 = forms.ModelChoiceField(
|
||||
Team.objects.filter(validation_status="2valid").all(),
|
||||
empty_label=_("Choose a team..."),
|
||||
label=_("Team 2"),
|
||||
)
|
||||
|
||||
problem2 = forms.IntegerField(
|
||||
min_value=1,
|
||||
max_value=8,
|
||||
initial=2,
|
||||
label=_("Problem defended by team 2"),
|
||||
)
|
||||
|
||||
team3 = forms.ModelChoiceField(
|
||||
Team.objects.filter(validation_status="2valid").all(),
|
||||
empty_label=_("Choose a team..."),
|
||||
label=_("Team 3"),
|
||||
)
|
||||
|
||||
problem3 = forms.IntegerField(
|
||||
min_value=1,
|
||||
max_value=8,
|
||||
initial=3,
|
||||
label=_("Problem defended by team 3"),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
team1, pb1 = cleaned_data["team1"], cleaned_data["problem1"]
|
||||
team2, pb2 = cleaned_data["team2"], cleaned_data["problem2"]
|
||||
team3, pb3 = cleaned_data["team3"], cleaned_data["problem3"]
|
||||
|
||||
sol1 = Solution.objects.get(team=team1, problem=pb1, final=team1.selected_for_final)
|
||||
sol2 = Solution.objects.get(team=team2, problem=pb2, final=team2.selected_for_final)
|
||||
sol3 = Solution.objects.get(team=team3, problem=pb3, final=team3.selected_for_final)
|
||||
|
||||
cleaned_data["teams"] = [team1, team2, team3]
|
||||
cleaned_data["solutions"] = [sol1, sol2, sol3]
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def save(self, commit=True):
|
||||
pool = super().save(commit)
|
||||
|
||||
pool.refresh_from_db()
|
||||
pool.teams.set(self.cleaned_data["teams"])
|
||||
pool.solutions.set(self.cleaned_data["solutions"])
|
||||
pool.save()
|
||||
|
||||
return pool
|
||||
|
||||
class Meta:
|
||||
model = Pool
|
||||
fields = ('round', 'juries',)
|
@ -1,417 +0,0 @@
|
||||
import os
|
||||
|
||||
from django.core.mail import send_mail
|
||||
from django.db import models
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class Tournament(models.Model):
|
||||
"""
|
||||
Store the information of a tournament.
|
||||
"""
|
||||
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("name"),
|
||||
)
|
||||
|
||||
organizers = models.ManyToManyField(
|
||||
'member.TFJMUser',
|
||||
related_name="organized_tournaments",
|
||||
verbose_name=_("organizers"),
|
||||
help_text=_("List of all organizers that can see and manipulate data of the tournament and the teams."),
|
||||
)
|
||||
|
||||
size = models.PositiveSmallIntegerField(
|
||||
verbose_name=_("size"),
|
||||
help_text=_("Number of teams that are allowed to join the tournament."),
|
||||
)
|
||||
|
||||
place = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("place"),
|
||||
)
|
||||
|
||||
price = models.PositiveSmallIntegerField(
|
||||
verbose_name=_("price"),
|
||||
help_text=_("Price asked to participants. Free with a scholarship."),
|
||||
)
|
||||
|
||||
description = models.TextField(
|
||||
verbose_name=_("description"),
|
||||
)
|
||||
|
||||
date_start = models.DateField(
|
||||
default=timezone.now,
|
||||
verbose_name=_("date start"),
|
||||
)
|
||||
|
||||
date_end = models.DateField(
|
||||
default=timezone.now,
|
||||
verbose_name=_("date end"),
|
||||
)
|
||||
|
||||
date_inscription = models.DateTimeField(
|
||||
default=timezone.now,
|
||||
verbose_name=_("date of registration closing"),
|
||||
)
|
||||
|
||||
date_solutions = models.DateTimeField(
|
||||
default=timezone.now,
|
||||
verbose_name=_("date of maximal solution submission"),
|
||||
)
|
||||
|
||||
date_syntheses = models.DateTimeField(
|
||||
default=timezone.now,
|
||||
verbose_name=_("date of maximal syntheses submission for the first round"),
|
||||
)
|
||||
|
||||
date_solutions_2 = models.DateTimeField(
|
||||
default=timezone.now,
|
||||
verbose_name=_("date when solutions of round 2 are available"),
|
||||
)
|
||||
|
||||
date_syntheses_2 = models.DateTimeField(
|
||||
default=timezone.now,
|
||||
verbose_name=_("date of maximal syntheses submission for the second round"),
|
||||
)
|
||||
|
||||
final = models.BooleanField(
|
||||
verbose_name=_("final tournament"),
|
||||
help_text=_("It should be only one final tournament."),
|
||||
)
|
||||
|
||||
year = models.PositiveIntegerField(
|
||||
default=os.getenv("TFJM_YEAR", timezone.now().year),
|
||||
verbose_name=_("year"),
|
||||
)
|
||||
|
||||
@property
|
||||
def teams(self):
|
||||
"""
|
||||
Get all teams that are registered to this tournament, with a distinction for the final tournament.
|
||||
"""
|
||||
return self._teams if not self.final else Team.objects.filter(selected_for_final=True)
|
||||
|
||||
@property
|
||||
def linked_organizers(self):
|
||||
"""
|
||||
Display a list of the organizers with links to their personal page.
|
||||
"""
|
||||
return ['<a href="{url}">'.format(url=reverse_lazy("member:information", args=(user.pk,))) + str(user) + '</a>'
|
||||
for user in self.organizers.all()]
|
||||
|
||||
@property
|
||||
def solutions(self):
|
||||
"""
|
||||
Get all sent solutions for this tournament.
|
||||
"""
|
||||
from member.models import Solution
|
||||
return Solution.objects.filter(final=self.final) if self.final \
|
||||
else Solution.objects.filter(team__tournament=self, final=False)
|
||||
|
||||
@property
|
||||
def syntheses(self):
|
||||
"""
|
||||
Get all sent syntheses for this tournament.
|
||||
"""
|
||||
from member.models import Synthesis
|
||||
return Synthesis.objects.filter(final=self.final) if self.final \
|
||||
else Synthesis.objects.filter(team__tournament=self, final=False)
|
||||
|
||||
@classmethod
|
||||
def get_final(cls):
|
||||
"""
|
||||
Get the final tournament.
|
||||
This should exist and be unique.
|
||||
"""
|
||||
return cls.objects.get(year=os.getenv("TFJM_YEAR"), final=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("tournament")
|
||||
verbose_name_plural = _("tournaments")
|
||||
|
||||
def send_mail_to_organizers(self, template_name, subject="Contact TFJM²", **kwargs):
|
||||
"""
|
||||
Send a mail to all organizers of the tournament.
|
||||
The template of the mail should be found either in templates/mail_templates/<template_name>.html for the HTML
|
||||
version and in templates/mail_templates/<template_name>.txt for the plain text version.
|
||||
The context of the template contains the tournament and the user. Extra context can be given through the kwargs.
|
||||
"""
|
||||
context = kwargs
|
||||
context["tournament"] = self
|
||||
for user in self.organizers.all():
|
||||
context["user"] = user
|
||||
message = render_to_string("mail_templates/" + template_name + ".txt", context=context)
|
||||
message_html = render_to_string("mail_templates/" + template_name + ".html", context=context)
|
||||
send_mail(subject, message, "contact@tfjm.org", [user.email], html_message=message_html)
|
||||
from member.models import TFJMUser
|
||||
for user in TFJMUser.objects.get(is_superuser=True).all():
|
||||
context["user"] = user
|
||||
message = render_to_string("mail_templates/" + template_name + ".txt", context=context)
|
||||
message_html = render_to_string("mail_templates/" + template_name + ".html", context=context)
|
||||
send_mail(subject, message, "contact@tfjm.org", [user.email], html_message=message_html)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Team(models.Model):
|
||||
"""
|
||||
Store information about a registered team.
|
||||
"""
|
||||
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("name"),
|
||||
)
|
||||
|
||||
trigram = models.CharField(
|
||||
max_length=3,
|
||||
verbose_name=_("trigram"),
|
||||
help_text=_("The trigram should be composed of 3 capitalize letters, that is a funny acronym for the team."),
|
||||
)
|
||||
|
||||
tournament = models.ForeignKey(
|
||||
Tournament,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="_teams",
|
||||
verbose_name=_("tournament"),
|
||||
help_text=_("The tournament where the team is registered."),
|
||||
)
|
||||
|
||||
inscription_date = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name=_("inscription date"),
|
||||
)
|
||||
|
||||
validation_status = models.CharField(
|
||||
max_length=8,
|
||||
choices=[
|
||||
("0invalid", _("Registration not validated")),
|
||||
("1waiting", _("Waiting for validation")),
|
||||
("2valid", _("Registration validated")),
|
||||
],
|
||||
verbose_name=_("validation status"),
|
||||
)
|
||||
|
||||
selected_for_final = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("selected for final"),
|
||||
)
|
||||
|
||||
access_code = models.CharField(
|
||||
max_length=6,
|
||||
unique=True,
|
||||
verbose_name=_("access code"),
|
||||
)
|
||||
|
||||
year = models.PositiveIntegerField(
|
||||
default=os.getenv("TFJM_YEAR", timezone.now().year),
|
||||
verbose_name=_("year"),
|
||||
)
|
||||
|
||||
@property
|
||||
def valid(self):
|
||||
return self.validation_status == "2valid"
|
||||
|
||||
@property
|
||||
def waiting(self):
|
||||
return self.validation_status == "1waiting"
|
||||
|
||||
@property
|
||||
def invalid(self):
|
||||
return self.validation_status == "0invalid"
|
||||
|
||||
@property
|
||||
def coaches(self):
|
||||
"""
|
||||
Get all coaches of a team.
|
||||
"""
|
||||
return self.users.all().filter(role="2coach")
|
||||
|
||||
@property
|
||||
def linked_coaches(self):
|
||||
"""
|
||||
Get a list of the coaches of a team with html links to their pages.
|
||||
"""
|
||||
return ['<a href="{url}">'.format(url=reverse_lazy("member:information", args=(user.pk,))) + str(user) + '</a>'
|
||||
for user in self.coaches]
|
||||
|
||||
@property
|
||||
def participants(self):
|
||||
"""
|
||||
Get all particpants of a team, coaches excluded.
|
||||
"""
|
||||
return self.users.all().filter(role="3participant")
|
||||
|
||||
@property
|
||||
def linked_participants(self):
|
||||
"""
|
||||
Get a list of the participants of a team with html links to their pages.
|
||||
"""
|
||||
return ['<a href="{url}">'.format(url=reverse_lazy("member:information", args=(user.pk,))) + str(user) + '</a>'
|
||||
for user in self.participants]
|
||||
|
||||
@property
|
||||
def future_tournament(self):
|
||||
"""
|
||||
Get the last tournament where the team is registered.
|
||||
Only matters if the team is selected for final: if this is the case, we return the final tournament.
|
||||
Useful for deadlines.
|
||||
"""
|
||||
return Tournament.get_final() if self.selected_for_final else self.tournament
|
||||
|
||||
@property
|
||||
def can_validate(self):
|
||||
"""
|
||||
Check if a given team is able to ask for validation.
|
||||
A team can validate if:
|
||||
* All participants filled the photo consent
|
||||
* Minor participants filled the parental consent
|
||||
* Minor participants filled the sanitary plug
|
||||
* Teams sent their motivation letter
|
||||
* The team contains at least 4 participants
|
||||
* The team contains at least 1 coach
|
||||
"""
|
||||
# TODO In a normal time, team needs a motivation letter and authorizations.
|
||||
return self.coaches.exists() and self.participants.count() >= 4\
|
||||
and self.tournament.date_inscription <= timezone.now()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("team")
|
||||
verbose_name_plural = _("teams")
|
||||
unique_together = (('name', 'year',), ('trigram', 'year',),)
|
||||
|
||||
def send_mail(self, template_name, subject="Contact TFJM²", **kwargs):
|
||||
"""
|
||||
Send a mail to all members of a team with a given template.
|
||||
The template of the mail should be found either in templates/mail_templates/<template_name>.html for the HTML
|
||||
version and in templates/mail_templates/<template_name>.txt for the plain text version.
|
||||
The context of the template contains the team and the user. Extra context can be given through the kwargs.
|
||||
"""
|
||||
context = kwargs
|
||||
context["team"] = self
|
||||
for user in self.users.all():
|
||||
context["user"] = user
|
||||
message = render_to_string("mail_templates/" + template_name + ".txt", context=context)
|
||||
message_html = render_to_string("mail_templates/" + template_name + ".html", context=context)
|
||||
send_mail(subject, message, "contact@tfjm.org", [user.email], html_message=message_html)
|
||||
|
||||
def __str__(self):
|
||||
return self.trigram + " -- " + self.name
|
||||
|
||||
|
||||
class Pool(models.Model):
|
||||
"""
|
||||
Store information of a pool.
|
||||
A pool is only a list of accessible solutions to some teams and some juries.
|
||||
TODO: check that the set of teams is equal to the set of the teams that have a solution in this set.
|
||||
TODO: Moreover, a team should send only one solution.
|
||||
"""
|
||||
teams = models.ManyToManyField(
|
||||
Team,
|
||||
related_name="pools",
|
||||
verbose_name=_("teams"),
|
||||
)
|
||||
|
||||
solutions = models.ManyToManyField(
|
||||
"member.Solution",
|
||||
related_name="pools",
|
||||
verbose_name=_("solutions"),
|
||||
)
|
||||
|
||||
round = models.PositiveIntegerField(
|
||||
choices=[
|
||||
(1, _("Round 1")),
|
||||
(2, _("Round 2")),
|
||||
],
|
||||
verbose_name=_("round"),
|
||||
)
|
||||
|
||||
juries = models.ManyToManyField(
|
||||
"member.TFJMUser",
|
||||
related_name="pools",
|
||||
verbose_name=_("juries"),
|
||||
)
|
||||
|
||||
@property
|
||||
def problems(self):
|
||||
"""
|
||||
Get problem numbers of the sent solutions as a list of integers.
|
||||
"""
|
||||
return list(d["problem"] for d in self.solutions.values("problem").all())
|
||||
|
||||
@property
|
||||
def tournament(self):
|
||||
"""
|
||||
Get the concerned tournament.
|
||||
We assume that the pool is correct, so all solutions belong to the same tournament.
|
||||
"""
|
||||
return self.solutions.first().tournament
|
||||
|
||||
@property
|
||||
def syntheses(self):
|
||||
"""
|
||||
Get the syntheses of the teams that are in this pool, for the correct round.
|
||||
"""
|
||||
from member.models import Synthesis
|
||||
return Synthesis.objects.filter(team__in=self.teams.all(), round=self.round, final=self.tournament.final)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("pool")
|
||||
verbose_name_plural = _("pools")
|
||||
|
||||
|
||||
class Payment(models.Model):
|
||||
"""
|
||||
Store some information about payments, to recover data.
|
||||
TODO: handle it...
|
||||
"""
|
||||
user = models.OneToOneField(
|
||||
'member.TFJMUser',
|
||||
on_delete=models.CASCADE,
|
||||
related_name="payment",
|
||||
verbose_name=_("user"),
|
||||
)
|
||||
|
||||
team = models.ForeignKey(
|
||||
Team,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="payments",
|
||||
verbose_name=_("team"),
|
||||
)
|
||||
|
||||
method = models.CharField(
|
||||
max_length=16,
|
||||
choices=[
|
||||
("not_paid", _("Not paid")),
|
||||
("credit_card", _("Credit card")),
|
||||
("check", _("Bank check")),
|
||||
("transfer", _("Bank transfer")),
|
||||
("cash", _("Cash")),
|
||||
("scholarship", _("Scholarship")),
|
||||
],
|
||||
default="not_paid",
|
||||
verbose_name=_("payment method"),
|
||||
)
|
||||
|
||||
validation_status = models.CharField(
|
||||
max_length=8,
|
||||
choices=[
|
||||
("0invalid", _("Registration not validated")),
|
||||
("1waiting", _("Waiting for validation")),
|
||||
("2valid", _("Registration validated")),
|
||||
],
|
||||
verbose_name=_("validation status"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("payment")
|
||||
verbose_name_plural = _("payments")
|
||||
|
||||
def __str__(self):
|
||||
return _("Payment of {user}").format(str(self.user))
|
@ -1,164 +0,0 @@
|
||||
import django_tables2 as tables
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_tables2 import A
|
||||
|
||||
from member.models import Solution, Synthesis
|
||||
from .models import Tournament, Team, Pool
|
||||
|
||||
|
||||
class TournamentTable(tables.Table):
|
||||
"""
|
||||
List all tournaments.
|
||||
"""
|
||||
|
||||
name = tables.LinkColumn(
|
||||
"tournament:detail",
|
||||
args=[A("pk")],
|
||||
)
|
||||
|
||||
date_start = tables.Column(
|
||||
verbose_name=_("dates").capitalize(),
|
||||
)
|
||||
|
||||
def render_date_start(self, record):
|
||||
return _("From {start:%b %d %Y} to {end:%b %d %Y}").format(start=record.date_start, end=record.date_end)
|
||||
|
||||
class Meta:
|
||||
model = Tournament
|
||||
fields = ("name", "date_start", "date_inscription", "date_solutions", "size", )
|
||||
attrs = {
|
||||
'class': 'table table-condensed table-striped table-hover'
|
||||
}
|
||||
order_by = ('date_start', 'name',)
|
||||
|
||||
|
||||
class TeamTable(tables.Table):
|
||||
"""
|
||||
Table of some teams. Can be filtered with a queryset (for example, teams of a tournament)
|
||||
"""
|
||||
|
||||
name = tables.LinkColumn(
|
||||
"tournament:team_detail",
|
||||
args=[A("pk")],
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = ("name", "trigram", "validation_status", )
|
||||
attrs = {
|
||||
'class': 'table table-condensed table-striped table-hover'
|
||||
}
|
||||
order_by = ('-validation_status', 'trigram',)
|
||||
|
||||
|
||||
class SolutionTable(tables.Table):
|
||||
"""
|
||||
Display a table of some solutions.
|
||||
"""
|
||||
|
||||
team = tables.LinkColumn(
|
||||
"tournament:team_detail",
|
||||
args=[A("team.pk")],
|
||||
)
|
||||
|
||||
tournament = tables.LinkColumn(
|
||||
"tournament:detail",
|
||||
args=[A("tournament.pk")],
|
||||
accessor=A("tournament"),
|
||||
order_by=("team__tournament__date_start", "team__tournament__name",),
|
||||
verbose_name=_("Tournament"),
|
||||
)
|
||||
|
||||
file = tables.LinkColumn(
|
||||
"document",
|
||||
args=[A("file")],
|
||||
attrs={
|
||||
"a": {
|
||||
"data-turbolinks": "false",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
def render_file(self):
|
||||
return _("Download")
|
||||
|
||||
class Meta:
|
||||
model = Solution
|
||||
fields = ("team", "tournament", "problem", "uploaded_at", "file", )
|
||||
attrs = {
|
||||
'class': 'table table-condensed table-striped table-hover'
|
||||
}
|
||||
|
||||
|
||||
class SynthesisTable(tables.Table):
|
||||
"""
|
||||
Display a table of some syntheses.
|
||||
"""
|
||||
|
||||
team = tables.LinkColumn(
|
||||
"tournament:team_detail",
|
||||
args=[A("team.pk")],
|
||||
)
|
||||
|
||||
tournament = tables.LinkColumn(
|
||||
"tournament:detail",
|
||||
args=[A("tournament.pk")],
|
||||
accessor=A("tournament"),
|
||||
order_by=("team__tournament__date_start", "team__tournament__name",),
|
||||
verbose_name=_("tournament"),
|
||||
)
|
||||
|
||||
file = tables.LinkColumn(
|
||||
"document",
|
||||
args=[A("file")],
|
||||
attrs={
|
||||
"a": {
|
||||
"data-turbolinks": "false",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
def render_file(self):
|
||||
return _("Download")
|
||||
|
||||
class Meta:
|
||||
model = Synthesis
|
||||
fields = ("team", "tournament", "round", "source", "uploaded_at", "file", )
|
||||
attrs = {
|
||||
'class': 'table table-condensed table-striped table-hover'
|
||||
}
|
||||
|
||||
|
||||
class PoolTable(tables.Table):
|
||||
"""
|
||||
Display a table of some pools.
|
||||
"""
|
||||
|
||||
problems = tables.Column(
|
||||
verbose_name=_("Problems"),
|
||||
orderable=False,
|
||||
)
|
||||
|
||||
tournament = tables.LinkColumn(
|
||||
"tournament:detail",
|
||||
args=[A("tournament.pk")],
|
||||
verbose_name=_("Tournament"),
|
||||
order_by=("teams__tournament__date_start", "teams__tournament__name",),
|
||||
)
|
||||
|
||||
def render_teams(self, record, value):
|
||||
return format_html('<a href="{url}">{trigrams}</a>',
|
||||
url=reverse_lazy('tournament:pool_detail', args=(record.pk,)),
|
||||
trigrams=", ".join(team.trigram for team in value.all()))
|
||||
|
||||
def render_problems(self, value):
|
||||
return ", ".join([str(pb) for pb in value])
|
||||
|
||||
class Meta:
|
||||
model = Pool
|
||||
fields = ("teams", "tournament", "problems", "round", )
|
||||
attrs = {
|
||||
'class': 'table table-condensed table-striped table-hover'
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import TournamentListView, TournamentCreateView, TournamentDetailView, TournamentUpdateView, \
|
||||
TeamDetailView, TeamUpdateView, AddOrganizerView, SolutionsView, SolutionsOrgaListView, SynthesesView, \
|
||||
SynthesesOrgaListView, PoolListView, PoolCreateView, PoolDetailView
|
||||
|
||||
app_name = "tournament"
|
||||
|
||||
urlpatterns = [
|
||||
path('list/', TournamentListView.as_view(), name="list"),
|
||||
path("add/", TournamentCreateView.as_view(), name="add"),
|
||||
path('<int:pk>/', TournamentDetailView.as_view(), name="detail"),
|
||||
path('<int:pk>/update/', TournamentUpdateView.as_view(), name="update"),
|
||||
path('team/<int:pk>/', TeamDetailView.as_view(), name="team_detail"),
|
||||
path('team/<int:pk>/update/', TeamUpdateView.as_view(), name="team_update"),
|
||||
path("add-organizer/", AddOrganizerView.as_view(), name="add_organizer"),
|
||||
path("solutions/", SolutionsView.as_view(), name="solutions"),
|
||||
path("all-solutions/", SolutionsOrgaListView.as_view(), name="all_solutions"),
|
||||
path("syntheses/", SynthesesView.as_view(), name="syntheses"),
|
||||
path("all_syntheses/", SynthesesOrgaListView.as_view(), name="all_syntheses"),
|
||||
path("pools/", PoolListView.as_view(), name="pools"),
|
||||
path("pool/add/", PoolCreateView.as_view(), name="create_pool"),
|
||||
path("pool/<int:pk>/", PoolDetailView.as_view(), name="pool_detail"),
|
||||
]
|
@ -1,646 +0,0 @@
|
||||
import random
|
||||
import zipfile
|
||||
from datetime import timedelta
|
||||
from io import BytesIO
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.mail import send_mail
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, CreateView, UpdateView
|
||||
from django.views.generic.edit import BaseFormView
|
||||
from django_tables2.views import SingleTableView
|
||||
from member.models import TFJMUser, Solution, Synthesis
|
||||
|
||||
from .forms import TournamentForm, OrganizerForm, SolutionForm, SynthesisForm, TeamForm, PoolForm
|
||||
from .models import Tournament, Team, Pool
|
||||
from .tables import TournamentTable, TeamTable, SolutionTable, SynthesisTable, PoolTable
|
||||
|
||||
|
||||
class AdminMixin(LoginRequiredMixin):
|
||||
"""
|
||||
If a view extends this mixin, then the view will be only accessible to administrators.
|
||||
"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated or not request.user.admin:
|
||||
raise PermissionDenied
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class OrgaMixin(LoginRequiredMixin):
|
||||
"""
|
||||
If a view extends this mixin, then the view will be only accessible to administrators or organizers.
|
||||
"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated or not request.user.organizes:
|
||||
raise PermissionDenied
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class TeamMixin(LoginRequiredMixin):
|
||||
"""
|
||||
If a view extends this mixin, then the view will be only accessible to users that are registered in a team.
|
||||
"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated or not request.user.team:
|
||||
raise PermissionDenied
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class TournamentListView(SingleTableView):
|
||||
"""
|
||||
Display the list of all tournaments, ordered by start date then name.
|
||||
"""
|
||||
|
||||
model = Tournament
|
||||
table_class = TournamentTable
|
||||
extra_context = dict(title=_("Tournaments list"),)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
team_users = TFJMUser.objects.filter(Q(team__isnull=False) | Q(role="admin") | Q(role="organizer"))\
|
||||
.order_by('-role')
|
||||
valid_team_users = team_users.filter(
|
||||
Q(team__validation_status="2valid") | Q(role="admin") | Q(role="organizer"))
|
||||
|
||||
context["team_users_emails"] = [user.email for user in team_users]
|
||||
context["valid_team_users_emails"] = [user.email for user in valid_team_users]
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class TournamentCreateView(AdminMixin, CreateView):
|
||||
"""
|
||||
Create a tournament. Only accessible to admins.
|
||||
"""
|
||||
|
||||
model = Tournament
|
||||
form_class = TournamentForm
|
||||
extra_context = dict(title=_("Add tournament"),)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy('tournament:detail', args=(self.object.pk,))
|
||||
|
||||
|
||||
class TournamentDetailView(DetailView):
|
||||
"""
|
||||
Display the detail of a tournament.
|
||||
Accessible to all, including not authenticated users.
|
||||
"""
|
||||
|
||||
model = Tournament
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
context["title"] = _("Tournament of {name}").format(name=self.object.name)
|
||||
|
||||
if self.object.final:
|
||||
team_users = TFJMUser.objects.filter(team__selected_for_final=True)
|
||||
valid_team_users = team_users
|
||||
else:
|
||||
team_users = TFJMUser.objects.filter(
|
||||
Q(team__tournament=self.object)
|
||||
| Q(organized_tournaments=self.object)).order_by('role')
|
||||
valid_team_users = team_users.filter(
|
||||
Q(team__validation_status="2valid")
|
||||
| Q(role="admin")
|
||||
| Q(organized_tournaments=self.object))
|
||||
|
||||
context["team_users_emails"] = [user.email for user in team_users]
|
||||
context["valid_team_users_emails"] = [user.email for user in valid_team_users]
|
||||
|
||||
context["teams"] = TeamTable(self.object.teams.all())
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class TournamentUpdateView(OrgaMixin, UpdateView):
|
||||
"""
|
||||
Update the data of a tournament.
|
||||
Reserved to admins and organizers of the tournament.
|
||||
"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""
|
||||
Restrict the view to organizers of tournaments, then process the request.
|
||||
"""
|
||||
if self.request.user.role == "1volunteer" and self.request.user not in self.get_object().organizers.all():
|
||||
raise PermissionDenied
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
model = Tournament
|
||||
form_class = TournamentForm
|
||||
extra_context = dict(title=_("Update tournament"),)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy('tournament:detail', args=(self.object.pk,))
|
||||
|
||||
|
||||
class TeamDetailView(LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
View the detail of a team.
|
||||
Restricted to this team, admins and organizers of its tournament.
|
||||
"""
|
||||
model = Team
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
"""
|
||||
Protect the page and process the request.
|
||||
"""
|
||||
if not request.user.is_authenticated or \
|
||||
(not request.user.admin and self.request.user not in self.get_object().tournament.organizers.all()
|
||||
and not (self.get_object().selected_for_final
|
||||
and request.user in Tournament.get_final().organizers.all())
|
||||
and self.get_object() != request.user.team):
|
||||
raise PermissionDenied
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
Process POST requests. Supported requests:
|
||||
- get the solutions of the team as a ZIP archive
|
||||
- a user leaves its team (if the composition is not validated yet)
|
||||
- the team requests the validation
|
||||
- Organizers can validate or invalidate the request
|
||||
- Admins can delete teams
|
||||
- Admins can select teams for the final tournament
|
||||
"""
|
||||
team = self.get_object()
|
||||
if "zip" in request.POST:
|
||||
solutions = team.solutions.all()
|
||||
|
||||
out = BytesIO()
|
||||
zf = zipfile.ZipFile(out, "w")
|
||||
|
||||
for solution in solutions:
|
||||
zf.write(solution.file.path, str(solution) + ".pdf")
|
||||
|
||||
zf.close()
|
||||
|
||||
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
|
||||
resp['Content-Disposition'] = 'attachment; filename={}'\
|
||||
.format(_("Solutions for team {team}.zip")
|
||||
.format(team=str(team)).replace(" ", "%20"))
|
||||
return resp
|
||||
elif "leave" in request.POST and request.user.participates:
|
||||
request.user.team = None
|
||||
request.user.save()
|
||||
if not team.users.exists():
|
||||
team.delete()
|
||||
return redirect('tournament:detail', pk=team.tournament.pk)
|
||||
elif "request_validation" in request.POST and request.user.participates and team.can_validate:
|
||||
team.validation_status = "1waiting"
|
||||
team.save()
|
||||
team.tournament.send_mail_to_organizers("request_validation", "Demande de validation TFJM²", team=team)
|
||||
return redirect('tournament:team_detail', pk=team.pk)
|
||||
elif "validate" in request.POST and request.user.organizes:
|
||||
team.validation_status = "2valid"
|
||||
team.save()
|
||||
team.send_mail("validate_team", "Équipe validée TFJM²")
|
||||
return redirect('tournament:team_detail', pk=team.pk)
|
||||
elif "invalidate" in request.POST and request.user.organizes:
|
||||
team.validation_status = "0invalid"
|
||||
team.save()
|
||||
team.send_mail("unvalidate_team", "Équipe non validée TFJM²")
|
||||
return redirect('tournament:team_detail', pk=team.pk)
|
||||
elif "delete" in request.POST and request.user.organizes:
|
||||
team.delete()
|
||||
return redirect('tournament:detail', pk=team.tournament.pk)
|
||||
elif "select_final" in request.POST and request.user.admin and not team.selected_for_final and team.pools:
|
||||
# We copy all solutions for solutions for the final
|
||||
for solution in team.solutions.all():
|
||||
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
id = ""
|
||||
for i in range(64):
|
||||
id += random.choice(alphabet)
|
||||
with solution.file.open("rb") as source:
|
||||
with open("/code/media/" + id, "wb") as dest:
|
||||
for chunk in source.chunks():
|
||||
dest.write(chunk)
|
||||
new_sol = Solution(
|
||||
file=id,
|
||||
team=team,
|
||||
problem=solution.problem,
|
||||
final=True,
|
||||
)
|
||||
new_sol.save()
|
||||
team.selected_for_final = True
|
||||
team.save()
|
||||
team.send_mail("select_for_final", "Sélection pour la finale, félicitations ! - TFJM²",
|
||||
final=Tournament.get_final())
|
||||
return redirect('tournament:team_detail', pk=team.pk)
|
||||
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
context["title"] = _("Information about team")
|
||||
context["ordered_solutions"] = self.object.solutions.order_by('problem').all()
|
||||
context["team_users_emails"] = [user.email for user in self.object.users.all()]
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class TeamUpdateView(LoginRequiredMixin, UpdateView):
|
||||
"""
|
||||
Update the information about a team.
|
||||
Team members, admins and organizers are allowed to do this.
|
||||
"""
|
||||
|
||||
model = Team
|
||||
form_class = TeamForm
|
||||
extra_context = dict(title=_("Update team"),)
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if not request.user.admin and self.request.user not in self.get_object().tournament.organizers.all() \
|
||||
and self.get_object() != self.request.user.team:
|
||||
raise PermissionDenied
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class AddOrganizerView(AdminMixin, CreateView):
|
||||
"""
|
||||
Add a new organizer account. No password is created, the user should reset its password using the link
|
||||
sent by mail. Only name and email are requested.
|
||||
Only admins are granted to do this.
|
||||
"""
|
||||
|
||||
model = TFJMUser
|
||||
form_class = OrganizerForm
|
||||
extra_context = dict(title=_("Add organizer"),)
|
||||
template_name = "tournament/add_organizer.html"
|
||||
|
||||
def form_valid(self, form):
|
||||
user = form.instance
|
||||
msg = render_to_string("mail_templates/add_organizer.txt", context=dict(user=user))
|
||||
msg_html = render_to_string("mail_templates/add_organizer.html", context=dict(user=user))
|
||||
send_mail('Organisateur du TFJM² 2020', msg, 'contact@tfjm.org', [user.email], html_message=msg_html)
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy('index')
|
||||
|
||||
|
||||
class SolutionsView(TeamMixin, BaseFormView, SingleTableView):
|
||||
"""
|
||||
Upload and view solutions for a team.
|
||||
"""
|
||||
|
||||
model = Solution
|
||||
table_class = SolutionTable
|
||||
form_class = SolutionForm
|
||||
template_name = "tournament/solutions_list.html"
|
||||
extra_context = dict(title=_("Solutions"))
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if "zip" in request.POST:
|
||||
solutions = request.user.team.solutions
|
||||
|
||||
out = BytesIO()
|
||||
zf = zipfile.ZipFile(out, "w")
|
||||
|
||||
for solution in solutions:
|
||||
zf.write(solution.file.path, str(solution) + ".pdf")
|
||||
|
||||
zf.close()
|
||||
|
||||
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
|
||||
resp['Content-Disposition'] = 'attachment; filename={}'\
|
||||
.format(_("Solutions for team {team}.zip")
|
||||
.format(team=str(request.user.team)).replace(" ", "%20"))
|
||||
return resp
|
||||
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
self.object_list = self.get_queryset()
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["now"] = timezone.now()
|
||||
context["real_deadline"] = self.request.user.team.future_tournament.date_solutions + timedelta(minutes=30)
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset().filter(team=self.request.user.team)
|
||||
return qs.order_by('final', 'team__tournament__date_start', 'team__tournament__name', 'team__trigram',
|
||||
'problem',)
|
||||
|
||||
def form_valid(self, form):
|
||||
solution = form.instance
|
||||
solution.team = self.request.user.team
|
||||
solution.final = solution.team.selected_for_final
|
||||
|
||||
if timezone.now() > solution.tournament.date_solutions + timedelta(minutes=30):
|
||||
form.add_error('file', _("You can't publish your solution anymore. Deadline: {date:%m-%d-%Y %H:%M}.")
|
||||
.format(date=timezone.localtime(solution.tournament.date_solutions)))
|
||||
return super().form_invalid(form)
|
||||
|
||||
prev_sol = Solution.objects.filter(problem=solution.problem, team=solution.team, final=solution.final)
|
||||
for sol in prev_sol.all():
|
||||
sol.delete()
|
||||
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
id = ""
|
||||
for i in range(64):
|
||||
id += random.choice(alphabet)
|
||||
solution.file.name = id
|
||||
solution.save()
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy("tournament:solutions")
|
||||
|
||||
|
||||
class SolutionsOrgaListView(OrgaMixin, SingleTableView):
|
||||
"""
|
||||
View all solutions sent by teams for the organized tournaments. Juries can view solutions of their pools.
|
||||
Organizers can download a ZIP archive for each organized tournament.
|
||||
"""
|
||||
|
||||
model = Solution
|
||||
table_class = SolutionTable
|
||||
template_name = "tournament/solutions_orga_list.html"
|
||||
extra_context = dict(title=_("All solutions"))
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if "tournament_zip" in request.POST:
|
||||
tournament = Tournament.objects.get(pk=int(request.POST["tournament_zip"]))
|
||||
solutions = tournament.solutions
|
||||
if not request.user.admin and request.user not in tournament.organizers.all():
|
||||
raise PermissionDenied
|
||||
|
||||
out = BytesIO()
|
||||
zf = zipfile.ZipFile(out, "w")
|
||||
|
||||
for solution in solutions:
|
||||
zf.write(solution.file.path, str(solution) + ".pdf")
|
||||
|
||||
zf.close()
|
||||
|
||||
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
|
||||
resp['Content-Disposition'] = 'attachment; filename={}'\
|
||||
.format(_("Solutions for tournament {tournament}.zip")
|
||||
.format(tournament=str(tournament)).replace(" ", "%20"))
|
||||
return resp
|
||||
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
context["tournaments"] = \
|
||||
Tournament.objects if self.request.user.admin else self.request.user.organized_tournaments
|
||||
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
if not self.request.user.admin:
|
||||
if self.request.user in Tournament.get_final().organizers.all():
|
||||
qs = qs.filter(Q(team__tournament__organizers=self.request.user) | Q(pools__juries=self.request.user)
|
||||
| Q(final=True))
|
||||
else:
|
||||
qs = qs.filter(Q(team__tournament__organizers=self.request.user) | Q(pools__juries=self.request.user))
|
||||
return qs.order_by('final', 'team__tournament__date_start', 'team__tournament__name', 'team__trigram',
|
||||
'problem',).distinct()
|
||||
|
||||
|
||||
class SynthesesView(TeamMixin, BaseFormView, SingleTableView):
|
||||
"""
|
||||
Upload and view syntheses for a team.
|
||||
"""
|
||||
model = Synthesis
|
||||
table_class = SynthesisTable
|
||||
form_class = SynthesisForm
|
||||
template_name = "tournament/syntheses_list.html"
|
||||
extra_context = dict(title=_("Syntheses"))
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if "zip" in request.POST:
|
||||
syntheses = request.user.team.syntheses
|
||||
|
||||
out = BytesIO()
|
||||
zf = zipfile.ZipFile(out, "w")
|
||||
|
||||
for synthesis in syntheses:
|
||||
zf.write(synthesis.file.path, str(synthesis) + ".pdf")
|
||||
|
||||
zf.close()
|
||||
|
||||
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
|
||||
resp['Content-Disposition'] = 'attachment; filename={}'\
|
||||
.format(_("Syntheses for team {team}.zip")
|
||||
.format(team=str(request.user.team)).replace(" ", "%20"))
|
||||
return resp
|
||||
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset().filter(team=self.request.user.team)
|
||||
return qs.order_by('final', 'team__tournament__date_start', 'team__tournament__name', 'team__trigram',
|
||||
'round', 'source',)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
self.object_list = self.get_queryset()
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["now"] = timezone.now()
|
||||
context["real_deadline_1"] = self.request.user.team.future_tournament.date_syntheses + timedelta(minutes=30)
|
||||
context["real_deadline_2"] = self.request.user.team.future_tournament.date_syntheses_2 + timedelta(minutes=30)
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
synthesis = form.instance
|
||||
synthesis.team = self.request.user.team
|
||||
synthesis.final = synthesis.team.selected_for_final
|
||||
|
||||
if synthesis.round == '1' and timezone.now() > (synthesis.tournament.date_syntheses + timedelta(minutes=30)):
|
||||
form.add_error('file', _("You can't publish your synthesis anymore for the first round."
|
||||
" Deadline: {date:%m-%d-%Y %H:%M}.")
|
||||
.format(date=timezone.localtime(synthesis.tournament.date_syntheses)))
|
||||
return super().form_invalid(form)
|
||||
|
||||
if synthesis.round == '2' and timezone.now() > synthesis.tournament.date_syntheses_2 + timedelta(minutes=30):
|
||||
form.add_error('file', _("You can't publish your synthesis anymore for the second round."
|
||||
" Deadline: {date:%m-%d-%Y %H:%M}.")
|
||||
.format(date=timezone.localtime(synthesis.tournament.date_syntheses_2)))
|
||||
return super().form_invalid(form)
|
||||
|
||||
prev_syn = Synthesis.objects.filter(team=synthesis.team, round=synthesis.round, source=synthesis.source,
|
||||
final=synthesis.final)
|
||||
for syn in prev_syn.all():
|
||||
syn.delete()
|
||||
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
id = ""
|
||||
for i in range(64):
|
||||
id += random.choice(alphabet)
|
||||
synthesis.file.name = id
|
||||
synthesis.save()
|
||||
|
||||
return super().form_valid(form)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy("tournament:syntheses")
|
||||
|
||||
|
||||
class SynthesesOrgaListView(OrgaMixin, SingleTableView):
|
||||
"""
|
||||
View all syntheses sent by teams for the organized tournaments. Juries can view syntheses of their pools.
|
||||
Organizers can download a ZIP archive for each organized tournament.
|
||||
"""
|
||||
model = Synthesis
|
||||
table_class = SynthesisTable
|
||||
template_name = "tournament/syntheses_orga_list.html"
|
||||
extra_context = dict(title=_("All syntheses"))
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
if "tournament_zip" in request.POST:
|
||||
tournament = Tournament.objects.get(pk=request.POST["tournament_zip"])
|
||||
syntheses = tournament.syntheses
|
||||
if not request.user.admin and request.user not in tournament.organizers.all():
|
||||
raise PermissionDenied
|
||||
|
||||
out = BytesIO()
|
||||
zf = zipfile.ZipFile(out, "w")
|
||||
|
||||
for synthesis in syntheses:
|
||||
zf.write(synthesis.file.path, str(synthesis) + ".pdf")
|
||||
|
||||
zf.close()
|
||||
|
||||
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
|
||||
resp['Content-Disposition'] = 'attachment; filename={}'\
|
||||
.format(_("Syntheses for tournament {tournament}.zip")
|
||||
.format(tournament=str(tournament)).replace(" ", "%20"))
|
||||
return resp
|
||||
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
context["tournaments"] = \
|
||||
Tournament.objects if self.request.user.admin else self.request.user.organized_tournaments
|
||||
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
if not self.request.user.admin:
|
||||
if self.request.user in Tournament.get_final().organizers.all():
|
||||
qs = qs.filter(Q(team__tournament__organizers=self.request.user)
|
||||
| Q(team__pools__juries=self.request.user)
|
||||
| Q(final=True))
|
||||
else:
|
||||
qs = qs.filter(Q(team__tournament__organizers=self.request.user)
|
||||
| Q(team__pools__juries=self.request.user))
|
||||
return qs.order_by('final', 'team__tournament__date_start', 'team__tournament__name', 'team__trigram',
|
||||
'round', 'source',).distinct()
|
||||
|
||||
|
||||
class PoolListView(LoginRequiredMixin, SingleTableView):
|
||||
"""
|
||||
View the list of visible pools.
|
||||
Admins see all, juries see their own pools, organizers see the pools of their tournaments.
|
||||
"""
|
||||
model = Pool
|
||||
table_class = PoolTable
|
||||
extra_context = dict(title=_("Pools"))
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
user = self.request.user
|
||||
if not user.admin and user.organizes:
|
||||
qs = qs.filter(Q(juries=user) | Q(teams__tournament__organizers=user))
|
||||
elif user.participates:
|
||||
qs = qs.filter(teams=user.team)
|
||||
qs = qs.distinct().order_by('solutions__final', 'teams__tournament__date_start', 'teams__tournament__name',
|
||||
'round',)
|
||||
return qs
|
||||
|
||||
|
||||
class PoolCreateView(AdminMixin, CreateView):
|
||||
"""
|
||||
Create a pool manually.
|
||||
This page should not be used: prefer send automatically data from the drawing bot.
|
||||
"""
|
||||
model = Pool
|
||||
form_class = PoolForm
|
||||
extra_context = dict(title=_("Create pool"))
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy("tournament:pools")
|
||||
|
||||
|
||||
class PoolDetailView(LoginRequiredMixin, DetailView):
|
||||
"""
|
||||
See the detail of a pool.
|
||||
Teams and juries can download here defended solutions of the pool.
|
||||
If this is the second round, teams can't download solutions of the other teams before the date when they
|
||||
should be available.
|
||||
Juries see also syntheses. They see of course solutions immediately.
|
||||
This is also true for organizers and admins.
|
||||
All can be downloaded as a ZIP archive.
|
||||
"""
|
||||
model = Pool
|
||||
extra_context = dict(title=_("Pool detail"))
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
user = self.request.user
|
||||
if not user.admin and user.organizes:
|
||||
qs = qs.filter(Q(juries=user) | Q(teams__tournament__organizers=user))
|
||||
elif user.participates:
|
||||
qs = qs.filter(teams=user.team)
|
||||
return qs.distinct()
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
user = request.user
|
||||
pool = self.get_object()
|
||||
|
||||
if "solutions_zip" in request.POST:
|
||||
if user.participates and pool.round == 2 and pool.tournament.date_solutions_2 > timezone.now():
|
||||
raise PermissionDenied
|
||||
|
||||
out = BytesIO()
|
||||
zf = zipfile.ZipFile(out, "w")
|
||||
|
||||
for solution in pool.solutions.all():
|
||||
zf.write(solution.file.path, str(solution) + ".pdf")
|
||||
|
||||
zf.close()
|
||||
|
||||
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
|
||||
resp['Content-Disposition'] = 'attachment; filename={}' \
|
||||
.format(_("Solutions of a pool for the round {round} of the tournament {tournament}.zip")
|
||||
.format(round=pool.round, tournament=str(pool.tournament)).replace(" ", "%20"))
|
||||
return resp
|
||||
elif "syntheses_zip" in request.POST and user.organizes:
|
||||
if user.participates and pool.round == 2 and pool.tournament.date_solutions_2 > timezone.now():
|
||||
raise PermissionDenied
|
||||
|
||||
out = BytesIO()
|
||||
zf = zipfile.ZipFile(out, "w")
|
||||
|
||||
for synthesis in pool.syntheses.all():
|
||||
zf.write(synthesis.file.path, str(synthesis) + ".pdf")
|
||||
|
||||
zf.close()
|
||||
|
||||
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
|
||||
resp['Content-Disposition'] = 'attachment; filename={}' \
|
||||
.format(_("Syntheses of a pool for the round {round} of the tournament {tournament}.zip")
|
||||
.format(round=pool.round, tournament=str(pool.tournament)).replace(" ", "%20"))
|
||||
return resp
|
||||
|
||||
return self.get(request, *args, **kwargs)
|
2
chat/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
28
chat/admin.py
Normal file
@ -0,0 +1,28 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Channel, Message
|
||||
|
||||
|
||||
@admin.register(Channel)
|
||||
class ChannelAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Modèle d'administration des canaux de chat.
|
||||
"""
|
||||
list_display = ('name', 'category', 'read_access', 'write_access', 'tournament', 'private',)
|
||||
list_filter = ('category', 'read_access', 'write_access', 'tournament', 'private',)
|
||||
search_fields = ('name', 'tournament__name', 'team__name', 'team__trigram',)
|
||||
autocomplete_fields = ('tournament', 'pool', 'team', 'invited', )
|
||||
|
||||
|
||||
@admin.register(Message)
|
||||
class MessageAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Modèle d'administration des messages de chat.
|
||||
"""
|
||||
list_display = ('channel', 'author', 'created_at', 'updated_at', 'content',)
|
||||
list_filter = ('channel', 'created_at', 'updated_at',)
|
||||
search_fields = ('author__username', 'author__first_name', 'author__last_name', 'content',)
|
||||
autocomplete_fields = ('channel', 'author', 'users_read',)
|
16
chat/apps.py
Normal file
@ -0,0 +1,16 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.db.models.signals import post_save
|
||||
|
||||
|
||||
class ChatConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "chat"
|
||||
|
||||
def ready(self):
|
||||
from chat import signals
|
||||
post_save.connect(signals.create_tournament_channels, "participation.Tournament")
|
||||
post_save.connect(signals.create_pool_channels, "participation.Pool")
|
||||
post_save.connect(signals.create_team_channel, "participation.Participation")
|
370
chat/consumers.py
Normal file
@ -0,0 +1,370 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Count, F, Q
|
||||
from registration.models import Registration
|
||||
|
||||
from .models import Channel, Message
|
||||
|
||||
|
||||
class ChatConsumer(AsyncJsonWebsocketConsumer):
|
||||
"""
|
||||
Ce consommateur gère les connexions WebSocket pour le chat.
|
||||
"""
|
||||
async def connect(self) -> None:
|
||||
"""
|
||||
Cette fonction est appelée lorsqu'un nouveau websocket tente de se connecter au serveur.
|
||||
On n'accept que si c'est un⋅e utilisateur⋅rice connecté⋅e.
|
||||
"""
|
||||
if '_fake_user_id' in self.scope['session']:
|
||||
# Dans le cas d'une impersonification, on charge l'utilisateur⋅rice concerné
|
||||
self.scope['user'] = await User.objects.aget(pk=self.scope['session']['_fake_user_id'])
|
||||
|
||||
# Récupération de l'utilisateur⋅rice courant⋅e
|
||||
user = self.scope['user']
|
||||
if user.is_anonymous:
|
||||
# L'utilisateur⋅rice n'est pas connecté⋅e
|
||||
await self.close()
|
||||
return
|
||||
|
||||
reg = await Registration.objects.aget(user_id=user.id)
|
||||
self.registration = reg
|
||||
|
||||
# Acceptation de la connexion
|
||||
await self.accept()
|
||||
|
||||
# Récupération des canaux accessibles en lecture et/ou en écriture
|
||||
self.read_channels = await Channel.get_accessible_channels(user, 'read')
|
||||
self.write_channels = await Channel.get_accessible_channels(user, 'write')
|
||||
|
||||
# Abonnement aux canaux de diffusion Websocket pour les différents canaux de chat
|
||||
async for channel in self.read_channels.all():
|
||||
await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
|
||||
# Abonnement à un canal de diffusion Websocket personnel, utile pour s'adresser à une unique personne
|
||||
await self.channel_layer.group_add(f"user-{user.id}", self.channel_name)
|
||||
|
||||
async def disconnect(self, close_code: int) -> None:
|
||||
"""
|
||||
Cette fonction est appelée lorsqu'un websocket se déconnecte du serveur.
|
||||
:param close_code: Le code d'erreur.
|
||||
"""
|
||||
if self.scope['user'].is_anonymous:
|
||||
# L'utilisateur⋅rice n'était pas connecté⋅e, on ne fait rien
|
||||
return
|
||||
|
||||
async for channel in self.read_channels.all():
|
||||
# Désabonnement des canaux de diffusion Websocket liés aux canaux de chat
|
||||
await self.channel_layer.group_discard(f"chat-{channel.id}", self.channel_name)
|
||||
# Désabonnement du canal de diffusion Websocket personnel
|
||||
await self.channel_layer.group_discard(f"user-{self.scope['user'].id}", self.channel_name)
|
||||
|
||||
async def receive_json(self, content: dict, **kwargs) -> None:
|
||||
"""
|
||||
Appelée lorsque le client nous envoie des données, décodées depuis du JSON.
|
||||
:param content: Les données envoyées par le client, décodées depuis du JSON. Doit contenir un champ 'type'.
|
||||
"""
|
||||
match content['type']:
|
||||
case 'fetch_channels':
|
||||
# Demande de récupération des canaux disponibles
|
||||
await self.fetch_channels()
|
||||
case 'send_message':
|
||||
# Envoi d'un message dans un canal
|
||||
await self.receive_message(**content)
|
||||
case 'edit_message':
|
||||
# Modification d'un message
|
||||
await self.edit_message(**content)
|
||||
case 'delete_message':
|
||||
# Suppression d'un message
|
||||
await self.delete_message(**content)
|
||||
case 'fetch_messages':
|
||||
# Récupération des messages d'un canal (ou d'une partie)
|
||||
await self.fetch_messages(**content)
|
||||
case 'mark_read':
|
||||
# Marquage de messages comme lus
|
||||
await self.mark_read(**content)
|
||||
case 'start_private_chat':
|
||||
# Démarrage d'une conversation privée avec un⋅e autre utilisateur⋅rice
|
||||
await self.start_private_chat(**content)
|
||||
case unknown:
|
||||
# Type inconnu, on soulève une erreur
|
||||
raise ValueError(f"Unknown message type: {unknown}")
|
||||
|
||||
async def fetch_channels(self) -> None:
|
||||
"""
|
||||
L'utilisateur⋅rice demande à récupérer la liste des canaux disponibles.
|
||||
On lui renvoie alors la liste des canaux qui lui sont accessibles en lecture,
|
||||
en fournissant nom, catégorie, permission de lecture et nombre de messages non lus.
|
||||
"""
|
||||
user = self.scope['user']
|
||||
|
||||
# Récupération des canaux accessibles en lecture, avec le nombre de messages non lus
|
||||
channels = self.read_channels.prefetch_related('invited') \
|
||||
.annotate(total_messages=Count('messages', distinct=True)) \
|
||||
.annotate(read_messages=Count('messages', filter=Q(messages__users_read=user), distinct=True)) \
|
||||
.annotate(unread_messages=F('total_messages') - F('read_messages')).all()
|
||||
|
||||
# Envoi de la liste des canaux
|
||||
message = {
|
||||
'type': 'fetch_channels',
|
||||
'channels': [
|
||||
{
|
||||
'id': channel.id,
|
||||
'name': channel.get_visible_name(user),
|
||||
'category': channel.category,
|
||||
'read_access': True,
|
||||
'write_access': await self.write_channels.acontains(channel),
|
||||
'unread_messages': channel.unread_messages,
|
||||
}
|
||||
async for channel in channels
|
||||
]
|
||||
}
|
||||
await self.send_json(message)
|
||||
|
||||
async def receive_message(self, channel_id: int, content: str, **kwargs) -> None:
|
||||
"""
|
||||
L'utilisateur⋅ice a envoyé un message dans un canal.
|
||||
On vérifie d'abord la permission d'écriture, puis on crée le message et on l'envoie à tou⋅tes les
|
||||
utilisateur⋅ices abonné⋅es au canal.
|
||||
|
||||
:param channel_id: Identifiant du canal où envoyer le message.
|
||||
:param content: Contenu du message.
|
||||
"""
|
||||
user = self.scope['user']
|
||||
|
||||
# Récupération du canal
|
||||
channel = await Channel.objects.prefetch_related('tournament__pools__juries', 'pool', 'team', 'invited') \
|
||||
.aget(id=channel_id)
|
||||
if not await self.write_channels.acontains(channel):
|
||||
# L'utilisateur⋅ice n'a pas la permission d'écrire dans ce canal, on abandonne
|
||||
return
|
||||
|
||||
# Création du message
|
||||
message = await Message.objects.acreate(
|
||||
author=user,
|
||||
channel=channel,
|
||||
content=content,
|
||||
)
|
||||
|
||||
# Envoi du message à toutes les personnes connectées sur le canal
|
||||
await self.channel_layer.group_send(f'chat-{channel.id}', {
|
||||
'type': 'chat.send_message',
|
||||
'id': message.id,
|
||||
'channel_id': channel.id,
|
||||
'timestamp': message.created_at.isoformat(),
|
||||
'author_id': message.author_id,
|
||||
'author': await message.aget_author_name(),
|
||||
'content': message.content,
|
||||
})
|
||||
|
||||
async def edit_message(self, message_id: int, content: str, **kwargs) -> None:
|
||||
"""
|
||||
L'utilisateur⋅ice a modifié un message.
|
||||
On vérifie d'abord que l'utilisateur⋅ice a le droit de modifier le message, puis on modifie le message
|
||||
et on envoie la modification à tou⋅tes les utilisateur⋅ices abonné⋅es au canal.
|
||||
|
||||
:param message_id: Identifiant du message à modifier.
|
||||
:param content: Nouveau contenu du message.
|
||||
"""
|
||||
user = self.scope['user']
|
||||
|
||||
# Récupération du message
|
||||
message = await Message.objects.aget(id=message_id)
|
||||
if user.id != message.author_id and not user.is_superuser:
|
||||
# Seul⋅e l'auteur⋅ice du message ou un⋅e admin peut modifier un message
|
||||
return
|
||||
|
||||
# Modification du contenu du message
|
||||
message.content = content
|
||||
await message.asave()
|
||||
|
||||
# Envoi de la modification à tou⋅tes les personnes connectées sur le canal
|
||||
await self.channel_layer.group_send(f'chat-{message.channel_id}', {
|
||||
'type': 'chat.edit_message',
|
||||
'id': message_id,
|
||||
'channel_id': message.channel_id,
|
||||
'content': content,
|
||||
})
|
||||
|
||||
async def delete_message(self, message_id: int, **kwargs) -> None:
|
||||
"""
|
||||
L'utilisateur⋅ice a supprimé un message.
|
||||
On vérifie d'abord que l'utilisateur⋅ice a le droit de supprimer le message, puis on supprime le message
|
||||
et on envoie la suppression à tou⋅tes les utilisateur⋅ices abonné⋅es au canal.
|
||||
|
||||
:param message_id: Identifiant du message à supprimer.
|
||||
"""
|
||||
user = self.scope['user']
|
||||
|
||||
# Récupération du message
|
||||
message = await Message.objects.aget(id=message_id)
|
||||
if user.id != message.author_id and not user.is_superuser:
|
||||
return
|
||||
|
||||
# Suppression effective du message
|
||||
await message.adelete()
|
||||
|
||||
# Envoi de la suppression à tou⋅tes les personnes connectées sur le canal
|
||||
await self.channel_layer.group_send(f'chat-{message.channel_id}', {
|
||||
'type': 'chat.delete_message',
|
||||
'id': message_id,
|
||||
'channel_id': message.channel_id,
|
||||
})
|
||||
|
||||
async def fetch_messages(self, channel_id: int, offset: int = 0, limit: int = 50, **_kwargs) -> None:
|
||||
"""
|
||||
L'utilisateur⋅ice demande à récupérer les messages d'un canal.
|
||||
On vérifie la permission de lecture, puis on renvoie les messages demandés.
|
||||
|
||||
:param channel_id: Identifiant du canal où récupérer les messages.
|
||||
:param offset: Décalage pour la pagination, à partir du dernier message.
|
||||
Par défaut : 0, on commence au dernier message.
|
||||
:param limit: Nombre de messages à récupérer. Par défaut, on récupère 50 messages.
|
||||
"""
|
||||
# Récupération du canal
|
||||
channel = await Channel.objects.aget(id=channel_id)
|
||||
if not await self.read_channels.acontains(channel):
|
||||
# L'utilisateur⋅rice n'a pas la permission de lire ce canal, on abandonne
|
||||
return
|
||||
|
||||
limit = min(limit, 200) # On limite le nombre de messages à 200 maximum
|
||||
|
||||
# Récupération des messages, avec un indicateur de lecture pour l'utilisateur⋅ice courant⋅e
|
||||
messages = Message.objects \
|
||||
.filter(channel=channel) \
|
||||
.annotate(read=Count('users_read', filter=Q(users_read=self.scope['user']))) \
|
||||
.order_by('-created_at')[offset:offset + limit].all()
|
||||
|
||||
# Envoi de la liste des messages, en les renvoyant dans l'ordre chronologique
|
||||
await self.send_json({
|
||||
'type': 'fetch_messages',
|
||||
'channel_id': channel_id,
|
||||
'messages': list(reversed([
|
||||
{
|
||||
'id': message.id,
|
||||
'timestamp': message.created_at.isoformat(),
|
||||
'author_id': message.author_id,
|
||||
'author': await message.aget_author_name(),
|
||||
'content': message.content,
|
||||
'read': message.read > 0,
|
||||
}
|
||||
async for message in messages
|
||||
]))
|
||||
})
|
||||
|
||||
async def mark_read(self, message_ids: list[int], **_kwargs) -> None:
|
||||
"""
|
||||
L'utilisateur⋅ice marque des messages comme lus, après les avoir affichés à l'écran.
|
||||
|
||||
:param message_ids: Liste des identifiants des messages qu'il faut marquer comme lus.
|
||||
"""
|
||||
# Récupération des messages à marquer comme lus
|
||||
messages = Message.objects.filter(id__in=message_ids)
|
||||
async for message in messages.all():
|
||||
# Ajout de l'utilisateur⋅ice courant⋅e à la liste des personnes ayant lu le message
|
||||
await message.users_read.aadd(self.scope['user'])
|
||||
|
||||
# Actualisation du nombre de messages non lus par canal
|
||||
unread_messages_by_channel = Message.objects.exclude(users_read=self.scope['user']).values('channel_id') \
|
||||
.annotate(unread_messages=Count('channel_id'))
|
||||
|
||||
# Envoi des identifiants des messages non lus et du nombre de messages non lus par canal, actualisés
|
||||
await self.send_json({
|
||||
'type': 'mark_read',
|
||||
'messages': [{'id': message.id, 'channel_id': message.channel_id} async for message in messages.all()],
|
||||
'unread_messages': {group['channel_id']: group['unread_messages']
|
||||
async for group in unread_messages_by_channel.all()},
|
||||
})
|
||||
|
||||
async def start_private_chat(self, user_id: int, **kwargs) -> None:
|
||||
"""
|
||||
L'utilisateur⋅ice souhaite démarrer une conversation privée avec un⋅e autre utilisateur⋅ice.
|
||||
Pour cela, on récupère le salon privé s'il existe, sinon on en crée un.
|
||||
Dans le cas d'une création, les deux personnes sont transférées immédiatement dans ce nouveau canal.
|
||||
|
||||
:param user_id: L'utilisateur⋅rice avec qui démarrer la conversation privée.
|
||||
"""
|
||||
user = self.scope['user']
|
||||
# Récupération de l'autre utilisateur⋅ice avec qui démarrer la conversation
|
||||
other_user = await User.objects.aget(id=user_id)
|
||||
|
||||
# Vérification de l'existence d'un salon privé entre les deux personnes
|
||||
channel_qs = Channel.objects.filter(private=True).filter(invited=user).filter(invited=other_user)
|
||||
if not await channel_qs.aexists():
|
||||
# Le salon privé n'existe pas, on le crée alors
|
||||
channel = await Channel.objects.acreate(
|
||||
name=f"{user.first_name} {user.last_name}, {other_user.first_name} {other_user.last_name}",
|
||||
category=Channel.ChannelCategory.PRIVATE,
|
||||
private=True,
|
||||
)
|
||||
await channel.invited.aset([user, other_user])
|
||||
|
||||
# On s'ajoute au salon privé
|
||||
await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
|
||||
|
||||
if user != other_user:
|
||||
# On transfère l'autre utilisateur⋅ice dans le salon privé
|
||||
await self.channel_layer.group_send(f"user-{other_user.id}", {
|
||||
'type': 'chat.start_private_chat',
|
||||
'channel': {
|
||||
'id': channel.id,
|
||||
'name': f"{user.first_name} {user.last_name}",
|
||||
'category': channel.category,
|
||||
'read_access': True,
|
||||
'write_access': True,
|
||||
}
|
||||
})
|
||||
else:
|
||||
# Récupération dudit salon privé
|
||||
channel = await channel_qs.afirst()
|
||||
|
||||
# Invitation de l'autre utilisateur⋅rice à rejoindre le salon privé
|
||||
await self.channel_layer.group_send(f"user-{user.id}", {
|
||||
'type': 'chat.start_private_chat',
|
||||
'channel': {
|
||||
'id': channel.id,
|
||||
'name': f"{other_user.first_name} {other_user.last_name}",
|
||||
'category': channel.category,
|
||||
'read_access': True,
|
||||
'write_access': True,
|
||||
}
|
||||
})
|
||||
|
||||
async def chat_send_message(self, message) -> None:
|
||||
"""
|
||||
Envoi d'un message à tou⋅tes les personnes connectées sur un canal.
|
||||
:param message: Dictionnaire contenant les informations du message à envoyer,
|
||||
contenant l'identifiant du message "id", l'identifiant du canal "channel_id",
|
||||
l'heure de création "timestamp", l'identifiant de l'auteur "author_id",
|
||||
le nom de l'auteur "author" et le contenu du message "content".
|
||||
"""
|
||||
await self.send_json({'type': 'send_message', 'id': message['id'], 'channel_id': message['channel_id'],
|
||||
'timestamp': message['timestamp'], 'author': message['author'],
|
||||
'content': message['content']})
|
||||
|
||||
async def chat_edit_message(self, message) -> None:
|
||||
"""
|
||||
Envoi d'une modification de message à tou⋅tes les personnes connectées sur un canal.
|
||||
:param message: Dictionnaire contenant les informations du message à modifier,
|
||||
contenant l'identifiant du message "id", l'identifiant du canal "channel_id"
|
||||
et le nouveau contenu "content".
|
||||
"""
|
||||
await self.send_json({'type': 'edit_message', 'id': message['id'], 'channel_id': message['channel_id'],
|
||||
'content': message['content']})
|
||||
|
||||
async def chat_delete_message(self, message) -> None:
|
||||
"""
|
||||
Envoi d'une suppression de message à tou⋅tes les personnes connectées sur un canal.
|
||||
:param message: Dictionnaire contenant les informations du message à supprimer,
|
||||
contenant l'identifiant du message "id" et l'identifiant du canal "channel_id".
|
||||
"""
|
||||
await self.send_json({'type': 'delete_message', 'id': message['id'], 'channel_id': message['channel_id']})
|
||||
|
||||
async def chat_start_private_chat(self, message) -> None:
|
||||
"""
|
||||
Envoi d'un message pour démarrer une conversation privée à une personne connectée.
|
||||
:param message: Dictionnaire contenant les informations du nouveau canal privé.
|
||||
"""
|
||||
await self.channel_layer.group_add(f"chat-{message['channel']['id']}", self.channel_name)
|
||||
await self.send_json({'type': 'start_private_chat', 'channel': message['channel']})
|
167
chat/management/commands/create_chat_channels.py
Normal file
@ -0,0 +1,167 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import BaseCommand
|
||||
from django.utils.translation import activate
|
||||
from participation.models import Team, Tournament
|
||||
from tfjm.permissions import PermissionType
|
||||
|
||||
from ...models import Channel
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Cette commande permet de créer les canaux de chat pour les tournois et les équipes.
|
||||
Différents canaux sont créés pour chaque tournoi, puis pour chaque poule.
|
||||
Enfin, un canal de communication par équipe est créé.
|
||||
"""
|
||||
help = "Create chat channels for tournaments and teams."
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
activate(settings.PREFERRED_LANGUAGE_CODE)
|
||||
|
||||
# Création de canaux généraux, d'annonces, d'aide jurys et orgas, etc.
|
||||
# Le canal d'annonces est accessibles à tous⋅tes, mais seul⋅es les admins peuvent y écrire.
|
||||
Channel.objects.update_or_create(
|
||||
name="Annonces",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.GENERAL,
|
||||
read_access=PermissionType.AUTHENTICATED,
|
||||
write_access=PermissionType.ADMIN,
|
||||
),
|
||||
)
|
||||
|
||||
# Un canal d'aide pour les bénévoles est dédié.
|
||||
Channel.objects.update_or_create(
|
||||
name="Aide jurys et orgas",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.GENERAL,
|
||||
read_access=PermissionType.VOLUNTEER,
|
||||
write_access=PermissionType.VOLUNTEER,
|
||||
),
|
||||
)
|
||||
|
||||
# Un canal de discussion générale en lien avec le tournoi est accessible librement.
|
||||
Channel.objects.update_or_create(
|
||||
name="Général",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.GENERAL,
|
||||
read_access=PermissionType.AUTHENTICATED,
|
||||
write_access=PermissionType.AUTHENTICATED,
|
||||
),
|
||||
)
|
||||
|
||||
# Un canal de discussion entre participant⋅es est accessible à tous⋅tes,
|
||||
# dont l'objectif est de faciliter la mise en relation entre élèves afin de constituer une équipe.
|
||||
Channel.objects.update_or_create(
|
||||
name="Je cherche une équipe",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.GENERAL,
|
||||
read_access=PermissionType.AUTHENTICATED,
|
||||
write_access=PermissionType.AUTHENTICATED,
|
||||
),
|
||||
)
|
||||
|
||||
# Un canal de discussion libre est accessible pour tous⋅tes.
|
||||
Channel.objects.update_or_create(
|
||||
name="Détente",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.GENERAL,
|
||||
read_access=PermissionType.AUTHENTICATED,
|
||||
write_access=PermissionType.AUTHENTICATED,
|
||||
),
|
||||
)
|
||||
|
||||
for tournament in Tournament.objects.all():
|
||||
# Pour chaque tournoi, on crée un canal d'annonces, un canal général et un de détente,
|
||||
# qui sont comme les canaux généraux du même nom mais réservés aux membres du tournoi concerné.
|
||||
# Les membres d'un tournoi sont les organisateur⋅rices, les juré⋅es d'une poule du tournoi
|
||||
# ainsi que les membres d'une équipe inscrite au tournoi et qui est validée.
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Annonces",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
write_access=PermissionType.TOURNAMENT_ORGANIZER,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Général",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
write_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Détente",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
write_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
# Un canal réservé à tous⋅tes les juré⋅es du tournoi est créé.
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Juré⋅es",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.JURY_MEMBER,
|
||||
write_access=PermissionType.JURY_MEMBER,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
if tournament.remote:
|
||||
# Dans le cadre d'un tournoi distanciel, un canal pour les président⋅es de jury est créé.
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Président⋅es de jury",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.TOURNAMENT_JURY_PRESIDENT,
|
||||
write_access=PermissionType.TOURNAMENT_JURY_PRESIDENT,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
for pool in tournament.pools.all():
|
||||
# Pour chaque poule d'un tournoi distanciel, on crée un canal pour les membres de la poule
|
||||
# (équipes et juré⋅es), et un pour les juré⋅es uniquement.
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Poule {pool.short_name}",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.POOL_MEMBER,
|
||||
write_access=PermissionType.POOL_MEMBER,
|
||||
pool=pool,
|
||||
),
|
||||
)
|
||||
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Poule {pool.short_name} - Jury",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.JURY_MEMBER,
|
||||
write_access=PermissionType.JURY_MEMBER,
|
||||
pool=pool,
|
||||
),
|
||||
)
|
||||
|
||||
for team in Team.objects.filter(participation__valid=True).all():
|
||||
# Chaque équipe validée a le droit à son canal de communication.
|
||||
Channel.objects.update_or_create(
|
||||
name=f"Équipe {team.trigram}",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TEAM,
|
||||
read_access=PermissionType.TEAM_MEMBER,
|
||||
write_access=PermissionType.TEAM_MEMBER,
|
||||
team=team,
|
||||
),
|
||||
)
|
200
chat/migrations/0001_initial.py
Normal file
@ -0,0 +1,200 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-27 07:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("participation", "0013_alter_pool_options_pool_room"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Channel",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255, verbose_name="name")),
|
||||
(
|
||||
"read_access",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("anonymous", "Everyone, including anonymous users"),
|
||||
("authenticated", "Authenticated users"),
|
||||
("volunteer", "All volunteers"),
|
||||
("tournament", "All members of a given tournament"),
|
||||
("organizer", "Tournament organizers only"),
|
||||
(
|
||||
"jury_president",
|
||||
"Tournament organizers and jury presidents of the tournament",
|
||||
),
|
||||
("jury", "Jury members of the pool"),
|
||||
("pool", "Jury members and participants of the pool"),
|
||||
(
|
||||
"team",
|
||||
"Members of the team and organizers of concerned tournaments",
|
||||
),
|
||||
(
|
||||
"private",
|
||||
"Private, reserved to explicit authorized users",
|
||||
),
|
||||
("admin", "Admin users"),
|
||||
],
|
||||
max_length=16,
|
||||
verbose_name="read permission",
|
||||
),
|
||||
),
|
||||
(
|
||||
"write_access",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("anonymous", "Everyone, including anonymous users"),
|
||||
("authenticated", "Authenticated users"),
|
||||
("volunteer", "All volunteers"),
|
||||
("tournament", "All members of a given tournament"),
|
||||
("organizer", "Tournament organizers only"),
|
||||
(
|
||||
"jury_president",
|
||||
"Tournament organizers and jury presidents of the tournament",
|
||||
),
|
||||
("jury", "Jury members of the pool"),
|
||||
("pool", "Jury members and participants of the pool"),
|
||||
(
|
||||
"team",
|
||||
"Members of the team and organizers of concerned tournaments",
|
||||
),
|
||||
(
|
||||
"private",
|
||||
"Private, reserved to explicit authorized users",
|
||||
),
|
||||
("admin", "Admin users"),
|
||||
],
|
||||
max_length=16,
|
||||
verbose_name="write permission",
|
||||
),
|
||||
),
|
||||
(
|
||||
"private",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="If checked, only users who have been explicitly added to the channel will be able to access it.",
|
||||
verbose_name="private",
|
||||
),
|
||||
),
|
||||
(
|
||||
"invited",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Extra users who have been invited to the channel, in addition to the permitted group of the channel.",
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="invited users",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pool",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="For a permission that concerns a pool, indicates what is the concerned pool.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="chat_channels",
|
||||
to="participation.pool",
|
||||
verbose_name="pool",
|
||||
),
|
||||
),
|
||||
(
|
||||
"team",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="For a permission that concerns a team, indicates what is the concerned team.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="chat_channels",
|
||||
to="participation.team",
|
||||
verbose_name="team",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tournament",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="For a permission that concerns a tournament, indicates what is the concerned tournament.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="chat_channels",
|
||||
to="participation.tournament",
|
||||
verbose_name="tournament",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "channel",
|
||||
"verbose_name_plural": "channels",
|
||||
"ordering": ("name",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Message",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="updated at"),
|
||||
),
|
||||
("content", models.TextField(verbose_name="content")),
|
||||
(
|
||||
"author",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="chat_messages",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="author",
|
||||
),
|
||||
),
|
||||
(
|
||||
"channel",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="messages",
|
||||
to="chat.channel",
|
||||
verbose_name="channel",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "message",
|
||||
"verbose_name_plural": "messages",
|
||||
"ordering": ("created_at",),
|
||||
},
|
||||
),
|
||||
]
|
@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-28 11:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("chat", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="channel",
|
||||
options={
|
||||
"ordering": ("category", "name"),
|
||||
"verbose_name": "channel",
|
||||
"verbose_name_plural": "channels",
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="channel",
|
||||
name="category",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("general", "General channels"),
|
||||
("tournament", "Tournament channels"),
|
||||
("team", "Team channels"),
|
||||
("private", "Private channels"),
|
||||
],
|
||||
default="general",
|
||||
max_length=255,
|
||||
verbose_name="category",
|
||||
),
|
||||
),
|
||||
]
|
26
chat/migrations/0003_message_users_read.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.0.3 on 2024-04-28 18:52
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("chat", "0002_alter_channel_options_channel_category"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="message",
|
||||
name="users_read",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Users who have read the message.",
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="users read",
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,94 @@
|
||||
# Generated by Django 5.0.6 on 2024-05-26 20:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("chat", "0003_message_users_read"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="channel",
|
||||
name="category",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("general", "General channels"),
|
||||
("tournament", "Tournament channels"),
|
||||
("team", "Team channels"),
|
||||
("private", "Private channels"),
|
||||
],
|
||||
default="general",
|
||||
help_text="Category of the channel, between general channels, tournament-specific channels, team channels or private channels. Will be used to sort channels in the channel list.",
|
||||
max_length=255,
|
||||
verbose_name="category",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="channel",
|
||||
name="name",
|
||||
field=models.CharField(
|
||||
help_text="Visible name of the channel.",
|
||||
max_length=255,
|
||||
verbose_name="name",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="channel",
|
||||
name="read_access",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("anonymous", "Everyone, including anonymous users"),
|
||||
("authenticated", "Authenticated users"),
|
||||
("volunteer", "All volunteers"),
|
||||
("tournament", "All members of a given tournament"),
|
||||
("organizer", "Tournament organizers only"),
|
||||
(
|
||||
"jury_president",
|
||||
"Tournament organizers and jury presidents of the tournament",
|
||||
),
|
||||
("jury", "Jury members of the pool"),
|
||||
("pool", "Jury members and participants of the pool"),
|
||||
(
|
||||
"team",
|
||||
"Members of the team and organizers of concerned tournaments",
|
||||
),
|
||||
("private", "Private, reserved to explicit authorized users"),
|
||||
("admin", "Admin users"),
|
||||
],
|
||||
help_text="Permission type that is required to read the messages of the channels.",
|
||||
max_length=16,
|
||||
verbose_name="read permission",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="channel",
|
||||
name="write_access",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("anonymous", "Everyone, including anonymous users"),
|
||||
("authenticated", "Authenticated users"),
|
||||
("volunteer", "All volunteers"),
|
||||
("tournament", "All members of a given tournament"),
|
||||
("organizer", "Tournament organizers only"),
|
||||
(
|
||||
"jury_president",
|
||||
"Tournament organizers and jury presidents of the tournament",
|
||||
),
|
||||
("jury", "Jury members of the pool"),
|
||||
("pool", "Jury members and participants of the pool"),
|
||||
(
|
||||
"team",
|
||||
"Members of the team and organizers of concerned tournaments",
|
||||
),
|
||||
("private", "Private, reserved to explicit authorized users"),
|
||||
("admin", "Admin users"),
|
||||
],
|
||||
help_text="Permission type that is required to write a message to a channel.",
|
||||
max_length=16,
|
||||
verbose_name="write permission",
|
||||
),
|
||||
),
|
||||
]
|
2
chat/migrations/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
365
chat/models.py
Normal file
@ -0,0 +1,365 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.utils.text import format_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from participation.models import Pool, Team, Tournament
|
||||
from registration.models import ParticipantRegistration, Registration, VolunteerRegistration
|
||||
from tfjm.permissions import PermissionType
|
||||
|
||||
|
||||
class Channel(models.Model):
|
||||
"""
|
||||
Ce modèle représente un canal de chat, défini par son nom, sa catégorie, les permissions de lecture et d'écriture
|
||||
requises pour accéder au canal, et éventuellement un tournoi, une poule ou une équipe associée.
|
||||
"""
|
||||
|
||||
class ChannelCategory(models.TextChoices):
|
||||
GENERAL = 'general', _("General channels")
|
||||
TOURNAMENT = 'tournament', _("Tournament channels")
|
||||
TEAM = 'team', _("Team channels")
|
||||
PRIVATE = 'private', _("Private channels")
|
||||
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("name"),
|
||||
help_text=_("Visible name of the channel."),
|
||||
)
|
||||
|
||||
category = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("category"),
|
||||
choices=ChannelCategory,
|
||||
default=ChannelCategory.GENERAL,
|
||||
help_text=_("Category of the channel, between general channels, tournament-specific channels, team channels "
|
||||
"or private channels. Will be used to sort channels in the channel list."),
|
||||
)
|
||||
|
||||
read_access = models.CharField(
|
||||
max_length=16,
|
||||
verbose_name=_("read permission"),
|
||||
choices=PermissionType,
|
||||
help_text=_("Permission type that is required to read the messages of the channels."),
|
||||
)
|
||||
|
||||
write_access = models.CharField(
|
||||
max_length=16,
|
||||
verbose_name=_("write permission"),
|
||||
choices=PermissionType,
|
||||
help_text=_("Permission type that is required to write a message to a channel."),
|
||||
)
|
||||
|
||||
tournament = models.ForeignKey(
|
||||
'participation.Tournament',
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("tournament"),
|
||||
related_name='chat_channels',
|
||||
help_text=_("For a permission that concerns a tournament, indicates what is the concerned tournament."),
|
||||
)
|
||||
|
||||
pool = models.ForeignKey(
|
||||
'participation.Pool',
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("pool"),
|
||||
related_name='chat_channels',
|
||||
help_text=_("For a permission that concerns a pool, indicates what is the concerned pool."),
|
||||
)
|
||||
|
||||
team = models.ForeignKey(
|
||||
'participation.Team',
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
default=None,
|
||||
verbose_name=_("team"),
|
||||
related_name='chat_channels',
|
||||
help_text=_("For a permission that concerns a team, indicates what is the concerned team."),
|
||||
)
|
||||
|
||||
private = models.BooleanField(
|
||||
verbose_name=_("private"),
|
||||
default=False,
|
||||
help_text=_("If checked, only users who have been explicitly added to the channel will be able to access it."),
|
||||
)
|
||||
|
||||
invited = models.ManyToManyField(
|
||||
'auth.User',
|
||||
verbose_name=_("invited users"),
|
||||
related_name='+',
|
||||
blank=True,
|
||||
help_text=_("Extra users who have been invited to the channel, "
|
||||
"in addition to the permitted group of the channel."),
|
||||
)
|
||||
|
||||
def get_visible_name(self, user: User) -> str:
|
||||
"""
|
||||
Renvoie le nom du channel tel qu'il est visible pour l'utilisateur⋅rice donné.
|
||||
Dans le cas d'un canal classique, renvoie directement le nom.
|
||||
Dans le cas d'un canal privé, renvoie la liste des personnes membres du canal,
|
||||
à l'exception de la personne connectée, afin de ne pas afficher son propre nom.
|
||||
Dans le cas d'un chat avec uniquement soi-même, on affiche que notre propre nom.
|
||||
"""
|
||||
if self.private:
|
||||
# Le canal est privé, on renvoie la liste des personnes membres du canal
|
||||
# à l'exception de soi-même (sauf si on est la seule personne dans le canal)
|
||||
users = [f"{u.first_name} {u.last_name}" for u in self.invited.all() if u != user] \
|
||||
or [f"{user.first_name} {user.last_name}"]
|
||||
return ", ".join(users)
|
||||
# Le canal est public, on renvoie directement le nom
|
||||
return self.name
|
||||
|
||||
def __str__(self):
|
||||
return str(format_lazy(_("Channel {name}"), name=self.name))
|
||||
|
||||
@staticmethod
|
||||
async def get_accessible_channels(user: User, permission_type: str = 'read') -> QuerySet["Channel"]:
|
||||
"""
|
||||
Renvoie les canaux auxquels l'utilisateur⋅rice donné a accès, en lecture ou en écriture.
|
||||
|
||||
Types de permissions :
|
||||
ANONYMOUS : Tout le monde, y compris les utilisateur⋅rices non connecté⋅es
|
||||
AUTHENTICATED : Toustes les utilisateur⋅rices connecté⋅es
|
||||
VOLUNTEER : Toustes les bénévoles
|
||||
TOURNAMENT_MEMBER : Toustes les membres d'un tournoi donné (orgas, juré⋅es, participant⋅es)
|
||||
TOURNAMENT_ORGANIZER : Les organisateur⋅rices d'un tournoi donné
|
||||
TOURNAMENT_JURY_PRESIDENT : Les organisateur⋅rices et les président⋅es de jury d'un tournoi donné
|
||||
JURY_MEMBER : Les membres du jury d'une poule donnée, ou les organisateur⋅rices du tournoi
|
||||
POOL_MEMBER : Les membres du jury et les participant⋅es d'une poule donnée, ou les organisateur⋅rices du tournoi
|
||||
TEAM_MEMBER : Les membres d'une équipe donnée
|
||||
PRIVATE : Les utilisateur⋅rices explicitement invité⋅es
|
||||
ADMIN : Les utilisateur⋅rices administrateur⋅rices (qui ont accès à tout)
|
||||
|
||||
Les canaux privés sont utilisés pour les messages privés, et ne sont pas affichés aux admins.
|
||||
|
||||
:param user: L'utilisateur⋅rice dont on veut récupérer la liste des canaux.
|
||||
:param permission_type: Le type de permission concerné (read ou write).
|
||||
:return: Le Queryset des canaux autorisés.
|
||||
"""
|
||||
permission_type = 'write_access' if 'write' in permission_type.lower() else 'read_access'
|
||||
|
||||
qs = Channel.objects.none()
|
||||
if user.is_anonymous:
|
||||
# Les utilisateur⋅rices non connecté⋅es ont accès aux canaux publics pour toustes
|
||||
return Channel.objects.filter(**{permission_type: PermissionType.ANONYMOUS})
|
||||
|
||||
# Les utilisateur⋅rices connecté⋅es ont accès aux canaux publics pour les personnes connectées
|
||||
qs |= Channel.objects.filter(**{permission_type: PermissionType.AUTHENTICATED})
|
||||
registration = await Registration.objects.prefetch_related('user').aget(user_id=user.id)
|
||||
|
||||
if registration.is_admin:
|
||||
# Les administrateur⋅rices ont accès à tous les canaux, sauf les canaux privés sont iels ne sont pas membres
|
||||
return Channel.objects.prefetch_related('invited').exclude(~Q(invited=user) & Q(private=True)).all()
|
||||
|
||||
if registration.is_volunteer:
|
||||
registration = await VolunteerRegistration.objects \
|
||||
.prefetch_related('jury_in__tournament', 'organized_tournaments').aget(user_id=user.id)
|
||||
|
||||
# Les bénévoles ont accès aux canaux pour bénévoles
|
||||
qs |= Channel.objects.filter(**{permission_type: PermissionType.VOLUNTEER})
|
||||
|
||||
# Iels ont accès aux tournois dont iels sont organisateur⋅rices ou juré⋅es
|
||||
# pour la permission TOURNAMENT_MEMBER
|
||||
qs |= Channel.objects.filter(Q(tournament__in=registration.interesting_tournaments),
|
||||
**{permission_type: PermissionType.TOURNAMENT_MEMBER})
|
||||
|
||||
# Iels ont accès aux canaux pour les organisateur⋅rices des tournois dont iels sont organisateur⋅rices
|
||||
# pour la permission TOURNAMENT_ORGANIZER
|
||||
qs |= Channel.objects.filter(Q(tournament__in=registration.organized_tournaments.all()),
|
||||
**{permission_type: PermissionType.TOURNAMENT_ORGANIZER})
|
||||
|
||||
# Iels ont accès aux canaux pour les organisateur⋅rices et président⋅es de jury des tournois dont iels sont
|
||||
# organisateur⋅rices ou juré⋅es pour la permission TOURNAMENT_JURY_PRESIDENT
|
||||
qs |= Channel.objects.filter(Q(tournament__pools__in=registration.pools_presided.all())
|
||||
| Q(tournament__in=registration.organized_tournaments.all()),
|
||||
**{permission_type: PermissionType.TOURNAMENT_JURY_PRESIDENT})
|
||||
|
||||
# Iels ont accès aux canaux pour les juré⋅es des poules dont iels sont juré⋅es
|
||||
# ou les organisateur⋅rices des tournois dont iels sont organisateur⋅rices
|
||||
# pour la permission JURY_MEMBER
|
||||
qs |= Channel.objects.filter(Q(pool__in=registration.jury_in.all())
|
||||
| Q(pool__tournament__in=registration.organized_tournaments.all())
|
||||
| Q(pool__tournament__pools__in=registration.pools_presided.all()),
|
||||
**{permission_type: PermissionType.JURY_MEMBER})
|
||||
|
||||
# Iels ont accès aux canaux pour les juré⋅es et participant⋅es des poules dont iels sont juré⋅es
|
||||
# ou les organisateur⋅rices des tournois dont iels sont organisateur⋅rices
|
||||
# pour la permission POOL_MEMBER
|
||||
qs |= Channel.objects.filter(Q(pool__in=registration.jury_in.all())
|
||||
| Q(pool__tournament__in=registration.organized_tournaments.all())
|
||||
| Q(pool__tournament__pools__in=registration.pools_presided.all()),
|
||||
**{permission_type: PermissionType.POOL_MEMBER})
|
||||
else:
|
||||
registration = await ParticipantRegistration.objects \
|
||||
.prefetch_related('team__participation__pools', 'team__participation__tournament').aget(user_id=user.id)
|
||||
|
||||
team = registration.team
|
||||
tournaments = []
|
||||
if team.participation.valid:
|
||||
tournaments.append(team.participation.tournament)
|
||||
if team.participation.final:
|
||||
tournaments.append(await Tournament.objects.aget(final=True))
|
||||
|
||||
# Les participant⋅es ont accès aux canaux généraux pour le tournoi dont iels sont membres
|
||||
# Cela comprend la finale s'iels sont finalistes
|
||||
qs |= Channel.objects.filter(Q(tournament__in=tournaments),
|
||||
**{permission_type: PermissionType.TOURNAMENT_MEMBER})
|
||||
|
||||
# Iels ont accès aux canaux généraux pour les poules dont iels sont participant⋅es
|
||||
qs |= Channel.objects.filter(Q(pool__in=team.participation.pools.all()),
|
||||
**{permission_type: PermissionType.POOL_MEMBER})
|
||||
|
||||
# Iels ont accès aux canaux propres à leur équipe
|
||||
qs |= Channel.objects.filter(Q(team=team),
|
||||
**{permission_type: PermissionType.TEAM_MEMBER})
|
||||
|
||||
# Les utilisateur⋅rices ont de plus accès aux messages privés qui leur sont adressés
|
||||
qs |= Channel.objects.filter(invited=user).prefetch_related('invited')
|
||||
|
||||
return qs
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("channel")
|
||||
verbose_name_plural = _("channels")
|
||||
ordering = ('category', 'name',)
|
||||
|
||||
|
||||
class Message(models.Model):
|
||||
"""
|
||||
Ce modèle représente un message de chat.
|
||||
Un message appartient à un canal, et est défini par son contenu, son auteur⋅rice, sa date de création et sa date
|
||||
de dernière modification.
|
||||
De plus, on garde en mémoire les utilisateur⋅rices qui ont lu le message.
|
||||
"""
|
||||
channel = models.ForeignKey(
|
||||
Channel,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_("channel"),
|
||||
related_name='messages',
|
||||
)
|
||||
|
||||
author = models.ForeignKey(
|
||||
'auth.User',
|
||||
verbose_name=_("author"),
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='chat_messages',
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(
|
||||
verbose_name=_("created at"),
|
||||
auto_now_add=True,
|
||||
)
|
||||
|
||||
updated_at = models.DateTimeField(
|
||||
verbose_name=_("updated at"),
|
||||
auto_now=True,
|
||||
)
|
||||
|
||||
content = models.TextField(
|
||||
verbose_name=_("content"),
|
||||
)
|
||||
|
||||
users_read = models.ManyToManyField(
|
||||
'auth.User',
|
||||
verbose_name=_("users read"),
|
||||
related_name='+',
|
||||
blank=True,
|
||||
help_text=_("Users who have read the message."),
|
||||
)
|
||||
|
||||
def get_author_name(self) -> str:
|
||||
"""
|
||||
Renvoie le nom de l'auteur⋅rice du message, en fonction de son rôle dans l'organisation
|
||||
dans le cadre d'un⋅e bénévole, ou de son équipe dans le cadre d'un⋅e participant⋅e.
|
||||
"""
|
||||
registration = self.author.registration
|
||||
|
||||
author_name = f"{self.author.first_name} {self.author.last_name}"
|
||||
if registration.is_volunteer:
|
||||
if registration.is_admin:
|
||||
# Les administrateur⋅rices ont le suffixe (CNO)
|
||||
author_name += " (CNO)"
|
||||
|
||||
if self.channel.pool:
|
||||
if registration == self.channel.pool.jury_president:
|
||||
# Læ président⋅e de jury de la poule a le suffixe (P. jury)
|
||||
author_name += " (P. jury)"
|
||||
elif registration in self.channel.pool.juries.all():
|
||||
# Les juré⋅es de la poule ont le suffixe (Juré⋅e)
|
||||
author_name += " (Juré⋅e)"
|
||||
elif registration in self.channel.pool.tournament.organizers.all():
|
||||
# Les organisateur⋅rices du tournoi ont le suffixe (CRO)
|
||||
author_name += " (CRO)"
|
||||
else:
|
||||
# Les éventuel⋅les autres bénévoles ont le suffixe (Bénévole)
|
||||
author_name += " (Bénévole)"
|
||||
elif self.channel.tournament:
|
||||
if registration in self.channel.tournament.organizers.all():
|
||||
# Les organisateur⋅rices du tournoi ont le suffixe (CRO)
|
||||
author_name += " (CRO)"
|
||||
elif any([registration.id == pool.jury_president
|
||||
for pool in self.channel.tournament.pools.all()]):
|
||||
# Les président⋅es de jury des poules ont le suffixe (P. jury)
|
||||
# mentionnant l'ensemble des poules qu'iels président
|
||||
pools = ", ".join([pool.short_name
|
||||
for pool in self.channel.tournament.pools.all()
|
||||
if pool.jury_president == registration])
|
||||
author_name += f" (P. jury {pools})"
|
||||
elif any([pool.juries.contains(registration)
|
||||
for pool in self.channel.tournament.pools.all()]):
|
||||
# Les juré⋅es des poules ont le suffixe (Juré⋅e)
|
||||
# mentionnant l'ensemble des poules auxquelles iels participent
|
||||
pools = ", ".join([pool.short_name
|
||||
for pool in self.channel.tournament.pools.all()
|
||||
if pool.juries.acontains(registration)])
|
||||
author_name += f" (Juré⋅e {pools})"
|
||||
else:
|
||||
# Les éventuel⋅les autres bénévoles ont le suffixe (Bénévole)
|
||||
author_name += " (Bénévole)"
|
||||
else:
|
||||
if registration.organized_tournaments.exists():
|
||||
# Les organisateur⋅rices de tournois ont le suffixe (CRO) mentionnant les tournois organisés
|
||||
tournaments = ", ".join([tournament.name
|
||||
for tournament in registration.organized_tournaments.all()])
|
||||
author_name += f" (CRO {tournaments})"
|
||||
if Pool.objects.filter(jury_president=registration).exists():
|
||||
# Les président⋅es de jury ont le suffixe (P. jury) mentionnant les tournois présidés
|
||||
tournaments = Tournament.objects.filter(pools__jury_president=registration).distinct()
|
||||
tournaments = ", ".join([tournament.name for tournament in tournaments])
|
||||
author_name += f" (P. jury {tournaments})"
|
||||
elif registration.jury_in.exists():
|
||||
# Les juré⋅es ont le suffixe (Juré⋅e) mentionnant les tournois auxquels iels participent
|
||||
tournaments = Tournament.objects.filter(pools__juries=registration).distinct()
|
||||
tournaments = ", ".join([tournament.name for tournament in tournaments])
|
||||
author_name += f" (Juré⋅e {tournaments})"
|
||||
else:
|
||||
if registration.team_id:
|
||||
# Le trigramme de l'équipe de læ participant⋅e est ajouté en suffixe
|
||||
team = Team.objects.get(id=registration.team_id)
|
||||
author_name += f" ({team.trigram})"
|
||||
else:
|
||||
author_name += " (sans équipe)"
|
||||
|
||||
return author_name
|
||||
|
||||
async def aget_author_name(self) -> str:
|
||||
"""
|
||||
Fonction asynchrone pour récupérer le nom de l'auteur⋅rice du message.
|
||||
Voir `get_author_name` pour plus de détails.
|
||||
"""
|
||||
return await sync_to_async(self.get_author_name)()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("message")
|
||||
verbose_name_plural = _("messages")
|
||||
ordering = ('created_at',)
|
120
chat/signals.py
Normal file
@ -0,0 +1,120 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from chat.models import Channel
|
||||
from participation.models import Participation, Pool, Tournament
|
||||
from tfjm.permissions import PermissionType
|
||||
|
||||
|
||||
def create_tournament_channels(instance: Tournament, **_kwargs):
|
||||
"""
|
||||
Lorsqu'un tournoi est créé, on crée les canaux de chat associés.
|
||||
On crée notamment un canal d'annonces (accessible en écriture uniquement aux orgas),
|
||||
un canal général, un de détente, un pour les juré⋅es et un pour les président⋅es de jury.
|
||||
"""
|
||||
tournament = instance
|
||||
|
||||
# Création du canal « Tournoi - Annonces »
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Annonces",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
write_access=PermissionType.TOURNAMENT_ORGANIZER,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
# Création du canal « Tournoi - Général »
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Général",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
write_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
# Création du canal « Tournoi - Détente »
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Détente",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
write_access=PermissionType.TOURNAMENT_MEMBER,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
# Création du canal « Tournoi - Juré⋅es »
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Juré⋅es",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.JURY_MEMBER,
|
||||
write_access=PermissionType.JURY_MEMBER,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
if tournament.remote:
|
||||
# Création du canal « Tournoi - Président⋅es de jury » dans le cas d'un tournoi distanciel
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Président⋅es de jury",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.TOURNAMENT_JURY_PRESIDENT,
|
||||
write_access=PermissionType.TOURNAMENT_JURY_PRESIDENT,
|
||||
tournament=tournament,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def create_pool_channels(instance: Pool, **_kwargs):
|
||||
"""
|
||||
Lorsqu'une poule est créée, on crée les canaux de chat associés.
|
||||
On crée notamment un canal pour les membres de la poule et un pour les juré⋅es.
|
||||
Cela ne concerne que les tournois distanciels.
|
||||
"""
|
||||
pool = instance
|
||||
tournament = pool.tournament
|
||||
|
||||
if tournament.remote:
|
||||
# Dans le cadre d'un tournoi distanciel, on crée un canal pour les membres de la poule
|
||||
# et un pour les juré⋅es de la poule.
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Poule {pool.short_name}",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.POOL_MEMBER,
|
||||
write_access=PermissionType.POOL_MEMBER,
|
||||
pool=pool,
|
||||
),
|
||||
)
|
||||
|
||||
Channel.objects.update_or_create(
|
||||
name=f"{tournament.name} - Poule {pool.short_name} - Jury",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TOURNAMENT,
|
||||
read_access=PermissionType.JURY_MEMBER,
|
||||
write_access=PermissionType.JURY_MEMBER,
|
||||
pool=pool,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def create_team_channel(instance: Participation, **_kwargs):
|
||||
"""
|
||||
Lorsqu'une équipe est validée, on crée un canal de chat associé.
|
||||
"""
|
||||
if instance.valid:
|
||||
Channel.objects.update_or_create(
|
||||
name=f"Équipe {instance.team.trigram}",
|
||||
defaults=dict(
|
||||
category=Channel.ChannelCategory.TEAM,
|
||||
read_access=PermissionType.TEAM_MEMBER,
|
||||
write_access=PermissionType.TEAM_MEMBER,
|
||||
team=instance.team,
|
||||
),
|
||||
)
|
17
chat/static/tfjm/chat_eteam.webmanifest
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"background_color": "white",
|
||||
"description": "Chat for ETEAM",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/tfjm/img/eteam.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"name": "ETEAM Chat",
|
||||
"short_name": "ETEAM Chat",
|
||||
"start_url": "/chat/fullscreen",
|
||||
"theme_color": "black"
|
||||
}
|
29
chat/static/tfjm/chat_tfjm.webmanifest
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"background_color": "white",
|
||||
"description": "Chat pour le TFJM²",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/tfjm/img/tfjm-square.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/tfjm/img/tfjm-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/static/tfjm/img/tfjm-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"name": "Chat TFJM²",
|
||||
"short_name": "Chat TFJM²",
|
||||
"start_url": "/chat/fullscreen",
|
||||
"theme_color": "black"
|
||||
}
|
912
chat/static/tfjm/js/chat.js
Normal file
@ -0,0 +1,912 @@
|
||||
(async () => {
|
||||
// Vérification de la permission pour envoyer des notifications
|
||||
// C'est utile pour prévenir les utilisateur⋅rices de l'arrivée de nouveaux messages les mentionnant
|
||||
await Notification.requestPermission()
|
||||
})()
|
||||
|
||||
const MAX_MESSAGES = 50 // Nombre maximal de messages à charger à la fois
|
||||
|
||||
const channel_categories = ['general', 'tournament', 'team', 'private'] // Liste des catégories de canaux
|
||||
let channels = {} // Liste des canaux disponibles
|
||||
let messages = {} // Liste des messages reçus par canal
|
||||
let selected_channel_id = null // Canal courant
|
||||
|
||||
/**
|
||||
* Affiche une nouvelle notification avec le titre donné et le contenu donné.
|
||||
* @param title Le titre de la notification
|
||||
* @param body Le contenu de la notification
|
||||
* @param timeout La durée (en millisecondes) après laquelle la notification se ferme automatiquement.
|
||||
* Définir à 0 (défaut) pour la rendre infinie.
|
||||
* @return Notification
|
||||
*/
|
||||
function showNotification(title, body, timeout = 0) {
|
||||
Notification.requestPermission().then((status) => {
|
||||
if (status === 'granted') {
|
||||
// On envoie la notification que si la permission a été donnée
|
||||
let notif = new Notification(title, {'body': body, 'icon': "/static/tfjm-192.png"})
|
||||
if (timeout > 0)
|
||||
setTimeout(() => notif.close(), timeout)
|
||||
return notif
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sélectionne le canal courant à afficher sur l'interface de chat.
|
||||
* Va alors définir le canal courant et mettre à jour les messages affichés.
|
||||
* @param channel_id L'identifiant du canal à afficher.
|
||||
*/
|
||||
function selectChannel(channel_id) {
|
||||
let channel = channels[channel_id]
|
||||
if (!channel) {
|
||||
// Le canal n'existe pas
|
||||
console.error('Channel not found:', channel_id)
|
||||
return
|
||||
}
|
||||
|
||||
selected_channel_id = channel_id
|
||||
// On stocke dans le stockage local l'identifiant du canal
|
||||
// pour pouvoir rouvrir le dernier canal ouvert dans le futur
|
||||
localStorage.setItem('chat.last-channel-id', channel_id)
|
||||
|
||||
// Définition du titre du contenu
|
||||
let channelTitle = document.getElementById('channel-title')
|
||||
channelTitle.innerText = channel.name
|
||||
|
||||
// Si on a pas le droit d'écrire dans le canal, on désactive l'input de message
|
||||
// On l'active sinon
|
||||
let messageInput = document.getElementById('input-message')
|
||||
messageInput.disabled = !channel.write_access
|
||||
|
||||
// On redessine la liste des messages à partir des messages stockés
|
||||
redrawMessages()
|
||||
}
|
||||
|
||||
/**
|
||||
* On récupère le message écrit par l'utilisateur⋅rice dans le champ de texte idoine,
|
||||
* et on le transmet ensuite au serveur.
|
||||
* Il ne s'affiche pas instantanément sur l'interface,
|
||||
* mais seulement une fois que le serveur aura validé et retransmis le message.
|
||||
*/
|
||||
function sendMessage() {
|
||||
// Récupération du message à envoyer
|
||||
let messageInput = document.getElementById('input-message')
|
||||
let message = messageInput.value
|
||||
// On efface le champ de texte après avoir récupéré le message
|
||||
messageInput.value = ''
|
||||
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
|
||||
// Envoi du message au serveur
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'send_message',
|
||||
'channel_id': selected_channel_id,
|
||||
'content': message,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la liste des canaux disponibles, à partir de la liste récupérée du serveur.
|
||||
* @param new_channels La liste des canaux à afficher.
|
||||
* Chaque canal doit être un objet avec les clés `id`, `name`, `category`
|
||||
* `read_access`, `write_access` et `unread_messages`, correspondant à l'identifiant du canal,
|
||||
* son nom, sa catégorie, la permission de lecture, d'écriture et le nombre de messages non lus.
|
||||
*/
|
||||
function setChannels(new_channels) {
|
||||
channels = {}
|
||||
for (let category of channel_categories) {
|
||||
// On commence par vider la liste des canaux sélectionnables
|
||||
let categoryList = document.getElementById(`nav-${category}-channels-tab`)
|
||||
categoryList.innerHTML = ''
|
||||
categoryList.parentElement.classList.add('d-none')
|
||||
}
|
||||
|
||||
for (let channel of new_channels)
|
||||
// On ajoute chaque canal à la liste des canaux
|
||||
addChannel(channel)
|
||||
|
||||
if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) {
|
||||
// Si aucun canal n'a encore été sélectionné et qu'il y a des canaux disponibles,
|
||||
// on commence par vérifier si on a stocké un canal précédemment sélectionné et on l'affiche si c'est le cas
|
||||
// Sinon, on affiche le premier canal disponible
|
||||
let last_channel_id = parseInt(localStorage.getItem('chat.last-channel-id'))
|
||||
if (last_channel_id && channels[last_channel_id])
|
||||
selectChannel(last_channel_id)
|
||||
else
|
||||
selectChannel(Object.keys(channels)[0])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute un canal à la liste des canaux disponibles.
|
||||
* @param channel Le canal à ajouter. Doit être un objet avec les clés `id`, `name`, `category`,
|
||||
* `read_access`, `write_access` et `unread_messages`, correspondant à l'identifiant du canal,
|
||||
* son nom, sa catégorie, la permission de lecture, d'écriture et le nombre de messages non lus.
|
||||
*/
|
||||
async function addChannel(channel) {
|
||||
channels[channel.id] = channel
|
||||
if (!messages[channel.id])
|
||||
messages[channel.id] = new Map()
|
||||
|
||||
// On récupère la liste des canaux de la catégorie concernée
|
||||
let categoryList = document.getElementById(`nav-${channel.category}-channels-tab`)
|
||||
// On la rend visible si elle ne l'était pas déjà
|
||||
categoryList.parentElement.classList.remove('d-none')
|
||||
|
||||
// On crée un nouvel élément de liste pour la catégorie concernant le canal
|
||||
let navItem = document.createElement('li')
|
||||
navItem.classList.add('list-group-item', 'tab-channel')
|
||||
navItem.id = `tab-channel-${channel.id}`
|
||||
navItem.setAttribute('data-bs-dismiss', 'offcanvas')
|
||||
navItem.onclick = () => selectChannel(channel.id)
|
||||
categoryList.appendChild(navItem)
|
||||
|
||||
// L'élément est cliquable afin de sélectionner le canal
|
||||
let channelButton = document.createElement('button')
|
||||
channelButton.classList.add('nav-link')
|
||||
channelButton.type = 'button'
|
||||
channelButton.innerText = channel.name
|
||||
navItem.appendChild(channelButton)
|
||||
|
||||
// Affichage du nombre de messages non lus
|
||||
let unreadBadge = document.createElement('span')
|
||||
unreadBadge.classList.add('badge', 'rounded-pill', 'text-bg-light', 'ms-2')
|
||||
unreadBadge.id = `unread-messages-${channel.id}`
|
||||
unreadBadge.innerText = channel.unread_messages || 0
|
||||
if (!channel.unread_messages)
|
||||
unreadBadge.classList.add('d-none')
|
||||
channelButton.appendChild(unreadBadge)
|
||||
|
||||
// Si on veut trier les canaux par nombre décroissant de messages non lus,
|
||||
// on définit l'ordre de l'élément (propriété CSS) en fonction du nombre de messages non lus
|
||||
if (document.getElementById('sort-by-unread-switch').checked)
|
||||
navItem.style.order = `${-channel.unread_messages}`
|
||||
|
||||
// On demande enfin à récupérer les derniers messages du canal en question afin de les stocker / afficher
|
||||
fetchMessages(channel.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Un⋅e utilisateur⋅rice a envoyé un message, qui a été retransmis par le serveur.
|
||||
* On le stocke alors et on l'affiche sur l'interface si nécessaire.
|
||||
* On affiche également une notification si le message contient une mention pour tout le monde.
|
||||
* @param message Le message qui a été transmis. Doit être un objet avec
|
||||
* les clés `id`, `channel_id`, `author`, `author_id`, `content` et `timestamp`,
|
||||
* correspondant à l'identifiant du message, du canal, le nom de l'auteur⋅rice et l'heure d'envoi.
|
||||
*/
|
||||
function receiveMessage(message) {
|
||||
// On vérifie si la barre de défilement est tout en bas
|
||||
let scrollableContent = document.getElementById('chat-messages')
|
||||
let isScrolledToBottom = scrollableContent.scrollHeight - scrollableContent.clientHeight <= scrollableContent.scrollTop + 1
|
||||
|
||||
// On stocke le message dans la liste des messages du canal concerné
|
||||
// et on redessine les messages affichés si on est dans le canal concerné
|
||||
messages[message.channel_id].set(message.id, message)
|
||||
if (message.channel_id === selected_channel_id)
|
||||
redrawMessages()
|
||||
|
||||
// Si la barre de défilement était tout en bas, alors on la remet tout en bas après avoir redessiné les messages
|
||||
if (isScrolledToBottom)
|
||||
scrollableContent.scrollTop = scrollableContent.scrollHeight - scrollableContent.clientHeight
|
||||
|
||||
// On ajoute un à la liste des messages non lus du canal (il pourra être lu plus tard)
|
||||
updateUnreadBadge(message.channel_id, channels[message.channel_id].unread_messages + 1)
|
||||
|
||||
// Si le message contient une mention à @everyone, alors on envoie une notification (si la permission est donnée)
|
||||
if (message.content.includes("@everyone"))
|
||||
showNotification(channels[message.channel_id].name, `${message.author} : ${message.content}`)
|
||||
|
||||
// On envoie un événement personnalisé pour indiquer que les messages ont été mis à jour
|
||||
// Permettant entre autres de marquer le message comme lu si c'est le cas
|
||||
document.getElementById('message-list').dispatchEvent(new CustomEvent('updatemessages'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Un message a été modifié, et le serveur nous a transmis les nouvelles informations.
|
||||
* @param data Le nouveau message qui a été modifié.
|
||||
*/
|
||||
function editMessage(data) {
|
||||
// On met à jour le contenu du message
|
||||
messages[data.channel_id].get(data.id).content = data.content
|
||||
// Si le message appartient au canal courant, on redessine les messages
|
||||
if (data.channel_id === selected_channel_id)
|
||||
redrawMessages()
|
||||
}
|
||||
|
||||
/**
|
||||
* Un message a été supprimé, et le serveur nous a transmis les informations.
|
||||
* @param data Le message qui a été supprimé.
|
||||
*/
|
||||
function deleteMessage(data) {
|
||||
// On supprime le message de la liste des messages du canal concerné
|
||||
messages[data.channel_id].delete(data.id)
|
||||
// Si le message appartient au canal courant, on redessine les messages
|
||||
if (data.channel_id === selected_channel_id)
|
||||
redrawMessages()
|
||||
}
|
||||
|
||||
/**
|
||||
* Demande au serveur de récupérer les messages du canal donné.
|
||||
* @param channel_id L'identifiant du canal dont on veut récupérer les messages.
|
||||
* @param offset Le décalage à partir duquel on veut récupérer les messages,
|
||||
* correspond au nombre de messages en mémoire.
|
||||
* @param limit Le nombre maximal de messages à récupérer.
|
||||
*/
|
||||
function fetchMessages(channel_id, offset = 0, limit = MAX_MESSAGES) {
|
||||
// Envoi de la requête au serveur avec les différents paramètres
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'fetch_messages',
|
||||
'channel_id': channel_id,
|
||||
'offset': offset,
|
||||
'limit': limit,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Demande au serveur de récupérer les messages précédents du canal courant.
|
||||
* Par défaut, on récupère `MAX_MESSAGES` messages avant tous ceux qui ont été reçus sur ce canal.
|
||||
*/
|
||||
function fetchPreviousMessages() {
|
||||
let channel_id = selected_channel_id
|
||||
let offset = messages[channel_id].size
|
||||
fetchMessages(channel_id, offset, MAX_MESSAGES)
|
||||
}
|
||||
|
||||
/**
|
||||
* L'utilisateur⋅rice a demandé à récupérer une partie des messages d'un canal.
|
||||
* Cette fonction est alors appelée lors du retour du serveur.
|
||||
* @param data Dictionnaire contenant l'identifiant du canal concerné, et la liste des messages récupérés.
|
||||
*/
|
||||
function receiveFetchedMessages(data) {
|
||||
// Récupération du canal concerné ainsi que des nouveaux messages à mémoriser
|
||||
let channel_id = data.channel_id
|
||||
let new_messages = data.messages
|
||||
|
||||
if (!messages[channel_id])
|
||||
messages[channel_id] = new Map()
|
||||
|
||||
// Ajout des nouveaux messages à la liste des messages du canal
|
||||
for (let message of new_messages)
|
||||
messages[channel_id].set(message.id, message)
|
||||
|
||||
// On trie les messages reçus par date et heure d'envoi
|
||||
messages[channel_id] = new Map([...messages[channel_id].values()]
|
||||
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
|
||||
.map(message => [message.id, message]))
|
||||
|
||||
// Enfin, si le canal concerné est le canal courant, on redessine les messages
|
||||
if (channel_id === selected_channel_id)
|
||||
redrawMessages()
|
||||
}
|
||||
|
||||
/**
|
||||
* L'utilisateur⋅rice a indiqué au serveur que des messages ont été lus.
|
||||
* Cette fonction est appelée en retour, pour confirmer, et stocke quels messages ont été lus
|
||||
* et combien de messages sont non lus par canal.
|
||||
* @param data Dictionnaire contenant une clé `read`, contenant la liste des identifiants des messages
|
||||
* marqués comme lus avec leur canal respectif, et une clé `unread_messages` contenant le nombre
|
||||
* de messages non lus par canal.
|
||||
*/
|
||||
function markMessageAsRead(data) {
|
||||
for (let message of data.messages) {
|
||||
// Récupération du message à marquer comme lu
|
||||
let stored_message = messages[message.channel_id].get(message.id)
|
||||
// Marquage du message comme lu
|
||||
if (stored_message)
|
||||
stored_message.read = true
|
||||
}
|
||||
// Actualisation des badges contenant le nombre de messages non lus par canal
|
||||
updateUnreadBadges(data.unread_messages)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mise à jour des badges contenant le nombre de messages non lus par canal.
|
||||
* @param unreadMessages Dictionnaire des nombres de messages non lus par canal (identifiés par leurs identifiants)
|
||||
*/
|
||||
function updateUnreadBadges(unreadMessages) {
|
||||
for (let channel of Object.values(channels)) {
|
||||
// Récupération du nombre de messages non lus pour le canal en question et mise à jour du badge pour ce canal
|
||||
updateUnreadBadge(channel.id, unreadMessages[channel.id] || 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mise à jour du badge du nombre de messages non lus d'un canal.
|
||||
* Actualise sa visibilité.
|
||||
* @param channel_id Identifiant du canal concerné.
|
||||
* @param unreadMessagesCount Nombre de messages non lus du canal.
|
||||
*/
|
||||
function updateUnreadBadge(channel_id, unreadMessagesCount = 0) {
|
||||
// Vaut true si on veut trier les canaux par nombre de messages non lus ou non
|
||||
const sortByUnread = document.getElementById('sort-by-unread-switch').checked
|
||||
|
||||
// Récupération du canal concerné
|
||||
let channel = channels[channel_id]
|
||||
|
||||
// Récupération du nombre de messages non lus pour le canal en question, que l'on stocke
|
||||
channel.unread_messages = unreadMessagesCount
|
||||
|
||||
// On met à jour le badge du canal contenant le nombre de messages non lus
|
||||
let unreadBadge = document.getElementById(`unread-messages-${channel.id}`)
|
||||
unreadBadge.innerText = unreadMessagesCount.toString()
|
||||
|
||||
// Le badge est visible si et seulement si il y a au moins un message non lu
|
||||
if (unreadMessagesCount)
|
||||
unreadBadge.classList.remove('d-none')
|
||||
else
|
||||
unreadBadge.classList.add('d-none')
|
||||
|
||||
// S'il faut trier les canaux par nombre de messages non lus, on ajoute la propriété CSS correspondante
|
||||
if (sortByUnread)
|
||||
document.getElementById(`tab-channel-${channel.id}`).style.order = `${-unreadMessagesCount}`
|
||||
}
|
||||
|
||||
/**
|
||||
* La création d'un canal privé entre deux personnes a été demandée.
|
||||
* Cette fonction est appelée en réponse du serveur.
|
||||
* Le canal est ajouté à la liste s'il est nouveau, et automatiquement sélectionné.
|
||||
* @param data Dictionnaire contenant une unique clé `channel` correspondant aux informations du canal privé.
|
||||
*/
|
||||
function startPrivateChat(data) {
|
||||
// Récupération du canal
|
||||
let channel = data.channel
|
||||
if (!channel) {
|
||||
console.error('Private chat not found:', data)
|
||||
return
|
||||
}
|
||||
|
||||
if (!channels[channel.id]) {
|
||||
// Si le canal n'est pas récupéré, on l'ajoute à la liste
|
||||
channels[channel.id] = channel
|
||||
messages[channel.id] = new Map()
|
||||
addChannel(channel)
|
||||
}
|
||||
|
||||
// Sélection immédiate du canal privé
|
||||
selectChannel(channel.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour le composant correspondant à la liste des messages du canal sélectionné.
|
||||
* Le conteneur est d'abord réinitialisé, puis les messages sont affichés un à un à partir de ceux stockés.
|
||||
*/
|
||||
function redrawMessages() {
|
||||
// Récupération du composant HTML <ul> correspondant à la liste des messages affichés
|
||||
let messageList = document.getElementById('message-list')
|
||||
// On commence par le vider
|
||||
messageList.innerHTML = ''
|
||||
|
||||
let lastMessage = null
|
||||
let lastContentDiv = null
|
||||
|
||||
for (let message of messages[selected_channel_id].values()) {
|
||||
if (lastMessage && lastMessage.author === message.author) {
|
||||
// Si le message est écrit par læ même auteur⋅rice que le message précédent,
|
||||
// alors on les groupe ensemble
|
||||
let lastTimestamp = new Date(lastMessage.timestamp)
|
||||
let newTimestamp = new Date(message.timestamp)
|
||||
if ((newTimestamp - lastTimestamp) / 1000 < 60 * 10) {
|
||||
// Les messages sont groupés uniquement s'il y a une différence maximale de 10 minutes
|
||||
// entre le premier message du groupe et celui en étude
|
||||
// On ajoute alors le contenu du message en cours dans le dernier div de message
|
||||
let messageContentDiv = document.createElement('div')
|
||||
messageContentDiv.classList.add('message')
|
||||
messageContentDiv.setAttribute('data-message-id', message.id)
|
||||
lastContentDiv.appendChild(messageContentDiv)
|
||||
let messageContentSpan = document.createElement('span')
|
||||
messageContentSpan.innerHTML = markdownToHTML(message.content)
|
||||
messageContentDiv.appendChild(messageContentSpan)
|
||||
|
||||
// Enregistrement du menu contextuel pour le message permettant la modification, la suppression
|
||||
// et l'envoi de messages privés
|
||||
registerMessageContextMenu(message, messageContentDiv, messageContentSpan)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Création de l'élément <li> pour le bloc de messages
|
||||
let messageElement = document.createElement('li')
|
||||
messageElement.classList.add('list-group-item')
|
||||
messageList.appendChild(messageElement)
|
||||
|
||||
// Ajout d'un div contenant le nom de l'auteur⋅rice du message ainsi que la date et heure d'envoi
|
||||
let authorDiv = document.createElement('div')
|
||||
messageElement.appendChild(authorDiv)
|
||||
|
||||
// Ajout du nom de l'auteur⋅rice du message
|
||||
let authorSpan = document.createElement('span')
|
||||
authorSpan.classList.add('text-muted', 'fw-bold')
|
||||
authorSpan.innerText = message.author
|
||||
authorDiv.appendChild(authorSpan)
|
||||
|
||||
// Ajout de la date du message
|
||||
let dateSpan = document.createElement('span')
|
||||
dateSpan.classList.add('text-muted', 'float-end')
|
||||
dateSpan.innerText = new Date(message.timestamp).toLocaleString()
|
||||
authorDiv.appendChild(dateSpan)
|
||||
|
||||
// Enregistrement du menu contextuel pour le message permettant l'envoi de messages privés à l'auteur⋅rice
|
||||
registerSendPrivateMessageContextMenu(message, authorDiv, authorSpan)
|
||||
|
||||
let contentDiv = document.createElement('div')
|
||||
messageElement.appendChild(contentDiv)
|
||||
|
||||
// Ajout du contenu du message
|
||||
// Le contenu est mis dans un span lui-même inclus dans un div,
|
||||
let messageContentDiv = document.createElement('div')
|
||||
messageContentDiv.classList.add('message')
|
||||
messageContentDiv.setAttribute('data-message-id', message.id)
|
||||
contentDiv.appendChild(messageContentDiv)
|
||||
let messageContentSpan = document.createElement('span')
|
||||
messageContentSpan.innerHTML = markdownToHTML(message.content)
|
||||
messageContentDiv.appendChild(messageContentSpan)
|
||||
|
||||
// Enregistrement du menu contextuel pour le message permettant la modification, la suppression
|
||||
// et l'envoi de messages privés
|
||||
registerMessageContextMenu(message, messageContentDiv, messageContentSpan)
|
||||
|
||||
lastMessage = message
|
||||
lastContentDiv = contentDiv
|
||||
}
|
||||
|
||||
// Le bouton « Afficher les messages précédents » est affiché si et seulement si
|
||||
// il y a des messages à récupérer (c'est-à-dire si le nombre de messages récupérés est un multiple de MAX_MESSAGES)
|
||||
let fetchMoreButton = document.getElementById('fetch-previous-messages')
|
||||
if (!messages[selected_channel_id].size || messages[selected_channel_id].size % MAX_MESSAGES !== 0)
|
||||
fetchMoreButton.classList.add('d-none')
|
||||
else
|
||||
fetchMoreButton.classList.remove('d-none')
|
||||
|
||||
// On envoie un événement personnalisé pour indiquer que les messages ont été mis à jour
|
||||
// Permettant entre autres de marquer les messages visibles comme lus si c'est le cas
|
||||
messageList.dispatchEvent(new CustomEvent('updatemessages'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un texte écrit en Markdown en HTML.
|
||||
* Les balises Markdown suivantes sont supportées :
|
||||
* - Souligné : `_texte_`
|
||||
* - Gras : `**texte**`
|
||||
* - Italique : `*texte*`
|
||||
* - Code : `` `texte` ``
|
||||
* - Les liens sont automatiquement convertis
|
||||
* - Les esperluettes, guillemets et chevrons sont échappés.
|
||||
* @param text Le texte écrit en Markdown.
|
||||
* @return {string} Le texte converti en HTML.
|
||||
*/
|
||||
function markdownToHTML(text) {
|
||||
// On échape certains caractères spéciaux (esperluettes, chevrons, guillemets)
|
||||
let safeText = text.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
let lines = safeText.split('\n')
|
||||
let htmlLines = []
|
||||
for (let line of lines) {
|
||||
// Pour chaque ligne, on remplace le Markdown par un équivalent HTML (pour ce qui est supporté)
|
||||
let htmlLine = line
|
||||
.replaceAll(/_(.*)_/gim, '<span class="text-decoration-underline">$1</span>') // Souligné
|
||||
.replaceAll(/\*\*(.*)\*\*/gim, '<span class="fw-bold">$1</span>') // Gras
|
||||
.replaceAll(/\*(.*)\*/gim, '<span class="fst-italic">$1</span>') // Italique
|
||||
.replaceAll(/`(.*)`/gim, '<pre>$1</pre>') // Code
|
||||
.replaceAll(/(https?:\/\/\S+)/g, '<a href="$1" target="_blank">$1</a>') // Liens
|
||||
htmlLines.push(htmlLine)
|
||||
}
|
||||
// On joint enfin toutes les lignes par des balises de saut de ligne
|
||||
return htmlLines.join('<br>')
|
||||
}
|
||||
|
||||
/**
|
||||
* Ferme toutes les popovers ouvertes.
|
||||
*/
|
||||
function removeAllPopovers() {
|
||||
for (let popover of document.querySelectorAll('*[aria-describedby*="popover"]')) {
|
||||
let instance = bootstrap.Popover.getInstance(popover)
|
||||
if (instance)
|
||||
instance.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistrement du menu contextuel pour un⋅e auteur⋅rice de message,
|
||||
* donnant la possibilité d'envoyer un message privé.
|
||||
* @param message Le message écrit par l'auteur⋅rice du bloc en question.
|
||||
* @param div Le bloc contenant le nom de l'auteur⋅rice et de la date d'envoi du message.
|
||||
* Un clic droit sur lui affichera le menu contextuel.
|
||||
* @param span Le span contenant le nom de l'auteur⋅rice.
|
||||
* Il désignera l'emplacement d'affichage du popover.
|
||||
*/
|
||||
function registerSendPrivateMessageContextMenu(message, div, span) {
|
||||
// Enregistrement de l'écouteur d'événement pour le clic droit
|
||||
div.addEventListener('contextmenu', (menu_event) => {
|
||||
// On empêche le menu traditionnel de s'afficher
|
||||
menu_event.preventDefault()
|
||||
// On retire toutes les popovers déjà ouvertes
|
||||
removeAllPopovers()
|
||||
|
||||
// On crée le popover contenant le lien pour envoyer un message privé, puis on l'affiche
|
||||
const popover = bootstrap.Popover.getOrCreateInstance(span, {
|
||||
'title': message.author,
|
||||
'content': `<a id="send-private-message-link-${message.id}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>`,
|
||||
'html': true,
|
||||
})
|
||||
popover.show()
|
||||
|
||||
// Lorsqu'on clique sur le lien, on ferme le popover
|
||||
// et on demande à ouvrir le canal privé avec l'auteur⋅rice du message
|
||||
document.getElementById('send-private-message-link-' + message.id).addEventListener('click', event => {
|
||||
event.preventDefault()
|
||||
popover.dispose()
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'start_private_chat',
|
||||
'user_id': message.author_id,
|
||||
}))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistrement du menu contextuel pour un message,
|
||||
* donnant la possibilité de modifier ou de supprimer le message, ou d'envoyer un message privé à l'auteur⋅rice.
|
||||
* @param message Le message en question.
|
||||
* @param div Le bloc contenant le contenu du message.
|
||||
* Un clic droit sur lui affichera le menu contextuel.
|
||||
* @param span Le span contenant le contenu du message.
|
||||
* Il désignera l'emplacement d'affichage du popover.
|
||||
*/
|
||||
function registerMessageContextMenu(message, div, span) {
|
||||
// Enregistrement de l'écouteur d'événement pour le clic droit
|
||||
div.addEventListener('contextmenu', (menu_event) => {
|
||||
// On empêche le menu traditionnel de s'afficher
|
||||
menu_event.preventDefault()
|
||||
// On retire toutes les popovers déjà ouvertes
|
||||
removeAllPopovers()
|
||||
|
||||
// On crée le popover contenant les liens pour modifier, supprimer le message ou envoyer un message privé.
|
||||
let content = `<a id="send-private-message-link-msg-${message.id}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>`
|
||||
|
||||
// On ne peut modifier ou supprimer un message que si on est l'auteur⋅rice ou que l'on est administrateur⋅rice.
|
||||
let has_right_to_edit = message.author_id === USER_ID || IS_ADMIN
|
||||
if (has_right_to_edit) {
|
||||
content += `<hr class="my-1">`
|
||||
content += `<a id="edit-message-${message.id}" class="nav-link" href="#" tabindex="0">Modifier</a>`
|
||||
content += `<a id="delete-message-${message.id}" class="nav-link" href="#" tabindex="0">Supprimer</a>`
|
||||
}
|
||||
|
||||
const popover = bootstrap.Popover.getOrCreateInstance(span, {
|
||||
'content': content,
|
||||
'html': true,
|
||||
'placement': 'bottom',
|
||||
})
|
||||
popover.show()
|
||||
|
||||
// Lorsqu'on clique sur le lien d'envoi de message privé, on ferme le popover
|
||||
// et on demande à ouvrir le canal privé avec l'auteur⋅rice du message
|
||||
document.getElementById('send-private-message-link-msg-' + message.id).addEventListener('click', event => {
|
||||
event.preventDefault()
|
||||
popover.dispose()
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'start_private_chat',
|
||||
'user_id': message.author_id,
|
||||
}))
|
||||
})
|
||||
|
||||
if (has_right_to_edit) {
|
||||
// Si on a le droit de modifier ou supprimer le message, on enregistre les écouteurs d'événements
|
||||
// Le bouton de modification de message ouvre une boîte de dialogue pour modifier le message
|
||||
document.getElementById('edit-message-' + message.id).addEventListener('click', event => {
|
||||
event.preventDefault()
|
||||
// Fermeture du popover
|
||||
popover.dispose()
|
||||
|
||||
// Ouverture d'une boîte de diaologue afin de modifier le message
|
||||
let new_message = prompt("Modifier le message", message.content)
|
||||
if (new_message) {
|
||||
// Si le message a été modifié, on envoie la demande de modification au serveur
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'edit_message',
|
||||
'message_id': message.id,
|
||||
'content': new_message,
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// Le bouton de suppression de message demande une confirmation avant de supprimer le message
|
||||
document.getElementById('delete-message-' + message.id).addEventListener('click', event => {
|
||||
event.preventDefault()
|
||||
// Fermeture du popover
|
||||
popover.dispose()
|
||||
|
||||
// Demande de confirmation avant de supprimer le message
|
||||
if (confirm(`Supprimer le message ?\n${message.content}`)) {
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'delete_message',
|
||||
'message_id': message.id,
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Passe le chat en version plein écran, ou l'inverse si c'est déjà le cas.
|
||||
*/
|
||||
function toggleFullscreen() {
|
||||
let chatContainer = document.getElementById('chat-container')
|
||||
if (!chatContainer.getAttribute('data-fullscreen')) {
|
||||
// Le chat n'est pas en plein écran.
|
||||
// On le passe en plein écran en le plaçant en avant plan en position absolue
|
||||
// prenant toute la hauteur et toute la largeur
|
||||
chatContainer.setAttribute('data-fullscreen', 'true')
|
||||
chatContainer.classList.add('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3')
|
||||
window.history.replaceState({}, null, `?fullscreen=1`)
|
||||
}
|
||||
else {
|
||||
// Le chat est déjà en plein écran. On retire les tags CSS correspondant au plein écran.
|
||||
chatContainer.removeAttribute('data-fullscreen')
|
||||
chatContainer.classList.remove('position-absolute', 'top-0', 'start-0', 'vh-100', 'z-3')
|
||||
window.history.replaceState({}, null, `?fullscreen=0`)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Lorsqu'on effectue le moindre clic, on ferme les éventuelles popovers ouvertes
|
||||
document.addEventListener('click', removeAllPopovers)
|
||||
|
||||
// Lorsqu'on change entre le tri des canaux par ordre alphabétique et par nombre de messages non lus,
|
||||
// on met à jour l'ordre des canaux
|
||||
document.getElementById('sort-by-unread-switch').addEventListener('change', event => {
|
||||
const sortByUnread = event.target.checked
|
||||
for (let channel of Object.values(channels)) {
|
||||
let item = document.getElementById(`tab-channel-${channel.id}`)
|
||||
if (sortByUnread)
|
||||
// Si on trie par nombre de messages non lus,
|
||||
// on définit l'ordre de l'élément en fonction du nombre de messages non lus
|
||||
// à l'aide d'une propriété CSS
|
||||
item.style.order = `${-channel.unread_messages}`
|
||||
else
|
||||
// Sinon, les canaux sont de base triés par ordre alphabétique
|
||||
item.style.removeProperty('order')
|
||||
}
|
||||
|
||||
// On stocke le mode de tri dans le stockage local
|
||||
localStorage.setItem('chat.sort-by-unread', sortByUnread)
|
||||
})
|
||||
|
||||
// On récupère le mode de tri des canaux depuis le stockage local
|
||||
if (localStorage.getItem('chat.sort-by-unread') === 'true') {
|
||||
document.getElementById('sort-by-unread-switch').checked = true
|
||||
document.getElementById('sort-by-unread-switch').dispatchEvent(new Event('change'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Des données sont reçues depuis le serveur. Elles sont traitées dans cette fonction,
|
||||
* qui a pour but de trier et de répartir dans d'autres sous-fonctions.
|
||||
* @param data Le message reçu.
|
||||
*/
|
||||
function processMessage(data) {
|
||||
// On traite le message en fonction de son type
|
||||
switch (data.type) {
|
||||
case 'fetch_channels':
|
||||
setChannels(data.channels)
|
||||
break
|
||||
case 'send_message':
|
||||
receiveMessage(data)
|
||||
break
|
||||
case 'edit_message':
|
||||
editMessage(data)
|
||||
break
|
||||
case 'delete_message':
|
||||
deleteMessage(data)
|
||||
break
|
||||
case 'fetch_messages':
|
||||
receiveFetchedMessages(data)
|
||||
break
|
||||
case 'mark_read':
|
||||
markMessageAsRead(data)
|
||||
break
|
||||
case 'start_private_chat':
|
||||
startPrivateChat(data)
|
||||
break
|
||||
default:
|
||||
// Le type de message est inconnu. On affiche une erreur dans la console.
|
||||
console.log(data)
|
||||
console.error('Unknown message type:', data.type)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration du socket de chat, permettant de communiquer avec le serveur.
|
||||
* @param nextDelay Correspond au délai de reconnexion en cas d'erreur.
|
||||
* Augmente exponentiellement en cas d'erreurs répétées,
|
||||
* et se réinitialise à 1s en cas de connexion réussie.
|
||||
*/
|
||||
function setupSocket(nextDelay = 1000) {
|
||||
// Ouverture du socket
|
||||
socket = new WebSocket(
|
||||
(document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/chat/'
|
||||
)
|
||||
let socketOpen = false
|
||||
|
||||
// Écoute des messages reçus depuis le serveur
|
||||
socket.addEventListener('message', e => {
|
||||
// Analyse du message reçu en tant que JSON
|
||||
const data = JSON.parse(e.data)
|
||||
|
||||
// Traite le message reçu
|
||||
processMessage(data)
|
||||
})
|
||||
|
||||
// En cas d'erreur, on affiche un message et on réessaie de se connecter après un certain délai
|
||||
// Ce délai double après chaque erreur répétée, jusqu'à un maximum de 2 minutes
|
||||
socket.addEventListener('close', e => {
|
||||
console.error('Chat socket closed unexpectedly, restarting…')
|
||||
setTimeout(() => setupSocket(Math.max(socketOpen ? 1000 : 2 * nextDelay, 120000)), nextDelay)
|
||||
})
|
||||
|
||||
// En cas de connexion réussie, on demande au serveur les derniers messages pour chaque canal
|
||||
socket.addEventListener('open', e => {
|
||||
socketOpen = true
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'fetch_channels',
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration du swipe pour ouvrir et fermer le sélecteur de canaux.
|
||||
* Fonctionne a priori uniquement sur les écrans tactiles.
|
||||
* Lorsqu'on swipe de la gauche vers la droite, depuis le côté gauche de l'écran, on ouvre le sélecteur de canaux.
|
||||
* Quand on swipe de la droite vers la gauche, on ferme le sélecteur de canaux.
|
||||
*/
|
||||
function setupSwipeOffscreen() {
|
||||
// Récupération du sélecteur de canaux
|
||||
const offcanvas = new bootstrap.Offcanvas(document.getElementById('channelSelector'))
|
||||
|
||||
// L'écran a été touché. On récupère la coordonnée X de l'emplacement touché.
|
||||
let lastX = null
|
||||
document.addEventListener('touchstart', (event) => {
|
||||
if (event.touches.length === 1)
|
||||
lastX = event.touches[0].clientX
|
||||
})
|
||||
|
||||
// Le doigt a été déplacé. Selon le nouvel emplacement du doigt, on ouvre ou on ferme le sélecteur de canaux.
|
||||
document.addEventListener('touchmove', (event) => {
|
||||
if (event.touches.length === 1 && lastX !== null) {
|
||||
// L'écran a été touché à un seul doigt, et on a déjà récupéré la coordonnée X touchée.
|
||||
const diff = event.touches[0].clientX - lastX
|
||||
if (diff > window.innerWidth / 10 && lastX < window.innerWidth / 4) {
|
||||
// Si le déplacement correspond à au moins 10 % de la largeur de l'écran vers la droite
|
||||
// et que le point de départ se trouve dans le quart gauche de l'écran, alors on ouvre le sélecteur
|
||||
offcanvas.show()
|
||||
lastX = null
|
||||
}
|
||||
else if (diff < -window.innerWidth / 10) {
|
||||
// Si le déplacement correspond à au moins 10 % de la largeur de l'écran vers la gauche,
|
||||
// alors on ferme le sélecteur
|
||||
offcanvas.hide()
|
||||
lastX = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Le doigt a été relâché. On réinitialise la coordonnée X touchée.
|
||||
document.addEventListener('touchend', () => {
|
||||
lastX = null
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration du suivi de lecture des messages.
|
||||
* Lorsque l'utilisateur⋅rice scrolle dans la fenêtre de chat, on vérifie quels sont les messages qui sont
|
||||
* visibles à l'écran, et on les marque comme lus.
|
||||
*/
|
||||
function setupReadTracker() {
|
||||
// Récupération du conteneur de messages
|
||||
const scrollableContent = document.getElementById('chat-messages')
|
||||
const messagesList = document.getElementById('message-list')
|
||||
let markReadBuffer = []
|
||||
let markReadTimeout = null
|
||||
|
||||
// Lorsqu'on scrolle, on récupère les anciens messages si on est tout en haut,
|
||||
// et on marque les messages visibles comme lus
|
||||
scrollableContent.addEventListener('scroll', () => {
|
||||
if (scrollableContent.clientHeight - scrollableContent.scrollTop === scrollableContent.scrollHeight
|
||||
&& !document.getElementById('fetch-previous-messages').classList.contains('d-none')) {
|
||||
// Si l'utilisateur⋅rice est en haut du chat, on récupère la liste des anciens messages
|
||||
fetchPreviousMessages()}
|
||||
|
||||
// On marque les messages visibles comme lus
|
||||
markVisibleMessagesAsRead()
|
||||
})
|
||||
|
||||
// Lorsque les messages stockés sont mis à jour, on vérifie quels sont les messages visibles à marquer comme lus
|
||||
messagesList.addEventListener('updatemessages', () => markVisibleMessagesAsRead())
|
||||
|
||||
/**
|
||||
* Marque les messages visibles à l'écran comme lus.
|
||||
* On récupère pour cela les coordonnées du conteneur de messages ainsi que les coordonnées de chaque message
|
||||
* et on vérifie si le message est visible à l'écran. Si c'est le cas, on le marque comme lu.
|
||||
* Après 3 secondes d'attente après qu'aucun message n'ait été lu,
|
||||
* on envoie la liste des messages lus au serveur.
|
||||
*/
|
||||
function markVisibleMessagesAsRead() {
|
||||
// Récupération des coordonnées visibles du conteneur de messages
|
||||
let viewport = scrollableContent.getBoundingClientRect()
|
||||
|
||||
for (let item of messagesList.querySelectorAll('.message')) {
|
||||
let message = messages[selected_channel_id].get(parseInt(item.getAttribute('data-message-id')))
|
||||
if (!message.read) {
|
||||
// Si le message n'a pas déjà été lu, on récupère ses coordonnées
|
||||
let rect = item.getBoundingClientRect()
|
||||
if (rect.top >= viewport.top && rect.bottom <= viewport.bottom) {
|
||||
// Si les coordonnées sont entièrement incluses dans le rectangle visible, on le marque comme lu
|
||||
// et comme étant à envoyer au serveur
|
||||
message.read = true
|
||||
markReadBuffer.push(message.id)
|
||||
if (markReadTimeout)
|
||||
clearTimeout(markReadTimeout)
|
||||
// 3 secondes après qu'aucun nouveau message n'ait été rajouté, on envoie la liste des messages
|
||||
// lus au serveur
|
||||
markReadTimeout = setTimeout(() => {
|
||||
socket.send(JSON.stringify({
|
||||
'type': 'mark_read',
|
||||
'message_ids': markReadBuffer,
|
||||
}))
|
||||
markReadBuffer = []
|
||||
markReadTimeout = null
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On considère les messages d'ores-et-déjà visibles comme lus
|
||||
markVisibleMessagesAsRead()
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration de la demande d'installation de l'application en tant qu'application web progressive (PWA).
|
||||
* Lorsque l'utilisateur⋅rice arrive sur la page, on lui propose de télécharger l'application
|
||||
* pour l'ajouter à son écran d'accueil.
|
||||
* Fonctionne uniquement sur les navigateurs compatibles.
|
||||
*/
|
||||
function setupPWAPrompt() {
|
||||
let deferredPrompt = null
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (e) => {
|
||||
// Une demande d'installation a été faite. On commence par empêcher l'action par défaut.
|
||||
e.preventDefault()
|
||||
deferredPrompt = e
|
||||
|
||||
// L'installation est possible, on rend visible le bouton de téléchargement
|
||||
// ainsi que le message qui indique c'est possible.
|
||||
let btn = document.getElementById('install-app-home-screen')
|
||||
let alert = document.getElementById('alert-download-chat-app')
|
||||
btn.classList.remove('d-none')
|
||||
alert.classList.remove('d-none')
|
||||
btn.onclick = function () {
|
||||
// Lorsque le bouton de téléchargement est cliqué, on lance l'installation du PWA.
|
||||
deferredPrompt.prompt()
|
||||
deferredPrompt.userChoice.then((choiceResult) => {
|
||||
if (choiceResult.outcome === 'accepted') {
|
||||
// Si l'installation a été acceptée, on masque le bouton de téléchargement.
|
||||
deferredPrompt = null
|
||||
btn.classList.add('d-none')
|
||||
alert.classList.add('d-none')
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setupSocket() // Configuration du Websocket
|
||||
setupSwipeOffscreen() // Configuration du swipe sur les écrans tactiles pour le sélecteur de canaux
|
||||
setupReadTracker() // Configuration du suivi de lecture des messages
|
||||
setupPWAPrompt() // Configuration de l'installateur d'application en tant qu'application web progressive
|
||||
})
|
25
chat/templates/chat/chat.html
Normal file
@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load pipeline %}
|
||||
|
||||
{% block extracss %}
|
||||
{# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #}
|
||||
{% if TFJM.APP == "TFJM" %}
|
||||
<link rel="manifest" href="{% static "tfjm/chat_tfjm.webmanifest" %}">
|
||||
{% elif TFJM.APP == "ETEAM" %}
|
||||
<link rel="manifest" href="{% static "tfjm/chat_eteam.webmanifest" %}">
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content-title %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "chat/content.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extrajavascript %}
|
||||
{# Ce script contient toutes les données pour la gestion du chat. #}
|
||||
{% javascript 'chat' %}
|
||||
{% endblock %}
|
126
chat/templates/chat/content.html
Normal file
@ -0,0 +1,126 @@
|
||||
{% load i18n %}
|
||||
|
||||
<noscript>
|
||||
{# Le chat fonctionne à l'aide d'un script JavaScript, sans JavaScript activé il n'est pas possible d'utiliser le chat. #}
|
||||
{% trans "JavaScript must be enabled on your browser to access chat." %}
|
||||
</noscript>
|
||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="channelSelector" aria-labelledby="offcanvasTitle">
|
||||
<div class="offcanvas-header">
|
||||
{# Titre du sélecteur de canaux #}
|
||||
<h3 class="offcanvas-title" id="offcanvasTitle">{% trans "Chat channels" %}</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
{# Contenu du sélecteur de canaux #}
|
||||
<div class="form-switch form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="sort-by-unread-switch">
|
||||
<label class="form-check-label" for="sort-by-unread-switch">{% trans "Sort by unread messages" %}</label>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush" id="nav-channels-tab">
|
||||
{# Liste des différentes catégories, avec les canaux par catégorie #}
|
||||
<li class="list-group-item d-none">
|
||||
{# Canaux généraux #}
|
||||
<h4>{% trans "General channels" %}</h4>
|
||||
<ul class="list-group list-group-flush" id="nav-general-channels-tab"></ul>
|
||||
</li>
|
||||
<li class="list-group-item d-none">
|
||||
{# Canaux liés à un tournoi #}
|
||||
<h4>{% trans "Tournament channels" %}</h4>
|
||||
<ul class="list-group list-group-flush" id="nav-tournament-channels-tab"></ul>
|
||||
</li>
|
||||
<li class="list-group-item d-none">
|
||||
{# Canaux d'équipes #}
|
||||
<h4>{% trans "Team channels" %}</h4>
|
||||
<ul class="list-group list-group-flush" id="nav-team-channels-tab"></ul>
|
||||
</li>
|
||||
<li class="list-group-item d-none">
|
||||
{# Échanges privés #}
|
||||
<h4>{% trans "Private channels" %}</h4>
|
||||
<ul class="list-group list-group-flush" id="nav-private-channels-tab"></ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info d-none" id="alert-download-chat-app">
|
||||
{# Lorsque l'application du chat est installable (par exemple sur un Chrome sur Android), on affiche le message qui indique que c'est bien possible. #}
|
||||
{% trans "You can install a shortcut to the chat on your home screen using the download button on the header." %}
|
||||
</div>
|
||||
|
||||
{# Conteneur principal du chat. #}
|
||||
{# Lorsque le chat est en plein écran, on le place en coordonnées absolues, occupant tout l'espace de l'écran. #}
|
||||
<div class="card tab-content w-100 mh-100{% if request.GET.fullscreen == '1' or fullscreen %} position-absolute top-0 start-0 vh-100 z-3{% endif %}"
|
||||
style="height: 95vh" id="chat-container">
|
||||
<div class="card-header">
|
||||
<h3>
|
||||
{% if fullscreen %}
|
||||
{# Lorsque le chat est en plein écran, on affiche le bouton de déconnexion. #}
|
||||
{# Le bouton de déconnexion doit être présent dans un formulaire. Le formulaire doit inclure toute la ligne. #}
|
||||
<form action="{% url 'chat:logout' %}" method="post">
|
||||
{% csrf_token %}
|
||||
{% endif %}
|
||||
{# Bouton qui permet d'ouvrir le sélecteur de canaux #}
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="offcanvas" data-bs-target="#channelSelector"
|
||||
aria-controls="channelSelector" aria-expanded="false" aria-label="Toggle channel selector">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<span id="channel-title"></span> {# Titre du canal sélectionné #}
|
||||
{% if not fullscreen %}
|
||||
{# Dans le cas où on est pas uniquement en plein écran (cas de l'application), on affiche les boutons pour passer en ou quitter le mode plein écran. #}
|
||||
<button class="btn float-end" type="button" onclick="toggleFullscreen()" title="{% trans "Toggle fullscreen mode" %}">
|
||||
<i class="fas fa-expand"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
{# Le bouton de déconnexion n'est affiché que sur l'application. #}
|
||||
<button class="btn float-end" title="{% trans "Log out" %}">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
{# On affiche le bouton d'installation uniquement dans le cas où l'application est installable sur l'écran d'accueil. #}
|
||||
<button class="btn float-end d-none" type="button" id="install-app-home-screen" title="{% trans "Install app on home screen" %}">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
{% if fullscreen %}
|
||||
</form>
|
||||
{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{# Contenu de la carte, contenant la liste des messages. La liste des messages est affichée à l'envers pour avoir un scroll plus cohérent. #}
|
||||
<div class="card-body d-flex flex-column-reverse flex-grow-0 overflow-y-scroll" id="chat-messages">
|
||||
{# Correspond à la liste des messages à afficher. #}
|
||||
<ul class="list-group list-group-flush" id="message-list"></ul>
|
||||
{# S'il y a des messages à récupérer, on affiche un lien qui permet de récupérer les anciens messages. #}
|
||||
<div class="text-center d-none" id="fetch-previous-messages">
|
||||
<a href="#" class="nav-link" onclick="event.preventDefault(); fetchPreviousMessages()">
|
||||
{% trans "Fetch previous messages…" %}
|
||||
</a>
|
||||
<hr>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Pied de la carte, contenant le formulaire pour envoyer un message. #}
|
||||
<div class="card-footer mt-auto">
|
||||
{# Lorsqu'on souhaite envoyer un message, on empêche le formulaire de s'envoyer et on envoie le message par websocket. #}
|
||||
<form onsubmit="event.preventDefault(); sendMessage()">
|
||||
<div class="input-group">
|
||||
<label for="input-message" class="input-group-text">
|
||||
<i class="fas fa-comment"></i>
|
||||
</label>
|
||||
{# Affichage du contrôleur de texte pour rédiger le message à envoyer. #}
|
||||
<input type="text" class="form-control" id="input-message" placeholder="{% trans "Send message…" %}" autofocus autocomplete="off">
|
||||
<button class="input-group-text btn btn-success" type="submit">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{# Récupération de l'utilisateur⋅rice courant⋅e afin de pouvoir effectuer des tests plus tard. #}
|
||||
const USER_ID = {{ request.user.id }}
|
||||
{# Récupération du statut administrateur⋅rice de l'utilisateur⋅rice connecté⋅e afin de pouvoir effectuer des tests plus tard. #}
|
||||
const IS_ADMIN = {{ request.user.registration.is_admin|yesno:"true,false" }}
|
||||
</script>
|
47
chat/templates/chat/fullscreen.html
Normal file
@ -0,0 +1,47 @@
|
||||
{% load i18n pipeline static %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
|
||||
<html lang="{{ LANGUAGE_CODE|default:"fr" }}" {% if LANGUAGE_BIDI %}dir="rtl"{% endif %}>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
{% if TFJM.APP == "TFJM" %}
|
||||
<title>{% trans "TFJM² Chat" %}</title>
|
||||
<meta name="description" content="{% trans "TFJM² Chat" %}">
|
||||
{% elif TFJM.APP == "ETEAM" %}
|
||||
<title>{% trans "ETEAM Chat" %}</title>
|
||||
<meta name="description" content="{% trans "ETEAM Chat" %}">
|
||||
{% endif %}
|
||||
|
||||
{# Favicon #}
|
||||
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
{# Bootstrap CSS #}
|
||||
<link href="{% static "bootstrap/css/bootstrap.min.css" %}" rel="stylesheet" type="text/css">
|
||||
{# Fontawesome CSS #}
|
||||
<link href="{% static "fontawesome/css/all.min.css" %}" rel="stylesheet" type="text/css">
|
||||
<link href="{% static "fontawesome/css/v4-shims.css" %}">
|
||||
{# bootstrap-select CSS #}
|
||||
<link href="{% static "bootstrap-select/css/bootstrap-select.min.css" %}" rel="stylesheet" type="text/css">
|
||||
|
||||
{# Bootstrap JavaScript #}
|
||||
<script type="application/javascript" src="{% static "bootstrap/js/bootstrap.bundle.min.js" %}" charset="utf-8"></script>
|
||||
|
||||
{# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #}
|
||||
{% if TFJM.APP == "TFJM" %}
|
||||
<link rel="manifest" href="{% static "tfjm/chat_tfjm.webmanifest" %}">
|
||||
{% elif TFJM.APP == "ETEAM" %}
|
||||
<link rel="manifest" href="{% static "tfjm/chat_eteam.webmanifest" %}">
|
||||
{% endif %}
|
||||
</head>
|
||||
<body class="d-flex w-100 h-100 flex-column">
|
||||
{% include "chat/content.html" with fullscreen=True %}
|
||||
|
||||
{# Inclusion du script permettant de gérer le thème sombre et le thème clair #}
|
||||
{% javascript 'theme' %}
|
||||
{# Inclusion du script gérant le chat #}
|
||||
{% javascript 'chat' %}
|
||||
</body>
|
||||
</html>
|
43
chat/templates/chat/login.html
Normal file
@ -0,0 +1,43 @@
|
||||
{% load i18n pipeline static %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
|
||||
<html lang="{{ LANGUAGE_CODE|default:"fr" }}" {% if LANGUAGE_BIDI %}dir="rtl"{% endif %}>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<title>
|
||||
{% trans "Chat" %} - {% trans "Log in" %}
|
||||
</title>
|
||||
<meta name="description" content="{% trans "Chat" %}">
|
||||
|
||||
{# Favicon #}
|
||||
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
{# Bootstrap CSS #}
|
||||
<link href="{% static "bootstrap/css/bootstrap.min.css" %}" rel="stylesheet" type="text/css">
|
||||
{# Fontawesome CSS #}
|
||||
<link href="{% static "fontawesome/css/all.min.css" %}" rel="stylesheet" type="text/css">
|
||||
<link href="{% static "fontawesome/css/v4-shims.css" %}">
|
||||
|
||||
{# Bootstrap JavaScript #}
|
||||
<script type="application/javascript" src="{% static "bootstrap/js/bootstrap.bundle.min.js" %}" charset="utf-8"></script>
|
||||
|
||||
{# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #}
|
||||
{% if TFJM.APP == "TFJM" %}
|
||||
<link rel="manifest" href="{% static "tfjm/chat_tfjm.webmanifest" %}">
|
||||
{% elif TFJM.APP == "ETEAM" %}
|
||||
<link rel="manifest" href="{% static "tfjm/chat_eteam.webmanifest" %}">
|
||||
{% endif %}
|
||||
</head>
|
||||
<body class="d-flex w-100 h-100 flex-column">
|
||||
<div class="container">
|
||||
<h1>{% trans "Log in" %}</h1>
|
||||
{% include "registration/includes/login.html" %}
|
||||
</div>
|
||||
|
||||
{# Inclusion du script permettant de gérer le thème sombre et le thème clair #}
|
||||
{% javascript 'theme' %}
|
||||
</body>
|
||||
</html>
|
2
chat/tests.py
Normal file
@ -0,0 +1,2 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
18
chat/urls.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.contrib.auth.views import LoginView, LogoutView
|
||||
from django.urls import path
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from tfjm.views import LoginRequiredTemplateView
|
||||
|
||||
app_name = 'chat'
|
||||
|
||||
urlpatterns = [
|
||||
path('', LoginRequiredTemplateView.as_view(template_name="chat/chat.html",
|
||||
extra_context={'title': _("Chat")}), name='chat'),
|
||||
path('fullscreen/', LoginRequiredTemplateView.as_view(template_name="chat/fullscreen.html", login_url='chat:login'),
|
||||
name='fullscreen'),
|
||||
path('login/', LoginView.as_view(template_name="chat/login.html"), name='login'),
|
||||
path('logout/', LogoutView.as_view(next_page='chat:fullscreen'), name='logout'),
|
||||
]
|
20
docs/Makefile
Normal file
@ -0,0 +1,20 @@
|
||||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line, and also
|
||||
# from the environment for the first two.
|
||||
SPHINXOPTS ?=
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SOURCEDIR = .
|
||||
BUILDDIR = _build
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
.PHONY: help Makefile
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
BIN
docs/_static/img/choose_tournament.png
vendored
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
docs/_static/img/create_team.png
vendored
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
docs/_static/img/draw_choose_problem.png
vendored
Normal file
After Width: | Height: | Size: 27 KiB |
BIN
docs/_static/img/draw_choose_problem_full.png
vendored
Normal file
After Width: | Height: | Size: 84 KiB |
BIN
docs/_static/img/draw_end_round_1.png
vendored
Normal file
After Width: | Height: | Size: 124 KiB |
BIN
docs/_static/img/draw_example.png
vendored
Normal file
After Width: | Height: | Size: 101 KiB |
BIN
docs/_static/img/draw_general.png
vendored
Normal file
After Width: | Height: | Size: 107 KiB |
BIN
docs/_static/img/draw_last_rolls.png
vendored
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
docs/_static/img/draw_passage_tables.png
vendored
Normal file
After Width: | Height: | Size: 81 KiB |
BIN
docs/_static/img/draw_recap.png
vendored
Normal file
After Width: | Height: | Size: 48 KiB |
BIN
docs/_static/img/draw_start.png
vendored
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
docs/_static/img/draw_tournament_tabs.png
vendored
Normal file
After Width: | Height: | Size: 9.8 KiB |
BIN
docs/_static/img/draw_waiting_choose_problem_order.png
vendored
Normal file
After Width: | Height: | Size: 76 KiB |
BIN
docs/_static/img/draw_waiting_choose_problem_order_full.png
vendored
Normal file
After Width: | Height: | Size: 131 KiB |
BIN
docs/_static/img/draw_waiting_passage_order.png
vendored
Normal file
After Width: | Height: | Size: 65 KiB |
BIN
docs/_static/img/draw_waiting_passage_order_full.png
vendored
Normal file
After Width: | Height: | Size: 92 KiB |
BIN
docs/_static/img/draw_waiting_problem_draw.png
vendored
Normal file
After Width: | Height: | Size: 29 KiB |
BIN
docs/_static/img/draw_waiting_problem_draw_full.png
vendored
Normal file
After Width: | Height: | Size: 86 KiB |
BIN
docs/_static/img/join_team.png
vendored
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
docs/_static/img/payment_bank_transfer.png
vendored
Normal file
After Width: | Height: | Size: 118 KiB |
BIN
docs/_static/img/payment_grouped.png
vendored
Normal file
After Width: | Height: | Size: 115 KiB |
BIN
docs/_static/img/payment_hello_asso_confirmation.png
vendored
Normal file
After Width: | Height: | Size: 107 KiB |
BIN
docs/_static/img/payment_hello_asso_step_1.png
vendored
Normal file
After Width: | Height: | Size: 101 KiB |
BIN
docs/_static/img/payment_hello_asso_step_2.png
vendored
Normal file
After Width: | Height: | Size: 87 KiB |
BIN
docs/_static/img/payment_index.png
vendored
Normal file
After Width: | Height: | Size: 120 KiB |
BIN
docs/_static/img/payment_scholarship.png
vendored
Normal file
After Width: | Height: | Size: 94 KiB |
BIN
docs/_static/img/team_info.png
vendored
Normal file
After Width: | Height: | Size: 61 KiB |
BIN
docs/_static/img/tournament_info.png
vendored
Normal file
After Width: | Height: | Size: 77 KiB |
BIN
docs/_static/img/user_info.png
vendored
Normal file
After Width: | Height: | Size: 73 KiB |
BIN
docs/_static/img/validate_team.png
vendored
Normal file
After Width: | Height: | Size: 16 KiB |
63
docs/conf.py
Normal file
@ -0,0 +1,63 @@
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# This file only contains a selection of the most common options. For a full
|
||||
# list see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
# -- Path setup --------------------------------------------------------------
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
#
|
||||
# import os
|
||||
# import sys
|
||||
# sys.path.insert(0, os.path.abspath('.'))
|
||||
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = 'Plateforme du TFJM²'
|
||||
copyright = "2020-2024"
|
||||
author = "Animath"
|
||||
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
"sphinx_rtd_theme",
|
||||
"sphinx_rtd_dark_mode",
|
||||
]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = 'fr'
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
# This pattern also affects html_static_path and html_extra_path.
|
||||
exclude_patterns = ['_build']
|
||||
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
#
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
|
||||
default_dark_mode = True
|
23
docs/dev/index.rst
Normal file
@ -0,0 +1,23 @@
|
||||
Développer la plateforme
|
||||
========================
|
||||
|
||||
Cette page est dédiée aux responsables informatiques qui cherchent à contribuer à la plateforme.
|
||||
|
||||
Présentation
|
||||
------------
|
||||
|
||||
La plateforme d'inscription du TFJM² actuelle est née lors de l'édition 2020. Elle n'est
|
||||
pas la première à exister, elle succède à une précédente, moins fonctionnelle, dont les
|
||||
sources ont été perdues. Elle a été développée par Emmy D'Anello, bénévole pour Animath,
|
||||
qui la maintient au moins jusqu'en 2024.
|
||||
|
||||
La plateforme est développée en Python, utilisant le framework web
|
||||
`Django <https://www.djangoproject.com/>`_. Elle est diponible librement sous licence GPLv3
|
||||
à l'adresse `<https://gitlab.com/animath/si/plateforme-tfjm>`_.
|
||||
|
||||
L'instance de production est accessible à l'adresse `<https://inscription.tfjm.org/>`_.
|
||||
Une instance de développement est accessible à l'adresse `<https://inscription-dev.tfjm.org/>`_.
|
||||
|
||||
Les deux instances sont hébergées sur le serveur d'Animath. La documentation spécifique
|
||||
à l'installation des services d'Animath peut être trouvée à l'adresse
|
||||
`<https://doc.animath.live/>`_.
|
309
docs/dev/install.rst
Normal file
@ -0,0 +1,309 @@
|
||||
Installation de la plateforme du TFJM²
|
||||
======================================
|
||||
|
||||
Cette page documente la procédure d'installation de la plateforme du TFJM²,
|
||||
aussi bien dans un environnement de développement que de production.
|
||||
|
||||
|
||||
Installation en production
|
||||
--------------------------
|
||||
|
||||
Installation de Docker
|
||||
""""""""""""""""""""""
|
||||
|
||||
Les outils du TFJM² sont déployés en utilisant `Docker <https://www.docker.com/>`_.
|
||||
Pour plus de détails sur la configuration pour le TFJM², voir
|
||||
`la page dédiée à Docker<docker.html>`_.
|
||||
|
||||
Commencez par installer Docker et Docker-Compose (qui sont a priori déjà installés
|
||||
sur la plateforme du TFJM²), en commençant par installer le dépôt APT de Docker :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
# Add Docker's official GPG key:
|
||||
sudo apt update
|
||||
sudo apt install ca-certificates curl gnupg
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
|
||||
sudo chmod a+r /usr/share/keyrings/docker-archive-keyring.gpg
|
||||
|
||||
# Add the repository to Apt sources:
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
|
||||
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
|
||||
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt update
|
||||
|
||||
sudo apt install docker-ce docker-compose-plugin
|
||||
|
||||
Installation de la plateforme
|
||||
"""""""""""""""""""""""""""""
|
||||
|
||||
Dans le fichier ``docker-compose.yml``, configurer :
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
version: "3.8"
|
||||
services:
|
||||
# […]
|
||||
inscription:
|
||||
build: https://gitlab.com/animath/si/plateforme.git
|
||||
links:
|
||||
- postgres
|
||||
- redis
|
||||
- elasticsearch
|
||||
env_file:
|
||||
- ./secrets/inscription.env
|
||||
restart: always
|
||||
volumes:
|
||||
- "./data/inscription/media:/code/media"
|
||||
- "/etc/localtime:/etc/localtime:ro"
|
||||
networks:
|
||||
- tfjm
|
||||
labels:
|
||||
- "traefik.http.routers.inscription-tfjm2.rule=Host(`inscription.tfjm.org`, `inscriptions.tfjm.org`, `plateforme.tfjm.org`)"
|
||||
- "traefik.http.routers.inscription-tfjm2.entrypoints=websecure"
|
||||
- "traefik.http.routers.inscription-tfjm2.tls.certresolver=mytlschallenge"
|
||||
|
||||
postgres:
|
||||
image: postgres:16
|
||||
volumes:
|
||||
- "./data/postgresql_16:/var/lib/postgresql/data"
|
||||
restart: always
|
||||
env_file:
|
||||
- ./secrets/postgres.env
|
||||
networks:
|
||||
- tfjm
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
restart: always
|
||||
networks:
|
||||
- tfjm
|
||||
|
||||
elasticsearch:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.9
|
||||
restart: always
|
||||
env_file:
|
||||
- ./secrets/elasticsearch.env
|
||||
networks:
|
||||
- tfjm
|
||||
# […]
|
||||
|
||||
En cas d'instance de pré-production, il est possible de changer de branche en
|
||||
rajoutant par exemple ``#dev`` à la fin de l'URL.
|
||||
|
||||
Les différents paramètres peuvent être modifiés si nécessaire, à commencer par les
|
||||
versions des autres services (PostgreSQL, Redis, Elasticsearch). Penser à mettre à jour
|
||||
cette documentation en cas de mise à jour future. Il est attendu d'avoir un réseau
|
||||
``tfjm`` configuré :
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
networks:
|
||||
tfjm:
|
||||
driver: bridge
|
||||
|
||||
L'URL utilisée peut être modifiée en changeant les options de Traefik, section ``labels``.
|
||||
|
||||
Configuration
|
||||
"""""""""""""
|
||||
|
||||
La configuration se fait essentiellement dans les fichiers d'environnement.
|
||||
|
||||
Pour la base de données, une seule variable d'environnement est requise :
|
||||
``POSTGRES_PASSWORD``, qui correspond au mot de passe maître de PostgreSQL.
|
||||
|
||||
Les variables d'environnement de ElasticSearch se résume à du paramétrage de mémoire :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
discovery.type=single-node
|
||||
ES_JAVA_OPTS=-Xms512m -Xmx512m
|
||||
|
||||
Redis n'a pas besoin de configuration particulière.
|
||||
|
||||
Enfin, pour la plateforme elle-même, il faut configurer les variables suivantes :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
TFJM_STAGE=prod
|
||||
DJANGO_SECRET_KEY=
|
||||
DJANGO_DB_TYPE=PostgreSQL
|
||||
DJANGO_DB_HOST=postgres
|
||||
DJANGO_DB_PORT=
|
||||
DJANGO_DB_NAME=
|
||||
DJANGO_DB_USER=
|
||||
DJANGO_DB_PASSWORD=
|
||||
REDIS_SERVER_HOST=redis
|
||||
REDIS_SERVER_PORT=6379
|
||||
SMTP_HOST=ssl0.ovh.net
|
||||
SMTP_PORT=465
|
||||
SMTP_HOST_USER=contact@tfjm.org
|
||||
SMTP_HOST_PASSWORD=
|
||||
FROM_EMAIL=Contact TFJM²
|
||||
SERVER_EMAIL=contact@tfjm.org
|
||||
HAYSTACK_INDEX_NAME=inscription-tfjm
|
||||
SYMPA_HOST=lists.tfjm.org
|
||||
SYMPA_URL=lists.tfjm.org/sympa
|
||||
SYMPA_EMAIL=contact@tfjm.org
|
||||
SYMPA_PASSWORD=
|
||||
HELLOASSO_CLIENT_ID=
|
||||
HELLOASSO_CLIENT_SECRET=
|
||||
|
||||
* ``TFJM_STAGE`` : ``prod`` ou ``dev``. ``prod`` est utilisé pour la production,
|
||||
``dev`` pour le développement ou pré-production. Permet de désactiver certaines
|
||||
fonctionnalités de production, notamment l'envoi de mails, le cache Redis, les listes
|
||||
de diffusion, et d'activer le débogage. Les erreurs seront ainsi directement affichées
|
||||
en développement tandis qu'elles seront envoyées par mail en production.
|
||||
* ``DJANGO_SECRET_KEY`` : correspond à
|
||||
`la clé secrète Django <https://docs.djangoproject.com/fr/5.0/ref/settings/#secret-key>`_,
|
||||
permettant de signer les sessions et les cookies. Il est important de la garder secrète.
|
||||
Pour la générer, il est possible d'utiliser la commande
|
||||
``python3 manage.py generate_secret_key``.
|
||||
* ``DJANGO_DB_TYPE`` : Le type de base de données à utiliser, parmi
|
||||
``PostgreSQL``, ``MySQL`` et ``SQLite``. ``SQLite`` n'est à utiliser qu'en développement
|
||||
local.
|
||||
* ``DJANGO_DB_HOST`` : L'hôte de la base de données pour les bases de données
|
||||
``PostgreSQL`` et ``MySQL``. Pour ``SQLite``, il faut mettre le chemin vers le fichier
|
||||
de base de données. Dans l'exemple ci-dessus, ``postgres`` est l'hôte Docker.
|
||||
* ``DJANGO_DB_PORT`` : Le port de la base de données. (PostgreSQL ou MySQL uniquement)
|
||||
Si laissé vide, utilisera le port par défaut du service, ``5432`` pour PostgreSQL et
|
||||
``3306`` pour MySQL.
|
||||
* ``DJANGO_DB_NAME`` : Le nom de la base de données. (PostgreSQL ou MySQL uniquement)
|
||||
* ``DJANGO_DB_USER`` : Le nom d'utilisateur de la base de données.
|
||||
(PostgreSQL ou MySQL uniquement)
|
||||
* ``DJANGO_DB_PASSWORD`` : Le mot de passe de la base de données.
|
||||
(PostgreSQL ou MySQL uniquement)
|
||||
* ``REDIS_SERVER_HOST`` : L'hôte de Redis (en production uniquement). Dans l'exemple
|
||||
ci-dessus, ``redis`` est l'hôte Docker.
|
||||
* ``REDIS_SERVER_PORT`` : Le port de Redis (en production uniquement). Si laissé vide,
|
||||
utilisera le port par défaut du service, ``6379``.
|
||||
* ``SMTP_HOST`` : L'hôte du serveur SMTP à utiliser pour envoyer les mails. Utilise par
|
||||
défaut le serveur d'OVH.
|
||||
* ``SMTP_PORT`` : Le port du serveur SMTP à utiliser pour envoyer les mails.
|
||||
* ``SMTP_HOST_USER`` : Le nom d'utilisateur du serveur SMTP à utiliser pour envoyer les
|
||||
mails. Correspond à l'identifiant OVH.
|
||||
* ``SMTP_HOST_PASSWORD`` : Le mot de passe du serveur SMTP à utiliser pour envoyer les
|
||||
mails. Correspond au mot de passe OVH.
|
||||
* ``FROM_EMAIL`` : Le nom lisible à utiliser comme expéditeur des mails
|
||||
(défaut : Contact TFJM²).
|
||||
* ``SERVER_EMAIL`` : L'adresse mail à utiliser comme expéditeur des mails
|
||||
(défaut : contact@tfjm.org).
|
||||
* ``HAYSTACK_INDEX_NAME`` : Le nom de l'index ElasticSearch à utiliser pour les recherches
|
||||
dans ElasticSearch (défaut : inscription-tfjm).
|
||||
* ``SYMPA_HOST`` : Le domaine des listes de diffusion Sympa utilisé.
|
||||
* ``SYMPA_URL`` : L'URL du serveur Sympa à utiliser pour gérer les listes de diffusion.
|
||||
* ``SYMPA_EMAIL`` : L'adresse mail à utiliser pour se connecter à Sympa.
|
||||
* ``SYMPA_PASSWORD`` : Le mot de passe à utiliser pour se connecter à Sympa.
|
||||
* ``HELLOASSO_CLIENT_ID`` : L'identifiant client HelloAsso à utiliser pour gérer les
|
||||
paiements HelloAsso.
|
||||
* ``HELLOASSO_CLIENT_SECRET`` : Le secret client HelloAsso à utiliser pour gérer les
|
||||
paiements HelloAsso. Doit être maintenu secret.
|
||||
|
||||
Installation de la base de données
|
||||
""""""""""""""""""""""""""""""""""
|
||||
|
||||
Pour gérer la base de données PostgreSQL, on peut utiliser les commandes suivantes :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
sudo docker compose up -d postgres
|
||||
sudo docker compose exec -u postgres postgres createuser -P inscription_tfjm # Création du compte `inscription_tfjm` en demander un mot de passe. À ne faire qu'une seule fois
|
||||
sudo docker compose exec -u postgres postgres createdb -O inscription_tfjm inscription_tfjm # Création de la base de données `inscription_tfjm` en utilisant le compte `inscription_tfjm`
|
||||
sudo docker compose exec -u postgres pg_dump inscription_tfjm > inscription_tfjm.sql # Pour sauvegarder la base de données `inscription_tfjm`
|
||||
sudo docker compose exec -u postgres dropdb inscription_tfjm # Pour supprimer la base de données `inscription_tfjm`
|
||||
|
||||
La suppression et la recréation sont utiles en cas de réinitialisation annuelle de la base
|
||||
de données.
|
||||
|
||||
|
||||
Lancement
|
||||
"""""""""
|
||||
|
||||
Pour lancer la plateforme, il suffit de lancer la commande suivante :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
sudo docker compose up -d inscription
|
||||
|
||||
Pour arrêter la plateforme, il suffit de lancer la commande suivante :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
sudo docker compose stop inscription
|
||||
|
||||
En cas de mise à jour de la plateforme, il suffit de lancer la commande suivante :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
sudo docker compose up -d --build inscription
|
||||
|
||||
Selon le principe de Docker, les données sont conservées dans un volume Docker, ici
|
||||
dans le dossier ``data/inscription``. Le reste est volatile, puisqu'il peut être recréé
|
||||
à partir du code source. Attention donc de ne pas coder en production (ce qui est de toute
|
||||
façon à proscrire !).
|
||||
|
||||
Les migrations de base de données sont automatiquement appliquées au lancement de la
|
||||
plateforme, de même que la collecte de fichiers statiques ou encore la génération de la
|
||||
documentation. Il n'y a rien à faire de spécial post-lancement, si ce n'est vérifier que
|
||||
tout fonctionne correctement.
|
||||
|
||||
|
||||
Installation en développement
|
||||
-----------------------------
|
||||
|
||||
L'installation sur une machine locale est plus légère et est utile pour pouvoir tester
|
||||
rapidement.
|
||||
|
||||
Commencez par récupérer le code source de la plateforme :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
git clone https://gitlab.com/animath/si/plateforme-tfjm.git
|
||||
cd plateforme-tfjm
|
||||
|
||||
Afin de pouvoir isoler l'environnement de développement, il est conseillé d'utiliser
|
||||
un environnement virtuel Python. Commencez alors par installer Python (si ce n'est pas
|
||||
déjà fait, au moins en version 3.10) ainsi que ``virtualenv``, puis vous pouvez créer
|
||||
l'environnement virtuel, et entrer dedans :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
Il vous faut ensuite installer les dépendances de la plateforme :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip install -r requirements.txt
|
||||
|
||||
Pour exécuter des tests, il est nécessaire d'installer ``tox`` en supplément.
|
||||
|
||||
Ensuite, vous devez initialiser la base de données locale (qui sera stockée dans un
|
||||
fichier SQLite ``db.sqlite3``) :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
python3 manage.py migrate
|
||||
|
||||
Enfin, vous pouvez lancer la plateforme :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
python3 manage.py runserver
|
||||
|
||||
Vous pouvez alors accéder à la plateforme à l'adresse `<http://localhost:8000/>`_.
|
||||
Elle se recharge automatiquement à chaque modification du code source, inutile de la
|
||||
relancer.
|
||||
|
||||
Pour arrêter la plateforme, il suffit d'appuyer sur ``Ctrl+C`` dans le terminal.
|
||||
|
||||
Pour vous créer un compte administrateur⋅rice, il suffit de lancer la commande suivante :
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
python3 manage.py createsuperuser
|
||||
|
||||
En cas de problème, vous pouvez librement supprimer la base de données locale et la
|
||||
recréer en réexécutant la commande ``python3 manage.py migrate``.
|
211
docs/dev/transition.rst
Normal file
@ -0,0 +1,211 @@
|
||||
Transition d'années
|
||||
===================
|
||||
|
||||
Entre deux sessions du TFJM², certaines opérations doivent être effectuées chaque année,
|
||||
afin de réinitialiser les données et de passer à l'année suivante.
|
||||
|
||||
Réinitialisation de la base de données
|
||||
--------------------------------------
|
||||
|
||||
Conservation des autorisations de droit à l'image
|
||||
"""""""""""""""""""""""""""""""""""""""""""""""""
|
||||
|
||||
La base de données du TFJM² est supprimée chaque année, avant chaque tournoi. Il n'y a
|
||||
pas de conservation de données personnelles à l'exception des autorisations de droit
|
||||
à l'image qui doivent être conservées pour des raisons légales pendant 5 ans.
|
||||
|
||||
Elles doivent alors être stockées sur Owncloud. Pour cela, il faut commencer par créer
|
||||
un dossier dans Owncloud, qui stockera lesdites autorisations.
|
||||
|
||||
Rendez-vous ensuite dans le conteneur Docker et exécuter le script :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
./manage.py export_photo_authorizations
|
||||
|
||||
Cela a pour effet de générer un dossier dans ``output/photo_authorizations``, qui contient
|
||||
un dossier par équipe avec les différentes autorisations de droit à l'image.
|
||||
|
||||
Il faut maintenant récupérer ce dossier. Sortir du conteneur, et exécuter dans ``/srv/TFJM`` :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
sudo docker cp tfjm-inscription-1:/code/output/photo_authorizations .
|
||||
sudo mv photo_authorizations/* "data/owncloud/data/Emmy/files/Autorisations de droit à l'image/Autorisations de droit à l'image 2024/"
|
||||
sudo chown -R www-data:root "data/owncloud/data/Emmy/files/Autorisations de droit à l'image/Autorisations de droit à l'image 2024"
|
||||
sudo rmdir photo_authorizations
|
||||
|
||||
Il faut enfin réactualiser Owncloud. Exécuter en tant que www-data :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
sudo docker compose exec -u www-data cloud php occ files:scan Emmy
|
||||
|
||||
Vérifiez enfin que les fichiers sont bien accessibles dans l'interface Web.
|
||||
Ne pas oublier enfin de partager le dossier.
|
||||
|
||||
|
||||
Sauvegarde de secours
|
||||
"""""""""""""""""""""
|
||||
|
||||
Si les données doivent être supprimées, il peut être utile de réaliser une sauvegarde à conserver
|
||||
quelques mois.
|
||||
|
||||
.. danger::
|
||||
|
||||
Cette sauvegarde ne doit être faite qu'à des fins utiles et supprimée dès que plus nécessaire.
|
||||
|
||||
Sauvegardez alors le dossier ``/srv/TFJM/data/inscription/media`` et exportez la base de données :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
sudo cp -r data/inscription/media data/inscription/media-2024
|
||||
sudo docker compose exec -u postgres postgres pg_dump inscription_tfjm | sudo tee inscription_tfjm_bkp_2024.sql > /dev/null
|
||||
|
||||
|
||||
Réinitialisation effective
|
||||
""""""""""""""""""""""""""
|
||||
|
||||
Il est désormais possible de réinitialiser la base de données, après avoir éteint le serveur :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
sudo docker compose stop inscription
|
||||
sudo rm -r data/inscription/media/*
|
||||
sudo docker compose exec -u postgres postgres dropdb inscription_tfjm
|
||||
sudo docker compose exec -u postgres postgres createdb -O inscription_tfjm inscription_tfjm
|
||||
|
||||
Redémarrez enfin le serveur (les migrations seront créées automatiquement)
|
||||
et créez un nouveau compte administrateur⋅rice :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
sudo docker compose up -d inscription
|
||||
sudo docker compose exec inscription bash
|
||||
./manage.py createsuperuser
|
||||
|
||||
Vérifiez finalement le bon fonctionnement du site.
|
||||
|
||||
|
||||
Sites Django
|
||||
""""""""""""
|
||||
|
||||
Après avoir réinitialisé les données, il faut mettre à jour le site Django, qui permettra
|
||||
d'avoir notamment des noms de domaine correct dans les mails envoyés.
|
||||
|
||||
Se connecter alors sur le site réouvert, puis dans la partie « Administration », chercher la
|
||||
section « Sites » et modifier l'unique site présent. Vous pouvez ensuite effectuer les modifications
|
||||
à réaliser.
|
||||
|
||||
|
||||
Nouveaux paramètres pour la nouvelle année
|
||||
------------------------------------------
|
||||
|
||||
Certains paramètres doivent être modifiés pour prendre en compte la nouvelle année.
|
||||
|
||||
Dates d'inscription
|
||||
"""""""""""""""""""
|
||||
|
||||
Les inscriptions sont permises uniquement entre l'ouverture et la fermeture, afin d'éviter
|
||||
d'avoir des personnes s'inscrivant en dehors du TFJM².
|
||||
|
||||
Pour cela, dans votre projet local, rendez-vous dans ``tfjm/settings.py`` et cherchez
|
||||
le paramètre ``REGISTRATION_DATES`` (pour le TFJM²). Modifiez alors les sous-paramètres
|
||||
``open`` et ``close`` pour définir les dates pendant lesquelles les inscriptions des
|
||||
participant⋅es sont permises pour cette nouvelle année. Elles doivent être au format ISO.
|
||||
|
||||
Exemple pour l'année 2025 où les inscriptions ouvrent au 8 janvier midi pour fermer
|
||||
le 2 mars à 22h :
|
||||
|
||||
.. code:: python
|
||||
|
||||
REGISTRATION_DATES = dict(
|
||||
open=datetime.fromisoformat("2025-01-15T12:00:00+0100"),
|
||||
close=datetime.fromisoformat("2025-03-02T22:00:00+0100"),
|
||||
)
|
||||
|
||||
Il faudra ensuite commiter la modification et redémarrer le serveur pour que la modification
|
||||
prenne effet.
|
||||
|
||||
|
||||
Noms des problèmes
|
||||
""""""""""""""""""
|
||||
|
||||
Toujours dans la configuration dans ``tfjm/settings.py``, la liste des problèmes doit être
|
||||
modifiée pour que leurs noms s'affichent correctement lors du tirage au sort.
|
||||
|
||||
Cherchez le paramètre ``PROBLEMS`` et mettez alors à jour la liste, dans l'ordre, des noms
|
||||
des problèmes.
|
||||
|
||||
À nouveau, il est nécessaire de commiter la modification et redémarrer le serveur.
|
||||
|
||||
|
||||
Paramètres des tournois
|
||||
"""""""""""""""""""""""
|
||||
|
||||
Il faut enfin paramétrer les différentes dates des tournois.
|
||||
|
||||
Pour cela, connectez-vous sur la plateforme (avec un compte administrateur⋅rice), et dans l'onglet
|
||||
« Tournois », vous pouvez créer les différents tournois avec les différentes dates pour chaque tournoi.
|
||||
Plus d'information sur les différents paramètres dans la `section concernée
|
||||
<../orga.html#creer-un-tournoi>`_
|
||||
|
||||
|
||||
À la fin du tournoi
|
||||
-------------------
|
||||
|
||||
Lorsque le tournoi est terminé, il faut récupérer les informations à stocker de façon pérenne,
|
||||
notamment les solutions des équipes, les résultats ainsi que les autorisation de droit à l'image
|
||||
comme indiqué précédemment.
|
||||
|
||||
Conservation des autorisations de droit à l'image
|
||||
"""""""""""""""""""""""""""""""""""""""""""""""""
|
||||
|
||||
Se référer à la section plus haut.
|
||||
|
||||
|
||||
Conservation des solutions des équipes
|
||||
""""""""""""""""""""""""""""""""""""""
|
||||
|
||||
Le processus est très similaire à la conservation des autorisations de droit à l'image.
|
||||
Il faut d'abord, dans le conteneur, lancer le script dédié pour récupérer les solutions
|
||||
dans ``/code/output/solutions`` :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
./manage.py export_solutions
|
||||
|
||||
On sort du conteneur et on récupère les solutions pour les déplacer dans Owncloud :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
sudo docker cp tfjm-inscription-1:/code/output/solutions .
|
||||
sudo mv solutions/* "data/owncloud/data/Emmy/files/Solutions écrites 2024/"
|
||||
sudo chown -R www-data:root "data/owncloud/data/Emmy/files/Solutions écrites 2024"
|
||||
sudo rmdir solutions
|
||||
|
||||
Il faut enfin réactualiser Owncloud. Exécuter en tant que www-data :
|
||||
|
||||
.. code:: bash
|
||||
|
||||
sudo docker compose exec -u www-data cloud php occ files:scan Emmy
|
||||
|
||||
Vérifiez enfin que les fichiers sont bien accessibles dans l'interface Web.
|
||||
Ne pas oublier enfin de partager le dossier.
|
||||
|
||||
|
||||
Génération de la page de résultats Wordpress
|
||||
""""""""""""""""""""""""""""""""""""""""""""
|
||||
|
||||
Pour finir, il est possible de récupérer les notes pour chaque tournoi afin de générer
|
||||
la page Wordpress dans la section *Éditions précédentes*.
|
||||
|
||||
Il suffit de lancer le script ``./manage.py export_results``, qui donne le texte brut pour
|
||||
Wordpress à ajouter sur la page de l'édition qui vient de se terminer dans l'onglet
|
||||
*Éditions précédentes*.
|
||||
|
||||
Pensez à bien inclure sur cette page le lien vers les problèmes de l'année, ainsi que le
|
||||
lien vers le dossier partagé dans le Owncloud concernant les solutions des équipes.
|
||||
|
||||
Assurez-vous de mettre à jour la page *Éditions précédentes* afin d'inclure le lien vers
|
||||
la page nouvellement créée.
|
645
docs/draw.rst
Normal file
@ -0,0 +1,645 @@
|
||||
Tirage au sort
|
||||
==============
|
||||
|
||||
La phase de tirage au sort est celle qui va déterminer d'une part
|
||||
dans quelle poule se trouve chaque équipe et dans quelle ordre elles
|
||||
défendront leurs problèmes, et d'autre part quel problème défendra
|
||||
quelle équipe. Cette phase a lieu dans la semaine qui précède le
|
||||
tournoi, typiquement le mardi entre 19h et 21h.
|
||||
|
||||
Exception pour la finale nationale : seul le premier tour est tiré
|
||||
au sort à ce moment-là, le tirage au sort pour le second tour est
|
||||
réalisé immédiatement après le premier tour et dépend des résultats.
|
||||
Sinon, pour les finales régionales, les tirages au sort pour les
|
||||
tours 1 et 2 sont réalisés successivement, bien que les solutions
|
||||
à opposer et à rapporter pour le second tour ne sont pas accessibles
|
||||
avant la fin du premier tour.
|
||||
|
||||
**Disclaimer :** si cette documentation est normalement tenue à jour,
|
||||
seul le règlement et ses annexes font foi. Elle est essentiellement ici
|
||||
pour décrire le fonctionnement de la plateforme vis-à-vis du tirage
|
||||
au sort. En cas de litige, merci de se fier au règlement, et de
|
||||
s'y référer `sur le site du TFJM² <https://tfjm.org/reglement/>`_.
|
||||
|
||||
|
||||
Principe
|
||||
--------
|
||||
|
||||
.. warning:: Cette section arborde en détail le fonctionnement théorique
|
||||
du tirage au sort. Si vous souhaitez comprendre comment se déroule
|
||||
le tirage au sort en pratique sur la plateforme, merci de vous référer
|
||||
directement à la section `Déroulement du tirage`_.
|
||||
|
||||
Composition des poules
|
||||
~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Le principe du tirage au sort est détaillé dans la fiche pratique dédiée.
|
||||
|
||||
Chaque équipe commence par désigner un⋅e capitaine d'équipe qui s'occupera
|
||||
de réaliser les tirages et de prendre les décisions.
|
||||
|
||||
Les poules sont triées de la plus grande à la plus petite, en terme de
|
||||
nombre d'équipes.
|
||||
|
||||
Les capitaines d'équipe lancent un dé entre 1 et 100. S'il y a des égalités,
|
||||
alors les équipes concernées relancent leur dé jusqu'à ne plus avoir d'égalité.
|
||||
Ce score détermine les compositions des poules pour les deux tours (sauf pour
|
||||
la finale nationale ou ce n'est que le premier tour).
|
||||
|
||||
On trie ensuite les équipes par ordre croissant de score de dé. On remplit
|
||||
ensuite les poules une à une de façon gloutonne : si par exemple la
|
||||
première poule est une poule à 3 équipes, alors on prend les 3 premières
|
||||
équipes de la liste et on les place dans cette poule. On recommence ensuite
|
||||
avec la deuxième poule, etc.
|
||||
|
||||
Au sein d'une poule, l'ordre de passage des équipes est déterminé par
|
||||
l'ordre des restes modulo 100 des scores de dé multipliés par 27.
|
||||
Par exemple, si les scores de dé sont 12, 34 et 56, alors si on
|
||||
multiplie par 27 et qu'on prend le reste modulo 100, on obtient
|
||||
respectivement 24, 82 et 52. L'ordre de passage sera donc l'équipe 1,
|
||||
l'équipe 3, puis l'équipe 2. Ce choix est réalisé pour avoir un semblant
|
||||
déterministe de mélange.
|
||||
|
||||
Pour le second tour, on considère à nouveau les scores de dé, où l'équipe
|
||||
qui a eu le score le plus faible sera dans la première poule, celle qui
|
||||
a eu le second score le plus faible dans la deuxième poule, etc. L'ordre
|
||||
de passage est cette fois-ci plus simple, puisqu'il est directement croissant
|
||||
avec les scores de dé. Exception : pour les poules à 5, l'équipe avec le
|
||||
score le plus gros sera la dernière de la première poule, et il ne peut
|
||||
y avoir qu'une seule poule à 5 équipes.
|
||||
|
||||
Considérons par exemple un tournoi fictif composé de 11 équipes, avec
|
||||
une poule de 5 équipes et deux poules de 3 équipes. Les équipes sont
|
||||
AAA, BBB, CCC, DDD, EEE, FFF, GGG, HHH, III, JJJ et KKK. Les scores
|
||||
de dés sont :
|
||||
|
||||
.. table:: Exemple de tirage de dés et de répartition dans les poules
|
||||
|
||||
+-----------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
| Équipe | AAA | BBB | CCC | DDD | EEE | FFF | GGG | HHH | III | JJJ | KKK |
|
||||
+=============================+=====+=====+=====+=====+=====+=====+=====+=====+=====+=====+=====+
|
||||
| Score de dé | 45 | 17 | 64 | 3 | 98 | 41 | 34 | 63 | 86 | 23 | 70 |
|
||||
+-----------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
| Poule au 1\ :sup:`er` tour | B | A | B | A | C | A | A | B | C | A | C |
|
||||
+-----------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
| Poule au 2\ :sup:`ème` tour | C | B | B | A | A | B | A | A | A | C | C |
|
||||
+-----------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
|
||||
Explication : les équipes **DDD**, **BBB**, **JJJ**, **GGG** et **FFF** sont les
|
||||
équipes ayant réalisé le plus bas score (respectivement 3, 17, 23, 34 et, 41) et
|
||||
sont alors placées dans la poule **A**. Les équipes **AAA**, **HHH** et **CCC**
|
||||
sont les trois suivantes (avec pour scores 45, 63 et 64) et composeront alors
|
||||
la poule **B** tandis que les équipes **KKK**, **III** et **EEE** (avec pour
|
||||
scores 70, 86 et 98) seront dans la poule **C** au premier tour.
|
||||
|
||||
Ainsi pour le second tour, l'équipe **DDD** ira dans la poule **A**, **BBB**
|
||||
dans la poule **B**, **JJJ** dans la poule **C**, **GGG** dans la poule **A**,
|
||||
**FFF** dans la poule **B**, **AAA** dans la poule **C**, **HHH** dans la poule
|
||||
**A**, **CCC** dans la poule **B**, **KKK** dans la poule **C**, **III** dans
|
||||
la poule **A** et enfin **EEE** également dans la poule **A** puisqu'elle y est
|
||||
forcée.
|
||||
|
||||
Pour ce qui est de l'ordre de passage :
|
||||
|
||||
.. table:: Exemple d'ordre de passage pour le premier tour
|
||||
|
||||
+--------------------------------+-----------------------------+-----------------+-----------------+
|
||||
| Poule | Poule A | Poule B | Poule C |
|
||||
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
| Équipe | DDD | BBB | JJJ | GGG | FFF | AAA | HHH | CCC | KKK | III | EEE |
|
||||
+================================+=====+=====+=====+=====+=====+=====+=====+=====+=====+=====+=====+
|
||||
| Score de dé | 3 | 17 | 23 | 34 | 41 | 45 | 63 | 64 | 70 | 86 | 98 |
|
||||
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
| Score de dé fois 27 modulo 100 | 81 | 59 | 21 | 18 | 7 | 15 | 1 | 28 | 90 | 22 | 46 |
|
||||
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
| Ordre de passage dans la poule | 5 | 4 | 3 | 2 | 1 | 2 | 1 | 3 | 3 | 1 | 2 |
|
||||
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
|
||||
.. table:: Exemple d'ordre de passage pour le second tour
|
||||
|
||||
+--------------------------------+-----------------------------+-----------------+-----------------+
|
||||
| Poule | Poule A | Poule B | Poule C |
|
||||
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
| Équipe | DDD | GGG | HHH | III | EEE | BBB | FFF | CCC | JJJ | AAA | KKK |
|
||||
+================================+=====+=====+=====+=====+=====+=====+=====+=====+=====+=====+=====+
|
||||
| Score de dé | 3 | 34 | 63 | 86 | 98 | 17 | 41 | 64 | 23 | 45 | 70 |
|
||||
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
| Ordre de passage dans la poule | 1 | 2 | 3 | 4 | 5 | 1 | 2 | 3 | 1 | 2 | 3 |
|
||||
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
|
||||
|
||||
|
||||
Pour la finale nationale, les ordres de passage et les répartitions dans les poules
|
||||
du second tour sont décidé⋅es dans l'ordre décroissant des résultats obtenus au
|
||||
premier tour. Si par exemple l'équipe **AAA** a fini en tête du premier tour, elle
|
||||
sera alors la première à passer dans la poule **A** du second tour.
|
||||
|
||||
|
||||
Tirage des problèmes
|
||||
~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Une fois les équipes réparties dans les poules, on tire au sort les problèmes. Ce
|
||||
tirage se déroule poule par poule.
|
||||
|
||||
Au début du tirage au sort des problèmes d'une poule, les équipes de la poule
|
||||
commencent par tirer un nouveau dé à 100 faces. S'il y a des égalités, alors
|
||||
les équipes concernées relancent leur dé jusqu'à ne plus avoir d'égalité.
|
||||
Les équipes sont triées dans l'ordre décroissant de leur score de dé. L'équipe
|
||||
ayant tiré le plus gros score est invitée à tirer son problème en première.
|
||||
|
||||
Il y a deux contraintes de tirage :
|
||||
|
||||
* Il n'est pas possible de choisir le même problème qu'une autre équipe de la poule ;
|
||||
* Il n'est pas possible de défendre le même problème pour les deux jours.
|
||||
|
||||
À noter qu'il n'est pas impossible de tirer et de choisir un problème qui n'a
|
||||
pas été traité par l'équipe, bien que cela est fortement non recommandé.
|
||||
|
||||
Une fois l'ordre de tirage établi, les équipes tirent tour à tour leur problème,
|
||||
tant qu'elle ne l'ont pas encore choisi. Pour cela, l'équipe active tire au sort
|
||||
un problème. Il est attendu que le problème tiré garantisse les deux contraintes,
|
||||
et que donc il n'est pas possible de tirer un problème déjà choisi par une autre
|
||||
équipe, ou bien le problème défendu lors du tour 1 si on est au tour 2.
|
||||
|
||||
L'équipe a désormais deux choix :
|
||||
|
||||
* Accepter le problème. Dans ce cas, ce sera ce problème qu'elle défendra, et
|
||||
son tirage est termé.
|
||||
* Refuser le problème. Dans ce cas, la main passe à l'équipe suivante, selon le
|
||||
score des dés. Exception : si le problème tiré est un problème qui a déjà été
|
||||
refusé auparavant, alors dans ce cas l'équipe peut immédiatement tirer un
|
||||
nouveau problème (mais elle a tout de même le choix de l'accepter ou de le
|
||||
refuser).
|
||||
|
||||
**Attention :** si une équipe refuse trop de problèmes, alors elle pourra être
|
||||
pénalisée. Chaque équipe a droit à ``P - 5`` refus sans pénalités, où ``P``
|
||||
est le nombre de problèmes disponibles cette année. Par exemple, s'il y a
|
||||
8 problèmes cette année, alors les équipes ont droit à 3 refus sans pénalités.
|
||||
Seuls les refus distincts comptent : refuser une deuxième fois un problème
|
||||
déjà refusé ne compte pas. Au-delà de ces refus gratuits, l'équipe se verra
|
||||
dotée d'une pénalité de 25 % sur le coefficient de l'oral de défense, par
|
||||
refus. Par exemple, si une équipe refuse 4 problèmes avec un coefficient
|
||||
sur l'oral de défense normalement à ``1.6``, son coefficient passera à ``1.2``.
|
||||
|
||||
Une fois que toutes les équipes de la poule ont tiré leur problème, on passe
|
||||
à la poule suivante. Une fois que toutes les poules ont vu leurs problèmes
|
||||
tirés, on recommence pour le second tour, à partir de la première poule (déjà
|
||||
définie), sauf pour la finale nationale. Le tirage au sort est terminé lorsque
|
||||
toutes les poules ont vu leurs problèmes tirés pour tous les tours.
|
||||
|
||||
|
||||
Récupération des solutions
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Les solutions défendues pour le premier tour dans une poule sont immédiatement
|
||||
accessibles après le tirage au sort aux équipes qui doivent opposer ou rapporter
|
||||
ces solutions. Pour le second tour, elles ne seront disponibles qu'après la fin
|
||||
du premier tour.
|
||||
|
||||
Elles seront également envoyées par les organisateurices localaux, dans les mêmes
|
||||
délais.
|
||||
|
||||
|
||||
Tirage au sort sur la plateforme
|
||||
--------------------------------
|
||||
|
||||
Le tirage est sort est géré directement sur la plateforme, de façon intuitive,
|
||||
ergonomique et dynamique.
|
||||
|
||||
Il est accessible à l'adresse `<https://inscription.tfjm.org/draw/>`_, ou bien
|
||||
en accédant à l'onglet « Tirage au sort » sur le bandeau de navigation, pourvu
|
||||
d'être organisateurice ou bien d'être dans une équipe validée.
|
||||
|
||||
|
||||
Présentation de l'interface
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
L'interface est divisée en 5 sections :
|
||||
|
||||
* Le nom du tournoi dans lequel se passe le tirage au sort ;
|
||||
* Les derniers résultats de dés par équipe ;
|
||||
* Le récapitulatif du tirage au sort en cours ;
|
||||
* L'étape actuelle ;
|
||||
* Les tableaux de passage par poule.
|
||||
|
||||
.. figure:: _static/img/draw_general.png
|
||||
:alt: Interface générale du tirage au sort
|
||||
|
||||
Interface générale du tirage au sort
|
||||
|
||||
Sur cette capture d'écran, on voit par exemple que l'on est actuellement dans le
|
||||
tournoi de Strasbourg. Le tirage du tour 1 est déjà terminé, et on est en train
|
||||
de tirer la poule B2.
|
||||
|
||||
Nom du tournoi
|
||||
..............
|
||||
|
||||
Les différents onglets représentent les différents tournois.
|
||||
|
||||
Pour une équipe participante ou un⋅e organisateurice local⋅e, il peut n'y avoir
|
||||
qu'un seul tournoi, celui qui concerne l'utilisateurice.
|
||||
|
||||
Les administrateurices ont accès à tous les onglets, ce qui peut permettre de
|
||||
passer d'un tirage au sort à un autre facilement, en un clic et sans délai.
|
||||
|
||||
.. figure:: _static/img/draw_tournament_tabs.png
|
||||
:alt: Onglets des différents tirages au sort par tournoi
|
||||
|
||||
Le tournoi de Strasbourg est le tournoi sélectionné dans cet exemple.
|
||||
|
||||
|
||||
Derniers résultats de dés
|
||||
.........................
|
||||
|
||||
Les derniers jets de dés sont affichés dans cette section, avec le trigramme de
|
||||
chaque équipe et son score de dé s'il existe. Le score est vert si l'équipe a
|
||||
déjà tiré son dé, sinon il est jaune.
|
||||
|
||||
Les organisateurices peuvent cliquer sur le score de dé pour jeter un dé à la place
|
||||
de l'équipe, ce qui est notamment utile à des fins de débuggage ou de souci technique
|
||||
avec l'équipe en question.
|
||||
|
||||
.. figure:: _static/img/draw_last_rolls.png
|
||||
:alt: Derniers jets de dés
|
||||
|
||||
Ici, les équipes BBB, DDD, EEE, GGG et HHH ont déjà tiré leur dé, avec pour scores
|
||||
respectifs 57, 66, 58, 41 et 68, tandis que les équipes AAA, CCC, FFF et III n'ont
|
||||
pas encore tiré leur dé.
|
||||
|
||||
Récapitulatif du tirage au sort
|
||||
...............................
|
||||
|
||||
La section de gauche affiche le récapitulatif du tirage au sort en cours.
|
||||
Elle n'est là qu'à des fins d'affichage, il n'est pas possible d'interagir avec.
|
||||
|
||||
La colonne de gauche concerne le premier tour, et la colonne de droite le second tour.
|
||||
Chaque colonne est divisée en plusieurs parties, une pour chaque poule. Enfin, chaque
|
||||
poule contient la liste des équipes membres, triées par ordre de passage. Elles ne sont
|
||||
affichées que lorsque l'ordre de passage est déterminé.
|
||||
|
||||
Le tour actuel de tirage est mis en surbrillance. La poule actuelle qui réalise son
|
||||
tirage est sur fond vert, l'équipe qui doit tirer son problème est sur fond bleu.
|
||||
|
||||
La cellule d'une équipe affiche son trigramme, son problème sélectionné pour ce tour
|
||||
(s'il est déjà choisi), l'ensemble des problèmes qu'elle a refusé, et le cas échéant
|
||||
ses pénalités.
|
||||
|
||||
.. figure:: _static/img/draw_recap.png
|
||||
:alt: Récapitulatif du tirage au sort
|
||||
|
||||
Récapitulatif du tirage au sort en cours
|
||||
|
||||
La poule A1 est composée des équipes **GGG**, **AAA** et **III**, qui défendront
|
||||
dans cet ordre. L'équipe **GGG** a tiré le problème 7, et l'a accepté. L'équipe
|
||||
**AAA** a tiré le problème 3, et l'a accepté, après avoir refusé le problème 2.
|
||||
L'équipe **III** a tiré le problème 1, et l'a accepté, après avoir refusé les
|
||||
problèmes 3, 4, 2 et 6, et a donc une pénalité de 25 % sur son coefficient de
|
||||
l'oral de défense. Notez qu'en poule B1, l'équipe **BBB** a bien pu accepter le
|
||||
problème 8 après l'avoir refusé une première fois.
|
||||
|
||||
Dans la poule B2, actuellement en tirage, l'équipe **EEE** a déjà accepté le
|
||||
problème 6, tandis que l'équipe **CCC** a refusé les problèmes 5 et 2 et que
|
||||
l'équipe **AAA** a refusé le problème 2. C'est actuellement au tour de
|
||||
l'équipe **AAA**, qui doit choisir d'accepter ou de refuser le problème 7
|
||||
qu'elle vient de tirer.
|
||||
|
||||
Notez la présence du bouton « Annuler la dernière étape », visible uniquement
|
||||
pour les organisateurices. Il permet d'annuler la dernière action qui vient
|
||||
d'avoir lieu. Il est utile en cas de souci technique ou de mauvaise manipulation.
|
||||
|
||||
En cas de besoin majeur, un bouton « Annuler » est disponible en bas de l'interface
|
||||
afin de réinitialiser le tirage.
|
||||
|
||||
|
||||
Étape actuelle
|
||||
..............
|
||||
|
||||
La partie de droite de l'écran est dédiée à l'explication de l'étape en cours du tirage
|
||||
au sort. Tout est expliqué dans un encadré bleu.
|
||||
|
||||
À noter qu'un bouton « Exporter » peut être disponible pour les organisateurices à la
|
||||
suite du tirage d'une poule. Il est utile pour valider le tirage et transmettre les
|
||||
données au reste de la plateforme, débloquant notamment l'accès aux différentes solutions
|
||||
pour les équipes.
|
||||
|
||||
Le tirage au sort est découpé en 4 phases majeures :
|
||||
|
||||
Composition des poules
|
||||
''''''''''''''''''''''
|
||||
|
||||
La première phase est la composition des poules. Elle est détaillée dans la section
|
||||
« Composition des poules ». Le texte affiché :
|
||||
|
||||
.. note::
|
||||
Nous allons commencer le tirage des problèmes.
|
||||
Vous pouvez à tout moment poser toute question si quelque chose n'est pas clair ou ne va pas.
|
||||
|
||||
Nous allons d'abord tirer les poules et l'ordre de passage pour le premier tour avec toutes les équipes puis pour chaque poule, nous tirerons l'ordre de tirage pour le tour et les problèmes.
|
||||
|
||||
Les capitaines, vous pouvez désormais toustes lancer un dé 100, en cliquant sur le gros bouton. Les poules et l'ordre de passage lors du premier tour sera l'ordre croissant des dés, c'est-à-dire que le plus petit lancer sera le premier à passer dans la poule A.
|
||||
|
||||
Pour plus de détails sur le déroulement du tirage au sort, le règlement est accessible sur https://tfjm.org/reglement.
|
||||
|
||||
Un gros émoji « dé » 🎲 est affiché pour les participant⋅es, leur permettant de lancer
|
||||
leur dé. Le résultat sera affiché dans la section « Derniers jets de dés », et le
|
||||
bouton disparaîtra.
|
||||
|
||||
Les organisateurices peuvent également appuyer sur le bouton, ce qui aura pour effet
|
||||
de lancer un dé pour une équipe qui n'a pas encore lancé son dé. Cela peut être utile
|
||||
pour débugger ou pour aider une équipe qui a un souci technique. Rappelons toutefois
|
||||
qu'il suffit de cliquer sur le score de dé d'une équipe pour lancer son dé à sa place.
|
||||
|
||||
.. figure:: _static/img/draw_waiting_passage_order.png
|
||||
:alt: Étape de composition des poules
|
||||
|
||||
Le tirage au sort est en attente de la composition des poules.
|
||||
|
||||
|
||||
Ordre de tirage des problèmes
|
||||
'''''''''''''''''''''''''''''
|
||||
|
||||
Une fois les poules constituées, il faut déterminer dans quel ordre les équipes
|
||||
tireront leur problème, par le biais d'un nouveau jet de dé. Un texte par exemple
|
||||
peut être :
|
||||
|
||||
.. note::
|
||||
Nous passons au tirage des problèmes pour la poule Poule A1, entre les équipes GGG, AAA, III. Les capitaines peuvent lancer un dé 100 en cliquant sur le gros bouton pour déterminer l'ordre de tirage. L'équipe réalisant le plus gros score pourra tirer en premier.
|
||||
|
||||
Pour plus de détails sur le déroulement du tirage au sort, le règlement est accessible sur https://tfjm.org/reglement.
|
||||
|
||||
L'émoji « dé » 🎲 n'est affiché que pour les équipes membre de la poule concernée.
|
||||
Encore une fois, les organisateurices peuvent lancer le dé à la place d'une équipe
|
||||
en cliquant sur son score de dé, ou bien en cliquant sur l'émoji.
|
||||
|
||||
.. figure:: _static/img/draw_waiting_choose_problem_order.png
|
||||
:alt: Étape de tirage de l'ordre de tirage des problèmes
|
||||
|
||||
Le tirage au sort est en attente du tirage l'ordre de tirage pour la poule A1.
|
||||
|
||||
|
||||
Tirage des problèmes
|
||||
''''''''''''''''''''
|
||||
|
||||
Une fois l'ordre de tirage déterminé, les équipes sont invitées à tirer leurs
|
||||
problèmes. Le texte affiché est par exemple :
|
||||
|
||||
.. note::
|
||||
C'est au tour de l'équipe GGG de choisir son problème. Cliquez sur l'urne au milieu pour tirer un problème au sort.
|
||||
|
||||
Pour plus de détails sur le déroulement du tirage au sort, le règlement est accessible sur https://tfjm.org/reglement.
|
||||
|
||||
Un émoji « urne » 🗳️ est visible uniquement pour l'équipe active, qui doit tirer
|
||||
son problème. Elle est invitée à cliquer dessus. À nouveau, les organisateurices
|
||||
peuvent cliquer dessus à la place de l'équipe.
|
||||
|
||||
.. figure:: _static/img/draw_waiting_problem_draw.png
|
||||
:alt: Étape de tirage des problèmes
|
||||
|
||||
Le tirage au sort est en attente du tirage du problème de l'équipe GGG.
|
||||
|
||||
|
||||
Choix du problème
|
||||
'''''''''''''''''
|
||||
|
||||
Une fois le problème tiré, l'équipe peut alors choisir de l'accepter ou de le
|
||||
refuser. Le texte affiché est par exemple :
|
||||
|
||||
.. note::
|
||||
L'équipe GGG a tiré le problème 7 : Drôles de cookies. Elle peut décider d'accepter ou de refuser ce problème. Il reste 3 refus sans pénalité.
|
||||
|
||||
Pour plus de détails sur le déroulement du tirage au sort, le règlement est accessible sur https://tfjm.org/reglement.
|
||||
|
||||
Deux boutons sont affichés sur la page, un bouton « Accepter », en vert,
|
||||
et un bouton « Refuser », en rouge. L'équipe peut cliquer sur l'un ou l'autre
|
||||
pour faire son choix. Les organisateurices peuvent également cliquer sur l'un
|
||||
ou l'autre pour faire le choix à la place de l'équipe. Ces boutons sont
|
||||
invisibles pour les autres équipes.
|
||||
|
||||
.. figure:: _static/img/draw_choose_problem.png
|
||||
:alt: Étape de choix du problème
|
||||
|
||||
L'équipe GGG a tiré le problème 7. Elle peut choisir de l'accepter ou de le refuser.
|
||||
|
||||
.. TODO
|
||||
.. note::
|
||||
Cette section sera mise à jour plus tard.
|
||||
|
||||
|
||||
Tableaux de passage par poule
|
||||
.............................
|
||||
|
||||
Ces tableaux, actualisés en temps réel, permettent d'afficher quels seront les
|
||||
différents passages lors d'une poule. En particulier, quelle équipe défendra
|
||||
quel problème, et quelle équipe opposera et laquelle rapportera.
|
||||
|
||||
.. figure:: _static/img/draw_passage_tables.png
|
||||
:alt: Tableaux de passage par poule
|
||||
|
||||
Tableaux de passage par poule
|
||||
|
||||
|
||||
Déroulement du tirage
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Cette section décrit le déroulement du tirage au sort sur la plateforme, sans
|
||||
rentrer dans les détails théoriques. Si la partie théorique vous intéresse,
|
||||
rendez-vous à la section `Principe`_.
|
||||
|
||||
Démarrage du tirage au sort
|
||||
...........................
|
||||
|
||||
Si le tirage n'a pas encore commencé, un message d'alerte s'affiche.
|
||||
|
||||
Les organisateurices peuvent lancer le tirage au sort en cliquant sur le bouton
|
||||
« Démarrer ! ». Iels doivent d'abord paramétrer le format du tirage, en indiquant
|
||||
le nombre d'équipe par poule, en séparant les nombres par des plus. Par exemple,
|
||||
pour un tournoi de 9 équipes avec 3 poules de 3 équipes, on écrira ``3+3+3``.
|
||||
|
||||
.. figure:: _static/img/draw_start.png
|
||||
:alt: Création du tirage au sort
|
||||
|
||||
Le formulaire de lancement du tirage au sort. Ici, on souhaite un tirage
|
||||
au sort avec 3 poules de 3 équipes, pour le tournoi de Strasbourg.
|
||||
|
||||
Attention : si toutes les poules n'ont pas la même capacité, il est essentiel de
|
||||
mettre les poules dans l'ordre décroissant de capacité. Par exemple, pour un tournoi
|
||||
de 8 équipes avec une poule de 5 équipes et une poules de 3 équipes, il faut
|
||||
écrire ``5+3``. De plus, il n'est pas possible d'avoir 2 poules de 5 équipes.
|
||||
|
||||
|
||||
Composition des poules
|
||||
......................
|
||||
|
||||
Les capitaines d'équipe sont invités à lancer un dé à 100 faces. Pour cela, ils
|
||||
peuvent cliquer sur le gros bouton avec l'émoji « dé » 🎲 sur la droite de l'écran.
|
||||
Le résultat est affiché dans la section « Derniers jets de dés ».
|
||||
|
||||
.. figure:: _static/img/draw_waiting_passage_order_full.png
|
||||
:alt: Étape de composition des poules
|
||||
|
||||
Le tirage au sort est en attente de la composition des poules.
|
||||
Les équipes **AAA**, **CCC** et **EEE** ont déjà lancé leur dé, avec pour
|
||||
scores 66, 34 et 97.
|
||||
|
||||
Une fois le bouton cliqué, il disparaît.
|
||||
|
||||
Les organisateurices peuvent également cliquer sur le score de dé d'une équipe
|
||||
pour lancer le dé à sa place. Cela peut être utile pour débugger ou pour aider
|
||||
une équipe qui a un souci technique. Il est possible de cliquer sur le dé, ce
|
||||
qui a pour effet de lancer le dé pour l'équipe qui n'a pas encore tiré son dé.
|
||||
|
||||
Les poules sont constituées dès que toutes les équipes ont tiré leur dé, selon
|
||||
le principe énoncé dans la partie `Principe`_. S'il y a des égalités, alors les
|
||||
équipes concernées relancent leur dé jusqu'à ne plus avoir d'égalité.
|
||||
|
||||
|
||||
Ordre de tirage des problèmes
|
||||
.............................
|
||||
|
||||
Une fois les poules constituées, il faut déterminer dans quel ordre les équipes
|
||||
tireront leur problème, par le biais d'un nouveau jet de dé. Les équipes sont
|
||||
invitées à lancer un dé à 100 faces. Pour cela, elles peuvent cliquer sur le
|
||||
gros bouton avec l'émoji « dé » 🎲 sur la droite de l'écran. Le résultat est
|
||||
affiché dans la section « Derniers jets de dés ».
|
||||
|
||||
.. figure:: _static/img/draw_waiting_choose_problem_order_full.png
|
||||
:alt: Étape de tirage de l'ordre de tirage des problèmes
|
||||
|
||||
Le tirage au sort est en attente du tirage l'ordre de tirage pour la poule A1.
|
||||
L'équipe **BBB** a déjà tiré son dé, avec pour score 56. Les équipes **CCC**
|
||||
et **DDD** sont alors attendues.
|
||||
|
||||
Une fois le bouton cliqué, il disparaît.
|
||||
|
||||
À nouveau, les organisateurices peuvent cliquer sur le score de dé d'une équipe
|
||||
pour lancer le dé à sa place. Cela peut être utile pour débugger ou pour aider
|
||||
une équipe qui a un souci technique. Il est possible de cliquer sur le dé, ce
|
||||
qui a pour effet de lancer le dé pour l'équipe qui n'a pas encore tiré son dé.
|
||||
|
||||
Si deux équipes réalisent le même score, alors les équipes concernées relancent
|
||||
leur dé jusqu'à ne plus avoir d'égalité.
|
||||
|
||||
|
||||
Tirage des problèmes
|
||||
....................
|
||||
|
||||
Les équipes membre de la poule active sont invitées à tirer leur problème, dans
|
||||
l'ordre déterminé par les dés précédents.
|
||||
|
||||
L'équipe active est invitée à cliquer sur l'urne 🗳️ sur la droite de l'écran pour
|
||||
tirer un problème au sort.
|
||||
|
||||
.. figure:: _static/img/draw_waiting_problem_draw_full.png
|
||||
:alt: Étape de tirage des problèmes
|
||||
|
||||
L'équipe **DDD** est la première à tirer son problème, puisqu'elle a réalisé
|
||||
le plus gros score. Elle est mise en surbrillance.
|
||||
|
||||
Les organisateurices peuvent également cliquer sur l'urne pour lancer le dé à la
|
||||
place de l'équipe. Cela peut être utile pour débugger ou pour aider une équipe qui
|
||||
a un souci technique.
|
||||
|
||||
Le problème tiré ne peut pas être le même que celui d'une autre équipe de la poule,
|
||||
ni le même que celui défendu par l'équipe lors du premier tour si on est au second
|
||||
tour. Il peut en revanche être un problème déjà refusé auparavant.
|
||||
|
||||
|
||||
Choix du problème
|
||||
.................
|
||||
|
||||
Une fois le problème tiré, l'équipe peut alors choisir de l'accepter ou de le
|
||||
refuser. Elle est invitée à cliquer sur le bouton « Accepter » ou « Refuser »
|
||||
pour faire son choix.
|
||||
|
||||
.. figure:: _static/img/draw_choose_problem_full.png
|
||||
:alt: Étape de choix du problème
|
||||
|
||||
L'équipe **DDD** a tiré le problème 3. Elle peut choisir de l'accepter ou de le refuser.
|
||||
|
||||
Si elle accepte le problème, alors elle a terminé, et la main passe à l'équipe
|
||||
suivante. Ce sera ce problème qu'elle défendra.
|
||||
|
||||
Si elle refuse le problème, alors :
|
||||
|
||||
* Soit le problème n'avait pas encore été refusé. Dans ce cas, la main passe à
|
||||
l'équipe suivante, selon l'ordre de tirage des problèmes. Si le nombre de refus
|
||||
dépasse ``P - 3`` où ``P`` est le nombre de problèmes, alors l'équipe se verra
|
||||
dotée d'une pénalité de 25 % sur son coefficient de l'oral de défense. Ces
|
||||
pénalités sont cumulables.
|
||||
* Soit le problème avait déjà été refusé. Dans ce cas, l'équipe peut revenir sur
|
||||
sa décision et accepter le problème, ou bien le rejeter gratuitement et tirer
|
||||
immédiatement un nouveau problème. Cela ne compte pas comme un refus
|
||||
supplémentaire et ne peut ajouter de pénalité.
|
||||
|
||||
Les organisateurices peuvent à nouveau cliquer sur les boutons à la place de
|
||||
l'équipe. Cela peut être utile pour débugger ou pour aider une équipe qui a un
|
||||
souci technique.
|
||||
|
||||
.. figure:: _static/img/draw_example.png
|
||||
:alt: Exemple de tirage de la poule A1
|
||||
|
||||
Dans cet exemple, l'équipe **DDD** a tiré le problème 3, et l'a accepté.
|
||||
L'équipe **BBB** a d'abord refusé le problème 6, et a accepté plus tard
|
||||
le problème 5. L'équipe **CCC**, à qui c'est le tour, a refusé les problèmes
|
||||
8, 4, 2 et 6. Puisqu'il y a 8 problèmes, elle a une pénalité de 25 % sur son
|
||||
coefficient de l'oral de défense. Elle vient de tirer le problème 2, qu'elle
|
||||
avait déjà refusé. Elle est donc libre de le refuser à nouveau sans pénalité
|
||||
supplémentaire, ou bien de l'accepter, comme indiqué dans l'encadré à droite.
|
||||
|
||||
Lorsque toutes les équipes de la poule ont accepté leur problème, on passe à la
|
||||
poule suivante, en revenant à l'étape de tirage au sort de l'ordre des problèmes.
|
||||
Lorsqu'un tour est terminé, on passe au tour suivant, à la première poule.
|
||||
|
||||
Exception : pour la finale, on ne tire au sort que le premier tour.
|
||||
|
||||
.. figure:: _static/img/draw_end_round_1.png
|
||||
:alt: Fin du tirage au sort du premier tour
|
||||
|
||||
Toutes les équipes ont tiré leur problème pour le premier tour. On passe
|
||||
au second tour, à la poule A2.
|
||||
|
||||
|
||||
Fin du tirage au sort
|
||||
.....................
|
||||
|
||||
Le tirage se termine lorsque toutes les équipes se sont vues attribuer un problème
|
||||
pour chacun des tours.
|
||||
|
||||
Les organisateurices peuvent alors cliquer sur le bouton « Exporter » pour valider
|
||||
le tirage et transmettre les données au reste de la plateforme, débloquant notamment
|
||||
l'accès aux différentes solutions pour les équipes. Attention : cette opération n'est
|
||||
pas réversible facilement.
|
||||
|
||||
Spécificité de la finale
|
||||
........................
|
||||
|
||||
Pour la finale, le tirage au sort est légèrement différent. Seul le premier tour
|
||||
est tiré au sort initialement. Pour le second tour, les poules et ordres de passage
|
||||
sont déterminés selon le classement du premier tour.
|
||||
|
||||
Avant de reprendre le tirage au sort du second tour, il est essentiel que les notes
|
||||
du premier tour soient rentrées correctement. En effet, ce sont ces scores qui
|
||||
détermineront les poules et l'ordre de passage pour le second tour.
|
||||
|
||||
Pour lancer le second tour, un⋅e organisateurice doit cliquer sur le bouton
|
||||
« Continuer ». Le tirage reprend ensuite normalement, à partir de la poule **A2**.
|
||||
|
||||
.. danger::
|
||||
À terme, il sera possible de réaliser ce second tirage au sort IRL, et de rentrer
|
||||
facilement les données au fur et à mesure du tirage.
|
||||
|
||||
|
||||
Annulation d'une étape
|
||||
......................
|
||||
|
||||
Il est possible d'annuler la dernière étape du tirage au sort. Cela peut être utile
|
||||
en cas de souci technique ou de mauvaise manipulation. Cette option est uniquement
|
||||
réservée aux organisateurices.
|
||||
|
||||
Pour cela, il suffit de cliquer sur le bouton « Annuler la dernière étape » dans
|
||||
la partie « Récapitulatif ». Cela annule immédiatement la dernière action. Il est
|
||||
possible de continuer à remonter le temps ainsi.
|
||||
|
||||
En cas de plus gros problème, il est possible de cliquer sur le bouton « Annuler »
|
||||
en bas de page. Cela réinitialise le tirage au sort, et permet de recommencer
|
||||
depuis le début. Cette suppression est irréversible, soyez sûr⋅es de ce que vous
|
||||
faites avant de cliquer dessus.
|
24
docs/index.rst
Normal file
@ -0,0 +1,24 @@
|
||||
Documentation de la plateforme du TFJM²
|
||||
=======================================
|
||||
|
||||
Ce site vise à documenter l'usage de la plateforme de gestion du TFJM², aussi
|
||||
bien du côté utilisateur⋅rice que du côté organisateur⋅rice ou bien
|
||||
administrateur⋅rice.
|
||||
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 3
|
||||
:caption: Utiliser
|
||||
|
||||
user
|
||||
orga
|
||||
draw
|
||||
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 3
|
||||
:caption: Développer
|
||||
|
||||
dev/index
|
||||
dev/install
|
||||
dev/transition
|
114
docs/orga.rst
Normal file
@ -0,0 +1,114 @@
|
||||
Partie organisateur⋅rices
|
||||
=========================
|
||||
|
||||
.. contents::
|
||||
|
||||
Cette page est dédiée aux organisateur⋅rices qui souhaitent utiliser la plateforme pour gérer
|
||||
les différentes équipes et les inscriptions.
|
||||
|
||||
|
||||
Ajouter un⋅e nouvelleau organisateur⋅rice
|
||||
-----------------------------------------
|
||||
|
||||
Seul⋅es les actuel⋅les organisateur⋅rices peuvent en ajouter de nouvelleaux. Il n'est pas possible
|
||||
de s'inscrire.
|
||||
|
||||
Pour cela, il faut se connecter, aller dans l'onglet « Utilisateur⋅rices », puis « Ajouter un⋅e organisateur⋅rice ».
|
||||
|
||||
Les informations suivantes sont demandées :
|
||||
|
||||
* Prénom
|
||||
* Nom de famille
|
||||
* Adresse e-mail (préférer une adresse institutionnelle)
|
||||
* Rôle (Bénévole ou administrateur⋅rice)
|
||||
* Activité professionnelle (permet de savoir d'où viennent les bénévoles)
|
||||
|
||||
Les bénévoles peuvent gérer ce qui les concernent (tournois, jurys,…), les administrateur⋅rices ont un accès
|
||||
intégral à l'ensemble de la plateforme, non restreint. Ce dernier statut ne devrait être réservé qu'aux membres du CNO.
|
||||
|
||||
Une fois le formulaire validé, un mail est envoyé permettant de définir son mot de passe. Iel peut ensuite se
|
||||
connecter en utilisant son adresse e-mail et son mot de passe.
|
||||
|
||||
|
||||
Gestion des tournois
|
||||
--------------------
|
||||
|
||||
Créer un tournoi
|
||||
""""""""""""""""
|
||||
|
||||
.. important::
|
||||
Seul⋅es les administrateur⋅rices peuvent créer des tournois.
|
||||
|
||||
Pour créer un tournoi, il suffit de cliquer dans l'onglet « Tournois » puis « Ajouter un tournoi ».
|
||||
Les descriptions des différents paramètres sont dans la section suivante.
|
||||
|
||||
|
||||
Modifier un tournoi
|
||||
"""""""""""""""""""
|
||||
|
||||
.. important::
|
||||
Seul⋅es les administrateur⋅rices ainsi que les organisateur⋅rices dudit tournoi peuvent modifier le tournoi.
|
||||
|
||||
Pour modifier un tournoi, il faut déjà se rendre sur la page du tournoi : onglet « Tournois » puis cliquer sur
|
||||
le bon tournoi. Le bouton « Modifier le tournoi » devrait être accessible.
|
||||
|
||||
.. warning::
|
||||
Si le bouton n'est pas visible, vérifiez que vous êtes bien connecté⋅e, et que vous êtes bien marqué⋅es parmi
|
||||
les organisateur⋅rices. N'hésitez pas à les contacter si ce n'est pas le cas.
|
||||
|
||||
Les informations suivantes peuvent être modifiées :
|
||||
|
||||
* Nom du tournoi
|
||||
* Date de début (le samedi)
|
||||
* Date de fin (le dimanche)
|
||||
* Adresse du lieu physique
|
||||
* Nombre indicatif maximal d'équipes autorisées
|
||||
(un multiple de 3, n'est là qu'à titre indicatif et n'est pas bloquant pour la suite)
|
||||
* Prix demandé aux participant⋅es, normalement 21 € sauf pour la finale 35 € (hors boursièr⋅es et tournois en visio)
|
||||
* La case « À distance » doit rester décochée tant que les tournois en visio n'ont pas repris
|
||||
* Date limite d'inscription : date jusqu'à laquelle les équipes peuvent finaliser leur inscription (non bloquant).
|
||||
En général le mois précédent le tournoi
|
||||
* Date limite pour envoyer les solutions : date jusqu'à laquelle les équipes peuvent soumettre leurs solutions
|
||||
(au-delà, le remplacement de solutions déjà soumises n'est plus permis mais l'envoi de nouvelles reste possible en
|
||||
cas de besoin, à décourager). En général le dimanche avant le tournoi vers 22h
|
||||
* Tirage au sort : date indicative qui dit quand le tirage au sort va se dérouler. En général le mardi précédent
|
||||
le tournoi vers 20h
|
||||
* Date limite pour envoyer les notes de synthèses pour la première/seconde phase : même règle que pour les solutions,
|
||||
mais pour les notes de synthèse. Généralement le vendredi à 22h pour le premier tour et le dimanche à 10h pour le
|
||||
second tour
|
||||
* Date à laquelle les solutions pour le second tour sont accessibles : seules les solutions pour le premier tour sont
|
||||
directement accessible après le tirage au sort, celles pour le second tour sont libérées automatiquement une fois
|
||||
cette date passée. Généralement le samedi entre 17h et 18h (à adapter)
|
||||
* Description
|
||||
* Organisateur⋅rices : liste des personnes qui organisent le tournoi et peuvent le gérer numériquement. N'inclut pas
|
||||
les juré⋅es
|
||||
* Finale (admin uniquement) : cette case ne doit être cochée que pour le tournoi de la finale.
|
||||
|
||||
|
||||
Liste des équipes
|
||||
"""""""""""""""""
|
||||
|
||||
Lorsque les équipes choisissent leur tournoi, elle est répertoriée sur la page du tournoi.
|
||||
|
||||
|
||||
Valider une équipe
|
||||
""""""""""""""""""
|
||||
|
||||
Lorsqu'une équipe a finalisé son inscription et a demandé à être validée, un mail est envoyé à l'ensemble des
|
||||
organisateur⋅rices. Sur l'interface du tournoi, il est possible de cliquer sur l'équipe et accéder à l'ensemble
|
||||
de ses informations :
|
||||
|
||||
* Nom de l'équipe
|
||||
* Trigramme
|
||||
* Encadrant⋅es
|
||||
* Participant⋅es
|
||||
* Diverses autorisations
|
||||
* Lettre de motivation
|
||||
|
||||
Lorsqu'il est temps de valider l'équipe, un formulaire dédié apparaît en bas. Un texte peut être envoyé à l'équipe,
|
||||
et le choix est proposé de valider l'équipe ou non.
|
||||
|
||||
|
||||
.. TODO
|
||||
.. note::
|
||||
Cette documentation sera complétée à l'avenir pour prendre en compte les enjeux du tournoi au moment venu.
|
3
docs/requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
sphinx>=3.3
|
||||
sphinx-rtd-theme>=2.0
|
||||
sphinx_rtd_dark_mode>=1.3.0
|
330
docs/user.rst
Normal file
@ -0,0 +1,330 @@
|
||||
Utiliser la plateforme
|
||||
======================
|
||||
|
||||
.. contents::
|
||||
|
||||
Cette plateforme est conçue pour que les différentes équipes puissent s'inscrire au TFJM² et envoyer
|
||||
leurs solutions.
|
||||
|
||||
Si vous êtes ici, c'est que vous avez des questions sur l'utilisation du site en tant que participant⋅e.
|
||||
|
||||
Les inscriptions ouvrent dans le courant du mois de janvier, généralement une semaine après la publication
|
||||
des problèmes. Pour l'édition 2024, les inscriptions ouvrent le 17 janvier 2024.
|
||||
|
||||
Tout se passe sur le site https://inscription.tfjm.org/.
|
||||
|
||||
|
||||
S'inscrire
|
||||
----------
|
||||
|
||||
Il est important de noter que chaque personne d'une équipe doit s'inscrire, y compris les encadrant⋅es.
|
||||
|
||||
Rendez-vous sur le site d'inscription, bouton « S'inscrire » en haut à droite.
|
||||
|
||||
Les informations suivantes sont requises pour tout le monde :
|
||||
|
||||
* Prénom d'usage
|
||||
* Nom de famille (ou d'usage)
|
||||
* Adresse électronique (sera vérifiée)
|
||||
* Mot de passe et confirmation
|
||||
* Rôle (participant⋅e ou encadrant⋅e)
|
||||
* Date de naissance
|
||||
* Genre
|
||||
* Adresse postale
|
||||
* Numéro de téléphone de contact
|
||||
* Problèmes de santé à déclarer (allergies,…)
|
||||
* Contraintes de logement à déclarer (problèmes médicaux, contraintes horaires, questions de genre,…)
|
||||
* Donne son consentement pour se faire recontacter par Animath
|
||||
|
||||
Informations demandées exclusivement aux élèves :
|
||||
|
||||
* Classe (seconde ou avant/première/terminale)
|
||||
* Établissement scolaire
|
||||
* Nom d'un⋅e responsable légal⋅e
|
||||
* Numéro de téléphone d'un⋅e responsable légal⋅e
|
||||
* Adresse e-mail d'un⋅e responsable légal⋅e
|
||||
|
||||
Informations exclusivement demandées aux encadrant⋅es :
|
||||
|
||||
* Activité professionnelle
|
||||
|
||||
Une fois inscrit⋅e, vous recevrez par mail un lien de confirmation, qu'il vous faudra cliquer.
|
||||
|
||||
|
||||
Connexion
|
||||
---------
|
||||
|
||||
Une fois inscrit⋅e, vous pouvez vous connecter en utilisant le bouton en haut à droite.
|
||||
|
||||
Dans le champ « nom d'utilisateur », rentrez votre adresse mail. Si vous avez oublié votre mot de
|
||||
passe, un formulaire est disponible pour vous aider à le réinitialiser. Vous pouvez ensuite vous
|
||||
connecter. Votre prénom et votre nom apparaîtra en haut à droite.
|
||||
|
||||
|
||||
Créer une équipe
|
||||
----------------
|
||||
|
||||
Il suffit d'une seule personne (participant⋅e ou encadrant⋅e) pour créer une équipe. Pour créer une
|
||||
équipe, il faut cliquer sur le bouton « créer une équipe ». Un nom d'équipe et un trigramme seront
|
||||
demandés. Le trigramme est composé de 3 lettres majuscules, c'est ce qui permettra aux
|
||||
organisateur⋅rices d'identifier rapidement votre équipe.
|
||||
|
||||
.. image:: /_static/img/create_team.png
|
||||
:alt: Création d'une équipe
|
||||
|
||||
Une fois l'équipe créée, vous obtenez un code à 6 caractères, lettres ou chiffre. Ce code est à
|
||||
transettre à l'ensemble des membres de votre équipe (et seulement à elleux).
|
||||
|
||||
.. image:: /_static/img/team_info.png
|
||||
:alt: Information sur l'équipe nouvellement créée
|
||||
|
||||
|
||||
Rejoindre une équipe
|
||||
--------------------
|
||||
|
||||
Si l'équipe est déjà créée, vous aurez besoin du code d'accès transmis par la personne ayant créé
|
||||
l'équipe. Vous pouvez cliquer sur « Rejoindre une équipe », et entrer le code.
|
||||
|
||||
.. image:: /_static/img/join_team.png
|
||||
:alt: Rejoindre une équipe par son code d'accès
|
||||
|
||||
Vous avez désormais normalement rejoint l'équipe.
|
||||
|
||||
En cas de problème, ou si vous ne savez pas de quel code on parle, contactez-nous à l'adresse
|
||||
contact@tfjm.org.
|
||||
|
||||
|
||||
Informations sur les tournois
|
||||
-----------------------------
|
||||
|
||||
Les tournois peuvent être trouvés dans l'onglet « Tournois ». Vous avez accès, pour chaque tournoi,
|
||||
à l'ensemble des dates importantes et les lieux des tournois.
|
||||
|
||||
.. image:: /_static/img/tournament_info.png
|
||||
:alt: Informations sur un tournoi
|
||||
|
||||
Davantage d'informations peuvent être trouvées sur le site vitrine : https://tfjm.org/infos-tournois/.
|
||||
|
||||
|
||||
Choisir un tournoi
|
||||
------------------
|
||||
|
||||
Pour accéder aux paramètres de votre équipe, vous pouvez aller sur l'onglet « Mon équipe », dans la
|
||||
barre de navigation.
|
||||
|
||||
Pour choisir votre tournoi, il vous suffit de vous rendre sur la page de votre équipe et de cliquer
|
||||
sur « Modifier ». Un formulaire vous permet alors de choisir votre tournoi.
|
||||
|
||||
.. image:: /_static/img/choose_tournament.png
|
||||
:alt: Formulaire de mise à jour de l'équipe permettant de choisir un tournoi
|
||||
|
||||
Attention cependant : cela ne confirme pas votre inscription. Vous devez pour cela envoyer l'ensemble
|
||||
de vos documents (voir ci-dessous).
|
||||
|
||||
|
||||
Transmettre ses documents
|
||||
-------------------------
|
||||
|
||||
Pour valider votre inscription, vous devez :
|
||||
|
||||
* Avoir choisi un tournoi ;
|
||||
* Que chaque membre de l'équipe ait transmis :
|
||||
|
||||
* Autorisation de droit à l'image ;
|
||||
* Fiche sanitaire de liaison et carnet de vaccination (pour les mineur⋅es) ;
|
||||
* Autorisation parentale (pour les mineur⋅es) ;
|
||||
|
||||
* Transmettre une lettre de motivation.
|
||||
|
||||
La lettre de motivation doit être envoyée une seule fois pour toute l'équipe, peut être envoyée
|
||||
depuis l'interface « Mon équipe », au format PDF, dont le contenu est défini dans le
|
||||
règlement : https://tfjm.org/reglement/.
|
||||
|
||||
Concernant les documents personnels, ils peuvent être envoyés depuis le menu « Mon compte », qui
|
||||
peut être trouvé en haut à droite dans la barre de navigation. Chaque fichier doit être envoyé
|
||||
au format PDF et peser moins de 2 Mo.
|
||||
|
||||
.. image:: /_static/img/user_info.png
|
||||
:alt: Informations sur l'utilisateur⋅rice
|
||||
|
||||
En cas de besoin, contactez-nous à l'adresse contact@tfjm.org.
|
||||
|
||||
|
||||
Valider son équipe
|
||||
------------------
|
||||
|
||||
Pour prétendre à la validation, il faut que l'équipe compte au moins 1 encadrant⋅e et 4 participant⋅es.
|
||||
Il faut ensuite que la lettre de motivation soit transmise, le tournoi choisi et que tous les documents
|
||||
nécessaires ont été transmis (voir section précédente).
|
||||
|
||||
Une fois tous les prérequis réunis, sur la page « Mon équipe », il est possible de cliquer sur le bouton
|
||||
pour demander la validation.
|
||||
|
||||
.. image:: /_static/img/validate_team.png
|
||||
:alt: Formulaire de validation d'équipe
|
||||
|
||||
.. warning::
|
||||
Les places étant limitées, rien ne garantit que vous pourrez avoir votre place dans le tournoi. Nous
|
||||
vous encourageons à respecter un maximum les critères définis dans le règlement :
|
||||
https://tfjm.org/reglement/. Selon les disponiblités et votre position géographique, il pourra
|
||||
vous être proposé de participer à un tournoi voisin.
|
||||
|
||||
Une fois les deadlines dépassées, rien ne vous garantit une place au TFJM², alors attention aux dates.
|
||||
|
||||
Vous recevrez par mail une réponse des organisateur⋅rices locaux⋅ales. En cas de besoin, contactez-nous
|
||||
à l'adresse contact@tfjm.org.
|
||||
|
||||
|
||||
Payer son inscription
|
||||
---------------------
|
||||
|
||||
Une fois votre inscription validée, il vous faudra payer votre participation. Les frais s'élèvent à
|
||||
21 € par élève, sauf pour les élèves boursièr⋅es qui en sont exonéré⋅es. Les encadrant⋅es n'ont pas
|
||||
à payer. Pour la finale, les frais sont de 35 € par élève.
|
||||
|
||||
.. note::
|
||||
Ces frais couvrent une partie des frais de restauration et d'hébergement. L'organisation reste
|
||||
bénévole.
|
||||
|
||||
Il est possible de payer par carte bancaire ou virement bancaire. Pour d'autres types de paiement,
|
||||
merci de nous contacter.
|
||||
|
||||
Pour payer, si votre équipe est bien validée, vous pouvez vous rendre sur la page de votre compte
|
||||
ou celle de votre équipe, et cliquer sur le bouton « Modifier le paiement », qui devrais désormais
|
||||
apparaître. Vous pouvez également utiliser le lien présent dans le volet « Informations ».
|
||||
|
||||
.. image:: /_static/img/payment_index.png
|
||||
:alt: Page de paiement
|
||||
|
||||
.. note::
|
||||
|
||||
Vous recevrez un mail de rappel chaque semaine. Le paiement doit être effectué avant le début du
|
||||
tournoi, sans quoi votre participation pourrait être refusée. En cas de difficultés de paiement,
|
||||
merci de nous contacter.
|
||||
|
||||
Carte bancaire
|
||||
""""""""""""""
|
||||
|
||||
La façon la plus simple de payer son inscription est de payer par carte bancaire. Animath utilise
|
||||
`Hello Asso <https://helloasso.com/>`_ en guise de solution de paiements en ligne.
|
||||
|
||||
Il vous suffit de cliquer sur le bouton « Aller à la page Hello Asso ». Vous serez redirigé⋅e ensuite
|
||||
vers la page de paiement.
|
||||
|
||||
.. warning::
|
||||
|
||||
Pour procéder au paiement, si vous êtes mineur⋅e, vous devrez demander à un⋅e adulte de payer à
|
||||
votre place. Il est important dans la suite de bien mettre les coordonnées du payeur ou de la payeuse,
|
||||
majeur⋅e, et non celles de l'élève.
|
||||
|
||||
.. image:: /_static/img/payment_hello_asso_step_1.png
|
||||
:alt: Formulaire de paiement Hello Asso
|
||||
|
||||
La personne qui paie peut rentrer ses informations demandées (nom, prénom, e-mail, date de naissance).
|
||||
|
||||
Notez que, par défaut, Hello Asso ajoute automatiquement une participation à ses frais de fonctionnement,
|
||||
d'environ 15 à 20 % du prix payé. Ces frais ne sont pas obligatoires, ne sont pas versés à Animath et
|
||||
représentent la seule source de revenus à Hello Asso. En effet : Animath ne verse aucune commission lors
|
||||
de ses transactions, et seules les contributions volontaires financent leur service.
|
||||
|
||||
Sur la page suivante, vous pouvez indiquer vos coordonnées bancaires :
|
||||
|
||||
.. image:: /_static/img/payment_hello_asso_step_2.png
|
||||
:alt: Formulaire de paiement Hello Asso - coordonnées bancaires
|
||||
|
||||
Vous devez ensuite éventuellement confirmer votre paiement auprès de votre banque.
|
||||
|
||||
Une fois ceci fait, vous êtes automatiquement redirigé⋅es vers la plateforme du TFJM² :
|
||||
|
||||
.. image:: /_static/img/payment_hello_asso_confirmation.png
|
||||
:alt: Confirmation de paiement Hello Asso
|
||||
|
||||
Il se peut que la validation ne soit pas instantanée. Elle peut prendre au plus quelques minutes.
|
||||
Si le délai est plus long, merci de nous contacter.
|
||||
|
||||
Vous recevrez ensuite un mail de confirmation de la plateforme, ainsi qu'un justificatif de paiement
|
||||
de la part de Hello Asso.
|
||||
|
||||
|
||||
Carte bancaire - paiement par un tiers
|
||||
""""""""""""""""""""""""""""""""""""""
|
||||
|
||||
Il est possible, si nécessaire, de faire payer l'inscription par carte bancaire par un tiers. Pour cela,
|
||||
vous pouvez lui transmettre le lien de paiement qui apparaît au centre de l'écran. Cela est notamment
|
||||
utile pour faire payer l'inscription par un établissement scolaire, ou par des parents.
|
||||
|
||||
L'interface de paiement sera ensuite identique.
|
||||
|
||||
|
||||
Virement bancaire
|
||||
"""""""""""""""""
|
||||
|
||||
Il est possible de payer par virement bancaire. Pour cela, vous pouvez ouvrir l'onglet virement bancaire :
|
||||
|
||||
.. image:: /_static/img/payment_bank_transfer.png
|
||||
:alt: Formulaire de paiement par virement bancaire
|
||||
|
||||
Pour effectuer le virement, merci de mettre en référence du virement « TFJMpu » suivi du nom et du prénom de l'élève.
|
||||
|
||||
Les coordonnées bancaires sont :
|
||||
|
||||
* IBAN : FR76 1027 8065 0000 0206 4290 127
|
||||
* BIC : CMCIFR2A
|
||||
|
||||
Une fois le paiment effectué, vous pouvez envoyer une preuve de virement via le formulaire ci-dessus. Le paiement
|
||||
sera ensuite validé manuellement par les organisateur⋅rices après réception.
|
||||
|
||||
Si vous avez besoin d'une facture, merci de nous contacter.
|
||||
|
||||
|
||||
Exonération - boursièr⋅es
|
||||
"""""""""""""""""""""""""
|
||||
|
||||
Si vous bénéficiez d'une bourse, vous pouvez être exonéré⋅es des frais de participation. Pour cela, il vous suffit
|
||||
de nous envoyer une copie de votre notification de bourse, ou tout autre document justifiant de votre situation.
|
||||
Vous pouvez envoyer ce document en vous rendant sur l'onglet dédié :
|
||||
|
||||
.. image:: /_static/img/payment_scholarship.png
|
||||
:alt: Formulaire de soumission de notification de bourse
|
||||
|
||||
|
||||
Paiements groupés
|
||||
"""""""""""""""""
|
||||
|
||||
Il est possible de payer en une seule fois pour toute l'équipe. Cela est notamment utile si l'inscription est
|
||||
payée par l'établissement. Pour cela, il suffit de cliquer sur le bouton « Regrouper les paiements de mon équipe ».
|
||||
Cela a pour effet d'unifier les paiements de l'équipe, et de ne pas demander à chaque membre de payer individuellement.
|
||||
Attention : cette fonction n'est possible que si aucun membre de l'équipe n'a encore payé son inscription.
|
||||
|
||||
.. image:: /_static/img/payment_grouped.png
|
||||
:alt: Page de paiement groupé
|
||||
|
||||
|
||||
Envoyer ses solutions
|
||||
---------------------
|
||||
|
||||
.. TODO
|
||||
.. note::
|
||||
Cette section sera mise à jour plus tard.
|
||||
|
||||
|
||||
Participer au tirage au sort
|
||||
----------------------------
|
||||
|
||||
La documentation des tirages au sort est disponible sur `la page dédiée <draw.html>`_.
|
||||
|
||||
|
||||
Envoyer ses notes de synthèse
|
||||
-----------------------------
|
||||
|
||||
.. TODO
|
||||
.. note::
|
||||
Cette section sera mise à jour plus tard.
|
||||
|
||||
|
||||
Récupérer les solutions adverses
|
||||
--------------------------------
|
||||
|
||||
.. TODO
|
||||
.. note::
|
||||
Cette section sera mise à jour plus tard.
|