Compare commits
673 Commits
1ae6049974
...
docs
Author | SHA1 | Date | |
---|---|---|---|
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 |
929
.bashrc
@ -1,925 +1,6 @@
|
||||
# =============================================================== #
|
||||
#
|
||||
# PERSONAL $HOME/.bashrc FILE for bash-3.0 (or later)
|
||||
# By Emmanuel Rouat [no-email]
|
||||
#
|
||||
# Last modified: Tue Nov 20 22:04:47 CET 2012
|
||||
PS1='\[\033[01;31m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '
|
||||
|
||||
# This file is normally read by interactive shells only.
|
||||
#+ Here is the place to define your aliases, functions and
|
||||
#+ other interactive features like your prompt.
|
||||
#
|
||||
# The majority of the code here assumes you are on a GNU
|
||||
#+ system (most likely a Linux box) and is often based on code
|
||||
#+ found on Usenet or Internet.
|
||||
#
|
||||
# See for instance:
|
||||
# http://tldp.org/LDP/abs/html/index.html
|
||||
# http://www.caliban.org/bash
|
||||
# http://www.shelldorado.com/scripts/categories.html
|
||||
# http://www.dotfiles.org
|
||||
#
|
||||
# The choice of colors was done for a shell with a dark background
|
||||
#+ (white on black), and this is usually also suited for pure text-mode
|
||||
#+ consoles (no X server available). If you use a white background,
|
||||
#+ you'll have to do some other choices for readability.
|
||||
#
|
||||
# This bashrc file is a bit overcrowded.
|
||||
# Remember, it is just just an example.
|
||||
# Tailor it to your needs.
|
||||
#
|
||||
# =============================================================== #
|
||||
|
||||
# --> Comments added by HOWTO author.
|
||||
|
||||
# If not running interactively, don't do anything
|
||||
[ -z "$PS1" ] && return
|
||||
|
||||
|
||||
#-------------------------------------------------------------
|
||||
# Source global definitions (if any)
|
||||
#-------------------------------------------------------------
|
||||
|
||||
|
||||
if [ -f /etc/bashrc ]; then
|
||||
. /etc/bashrc # --> Read /etc/bashrc, if present.
|
||||
fi
|
||||
|
||||
|
||||
#--------------------------------------------------------------
|
||||
# Automatic setting of $DISPLAY (if not set already).
|
||||
# This works for me - your mileage may vary. . . .
|
||||
# The problem is that different types of terminals give
|
||||
#+ different answers to 'who am i' (rxvt in particular can be
|
||||
#+ troublesome) - however this code seems to work in a majority
|
||||
#+ of cases.
|
||||
#--------------------------------------------------------------
|
||||
|
||||
function get_xserver ()
|
||||
{
|
||||
case $TERM in
|
||||
xterm )
|
||||
XSERVER=$(who am i | awk '{print $NF}' | tr -d ')''(' )
|
||||
# Ane-Pieter Wieringa suggests the following alternative:
|
||||
# I_AM=$(who am i)
|
||||
# SERVER=${I_AM#*(}
|
||||
# SERVER=${SERVER%*)}
|
||||
XSERVER=${XSERVER%%:*}
|
||||
;;
|
||||
aterm | rxvt)
|
||||
# Find some code that works here. ...
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [ -z ${DISPLAY:=""} ]; then
|
||||
# get_xserver
|
||||
if [[ -z ${XSERVER} || ${XSERVER} == $(hostname) ||
|
||||
${XSERVER} == "unix" ]]; then
|
||||
DISPLAY=":0.0" # Display on local host.
|
||||
else
|
||||
DISPLAY=${XSERVER}:0.0 # Display on remote host.
|
||||
fi
|
||||
fi
|
||||
|
||||
export DISPLAY
|
||||
|
||||
#-------------------------------------------------------------
|
||||
# Some settings
|
||||
#-------------------------------------------------------------
|
||||
|
||||
#set -o nounset # These two options are useful for debugging.
|
||||
#set -o xtrace
|
||||
alias debug="set -o nounset; set -o xtrace"
|
||||
|
||||
ulimit -S -c 0 # Don't want coredumps.
|
||||
set -o notify
|
||||
set -o noclobber
|
||||
# set -o ignoreeof
|
||||
|
||||
|
||||
# Enable options:
|
||||
shopt -s cdspell
|
||||
shopt -s cdable_vars
|
||||
shopt -s checkhash
|
||||
shopt -s checkwinsize
|
||||
shopt -s sourcepath
|
||||
shopt -s no_empty_cmd_completion
|
||||
shopt -s cmdhist
|
||||
shopt -s histappend histreedit histverify
|
||||
shopt -s extglob # Necessary for programmable completion.
|
||||
|
||||
# Disable options:
|
||||
shopt -u mailwarn
|
||||
unset MAILCHECK # Don't want my shell to warn me of incoming mail.
|
||||
|
||||
|
||||
#-------------------------------------------------------------
|
||||
# Greeting, motd etc. ...
|
||||
#-------------------------------------------------------------
|
||||
|
||||
# Color definitions (taken from Color Bash Prompt HowTo).
|
||||
# Some colors might look different of some terminals.
|
||||
# For example, I see 'Bold Red' as 'orange' on my screen,
|
||||
# hence the 'Green' 'BRed' 'Red' sequence I often use in my prompt.
|
||||
|
||||
|
||||
# Normal Colors
|
||||
Black='\e[0;30m' # Black
|
||||
Red='\e[0;31m' # Red
|
||||
Green='\e[0;32m' # Green
|
||||
Yellow='\e[0;33m' # Yellow
|
||||
Blue='\e[0;34m' # Blue
|
||||
Purple='\e[0;35m' # Purple
|
||||
Cyan='\e[0;36m' # Cyan
|
||||
White='\e[0;37m' # White
|
||||
|
||||
# Bold
|
||||
BBlack='\e[1;30m' # Black
|
||||
BRed='\e[1;31m' # Red
|
||||
BGreen='\e[1;32m' # Green
|
||||
BYellow='\e[1;33m' # Yellow
|
||||
BBlue='\e[1;34m' # Blue
|
||||
BPurple='\e[1;35m' # Purple
|
||||
BCyan='\e[1;36m' # Cyan
|
||||
BWhite='\e[1;37m' # White
|
||||
|
||||
# Background
|
||||
On_Black='\e[40m' # Black
|
||||
On_Red='\e[41m' # Red
|
||||
On_Green='\e[42m' # Green
|
||||
On_Yellow='\e[43m' # Yellow
|
||||
On_Blue='\e[44m' # Blue
|
||||
On_Purple='\e[45m' # Purple
|
||||
On_Cyan='\e[46m' # Cyan
|
||||
On_White='\e[47m' # White
|
||||
|
||||
NC="\e[m" # Color Reset
|
||||
|
||||
|
||||
ALERT=${BWhite}${On_Red} # Bold White on red background
|
||||
|
||||
|
||||
|
||||
echo -e "${BCyan}This is BASH ${BRed}${BASH_VERSION%.*}${BCyan}\
|
||||
- DISPLAY on ${BRed}$DISPLAY${NC}\n"
|
||||
date
|
||||
if [ -x /usr/games/fortune ]; then
|
||||
/usr/games/fortune -s # Makes our day a bit more fun.... :-)
|
||||
fi
|
||||
|
||||
# function _exit() # Function to run upon exit of shell.
|
||||
# {
|
||||
# echo -e "${BRed}Hasta la vista, baby${NC}"
|
||||
# }
|
||||
# trap _exit EXIT
|
||||
|
||||
#-------------------------------------------------------------
|
||||
# Shell Prompt - for many examples, see:
|
||||
# http://www.debian-administration.org/articles/205
|
||||
# http://www.askapache.com/linux/bash-power-prompt.html
|
||||
# http://tldp.org/HOWTO/Bash-Prompt-HOWTO
|
||||
# https://github.com/nojhan/liquidprompt
|
||||
#-------------------------------------------------------------
|
||||
# Current Format: [TIME USER@HOST PWD] >
|
||||
# TIME:
|
||||
# Green == machine load is low
|
||||
# Orange == machine load is medium
|
||||
# Red == machine load is high
|
||||
# ALERT == machine load is very high
|
||||
# USER:
|
||||
# Cyan == normal user
|
||||
# Orange == SU to user
|
||||
# Red == root
|
||||
# HOST:
|
||||
# Cyan == local session
|
||||
# Green == secured remote connection (via ssh)
|
||||
# Red == unsecured remote connection
|
||||
# PWD:
|
||||
# Green == more than 10% free disk space
|
||||
# Orange == less than 10% free disk space
|
||||
# ALERT == less than 5% free disk space
|
||||
# Red == current user does not have write privileges
|
||||
# Cyan == current filesystem is size zero (like /proc)
|
||||
# >:
|
||||
# White == no background or suspended jobs in this shell
|
||||
# Cyan == at least one background job in this shell
|
||||
# Orange == at least one suspended job in this shell
|
||||
#
|
||||
# Command is added to the history file each time you hit enter,
|
||||
# so it's available to all shells (using 'history -a').
|
||||
|
||||
|
||||
# Test connection type:
|
||||
if [ -n "${SSH_CONNECTION}" ]; then
|
||||
CNX=${Green} # Connected on remote machine, via ssh (good).
|
||||
elif [[ "${DISPLAY%%:0*}" != "" ]]; then
|
||||
CNX=${ALERT} # Connected on remote machine, not via ssh (bad).
|
||||
else
|
||||
CNX=${BCyan} # Connected on local machine.
|
||||
fi
|
||||
|
||||
# Test user type:
|
||||
if [[ ${USER} == "root" ]]; then
|
||||
SU=${Red} # User is root.
|
||||
# elif [[ ${USER} != $(logname) ]]; then
|
||||
# SU=${BRed} # User is not login user.
|
||||
else
|
||||
SU=${BCyan} # User is normal (well ... most of us are).
|
||||
fi
|
||||
|
||||
|
||||
|
||||
NCPU=$(grep -c 'processor' /proc/cpuinfo) # Number of CPUs
|
||||
SLOAD=$(( 100*${NCPU} )) # Small load
|
||||
MLOAD=$(( 200*${NCPU} )) # Medium load
|
||||
XLOAD=$(( 400*${NCPU} )) # Xlarge load
|
||||
|
||||
# Returns system load as percentage, i.e., '40' rather than '0.40)'.
|
||||
function load()
|
||||
{
|
||||
local SYSLOAD=$(cut -d " " -f1 /proc/loadavg | tr -d '.')
|
||||
# System load of the current host.
|
||||
echo $((10#$SYSLOAD)) # Convert to decimal.
|
||||
}
|
||||
|
||||
# Returns a color indicating system load.
|
||||
function load_color()
|
||||
{
|
||||
local SYSLOAD=$(load)
|
||||
if [ ${SYSLOAD} -gt ${XLOAD} ]; then
|
||||
echo -en ${ALERT}
|
||||
elif [ ${SYSLOAD} -gt ${MLOAD} ]; then
|
||||
echo -en ${Red}
|
||||
elif [ ${SYSLOAD} -gt ${SLOAD} ]; then
|
||||
echo -en ${BRed}
|
||||
else
|
||||
echo -en ${Green}
|
||||
fi
|
||||
}
|
||||
|
||||
# Returns a color according to free disk space in $PWD.
|
||||
function disk_color()
|
||||
{
|
||||
if [ ! -w "${PWD}" ] ; then
|
||||
echo -en ${Red}
|
||||
# No 'write' privilege in the current directory.
|
||||
elif [ -s "${PWD}" ] ; then
|
||||
local used=$(command df -P "$PWD" |
|
||||
awk 'END {print $5} {sub(/%/,"")}')
|
||||
if [ ${used} -gt 95 ]; then
|
||||
echo -en ${ALERT} # Disk almost full (>95%).
|
||||
elif [ ${used} -gt 90 ]; then
|
||||
echo -en ${BRed} # Free disk space almost gone.
|
||||
else
|
||||
echo -en ${Green} # Free disk space is ok.
|
||||
fi
|
||||
else
|
||||
echo -en ${Cyan}
|
||||
# Current directory is size '0' (like /proc, /sys etc).
|
||||
fi
|
||||
}
|
||||
|
||||
# Returns a color according to running/suspended jobs.
|
||||
function job_color()
|
||||
{
|
||||
if [ $(jobs -s | wc -l) -gt "0" ]; then
|
||||
echo -en ${BRed}
|
||||
elif [ $(jobs -r | wc -l) -gt "0" ] ; then
|
||||
echo -en ${BCyan}
|
||||
fi
|
||||
}
|
||||
|
||||
# Adds some text in the terminal frame (if applicable).
|
||||
|
||||
|
||||
# Now we construct the prompt.
|
||||
PROMPT_COMMAND="history -a"
|
||||
case ${TERM} in
|
||||
*term | rxvt | linux)
|
||||
PS1="\[\$(load_color)\][\A\[${NC}\] "
|
||||
# Time of day (with load info):
|
||||
PS1="\[\$(load_color)\][\A\[${NC}\] "
|
||||
# User@Host (with connection type info):
|
||||
PS1=${PS1}"\[${SU}\]\u\[${NC}\]@\[${CNX}\]\h\[${NC}\] "
|
||||
# PWD (with 'disk space' info):
|
||||
PS1=${PS1}"\[\$(disk_color)\]\W]\[${NC}\] "
|
||||
# Prompt (with 'job' info):
|
||||
PS1=${PS1}"\[\$(job_color)\]>\[${NC}\] "
|
||||
# Set title of current xterm:
|
||||
PS1=${PS1}"\[\e]0;[\u@\h] \w\a\]"
|
||||
;;
|
||||
*)
|
||||
PS1="(\A \u@\h \W) > " # --> PS1="(\A \u@\h \w) > "
|
||||
# --> Shows full pathname of current dir.
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
export TIMEFORMAT=$'\nreal %3R\tuser %3U\tsys %3S\tpcpu %P\n'
|
||||
export HISTIGNORE="&:bg:fg:ll:h"
|
||||
export HISTTIMEFORMAT="$(echo -e ${BCyan})[%d/%m %H:%M:%S]$(echo -e ${NC}) "
|
||||
export HISTCONTROL=ignoredups
|
||||
export HOSTFILE=$HOME/.hosts # Put a list of remote hosts in ~/.hosts
|
||||
|
||||
|
||||
#============================================================
|
||||
#
|
||||
# ALIASES AND FUNCTIONS
|
||||
#
|
||||
# Arguably, some functions defined here are quite big.
|
||||
# If you want to make this file smaller, these functions can
|
||||
#+ be converted into scripts and removed from here.
|
||||
#
|
||||
#============================================================
|
||||
|
||||
#-------------------
|
||||
# Personnal Aliases
|
||||
#-------------------
|
||||
|
||||
alias rm='rm -i'
|
||||
alias cp='cp -i'
|
||||
alias mv='mv -i'
|
||||
# -> Prevents accidentally clobbering files.
|
||||
alias mkdir='mkdir -p'
|
||||
|
||||
alias h='history'
|
||||
alias j='jobs -l'
|
||||
alias which='type -a'
|
||||
alias ..='cd ..'
|
||||
alias ...='cd ../..'
|
||||
|
||||
# Pretty-print of some PATH variables:
|
||||
alias path='echo -e ${PATH//:/\\n}'
|
||||
alias libpath='echo -e ${LD_LIBRARY_PATH//:/\\n}'
|
||||
|
||||
|
||||
alias du='du -kh' # Makes a more readable output.
|
||||
alias df='df -kTh'
|
||||
|
||||
#-------------------------------------------------------------
|
||||
# The 'ls' family (this assumes you use a recent GNU ls).
|
||||
#-------------------------------------------------------------
|
||||
# Add colors for filetype and human-readable sizes by default on 'ls':
|
||||
alias ls='ls -h --color'
|
||||
alias lx='ls -lXB' # Sort by extension.
|
||||
alias lk='ls -lSr' # Sort by size, biggest last.
|
||||
alias lt='ls -ltr' # Sort by date, most recent last.
|
||||
alias lc='ls -ltcr' # Sort by/show change time,most recent last.
|
||||
alias lu='ls -ltur' # Sort by/show access time,most recent last.
|
||||
|
||||
# The ubiquitous 'll': directories first, with alphanumeric sorting:
|
||||
alias ll="ls -lv --group-directories-first"
|
||||
alias lm='ll |more' # Pipe through 'more'
|
||||
alias lr='ll -R' # Recursive ls.
|
||||
alias la='ll -A' # Show hidden files.
|
||||
alias l='la'
|
||||
alias tree='tree -Csuh' # Nice alternative to 'recursive ls' ...
|
||||
|
||||
|
||||
#-------------------------------------------------------------
|
||||
# Tailoring 'less'
|
||||
#-------------------------------------------------------------
|
||||
|
||||
alias more='less'
|
||||
export PAGER=less
|
||||
export LESSCHARSET='latin1'
|
||||
export LESSOPEN='|/usr/bin/lesspipe.sh %s 2>&-'
|
||||
# Use this if lesspipe.sh exists.
|
||||
export LESS='-i -N -w -z-4 -g -e -M -X -F -R -P%t?f%f \
|
||||
:stdin .?pb%pb\%:?lbLine %lb:?bbByte %bb:-...'
|
||||
|
||||
# LESS man page colors (makes Man pages more readable).
|
||||
export LESS_TERMCAP_mb=$'\E[01;31m'
|
||||
export LESS_TERMCAP_md=$'\E[01;31m'
|
||||
export LESS_TERMCAP_me=$'\E[0m'
|
||||
export LESS_TERMCAP_se=$'\E[0m'
|
||||
export LESS_TERMCAP_so=$'\E[01;44;33m'
|
||||
export LESS_TERMCAP_ue=$'\E[0m'
|
||||
export LESS_TERMCAP_us=$'\E[01;32m'
|
||||
|
||||
|
||||
#-------------------------------------------------------------
|
||||
# Spelling typos - highly personnal and keyboard-dependent :-)
|
||||
#-------------------------------------------------------------
|
||||
|
||||
alias xs='cd'
|
||||
alias vf='cd'
|
||||
alias moer='more'
|
||||
alias moew='more'
|
||||
alias kk='ll'
|
||||
|
||||
|
||||
#-------------------------------------------------------------
|
||||
# A few fun ones
|
||||
#-------------------------------------------------------------
|
||||
|
||||
# Adds some text in the terminal frame (if applicable).
|
||||
|
||||
function xtitle()
|
||||
{
|
||||
case "$TERM" in
|
||||
*term* | rxvt)
|
||||
echo -en "\e]0;$*\a" ;;
|
||||
*) ;;
|
||||
esac
|
||||
}
|
||||
|
||||
|
||||
# Aliases that use xtitle
|
||||
alias top='xtitle Processes on $HOST && top'
|
||||
alias make='xtitle Making $(basename $PWD) ; make'
|
||||
|
||||
# .. and functions
|
||||
function man()
|
||||
{
|
||||
for i ; do
|
||||
xtitle The $(basename $1|tr -d .[:digit:]) manual
|
||||
command man -a "$i"
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
#-------------------------------------------------------------
|
||||
# Make the following commands run in background automatically:
|
||||
#-------------------------------------------------------------
|
||||
|
||||
function te() # wrapper around xemacs/gnuserv
|
||||
{
|
||||
if [ "$(gnuclient -batch -eval t 2>&-)" == "t" ]; then
|
||||
gnuclient -q "$@";
|
||||
else
|
||||
( xemacs "$@" &);
|
||||
fi
|
||||
}
|
||||
|
||||
function soffice() { command soffice "$@" & }
|
||||
function firefox() { command firefox "$@" & }
|
||||
function xpdf() { command xpdf "$@" & }
|
||||
|
||||
|
||||
#-------------------------------------------------------------
|
||||
# File & strings related functions:
|
||||
#-------------------------------------------------------------
|
||||
|
||||
|
||||
# Find a file with a pattern in name:
|
||||
function ff() { find . -type f -iname '*'"$*"'*' -ls ; }
|
||||
|
||||
# Find a file with pattern $1 in name and Execute $2 on it:
|
||||
function fe() { find . -type f -iname '*'"${1:-}"'*' \
|
||||
-exec ${2:-file} {} \; ; }
|
||||
|
||||
# Find a pattern in a set of files and highlight them:
|
||||
#+ (needs a recent version of egrep).
|
||||
function fstr()
|
||||
{
|
||||
OPTIND=1
|
||||
local mycase=""
|
||||
local usage="fstr: find string in files.
|
||||
Usage: fstr [-i] \"pattern\" [\"filename pattern\"] "
|
||||
while getopts :it opt
|
||||
do
|
||||
case "$opt" in
|
||||
i) mycase="-i " ;;
|
||||
*) echo "$usage"; return ;;
|
||||
esac
|
||||
done
|
||||
shift $(( $OPTIND - 1 ))
|
||||
if [ "$#" -lt 1 ]; then
|
||||
echo "$usage"
|
||||
return;
|
||||
fi
|
||||
find . -type f -name "${2:-*}" -print0 | \
|
||||
xargs -0 egrep --color=always -sn ${case} "$1" 2>&- | more
|
||||
|
||||
}
|
||||
|
||||
|
||||
function swap()
|
||||
{ # Swap 2 filenames around, if they exist (from Uzi's bashrc).
|
||||
local TMPFILE=tmp.$$
|
||||
|
||||
[ $# -ne 2 ] && echo "swap: 2 arguments needed" && return 1
|
||||
[ ! -e $1 ] && echo "swap: $1 does not exist" && return 1
|
||||
[ ! -e $2 ] && echo "swap: $2 does not exist" && return 1
|
||||
|
||||
mv "$1" $TMPFILE
|
||||
mv "$2" "$1"
|
||||
mv $TMPFILE "$2"
|
||||
}
|
||||
|
||||
function extract() # Handy Extract Program
|
||||
{
|
||||
if [ -f $1 ] ; then
|
||||
case $1 in
|
||||
*.tar.bz2) tar xvjf $1 ;;
|
||||
*.tar.gz) tar xvzf $1 ;;
|
||||
*.bz2) bunzip2 $1 ;;
|
||||
*.rar) unrar x $1 ;;
|
||||
*.gz) gunzip $1 ;;
|
||||
*.tar) tar xvf $1 ;;
|
||||
*.tbz2) tar xvjf $1 ;;
|
||||
*.tgz) tar xvzf $1 ;;
|
||||
*.zip) unzip $1 ;;
|
||||
*.Z) uncompress $1 ;;
|
||||
*.7z) 7z x $1 ;;
|
||||
*) echo "'$1' cannot be extracted via >extract<" ;;
|
||||
esac
|
||||
else
|
||||
echo "'$1' is not a valid file!"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# Creates an archive (*.tar.gz) from given directory.
|
||||
function maketar() { tar cvzf "${1%%/}.tar.gz" "${1%%/}/"; }
|
||||
|
||||
# Create a ZIP archive of a file or folder.
|
||||
function makezip() { zip -r "${1%%/}.zip" "$1" ; }
|
||||
|
||||
# Make your directories and files access rights sane.
|
||||
function sanitize() { chmod -R u=rwX,g=rX,o= "$@" ;}
|
||||
|
||||
#-------------------------------------------------------------
|
||||
# Process/system related functions:
|
||||
#-------------------------------------------------------------
|
||||
|
||||
|
||||
function my_ps() { ps $@ -u $USER -o pid,%cpu,%mem,bsdtime,command ; }
|
||||
function pp() { my_ps f | awk '!/awk/ && $0~var' var=${1:-".*"} ; }
|
||||
|
||||
|
||||
function killps() # kill by process name
|
||||
{
|
||||
local pid pname sig="-TERM" # default signal
|
||||
if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then
|
||||
echo "Usage: killps [-SIGNAL] pattern"
|
||||
return;
|
||||
fi
|
||||
if [ $# = 2 ]; then sig=$1 ; fi
|
||||
for pid in $(my_ps| awk '!/awk/ && $0~pat { print $1 }' pat=${!#} )
|
||||
do
|
||||
pname=$(my_ps | awk '$1~var { print $5 }' var=$pid )
|
||||
if ask "Kill process $pid <$pname> with signal $sig?"
|
||||
then kill $sig $pid
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
function mydf() # Pretty-print of 'df' output.
|
||||
{ # Inspired by 'dfc' utility.
|
||||
for fs ; do
|
||||
|
||||
if [ ! -d $fs ]
|
||||
then
|
||||
echo -e $fs" :No such file or directory" ; continue
|
||||
fi
|
||||
|
||||
local info=( $(command df -P $fs | awk 'END{ print $2,$3,$5 }') )
|
||||
local free=( $(command df -Pkh $fs | awk 'END{ print $4 }') )
|
||||
local nbstars=$(( 20 * ${info[1]} / ${info[0]} ))
|
||||
local out="["
|
||||
for ((j=0;j<20;j++)); do
|
||||
if [ ${j} -lt ${nbstars} ]; then
|
||||
out=$out"*"
|
||||
else
|
||||
out=$out"-"
|
||||
fi
|
||||
done
|
||||
out=${info[2]}" "$out"] ("$free" free on "$fs")"
|
||||
echo -e $out
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
function my_ip() # Get IP adress on ethernet.
|
||||
{
|
||||
MY_IP=$(/sbin/ifconfig eth0 | awk '/inet/ { print $2 } ' |
|
||||
sed -e s/addr://)
|
||||
echo ${MY_IP:-"Not connected"}
|
||||
}
|
||||
|
||||
function ii() # Get current host related info.
|
||||
{
|
||||
echo -e "\nYou are logged on ${BRed}$HOST"
|
||||
echo -e "\n${BRed}Additionnal information:$NC " ; uname -a
|
||||
echo -e "\n${BRed}Users logged on:$NC " ; w -hs |
|
||||
cut -d " " -f1 | sort | uniq
|
||||
echo -e "\n${BRed}Current date :$NC " ; date
|
||||
echo -e "\n${BRed}Machine stats :$NC " ; uptime
|
||||
echo -e "\n${BRed}Memory stats :$NC " ; free
|
||||
echo -e "\n${BRed}Diskspace :$NC " ; mydf / $HOME
|
||||
echo -e "\n${BRed}Local IP Address :$NC" ; my_ip
|
||||
echo -e "\n${BRed}Open connections :$NC "; netstat -pan --inet;
|
||||
echo
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------
|
||||
# Misc utilities:
|
||||
#-------------------------------------------------------------
|
||||
|
||||
function repeat() # Repeat n times command.
|
||||
{
|
||||
local i max
|
||||
max=$1; shift;
|
||||
for ((i=1; i <= max ; i++)); do # --> C-like syntax
|
||||
eval "$@";
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
function ask() # See 'killps' for example of use.
|
||||
{
|
||||
echo -n "$@" '[y/n] ' ; read ans
|
||||
case "$ans" in
|
||||
y*|Y*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
function corename() # Get name of app that created a corefile.
|
||||
{
|
||||
for file ; do
|
||||
echo -n $file : ; gdb --core=$file --batch | head -1
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
|
||||
#=========================================================================
|
||||
#
|
||||
# PROGRAMMABLE COMPLETION SECTION
|
||||
# Most are taken from the bash 2.05 documentation and from Ian McDonald's
|
||||
# 'Bash completion' package (http://www.caliban.org/bash/#completion)
|
||||
# You will in fact need bash more recent then 3.0 for some features.
|
||||
#
|
||||
# Note that most linux distributions now provide many completions
|
||||
# 'out of the box' - however, you might need to make your own one day,
|
||||
# so I kept those here as examples.
|
||||
#=========================================================================
|
||||
|
||||
if [ "${BASH_VERSION%.*}" \< "3.0" ]; then
|
||||
echo "You will need to upgrade to version 3.0 for full \
|
||||
programmable completion features"
|
||||
return
|
||||
fi
|
||||
|
||||
shopt -s extglob # Necessary.
|
||||
|
||||
complete -A hostname rsh rcp telnet rlogin ftp ping disk
|
||||
complete -A export printenv
|
||||
complete -A variable export local readonly unset
|
||||
complete -A enabled builtin
|
||||
complete -A alias alias unalias
|
||||
complete -A function function
|
||||
complete -A user su mail finger
|
||||
|
||||
complete -A helptopic help # Currently same as builtins.
|
||||
complete -A shopt shopt
|
||||
complete -A stopped -P '%' bg
|
||||
complete -A job -P '%' fg jobs disown
|
||||
|
||||
complete -A directory mkdir rmdir
|
||||
complete -A directory -o default cd
|
||||
|
||||
# Compression
|
||||
complete -f -o default -X '*.+(zip|ZIP)' zip
|
||||
complete -f -o default -X '!*.+(zip|ZIP)' unzip
|
||||
complete -f -o default -X '*.+(z|Z)' compress
|
||||
complete -f -o default -X '!*.+(z|Z)' uncompress
|
||||
complete -f -o default -X '*.+(gz|GZ)' gzip
|
||||
complete -f -o default -X '!*.+(gz|GZ)' gunzip
|
||||
complete -f -o default -X '*.+(bz2|BZ2)' bzip2
|
||||
complete -f -o default -X '!*.+(bz2|BZ2)' bunzip2
|
||||
complete -f -o default -X '!*.+(zip|ZIP|z|Z|gz|GZ|bz2|BZ2)' extract
|
||||
|
||||
|
||||
# Documents - Postscript,pdf,dvi.....
|
||||
complete -f -o default -X '!*.+(ps|PS)' gs ghostview ps2pdf ps2ascii
|
||||
complete -f -o default -X \
|
||||
'!*.+(dvi|DVI)' dvips dvipdf xdvi dviselect dvitype
|
||||
complete -f -o default -X '!*.+(pdf|PDF)' acroread pdf2ps
|
||||
complete -f -o default -X '!*.@(@(?(e)ps|?(E)PS|pdf|PDF)?\
|
||||
(.gz|.GZ|.bz2|.BZ2|.Z))' gv ggv
|
||||
complete -f -o default -X '!*.texi*' makeinfo texi2dvi texi2html texi2pdf
|
||||
complete -f -o default -X '!*.tex' tex latex slitex
|
||||
complete -f -o default -X '!*.lyx' lyx
|
||||
complete -f -o default -X '!*.+(htm*|HTM*)' lynx html2ps
|
||||
complete -f -o default -X \
|
||||
'!*.+(doc|DOC|xls|XLS|ppt|PPT|sx?|SX?|csv|CSV|od?|OD?|ott|OTT)' soffice
|
||||
|
||||
# Multimedia
|
||||
complete -f -o default -X \
|
||||
'!*.+(gif|GIF|jp*g|JP*G|bmp|BMP|xpm|XPM|png|PNG)' xv gimp ee gqview
|
||||
complete -f -o default -X '!*.+(mp3|MP3)' mpg123 mpg321
|
||||
complete -f -o default -X '!*.+(ogg|OGG)' ogg123
|
||||
complete -f -o default -X \
|
||||
'!*.@(mp[23]|MP[23]|ogg|OGG|wav|WAV|pls|\
|
||||
m3u|xm|mod|s[3t]m|it|mtm|ult|flac)' xmms
|
||||
complete -f -o default -X '!*.@(mp?(e)g|MP?(E)G|wma|avi|AVI|\
|
||||
asf|vob|VOB|bin|dat|vcd|ps|pes|fli|viv|rm|ram|yuv|mov|MOV|qt|\
|
||||
QT|wmv|mp3|MP3|ogg|OGG|ogm|OGM|mp4|MP4|wav|WAV|asx|ASX)' xine
|
||||
|
||||
|
||||
|
||||
complete -f -o default -X '!*.pl' perl perl5
|
||||
|
||||
|
||||
# This is a 'universal' completion function - it works when commands have
|
||||
#+ a so-called 'long options' mode , ie: 'ls --all' instead of 'ls -a'
|
||||
# Needs the '-o' option of grep
|
||||
#+ (try the commented-out version if not available).
|
||||
|
||||
# First, remove '=' from completion word separators
|
||||
#+ (this will allow completions like 'ls --color=auto' to work correctly).
|
||||
|
||||
COMP_WORDBREAKS=${COMP_WORDBREAKS/=/}
|
||||
|
||||
|
||||
_get_longopts()
|
||||
{
|
||||
#$1 --help | sed -e '/--/!d' -e 's/.*--\([^[:space:].,]*\).*/--\1/'| \
|
||||
#grep ^"$2" |sort -u ;
|
||||
$1 --help | grep -o -e "--[^[:space:].,]*" | grep -e "$2" |sort -u
|
||||
}
|
||||
|
||||
_longopts()
|
||||
{
|
||||
local cur
|
||||
cur=${COMP_WORDS[COMP_CWORD]}
|
||||
|
||||
case "${cur:-*}" in
|
||||
-*) ;;
|
||||
*) return ;;
|
||||
esac
|
||||
|
||||
case "$1" in
|
||||
\~*) eval cmd="$1" ;;
|
||||
*) cmd="$1" ;;
|
||||
esac
|
||||
COMPREPLY=( $(_get_longopts ${1} ${cur} ) )
|
||||
}
|
||||
complete -o default -F _longopts configure bash
|
||||
complete -o default -F _longopts wget id info a2ps ls recode
|
||||
|
||||
_tar()
|
||||
{
|
||||
local cur ext regex tar untar
|
||||
|
||||
COMPREPLY=()
|
||||
cur=${COMP_WORDS[COMP_CWORD]}
|
||||
|
||||
# If we want an option, return the possible long options.
|
||||
case "$cur" in
|
||||
-*) COMPREPLY=( $(_get_longopts $1 $cur ) ); return 0;;
|
||||
esac
|
||||
|
||||
if [ $COMP_CWORD -eq 1 ]; then
|
||||
COMPREPLY=( $( compgen -W 'c t x u r d A' -- $cur ) )
|
||||
return 0
|
||||
fi
|
||||
|
||||
case "${COMP_WORDS[1]}" in
|
||||
?(-)c*f)
|
||||
COMPREPLY=( $( compgen -f $cur ) )
|
||||
return 0
|
||||
;;
|
||||
+([^Izjy])f)
|
||||
ext='tar'
|
||||
regex=$ext
|
||||
;;
|
||||
*z*f)
|
||||
ext='tar.gz'
|
||||
regex='t\(ar\.\)\(gz\|Z\)'
|
||||
;;
|
||||
*[Ijy]*f)
|
||||
ext='t?(ar.)bz?(2)'
|
||||
regex='t\(ar\.\)bz2\?'
|
||||
;;
|
||||
*)
|
||||
COMPREPLY=( $( compgen -f $cur ) )
|
||||
return 0
|
||||
;;
|
||||
|
||||
esac
|
||||
|
||||
if [[ "$COMP_LINE" == tar*.$ext' '* ]]; then
|
||||
# Complete on files in tar file.
|
||||
#
|
||||
# Get name of tar file from command line.
|
||||
tar=$( echo "$COMP_LINE" | \
|
||||
sed -e 's|^.* \([^ ]*'$regex'\) .*$|\1|' )
|
||||
# Devise how to untar and list it.
|
||||
untar=t${COMP_WORDS[1]//[^Izjyf]/}
|
||||
|
||||
COMPREPLY=( $( compgen -W "$( echo $( tar $untar $tar \
|
||||
2>/dev/null ) )" -- "$cur" ) )
|
||||
return 0
|
||||
|
||||
else
|
||||
# File completion on relevant files.
|
||||
COMPREPLY=( $( compgen -G $cur\*.$ext ) )
|
||||
|
||||
fi
|
||||
|
||||
return 0
|
||||
|
||||
}
|
||||
|
||||
complete -F _tar -o default tar
|
||||
|
||||
_make()
|
||||
{
|
||||
local mdef makef makef_dir="." makef_inc gcmd cur prev i;
|
||||
COMPREPLY=();
|
||||
cur=${COMP_WORDS[COMP_CWORD]};
|
||||
prev=${COMP_WORDS[COMP_CWORD-1]};
|
||||
case "$prev" in
|
||||
-*f)
|
||||
COMPREPLY=($(compgen -f $cur ));
|
||||
return 0
|
||||
;;
|
||||
esac;
|
||||
case "$cur" in
|
||||
-*)
|
||||
COMPREPLY=($(_get_longopts $1 $cur ));
|
||||
return 0
|
||||
;;
|
||||
esac;
|
||||
|
||||
# ... make reads
|
||||
# GNUmakefile,
|
||||
# then makefile
|
||||
# then Makefile ...
|
||||
if [ -f ${makef_dir}/GNUmakefile ]; then
|
||||
makef=${makef_dir}/GNUmakefile
|
||||
elif [ -f ${makef_dir}/makefile ]; then
|
||||
makef=${makef_dir}/makefile
|
||||
elif [ -f ${makef_dir}/Makefile ]; then
|
||||
makef=${makef_dir}/Makefile
|
||||
else
|
||||
makef=${makef_dir}/*.mk # Local convention.
|
||||
fi
|
||||
|
||||
|
||||
# Before we scan for targets, see if a Makefile name was
|
||||
#+ specified with -f.
|
||||
for (( i=0; i < ${#COMP_WORDS[@]}; i++ )); do
|
||||
if [[ ${COMP_WORDS[i]} == -f ]]; then
|
||||
# eval for tilde expansion
|
||||
eval makef=${COMP_WORDS[i+1]}
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ ! -f $makef ] && return 0
|
||||
|
||||
# Deal with included Makefiles.
|
||||
makef_inc=$( grep -E '^-?include' $makef |
|
||||
sed -e "s,^.* ,"$makef_dir"/," )
|
||||
for file in $makef_inc; do
|
||||
[ -f $file ] && makef="$makef $file"
|
||||
done
|
||||
|
||||
|
||||
# If we have a partial word to complete, restrict completions
|
||||
#+ to matches of that word.
|
||||
if [ -n "$cur" ]; then gcmd='grep "^$cur"' ; else gcmd=cat ; fi
|
||||
|
||||
COMPREPLY=( $( awk -F':' '/^[a-zA-Z0-9][^$#\/\t=]*:([^=]|$)/ \
|
||||
{split($1,A,/ /);for(i in A)print A[i]}' \
|
||||
$makef 2>/dev/null | eval $gcmd ))
|
||||
|
||||
}
|
||||
|
||||
complete -F _make -X '+($*|*.[cho])' make gmake pmake
|
||||
|
||||
|
||||
|
||||
|
||||
_killall()
|
||||
{
|
||||
local cur prev
|
||||
COMPREPLY=()
|
||||
cur=${COMP_WORDS[COMP_CWORD]}
|
||||
|
||||
# Get a list of processes
|
||||
#+ (the first sed evaluation
|
||||
#+ takes care of swapped out processes, the second
|
||||
#+ takes care of getting the basename of the process).
|
||||
COMPREPLY=( $( ps -u $USER -o comm | \
|
||||
sed -e '1,1d' -e 's#[]\[]##g' -e 's#^.*/##'| \
|
||||
awk '{if ($0 ~ /^'$cur'/) print $0}' ))
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
complete -F _killall killall killps
|
||||
|
||||
|
||||
|
||||
# Local Variables:
|
||||
# mode:shell-script
|
||||
# sh-shell:bash
|
||||
# End:
|
||||
alias ls='ls --color=auto'
|
||||
alias ll='ls -l'
|
||||
alias la='ls -A'
|
||||
alias l='ls -lACF'
|
||||
|
@ -1,4 +1,3 @@
|
||||
__pycache__
|
||||
media
|
||||
import_olddb
|
||||
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
|
||||
|
||||
py311:
|
||||
stage: test
|
||||
image: python:3.11-alpine
|
||||
before_script:
|
||||
- apk add --no-cache libmagic
|
||||
- apk add --no-cache gettext git # Useful for django-haystack, remove when the newer versions are in PyPI
|
||||
- pip install tox --no-cache-dir
|
||||
script: tox -e py311
|
||||
|
||||
py312:
|
||||
stage: test
|
||||
image: python:3.12-alpine
|
||||
before_script:
|
||||
- apk add --no-cache libmagic
|
||||
- apk add --no-cache gettext git # Useful for django-haystack, remove when the newer versions are in PyPI
|
||||
- pip install tox --no-cache-dir
|
||||
script: tox -e py312
|
||||
|
||||
linters:
|
||||
stage: quality-assurance
|
||||
image: python:3-alpine
|
||||
before_script:
|
||||
- pip install tox --no-cache-dir
|
||||
script: tox -e linters
|
||||
allow_failure: true
|
@ -1,7 +0,0 @@
|
||||
ErrorDocument 403 /tfjm/server_files/403.php
|
||||
ErrorDocument 404 /tfjm/server_files/404.php
|
||||
|
||||
Options +FollowSymlinks
|
||||
Options -Indexes
|
||||
RewriteEngine On
|
||||
RewriteRule ^(.*)$ dispatcher.php?path=$1 [QSA,L]
|
6
.idea/.gitignore
generated
vendored
@ -1,6 +0,0 @@
|
||||
# Default ignored files
|
||||
/workspace.xml
|
||||
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
10
.idea/TFJM.iml
generated
@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
5
.idea/codeStyles/codeStyleConfig.xml
generated
@ -1,5 +0,0 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
</state>
|
||||
</component>
|
15
.idea/dataSources.xml
generated
@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="tfjm@ynerant.fr" uuid="ce243a48-c634-4134-8105-e68ea53cd5ed">
|
||||
<driver-ref>mysql.8</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:mysql://ynerant.fr:3306/tfjm</jdbc-url>
|
||||
<driver-properties>
|
||||
<property name="serverTimezone" value="GMT+1" />
|
||||
</driver-properties>
|
||||
<time-zone>GMT+1</time-zone>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
22
.idea/deployment.xml
generated
@ -1,22 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="PublishConfigData" autoUpload="Always" serverName="inscription.tfjm.org" autoUploadExternalChanges="true">
|
||||
<serverData>
|
||||
<paths name="inscription.tfjm.org">
|
||||
<serverdata>
|
||||
<mappings>
|
||||
<mapping deploy="/var/inscription-tfjm" local="$PROJECT_DIR$" web="/" />
|
||||
</mappings>
|
||||
</serverdata>
|
||||
</paths>
|
||||
<paths name="ynerant.fr">
|
||||
<serverdata>
|
||||
<mappings>
|
||||
<mapping deploy="/var/www/html/tfjm" local="$PROJECT_DIR$" web="/tfjm" />
|
||||
</mappings>
|
||||
</serverdata>
|
||||
</paths>
|
||||
</serverData>
|
||||
<option name="myAutoUpload" value="ALWAYS" />
|
||||
</component>
|
||||
</project>
|
6
.idea/misc.xml
generated
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="JavaScriptSettings">
|
||||
<option name="languageLevel" value="ES6" />
|
||||
</component>
|
||||
</project>
|
8
.idea/modules.xml
generated
@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/TFJM.iml" filepath="$PROJECT_DIR$/.idea/TFJM.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
6
.idea/vcs.xml
generated
@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
26
Dockerfile
@ -1,24 +1,34 @@
|
||||
FROM python:3-alpine
|
||||
FROM python:3.12-alpine
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV DJANGO_ALLOW_ASYNC_UNSAFE 1
|
||||
|
||||
# Install LaTeX requirements
|
||||
RUN apk add --no-cache gettext texlive nginx gcc libc-dev libffi-dev postgresql-dev mariadb-connector-c-dev
|
||||
RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libxml2-dev libxslt-dev npm postgresql-dev libmagic texlive texmf-dist-latexextra
|
||||
|
||||
RUN apk add --no-cache bash
|
||||
|
||||
RUN mkdir /code
|
||||
RUN npm install -g yuglify
|
||||
|
||||
RUN mkdir /code /code/docs
|
||||
WORKDIR /code
|
||||
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/
|
||||
|
||||
# Compile documentation
|
||||
RUN sphinx-build -M html docs docs/_build
|
||||
|
||||
RUN python manage.py collectstatic --noinput && \
|
||||
python manage.py compilemessages
|
||||
|
||||
# Configure nginx
|
||||
RUN mkdir /run/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/conf.d/tfjm.conf
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
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
|
||||
@ -26,4 +36,4 @@ RUN ln -s /code/.bashrc /root/.bashrc
|
||||
ENTRYPOINT ["/code/entrypoint.sh"]
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["./manage.py", "shell_plus", "--ptpython"]
|
||||
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>.
|
24
README.md
@ -1,7 +1,10 @@
|
||||
# Plateforme d'inscription du TFJM²
|
||||
# Plateforme du TFJM²
|
||||
|
||||
La plateforme du TFJM² est née pour l'édition 2020 du tournoi. D'abord codée en PHP, elle a subi une refonte totale en
|
||||
Python, à l'aide du framework Web [Django](https://www.djangoproject.com/).
|
||||
[](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
|
||||
@ -18,8 +21,8 @@ Le plus simple pour installer la plateforme est d'utiliser l'image Docker inclus
|
||||
exposé sur le port 80 avec le serveur Django. Ci-dessous une configuration Docker-Compose, à adapter selon vos besoins :
|
||||
|
||||
```yaml
|
||||
inscription-tfjm:
|
||||
build: ./inscription-tfjm
|
||||
plateforme-tfjm:
|
||||
build: https://gitlab.com/animath/si/plateforme-tfjm.git
|
||||
links:
|
||||
- postgres
|
||||
ports:
|
||||
@ -37,7 +40,6 @@ Il faut remplir les variables d'environnement suivantes :
|
||||
|
||||
```env
|
||||
TFJM_STAGE= # dev ou prod
|
||||
TFJM_YEAR=2021 # Année de la session du TFJM²
|
||||
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
|
||||
@ -49,17 +51,19 @@ 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.
|
||||
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.
|
||||
|
||||
Une fois le site lancé, le premier compte créé sera un compte administrateur.
|
||||
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,75 +0,0 @@
|
||||
import os
|
||||
from urllib.request import urlretrieve
|
||||
from shutil import copyfile
|
||||
|
||||
from django.core.management import BaseCommand
|
||||
from django.utils import translation
|
||||
from member.models import Solution
|
||||
from tournament.models import Tournament
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
PROBLEMS = [
|
||||
'Création de puzzles',
|
||||
'Départ en vacances',
|
||||
'Un festin stratégique',
|
||||
'Sauver les meubles',
|
||||
'Prêt à décoller !',
|
||||
'Ils nous espionnent !',
|
||||
'De joyeux bûcherons',
|
||||
'Robots auto-réplicateurs',
|
||||
]
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('dir',
|
||||
type=str,
|
||||
default='.',
|
||||
help="Directory where solutions should be saved.")
|
||||
parser.add_argument('--language', '-l',
|
||||
type=str,
|
||||
choices=['en', 'fr'],
|
||||
default='fr',
|
||||
help="Language of the title of the files.")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
Copy solutions elsewhere.
|
||||
"""
|
||||
d = options['dir']
|
||||
teams_dir = d + '/Par équipe'
|
||||
os.makedirs(teams_dir, exist_ok=True)
|
||||
|
||||
translation.activate(options['language'])
|
||||
|
||||
copied = 0
|
||||
|
||||
for tournament in Tournament.objects.all():
|
||||
os.mkdir(teams_dir + '/' + tournament.name)
|
||||
for team in tournament.teams.filter(validation_status='2valid'):
|
||||
os.mkdir(teams_dir + '/' + tournament.name + '/' + str(team))
|
||||
for sol in tournament.solutions:
|
||||
if not os.path.isfile('media/' + sol.file.name):
|
||||
self.stdout.write(self.style.WARNING(("Warning: solution '{sol}' is not found. Maybe the file"
|
||||
"was deleted?").format(sol=str(sol))))
|
||||
continue
|
||||
copyfile('media/' + sol.file.name, teams_dir + '/' + tournament.name
|
||||
+ '/' + str(sol.team) + '/' + str(sol) + '.pdf')
|
||||
copied += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Successfully copied {copied} solutions!".format(copied=copied)))
|
||||
|
||||
os.mkdir(d + '/Par problème')
|
||||
|
||||
for pb in range(1, 9):
|
||||
sols = Solution.objects.filter(problem=pb).all()
|
||||
pbdir = d + '/Par problème/Problème n°{number} — {problem}'.format(number=pb, problem=self.PROBLEMS[pb - 1])
|
||||
os.mkdir(pbdir)
|
||||
for sol in sols:
|
||||
os.symlink('../../Par équipe/' + sol.tournament.name + '/' + str(sol.team) + '/' + str(sol) + '.pdf',
|
||||
pbdir + '/' + str(sol) + '.pdf')
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Symlinks by problem created!"))
|
||||
|
||||
urlretrieve('https://tfjm.org/wp-content/uploads/2020/01/Problemes2020_23_01_v1_1.pdf', d + '/Énoncés.pdf')
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Questions retrieved!"))
|
@ -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,292 +0,0 @@
|
||||
import random
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, AccessMixin
|
||||
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.decorators import method_decorator
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.decorators.debug import sensitive_post_parameters
|
||||
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, Pool
|
||||
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"
|
||||
|
||||
# When errors are reported from the signup view, don't send passwords to admins
|
||||
@method_decorator(sensitive_post_parameters('password1', 'password2',))
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy('index')
|
||||
|
||||
|
||||
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(AccessMixin, 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})
|
||||
|
||||
if request.user.is_authenticated:
|
||||
grant = request.user.admin
|
||||
|
||||
if isinstance(doc, Solution) or isinstance(doc, Synthesis):
|
||||
grant = grant or doc.team == request.user.team or request.user in doc.tournament.organizers.all()
|
||||
elif 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, 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
|
||||
elif isinstance(doc, Synthesis):
|
||||
for pool in request.user.pools.all(): # If the user is a jury in the pool
|
||||
if doc.team in pool.teams.all() and doc.final == pool.tournament.final:
|
||||
grant = True
|
||||
break
|
||||
else:
|
||||
pool = Pool.objects.filter(extra_access_token=self.request.session["extra_access_token"])
|
||||
if pool.exists():
|
||||
pool = pool.get()
|
||||
if isinstance(doc, Solution):
|
||||
grant = doc in pool.solutions.all()
|
||||
elif isinstance(doc, Synthesis):
|
||||
grant = doc.team in pool.teams.all() and doc.final == pool.tournament.final
|
||||
else:
|
||||
grant = False
|
||||
else:
|
||||
grant = False
|
||||
|
||||
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,432 +0,0 @@
|
||||
import os
|
||||
import random
|
||||
|
||||
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"),
|
||||
)
|
||||
|
||||
extra_access_token = models.CharField(
|
||||
max_length=64,
|
||||
default="",
|
||||
verbose_name=_("extra access token"),
|
||||
help_text=_("Let other users access to the pool data without logging in."),
|
||||
)
|
||||
|
||||
@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)
|
||||
|
||||
def save(self, **kwargs):
|
||||
if not self.extra_access_token:
|
||||
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
code = "".join(random.choice(alphabet) for _ in range(64))
|
||||
self.extra_access_token = code
|
||||
super().save(**kwargs)
|
||||
|
||||
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,662 +0,0 @@
|
||||
import random
|
||||
import zipfile
|
||||
from datetime import timedelta
|
||||
from io import BytesIO
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, AccessMixin
|
||||
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(AccessMixin):
|
||||
"""
|
||||
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 and not request.session["extra_access_token"]:
|
||||
return self.handle_no_permission()
|
||||
elif request.user.is_authenticated and 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('final', '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)
|
||||
|
||||
if self.request.user.is_authenticated:
|
||||
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 self.request.user.is_authenticated and 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))
|
||||
elif not self.request.user.is_authenticated:
|
||||
qs = qs.filter(pools__extra_access_token=self.request.session["extra_access_token"])
|
||||
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)
|
||||
|
||||
if self.request.user.is_authenticated:
|
||||
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 self.request.user.is_authenticated and 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))
|
||||
elif not self.request.user.is_authenticated:
|
||||
pool = Pool.objects.filter(extra_access_token=self.request.session["extra_access_token"])
|
||||
if pool.exists():
|
||||
pool = pool.get()
|
||||
qs = qs.filter(team__pools=pool, final=pool.tournament.final)
|
||||
else:
|
||||
qs = qs.none()
|
||||
return qs.order_by('final', 'team__tournament__date_start', 'team__tournament__name', 'team__trigram',
|
||||
'round', 'source',).distinct()
|
||||
|
||||
|
||||
class PoolListView(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 user.is_authenticated:
|
||||
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)
|
||||
else:
|
||||
qs = qs.filter(extra_access_token=self.request.session["extra_access_token"])
|
||||
qs = qs.distinct().order_by('id')
|
||||
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(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 user.is_authenticated:
|
||||
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)
|
||||
else:
|
||||
qs = qs.filter(extra_access_token=self.request.session["extra_access_token"])
|
||||
return qs.distinct()
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
user = request.user
|
||||
pool = self.get_object()
|
||||
|
||||
if "solutions_zip" in request.POST:
|
||||
if user.is_authenticated and 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 (not user.is_authenticated or user.organizes):
|
||||
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)
|
@ -1,113 +0,0 @@
|
||||
\documentclass[a4paper,french,11pt]{article}
|
||||
|
||||
\usepackage[T1]{fontenc}
|
||||
\usepackage[utf8]{inputenc}
|
||||
\usepackage{lmodern}
|
||||
\usepackage[frenchb]{babel}
|
||||
|
||||
\usepackage{fancyhdr}
|
||||
\usepackage{graphicx}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{amssymb}
|
||||
%\usepackage{anyfontsize}
|
||||
\usepackage{fancybox}
|
||||
\usepackage{eso-pic,graphicx}
|
||||
\usepackage{xcolor}
|
||||
|
||||
|
||||
% Specials
|
||||
\newcommand{\writingsep}{\vrule height 4ex width 0pt}
|
||||
|
||||
% Page formating
|
||||
\hoffset -1in
|
||||
\voffset -1in
|
||||
\textwidth 180 mm
|
||||
\textheight 250 mm
|
||||
\oddsidemargin 15mm
|
||||
\evensidemargin 15mm
|
||||
\pagestyle{fancy}
|
||||
|
||||
% Headers and footers
|
||||
\fancyfoot{}
|
||||
\lhead{}
|
||||
\rhead{}
|
||||
\renewcommand{\headrulewidth}{0pt}
|
||||
\lfoot{\footnotesize 11 rue Pierre et Marie Curie, 75231 Paris Cedex 05\\ Numéro siret 431 598 366 00018}
|
||||
\rfoot{\footnotesize Association agréée par\\le Ministère de l'éducation nationale.}
|
||||
|
||||
\begin{document}
|
||||
|
||||
\includegraphics[height=2cm]{assets/logo_animath.png}\hfill{\fontsize{55pt}{55pt}{$\mathbb{TFJM}^2$}}
|
||||
|
||||
\vfill
|
||||
|
||||
\begin{center}
|
||||
|
||||
|
||||
\LARGE
|
||||
Autorisation d'enregistrement et de diffusion de l'image ({TOURNAMENT_NAME})
|
||||
\end{center}
|
||||
\normalsize
|
||||
|
||||
|
||||
\thispagestyle{empty}
|
||||
|
||||
\bigskip
|
||||
|
||||
|
||||
|
||||
Je soussign\'e {PARTICIPANT_NAME}\\
|
||||
demeurant au {ADDRESS}
|
||||
|
||||
\medskip
|
||||
Cochez la/les cases correspondantes.\\
|
||||
\medskip
|
||||
|
||||
\fbox{\textcolor{white}{A}} Autorise l'association Animath, \`a l'occasion du $\mathbb{TFJM}^2$ du {START_DATE} au {END_DATE} {YEAR} à : {PLACE}, \`a me photographier ou \`a me filmer et \`a diffuser les photos et/ou les vid\'eos r\'ealis\'ees \`a cette occasion sur son site et sur les sites partenaires. D\'eclare c\'eder \`a titre gracieux \`a Animath le droit d’utiliser mon image sur tous ses supports d'information : brochures, sites web, r\'eseaux sociaux. Animath devient, par la pr\'esente, cessionnaire des droits pendant toute la dur\'ee pour laquelle ont \'et\'e acquis les droits d'auteur de ces photographies.\\
|
||||
|
||||
\medskip
|
||||
Animath s'engage, conform\'ement aux dispositions l\'egales en vigueur relatives au droit \`a l'image, \`a ce que la publication et la diffusion de l'image ainsi que des commentaires l'accompagnant ne portent pas atteinte \`a la vie priv\'ee, \`a la dignit\'e et \`a la r\'eputation de la personne photographiée.\\
|
||||
|
||||
\medskip
|
||||
\fbox{\textcolor{white}{A}} Autorise la diffusion dans les medias (Presse, T\'el\'evision, Internet) de photographies prises \`a l'occasion d’une \'eventuelle m\'ediatisation de cet événement.\\
|
||||
|
||||
\medskip
|
||||
|
||||
Conform\'ement \`a la loi informatique et libert\'es du 6 janvier 1978, vous disposez d'un droit de libre acc\`es, de rectification, de modification et de suppression des donn\'ees qui vous concernent.
|
||||
Cette autorisation est donc r\'evocable \`a tout moment sur volont\'e express\'ement manifest\'ee par lettre recommand\'ee avec accus\'e de r\'eception adress\'ee \`a Animath, IHP, 11 rue Pierre et Marie Curie, 75231 Paris cedex 05.\\
|
||||
|
||||
\medskip
|
||||
\fbox{\textcolor{white}{A}} Autorise Animath à conserver mes données personnelles, dans le cadre défini par la loi n 78-17 du 6 janvier 1978 relative à l'informatique, aux fichiers et aux libertés et les textes la modifiant, pendant une durée de quatre ans à compter de ma dernière participation à un événement organisé par Animath.\\
|
||||
|
||||
\medskip
|
||||
\fbox{\textcolor{white}{A}} J'accepte d'être tenu informé d'autres activités organisées par l'association et ses partenaires.
|
||||
|
||||
\bigskip
|
||||
|
||||
Signature pr\'ec\'ed\'ee de la mention \og lu et approuv\'e \fg{}
|
||||
|
||||
\medskip
|
||||
|
||||
|
||||
|
||||
\begin{minipage}[c]{0.5\textwidth}
|
||||
|
||||
\underline{L'\'el\`eve :}\\
|
||||
|
||||
Fait \`a :\\
|
||||
le
|
||||
\end{minipage}
|
||||
|
||||
|
||||
\vfill
|
||||
\vfill
|
||||
\begin{minipage}[c]{0.5\textwidth}
|
||||
\footnotesize 11 rue Pierre et Marie Curie, 75231 Paris Cedex 05\\ Numéro siret 431 598 366 00018
|
||||
\end{minipage}
|
||||
\begin{minipage}[c]{0.5\textwidth}
|
||||
\footnotesize
|
||||
\begin{flushright}
|
||||
Association agréée par\\le Ministère de l'éducation nationale.
|
||||
\end{flushright}
|
||||
\end{minipage}
|
||||
\end{document}
|
@ -1,66 +0,0 @@
|
||||
\documentclass[a4paper,french,11pt]{article}
|
||||
|
||||
\usepackage[T1]{fontenc}
|
||||
\usepackage[utf8]{inputenc}
|
||||
\usepackage{lmodern}
|
||||
\usepackage[french]{babel}
|
||||
|
||||
\usepackage{fancyhdr}
|
||||
\usepackage{graphicx}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{amssymb}
|
||||
%\usepackage{anyfontsize}
|
||||
\usepackage{fancybox}
|
||||
\usepackage{eso-pic,graphicx}
|
||||
\usepackage{xcolor}
|
||||
|
||||
|
||||
% Specials
|
||||
\newcommand{\writingsep}{\vrule height 4ex width 0pt}
|
||||
|
||||
% Page formating
|
||||
\hoffset -1in
|
||||
\voffset -1in
|
||||
\textwidth 180 mm
|
||||
\textheight 250 mm
|
||||
\oddsidemargin 15mm
|
||||
\evensidemargin 15mm
|
||||
\pagestyle{fancy}
|
||||
|
||||
% Headers and footers
|
||||
\fancyfoot{}
|
||||
\lhead{}
|
||||
\rhead{}
|
||||
\renewcommand{\headrulewidth}{0pt}
|
||||
\lfoot{\footnotesize 11 rue Pierre et Marie Curie, 75231 Paris Cedex 05\\ Numéro siret 431 598 366 00018}
|
||||
\rfoot{\footnotesize Association agréée par\\le Ministère de l'éducation nationale.}
|
||||
|
||||
\begin{document}
|
||||
|
||||
\includegraphics[height=2cm]{assets/logo_animath.png}\hfill{\fontsize{55pt}{55pt}{$\mathbb{TFJM}^2$}}
|
||||
|
||||
\vfill
|
||||
|
||||
\begin{center}
|
||||
\Large \bf Autorisation parentale pour les mineurs ({TOURNAMENT_NAME})
|
||||
\end{center}
|
||||
|
||||
Je soussigné(e) \hrulefill,\\
|
||||
responsable légal, demeurant \writingsep\hrulefill\\
|
||||
\writingsep\hrulefill,\\
|
||||
\writingsep autorise {PARTICIPANT_NAME},\\
|
||||
né(e) le {BIRTHDAY},
|
||||
à participer au Tournoi Français des Jeunes Mathématiciennes et Mathématiciens ($\mathbb{TFJM}^2$) organisé \`a : {PLACE}, du {START_DATE} au {END_DATE} {YEAR}.
|
||||
|
||||
{PRONOUN} se rendra au lieu indiqu\'e ci-dessus le vendredi matin et quittera les lieux l'après-midi du dimanche par ses propres moyens et sous la responsabilité du représentant légal.
|
||||
|
||||
|
||||
|
||||
\vspace{8ex}
|
||||
|
||||
Fait à \vrule width 10cm height 0pt depth 0.4pt, le \phantom{232323}/\phantom{XXX}/{YEAR},
|
||||
|
||||
\vfill
|
||||
\vfill
|
||||
|
||||
\end{document}
|
Before Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 104 KiB |
@ -1,47 +0,0 @@
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--navbar-height: 32px;
|
||||
}
|
||||
|
||||
.container {
|
||||
min-height: 78%;
|
||||
}
|
||||
|
||||
.inner {
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
|
||||
footer .alert {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#navbar-logo {
|
||||
height: var(--navbar-height);
|
||||
display: block;
|
||||
}
|
||||
|
||||
ul .deroule {
|
||||
display: none;
|
||||
position: absolute;
|
||||
background: #f8f9fa !important;
|
||||
list-style-type: none;
|
||||
padding: 20px;
|
||||
z-index: 42;
|
||||
}
|
||||
|
||||
li:hover ul.deroule {
|
||||
display:block;
|
||||
}
|
||||
|
||||
a.nav-link:hover {
|
||||
background-color: #d8d9da;
|
||||
}
|
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']})
|
166
chat/management/commands/create_chat_channels.py
Normal file
@ -0,0 +1,166 @@
|
||||
# Copyright (C) 2024 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
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('fr')
|
||||
|
||||
# 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,
|
||||
),
|
||||
)
|
29
chat/static/tfjm/chat.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
|
||||
})
|
21
chat/templates/chat/chat.html
Normal file
@ -0,0 +1,21 @@
|
||||
{% 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 #}
|
||||
<link rel="manifest" href="{% static "tfjm/chat.webmanifest" %}">
|
||||
{% 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>
|
35
chat/templates/chat/fullscreen.html
Normal file
@ -0,0 +1,35 @@
|
||||
{% 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 "TFJM² Chat" %}
|
||||
</title>
|
||||
<meta name="description" content="{% trans "TFJM² Chat" %}">
|
||||
|
||||
{# Favicon #}
|
||||
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
{# Bootstrap + Font Awesome CSS #}
|
||||
{% stylesheet 'bootstrap_fontawesome' %}
|
||||
|
||||
{# Bootstrap JavaScript #}
|
||||
{% javascript 'bootstrap' %}
|
||||
|
||||
{# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #}
|
||||
<link rel="manifest" href="{% static "tfjm/chat.webmanifest" %}">
|
||||
</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>
|
36
chat/templates/chat/login.html
Normal file
@ -0,0 +1,36 @@
|
||||
{% 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 "TFJM² Chat" %} - {% trans "Log in" %}
|
||||
</title>
|
||||
<meta name="description" content="{% trans "TFJM² Chat" %}">
|
||||
|
||||
{# Favicon #}
|
||||
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
{# Bootstrap CSS #}
|
||||
{% stylesheet 'bootstrap_fontawesome' %}
|
||||
|
||||
{# Bootstrap JavaScript #}
|
||||
{% javascript 'bootstrap' %}
|
||||
|
||||
{# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #}
|
||||
<link rel="manifest" href="{% static "tfjm/chat.webmanifest" %}">
|
||||
</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'),
|
||||
]
|
@ -1,92 +0,0 @@
|
||||
<?php
|
||||
|
||||
require_once "server_files/config.php";
|
||||
|
||||
require_once "server_files/classes/Document.php";
|
||||
require_once "server_files/classes/Role.php";
|
||||
require_once "server_files/classes/SchoolClass.php";
|
||||
require_once "server_files/classes/Team.php";
|
||||
require_once "server_files/classes/Tournament.php";
|
||||
require_once "server_files/classes/User.php";
|
||||
require_once "server_files/classes/ValidationStatus.php";
|
||||
require_once "server_files/classes/PaymentMethod.php";
|
||||
require_once "server_files/classes/Payment.php";
|
||||
require_once "server_files/services/mail.php";
|
||||
require_once "server_files/utils.php";
|
||||
require_once "server_files/model.php";
|
||||
|
||||
loadUserValues();
|
||||
|
||||
if (!isset($_GET["path"]))
|
||||
require_once "server_files/403.php";
|
||||
|
||||
$path = $_GET["path"];
|
||||
|
||||
$ROUTES = [];
|
||||
|
||||
# URL paths
|
||||
|
||||
$ROUTES["^(|accueil|index|accueil\.php|accueil\.html|accueil\.py|index\.php|index\.html|index\.py)$"] = ["server_files/controllers/index.php"];
|
||||
$ROUTES["^ajouter_equipe$"] = ["server_files/controllers/ajouter_equipe.php"];
|
||||
$ROUTES["^ajouter_organisateur$"] = ["server_files/controllers/ajouter_organisateur.php"];
|
||||
$ROUTES["^ajouter_tournoi$"] = ["server_files/controllers/ajouter_tournoi.php"];
|
||||
$ROUTES["^confirmer_mail/([a-z0-9]*)/?$"] = ["server_files/controllers/confirmer_mail.php", "token"];
|
||||
$ROUTES["^connexion/(confirmation-mail)/?$"] = ["server_files/controllers/connexion.php", "confirmation-mail"];
|
||||
$ROUTES["^connexion/(mdp-oublie)/?$"] = ["server_files/controllers/connexion.php", "mdp_oublie"];
|
||||
$ROUTES["^connexion/(reinitialiser_mdp)/(.*)/?$"] = ["server_files/controllers/connexion.php", "reset_password", "token"];
|
||||
$ROUTES["^connexion/?$"] = ["server_files/controllers/connexion.php"];
|
||||
$ROUTES["^deconnexion/?$"] = ["server_files/controllers/deconnexion.php"];
|
||||
$ROUTES["^equipe/([A-Z]{3})/?$"] = ["server_files/controllers/equipe.php", "trigram"];
|
||||
$ROUTES["^equipe/([A-Z]{3})/(modifier)?$"] = ["server_files/controllers/equipe.php", "trigram", "modifier"];
|
||||
$ROUTES["^file/([a-z0-9]{64})/?$"] = ["server_files/controllers/view_file.php", "file_id"];
|
||||
$ROUTES["^informations/([0-9]*)/.*?$"] = ["server_files/controllers/informations.php", "id"];
|
||||
$ROUTES["^inscription/?$"] = ["server_files/controllers/inscription.php"];
|
||||
$ROUTES["^(modifier-page)$"] = ["server_files/controllers/index.php", "edit"];
|
||||
$ROUTES["^mon-compte/?$"] = ["server_files/controllers/mon_compte.php"];
|
||||
$ROUTES["^mon-equipe/(modifier)/?$"] = ["server_files/controllers/mon_equipe.php", "modifier"];
|
||||
$ROUTES["^mon-equipe/?$"] = ["server_files/controllers/mon_equipe.php"];
|
||||
$ROUTES["^organisateurs/?$"] = ["server_files/controllers/organisateurs.php"];
|
||||
$ROUTES["^paiement/?$"] = ["server_files/controllers/paiement.php"];
|
||||
$ROUTES["^profils/?$"] = ["server_files/controllers/profils.php"];
|
||||
$ROUTES["^profils-(orphelins)/?$"] = ["server_files/controllers/profils.php", "orphans"];
|
||||
$ROUTES["^rejoindre_equipe/?$"] = ["server_files/controllers/rejoindre_equipe.php"];
|
||||
$ROUTES["^solutions/?$"] = ["server_files/controllers/solutions.php"];
|
||||
$ROUTES["^solutions_orga/?$"] = ["server_files/controllers/solutions_orga.php"];
|
||||
$ROUTES["^syntheses/?$"] = ["server_files/controllers/syntheses.php"];
|
||||
$ROUTES["^syntheses_orga/?$"] = ["server_files/controllers/syntheses_orga.php"];
|
||||
$ROUTES["^tournoi/(.*)/(modifier)/?$"] = ["server_files/controllers/tournoi.php", "name", "modifier"];
|
||||
$ROUTES["^tournoi/(.*)/?$"] = ["server_files/controllers/tournoi.php", "name"];
|
||||
$ROUTES["^tournois/?$"] = ["server_files/controllers/tournois.php"];
|
||||
|
||||
$ROUTES["^Autorisation de droit à l'image.pdf$"] = ["server_files/controllers/autorisation_droit_image.php"];
|
||||
$ROUTES["^Autorisation parentale.pdf$"] = ["server_files/controllers/autorisation_parentale.php"];
|
||||
$ROUTES["^Instructions.pdf$"] = ["server_files/controllers/instructions.php"];
|
||||
|
||||
# Assets files
|
||||
|
||||
$ROUTES["^favicon\.ico$"] = ["assets/favicon.ico", "image/x-icon"];
|
||||
$ROUTES["^Fiche sanitaire\.pdf$"] = ["assets/Fiche_sanitaire.pdf", "application/pdf"];
|
||||
$ROUTES["^Note de synthèse.pdf$"] = ["assets/Fiche synthèse.pdf", "application/pdf"];
|
||||
$ROUTES["^Note de synthèse.tex"] = ["assets/Fiche synthèse.tex", "text/plain"];
|
||||
$ROUTES["^logo\.svg$"] = ["assets/logo.svg", "image/svg+xml"];
|
||||
$ROUTES["^style\.css$"] = ["assets/style.css", "text/css"];
|
||||
|
||||
foreach ($ROUTES as $route => $file) {
|
||||
if (preg_match('#' . $route . '#', $path, $matches)) {
|
||||
for ($i = 1; $i < sizeof($file); ++$i)
|
||||
$_GET[$file[$i]] = $matches[$i];
|
||||
|
||||
if (!preg_match("#php$#", $file[0])) {
|
||||
header("Content-Type: " . $file[1]);
|
||||
readfile($file[0]);
|
||||
exit();
|
||||
}
|
||||
|
||||
$view = $file[0];
|
||||
/** @noinspection PhpIncludeInspection */
|
||||
require $view;
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
require_once "server_files/404.php";
|
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 |