1
0
mirror of https://gitlab.com/animath/si/plateforme.git synced 2025-07-01 10:41:17 +02:00

Compare commits

..

895 Commits

Author SHA1 Message Date
905b96fbcf Translate GDPR warning 2025-01-15 13:35:41 +01:00
be2e258948 Correction ETEAM => TFJM² 2025-01-15 13:31:05 +01:00
882570800c Revert "Update 2 files"
This reverts commit 1977ffdbc9.
2025-01-15 13:30:06 +01:00
df31968a77 Revert "Update 2 files"
This reverts commit 7c83ae8730.
2025-01-15 13:29:17 +01:00
df6fb3b3f3 Drop support of Python 3.11 2025-01-14 20:21:57 +01:00
3807fbcf45 Linting 2025-01-14 20:16:04 +01:00
8433390e19 Update authorization templates for unified registration 2025-01-14 20:14:49 +01:00
ec85f62ab6 Add unified registration for Île-de-France 2025-01-14 19:32:05 +01:00
74b2a0c095 Restauration des mails du TFJM²
This reverts commit 21d4ac9d8d.
2025-01-14 18:20:03 +01:00
67958335ab Fix year transitioning documentation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-29 00:17:58 +01:00
20410cc17f Fix photo authorization export
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 23:53:44 +01:00
a5aff5ff21 Fix default storage
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 23:45:36 +01:00
196dbc8275 Delay registration opening by one week
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 23:42:59 +01:00
0847e5a308 Update Staticfiles storage for Django 5
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 23:40:13 +01:00
e5aa3ef059 Fix logo path
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 23:15:35 +01:00
e1b4e1bb6b Fix psycopg
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 22:57:40 +01:00
ecc59a6c8c Add documentation for year transitioning
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 22:24:11 +01:00
b053a47a19 Add export photo authorizations script
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 21:33:58 +01:00
ab2e49e8fb Add tests for registration ability outer registration dates
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 21:11:35 +01:00
fe399c869d Prevent registration when we are not between registration dates
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 20:21:02 +01:00
9de8a2ed0e Store registration dates
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 19:39:31 +01:00
d24f8cab16 Fix API router with newer version
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-21 19:40:19 +02:00
6cdf6331db Upgrade dependencies + add support for Python 3.13 and Django 5.1
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-21 19:36:08 +02:00
65c6158b52 TFJM² has not a single tournament
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-21 19:20:46 +02:00
4a5f48a834 Fix single tournament render
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-21 19:17:03 +02:00
4ab706d219 Fix TFJM_settings dictionary
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-21 19:09:24 +02:00
70f2be8b17 Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-20 20:15:29 +02:00
4317947501 More ETEAM parametrization
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-20 20:13:49 +02:00
f327a4c9c4 Patch observer oral min note
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-11 10:27:52 +02:00
1b24e90635 Fix team reorder for 5-teams pools in draw recap
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-09 13:51:50 +02:00
338f0d456a Fix undo draw step
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-09 13:47:59 +02:00
2c4de8cec3 Adapt the random draw for the next rounds of ETEAM
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-09 13:26:39 +02:00
6b7d52c79b Fix the passage table with observers
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-09 12:44:04 +02:00
f398bedcf3 Fix upload review URL
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-07 08:53:40 +02:00
fdffe2331f Better notation sheets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-07 00:01:24 +02:00
42425c392d Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-06 23:31:37 +02:00
18f3ce4023 Update scaling sheets for ETEAM
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-06 23:30:17 +02:00
620bbe7817 Defender => Reporter
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-06 22:12:07 +02:00
12205f953b Rename synthesis to written review
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-06 21:29:16 +02:00
696863f6c3 Translate written reviews templates
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-06 20:52:43 +02:00
748720df50 Fix GSheet update
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-06 16:21:44 +02:00
40db20a471 Fix buttons for third round
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-06 10:16:54 +02:00
2e99b3ea8e Fix GSheet parser
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-06 10:08:38 +02:00
9721898731 Fix GSheet column width
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-06 10:03:27 +02:00
5c3b3d26c8 Fix GSheet translated texts
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-06 09:41:41 +02:00
d13ae89267 Update GSheets for ETEAM
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-05 16:48:17 +02:00
44302a9ff4 Fix permission to access passage detail for an observer team
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-05 12:50:28 +02:00
8b3f3af2b9 Fix tests
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-05 12:48:47 +02:00
dd397ae7c0 Fix string
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-05 12:02:40 +02:00
3f2a757414 Allow observers to access solutions
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-05 12:02:08 +02:00
d20d5f6266 Fix CSV export
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-05 11:50:13 +02:00
05a6570bed Add observer team
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-05 11:47:19 +02:00
2a298a3ee4 Reporter -> reviewer
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-05 11:00:11 +02:00
05c6333c5e Translate draw messages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-05 10:41:48 +02:00
d84db949c6 Fix trigram validation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-13 11:03:10 +02:00
2627b3a9b8 Add migrations for ETEAM
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-13 10:57:51 +02:00
2c8f6f22f2 Set home title
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-08 00:51:39 +02:00
e258e6a337 Fix ETEAM name
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-08 00:46:56 +02:00
-
109748ffc6 Update index_eteam.html 2024-06-07 22:37:02 +00:00
-
4201a2dbe6 Update file tournament_detail.html 2024-06-07 22:32:19 +00:00
17c7d0ccc3 More specific code to ETEAM
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-08 00:23:44 +02:00
dd45f77a5e Fix draw
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 23:47:05 +02:00
eacebf1aa6 Fix Texlive packages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 23:46:51 +02:00
-
21d4ac9d8d Update 12 files
- /registration/templates/registration/mails/final_selection.html
- /registration/templates/registration/mails/final_selection.txt
- /registration/templates/registration/mails/payment_confirmation.txt
- /registration/templates/registration/mails/payment_confirmation.html
- /registration/templates/registration/mails/payment_reminder.txt
- /registration/templates/registration/mails/payment_reminder.html
- /participation/templates/participation/mails/team_not_validated.txt
- /participation/templates/participation/mails/team_validated.txt
- /participation/templates/participation/mails/team_validated.html
- /participation/templates/participation/mails/team_not_validated.html
- /participation/templates/participation/mails/request_validation.txt
- /participation/templates/participation/mails/request_validation.html
2024-06-07 20:20:36 +00:00
-
7c83ae8730 Update 2 files
- /registration/templates/registration/mails/add_organizer.html
- /registration/templates/registration/mails/add_organizer.txt
2024-06-07 17:42:27 +00:00
-
1977ffdbc9 Update 2 files
- /registration/templates/registration/mails/email_validation_email.html
- /registration/templates/registration/mails/email_validation_email.txt
2024-06-07 17:32:37 +00:00
a0a282df15 Fix Texlive packages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 18:53:28 +02:00
603ee76664 Allow to remove the checkbox to be recontacted by Animath
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 18:42:02 +02:00
147cbff7f5 Allow to remove the checkbox to be recontacted by Animath
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 18:39:16 +02:00
8878ae8d8d Install texmf-dist-fontsextra in Docker
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 18:14:13 +02:00
4c8347072c Fix ETEAM logo path
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 18:13:44 +02:00
73ea3d1717 Auto select the single tournament for ETEAM
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 17:24:24 +02:00
e026f49f8d Add parental and photo authorizations + make health and vaccine sheet and motivation letter optional
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 17:20:06 +02:00
ea03bd314b Fix tests with new stuff
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 16:39:43 +02:00
c12972b718 Make Sympa + payment support optional
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 16:35:08 +02:00
2a775cedc1 Don't minify what is already minified
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 16:21:18 +02:00
9bf3b7dff0 Fix permission to see solutions when they are available
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 16:16:11 +02:00
cf92c78d03 Store round dates
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 16:03:42 +02:00
38ceef7a54 Adapt platform to have 3 rounds (untested)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 15:56:43 +02:00
ec2fa43e20 Add single tournament mode
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 15:18:59 +02:00
85b3da09f6 Add country field in registration
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 14:52:09 +02:00
2c15774185 Fix DNS authorization
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 14:36:05 +02:00
08ad4f3888 First ETEAM adjustments
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 14:25:52 +02:00
872009894d New index page for ETEAM
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 14:25:51 +02:00
fd7fe90fce Translate index page
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 14:25:51 +02:00
2ad538f5cc Fix tests after moving static files
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-07 14:25:37 +02:00
5e2add90a8 Minify CSS and JavaScript files
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-06-02 19:47:35 +02:00
635606eb13 Add inscriptions.tfjm.org as valid DNS
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-05-29 23:35:44 +02:00
b828631106 Add french comments on chat application
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-05-26 22:08:34 +02:00
8216e0943f Don't display final selection in the final tournament page
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-05-20 16:06:40 +02:00
1138885fb4 Fix TFJM sympa lists every day instead of every two minutes
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-05-19 21:18:58 +02:00
a43dc9c12a Fix total score in tfjm.org export for 5-teams pools
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-05-19 21:09:34 +02:00
70050827d8 Better bold lines in tfjm.org export
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-05-19 21:02:44 +02:00
f687deed14 Fix bold lines in tfjm.org export
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-05-19 20:55:47 +02:00
7a0341e7cf Display mention on tfjm.org page
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-05-19 20:40:35 +02:00
0129e32643 Messages in team validation mails now contains line breaks
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-30 20:29:52 +02:00
64a2ea007e Add basic Markdown rules for the chat
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-30 20:20:10 +02:00
531eecf4b8 Make consistent the right alignment and the column structure
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 19:51:52 +02:00
bd416318ac Fix unread messages count
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:35 +02:00
90bec6bf5e Remove debug code
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:34 +02:00
ed5944e044 Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:34 +02:00
a41c17576f Store last visited channel in local storage
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:34 +02:00
80456f4da8 Add sort by unread messages option
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:34 +02:00
1a641cb2d7 Store what messages are read
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:34 +02:00
8f3929875f Improve context menus
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:34 +02:00
f26f102650 Automatically create appropriated channels when tournaments/pools/participations are updated
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:34 +02:00
1e5d0ebcfc Editing and deleting is working
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:33 +02:00
0cab21f344 Users can only edit & delete their own messages (except for admin users)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:33 +02:00
a771710094 Add popovers to edit and delete messages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:33 +02:00
3b3dcff28b Only give the focus to a private channel if it wasn't previously created
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:33 +02:00
d6aa5eb0cc Manage private chats
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:33 +02:00
c6b9a84def Reset retry delay to 1 second when a connection has succeeded
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:33 +02:00
675f19492c Extend session cookie age from 3 hours to 2 weeks
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:32 +02:00
a5c210e9b6 Add script to create channels per tournament, pools and teams. Put channels in categories
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:32 +02:00
784002c085 Open channels list by swiping
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:32 +02:00
e77cc558de Add specific login and logout pages for chat
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:32 +02:00
7bb0f78f34 Improve mobile chat
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:32 +02:00
bfd1a76a2d Notifications use the PNG logo
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:32 +02:00
b86dfe7351 Automatically scroll to bottom
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:32 +02:00
d36e97fa2e Chat is restricted to authenticated users
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:31 +02:00
181bb86e49 Simplify chat views
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:31 +02:00
a121d1042b Add feature to install chat on the home screen
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:31 +02:00
2d706b2b81 Add fullscreen mode for chat
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:31 +02:00
ca91842c2d Fill channel selector using JavaScript
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:31 +02:00
d617dd77c1 Properly sort messages and add fetch previous messages ability
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:31 +02:00
d59bb75dce Fetching last messages is working
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:30 +02:00
4a78e80399 Send messages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:30 +02:00
f3a4a99b78 Setup chat UI
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:30 +02:00
46fc5f39c8 Allow to impersonate user on draw interface
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:30 +02:00
b464e7df1d Manage channels permissions
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:30 +02:00
7498677bbd Permissions are strings, not integers
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:30 +02:00
ea8007aa07 Initialize chat interface
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:29 +02:00
d9bb0a0860 Prepare models for new chat feature
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:29 +02:00
a594b268ea Fix permission to download all authorizations of a tournament
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-25 12:42:37 +02:00
0bc5ef0a7f Add debug feature for problem draw, useful for final tournament
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-22 23:36:52 +02:00
943276ef71 Round is an integer
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-21 07:46:20 +02:00
13c815c62c Allow to parse empty mentions
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-20 21:43:37 +02:00
35e3be8af3 Fix one translation activation before parsing notes
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-20 21:38:33 +02:00
720de380d1 Tweaks are done in the pool of the first room
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-20 21:37:37 +02:00
ecf80f8b81 Use french translation when submitting notes to Google Sheets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-20 16:16:50 +02:00
3ca0148934 Update information about draw with the 2024 changes
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-19 19:02:11 +02:00
58608ea5ff Add red background if the defender has at least one penalty
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-19 18:51:13 +02:00
68da61a33b Fix script that generates data for second teams when there are 5 teams in the pool
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-19 18:38:19 +02:00
86e978faf2 Don't display ranking in notation ODS when there are 5 teams
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-19 18:30:59 +02:00
0845d0bfb6 Since a notation sheet has at most 4 passages, reduce the number of columns to 26
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-18 15:41:27 +02:00
f457a2355e Display scores of all teams in a 5-teams pool
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-18 15:22:59 +02:00
bacdd5cfcf Replace pool name by its short name in severous views
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-18 15:12:45 +02:00
3e24e10780 Fix information display for participants in 5-teams pools
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-18 15:07:15 +02:00
adc4634f3e Better pool view for 5-teams pools
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-18 15:05:10 +02:00
266afaf5c9 Split 5-teams pols in two pools for each room
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-18 14:53:58 +02:00
059cae75c5 Fix notation sheets when we change the order of pools
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-17 22:07:47 +02:00
91a1837c99 Fix 5-teams pools passages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-17 21:56:46 +02:00
b24201c529 Rapporteure => Rapportrice
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-17 00:58:56 +02:00
53302db56a Display mentions only after the reveal of the notes of the second round
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-17 00:43:42 +02:00
49fda3df49 Add mentions
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-17 00:38:18 +02:00
3a0a98a331 Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-17 00:02:48 +02:00
21c4d5d7f5 Exchange first and last teams if there is only one pool (event if there are only 3 or 4 teams)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-17 00:02:02 +02:00
338a19ec32 Remove observer status
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-16 23:59:18 +02:00
5bfcaab831 Fix scale for reporter
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>

Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-16 13:21:42 +02:00
49e5d97ec9 Generate spreadsheet with all teams at the second place
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-14 09:17:34 +02:00
0e185f5046 Add trigrams in column headers in Google Sheets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-13 20:15:07 +02:00
ab7cdd56cc Update scale in passage detail view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-13 20:08:47 +02:00
7edd43f626 Rapporteure => Rapportrice
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-13 12:48:13 +02:00
aca23eaf8b Fix under 18 calculus
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-09 17:45:41 +02:00
a02697a3a7 Use local time for channel ids
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-08 00:03:10 +02:00
d3d72e090c Fix tournament detail view for anonymous users
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 18:31:59 +02:00
6c76f1e633 Fix final selection
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 18:30:06 +02:00
4a094002f0 Fix under_18 calculus for students that are born on the February 29th
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 16:42:05 +02:00
3045857897 There is no fixture to load
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 13:46:03 +02:00
7a0b93b151 Send email after team final selection
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 13:39:44 +02:00
7073f64aa6 Duplicate solutions from regional tournament to final tournament after selection
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 12:54:16 +02:00
b4fc976197 Display informations about the final tournament in the sidebar
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 12:38:41 +02:00
7a004596ca Only display final selection after publishing results
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 12:09:31 +02:00
1493df0078 Implement final selection
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 11:41:14 +02:00
7732a737bb Use local date for GDrive channel ids
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 09:39:17 +02:00
b942baea17 Support ODS and CSV formats to read notes from a spreadsheet
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 09:34:52 +02:00
188b83ce2d Fix tournament prefetch related in GSheet notifications
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 00:21:20 +02:00
29d9432ca2 Order passages by position rather than id
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 23:34:06 +02:00
0181a1392d Guess the CSV delimiter when uploading a notation sheet
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 23:08:35 +02:00
ec0419a6d7 Fix expected GDrive channel ID
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 22:43:48 +02:00
54016a1fbf Remove test code
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 22:37:33 +02:00
7ae015cef9 Reject unauthenticated users + exponential wait time
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 22:31:52 +02:00
ea264fbca6 Reject unauthenticated users + exponential wait time
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 22:25:58 +02:00
758f714096 Add supportAllDrives=true parameter to GDrive notifications
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 22:18:22 +02:00
40d24740ed Fix import orders
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 22:05:48 +02:00
b7344566ef Only accept GDrive notifications if the content was updated
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 22:04:55 +02:00
0f5d0c8b40 Add try/catch in Google Sheets scripts
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 21:57:34 +02:00
c45071c038 Add notifications from Google Drive to automatically get updates from Google Sheets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 21:55:46 +02:00
aac4fc59e6 Fix parsing tweaks
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 19:16:32 +02:00
78a43148a8 Fetch registrations by user id
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 19:12:10 +02:00
ceedd0678c Sleep more in parsing notation sheets to avoid reaching the API limit
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 10:49:19 +02:00
d13385fa01 Don't set notes if there isn't anyone
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 10:42:55 +02:00
8996fc2cca Fix updating Google Spreadsheet after uploading CSV
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 10:39:08 +02:00
65dcc978c1 Don't parse spreadsheet if there is no spreadsheet
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 10:38:09 +02:00
923b07b97e Reduce delay to update the left bar to only 2 hours
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-03 23:34:59 +02:00
84860a2875 Add syntheses templates in information bar
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-03 23:32:01 +02:00
6add9a1419 Add links to solutions also for second round
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-03 23:21:23 +02:00
eddb741eb7 Important information are not only displayed to organizers
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-03 23:17:24 +02:00
a763abf781 Add direct links to the opponent and reporter solutions
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-03 23:14:59 +02:00
78e8a92c3a Fix solution link
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-03 23:06:11 +02:00
424dee4aea Fix solution path name
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-03 22:56:45 +02:00
a381b5583c Fix permissions for solutions and syntheses
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-03 22:23:36 +02:00
867ee7efe1 Fix passage view for participants
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-03 22:22:16 +02:00
32b2d7239c Fix important information for participants
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-03 22:19:09 +02:00
6ce179bd60 Fix important information for volunteers
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-01 19:01:17 +02:00
dba937fb03 Administrateurs => Administrateur⋅rices
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-01 18:59:25 +02:00
4efce6e325 Display datetimes with local timezone
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 22:46:40 +02:00
10a42d3633 Only harmonize valid participations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 22:12:54 +02:00
bb579d640c Add buttons to hide notes from public if needed
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 22:11:01 +02:00
d7b4233282 Rapporteure -> Rapportrice
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 21:47:14 +02:00
9092cf1846 Improve edit buttons
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 21:36:09 +02:00
37b86d4ea0 Better download link to the ODS file
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 21:23:57 +02:00
40988348d3 Upload notes to Google Sheets after uploading a CSV file
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 20:59:00 +02:00
1cbf95e6e1 Display at least our notes in the notes table
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 20:56:49 +02:00
c4ec6a6f29 Don't delete extra jury lines on Google Sheets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 15:34:21 +02:00
779aec5e55 Don't use Google Sheets in tests (for now)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 15:30:17 +02:00
bf5c673739 Update the final ranking page after the draw export
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 13:48:01 +02:00
a62e906b0e Hide draw export button sooner to avoid that double exports
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 13:45:32 +02:00
630633bab4 Teams may not beeing in a pool of the second round (for example, for the final tournament)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 13:42:34 +02:00
8d7d7cd645 Create Google Sheets after the draw
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 13:38:20 +02:00
e53575d31d Remove "Add passage" and "Udate pool teams" forms since they can lead to unwanted states. Pool teams and passages are managed by the draw system. If needed, use the admin interface
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 13:30:19 +02:00
412ff4e067 Update juries lines in Google Sheet after a pool update (not on every save)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 13:23:58 +02:00
29b01ebb13 Fix information for juries
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 22:27:38 +01:00
30b9a73df8 Allow pools to be already created, fetch them after the draw if necessary
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 22:25:08 +01:00
572a6c3299 Add information to teams and juries about pools
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 22:23:34 +01:00
c135da1f47 Share notation sheet with anyone that has the link
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 20:49:56 +01:00
6867c2cc2d Fix tests
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 20:43:04 +01:00
1e7bd209a1 Add harmonization view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 20:38:13 +01:00
109b603b7a Update Font Awesome
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 19:28:45 +01:00
6595409df0 Add Google Sheets link on tournament and pool pages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 19:15:21 +01:00
f1012efcaa Consider tweaks in notation sheet
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 18:57:05 +01:00
5261a52401 Add final ranking sheet
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 18:28:54 +01:00
a914237f66 Display only one decimal in Google Sheet
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 17:23:31 +01:00
2019c5c434 Validate note bounds and that they are integers
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 17:07:53 +01:00
234b84ef60 Add script to parse notes in Google Sheets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 16:36:57 +01:00
b9295cc199 Add options in the update_notation_sheets script
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 16:02:12 +01:00
3fae6a00dd Auto update Google Sheet after jury management
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 15:55:28 +01:00
37ad3cf8a6 Export notes on Google Sheet automatically
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 14:21:28 +01:00
c522387482 Export notation sheets on Google Sheets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 13:41:46 +01:00
0006ecc90d Display trigrams in note interface
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-29 19:22:20 +01:00
6b16ed3cc8 Add archive with all notation sheets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-29 18:59:37 +01:00
a44439671e Organizers can edit payments
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-29 17:44:38 +01:00
5084bb65d9 Add ZIP archive for tournament solutions
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-27 00:49:32 +01:00
4583cf46b1 Add ZIP archive for tournament authorizations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-26 23:55:29 +01:00
a865361117 More data in CSV file
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-26 23:03:11 +01:00
4ea93d3426 Fix draw tests since we updated the repartition algorithm
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-26 22:32:44 +01:00
8777c562dd Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-26 21:18:03 +01:00
4ea70e5ab9 Add juries => Edit jury
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 22:22:16 +01:00
df036ba384 Update draw with the new team repartition
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 22:20:33 +01:00
e9ae1fcb60 Update repartition for 5-teams pools
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 20:41:37 +01:00
bee04b0522 Update synthesis sheets templates
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 20:24:57 +01:00
b6d54d27cd Update ODS note sheets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 20:05:07 +01:00
3465da4c36 Update bareme
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 19:19:55 +01:00
4f129280c3 Add buttons to publish notes
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 18:14:43 +01:00
d2c1a826a8 Update permissions for juries presidents
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 17:42:09 +01:00
0b9079b431 Add button to update notes
Add jury president field for pools

Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 15:36:51 +01:00
6fa3a08a72 Add button to update notes
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 11:39:29 +01:00
64b7644e5e Admin users can manage juries
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 10:47:35 +01:00
50d8bc2aed Better jury autocomplete
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 10:33:42 +01:00
7f7ac5d5e6 Users can't join a team after validation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 10:29:45 +01:00
1dd9a5cf94 Add autocomplete feature for jury form
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-23 23:04:22 +01:00
40aa2e520f Add API endpoint to get volunteers names and emails, for tournament organizers only, to easily add juries
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-23 11:47:42 +01:00
0ebee1910b Add api endpoints for tweaks and payments
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-23 11:36:09 +01:00
81c2df7f10 Restructure add juree page
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-23 11:23:02 +01:00
833b300fde Fix motivation letter validation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-21 20:28:12 +01:00
12d25b64fe Payments in the list for a tournament are distinct
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-16 10:41:48 +01:00
afbc67c413 Let coaches update payment of the team
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-16 07:31:19 +01:00
71e33b2177 Typo
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-03 16:18:04 +01:00
f95309be08 Frais d'inscription => Frais de participation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-03 15:16:43 +01:00
0530441452 Fix receipt file name
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-02 19:44:35 +01:00
4ff53e08db Add privacy policy
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-02 12:52:47 +01:00
f9645b016a Allow organizers to submit payment forms
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-29 23:05:41 +01:00
6b7b802d14 Don't update payment amount if there isn't anyone
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-29 23:00:35 +01:00
1684c079e3 Fix payment group permission
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-29 22:59:54 +01:00
0c45a88246 Tournament.amount => Tournament.price
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-26 23:49:57 +01:00
de22a12e85 Activating translation is not needed
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 19:22:55 +01:00
415d83acc7 Read tox dependencies from requirements.txt file
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 19:15:07 +01:00
eb7e7c1579 Compile messages in tox tests
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 19:09:34 +01:00
348004320c Add tests for payment management commands
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 19:01:26 +01:00
9829541289 Add information about reminders
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 18:44:54 +01:00
1e1fef7a7b Add documentation dark theme
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 18:41:12 +01:00
d0c9256c5b Add payment user documentation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 18:40:58 +01:00
83300ad4b7 Add tests for Hello Asso payments using a fake endpoint
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 17:24:52 +01:00
92408b359b Move helloasso methods in a specific module
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 15:11:33 +01:00
01ba0a1df9 Replace assertEquals by assertEqual (deprecated and removed in Python 3.12)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-24 23:10:06 +01:00
207af441a0 Add payment interface tests
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-24 23:05:21 +01:00
2a2786ba6d Add payment information after payment
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-24 22:58:06 +01:00
1d01376703 Update validate team mail with a payment reminder
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-24 09:56:57 +01:00
6e35bdc0b3 Create payments in a signal rather than in a view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-24 09:39:04 +01:00
9380fbaaf7 Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-24 09:22:27 +01:00
295717256f Grouping payments is only allowed if all members of a team have not paid yet
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-24 08:54:01 +01:00
87038dd6f4 Allow to use a local settings file
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-24 08:45:59 +01:00
2155275627 Update Haystack search index in cron
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-23 23:08:47 +01:00
7b4e867e33 Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-23 23:05:10 +01:00
2c54f315f6 Add payments table page
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-23 22:58:23 +01:00
5cbc72b41f Teams tab is only accessible to admins
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-23 21:48:39 +01:00
de504398d2 Improve Django-admin interface, inlines and filters
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-23 21:43:44 +01:00
cae1c6fdb8 Send payment confirmation mail after payment, and send weekly reminders for people that have not paid
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-23 18:02:24 +01:00
6a928ee35b Prepare mails for payment confirmations and reminders
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-22 18:43:18 +01:00
bc535f4075 Restore payment edit form for volunteers
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-21 23:56:29 +01:00
64b91cf7e0 Display payments in team detail view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-21 23:41:31 +01:00
54dafe1cec Improve payment messages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-21 23:12:01 +01:00
b16b6e422f Allow anonymous users to perform a payment using a special auth token
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-21 22:44:56 +01:00
8d08b18d08 Configure Hello Asso return endpoint
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-20 22:54:12 +01:00
8c7e9648dd Use Hello Asso sandbox instance in dev mode
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-20 18:51:38 +01:00
b3555a7807 Create Hello Asso checkout intents
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-19 00:17:14 +01:00
98d04b9093 Make the payment group button work
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-18 23:02:27 +01:00
4d157b2bd7 Setup payment interface
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-18 22:36:01 +01:00
7c9083a6b8 Restructure payment model
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-12 22:58:48 +01:00
ece128836a Temporary disable payment form
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 23:51:33 +01:00
2e574d0659 Fix participation detail test (a tournament is required)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 23:47:01 +01:00
850659bf48 Display payment information on the sidebar
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 23:31:24 +01:00
672529382d Fix payment view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 23:12:48 +01:00
c1ce7cb70f Display pending validations for organizers
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 22:39:11 +01:00
bc67d1cf1f Add information about team registration
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 22:24:22 +01:00
652e913f49 Fix user update view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 21:41:37 +01:00
089374b937 Fix join team view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 21:40:06 +01:00
226e5620f9 Better footer on small screens
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 21:06:22 +01:00
ca9652cc60 Collapse sidebar on small screens
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 20:59:34 +01:00
acd1d80c75 First important informations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 20:20:28 +01:00
e7c207d2af Sidebar structure
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 19:23:16 +01:00
196ccb69ad Remove headers on index page
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 19:15:36 +01:00
2b941cb30f Rearrange base template with separated contents, add sidebar
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 18:43:23 +01:00
21ff044044 Install documentation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-20 22:42:36 +01:00
2a85d4ff38 Remove æ
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-20 20:14:00 +01:00
037b22fcaa Mention des contraintes de logement dans la documentation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-20 20:14:00 +01:00
0474615746 Documentation de la gestion du tirage au sort
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-20 20:14:00 +01:00
17057a5fe5 Fix tests for the new last_degree field
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-20 18:41:17 +01:00
a738a5a58d Add last degree field for coaches
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-20 18:41:17 +01:00
b35bebc7c2 Don't use Haystack real time signal processor in dev mode
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-20 18:41:06 +01:00
99f4aed360 Authorization templates are in french
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-20 17:10:32 +01:00
bd2cead945 Authorization templates can be fetched by tournament name
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-20 17:09:06 +01:00
62ab0a4c47 Remove obsolete cas_server config
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-18 20:01:59 +01:00
fd726f4121 Let Haystack realtime signal processor work in all cases
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-18 19:58:06 +01:00
2c02951a0d Remind that the username is the email address
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-18 19:53:12 +01:00
9ec35c917f Update index page for 2024
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-17 16:09:02 +01:00
7919b34d2b Haystack may be used in dev mode if we have an ElasticSearch URL
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-16 22:36:25 +01:00
c5a8581a80 Add housing constraints field, see #25
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-16 22:28:34 +01:00
e031e143c2 Upgrade Bootstrap to 5.3.2
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 20:15:07 +01:00
3964aaf595 Update problem names for 2024
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 20:04:51 +01:00
202f979403 Put secret key in env settings, fix security issue
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 19:59:57 +01:00
cf561c4584 Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 19:50:16 +01:00
e2679cf5e8 Add Haystack index name in env vars
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 19:33:31 +01:00
122edeef48 Fix purposed problem verbose name
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 19:32:39 +01:00
4ff9f44eae Don't need to rebuild the ES index periodically, do it only once
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 19:31:15 +01:00
5d13d9bc16 Fix basic search tests
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 19:24:55 +01:00
121e1da37d Add py312 tox env
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 19:00:46 +01:00
8222f3b781 Adapt search tests since the simple backend is not so permissive as ElasticSearch
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 18:47:17 +01:00
dc56396012 Use elasticsearch only in production
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 18:32:30 +01:00
f1d2acdc25 Remove whoosh in profit for Elasticsearch
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 18:28:45 +01:00
50e95ad3f2 Install Git in Gitlab CI
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 17:31:31 +01:00
7848a90d5d Fix gunicorn and psycopg2-binary versions
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 17:30:07 +01:00
f08cb229ca Use early version for Django Haystack for Django 5.0 support
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 17:27:58 +01:00
b0fbb406f6 Add Python 3.12 test in Gitlab CI
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 17:24:36 +01:00
0f2f34175c Upgrade Django to 5.0, update dependencies
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 17:21:55 +01:00
6226f06d97 Update Python to 3.12
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 17:09:45 +01:00
a853be73c5 Temporary remove chat feature (maybe reintroduce a better one later)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 17:04:45 +01:00
93a2e2436d Drop Matrix support
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-01-13 16:49:49 +01:00
2f4755ffc7 Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-10-23 22:02:09 +02:00
230dc545f4 Fix export scripts
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-20 22:13:51 +02:00
20daecf619 Syntheses must not exceed 2 pages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-20 17:10:03 +02:00
3333add7e0 Fix translation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-20 11:45:21 +02:00
777ae059f9 Non-admin users can't promote themselves to admin users
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-20 11:35:37 +02:00
310ac70a74 Add ability to fake the draw for admins
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-19 18:24:01 +02:00
29074c4bfd Add button to download all solutions and syntheses in a ZIP file
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-19 14:51:52 +02:00
9bc0e99d6d Fix the drawing resume for the final
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-11 18:00:32 +02:00
b38302449c Don't manage pools of the second day with the dices of the first day since we consider the scores of the first day
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-11 17:28:05 +02:00
feee5069b1 Add notification when the draw of the final is resumed
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-11 17:15:50 +02:00
6b962a74b3 Auto-restart the draw socket on close
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-11 17:13:52 +02:00
0c80385958 Use a unique socket for the drawing system
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-05-11 17:07:53 +02:00
8c41684993 Pool tables are not orderable by teams
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-16 09:25:00 +02:00
8245ba0063 Add Redis Channel Layer for the drawing system
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-12 00:10:17 +02:00
0e7a275a28 Order participations by validity status and by trigram
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-11 22:46:15 +02:00
59268f2d1e Add synthesis sheet template as DOCX format
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-11 22:23:30 +02:00
2ad7799b38 Fix the display of the draw button
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-11 22:20:15 +02:00
3b7f2130f3 Check that notes correspond to someone in the jury, and throw an error if this is not the case
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-10 17:38:58 +02:00
d75c800275 Because django-cas-server forbids Django 4.2, we must do a small trick to allow it. Remove when not necessary anymore
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-10 17:30:11 +02:00
41e69992c0 Allow ISO-8859-1 encoding is CSV files
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-10 17:26:55 +02:00
43af14ad77 Search juries by "{first_name} {last_name}"
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-10 17:26:30 +02:00
acf906b284 Fix draw template
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-10 10:11:32 +02:00
80f0baac1e Must be authenticated to upload notes
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-10 10:05:14 +02:00
3d7a39a593 Only participants in a valid team can see the draw
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-10 10:02:37 +02:00
a240d7cad5 Better unique validation errors
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-10 09:56:16 +02:00
b40dce27df Juries can't download ZIP archives with authorizations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-09 11:37:45 +02:00
9734b51f53 Test draw application
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-09 00:59:35 +02:00
80cfe874f5 Only process CSV files when they are correctly read
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-08 17:33:01 +02:00
bcf4e294e0 Add odfpy in tox
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 22:38:09 +02:00
a27a115d66 Add observer in the passage admin page
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 22:21:29 +02:00
6ac36fdb69 Close database connections after 10 seconds (experimental)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 22:02:37 +02:00
505a94e3aa Customize the notation sheet template for juries
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 21:47:06 +02:00
b921ca045e Process notation sheets when there are 4 or 5 teams
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 13:16:49 +02:00
a382e089ae Add observer notes
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 12:10:25 +02:00
9eed5ca2a0 Add e-mail address on tournament export
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 11:32:47 +02:00
cbf34fe90e Add texmf-dist-latexextra package to have more LaTeX packages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 00:33:38 +02:00
7dc812984b Add position field for passages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-07 00:06:21 +02:00
1ed4e9c17a Add multiple sheets for 5-teams pools
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-06 23:58:59 +02:00
5f09c35dee Add notation sheets templates that are autocompleted with the data
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-06 23:38:59 +02:00
ae62e3daf7 Reorganize the cancel step code in order to make it more readable
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-06 18:15:14 +02:00
8778f58fe4 The draw is now fully reversible
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-06 00:19:24 +02:00
751e35ac62 Cancel draw problem
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 23:28:12 +02:00
f41b2e16ab Cancel choose problem
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 19:40:47 +02:00
1f6ce072bf Add cancel button to cancel the last step (works for the last problem acceptance for now)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 19:22:48 +02:00
746aae464a Add confirmation modal before aborting a draw
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 18:41:28 +02:00
7e212d011e Add comments and linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 17:52:46 +02:00
2840a15fd5 Add form to add juries in a pool
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 16:54:16 +02:00
c1482d4802 Jury -> Juré⋅e
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 10:59:26 +02:00
16c4376941 Improve payment admin page
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-05 10:44:27 +02:00
dfc45dbc93 A team can't accept a problem that was previously *accepted* not the last purposed
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 21:21:55 +02:00
31f5373652 Await the send notifications coroutines
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 21:21:00 +02:00
ca7cf5987c Try to fix requirements
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 20:02:59 +02:00
34390a541a Update translations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 19:57:02 +02:00
b8b4891e9b Squash migrations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 19:54:18 +02:00
9cfab53bd2 Add a lot of comments
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 19:52:44 +02:00
82cda0b279 Reduce the usage of sync_to_async
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 15:10:28 +02:00
4357d51b9a Display problem names
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 11:56:13 +02:00
90bfc45858 Use the new asave function of Django 4.2
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 11:20:43 +02:00
bb9f0dab22 Django 4.2 got released
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 11:12:37 +02:00
b0a248e81a Fix the transition between the two rounds
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 11:07:08 +02:00
b3c26b8c1c Improve admin interface
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:55 +02:00
073d761a03 Add admin menu
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:55 +02:00
bd31375bf3 Fix CSV process
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:55 +02:00
7605b9cc00 Add download link to notation sheets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:55 +02:00
0fa76d6f25 Add letter in pool display
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:55 +02:00
14505260ff Use more complex calculus to mix teams for the second day
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:54 +02:00
cf8892ee1a Use separate fields for the two dices
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:54 +02:00
7f7d921c53 We want to avoid that a team chooses twice a same problem, not to wait an infinite loop
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:54 +02:00
8668430760 Add reverse-proxy headers
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:53 +02:00
45818eae24 Add websockets as dependency
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:53 +02:00
b154c4985d Fix duplicate problem check
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:53 +02:00
ac039c1073 Display draw tab only for authenticated users
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:53 +02:00
3717cd8b3f Don't import models too soon
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:52 +02:00
7855ec2225 Fix translation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:52 +02:00
fbaca32615 Teams can't select a same problem for the two days
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:52 +02:00
5b1374bf1b Add link to the drawing interface
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:52 +02:00
18bd2c7c18 In a 5-teams pool, the order of two teams that present the same problem is random
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:52 +02:00
a4c7951475 Make all invisible when a draw is aborted
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:51 +02:00
c299ff6634 Remove Python 3.9 compatibility (I love match/case)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:51 +02:00
7d8975339e Add continue button for the final tournament
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:51 +02:00
1bd9cea458 Fix update notes modal
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:51 +02:00
b838f1b3f0 Add export button
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:50 +02:00
e95d511017 Translate messages from websockets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:50 +02:00
942c96dbfa Reorder teams for 5-teams pools
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:50 +02:00
3cd40ee192 Add margins
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:50 +02:00
cebe977d49 Problems can be accepted or rejected. Draw can go to the end
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:50 +02:00
e90005b192 Teams can draw a problem
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
6b5c630048 Add Abort button
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
c9fcfcf498 Add messages for better understanding
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
dec9f9be11 Update translations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
f85a563cf3 Auto-generate tables
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
5399a875c6 Draw dices
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:49 +02:00
eb8ad4e771 Prepare template for the system
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:48 +02:00
93a71fb561 Fix errors and better tab usage
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:48 +02:00
bde3758c50 First interface to start draws
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:48 +02:00
88823b5252 Update database models and translations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:48 +02:00
9aa19ad3ca Fix tests
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:48 +02:00
ad4593a2f6 Prepare database model
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:47 +02:00
849194414d Fix tox
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:47 +02:00
b9ce4c737c First play with websockets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:47 +02:00
30efff0d9d Don't trigger signals on raw imports
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:47 +02:00
7364d27b4b Init new draw application
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:47 +02:00
19f41152ee Use Django 4.1 (soon 4.2) to use the new async framework
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:46 +02:00
f3d611913e Run ASGI server instead of WSGI
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:46 +02:00
1d81213773 Move apps in main directory
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:46 +02:00
2a545dae10 Fix add organizer view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-04-04 10:25:33 +02:00
fc6e2593b4 PdfFileReader is deprecated, replace by PdfReader 2023-03-29 18:34:55 +02:00
ce25341496 Fix administration tab 2023-03-29 18:33:48 +02:00
57bddc5628 Fix Update Payment modal
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-03-16 14:37:51 +01:00
d7b293dc87 2022 -> 2023
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-03-16 14:31:14 +01:00
ff414ea046 Add dark theme based on browser preference 2023-02-20 23:02:09 +01:00
91d39b44a2 Add possibility to load Matrix credentials from env configuration 2023-02-20 22:25:13 +01:00
d3631877c4 Forgotten password link was invisible 2023-02-20 22:13:03 +01:00
502b066311 Commit bootstrap-select 2023-02-20 21:47:08 +01:00
3efe5a2226 Linting 2023-02-20 21:14:16 +01:00
a2201e36fa Add crispy-bootstrap5 as dependency 2023-02-20 21:14:15 +01:00
69b94c9493 Render only useful content when displaying modals 2023-02-20 21:14:15 +01:00
a8f24b6581 Use bootstrap-select selector when it is necessary 2023-02-20 21:14:15 +01:00
e156ed6111 Remove jquery dependency code (keep it for bootstrap-select) 2023-02-20 21:14:15 +01:00
ea00657405 Use Bootstrap 5 instead of Bootstrap 4 2023-02-20 21:14:15 +01:00
5abca36498 Drop turbolinks support, too useless 2023-02-20 21:14:15 +01:00
731dfc049f Better select widget when searching organizers 2023-02-20 21:14:15 +01:00
4075f6cf78 Add vaccine sheet field, closes #18 2023-02-20 21:14:15 +01:00
0f2c44331c Add vaccine sheet field, closes #18 2023-02-20 00:38:57 +01:00
fae4ee7105 Drop AdminRegistration in favour of a new boolean field, closes #19 2023-02-20 00:25:06 +01:00
600ebd087e Add forbidden trigrams, closes #17 2023-02-19 23:13:58 +01:00
4a39d206d5 Update dead name 2023-02-19 19:25:37 +01:00
2faade0156 Remove bootstrap-datepicker-plus dependency, use native HTML selectors 2023-02-19 19:21:42 +01:00
e17273391d Update dependencies to those on Debian Bookworm 2023-02-19 18:53:04 +01:00
0e7be7e27c Students can't auto-select them for the final 2023-01-22 15:49:50 +01:00
b95b41a2ed ZIP code can be larger than 32767
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-01-16 23:14:44 +01:00
444bea2440 Fix tests
Update index page

Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-01-10 22:35:48 +01:00
7bb4e2c8eb Fix tests
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2023-01-10 22:06:16 +01:00
0f176ea4c6 Birth date is only for participants 2023-01-10 20:31:43 +01:00
63a10c1be5 Drop django-address dependency and keep only street, zip code and city (/!\ Breaking commit, can't upgrade) 2023-01-10 20:24:06 +01:00
f7eddd289b More inclusive words 2023-01-10 15:32:19 +01:00
6b4553b76b Add documentation for organizers 2023-01-10 15:13:18 +01:00
ccfd2c155b Starting documentation of organizers 2023-01-09 22:08:01 +01:00
814cb10439 Reorganize documentation 2023-01-09 15:26:34 +01:00
df8f6cff2b Add begin of user guide 2023-01-04 19:48:53 +01:00
7f8934a647 Drop Python 3.8 support, add Python 3.10 and 3.11 support
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2022-11-08 15:55:09 +01:00
815206a0a5 Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2022-11-08 15:52:54 +01:00
8350960d5f Fix problems export
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2022-10-22 15:09:26 +02:00
968162f34e Add tweaks to update notes 2022-05-15 16:47:51 +02:00
e848855072 Juries are volunteers 2022-05-15 16:20:43 +02:00
50409931cf Fix error 2022-05-15 16:16:41 +02:00
d18f76cf80 Upload notes from a CSV sheet 2022-05-15 12:24:50 +02:00
5f2cd16071 Files are required for solutions and syntheses
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-04-29 18:53:34 +02:00
c686584e74 Place field is useless
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-04-26 21:42:29 +02:00
3a650a1e89 Fix Hello Asso link
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-04-26 15:10:23 +02:00
51beb47191 Fix scholarship files
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-04-26 14:23:10 +02:00
e3f5541774 Add new "other" payment type
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-04-26 13:47:15 +02:00
14de6cf824 [helloasso] Manage duplicate users + ignore invalid users
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-04-26 13:44:16 +02:00
3e46d06817 Add CSV export for tournaments 2022-04-22 18:05:06 +02:00
0fd9222055 Filter on last name and optionally on first name for Hello Asso
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-03-28 21:36:00 +02:00
b67308065a Update Hello Asso URL 2022-03-28 21:15:44 +02:00
644afc6a0d Le tournoi ça commence le samedi 2022-03-21 19:28:50 +01:00
1ef981571d Parce que les gens ragent 2022-02-05 21:13:18 +01:00
30a8676555 Update 2022 2022-02-04 18:10:07 +01:00
cdf279bb02 Team name don't need to be uppercase 2022-02-04 15:40:45 +01:00
7515c2bec6 Define default auto field for Django 3.2 2022-02-04 15:25:40 +01:00
cce5e7c33c Hello 2022 2022-02-04 15:07:41 +01:00
f9e85dd63e Why was it broken 2022-02-04 15:01:15 +01:00
cb86fd43ac Fix bootstrap-datepicker-plus 2022-02-04 14:54:40 +01:00
be0662420d Upgrade dependencies 2022-02-04 14:45:00 +01:00
da1d7a83fa Remove header 2022-02-04 14:31:01 +01:00
d37354dc24 Don't create rooms for "mise en commun" 2022-02-04 14:14:59 +01:00
d210b2a221 /run/nginx now exists by default, but not /etc/nginx/conf.d
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2022-01-12 01:11:41 +01:00
e9958faace Add script to export solutions
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-11-21 22:27:09 +01:00
ab1f4c2eba Add script to generate Wordpress results
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-11-18 19:17:54 +01:00
1ba5cfa3f8 Add rooms for problems 2021-05-15 22:21:50 +02:00
e9cfae99da Filter passages per tournament 2021-05-14 13:34:31 +02:00
700df123b7 Fix tournament serializer 2021-05-11 17:19:28 +02:00
582a634da7 Fix participation detail template 2021-05-11 17:10:34 +02:00
837800345b Fix permissions for solutions for the final 2021-05-11 17:06:49 +02:00
384fbfd0b2 Better participation detail page 2021-05-11 17:03:25 +02:00
d8f2e56d45 fix solution str representation 2021-05-11 16:56:44 +02:00
ba6a6338f5 Fix permissions for final tournament 2021-05-11 16:40:18 +02:00
9a1006b341 Fix solution upload 2021-05-09 12:37:53 +02:00
e21c3bb413 Pool number is not day number 2021-04-29 15:47:46 +02:00
afde1d35d5 Indicate if this is a final solution 2021-04-29 15:46:38 +02:00
9e885153c2 We can select teams for the final tournament 2021-04-29 14:10:38 +02:00
ffaa6e8116 Force pool and passage tables to have chronological orders 2021-04-15 23:03:51 +02:00
9797268736 Add default order for solutions and syntheses
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-04-13 10:05:00 +02:00
fb4edccc40 Use full jquery lib
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-04-13 09:55:59 +02:00
f8297eebe1 Fix font awesome static files
Signed-off-by: Yohann D'ANELLO <ynerant@crans.org>
2021-04-12 22:52:14 +02:00
e41ad64b54 Use local static files 2021-04-12 22:41:50 +02:00
13c4c834d4 Round notes to one decimal 2021-04-10 14:38:15 +02:00
d6aa285bc5 Display notes for authenticated users 2021-04-10 11:43:31 +02:00
bbd8ad43cd Clarify syntheses name 2021-04-10 10:02:49 +02:00
ef8d124ade Display notes iff results are public 2021-04-10 09:59:04 +02:00
bb01e1b0b5 Display notes in django-admin 2021-04-09 16:17:12 +02:00
f9af52ce6a Organizers can manage pools 2021-04-09 14:28:36 +02:00
ef2911ab07 Add synthesis template links 2021-04-07 15:27:48 +02:00
3bd6d2e647 Invite local organizers, not all organizers in pool channels 2021-04-07 09:54:12 +02:00
9d741d76f2 Organizers can see solutions 2021-04-06 19:50:27 +02:00
de504a1706 Fix synthesis upload 2021-04-04 18:13:30 +02:00
30a0e63eb9 Fix solution view 2021-04-04 17:18:50 +02:00
de76abab5f Remove Matrix test 2021-04-04 16:42:09 +02:00
833249191c Missing await 2021-04-04 16:37:02 +02:00
0a99f10899 Create multiple channels in case of five people-pools 2021-04-04 16:28:06 +02:00
5101746d29 Reformat Matrix script 2021-04-04 16:06:16 +02:00
aa69e6eadb Run matrix script into an async loop 2021-04-04 16:02:37 +02:00
7dd85d7402 Update defender penalties 2021-04-04 13:35:45 +02:00
6b2ca1d2e1 Admin can see note details 2021-04-04 13:30:02 +02:00
fbedb941be Better pool display 2021-04-04 13:15:00 +02:00
46e75c7ae8 Passages are read-only 2021-04-04 12:17:54 +02:00
d26dee3bcf Fix tournament serializer 2021-04-04 11:35:00 +02:00
4084f7abb5 Fix solution upload 2021-04-03 22:15:03 +02:00
d4c7b39f46 Fix solution and synthesis forms 2021-04-03 22:02:53 +02:00
0576f3e32b Support penalties 2021-04-03 21:59:06 +02:00
d093414ec7 git is useful 2021-03-29 16:46:44 +02:00
cba4a01117 Upgrade django-cas-server, please ... 2021-03-29 16:45:56 +02:00
fde2fdba63 Remove asgiref dependency, django manages itself 2021-03-29 16:43:34 +02:00
aff1bbda0b Upgrade python-magic in test environment 2021-03-29 16:34:30 +02:00
4f9dfadb71 Add API filters for registration 2021-03-29 16:24:58 +02:00
1df1766753 Upgrade dependencies 2021-03-29 16:18:27 +02:00
9359aa7606 Add API views for participation app 2021-03-29 15:41:20 +02:00
a45d57e51a Team member don't have access to other people authorizations 2021-03-28 20:09:29 +02:00
35863c4bda Matrix cron is buggy 2021-03-28 20:08:00 +02:00
13414ee0c5 Organizers can upload documents for team members 2021-03-18 18:36:37 +01:00
cdacbe2ea1 Matrix is listening on https://tfjm.org/ and https://tfjm.org:8448/ 2021-03-18 18:13:06 +01:00
69325bff9a Fix translations 2021-03-15 10:17:31 +01:00
049234caae Fix hello asso check 2021-03-15 10:07:59 +01:00
f8d38738ea Authenticate to Hello Asso by client id and secret 2021-03-15 09:57:05 +01:00
f7d52aa6da Update HelloAsso link 2021-03-15 09:46:45 +01:00
99a2134a57 Increase cron delay 2021-03-15 09:35:42 +01:00
8fc99803c1 object -> get_object() 2021-03-14 23:46:11 +01:00
7984ce8e1d object -> get_object() 2021-03-14 18:57:51 +01:00
3f46e23588 Email address is no more required 2021-03-11 16:57:23 +01:00
a7665d41b7 Organizers can add other organizers 2021-02-16 10:58:14 +01:00
6c064d6570 Fix permissions on team authorizations 2021-02-13 17:09:17 +01:00
140048bcdb Fix typo: intersting -> interesting 2021-02-13 16:01:02 +00:00
73cadd8cfd Merge branch 'dev' into 'master'
Dev

See merge request animath/si/plateforme-tfjm!19
2021-02-07 16:45:54 +00:00
7a0cb64fb6 Fix team validation 2021-02-07 17:40:29 +01:00
c9067d5202 Team trigrams and names are unique 2021-02-07 17:39:24 +01:00
200848816d Admins can (in)validate participations 2021-02-07 17:31:50 +01:00
2b02c250a2 Email from is not a list 2021-02-07 16:51:09 +01:00
c32f9d2b17 People are stupid (or website is not enough documented, idk) 2021-02-07 16:45:17 +01:00
36d8d993e3 Fix subscribing to equipe-trigram@ 2021-02-07 16:08:46 +01:00
0d758d2b08 Fix subscription of teams in equipes-non-valides@ 2021-02-07 15:57:56 +01:00
adb64dec51 Volunteers can add organizers 2021-02-06 19:34:17 +01:00
12acb0ca26 Merge branch 'dev' into 'master'
Volunteers can add organizers

See merge request animath/si/plateforme-tfjm!18
2021-02-06 18:31:20 +00:00
d9fbd5564e Volunteers can add organizers 2021-02-06 19:26:19 +01:00
a846750911 Merge branch 'dev' into 'master'
Coaches can update their photo authorization

See merge request animath/si/plateforme-tfjm!17
2021-01-30 15:28:03 +00:00
7d9e80bf9f Coaches can update their photo authorization 2021-01-30 16:24:30 +01:00
a8a69c766c Merge branch 'dev' into 'master'
Raise error when a given tournament does not exist

See merge request animath/si/plateforme-tfjm!16
2021-01-29 19:24:43 +00:00
f4e0d0a95e Raise error when a given tournament does not exist 2021-01-29 15:05:25 +01:00
9c4e68d0ea Merge branch 'dev' into 'master'
Permissions on user detail

See merge request animath/si/plateforme-tfjm!15
2021-01-29 09:36:53 +00:00
2367131316 Raise error when a given tournament does not exist 2021-01-29 10:33:06 +01:00
67540df334 Fix error message when a tournament is not specified 2021-01-29 10:31:30 +01:00
a6000aec2a Fix permission to view user detail 2021-01-29 10:24:00 +01:00
e2d5a55173 Merge branch 'dev' into 'master'
Dev

See merge request animath/si/plateforme-tfjm!14
2021-01-24 22:57:37 +00:00
55c3a5fcc8 A team has at least 4 members and up to 6 2021-01-24 23:53:58 +01:00
d4111126c7 Download all authorizations -> Download all *submitted* authorizations 2021-01-24 23:42:59 +01:00
8212568fee The user detail page is on a separate page since custom JS can't be loaded 2021-01-24 23:40:05 +01:00
6898e9413a Fix user detail for children 2021-01-24 23:37:55 +01:00
1b117e9289 Merge branch 'dev' into 'master'
Local organizers validate teams

See merge request animath/si/plateforme-tfjm!13
2021-01-23 20:59:40 +00:00
c500a735d8 Users can indicate their health issues to organizers 2021-01-23 21:55:54 +01:00
f53f9fbc6c Send validation emails to all local organizers 2021-01-23 21:48:01 +01:00
629c4d2367 Merge branch 'dev' into 'master'
Remote tournaments + Animath logo

See merge request animath/si/plateforme-tfjm!12
2021-01-23 19:27:56 +00:00
ab1c5a276a Update Animath logo 2021-01-23 20:24:14 +01:00
2bd6988c6a Don't request too many authorizations for remote tournaments 2021-01-23 20:24:06 +01:00
f83b4c094e Merge branch 'dev' into 'master'
Dev

See merge request animath/si/plateforme-tfjm!11
2021-01-23 13:33:54 +00:00
4dd3c105fe Only consider a participant as a child if it is not 18 on the beginning of the tournament 2021-01-23 14:30:00 +01:00
a0266c691b Coaches have no health sheet 2021-01-23 14:27:21 +01:00
b5136ffa91 A motivation letter must be a PDF/JPEG/PNG file (it didn't work) 2021-01-23 14:26:15 +01:00
d9a2b31606 Django filters was missing 2021-01-23 13:43:31 +01:00
01a6e28623 Fix matrix avatar 2021-01-23 13:41:43 +01:00
8162a48754 Merge branch 'dev' into 'master'
Fix the permission to see a user page

See merge request animath/si/plateforme-tfjm!10
2021-01-23 10:06:14 +00:00
ea38c06631 Fix the permission to see a user page 2021-01-23 11:02:26 +01:00
68a5467a35 Merge branch 'dev' into 'master'
Unleash the beast

See merge request animath/si/plateforme-tfjm!9
2021-01-22 22:28:19 +00:00
0cd7ff512f Unleash the beast 2021-01-22 23:24:35 +01:00
a9f3cb7d3a Order tournaments by name 2021-01-22 22:33:48 +01:00
f36c36b96e Les gens sont trop rapides 2021-01-22 19:28:58 +01:00
b222a71d45 Resquash migrations 2021-01-22 19:27:37 +01:00
756f94cbd9 Remove wrong text on template 2021-01-22 19:26:24 +01:00
4c476a50ea Merge branch 'dev' into 'master'
Use a custom BBB url link, that is not necessary on visio.animath.live

See merge request animath/si/plateforme-tfjm!8
2021-01-22 17:32:34 +00:00
ea9d7cdd50 Use a custom BBB url link, that is not necessary on visio.animath.live 2021-01-22 18:27:57 +01:00
641e53e617 Merge branch 'dev' into 'master'
Dev

See merge request animath/si/plateforme-tfjm!7
2021-01-22 08:51:59 +00:00
c06ae694cd Fix tests, upload a fake motivation letter 2021-01-22 09:48:11 +01:00
1d25f7f824 Add button to download all authorizations of a team 2021-01-22 09:44:19 +01:00
ce206998f0 Teams must send their motivation letter 2021-01-22 09:40:28 +01:00
628f69e772 Gender is not a date 2021-01-22 09:04:44 +01:00
74c0260593 Don't re-upload a new avatar every time 2021-01-22 08:57:01 +01:00
384de5758b Ask gender 2021-01-22 08:45:00 +01:00
48107943f9 Use registration name rather than email address in the add organizer mail 2021-01-21 23:49:20 +01:00
5d524b263b Use the server email in the from header 2021-01-21 23:43:59 +01:00
75db278a97 Merge branch 'dev' into 'master'
Fix latex and admins

See merge request animath/si/plateforme-tfjm!6
2021-01-21 21:58:00 +00:00
0da0165ce2 Admins are superuser 2021-01-21 22:54:23 +01:00
214d422ee2 Texlive is missing in the docker image 2021-01-21 22:51:12 +01:00
1677731b4a Merge branch 'TFJMv3' into 'master'
TfjmV3

See merge request animath/si/plateforme-tfjm!5
2021-01-21 21:43:53 +00:00
7a4cc8843f Add tournament-specific email addresses 2021-01-21 22:36:35 +01:00
9e559db4b4 Display the BBB link of a pool 2021-01-21 22:32:43 +01:00
fd4280426b Add missing models in Django Admin 2021-01-21 22:22:21 +01:00
1a63c1f399 health_sheet field is now in StudentRegistration 2021-01-21 22:16:01 +01:00
b40c06fe9e Linting 2021-01-21 22:12:36 +01:00
416135ca3a Squash migrations 2021-01-21 22:06:58 +01:00
497b3ad8aa Indicate the tournament name in the photo authorizations 2021-01-21 22:04:49 +01:00
72fe279f15 The health sheet is required only for children 2021-01-21 21:55:19 +01:00
ae520f791c Generate authorization templates as PDF 2021-01-21 21:44:43 +01:00
35042f077f Store the BBB link in the Pool model 2021-01-21 17:55:20 +01:00
56ad352e64 Integrate BigBlueButton and whiteboard in pool rooms 2021-01-21 17:44:18 +01:00
97761e07a9 Fix #bot avatar 2021-01-20 23:34:45 +01:00
fd587099cb Channel titles are in french 2021-01-20 23:34:33 +01:00
ddaf5e82bd Prepare documentation on /doc 2021-01-20 15:00:26 +01:00
01e6ab2279 Add #tirage-au-sort Matrix channel 2021-01-19 11:32:28 +01:00
e4fa6c0321 Use a checkbox widget to select tournament organizers 2021-01-19 01:19:18 +01:00
4821b090ae Update on index page 2021-01-19 01:06:08 +01:00
0522db0f63 Install PyPDF3 in test environment 2021-01-19 00:38:21 +01:00
3e0e6ae7b4 Check that syntheses are valid files 2021-01-19 00:33:44 +01:00
d5e7295981 Detect when a solution has more than 30 pages 2021-01-19 00:32:34 +01:00
8515153be7 Fix linting 2021-01-19 00:13:22 +01:00
5a865efd18 Don't upload solutions or syntheses after the deadline, if an existing file was previously sent 2021-01-19 00:11:52 +01:00
a55eea7c10 Teams can see solutions only from the date from which they are available for the second round 2021-01-18 23:55:20 +01:00
cb5f597547 Index tournaments 2021-01-18 23:49:27 +01:00
96adb01edb Display scholarship attestation 2021-01-18 23:39:02 +01:00
40fd5a56c1 Add detail on how to pay 2021-01-18 23:27:01 +01:00
00c936f909 Add script that checks payments from Hello Asso 2021-01-18 23:00:39 +01:00
0346df11c2 Fix sympa lists and matrix channels in the cron 2021-01-18 22:33:43 +01:00
d02db9b858 Missing translation 2021-01-18 22:33:29 +01:00
ef4d74545a Fix tests 2021-01-18 22:28:43 +01:00
d05a8339fe If the tournament is free, then the payment is automatically valid 2021-01-18 21:30:26 +01:00
38dc00b2c9 Scholarships are not unique 2021-01-18 21:29:42 +01:00
4cd1e43564 It is possible to validate payment status 2021-01-18 20:02:49 +01:00
53a55ee898 Display payment status 2021-01-18 18:13:58 +01:00
d5ba7a08a9 Use ipython rather than ptpython 2021-01-18 17:29:48 +01:00
b0e43959eb Don't display notes too early 2021-01-18 16:54:57 +01:00
70d2ade6a3 Display pools only when necessary 2021-01-18 16:49:23 +01:00
364025b195 Add Payment model 2021-01-18 16:35:37 +01:00
e0f230b8c7 Update translations 2021-01-18 16:15:16 +01:00
47b14c3e47 Initialize a random password for new organizers 2021-01-18 16:12:00 +01:00
0607398491 This is TFJM², not Corres2math 2021-01-18 15:52:09 +01:00
a454441097 Display edit password button 2021-01-18 15:43:17 +01:00
b4da740fb6 Matrix channels are working 2021-01-18 15:30:24 +01:00
64e2d8d264 Manage Matrix channels 2021-01-18 02:07:31 +01:00
46ba112612 Use new logo 2021-01-18 01:28:33 +01:00
392ab86123 Latest python-magic version is broken 2021-01-17 17:36:18 +01:00
7decc18ad5 Add other authorizations in the team authorizations 2021-01-17 17:28:59 +01:00
daac77ba57 Linting 2021-01-17 16:23:48 +01:00
9b5ad96aaa Fix broken tests 2021-01-17 13:57:50 +01:00
c151ff3611 Protect pages (not tested) 2021-01-17 12:40:23 +01:00
1e413229a1 Mailing lists are working 2021-01-16 22:29:10 +01:00
71169048fb Update contact address 2021-01-14 21:22:24 +01:00
3e7ff21746 Register new organizers 2021-01-14 21:07:09 +01:00
1a7a411e10 Display passages as a table 2021-01-14 19:33:56 +01:00
7397afd236 Display the team ranking 2021-01-14 19:23:32 +01:00
61703b130d Update translations 2021-01-14 19:07:40 +01:00
a97541064e Display notes 2021-01-14 18:43:53 +01:00
ef785a5eb8 Add update note menu 2021-01-14 18:21:22 +01:00
be8904079d Jurys can note passages 2021-01-14 18:01:31 +01:00
c8780a6d9d Upload syntheses 2021-01-14 17:26:08 +01:00
6f26b24359 Store the defended solution in the passage 2021-01-14 16:27:44 +01:00
d912c8aab4 Display detail about a passage 2021-01-14 15:59:11 +01:00
f3f862c1ab Update the teams of a pool 2021-01-14 14:44:12 +01:00
7a6aaa3f58 Add Passage model 2021-01-14 14:22:45 +01:00
4d83664c0d Register pools 2021-01-13 17:00:50 +01:00
4faec03efb Display pools table 2021-01-13 16:22:26 +01:00
170326d503 Use a selector to choose a problem number 2021-01-12 18:02:00 +01:00
d75ba1f890 Disable turbolinks to load the solution file 2021-01-12 17:58:06 +01:00
e51674e76c Display solutions and syntheses 2021-01-12 17:56:40 +01:00
ead59e28b8 Upload to the good place 2021-01-12 17:51:55 +01:00
2ca0444053 Upload solution is working 2021-01-12 17:24:46 +01:00
09e5a72470 Display the solutions of the team 2021-01-12 16:26:52 +01:00
b4e7ec6550 There is no video in the TFJM² 2021-01-12 15:42:32 +01:00
6bcb050754 Display team detail 2021-01-02 00:06:58 +01:00
1805f48fa0 Fix signup 2021-01-01 21:49:40 +01:00
9473e101b8 Update translations 2021-01-01 17:07:28 +01:00
0f65bc4561 Display details about tournaments 2021-01-01 12:11:09 +01:00
bf6f87ee89 Indicate the maximum amount of teams in a tournament 2020-12-31 12:26:49 +01:00
52f0d442cd Better date render 2020-12-31 12:23:09 +01:00
4e29b4830a Create tournaments 2020-12-31 12:13:42 +01:00
03144ae58e Display the tournament list 2020-12-30 12:13:05 +01:00
e2e2c97584 Add protected pages to view authorizations 2020-12-30 11:03:12 +01:00
6611c1c896 Add phonenumbers in tox 2020-12-29 16:36:35 +01:00
e3a32a41f9 Upload all authorizations 2020-12-29 16:14:56 +01:00
72753edf64 Address, responsible and phone number were missing. Use Google Maps API to query the address, to ensure to have valid addresses 2020-12-28 23:59:21 +01:00
95fec7c0da Admins can declare an email as valid 2020-12-28 21:42:01 +01:00
c86ff67884 Remove Corres2math templates 2020-12-28 21:39:13 +01:00
b6c2a43a1b Drop indexed files 2020-12-28 19:38:10 +01:00
997b21e26a Commit migrations, they should not be too much updated 2020-12-28 19:30:51 +01:00
f1dbdde78d Commit migrations, they should not be too much updated 2020-12-28 19:28:53 +01:00
63f139be45 Wrong import order 2020-12-28 19:19:01 +01:00
0079b6d96d Don't use external libraries when running tests 2020-12-28 19:12:07 +01:00
67b01ea0b3 Make migrations before testing 2020-12-28 18:56:50 +01:00
7ef602c6cd Drop a lot of Corres2math content 2020-12-28 18:52:50 +01:00
f5ec9d1054 Add some properties to the models 2020-12-28 17:49:59 +01:00
ad1337209d Add gitlab ci file (warning: errors are coming) 2020-12-28 17:28:51 +01:00
18b4fa81d3 Prepare the data structure for the participations 2020-12-28 13:47:05 +01:00
d9a85948b3 Implement the data structure for members 2020-12-28 12:59:12 +01:00
03eca29316 Clone Corres2math platform 2020-12-27 11:49:54 +01:00
3d9bd88a41 Reset project 2020-12-27 11:14:35 +01:00
30fa8b7840 Validate emails 2020-09-20 21:24:52 +02:00
83d396a6dc Remove some useless templates 2020-09-19 22:23:03 +02:00
f6c209df03 Use a cron to send mails 2020-09-19 21:31:28 +02:00
d414f1a920 Smaller bashrc 2020-09-19 21:31:11 +02:00
b7cb5aa776 Use mailer to send mails 2020-09-19 21:13:45 +02:00
f2b498c352 Use datepicker inputs for birth date 2020-09-19 21:07:04 +02:00
dac4460c68 Better static files 2020-09-19 20:59:52 +02:00
067a266997 Commit migrations 2020-09-19 20:16:55 +02:00
2ecb13a68a Remove old PHP files 2020-09-19 15:22:51 +02:00
1ae6049974 Merge branch 'django' into 'master'
Django

See merge request animath/si/plateforme!4
2020-07-13 19:30:06 +00:00
db2ee8f78c Merge branch 'master' into 'django'
# Conflicts:
#   Dockerfile
2020-07-13 19:29:48 +00:00
c7f753cf09 🌱 Add README, improve settings 2020-07-13 21:27:36 +02:00
c30a0cdf86 🙈 Don't send any password to admins while registering 2020-07-13 20:29:05 +02:00
dd62a32e08 Use an alpine image rather than Debian buster: this is smaller 2020-06-03 20:42:12 +02:00
a1d02ce657 No longer use insecure mode, setup external nginx server (better performances) 2020-06-03 17:51:50 +02:00
3f83fd6ef3 Better tree 2020-06-02 22:32:10 +02:00
5ec2cf5acd Add symlinks per problem 2020-06-02 22:07:54 +02:00
dd4171b0e8 Fix extra token accesses when someone is already logged 2020-05-30 20:47:54 +02:00
1ef48fc3b4 Add a script to extract solutions, ordered in directories 2020-05-30 17:00:44 +02:00
6d01298e24 Authenticated juries get access with the extra token access 2020-05-25 22:12:05 +02:00
4a7d3c5604 Fix motivation letters 2020-05-25 19:24:19 +02:00
3d9e7136ac Add extra access to juries 2020-05-25 18:27:07 +02:00
522ed088ef Better pool rendering 2020-05-23 19:23:57 +02:00
132481fda0 First week fixes 2020-05-05 01:06:57 +02:00
a064cc1817 Fix broken send mail link 2020-04-27 14:25:40 +02:00
85f16ebd07 Add turbolinks and funny icons 2020-04-27 00:48:39 +02:00
a322ce4dfb Fix user deletion 2020-04-13 03:57:34 +02:00
50aec3c105 Changement confinement 2020-04-13 03:41:15 +02:00
a86bc3f124 Collect emails 2020-04-13 00:35:22 +02:00
45426e6835 Minor fixes 2020-02-21 23:26:47 +01:00
88dcb68aa8 Organizers can change name & trigram 2020-02-18 16:33:55 +01:00
d4fa8d9054 Les infos des respos légaux n'étaient pas sauvegardés ... 2020-02-18 16:10:18 +01:00
0018ce05ec Les organisateurs ne pouvaient pas valider les équipes 2020-02-18 15:46:34 +01:00
0dab65d82b 3 encadrants max par équipe 2020-02-18 15:33:55 +01:00
2c3e3ffcba Diverses coquilles 2020-02-14 21:23:36 +01:00
826e7f7c04 Suppression de toute mention des Correspondances qui errait 2020-02-14 21:12:10 +01:00
59985f8fc8 Corrections mineures 2020-02-03 16:44:38 +01:00
61d5af0651 Correction de bugs concernant les classes et écoles 2020-01-24 11:13:11 +01:00
2ee1c75d0c Sécurité 2020-01-22 22:18:55 +01:00
c64ef0646e Correction de certains mots 2020-01-22 22:06:53 +01:00
cd584f8bb6 Correction d'un bug majeur concernant l'inscription des participants 2020-01-22 21:11:54 +01:00
b16e40e15b Affichage de liens d'information pour les tournois 2020-01-22 13:43:27 +01:00
f8f3e7b41a Ouverture de la plateforme 2020-01-22 13:04:49 +01:00
939536a567 Clarification sur l'automatisation d'un message 2020-01-21 23:26:19 +01:00
48de59f630 Possibilité de modifier la page d'accueil 2020-01-21 23:23:14 +01:00
94f907abf2 Possibilité de supprimer un compte 2020-01-21 19:22:22 +01:00
6fe398d965 Modifications mineures 2020-01-21 12:43:13 +01:00
cd70de049a Modifications mail nouvau organisateur 2020-01-18 23:57:49 +01:00
7096f6fee1 Correction dans l'ajout d'organisateurs 2020-01-18 17:45:49 +01:00
eee1e9d68a Correction dans l'ajout d'organisateurs 2020-01-18 17:39:21 +01:00
a36a4cc728 Le premier inscrit est administrateur 2020-01-18 17:37:24 +01:00
9213258df4 Les équipes non validées ne peuvent pas procéder au paiement 2020-01-18 17:28:31 +01:00
1909dd3835 Génération automatique des fichiers 2020-01-18 17:26:12 +01:00
7d6e899f76 Support des lettres de motivation 2020-01-18 14:43:42 +01:00
b9299a31d0 Informations de paiement 2020-01-16 23:27:18 +01:00
9e7a7308be Corrections dans les mails 2020-01-16 23:00:31 +01:00
95ab142702 Diverses corrections 2020-01-16 22:04:29 +01:00
606ad5886f Validation des paiements faites par les admins 2020-01-14 17:16:27 +01:00
b86675ba98 Validation paiements 2020-01-14 12:21:18 +01:00
4b6d6f24ea Paiement 2020-01-02 00:09:02 +01:00
64ffcedbcc Problèmes de date 2020-01-01 22:41:08 +01:00
d81ad02235 Page de paiement (en cours) 2020-01-01 21:53:46 +01:00
69c453c408 Affichage du site en maintenance 2019-12-27 21:37:08 +01:00
1c6c480d4c Préparation pour la prise en charge du paiement 2019-12-27 14:54:44 +01:00
da8efde057 Ajouts & correction de bugs 2019-12-26 22:30:42 +01:00
e9f10ca14f Usurpation d'identité 2019-12-19 15:05:11 +01:00
7db606e6eb Redesign du site 2019-12-19 13:02:01 +01:00
a368dfbead Merge branch '2-add-a-home-page'
# Conflicts:
#	docker-compose.yml
#	server_files/config.php
#	server_files/views/connexion.php
#	setup/create_database.sql
2019-10-31 15:13:45 +01:00
7c935d067c Référencement Google 2019-10-22 22:53:07 +02:00
bf83e6534d Amélioration du code de la page "Tournoi" 2019-09-10 01:34:41 +02:00
cca62a99d0 Amélioration du code de la page "Notes de synthèse" (vue équipe) 2019-09-10 01:02:13 +02:00
44e3e3f639 Correction d'un problème dans le téléchargement des solutions (vue orga) 2019-09-10 00:57:33 +02:00
f5e73ae2ed Amélioration du code de la page des solutions (vue équipe) 2019-09-10 00:55:51 +02:00
98fb682c66 Amélioration du code de la page pour rejoindre une équipe 2019-09-10 00:49:55 +02:00
fffdaabe7c Fichier "Mon équipe" 2019-09-09 23:28:03 +02:00
fac2b29f4a Fichier "Mon compte" & modifications mineures 2019-09-09 22:42:38 +02:00
57d2bb9bec Optimisation des téléchargements ZIP 2019-09-09 12:15:48 +02:00
8eef72b104 💄 Ajout de jolis formulaires d'inscription
Signed-off-by: Hadrien RENAUD <hadrien.renaud@polytechnique.edu>
2019-09-09 02:11:02 +02:00
49a2fbe83e 💄 Remold the old inscription form
Signed-off-by: Hadrien RENAUD <hadrien.renaud@polytechnique.edu>
2019-09-09 02:11:02 +02:00
eb2fb734c6 💄 Rework on tournois.php
Signed-off-by: Hadrien RENAUD <hadrien.renaud@polytechnique.edu>
2019-09-09 02:11:02 +02:00
5004e1fb14 💄 Ajout d'une vraie home page
Signed-off-by: Hadrien RENAUD <hadrien.renaud@polytechnique.edu>
2019-09-09 02:11:02 +02:00
d691b3c849 💄 Add navbar 2019-09-09 02:11:02 +02:00
190039a5e8 Amélioration du code de la page de connexion 2019-09-09 00:41:52 +02:00
fbabdff69c Configuration du Dockerfile et support de l'envoi de mails 2019-09-08 23:10:59 +02:00
722fad4e6f Correction de problèmes mineurs 2019-09-08 22:54:57 +02:00
60344b896a 🐳 Edit docker-compose env
Signed-off-by: Hadrien RENAUD <hadrien.renaud@polytechnique.edu>
2019-09-08 22:20:00 +02:00
a4be91c8ee ♻️ Remove $URL_BASE from most html strings
Signed-off-by: Hadrien RENAUD <hadrien.renaud@polytechnique.edu>
2019-09-08 22:19:11 +02:00
1bf0316f2b Déploiement sur le serveur du TFJM² 2019-09-08 19:12:35 +02:00
08d0726af4 Création des tables de la base de données 2019-09-08 19:02:48 +02:00
f4fd072c0f 🐳 Add docker configuration 2019-09-08 16:04:31 +02:00
5771a15a32 Optimisation de l'envoi de mails 2019-09-08 12:45:48 +02:00
bd73e82cb0 Suppression d'erreurs 2019-09-08 12:44:00 +02:00
ca1a9e4415 Correction d'un problème 2019-09-08 01:47:30 +02:00
228c683dc8 Améliorations du code 2019-09-08 01:42:47 +02:00
a25ec69ae9 Améliorations du code 2019-09-08 01:35:05 +02:00
3cc66ef783 Les notes de synthèses n'ont pas à être copiées pour la finale 2019-09-07 21:06:49 +02:00
5a93a0a754 Correction de problèmes vis-à-vis de l'envoi et le téléchargement de fichiers 2019-09-07 19:01:23 +02:00
44e91a1f8b Ajout d'une classe pour les fichiers à télécharger, meilleur support des organisateurs d'un tournoi 2019-09-07 18:43:51 +02:00
8606ae7b95 Amélioration des fichiers d'ajout de tournoi 2019-09-07 18:08:40 +02:00
0f0c082437 Amélioration des fichiers d'ajout d'organisateur 2019-09-07 17:26:49 +02:00
945e1105b8 Quelques modifications 2019-09-07 17:26:30 +02:00
70cece8694 Amélioration de la classe d'ajout d'équipe 2019-09-07 16:58:23 +02:00
e5e197dd38 Début de gestion des mails et quelques modifications 2019-09-07 16:37:00 +02:00
7266fe8e24 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	server_files/controllers/inscription.php
2019-09-07 15:59:56 +02:00
b8dfe1a607 Restructuration de la page d'inscription 2019-09-07 15:52:25 +02:00
c6045a122f Restructuration de la page d'inscription 2019-09-07 15:51:16 +02:00
25a31b7f40 Le tournoi de la finale nationale est désormais une variable globale 2019-09-07 14:31:28 +02:00
cb760cb059 Utilisation d'un dispatcher pour gérer les redirections 2019-09-07 13:46:36 +02:00
4d3f6d1847 Utilisation d'un dispatcher pour gérer les redirections 2019-09-07 13:44:47 +02:00
ae648d7615 Initial commit 2019-09-07 13:44:41 +02:00
977f22af27 Utilisation de variables d'environnement pour les fichiers de configuration 2019-09-07 11:35:47 +02:00
fd861ca8c9 Quelques restrictions d'accès lors du téléchargement de fichiers 2019-09-07 01:55:10 +02:00
bffaf4b360 Utilisation des nouvelles classes, amélioration du code 2019-09-07 01:33:05 +02:00
b5d567e364 Nouveaux constructeurs pour les classes 2019-09-06 14:02:32 +02:00
a1ef162bdb Séparation vue et contrôleur 2019-09-06 13:48:50 +02:00
a1b4c42707 Déploiement automatique sur le serveur du TFJM² 2019-09-06 13:48:09 +02:00
3018d4c849 Affichage d'un message si le mod rewrite est désactivé 2019-09-06 12:29:42 +02:00
8e2ad0d15b Correction d'un petit bug 2019-09-06 12:27:23 +02:00
9c62d676e9 Création de quelques classes en vue d'une restructuration du code 2019-09-05 19:07:59 +02:00
d2a0d0fbec Modification mineure 2019-09-05 19:07:41 +02:00
39abeec4e6 Support de la finale 2019-09-03 00:01:54 +02:00
10da20f2c0 Erreur 404 si le tournoi n'existe pas 2019-09-02 22:01:26 +02:00
2ce1f83873 Seuls les organisateurs nationaux peuvent valider des équipes 2019-09-02 21:56:28 +02:00
8590d8f730 Pas d'affichage de description pour qui n'en a pas 2019-09-02 21:25:06 +02:00
683b8c71b7 Quelques éléments de vérification de sécurité 2019-09-02 21:21:37 +02:00
946d261c71 Modifications sur les pages d'erreur 2019-09-02 21:19:21 +02:00
273bd05944 Quelques vérifications temporelles et autres 2019-09-02 20:57:26 +02:00
35aed16e10 Typo 2019-09-02 20:08:20 +02:00
fc6f039212 Diminution des marges 2019-09-02 20:07:18 +02:00
885723af5f Lien du tournoi cliquable 2019-09-02 19:56:41 +02:00
864d94c51d Possibilité de voir les informations des organisateurs d'un tournoi (pour admins et autres orgas) 2019-09-02 19:52:13 +02:00
c2eba2bb2e Ajout d'une page de visualisation des informations personnelles des participants 2019-09-02 19:51:43 +02:00
7adba3f047 Correction d'un problème dans les pages d'erreur 2019-09-02 19:45:00 +02:00
4f4a3aaf4d Ajout d'une favicon 2019-09-02 18:00:43 +02:00
7b678e4683 Dossier server_files désormais inaccessible 2019-09-02 17:54:29 +02:00
e9579e7e94 L'adresse mail doit être confirmée pour pouvoir se connecter 2019-09-02 17:29:27 +02:00
0b1c3cb86e Ajout de l'option de réinitialisation de mot de passe 2019-09-02 16:39:57 +02:00
c3de4a9914 Améliorations au niveau de l'inscription 2019-08-28 00:28:07 +02:00
8b90877088 Organisateurs multiples, modification des tournois 2019-08-26 20:14:29 +02:00
7a81d09b88 Ajout d'un séparateur horizontal 2019-08-26 16:51:07 +02:00
2272a8b45d Possibilité de télécharger une archive des solutions et notes de synthèse pour les organisateurs 2019-08-26 16:27:55 +02:00
369fb4fd5b La page "mon compte" n'était pas accessible 2019-08-26 12:18:51 +02:00
1f186b43f7 Possibilité de modifier nom et trigramme d'une équipe avant validation 2019-08-26 12:16:39 +02:00
4604ddd758 Les organisateurs peuvent valider une équipe 2019-08-24 11:45:18 +02:00
311cb66cdd Initial commit 2019-08-21 22:56:46 +02:00
446 changed files with 53184 additions and 6436 deletions

6
.bashrc Normal file
View File

@ -0,0 +1,6 @@
PS1='\[\033[01;31m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '
alias ls='ls --color=auto'
alias ll='ls -l'
alias la='ls -A'
alias l='ls -lACF'

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
__pycache__
media
db.sqlite3

24
.gitignore vendored
View File

@ -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
View File

@ -0,0 +1,29 @@
stages:
- test
- quality-assurance
py312:
stage: test
image: python:3.12-alpine
before_script:
- apk add --no-cache libmagic
- apk add --no-cache gettext
- pip install tox --no-cache-dir
script: tox -e py312
py313:
stage: test
image: python:3.13-alpine
before_script:
- apk add --no-cache libmagic
- apk add --no-cache gettext
- pip install tox --no-cache-dir
script: tox -e py313
linters:
stage: quality-assurance
image: python:3-alpine
before_script:
- pip install tox --no-cache-dir
script: tox -e linters
allow_failure: true

View File

@ -1,18 +1,40 @@
FROM python:3-buster
FROM python:3.13-alpine
ENV PYTHONUNBUFFERED 1
ENV DJANGO_ALLOW_ASYNC_UNSAFE 1
RUN mkdir /code
RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libpq-dev libxml2-dev libxslt-dev \
npm libmagic texlive texmf-dist-fontsrecommended texmf-dist-lang texmf-dist-latexextra
RUN apk add --no-cache bash
RUN npm install -g yuglify
RUN mkdir /code /code/docs
WORKDIR /code
# Install LaTeX requirements
RUN apt update && \
apt install -y gettext texlive-latex-extra texlive-fonts-extra texlive-lang-french && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt /code/requirements.txt
COPY docs/requirements.txt /code/docs/requirements.txt
RUN pip install -r requirements.txt --no-cache-dir
RUN pip install -r docs/requirements.txt --no-cache-dir
COPY . /code/
RUN pip install -r requirements.txt
# Compile documentation
RUN sphinx-build -M html docs docs/_build
RUN python manage.py collectstatic --noinput && \
python manage.py compilemessages
# Configure nginx
RUN ln -sf /dev/stdout /var/log/nginx/access.log && ln -sf /dev/stderr /var/log/nginx/error.log
RUN ln -sf /code/nginx_tfjm.conf /etc/nginx/http.d/tfjm.conf && rm /etc/nginx/http.d/default.conf
RUN crontab /code/tfjm.cron
# With a bashrc, the shell is better
RUN ln -s /code/.bashrc /root/.bashrc
ENTRYPOINT ["/code/entrypoint.sh"]
EXPOSE 8000
EXPOSE 80
CMD ["./manage.py", "shell_plus", "--ipython"]

674
LICENSE Normal file
View File

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) 2020 Animath
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) 2020 Animath
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

69
README.md Normal file
View File

@ -0,0 +1,69 @@
# Plateforme du TFJM²
[![pipeline status](https://gitlab.com/animath/si/plateforme-tfjm/badges/master/pipeline.svg)](https://gitlab.com/animath/si/plateforme-tfjm/-/commits/master)
[![coverage report](https://gitlab.com/animath/si/plateforme-tfjm/badges/master/coverage.svg)](https://gitlab.com/animath/si/plateforme-tfjm/-/commits/master)
La plateforme du TFJM² est née pour la dixième édition en 2019 de l'action.
D'abord codée en PHP, elle a subi une refonte totale en Python, à l'aide du framework Web [Django](https://www.djangoproject.com/).
Cette plateforme permet aux participants et encadrants de s'inscrire et de déposer leurs autorisations nécessaires.
Ils pourront ensuite déposer leurs solutions et notes de synthèse pour le premier tour en temps voulu. La plateforme
offre également un accès pour les organisateurs et les jurys leur permettant de communiquer avec les équipes et de
récupérer les documents nécessaires.
Un wiki plus détaillé arrivera ultérieurement. L'interface organisateur et jury est vouée à être plus poussée.
L'instance de production est disponible à l'adresse [inscription.tfjm.org](https://inscription.tfjm.org).
## Installation
Le plus simple pour installer la plateforme est d'utiliser l'image Docker incluse, qui fait tourner un serveur Nginx
exposé sur le port 80 avec le serveur Django. Ci-dessous une configuration Docker-Compose, à adapter selon vos besoins :
```yaml
plateforme-tfjm:
build: https://gitlab.com/animath/si/plateforme-tfjm.git
links:
- postgres
ports:
- "80:80"
env_file:
- ./inscription-tfjm.env
volumes:
# - ./inscription-tfjm:/code
- ./inscription-tfjm/media:/code/media
```
Le volume `/code` n'est à ajouter uniquement en développement, et jamais en production.
Il faut remplir les variables d'environnement suivantes :
```env
TFJM_STAGE= # dev ou prod
DJANGO_DB_TYPE= # MySQL, PostgreSQL ou SQLite (par défaut)
DJANGO_DB_HOST= # Hôte de la base de données
DJANGO_DB_NAME= # Nom de la base de données
DJANGO_DB_USER= # Utilisateur de la base de données
DJANGO_DB_PASSWORD= # Mot de passe pour accéder à la base de données
SMTP_HOST= # Hôte SMTP pour l'envoi de mails
SMTP_PORT=465 # Port du serveur SMTP
SMTP_HOST_USER= # Utilisateur du compte SMTP
SMTP_HOST_PASSWORD= # Mot de passe du compte SMTP
FROM_EMAIL=contact@tfjm.org # Nom de l'expéditeur des mails
SERVER_EMAIL=contact@tfjm.org # Adresse e-mail expéditrice
SYMPA_URL=lists.example.com # Serveur Sympa à utiliser
SYMPA_EMAIL= # Adresse e-mail du compte administrateur de Sympa
SYMPA_PASSWORD= # Mot de passe du compte administrateur de Sympa
```
Si le type de base de données sélectionné est SQLite, la variable `DJANGO_DB_HOST` sera utilisée en guise de chemin vers
le fichier de base de données (par défaut, `db.sqlite3`).
En développement, il est recommandé d'utiliser SQLite pour des raisons de simplicité. Les paramètres de mail ne seront
pas utilisés, et les mails qui doivent être envoyés seront envoyés dans la console. L'intégration mail
seront également désactivées.
En production, il est recommandé de ne pas utiliser SQLite pour des raisons de performances.
La dernière différence entre le développment et la production est qu'en développement, chaque modification d'un fichier
est détectée et le serveur se relance automatiquement dès lors.

4
api/__init__.py Normal file
View File

@ -0,0 +1,4 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = 'api.apps.APIConfig'

View File

@ -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
View 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
View 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
View 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
View 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', ]

View File

@ -1 +0,0 @@
default_app_config = 'api.apps.APIConfig'

View File

@ -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__"

View File

@ -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')),
]

View File

@ -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)

View File

@ -1 +0,0 @@
default_app_config = 'member.apps.MemberConfig'

View File

@ -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.
"""

View File

@ -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')

View File

@ -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',)

View File

@ -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()

View File

@ -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"))

View File

@ -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")

View File

@ -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'
}

View File

@ -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)

View File

@ -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"),
]

View File

@ -1,265 +0,0 @@
import random
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import PermissionDenied
from django.db.models import Q
from django.http import FileResponse, Http404
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.generic import CreateView, UpdateView, DetailView, FormView
from django_tables2 import SingleTableView
from tournament.forms import TeamForm, JoinTeam
from tournament.models import Team, Tournament
from tournament.views import AdminMixin, TeamMixin, OrgaMixin
from .forms import SignUpForm, TFJMUserForm, AdminUserForm, CoachUserForm
from .models import TFJMUser, Document, Solution, MotivationLetter, Synthesis
from .tables import UserTable
class CreateUserView(CreateView):
"""
Signup form view.
"""
model = TFJMUser
form_class = SignUpForm
template_name = "registration/signup.html"
class MyAccountView(LoginRequiredMixin, UpdateView):
"""
Update our personal data.
"""
model = TFJMUser
template_name = "member/my_account.html"
def get_form_class(self):
# The used form can change according to the role of the user.
return AdminUserForm if self.request.user.organizes else TFJMUserForm \
if self.request.user.role == "3participant" else CoachUserForm
def get_object(self, queryset=None):
return self.request.user
def get_success_url(self):
return reverse_lazy('member:my_account')
class UserDetailView(LoginRequiredMixin, DetailView):
"""
View the personal information of a given user.
Only organizers can see this page, since there are personal data.
"""
model = TFJMUser
form_class = TFJMUserForm
context_object_name = "tfjmuser"
def dispatch(self, request, *args, **kwargs):
if isinstance(request.user, AnonymousUser):
raise PermissionDenied
self.object = self.get_object()
if not request.user.admin \
and (self.object.team is not None and request.user not in self.object.team.tournament.organizers.all())\
and (self.object.team is not None and self.object.team.selected_for_final
and request.user not in Tournament.get_final().organizers.all())\
and self.request.user != self.object:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
"""
An administrator can log in through this page as someone else, and act as this other person.
"""
if "view_as" in request.POST and self.request.user.admin:
session = request.session
session["admin"] = request.user.pk
obj = self.get_object()
session["_fake_user_id"] = obj.pk
return redirect(request.path)
return self.get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["title"] = str(self.object)
return context
class AddTeamView(LoginRequiredMixin, CreateView):
"""
Register a new team.
Users can choose the name, the trigram and a preferred tournament.
"""
model = Team
form_class = TeamForm
def form_valid(self, form):
if self.request.user.organizes:
form.add_error('name', _("You can't organize and participate at the same time."))
return self.form_invalid(form)
if self.request.user.team:
form.add_error('name', _("You are already in a team."))
return self.form_invalid(form)
# Generate a random access code
team = form.instance
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz0123456789"
code = ""
for i in range(6):
code += random.choice(alphabet)
team.access_code = code
team.validation_status = "0invalid"
team.save()
team.refresh_from_db()
self.request.user.team = team
self.request.user.save()
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy("member:my_team")
class JoinTeamView(LoginRequiredMixin, FormView):
"""
Join a team with a given access code.
"""
model = Team
form_class = JoinTeam
template_name = "tournament/team_form.html"
def form_valid(self, form):
team = form.cleaned_data["team"]
if self.request.user.organizes:
form.add_error('access_code', _("You can't organize and participate at the same time."))
return self.form_invalid(form)
if self.request.user.team:
form.add_error('access_code', _("You are already in a team."))
return self.form_invalid(form)
if self.request.user.role == '2coach' and len(team.coaches) == 3:
form.add_error('access_code', _("This team is full of coachs."))
return self.form_invalid(form)
if self.request.user.role == '3participant' and len(team.participants) == 6:
form.add_error('access_code', _("This team is full of participants."))
return self.form_invalid(form)
if not team.invalid:
form.add_error('access_code', _("This team is already validated or waiting for validation."))
self.request.user.team = team
self.request.user.save()
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy("member:my_team")
class MyTeamView(TeamMixin, View):
"""
Redirect to the page of the information of our personal team.
"""
def get(self, request, *args, **kwargs):
return redirect("tournament:team_detail", pk=request.user.team.pk)
class DocumentView(LoginRequiredMixin, View):
"""
View a PDF document, if we have the right.
- Everyone can see the documents that concern itself.
- An administrator can see anything.
- An organizer can see documents that are related to its tournament.
- A jury can see solutions and syntheses that are evaluated in their pools.
"""
def get(self, request, *args, **kwargs):
try:
doc = Document.objects.get(file=self.kwargs["file"])
except Document.DoesNotExist:
raise Http404(_("No %(verbose_name)s found matching the query") %
{'verbose_name': Document._meta.verbose_name})
grant = request.user.admin
if isinstance(doc, Solution) or isinstance(doc, Synthesis) or isinstance(doc, MotivationLetter):
grant = grant or doc.team == request.user.team or request.user in doc.team.tournament.organizers.all()
grant = grant or (doc.team.selected_for_final and request.user in Tournament.get_final().organizers.all())
if isinstance(doc, Synthesis) and request.user.organizes:
grant = True
if isinstance(doc, Solution):
for pool in doc.pools.all():
if request.user in pool.juries.all():
grant = True
break
if pool.round == 2 and timezone.now() < doc.tournament.date_solutions_2:
continue
if self.request.user.team in pool.teams.all():
grant = True
if not grant:
raise PermissionDenied
return FileResponse(doc.file, content_type="application/pdf", filename=str(doc) + ".pdf")
class ProfileListView(AdminMixin, SingleTableView):
"""
List all registered profiles.
"""
model = TFJMUser
queryset = TFJMUser.objects.order_by("role", "last_name", "first_name")
table_class = UserTable
template_name = "member/profile_list.html"
extra_context = dict(title=_("All profiles"), type="all")
class OrphanedProfileListView(AdminMixin, SingleTableView):
"""
List all orphaned profiles, ie. participants that have no team.
"""
model = TFJMUser
queryset = TFJMUser.objects.filter((Q(role="2coach") | Q(role="3participant")) & Q(team__isnull=True))\
.order_by("role", "last_name", "first_name")
table_class = UserTable
template_name = "member/profile_list.html"
extra_context = dict(title=_("Orphaned profiles"), type="orphaned")
class OrganizersListView(OrgaMixin, SingleTableView):
"""
List all organizers.
"""
model = TFJMUser
queryset = TFJMUser.objects.filter(Q(role="0admin") | Q(role="1volunteer"))\
.order_by("role", "last_name", "first_name")
table_class = UserTable
template_name = "member/profile_list.html"
extra_context = dict(title=_("Organizers"), type="organizers")
class ResetAdminView(AdminMixin, View):
"""
Return to admin view, clear the session field that let an administrator to log in as someone else.
"""
def dispatch(self, request, *args, **kwargs):
if "_fake_user_id" in request.session:
del request.session["_fake_user_id"]
return redirect(request.GET["path"])

View File

@ -1 +0,0 @@
default_app_config = 'tournament.apps.TournamentConfig'

View File

@ -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.
"""

View File

@ -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')

View File

@ -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',)

View File

@ -1,417 +0,0 @@
import os
from django.core.mail import send_mail
from django.db import models
from django.template.loader import render_to_string
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
class Tournament(models.Model):
"""
Store the information of a tournament.
"""
name = models.CharField(
max_length=255,
verbose_name=_("name"),
)
organizers = models.ManyToManyField(
'member.TFJMUser',
related_name="organized_tournaments",
verbose_name=_("organizers"),
help_text=_("List of all organizers that can see and manipulate data of the tournament and the teams."),
)
size = models.PositiveSmallIntegerField(
verbose_name=_("size"),
help_text=_("Number of teams that are allowed to join the tournament."),
)
place = models.CharField(
max_length=255,
verbose_name=_("place"),
)
price = models.PositiveSmallIntegerField(
verbose_name=_("price"),
help_text=_("Price asked to participants. Free with a scholarship."),
)
description = models.TextField(
verbose_name=_("description"),
)
date_start = models.DateField(
default=timezone.now,
verbose_name=_("date start"),
)
date_end = models.DateField(
default=timezone.now,
verbose_name=_("date end"),
)
date_inscription = models.DateTimeField(
default=timezone.now,
verbose_name=_("date of registration closing"),
)
date_solutions = models.DateTimeField(
default=timezone.now,
verbose_name=_("date of maximal solution submission"),
)
date_syntheses = models.DateTimeField(
default=timezone.now,
verbose_name=_("date of maximal syntheses submission for the first round"),
)
date_solutions_2 = models.DateTimeField(
default=timezone.now,
verbose_name=_("date when solutions of round 2 are available"),
)
date_syntheses_2 = models.DateTimeField(
default=timezone.now,
verbose_name=_("date of maximal syntheses submission for the second round"),
)
final = models.BooleanField(
verbose_name=_("final tournament"),
help_text=_("It should be only one final tournament."),
)
year = models.PositiveIntegerField(
default=os.getenv("TFJM_YEAR", timezone.now().year),
verbose_name=_("year"),
)
@property
def teams(self):
"""
Get all teams that are registered to this tournament, with a distinction for the final tournament.
"""
return self._teams if not self.final else Team.objects.filter(selected_for_final=True)
@property
def linked_organizers(self):
"""
Display a list of the organizers with links to their personal page.
"""
return ['<a href="{url}">'.format(url=reverse_lazy("member:information", args=(user.pk,))) + str(user) + '</a>'
for user in self.organizers.all()]
@property
def solutions(self):
"""
Get all sent solutions for this tournament.
"""
from member.models import Solution
return Solution.objects.filter(final=self.final) if self.final \
else Solution.objects.filter(team__tournament=self, final=False)
@property
def syntheses(self):
"""
Get all sent syntheses for this tournament.
"""
from member.models import Synthesis
return Synthesis.objects.filter(final=self.final) if self.final \
else Synthesis.objects.filter(team__tournament=self, final=False)
@classmethod
def get_final(cls):
"""
Get the final tournament.
This should exist and be unique.
"""
return cls.objects.get(year=os.getenv("TFJM_YEAR"), final=True)
class Meta:
verbose_name = _("tournament")
verbose_name_plural = _("tournaments")
def send_mail_to_organizers(self, template_name, subject="Contact TFJM²", **kwargs):
"""
Send a mail to all organizers of the tournament.
The template of the mail should be found either in templates/mail_templates/<template_name>.html for the HTML
version and in templates/mail_templates/<template_name>.txt for the plain text version.
The context of the template contains the tournament and the user. Extra context can be given through the kwargs.
"""
context = kwargs
context["tournament"] = self
for user in self.organizers.all():
context["user"] = user
message = render_to_string("mail_templates/" + template_name + ".txt", context=context)
message_html = render_to_string("mail_templates/" + template_name + ".html", context=context)
send_mail(subject, message, "contact@tfjm.org", [user.email], html_message=message_html)
from member.models import TFJMUser
for user in TFJMUser.objects.get(is_superuser=True).all():
context["user"] = user
message = render_to_string("mail_templates/" + template_name + ".txt", context=context)
message_html = render_to_string("mail_templates/" + template_name + ".html", context=context)
send_mail(subject, message, "contact@tfjm.org", [user.email], html_message=message_html)
def __str__(self):
return self.name
class Team(models.Model):
"""
Store information about a registered team.
"""
name = models.CharField(
max_length=255,
verbose_name=_("name"),
)
trigram = models.CharField(
max_length=3,
verbose_name=_("trigram"),
help_text=_("The trigram should be composed of 3 capitalize letters, that is a funny acronym for the team."),
)
tournament = models.ForeignKey(
Tournament,
on_delete=models.PROTECT,
related_name="_teams",
verbose_name=_("tournament"),
help_text=_("The tournament where the team is registered."),
)
inscription_date = models.DateTimeField(
auto_now_add=True,
verbose_name=_("inscription date"),
)
validation_status = models.CharField(
max_length=8,
choices=[
("0invalid", _("Registration not validated")),
("1waiting", _("Waiting for validation")),
("2valid", _("Registration validated")),
],
verbose_name=_("validation status"),
)
selected_for_final = models.BooleanField(
default=False,
verbose_name=_("selected for final"),
)
access_code = models.CharField(
max_length=6,
unique=True,
verbose_name=_("access code"),
)
year = models.PositiveIntegerField(
default=os.getenv("TFJM_YEAR", timezone.now().year),
verbose_name=_("year"),
)
@property
def valid(self):
return self.validation_status == "2valid"
@property
def waiting(self):
return self.validation_status == "1waiting"
@property
def invalid(self):
return self.validation_status == "0invalid"
@property
def coaches(self):
"""
Get all coaches of a team.
"""
return self.users.all().filter(role="2coach")
@property
def linked_coaches(self):
"""
Get a list of the coaches of a team with html links to their pages.
"""
return ['<a href="{url}">'.format(url=reverse_lazy("member:information", args=(user.pk,))) + str(user) + '</a>'
for user in self.coaches]
@property
def participants(self):
"""
Get all particpants of a team, coaches excluded.
"""
return self.users.all().filter(role="3participant")
@property
def linked_participants(self):
"""
Get a list of the participants of a team with html links to their pages.
"""
return ['<a href="{url}">'.format(url=reverse_lazy("member:information", args=(user.pk,))) + str(user) + '</a>'
for user in self.participants]
@property
def future_tournament(self):
"""
Get the last tournament where the team is registered.
Only matters if the team is selected for final: if this is the case, we return the final tournament.
Useful for deadlines.
"""
return Tournament.get_final() if self.selected_for_final else self.tournament
@property
def can_validate(self):
"""
Check if a given team is able to ask for validation.
A team can validate if:
* All participants filled the photo consent
* Minor participants filled the parental consent
* Minor participants filled the sanitary plug
* Teams sent their motivation letter
* The team contains at least 4 participants
* The team contains at least 1 coach
"""
# TODO In a normal time, team needs a motivation letter and authorizations.
return self.coaches.exists() and self.participants.count() >= 4\
and self.tournament.date_inscription <= timezone.now()
class Meta:
verbose_name = _("team")
verbose_name_plural = _("teams")
unique_together = (('name', 'year',), ('trigram', 'year',),)
def send_mail(self, template_name, subject="Contact TFJM²", **kwargs):
"""
Send a mail to all members of a team with a given template.
The template of the mail should be found either in templates/mail_templates/<template_name>.html for the HTML
version and in templates/mail_templates/<template_name>.txt for the plain text version.
The context of the template contains the team and the user. Extra context can be given through the kwargs.
"""
context = kwargs
context["team"] = self
for user in self.users.all():
context["user"] = user
message = render_to_string("mail_templates/" + template_name + ".txt", context=context)
message_html = render_to_string("mail_templates/" + template_name + ".html", context=context)
send_mail(subject, message, "contact@tfjm.org", [user.email], html_message=message_html)
def __str__(self):
return self.trigram + " -- " + self.name
class Pool(models.Model):
"""
Store information of a pool.
A pool is only a list of accessible solutions to some teams and some juries.
TODO: check that the set of teams is equal to the set of the teams that have a solution in this set.
TODO: Moreover, a team should send only one solution.
"""
teams = models.ManyToManyField(
Team,
related_name="pools",
verbose_name=_("teams"),
)
solutions = models.ManyToManyField(
"member.Solution",
related_name="pools",
verbose_name=_("solutions"),
)
round = models.PositiveIntegerField(
choices=[
(1, _("Round 1")),
(2, _("Round 2")),
],
verbose_name=_("round"),
)
juries = models.ManyToManyField(
"member.TFJMUser",
related_name="pools",
verbose_name=_("juries"),
)
@property
def problems(self):
"""
Get problem numbers of the sent solutions as a list of integers.
"""
return list(d["problem"] for d in self.solutions.values("problem").all())
@property
def tournament(self):
"""
Get the concerned tournament.
We assume that the pool is correct, so all solutions belong to the same tournament.
"""
return self.solutions.first().tournament
@property
def syntheses(self):
"""
Get the syntheses of the teams that are in this pool, for the correct round.
"""
from member.models import Synthesis
return Synthesis.objects.filter(team__in=self.teams.all(), round=self.round, final=self.tournament.final)
class Meta:
verbose_name = _("pool")
verbose_name_plural = _("pools")
class Payment(models.Model):
"""
Store some information about payments, to recover data.
TODO: handle it...
"""
user = models.OneToOneField(
'member.TFJMUser',
on_delete=models.CASCADE,
related_name="payment",
verbose_name=_("user"),
)
team = models.ForeignKey(
Team,
on_delete=models.CASCADE,
related_name="payments",
verbose_name=_("team"),
)
method = models.CharField(
max_length=16,
choices=[
("not_paid", _("Not paid")),
("credit_card", _("Credit card")),
("check", _("Bank check")),
("transfer", _("Bank transfer")),
("cash", _("Cash")),
("scholarship", _("Scholarship")),
],
default="not_paid",
verbose_name=_("payment method"),
)
validation_status = models.CharField(
max_length=8,
choices=[
("0invalid", _("Registration not validated")),
("1waiting", _("Waiting for validation")),
("2valid", _("Registration validated")),
],
verbose_name=_("validation status"),
)
class Meta:
verbose_name = _("payment")
verbose_name_plural = _("payments")
def __str__(self):
return _("Payment of {user}").format(str(self.user))

View File

@ -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'
}

View File

@ -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"),
]

View File

@ -1,646 +0,0 @@
import random
import zipfile
from datetime import timedelta
from io import BytesIO
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied
from django.core.mail import send_mail
from django.db.models import Q
from django.http import HttpResponse
from django.shortcuts import redirect
from django.template.loader import render_to_string
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, CreateView, UpdateView
from django.views.generic.edit import BaseFormView
from django_tables2.views import SingleTableView
from member.models import TFJMUser, Solution, Synthesis
from .forms import TournamentForm, OrganizerForm, SolutionForm, SynthesisForm, TeamForm, PoolForm
from .models import Tournament, Team, Pool
from .tables import TournamentTable, TeamTable, SolutionTable, SynthesisTable, PoolTable
class AdminMixin(LoginRequiredMixin):
"""
If a view extends this mixin, then the view will be only accessible to administrators.
"""
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated or not request.user.admin:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class OrgaMixin(LoginRequiredMixin):
"""
If a view extends this mixin, then the view will be only accessible to administrators or organizers.
"""
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated or not request.user.organizes:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class TeamMixin(LoginRequiredMixin):
"""
If a view extends this mixin, then the view will be only accessible to users that are registered in a team.
"""
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated or not request.user.team:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class TournamentListView(SingleTableView):
"""
Display the list of all tournaments, ordered by start date then name.
"""
model = Tournament
table_class = TournamentTable
extra_context = dict(title=_("Tournaments list"),)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
team_users = TFJMUser.objects.filter(Q(team__isnull=False) | Q(role="admin") | Q(role="organizer"))\
.order_by('-role')
valid_team_users = team_users.filter(
Q(team__validation_status="2valid") | Q(role="admin") | Q(role="organizer"))
context["team_users_emails"] = [user.email for user in team_users]
context["valid_team_users_emails"] = [user.email for user in valid_team_users]
return context
class TournamentCreateView(AdminMixin, CreateView):
"""
Create a tournament. Only accessible to admins.
"""
model = Tournament
form_class = TournamentForm
extra_context = dict(title=_("Add tournament"),)
def get_success_url(self):
return reverse_lazy('tournament:detail', args=(self.object.pk,))
class TournamentDetailView(DetailView):
"""
Display the detail of a tournament.
Accessible to all, including not authenticated users.
"""
model = Tournament
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["title"] = _("Tournament of {name}").format(name=self.object.name)
if self.object.final:
team_users = TFJMUser.objects.filter(team__selected_for_final=True)
valid_team_users = team_users
else:
team_users = TFJMUser.objects.filter(
Q(team__tournament=self.object)
| Q(organized_tournaments=self.object)).order_by('role')
valid_team_users = team_users.filter(
Q(team__validation_status="2valid")
| Q(role="admin")
| Q(organized_tournaments=self.object))
context["team_users_emails"] = [user.email for user in team_users]
context["valid_team_users_emails"] = [user.email for user in valid_team_users]
context["teams"] = TeamTable(self.object.teams.all())
return context
class TournamentUpdateView(OrgaMixin, UpdateView):
"""
Update the data of a tournament.
Reserved to admins and organizers of the tournament.
"""
def dispatch(self, request, *args, **kwargs):
"""
Restrict the view to organizers of tournaments, then process the request.
"""
if self.request.user.role == "1volunteer" and self.request.user not in self.get_object().organizers.all():
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
model = Tournament
form_class = TournamentForm
extra_context = dict(title=_("Update tournament"),)
def get_success_url(self):
return reverse_lazy('tournament:detail', args=(self.object.pk,))
class TeamDetailView(LoginRequiredMixin, DetailView):
"""
View the detail of a team.
Restricted to this team, admins and organizers of its tournament.
"""
model = Team
def dispatch(self, request, *args, **kwargs):
"""
Protect the page and process the request.
"""
if not request.user.is_authenticated or \
(not request.user.admin and self.request.user not in self.get_object().tournament.organizers.all()
and not (self.get_object().selected_for_final
and request.user in Tournament.get_final().organizers.all())
and self.get_object() != request.user.team):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
"""
Process POST requests. Supported requests:
- get the solutions of the team as a ZIP archive
- a user leaves its team (if the composition is not validated yet)
- the team requests the validation
- Organizers can validate or invalidate the request
- Admins can delete teams
- Admins can select teams for the final tournament
"""
team = self.get_object()
if "zip" in request.POST:
solutions = team.solutions.all()
out = BytesIO()
zf = zipfile.ZipFile(out, "w")
for solution in solutions:
zf.write(solution.file.path, str(solution) + ".pdf")
zf.close()
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
resp['Content-Disposition'] = 'attachment; filename={}'\
.format(_("Solutions for team {team}.zip")
.format(team=str(team)).replace(" ", "%20"))
return resp
elif "leave" in request.POST and request.user.participates:
request.user.team = None
request.user.save()
if not team.users.exists():
team.delete()
return redirect('tournament:detail', pk=team.tournament.pk)
elif "request_validation" in request.POST and request.user.participates and team.can_validate:
team.validation_status = "1waiting"
team.save()
team.tournament.send_mail_to_organizers("request_validation", "Demande de validation TFJM²", team=team)
return redirect('tournament:team_detail', pk=team.pk)
elif "validate" in request.POST and request.user.organizes:
team.validation_status = "2valid"
team.save()
team.send_mail("validate_team", "Équipe validée TFJM²")
return redirect('tournament:team_detail', pk=team.pk)
elif "invalidate" in request.POST and request.user.organizes:
team.validation_status = "0invalid"
team.save()
team.send_mail("unvalidate_team", "Équipe non validée TFJM²")
return redirect('tournament:team_detail', pk=team.pk)
elif "delete" in request.POST and request.user.organizes:
team.delete()
return redirect('tournament:detail', pk=team.tournament.pk)
elif "select_final" in request.POST and request.user.admin and not team.selected_for_final and team.pools:
# We copy all solutions for solutions for the final
for solution in team.solutions.all():
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz0123456789"
id = ""
for i in range(64):
id += random.choice(alphabet)
with solution.file.open("rb") as source:
with open("/code/media/" + id, "wb") as dest:
for chunk in source.chunks():
dest.write(chunk)
new_sol = Solution(
file=id,
team=team,
problem=solution.problem,
final=True,
)
new_sol.save()
team.selected_for_final = True
team.save()
team.send_mail("select_for_final", "Sélection pour la finale, félicitations ! - TFJM²",
final=Tournament.get_final())
return redirect('tournament:team_detail', pk=team.pk)
return self.get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["title"] = _("Information about team")
context["ordered_solutions"] = self.object.solutions.order_by('problem').all()
context["team_users_emails"] = [user.email for user in self.object.users.all()]
return context
class TeamUpdateView(LoginRequiredMixin, UpdateView):
"""
Update the information about a team.
Team members, admins and organizers are allowed to do this.
"""
model = Team
form_class = TeamForm
extra_context = dict(title=_("Update team"),)
def dispatch(self, request, *args, **kwargs):
if not request.user.admin and self.request.user not in self.get_object().tournament.organizers.all() \
and self.get_object() != self.request.user.team:
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
class AddOrganizerView(AdminMixin, CreateView):
"""
Add a new organizer account. No password is created, the user should reset its password using the link
sent by mail. Only name and email are requested.
Only admins are granted to do this.
"""
model = TFJMUser
form_class = OrganizerForm
extra_context = dict(title=_("Add organizer"),)
template_name = "tournament/add_organizer.html"
def form_valid(self, form):
user = form.instance
msg = render_to_string("mail_templates/add_organizer.txt", context=dict(user=user))
msg_html = render_to_string("mail_templates/add_organizer.html", context=dict(user=user))
send_mail('Organisateur du TFJM² 2020', msg, 'contact@tfjm.org', [user.email], html_message=msg_html)
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy('index')
class SolutionsView(TeamMixin, BaseFormView, SingleTableView):
"""
Upload and view solutions for a team.
"""
model = Solution
table_class = SolutionTable
form_class = SolutionForm
template_name = "tournament/solutions_list.html"
extra_context = dict(title=_("Solutions"))
def post(self, request, *args, **kwargs):
if "zip" in request.POST:
solutions = request.user.team.solutions
out = BytesIO()
zf = zipfile.ZipFile(out, "w")
for solution in solutions:
zf.write(solution.file.path, str(solution) + ".pdf")
zf.close()
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
resp['Content-Disposition'] = 'attachment; filename={}'\
.format(_("Solutions for team {team}.zip")
.format(team=str(request.user.team)).replace(" ", "%20"))
return resp
return super().post(request, *args, **kwargs)
def get_context_data(self, **kwargs):
self.object_list = self.get_queryset()
context = super().get_context_data(**kwargs)
context["now"] = timezone.now()
context["real_deadline"] = self.request.user.team.future_tournament.date_solutions + timedelta(minutes=30)
return context
def get_queryset(self):
qs = super().get_queryset().filter(team=self.request.user.team)
return qs.order_by('final', 'team__tournament__date_start', 'team__tournament__name', 'team__trigram',
'problem',)
def form_valid(self, form):
solution = form.instance
solution.team = self.request.user.team
solution.final = solution.team.selected_for_final
if timezone.now() > solution.tournament.date_solutions + timedelta(minutes=30):
form.add_error('file', _("You can't publish your solution anymore. Deadline: {date:%m-%d-%Y %H:%M}.")
.format(date=timezone.localtime(solution.tournament.date_solutions)))
return super().form_invalid(form)
prev_sol = Solution.objects.filter(problem=solution.problem, team=solution.team, final=solution.final)
for sol in prev_sol.all():
sol.delete()
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz0123456789"
id = ""
for i in range(64):
id += random.choice(alphabet)
solution.file.name = id
solution.save()
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy("tournament:solutions")
class SolutionsOrgaListView(OrgaMixin, SingleTableView):
"""
View all solutions sent by teams for the organized tournaments. Juries can view solutions of their pools.
Organizers can download a ZIP archive for each organized tournament.
"""
model = Solution
table_class = SolutionTable
template_name = "tournament/solutions_orga_list.html"
extra_context = dict(title=_("All solutions"))
def post(self, request, *args, **kwargs):
if "tournament_zip" in request.POST:
tournament = Tournament.objects.get(pk=int(request.POST["tournament_zip"]))
solutions = tournament.solutions
if not request.user.admin and request.user not in tournament.organizers.all():
raise PermissionDenied
out = BytesIO()
zf = zipfile.ZipFile(out, "w")
for solution in solutions:
zf.write(solution.file.path, str(solution) + ".pdf")
zf.close()
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
resp['Content-Disposition'] = 'attachment; filename={}'\
.format(_("Solutions for tournament {tournament}.zip")
.format(tournament=str(tournament)).replace(" ", "%20"))
return resp
return self.get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["tournaments"] = \
Tournament.objects if self.request.user.admin else self.request.user.organized_tournaments
return context
def get_queryset(self):
qs = super().get_queryset()
if not self.request.user.admin:
if self.request.user in Tournament.get_final().organizers.all():
qs = qs.filter(Q(team__tournament__organizers=self.request.user) | Q(pools__juries=self.request.user)
| Q(final=True))
else:
qs = qs.filter(Q(team__tournament__organizers=self.request.user) | Q(pools__juries=self.request.user))
return qs.order_by('final', 'team__tournament__date_start', 'team__tournament__name', 'team__trigram',
'problem',).distinct()
class SynthesesView(TeamMixin, BaseFormView, SingleTableView):
"""
Upload and view syntheses for a team.
"""
model = Synthesis
table_class = SynthesisTable
form_class = SynthesisForm
template_name = "tournament/syntheses_list.html"
extra_context = dict(title=_("Syntheses"))
def post(self, request, *args, **kwargs):
if "zip" in request.POST:
syntheses = request.user.team.syntheses
out = BytesIO()
zf = zipfile.ZipFile(out, "w")
for synthesis in syntheses:
zf.write(synthesis.file.path, str(synthesis) + ".pdf")
zf.close()
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
resp['Content-Disposition'] = 'attachment; filename={}'\
.format(_("Syntheses for team {team}.zip")
.format(team=str(request.user.team)).replace(" ", "%20"))
return resp
return super().post(request, *args, **kwargs)
def get_queryset(self):
qs = super().get_queryset().filter(team=self.request.user.team)
return qs.order_by('final', 'team__tournament__date_start', 'team__tournament__name', 'team__trigram',
'round', 'source',)
def get_context_data(self, **kwargs):
self.object_list = self.get_queryset()
context = super().get_context_data(**kwargs)
context["now"] = timezone.now()
context["real_deadline_1"] = self.request.user.team.future_tournament.date_syntheses + timedelta(minutes=30)
context["real_deadline_2"] = self.request.user.team.future_tournament.date_syntheses_2 + timedelta(minutes=30)
return context
def form_valid(self, form):
synthesis = form.instance
synthesis.team = self.request.user.team
synthesis.final = synthesis.team.selected_for_final
if synthesis.round == '1' and timezone.now() > (synthesis.tournament.date_syntheses + timedelta(minutes=30)):
form.add_error('file', _("You can't publish your synthesis anymore for the first round."
" Deadline: {date:%m-%d-%Y %H:%M}.")
.format(date=timezone.localtime(synthesis.tournament.date_syntheses)))
return super().form_invalid(form)
if synthesis.round == '2' and timezone.now() > synthesis.tournament.date_syntheses_2 + timedelta(minutes=30):
form.add_error('file', _("You can't publish your synthesis anymore for the second round."
" Deadline: {date:%m-%d-%Y %H:%M}.")
.format(date=timezone.localtime(synthesis.tournament.date_syntheses_2)))
return super().form_invalid(form)
prev_syn = Synthesis.objects.filter(team=synthesis.team, round=synthesis.round, source=synthesis.source,
final=synthesis.final)
for syn in prev_syn.all():
syn.delete()
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz0123456789"
id = ""
for i in range(64):
id += random.choice(alphabet)
synthesis.file.name = id
synthesis.save()
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy("tournament:syntheses")
class SynthesesOrgaListView(OrgaMixin, SingleTableView):
"""
View all syntheses sent by teams for the organized tournaments. Juries can view syntheses of their pools.
Organizers can download a ZIP archive for each organized tournament.
"""
model = Synthesis
table_class = SynthesisTable
template_name = "tournament/syntheses_orga_list.html"
extra_context = dict(title=_("All syntheses"))
def post(self, request, *args, **kwargs):
if "tournament_zip" in request.POST:
tournament = Tournament.objects.get(pk=request.POST["tournament_zip"])
syntheses = tournament.syntheses
if not request.user.admin and request.user not in tournament.organizers.all():
raise PermissionDenied
out = BytesIO()
zf = zipfile.ZipFile(out, "w")
for synthesis in syntheses:
zf.write(synthesis.file.path, str(synthesis) + ".pdf")
zf.close()
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
resp['Content-Disposition'] = 'attachment; filename={}'\
.format(_("Syntheses for tournament {tournament}.zip")
.format(tournament=str(tournament)).replace(" ", "%20"))
return resp
return self.get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["tournaments"] = \
Tournament.objects if self.request.user.admin else self.request.user.organized_tournaments
return context
def get_queryset(self):
qs = super().get_queryset()
if not self.request.user.admin:
if self.request.user in Tournament.get_final().organizers.all():
qs = qs.filter(Q(team__tournament__organizers=self.request.user)
| Q(team__pools__juries=self.request.user)
| Q(final=True))
else:
qs = qs.filter(Q(team__tournament__organizers=self.request.user)
| Q(team__pools__juries=self.request.user))
return qs.order_by('final', 'team__tournament__date_start', 'team__tournament__name', 'team__trigram',
'round', 'source',).distinct()
class PoolListView(LoginRequiredMixin, SingleTableView):
"""
View the list of visible pools.
Admins see all, juries see their own pools, organizers see the pools of their tournaments.
"""
model = Pool
table_class = PoolTable
extra_context = dict(title=_("Pools"))
def get_queryset(self):
qs = super().get_queryset()
user = self.request.user
if not user.admin and user.organizes:
qs = qs.filter(Q(juries=user) | Q(teams__tournament__organizers=user))
elif user.participates:
qs = qs.filter(teams=user.team)
qs = qs.distinct().order_by('solutions__final', 'teams__tournament__date_start', 'teams__tournament__name',
'round',)
return qs
class PoolCreateView(AdminMixin, CreateView):
"""
Create a pool manually.
This page should not be used: prefer send automatically data from the drawing bot.
"""
model = Pool
form_class = PoolForm
extra_context = dict(title=_("Create pool"))
def get_success_url(self):
return reverse_lazy("tournament:pools")
class PoolDetailView(LoginRequiredMixin, DetailView):
"""
See the detail of a pool.
Teams and juries can download here defended solutions of the pool.
If this is the second round, teams can't download solutions of the other teams before the date when they
should be available.
Juries see also syntheses. They see of course solutions immediately.
This is also true for organizers and admins.
All can be downloaded as a ZIP archive.
"""
model = Pool
extra_context = dict(title=_("Pool detail"))
def get_queryset(self):
qs = super().get_queryset()
user = self.request.user
if not user.admin and user.organizes:
qs = qs.filter(Q(juries=user) | Q(teams__tournament__organizers=user))
elif user.participates:
qs = qs.filter(teams=user.team)
return qs.distinct()
def post(self, request, *args, **kwargs):
user = request.user
pool = self.get_object()
if "solutions_zip" in request.POST:
if user.participates and pool.round == 2 and pool.tournament.date_solutions_2 > timezone.now():
raise PermissionDenied
out = BytesIO()
zf = zipfile.ZipFile(out, "w")
for solution in pool.solutions.all():
zf.write(solution.file.path, str(solution) + ".pdf")
zf.close()
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
resp['Content-Disposition'] = 'attachment; filename={}' \
.format(_("Solutions of a pool for the round {round} of the tournament {tournament}.zip")
.format(round=pool.round, tournament=str(pool.tournament)).replace(" ", "%20"))
return resp
elif "syntheses_zip" in request.POST and user.organizes:
if user.participates and pool.round == 2 and pool.tournament.date_solutions_2 > timezone.now():
raise PermissionDenied
out = BytesIO()
zf = zipfile.ZipFile(out, "w")
for synthesis in pool.syntheses.all():
zf.write(synthesis.file.path, str(synthesis) + ".pdf")
zf.close()
resp = HttpResponse(out.getvalue(), content_type="application/x-zip-compressed")
resp['Content-Disposition'] = 'attachment; filename={}' \
.format(_("Syntheses of a pool for the round {round} of the tournament {tournament}.zip")
.format(round=pool.round, tournament=str(pool.tournament)).replace(" ", "%20"))
return resp
return self.get(request, *args, **kwargs)

2
chat/__init__.py Normal file
View File

@ -0,0 +1,2 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later

28
chat/admin.py Normal file
View 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
View 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
View 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']})

View File

@ -0,0 +1,167 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.core.management import BaseCommand
from django.utils.translation import activate
from participation.models import Team, Tournament
from tfjm.permissions import PermissionType
from ...models import Channel
class Command(BaseCommand):
"""
Cette commande permet de créer les canaux de chat pour les tournois et les équipes.
Différents canaux sont créés pour chaque tournoi, puis pour chaque poule.
Enfin, un canal de communication par équipe est créé.
"""
help = "Create chat channels for tournaments and teams."
def handle(self, *args, **kwargs):
activate(settings.PREFERRED_LANGUAGE_CODE)
# Création de canaux généraux, d'annonces, d'aide jurys et orgas, etc.
# Le canal d'annonces est accessibles à tous⋅tes, mais seul⋅es les admins peuvent y écrire.
Channel.objects.update_or_create(
name="Annonces",
defaults=dict(
category=Channel.ChannelCategory.GENERAL,
read_access=PermissionType.AUTHENTICATED,
write_access=PermissionType.ADMIN,
),
)
# Un canal d'aide pour les bénévoles est dédié.
Channel.objects.update_or_create(
name="Aide jurys et orgas",
defaults=dict(
category=Channel.ChannelCategory.GENERAL,
read_access=PermissionType.VOLUNTEER,
write_access=PermissionType.VOLUNTEER,
),
)
# Un canal de discussion générale en lien avec le tournoi est accessible librement.
Channel.objects.update_or_create(
name="Général",
defaults=dict(
category=Channel.ChannelCategory.GENERAL,
read_access=PermissionType.AUTHENTICATED,
write_access=PermissionType.AUTHENTICATED,
),
)
# Un canal de discussion entre participant⋅es est accessible à tous⋅tes,
# dont l'objectif est de faciliter la mise en relation entre élèves afin de constituer une équipe.
Channel.objects.update_or_create(
name="Je cherche une équipe",
defaults=dict(
category=Channel.ChannelCategory.GENERAL,
read_access=PermissionType.AUTHENTICATED,
write_access=PermissionType.AUTHENTICATED,
),
)
# Un canal de discussion libre est accessible pour tous⋅tes.
Channel.objects.update_or_create(
name="Détente",
defaults=dict(
category=Channel.ChannelCategory.GENERAL,
read_access=PermissionType.AUTHENTICATED,
write_access=PermissionType.AUTHENTICATED,
),
)
for tournament in Tournament.objects.all():
# Pour chaque tournoi, on crée un canal d'annonces, un canal général et un de détente,
# qui sont comme les canaux généraux du même nom mais réservés aux membres du tournoi concerné.
# Les membres d'un tournoi sont les organisateur⋅rices, les juré⋅es d'une poule du tournoi
# ainsi que les membres d'une équipe inscrite au tournoi et qui est validée.
Channel.objects.update_or_create(
name=f"{tournament.name} - Annonces",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.TOURNAMENT_MEMBER,
write_access=PermissionType.TOURNAMENT_ORGANIZER,
tournament=tournament,
),
)
Channel.objects.update_or_create(
name=f"{tournament.name} - Général",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.TOURNAMENT_MEMBER,
write_access=PermissionType.TOURNAMENT_MEMBER,
tournament=tournament,
),
)
Channel.objects.update_or_create(
name=f"{tournament.name} - Détente",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.TOURNAMENT_MEMBER,
write_access=PermissionType.TOURNAMENT_MEMBER,
tournament=tournament,
),
)
# Un canal réservé à tous⋅tes les juré⋅es du tournoi est créé.
Channel.objects.update_or_create(
name=f"{tournament.name} - Juré⋅es",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.JURY_MEMBER,
write_access=PermissionType.JURY_MEMBER,
tournament=tournament,
),
)
if tournament.remote:
# Dans le cadre d'un tournoi distanciel, un canal pour les président⋅es de jury est créé.
Channel.objects.update_or_create(
name=f"{tournament.name} - Président⋅es de jury",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.TOURNAMENT_JURY_PRESIDENT,
write_access=PermissionType.TOURNAMENT_JURY_PRESIDENT,
tournament=tournament,
),
)
for pool in tournament.pools.all():
# Pour chaque poule d'un tournoi distanciel, on crée un canal pour les membres de la poule
# (équipes et juré⋅es), et un pour les juré⋅es uniquement.
Channel.objects.update_or_create(
name=f"{tournament.name} - Poule {pool.short_name}",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.POOL_MEMBER,
write_access=PermissionType.POOL_MEMBER,
pool=pool,
),
)
Channel.objects.update_or_create(
name=f"{tournament.name} - Poule {pool.short_name} - Jury",
defaults=dict(
category=Channel.ChannelCategory.TOURNAMENT,
read_access=PermissionType.JURY_MEMBER,
write_access=PermissionType.JURY_MEMBER,
pool=pool,
),
)
for team in Team.objects.filter(participation__valid=True).all():
# Chaque équipe validée a le droit à son canal de communication.
Channel.objects.update_or_create(
name=f"Équipe {team.trigram}",
defaults=dict(
category=Channel.ChannelCategory.TEAM,
read_access=PermissionType.TEAM_MEMBER,
write_access=PermissionType.TEAM_MEMBER,
team=team,
),
)

View 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",),
},
),
]

View File

@ -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",
),
),
]

View 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",
),
),
]

View File

@ -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",
),
),
]

View File

@ -0,0 +1,2 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later

365
chat/models.py Normal file
View 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
View 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,
),
)

View File

@ -0,0 +1,17 @@
{
"background_color": "white",
"description": "Chat for ETEAM",
"display": "standalone",
"icons": [
{
"src": "/static/tfjm/img/eteam.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"name": "ETEAM Chat",
"short_name": "ETEAM Chat",
"start_url": "/chat/fullscreen",
"theme_color": "black"
}

View 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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
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
})

View File

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% load pipeline %}
{% block extracss %}
{# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #}
{% if TFJM.APP == "TFJM" %}
<link rel="manifest" href="{% static "tfjm/chat_tfjm.webmanifest" %}">
{% elif TFJM.APP == "ETEAM" %}
<link rel="manifest" href="{% static "tfjm/chat_eteam.webmanifest" %}">
{% endif %}
{% endblock %}
{% block content-title %}{% endblock %}
{% block content %}
{% include "chat/content.html" %}
{% endblock %}
{% block extrajavascript %}
{# Ce script contient toutes les données pour la gestion du chat. #}
{% javascript 'chat' %}
{% endblock %}

View 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'utilisateurrice connectée afin de pouvoir effectuer des tests plus tard. #}
const IS_ADMIN = {{ request.user.registration.is_admin|yesno:"true,false" }}
</script>

View File

@ -0,0 +1,47 @@
{% load i18n pipeline static %}
<!DOCTYPE html>
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
<html lang="{{ LANGUAGE_CODE|default:"fr" }}" {% if LANGUAGE_BIDI %}dir="rtl"{% endif %}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
{% if TFJM.APP == "TFJM" %}
<title>{% trans "TFJM² Chat" %}</title>
<meta name="description" content="{% trans "TFJM² Chat" %}">
{% elif TFJM.APP == "ETEAM" %}
<title>{% trans "ETEAM Chat" %}</title>
<meta name="description" content="{% trans "ETEAM Chat" %}">
{% endif %}
{# Favicon #}
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
<meta name="theme-color" content="#ffffff">
{# Bootstrap CSS #}
<link href="{% static "bootstrap/css/bootstrap.min.css" %}" rel="stylesheet" type="text/css">
{# Fontawesome CSS #}
<link href="{% static "fontawesome/css/all.min.css" %}" rel="stylesheet" type="text/css">
<link href="{% static "fontawesome/css/v4-shims.css" %}">
{# bootstrap-select CSS #}
<link href="{% static "bootstrap-select/css/bootstrap-select.min.css" %}" rel="stylesheet" type="text/css">
{# Bootstrap JavaScript #}
<script type="application/javascript" src="{% static "bootstrap/js/bootstrap.bundle.min.js" %}" charset="utf-8"></script>
{# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #}
{% if TFJM.APP == "TFJM" %}
<link rel="manifest" href="{% static "tfjm/chat_tfjm.webmanifest" %}">
{% elif TFJM.APP == "ETEAM" %}
<link rel="manifest" href="{% static "tfjm/chat_eteam.webmanifest" %}">
{% endif %}
</head>
<body class="d-flex w-100 h-100 flex-column">
{% include "chat/content.html" with fullscreen=True %}
{# Inclusion du script permettant de gérer le thème sombre et le thème clair #}
{% javascript 'theme' %}
{# Inclusion du script gérant le chat #}
{% javascript 'chat' %}
</body>
</html>

View File

@ -0,0 +1,43 @@
{% load i18n pipeline static %}
<!DOCTYPE html>
{% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %}
<html lang="{{ LANGUAGE_CODE|default:"fr" }}" {% if LANGUAGE_BIDI %}dir="rtl"{% endif %}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>
{% trans "Chat" %} - {% trans "Log in" %}
</title>
<meta name="description" content="{% trans "Chat" %}">
{# Favicon #}
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
<meta name="theme-color" content="#ffffff">
{# Bootstrap CSS #}
<link href="{% static "bootstrap/css/bootstrap.min.css" %}" rel="stylesheet" type="text/css">
{# Fontawesome CSS #}
<link href="{% static "fontawesome/css/all.min.css" %}" rel="stylesheet" type="text/css">
<link href="{% static "fontawesome/css/v4-shims.css" %}">
{# Bootstrap JavaScript #}
<script type="application/javascript" src="{% static "bootstrap/js/bootstrap.bundle.min.js" %}" charset="utf-8"></script>
{# Webmanifest PWA permettant l'installation de l'application sur un écran d'accueil, pour navigateurs supportés #}
{% if TFJM.APP == "TFJM" %}
<link rel="manifest" href="{% static "tfjm/chat_tfjm.webmanifest" %}">
{% elif TFJM.APP == "ETEAM" %}
<link rel="manifest" href="{% static "tfjm/chat_eteam.webmanifest" %}">
{% endif %}
</head>
<body class="d-flex w-100 h-100 flex-column">
<div class="container">
<h1>{% trans "Log in" %}</h1>
{% include "registration/includes/login.html" %}
</div>
{# Inclusion du script permettant de gérer le thème sombre et le thème clair #}
{% javascript 'theme' %}
</body>
</html>

2
chat/tests.py Normal file
View File

@ -0,0 +1,2 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later

18
chat/urls.py Normal file
View File

@ -0,0 +1,18 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.views import LoginView, LogoutView
from django.urls import path
from django.utils.translation import gettext_lazy as _
from tfjm.views import LoginRequiredTemplateView
app_name = 'chat'
urlpatterns = [
path('', LoginRequiredTemplateView.as_view(template_name="chat/chat.html",
extra_context={'title': _("Chat")}), name='chat'),
path('fullscreen/', LoginRequiredTemplateView.as_view(template_name="chat/fullscreen.html", login_url='chat:login'),
name='fullscreen'),
path('login/', LoginView.as_view(template_name="chat/login.html"), name='login'),
path('logout/', LogoutView.as_view(next_page='chat:fullscreen'), name='logout'),
]

20
docs/Makefile Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
docs/_static/img/create_team.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
docs/_static/img/draw_choose_problem.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
docs/_static/img/draw_end_round_1.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

BIN
docs/_static/img/draw_example.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

BIN
docs/_static/img/draw_general.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

BIN
docs/_static/img/draw_last_rolls.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
docs/_static/img/draw_passage_tables.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
docs/_static/img/draw_recap.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
docs/_static/img/draw_start.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
docs/_static/img/join_team.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

BIN
docs/_static/img/payment_grouped.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

BIN
docs/_static/img/payment_index.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
docs/_static/img/payment_scholarship.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
docs/_static/img/team_info.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
docs/_static/img/tournament_info.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
docs/_static/img/user_info.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
docs/_static/img/validate_team.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

63
docs/conf.py Normal file
View File

@ -0,0 +1,63 @@
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))
# -- Project information -----------------------------------------------------
project = 'Plateforme du TFJM²'
copyright = "2020-2024"
author = "Animath"
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
"sphinx_rtd_theme",
"sphinx_rtd_dark_mode",
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = 'fr'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ['_build']
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'sphinx_rtd_theme'
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
default_dark_mode = True

23
docs/dev/index.rst Normal file
View File

@ -0,0 +1,23 @@
Développer la plateforme
========================
Cette page est dédiée aux responsables informatiques qui cherchent à contribuer à la plateforme.
Présentation
------------
La plateforme d'inscription du TFJM² actuelle est née lors de l'édition 2020. Elle n'est
pas la première à exister, elle succède à une précédente, moins fonctionnelle, dont les
sources ont été perdues. Elle a été développée par Emmy D'Anello, bénévole pour Animath,
qui la maintient au moins jusqu'en 2024.
La plateforme est développée en Python, utilisant le framework web
`Django <https://www.djangoproject.com/>`_. Elle est diponible librement sous licence GPLv3
à l'adresse `<https://gitlab.com/animath/si/plateforme-tfjm>`_.
L'instance de production est accessible à l'adresse `<https://inscription.tfjm.org/>`_.
Une instance de développement est accessible à l'adresse `<https://inscription-dev.tfjm.org/>`_.
Les deux instances sont hébergées sur le serveur d'Animath. La documentation spécifique
à l'installation des services d'Animath peut être trouvée à l'adresse
`<https://doc.animath.live/>`_.

309
docs/dev/install.rst Normal file
View File

@ -0,0 +1,309 @@
Installation de la plateforme du TFJM²
======================================
Cette page documente la procédure d'installation de la plateforme du TFJM²,
aussi bien dans un environnement de développement que de production.
Installation en production
--------------------------
Installation de Docker
""""""""""""""""""""""
Les outils du TFJM² sont déployés en utilisant `Docker <https://www.docker.com/>`_.
Pour plus de détails sur la configuration pour le TFJM², voir
`la page dédiée à Docker<docker.html>`_.
Commencez par installer Docker et Docker-Compose (qui sont a priori déjà installés
sur la plateforme du TFJM²), en commençant par installer le dépôt APT de Docker :
.. code-block:: bash
# Add Docker's official GPG key:
sudo apt update
sudo apt install ca-certificates curl gnupg
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
sudo chmod a+r /usr/share/keyrings/docker-archive-keyring.gpg
# Add the repository to Apt sources:
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install docker-ce docker-compose-plugin
Installation de la plateforme
"""""""""""""""""""""""""""""
Dans le fichier ``docker-compose.yml``, configurer :
.. code-block:: yaml
version: "3.8"
services:
# […]
inscription:
build: https://gitlab.com/animath/si/plateforme.git
links:
- postgres
- redis
- elasticsearch
env_file:
- ./secrets/inscription.env
restart: always
volumes:
- "./data/inscription/media:/code/media"
- "/etc/localtime:/etc/localtime:ro"
networks:
- tfjm
labels:
- "traefik.http.routers.inscription-tfjm2.rule=Host(`inscription.tfjm.org`, `inscriptions.tfjm.org`, `plateforme.tfjm.org`)"
- "traefik.http.routers.inscription-tfjm2.entrypoints=websecure"
- "traefik.http.routers.inscription-tfjm2.tls.certresolver=mytlschallenge"
postgres:
image: postgres:16
volumes:
- "./data/postgresql_16:/var/lib/postgresql/data"
restart: always
env_file:
- ./secrets/postgres.env
networks:
- tfjm
redis:
image: redis:alpine
restart: always
networks:
- tfjm
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.9
restart: always
env_file:
- ./secrets/elasticsearch.env
networks:
- tfjm
# […]
En cas d'instance de pré-production, il est possible de changer de branche en
rajoutant par exemple ``#dev`` à la fin de l'URL.
Les différents paramètres peuvent être modifiés si nécessaire, à commencer par les
versions des autres services (PostgreSQL, Redis, Elasticsearch). Penser à mettre à jour
cette documentation en cas de mise à jour future. Il est attendu d'avoir un réseau
``tfjm`` configuré :
.. code-block:: yaml
networks:
tfjm:
driver: bridge
L'URL utilisée peut être modifiée en changeant les options de Traefik, section ``labels``.
Configuration
"""""""""""""
La configuration se fait essentiellement dans les fichiers d'environnement.
Pour la base de données, une seule variable d'environnement est requise :
``POSTGRES_PASSWORD``, qui correspond au mot de passe maître de PostgreSQL.
Les variables d'environnement de ElasticSearch se résume à du paramétrage de mémoire :
.. code-block:: bash
discovery.type=single-node
ES_JAVA_OPTS=-Xms512m -Xmx512m
Redis n'a pas besoin de configuration particulière.
Enfin, pour la plateforme elle-même, il faut configurer les variables suivantes :
.. code-block:: bash
TFJM_STAGE=prod
DJANGO_SECRET_KEY=
DJANGO_DB_TYPE=PostgreSQL
DJANGO_DB_HOST=postgres
DJANGO_DB_PORT=
DJANGO_DB_NAME=
DJANGO_DB_USER=
DJANGO_DB_PASSWORD=
REDIS_SERVER_HOST=redis
REDIS_SERVER_PORT=6379
SMTP_HOST=ssl0.ovh.net
SMTP_PORT=465
SMTP_HOST_USER=contact@tfjm.org
SMTP_HOST_PASSWORD=
FROM_EMAIL=Contact TFJM²
SERVER_EMAIL=contact@tfjm.org
HAYSTACK_INDEX_NAME=inscription-tfjm
SYMPA_HOST=lists.tfjm.org
SYMPA_URL=lists.tfjm.org/sympa
SYMPA_EMAIL=contact@tfjm.org
SYMPA_PASSWORD=
HELLOASSO_CLIENT_ID=
HELLOASSO_CLIENT_SECRET=
* ``TFJM_STAGE`` : ``prod`` ou ``dev``. ``prod`` est utilisé pour la production,
``dev`` pour le développement ou pré-production. Permet de désactiver certaines
fonctionnalités de production, notamment l'envoi de mails, le cache Redis, les listes
de diffusion, et d'activer le débogage. Les erreurs seront ainsi directement affichées
en développement tandis qu'elles seront envoyées par mail en production.
* ``DJANGO_SECRET_KEY`` : correspond à
`la clé secrète Django <https://docs.djangoproject.com/fr/5.0/ref/settings/#secret-key>`_,
permettant de signer les sessions et les cookies. Il est important de la garder secrète.
Pour la générer, il est possible d'utiliser la commande
``python3 manage.py generate_secret_key``.
* ``DJANGO_DB_TYPE`` : Le type de base de données à utiliser, parmi
``PostgreSQL``, ``MySQL`` et ``SQLite``. ``SQLite`` n'est à utiliser qu'en développement
local.
* ``DJANGO_DB_HOST`` : L'hôte de la base de données pour les bases de données
``PostgreSQL`` et ``MySQL``. Pour ``SQLite``, il faut mettre le chemin vers le fichier
de base de données. Dans l'exemple ci-dessus, ``postgres`` est l'hôte Docker.
* ``DJANGO_DB_PORT`` : Le port de la base de données. (PostgreSQL ou MySQL uniquement)
Si laissé vide, utilisera le port par défaut du service, ``5432`` pour PostgreSQL et
``3306`` pour MySQL.
* ``DJANGO_DB_NAME`` : Le nom de la base de données. (PostgreSQL ou MySQL uniquement)
* ``DJANGO_DB_USER`` : Le nom d'utilisateur de la base de données.
(PostgreSQL ou MySQL uniquement)
* ``DJANGO_DB_PASSWORD`` : Le mot de passe de la base de données.
(PostgreSQL ou MySQL uniquement)
* ``REDIS_SERVER_HOST`` : L'hôte de Redis (en production uniquement). Dans l'exemple
ci-dessus, ``redis`` est l'hôte Docker.
* ``REDIS_SERVER_PORT`` : Le port de Redis (en production uniquement). Si laissé vide,
utilisera le port par défaut du service, ``6379``.
* ``SMTP_HOST`` : L'hôte du serveur SMTP à utiliser pour envoyer les mails. Utilise par
défaut le serveur d'OVH.
* ``SMTP_PORT`` : Le port du serveur SMTP à utiliser pour envoyer les mails.
* ``SMTP_HOST_USER`` : Le nom d'utilisateur du serveur SMTP à utiliser pour envoyer les
mails. Correspond à l'identifiant OVH.
* ``SMTP_HOST_PASSWORD`` : Le mot de passe du serveur SMTP à utiliser pour envoyer les
mails. Correspond au mot de passe OVH.
* ``FROM_EMAIL`` : Le nom lisible à utiliser comme expéditeur des mails
(défaut : Contact TFJM²).
* ``SERVER_EMAIL`` : L'adresse mail à utiliser comme expéditeur des mails
(défaut : contact@tfjm.org).
* ``HAYSTACK_INDEX_NAME`` : Le nom de l'index ElasticSearch à utiliser pour les recherches
dans ElasticSearch (défaut : inscription-tfjm).
* ``SYMPA_HOST`` : Le domaine des listes de diffusion Sympa utilisé.
* ``SYMPA_URL`` : L'URL du serveur Sympa à utiliser pour gérer les listes de diffusion.
* ``SYMPA_EMAIL`` : L'adresse mail à utiliser pour se connecter à Sympa.
* ``SYMPA_PASSWORD`` : Le mot de passe à utiliser pour se connecter à Sympa.
* ``HELLOASSO_CLIENT_ID`` : L'identifiant client HelloAsso à utiliser pour gérer les
paiements HelloAsso.
* ``HELLOASSO_CLIENT_SECRET`` : Le secret client HelloAsso à utiliser pour gérer les
paiements HelloAsso. Doit être maintenu secret.
Installation de la base de données
""""""""""""""""""""""""""""""""""
Pour gérer la base de données PostgreSQL, on peut utiliser les commandes suivantes :
.. code-block:: bash
sudo docker compose up -d postgres
sudo docker compose exec -u postgres postgres createuser -P inscription_tfjm # Création du compte `inscription_tfjm` en demander un mot de passe. À ne faire qu'une seule fois
sudo docker compose exec -u postgres postgres createdb -O inscription_tfjm inscription_tfjm # Création de la base de données `inscription_tfjm` en utilisant le compte `inscription_tfjm`
sudo docker compose exec -u postgres pg_dump inscription_tfjm > inscription_tfjm.sql # Pour sauvegarder la base de données `inscription_tfjm`
sudo docker compose exec -u postgres dropdb inscription_tfjm # Pour supprimer la base de données `inscription_tfjm`
La suppression et la recréation sont utiles en cas de réinitialisation annuelle de la base
de données.
Lancement
"""""""""
Pour lancer la plateforme, il suffit de lancer la commande suivante :
.. code-block:: bash
sudo docker compose up -d inscription
Pour arrêter la plateforme, il suffit de lancer la commande suivante :
.. code-block:: bash
sudo docker compose stop inscription
En cas de mise à jour de la plateforme, il suffit de lancer la commande suivante :
.. code-block:: bash
sudo docker compose up -d --build inscription
Selon le principe de Docker, les données sont conservées dans un volume Docker, ici
dans le dossier ``data/inscription``. Le reste est volatile, puisqu'il peut être recréé
à partir du code source. Attention donc de ne pas coder en production (ce qui est de toute
façon à proscrire !).
Les migrations de base de données sont automatiquement appliquées au lancement de la
plateforme, de même que la collecte de fichiers statiques ou encore la génération de la
documentation. Il n'y a rien à faire de spécial post-lancement, si ce n'est vérifier que
tout fonctionne correctement.
Installation en développement
-----------------------------
L'installation sur une machine locale est plus légère et est utile pour pouvoir tester
rapidement.
Commencez par récupérer le code source de la plateforme :
.. code-block:: bash
git clone https://gitlab.com/animath/si/plateforme-tfjm.git
cd plateforme-tfjm
Afin de pouvoir isoler l'environnement de développement, il est conseillé d'utiliser
un environnement virtuel Python. Commencez alors par installer Python (si ce n'est pas
déjà fait, au moins en version 3.10) ainsi que ``virtualenv``, puis vous pouvez créer
l'environnement virtuel, et entrer dedans :
.. code-block:: bash
python3 -m venv venv
source venv/bin/activate
Il vous faut ensuite installer les dépendances de la plateforme :
.. code-block:: bash
pip install -r requirements.txt
Pour exécuter des tests, il est nécessaire d'installer ``tox`` en supplément.
Ensuite, vous devez initialiser la base de données locale (qui sera stockée dans un
fichier SQLite ``db.sqlite3``) :
.. code-block:: bash
python3 manage.py migrate
Enfin, vous pouvez lancer la plateforme :
.. code-block:: bash
python3 manage.py runserver
Vous pouvez alors accéder à la plateforme à l'adresse `<http://localhost:8000/>`_.
Elle se recharge automatiquement à chaque modification du code source, inutile de la
relancer.
Pour arrêter la plateforme, il suffit d'appuyer sur ``Ctrl+C`` dans le terminal.
Pour vous créer un compte administrateur⋅rice, il suffit de lancer la commande suivante :
.. code-block:: bash
python3 manage.py createsuperuser
En cas de problème, vous pouvez librement supprimer la base de données locale et la
recréer en réexécutant la commande ``python3 manage.py migrate``.

211
docs/dev/transition.rst Normal file
View File

@ -0,0 +1,211 @@
Transition d'années
===================
Entre deux sessions du TFJM², certaines opérations doivent être effectuées chaque année,
afin de réinitialiser les données et de passer à l'année suivante.
Réinitialisation de la base de données
--------------------------------------
Conservation des autorisations de droit à l'image
"""""""""""""""""""""""""""""""""""""""""""""""""
La base de données du TFJM² est supprimée chaque année, avant chaque tournoi. Il n'y a
pas de conservation de données personnelles à l'exception des autorisations de droit
à l'image qui doivent être conservées pour des raisons légales pendant 5 ans.
Elles doivent alors être stockées sur Owncloud. Pour cela, il faut commencer par créer
un dossier dans Owncloud, qui stockera lesdites autorisations.
Rendez-vous ensuite dans le conteneur Docker et exécuter le script :
.. code:: bash
./manage.py export_photo_authorizations
Cela a pour effet de générer un dossier dans ``output/photo_authorizations``, qui contient
un dossier par équipe avec les différentes autorisations de droit à l'image.
Il faut maintenant récupérer ce dossier. Sortir du conteneur, et exécuter dans ``/srv/TFJM`` :
.. code:: bash
sudo docker cp tfjm-inscription-1:/code/output/photo_authorizations .
sudo mv photo_authorizations/* "data/owncloud/data/Emmy/files/Autorisations de droit à l'image/Autorisations de droit à l'image 2024/"
sudo chown -R www-data:root "data/owncloud/data/Emmy/files/Autorisations de droit à l'image/Autorisations de droit à l'image 2024"
sudo rmdir photo_authorizations
Il faut enfin réactualiser Owncloud. Exécuter en tant que www-data :
.. code:: bash
sudo docker compose exec -u www-data cloud php occ files:scan Emmy
Vérifiez enfin que les fichiers sont bien accessibles dans l'interface Web.
Ne pas oublier enfin de partager le dossier.
Sauvegarde de secours
"""""""""""""""""""""
Si les données doivent être supprimées, il peut être utile de réaliser une sauvegarde à conserver
quelques mois.
.. danger::
Cette sauvegarde ne doit être faite qu'à des fins utiles et supprimée dès que plus nécessaire.
Sauvegardez alors le dossier ``/srv/TFJM/data/inscription/media`` et exportez la base de données :
.. code:: bash
sudo cp -r data/inscription/media data/inscription/media-2024
sudo docker compose exec -u postgres postgres pg_dump inscription_tfjm | sudo tee inscription_tfjm_bkp_2024.sql > /dev/null
Réinitialisation effective
""""""""""""""""""""""""""
Il est désormais possible de réinitialiser la base de données, après avoir éteint le serveur :
.. code:: bash
sudo docker compose stop inscription
sudo rm -r data/inscription/media/*
sudo docker compose exec -u postgres postgres dropdb inscription_tfjm
sudo docker compose exec -u postgres postgres createdb -O inscription_tfjm inscription_tfjm
Redémarrez enfin le serveur (les migrations seront créées automatiquement)
et créez un nouveau compte administrateur⋅rice :
.. code:: bash
sudo docker compose up -d inscription
sudo docker compose exec inscription bash
./manage.py createsuperuser
Vérifiez finalement le bon fonctionnement du site.
Sites Django
""""""""""""
Après avoir réinitialisé les données, il faut mettre à jour le site Django, qui permettra
d'avoir notamment des noms de domaine correct dans les mails envoyés.
Se connecter alors sur le site réouvert, puis dans la partie « Administration », chercher la
section « Sites » et modifier l'unique site présent. Vous pouvez ensuite effectuer les modifications
à réaliser.
Nouveaux paramètres pour la nouvelle année
------------------------------------------
Certains paramètres doivent être modifiés pour prendre en compte la nouvelle année.
Dates d'inscription
"""""""""""""""""""
Les inscriptions sont permises uniquement entre l'ouverture et la fermeture, afin d'éviter
d'avoir des personnes s'inscrivant en dehors du TFJM².
Pour cela, dans votre projet local, rendez-vous dans ``tfjm/settings.py`` et cherchez
le paramètre ``REGISTRATION_DATES`` (pour le TFJM²). Modifiez alors les sous-paramètres
``open`` et ``close`` pour définir les dates pendant lesquelles les inscriptions des
participant⋅es sont permises pour cette nouvelle année. Elles doivent être au format ISO.
Exemple pour l'année 2025 où les inscriptions ouvrent au 8 janvier midi pour fermer
le 2 mars à 22h :
.. code:: python
REGISTRATION_DATES = dict(
open=datetime.fromisoformat("2025-01-15T12:00:00+0100"),
close=datetime.fromisoformat("2025-03-02T22:00:00+0100"),
)
Il faudra ensuite commiter la modification et redémarrer le serveur pour que la modification
prenne effet.
Noms des problèmes
""""""""""""""""""
Toujours dans la configuration dans ``tfjm/settings.py``, la liste des problèmes doit être
modifiée pour que leurs noms s'affichent correctement lors du tirage au sort.
Cherchez le paramètre ``PROBLEMS`` et mettez alors à jour la liste, dans l'ordre, des noms
des problèmes.
À nouveau, il est nécessaire de commiter la modification et redémarrer le serveur.
Paramètres des tournois
"""""""""""""""""""""""
Il faut enfin paramétrer les différentes dates des tournois.
Pour cela, connectez-vous sur la plateforme (avec un compte administrateur⋅rice), et dans l'onglet
« Tournois », vous pouvez créer les différents tournois avec les différentes dates pour chaque tournoi.
Plus d'information sur les différents paramètres dans la `section concernée
<../orga.html#creer-un-tournoi>`_
À la fin du tournoi
-------------------
Lorsque le tournoi est terminé, il faut récupérer les informations à stocker de façon pérenne,
notamment les solutions des équipes, les résultats ainsi que les autorisation de droit à l'image
comme indiqué précédemment.
Conservation des autorisations de droit à l'image
"""""""""""""""""""""""""""""""""""""""""""""""""
Se référer à la section plus haut.
Conservation des solutions des équipes
""""""""""""""""""""""""""""""""""""""
Le processus est très similaire à la conservation des autorisations de droit à l'image.
Il faut d'abord, dans le conteneur, lancer le script dédié pour récupérer les solutions
dans ``/code/output/solutions`` :
.. code:: bash
./manage.py export_solutions
On sort du conteneur et on récupère les solutions pour les déplacer dans Owncloud :
.. code:: bash
sudo docker cp tfjm-inscription-1:/code/output/solutions .
sudo mv solutions/* "data/owncloud/data/Emmy/files/Solutions écrites 2024/"
sudo chown -R www-data:root "data/owncloud/data/Emmy/files/Solutions écrites 2024"
sudo rmdir solutions
Il faut enfin réactualiser Owncloud. Exécuter en tant que www-data :
.. code:: bash
sudo docker compose exec -u www-data cloud php occ files:scan Emmy
Vérifiez enfin que les fichiers sont bien accessibles dans l'interface Web.
Ne pas oublier enfin de partager le dossier.
Génération de la page de résultats Wordpress
""""""""""""""""""""""""""""""""""""""""""""
Pour finir, il est possible de récupérer les notes pour chaque tournoi afin de générer
la page Wordpress dans la section *Éditions précédentes*.
Il suffit de lancer le script ``./manage.py export_results``, qui donne le texte brut pour
Wordpress à ajouter sur la page de l'édition qui vient de se terminer dans l'onglet
*Éditions précédentes*.
Pensez à bien inclure sur cette page le lien vers les problèmes de l'année, ainsi que le
lien vers le dossier partagé dans le Owncloud concernant les solutions des équipes.
Assurez-vous de mettre à jour la page *Éditions précédentes* afin d'inclure le lien vers
la page nouvellement créée.

645
docs/draw.rst Normal file
View File

@ -0,0 +1,645 @@
Tirage au sort
==============
La phase de tirage au sort est celle qui va déterminer d'une part
dans quelle poule se trouve chaque équipe et dans quelle ordre elles
défendront leurs problèmes, et d'autre part quel problème défendra
quelle équipe. Cette phase a lieu dans la semaine qui précède le
tournoi, typiquement le mardi entre 19h et 21h.
Exception pour la finale nationale : seul le premier tour est tiré
au sort à ce moment-là, le tirage au sort pour le second tour est
réalisé immédiatement après le premier tour et dépend des résultats.
Sinon, pour les finales régionales, les tirages au sort pour les
tours 1 et 2 sont réalisés successivement, bien que les solutions
à opposer et à rapporter pour le second tour ne sont pas accessibles
avant la fin du premier tour.
**Disclaimer :** si cette documentation est normalement tenue à jour,
seul le règlement et ses annexes font foi. Elle est essentiellement ici
pour décrire le fonctionnement de la plateforme vis-à-vis du tirage
au sort. En cas de litige, merci de se fier au règlement, et de
s'y référer `sur le site du TFJM² <https://tfjm.org/reglement/>`_.
Principe
--------
.. warning:: Cette section arborde en détail le fonctionnement théorique
du tirage au sort. Si vous souhaitez comprendre comment se déroule
le tirage au sort en pratique sur la plateforme, merci de vous référer
directement à la section `Déroulement du tirage`_.
Composition des poules
~~~~~~~~~~~~~~~~~~~~~~
Le principe du tirage au sort est détaillé dans la fiche pratique dédiée.
Chaque équipe commence par désigner un⋅e capitaine d'équipe qui s'occupera
de réaliser les tirages et de prendre les décisions.
Les poules sont triées de la plus grande à la plus petite, en terme de
nombre d'équipes.
Les capitaines d'équipe lancent un dé entre 1 et 100. S'il y a des égalités,
alors les équipes concernées relancent leur dé jusqu'à ne plus avoir d'égalité.
Ce score détermine les compositions des poules pour les deux tours (sauf pour
la finale nationale ou ce n'est que le premier tour).
On trie ensuite les équipes par ordre croissant de score de dé. On remplit
ensuite les poules une à une de façon gloutonne : si par exemple la
première poule est une poule à 3 équipes, alors on prend les 3 premières
équipes de la liste et on les place dans cette poule. On recommence ensuite
avec la deuxième poule, etc.
Au sein d'une poule, l'ordre de passage des équipes est déterminé par
l'ordre des restes modulo 100 des scores de dé multipliés par 27.
Par exemple, si les scores de dé sont 12, 34 et 56, alors si on
multiplie par 27 et qu'on prend le reste modulo 100, on obtient
respectivement 24, 82 et 52. L'ordre de passage sera donc l'équipe 1,
l'équipe 3, puis l'équipe 2. Ce choix est réalisé pour avoir un semblant
déterministe de mélange.
Pour le second tour, on considère à nouveau les scores de dé, où l'équipe
qui a eu le score le plus faible sera dans la première poule, celle qui
a eu le second score le plus faible dans la deuxième poule, etc. L'ordre
de passage est cette fois-ci plus simple, puisqu'il est directement croissant
avec les scores de dé. Exception : pour les poules à 5, l'équipe avec le
score le plus gros sera la dernière de la première poule, et il ne peut
y avoir qu'une seule poule à 5 équipes.
Considérons par exemple un tournoi fictif composé de 11 équipes, avec
une poule de 5 équipes et deux poules de 3 équipes. Les équipes sont
AAA, BBB, CCC, DDD, EEE, FFF, GGG, HHH, III, JJJ et KKK. Les scores
de dés sont :
.. table:: Exemple de tirage de dés et de répartition dans les poules
+-----------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
| Équipe | AAA | BBB | CCC | DDD | EEE | FFF | GGG | HHH | III | JJJ | KKK |
+=============================+=====+=====+=====+=====+=====+=====+=====+=====+=====+=====+=====+
| Score de dé | 45 | 17 | 64 | 3 | 98 | 41 | 34 | 63 | 86 | 23 | 70 |
+-----------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
| Poule au 1\ :sup:`er` tour | B | A | B | A | C | A | A | B | C | A | C |
+-----------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
| Poule au 2\ :sup:`ème` tour | C | B | B | A | A | B | A | A | A | C | C |
+-----------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
Explication : les équipes **DDD**, **BBB**, **JJJ**, **GGG** et **FFF** sont les
équipes ayant réalisé le plus bas score (respectivement 3, 17, 23, 34 et, 41) et
sont alors placées dans la poule **A**. Les équipes **AAA**, **HHH** et **CCC**
sont les trois suivantes (avec pour scores 45, 63 et 64) et composeront alors
la poule **B** tandis que les équipes **KKK**, **III** et **EEE** (avec pour
scores 70, 86 et 98) seront dans la poule **C** au premier tour.
Ainsi pour le second tour, l'équipe **DDD** ira dans la poule **A**, **BBB**
dans la poule **B**, **JJJ** dans la poule **C**, **GGG** dans la poule **A**,
**FFF** dans la poule **B**, **AAA** dans la poule **C**, **HHH** dans la poule
**A**, **CCC** dans la poule **B**, **KKK** dans la poule **C**, **III** dans
la poule **A** et enfin **EEE** également dans la poule **A** puisqu'elle y est
forcée.
Pour ce qui est de l'ordre de passage :
.. table:: Exemple d'ordre de passage pour le premier tour
+--------------------------------+-----------------------------+-----------------+-----------------+
| Poule | Poule A | Poule B | Poule C |
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
| Équipe | DDD | BBB | JJJ | GGG | FFF | AAA | HHH | CCC | KKK | III | EEE |
+================================+=====+=====+=====+=====+=====+=====+=====+=====+=====+=====+=====+
| Score de dé | 3 | 17 | 23 | 34 | 41 | 45 | 63 | 64 | 70 | 86 | 98 |
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
| Score de dé fois 27 modulo 100 | 81 | 59 | 21 | 18 | 7 | 15 | 1 | 28 | 90 | 22 | 46 |
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
| Ordre de passage dans la poule | 5 | 4 | 3 | 2 | 1 | 2 | 1 | 3 | 3 | 1 | 2 |
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
.. table:: Exemple d'ordre de passage pour le second tour
+--------------------------------+-----------------------------+-----------------+-----------------+
| Poule | Poule A | Poule B | Poule C |
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
| Équipe | DDD | GGG | HHH | III | EEE | BBB | FFF | CCC | JJJ | AAA | KKK |
+================================+=====+=====+=====+=====+=====+=====+=====+=====+=====+=====+=====+
| Score de dé | 3 | 34 | 63 | 86 | 98 | 17 | 41 | 64 | 23 | 45 | 70 |
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
| Ordre de passage dans la poule | 1 | 2 | 3 | 4 | 5 | 1 | 2 | 3 | 1 | 2 | 3 |
+--------------------------------+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
Pour la finale nationale, les ordres de passage et les répartitions dans les poules
du second tour sont décidé⋅es dans l'ordre décroissant des résultats obtenus au
premier tour. Si par exemple l'équipe **AAA** a fini en tête du premier tour, elle
sera alors la première à passer dans la poule **A** du second tour.
Tirage des problèmes
~~~~~~~~~~~~~~~~~~~~
Une fois les équipes réparties dans les poules, on tire au sort les problèmes. Ce
tirage se déroule poule par poule.
Au début du tirage au sort des problèmes d'une poule, les équipes de la poule
commencent par tirer un nouveau dé à 100 faces. S'il y a des égalités, alors
les équipes concernées relancent leur dé jusqu'à ne plus avoir d'égalité.
Les équipes sont triées dans l'ordre décroissant de leur score de dé. L'équipe
ayant tiré le plus gros score est invitée à tirer son problème en première.
Il y a deux contraintes de tirage :
* Il n'est pas possible de choisir le même problème qu'une autre équipe de la poule ;
* Il n'est pas possible de défendre le même problème pour les deux jours.
À noter qu'il n'est pas impossible de tirer et de choisir un problème qui n'a
pas été traité par l'équipe, bien que cela est fortement non recommandé.
Une fois l'ordre de tirage établi, les équipes tirent tour à tour leur problème,
tant qu'elle ne l'ont pas encore choisi. Pour cela, l'équipe active tire au sort
un problème. Il est attendu que le problème tiré garantisse les deux contraintes,
et que donc il n'est pas possible de tirer un problème déjà choisi par une autre
équipe, ou bien le problème défendu lors du tour 1 si on est au tour 2.
L'équipe a désormais deux choix :
* Accepter le problème. Dans ce cas, ce sera ce problème qu'elle défendra, et
son tirage est termé.
* Refuser le problème. Dans ce cas, la main passe à l'équipe suivante, selon le
score des dés. Exception : si le problème tiré est un problème qui a déjà été
refusé auparavant, alors dans ce cas l'équipe peut immédiatement tirer un
nouveau problème (mais elle a tout de même le choix de l'accepter ou de le
refuser).
**Attention :** si une équipe refuse trop de problèmes, alors elle pourra être
pénalisée. Chaque équipe a droit à ``P - 5`` refus sans pénalités, où ``P``
est le nombre de problèmes disponibles cette année. Par exemple, s'il y a
8 problèmes cette année, alors les équipes ont droit à 3 refus sans pénalités.
Seuls les refus distincts comptent : refuser une deuxième fois un problème
déjà refusé ne compte pas. Au-delà de ces refus gratuits, l'équipe se verra
dotée d'une pénalité de 25 % sur le coefficient de l'oral de défense, par
refus. Par exemple, si une équipe refuse 4 problèmes avec un coefficient
sur l'oral de défense normalement à ``1.6``, son coefficient passera à ``1.2``.
Une fois que toutes les équipes de la poule ont tiré leur problème, on passe
à la poule suivante. Une fois que toutes les poules ont vu leurs problèmes
tirés, on recommence pour le second tour, à partir de la première poule (déjà
définie), sauf pour la finale nationale. Le tirage au sort est terminé lorsque
toutes les poules ont vu leurs problèmes tirés pour tous les tours.
Récupération des solutions
~~~~~~~~~~~~~~~~~~~~~~~~~~
Les solutions défendues pour le premier tour dans une poule sont immédiatement
accessibles après le tirage au sort aux équipes qui doivent opposer ou rapporter
ces solutions. Pour le second tour, elles ne seront disponibles qu'après la fin
du premier tour.
Elles seront également envoyées par les organisateurices localaux, dans les mêmes
délais.
Tirage au sort sur la plateforme
--------------------------------
Le tirage est sort est géré directement sur la plateforme, de façon intuitive,
ergonomique et dynamique.
Il est accessible à l'adresse `<https://inscription.tfjm.org/draw/>`_, ou bien
en accédant à l'onglet « Tirage au sort » sur le bandeau de navigation, pourvu
d'être organisateurice ou bien d'être dans une équipe validée.
Présentation de l'interface
~~~~~~~~~~~~~~~~~~~~~~~~~~~
L'interface est divisée en 5 sections :
* Le nom du tournoi dans lequel se passe le tirage au sort ;
* Les derniers résultats de dés par équipe ;
* Le récapitulatif du tirage au sort en cours ;
* L'étape actuelle ;
* Les tableaux de passage par poule.
.. figure:: _static/img/draw_general.png
:alt: Interface générale du tirage au sort
Interface générale du tirage au sort
Sur cette capture d'écran, on voit par exemple que l'on est actuellement dans le
tournoi de Strasbourg. Le tirage du tour 1 est déjà terminé, et on est en train
de tirer la poule B2.
Nom du tournoi
..............
Les différents onglets représentent les différents tournois.
Pour une équipe participante ou un⋅e organisateurice local⋅e, il peut n'y avoir
qu'un seul tournoi, celui qui concerne l'utilisateurice.
Les administrateurices ont accès à tous les onglets, ce qui peut permettre de
passer d'un tirage au sort à un autre facilement, en un clic et sans délai.
.. figure:: _static/img/draw_tournament_tabs.png
:alt: Onglets des différents tirages au sort par tournoi
Le tournoi de Strasbourg est le tournoi sélectionné dans cet exemple.
Derniers résultats de dés
.........................
Les derniers jets de dés sont affichés dans cette section, avec le trigramme de
chaque équipe et son score de dé s'il existe. Le score est vert si l'équipe a
déjà tiré son dé, sinon il est jaune.
Les organisateurices peuvent cliquer sur le score de dé pour jeter un dé à la place
de l'équipe, ce qui est notamment utile à des fins de débuggage ou de souci technique
avec l'équipe en question.
.. figure:: _static/img/draw_last_rolls.png
:alt: Derniers jets de dés
Ici, les équipes BBB, DDD, EEE, GGG et HHH ont déjà tiré leur dé, avec pour scores
respectifs 57, 66, 58, 41 et 68, tandis que les équipes AAA, CCC, FFF et III n'ont
pas encore tiré leur dé.
Récapitulatif du tirage au sort
...............................
La section de gauche affiche le récapitulatif du tirage au sort en cours.
Elle n'est là qu'à des fins d'affichage, il n'est pas possible d'interagir avec.
La colonne de gauche concerne le premier tour, et la colonne de droite le second tour.
Chaque colonne est divisée en plusieurs parties, une pour chaque poule. Enfin, chaque
poule contient la liste des équipes membres, triées par ordre de passage. Elles ne sont
affichées que lorsque l'ordre de passage est déterminé.
Le tour actuel de tirage est mis en surbrillance. La poule actuelle qui réalise son
tirage est sur fond vert, l'équipe qui doit tirer son problème est sur fond bleu.
La cellule d'une équipe affiche son trigramme, son problème sélectionné pour ce tour
(s'il est déjà choisi), l'ensemble des problèmes qu'elle a refusé, et le cas échéant
ses pénalités.
.. figure:: _static/img/draw_recap.png
:alt: Récapitulatif du tirage au sort
Récapitulatif du tirage au sort en cours
La poule A1 est composée des équipes **GGG**, **AAA** et **III**, qui défendront
dans cet ordre. L'équipe **GGG** a tiré le problème 7, et l'a accepté. L'équipe
**AAA** a tiré le problème 3, et l'a accepté, après avoir refusé le problème 2.
L'équipe **III** a tiré le problème 1, et l'a accepté, après avoir refusé les
problèmes 3, 4, 2 et 6, et a donc une pénalité de 25 % sur son coefficient de
l'oral de défense. Notez qu'en poule B1, l'équipe **BBB** a bien pu accepter le
problème 8 après l'avoir refusé une première fois.
Dans la poule B2, actuellement en tirage, l'équipe **EEE** a déjà accepté le
problème 6, tandis que l'équipe **CCC** a refusé les problèmes 5 et 2 et que
l'équipe **AAA** a refusé le problème 2. C'est actuellement au tour de
l'équipe **AAA**, qui doit choisir d'accepter ou de refuser le problème 7
qu'elle vient de tirer.
Notez la présence du bouton « Annuler la dernière étape », visible uniquement
pour les organisateurices. Il permet d'annuler la dernière action qui vient
d'avoir lieu. Il est utile en cas de souci technique ou de mauvaise manipulation.
En cas de besoin majeur, un bouton « Annuler » est disponible en bas de l'interface
afin de réinitialiser le tirage.
Étape actuelle
..............
La partie de droite de l'écran est dédiée à l'explication de l'étape en cours du tirage
au sort. Tout est expliqué dans un encadré bleu.
À noter qu'un bouton « Exporter » peut être disponible pour les organisateurices à la
suite du tirage d'une poule. Il est utile pour valider le tirage et transmettre les
données au reste de la plateforme, débloquant notamment l'accès aux différentes solutions
pour les équipes.
Le tirage au sort est découpé en 4 phases majeures :
Composition des poules
''''''''''''''''''''''
La première phase est la composition des poules. Elle est détaillée dans la section
« Composition des poules ». Le texte affiché :
.. note::
Nous allons commencer le tirage des problèmes.
Vous pouvez à tout moment poser toute question si quelque chose n'est pas clair ou ne va pas.
Nous allons d'abord tirer les poules et l'ordre de passage pour le premier tour avec toutes les équipes puis pour chaque poule, nous tirerons l'ordre de tirage pour le tour et les problèmes.
Les capitaines, vous pouvez désormais toustes lancer un dé 100, en cliquant sur le gros bouton. Les poules et l'ordre de passage lors du premier tour sera l'ordre croissant des dés, c'est-à-dire que le plus petit lancer sera le premier à passer dans la poule A.
Pour plus de détails sur le déroulement du tirage au sort, le règlement est accessible sur https://tfjm.org/reglement.
Un gros émoji « dé » 🎲 est affiché pour les participant⋅es, leur permettant de lancer
leur dé. Le résultat sera affiché dans la section « Derniers jets de dés », et le
bouton disparaîtra.
Les organisateurices peuvent également appuyer sur le bouton, ce qui aura pour effet
de lancer un dé pour une équipe qui n'a pas encore lancé son dé. Cela peut être utile
pour débugger ou pour aider une équipe qui a un souci technique. Rappelons toutefois
qu'il suffit de cliquer sur le score de dé d'une équipe pour lancer son dé à sa place.
.. figure:: _static/img/draw_waiting_passage_order.png
:alt: Étape de composition des poules
Le tirage au sort est en attente de la composition des poules.
Ordre de tirage des problèmes
'''''''''''''''''''''''''''''
Une fois les poules constituées, il faut déterminer dans quel ordre les équipes
tireront leur problème, par le biais d'un nouveau jet de dé. Un texte par exemple
peut être :
.. note::
Nous passons au tirage des problèmes pour la poule Poule A1, entre les équipes GGG, AAA, III. Les capitaines peuvent lancer un dé 100 en cliquant sur le gros bouton pour déterminer l'ordre de tirage. L'équipe réalisant le plus gros score pourra tirer en premier.
Pour plus de détails sur le déroulement du tirage au sort, le règlement est accessible sur https://tfjm.org/reglement.
L'émoji « dé » 🎲 n'est affiché que pour les équipes membre de la poule concernée.
Encore une fois, les organisateurices peuvent lancer le dé à la place d'une équipe
en cliquant sur son score de dé, ou bien en cliquant sur l'émoji.
.. figure:: _static/img/draw_waiting_choose_problem_order.png
:alt: Étape de tirage de l'ordre de tirage des problèmes
Le tirage au sort est en attente du tirage l'ordre de tirage pour la poule A1.
Tirage des problèmes
''''''''''''''''''''
Une fois l'ordre de tirage déterminé, les équipes sont invitées à tirer leurs
problèmes. Le texte affiché est par exemple :
.. note::
C'est au tour de l'équipe GGG de choisir son problème. Cliquez sur l'urne au milieu pour tirer un problème au sort.
Pour plus de détails sur le déroulement du tirage au sort, le règlement est accessible sur https://tfjm.org/reglement.
Un émoji « urne » 🗳️ est visible uniquement pour l'équipe active, qui doit tirer
son problème. Elle est invitée à cliquer dessus. À nouveau, les organisateurices
peuvent cliquer dessus à la place de l'équipe.
.. figure:: _static/img/draw_waiting_problem_draw.png
:alt: Étape de tirage des problèmes
Le tirage au sort est en attente du tirage du problème de l'équipe GGG.
Choix du problème
'''''''''''''''''
Une fois le problème tiré, l'équipe peut alors choisir de l'accepter ou de le
refuser. Le texte affiché est par exemple :
.. note::
L'équipe GGG a tiré le problème 7 : Drôles de cookies. Elle peut décider d'accepter ou de refuser ce problème. Il reste 3 refus sans pénalité.
Pour plus de détails sur le déroulement du tirage au sort, le règlement est accessible sur https://tfjm.org/reglement.
Deux boutons sont affichés sur la page, un bouton « Accepter », en vert,
et un bouton « Refuser », en rouge. L'équipe peut cliquer sur l'un ou l'autre
pour faire son choix. Les organisateurices peuvent également cliquer sur l'un
ou l'autre pour faire le choix à la place de l'équipe. Ces boutons sont
invisibles pour les autres équipes.
.. figure:: _static/img/draw_choose_problem.png
:alt: Étape de choix du problème
L'équipe GGG a tiré le problème 7. Elle peut choisir de l'accepter ou de le refuser.
.. TODO
.. note::
Cette section sera mise à jour plus tard.
Tableaux de passage par poule
.............................
Ces tableaux, actualisés en temps réel, permettent d'afficher quels seront les
différents passages lors d'une poule. En particulier, quelle équipe défendra
quel problème, et quelle équipe opposera et laquelle rapportera.
.. figure:: _static/img/draw_passage_tables.png
:alt: Tableaux de passage par poule
Tableaux de passage par poule
Déroulement du tirage
~~~~~~~~~~~~~~~~~~~~~
Cette section décrit le déroulement du tirage au sort sur la plateforme, sans
rentrer dans les détails théoriques. Si la partie théorique vous intéresse,
rendez-vous à la section `Principe`_.
Démarrage du tirage au sort
...........................
Si le tirage n'a pas encore commencé, un message d'alerte s'affiche.
Les organisateurices peuvent lancer le tirage au sort en cliquant sur le bouton
« Démarrer ! ». Iels doivent d'abord paramétrer le format du tirage, en indiquant
le nombre d'équipe par poule, en séparant les nombres par des plus. Par exemple,
pour un tournoi de 9 équipes avec 3 poules de 3 équipes, on écrira ``3+3+3``.
.. figure:: _static/img/draw_start.png
:alt: Création du tirage au sort
Le formulaire de lancement du tirage au sort. Ici, on souhaite un tirage
au sort avec 3 poules de 3 équipes, pour le tournoi de Strasbourg.
Attention : si toutes les poules n'ont pas la même capacité, il est essentiel de
mettre les poules dans l'ordre décroissant de capacité. Par exemple, pour un tournoi
de 8 équipes avec une poule de 5 équipes et une poules de 3 équipes, il faut
écrire ``5+3``. De plus, il n'est pas possible d'avoir 2 poules de 5 équipes.
Composition des poules
......................
Les capitaines d'équipe sont invités à lancer un dé à 100 faces. Pour cela, ils
peuvent cliquer sur le gros bouton avec l'émoji « dé » 🎲 sur la droite de l'écran.
Le résultat est affiché dans la section « Derniers jets de dés ».
.. figure:: _static/img/draw_waiting_passage_order_full.png
:alt: Étape de composition des poules
Le tirage au sort est en attente de la composition des poules.
Les équipes **AAA**, **CCC** et **EEE** ont déjà lancé leur dé, avec pour
scores 66, 34 et 97.
Une fois le bouton cliqué, il disparaît.
Les organisateurices peuvent également cliquer sur le score de dé d'une équipe
pour lancer le dé à sa place. Cela peut être utile pour débugger ou pour aider
une équipe qui a un souci technique. Il est possible de cliquer sur le dé, ce
qui a pour effet de lancer le dé pour l'équipe qui n'a pas encore tiré son dé.
Les poules sont constituées dès que toutes les équipes ont tiré leur dé, selon
le principe énoncé dans la partie `Principe`_. S'il y a des égalités, alors les
équipes concernées relancent leur dé jusqu'à ne plus avoir d'égalité.
Ordre de tirage des problèmes
.............................
Une fois les poules constituées, il faut déterminer dans quel ordre les équipes
tireront leur problème, par le biais d'un nouveau jet de dé. Les équipes sont
invitées à lancer un dé à 100 faces. Pour cela, elles peuvent cliquer sur le
gros bouton avec l'émoji « dé » 🎲 sur la droite de l'écran. Le résultat est
affiché dans la section « Derniers jets de dés ».
.. figure:: _static/img/draw_waiting_choose_problem_order_full.png
:alt: Étape de tirage de l'ordre de tirage des problèmes
Le tirage au sort est en attente du tirage l'ordre de tirage pour la poule A1.
L'équipe **BBB** a déjà tiré son dé, avec pour score 56. Les équipes **CCC**
et **DDD** sont alors attendues.
Une fois le bouton cliqué, il disparaît.
À nouveau, les organisateurices peuvent cliquer sur le score de dé d'une équipe
pour lancer le dé à sa place. Cela peut être utile pour débugger ou pour aider
une équipe qui a un souci technique. Il est possible de cliquer sur le dé, ce
qui a pour effet de lancer le dé pour l'équipe qui n'a pas encore tiré son dé.
Si deux équipes réalisent le même score, alors les équipes concernées relancent
leur dé jusqu'à ne plus avoir d'égalité.
Tirage des problèmes
....................
Les équipes membre de la poule active sont invitées à tirer leur problème, dans
l'ordre déterminé par les dés précédents.
L'équipe active est invitée à cliquer sur l'urne 🗳️ sur la droite de l'écran pour
tirer un problème au sort.
.. figure:: _static/img/draw_waiting_problem_draw_full.png
:alt: Étape de tirage des problèmes
L'équipe **DDD** est la première à tirer son problème, puisqu'elle a réalisé
le plus gros score. Elle est mise en surbrillance.
Les organisateurices peuvent également cliquer sur l'urne pour lancer le dé à la
place de l'équipe. Cela peut être utile pour débugger ou pour aider une équipe qui
a un souci technique.
Le problème tiré ne peut pas être le même que celui d'une autre équipe de la poule,
ni le même que celui défendu par l'équipe lors du premier tour si on est au second
tour. Il peut en revanche être un problème déjà refusé auparavant.
Choix du problème
.................
Une fois le problème tiré, l'équipe peut alors choisir de l'accepter ou de le
refuser. Elle est invitée à cliquer sur le bouton « Accepter » ou « Refuser »
pour faire son choix.
.. figure:: _static/img/draw_choose_problem_full.png
:alt: Étape de choix du problème
L'équipe **DDD** a tiré le problème 3. Elle peut choisir de l'accepter ou de le refuser.
Si elle accepte le problème, alors elle a terminé, et la main passe à l'équipe
suivante. Ce sera ce problème qu'elle défendra.
Si elle refuse le problème, alors :
* Soit le problème n'avait pas encore été refusé. Dans ce cas, la main passe à
l'équipe suivante, selon l'ordre de tirage des problèmes. Si le nombre de refus
dépasse ``P - 3````P`` est le nombre de problèmes, alors l'équipe se verra
dotée d'une pénalité de 25 % sur son coefficient de l'oral de défense. Ces
pénalités sont cumulables.
* Soit le problème avait déjà été refusé. Dans ce cas, l'équipe peut revenir sur
sa décision et accepter le problème, ou bien le rejeter gratuitement et tirer
immédiatement un nouveau problème. Cela ne compte pas comme un refus
supplémentaire et ne peut ajouter de pénalité.
Les organisateurices peuvent à nouveau cliquer sur les boutons à la place de
l'équipe. Cela peut être utile pour débugger ou pour aider une équipe qui a un
souci technique.
.. figure:: _static/img/draw_example.png
:alt: Exemple de tirage de la poule A1
Dans cet exemple, l'équipe **DDD** a tiré le problème 3, et l'a accepté.
L'équipe **BBB** a d'abord refusé le problème 6, et a accepté plus tard
le problème 5. L'équipe **CCC**, à qui c'est le tour, a refusé les problèmes
8, 4, 2 et 6. Puisqu'il y a 8 problèmes, elle a une pénalité de 25 % sur son
coefficient de l'oral de défense. Elle vient de tirer le problème 2, qu'elle
avait déjà refusé. Elle est donc libre de le refuser à nouveau sans pénalité
supplémentaire, ou bien de l'accepter, comme indiqué dans l'encadré à droite.
Lorsque toutes les équipes de la poule ont accepté leur problème, on passe à la
poule suivante, en revenant à l'étape de tirage au sort de l'ordre des problèmes.
Lorsqu'un tour est terminé, on passe au tour suivant, à la première poule.
Exception : pour la finale, on ne tire au sort que le premier tour.
.. figure:: _static/img/draw_end_round_1.png
:alt: Fin du tirage au sort du premier tour
Toutes les équipes ont tiré leur problème pour le premier tour. On passe
au second tour, à la poule A2.
Fin du tirage au sort
.....................
Le tirage se termine lorsque toutes les équipes se sont vues attribuer un problème
pour chacun des tours.
Les organisateurices peuvent alors cliquer sur le bouton « Exporter » pour valider
le tirage et transmettre les données au reste de la plateforme, débloquant notamment
l'accès aux différentes solutions pour les équipes. Attention : cette opération n'est
pas réversible facilement.
Spécificité de la finale
........................
Pour la finale, le tirage au sort est légèrement différent. Seul le premier tour
est tiré au sort initialement. Pour le second tour, les poules et ordres de passage
sont déterminés selon le classement du premier tour.
Avant de reprendre le tirage au sort du second tour, il est essentiel que les notes
du premier tour soient rentrées correctement. En effet, ce sont ces scores qui
détermineront les poules et l'ordre de passage pour le second tour.
Pour lancer le second tour, un⋅e organisateurice doit cliquer sur le bouton
« Continuer ». Le tirage reprend ensuite normalement, à partir de la poule **A2**.
.. danger::
À terme, il sera possible de réaliser ce second tirage au sort IRL, et de rentrer
facilement les données au fur et à mesure du tirage.
Annulation d'une étape
......................
Il est possible d'annuler la dernière étape du tirage au sort. Cela peut être utile
en cas de souci technique ou de mauvaise manipulation. Cette option est uniquement
réservée aux organisateurices.
Pour cela, il suffit de cliquer sur le bouton « Annuler la dernière étape » dans
la partie « Récapitulatif ». Cela annule immédiatement la dernière action. Il est
possible de continuer à remonter le temps ainsi.
En cas de plus gros problème, il est possible de cliquer sur le bouton « Annuler »
en bas de page. Cela réinitialise le tirage au sort, et permet de recommencer
depuis le début. Cette suppression est irréversible, soyez sûr⋅es de ce que vous
faites avant de cliquer dessus.

24
docs/index.rst Normal file
View File

@ -0,0 +1,24 @@
Documentation de la plateforme du TFJM²
=======================================
Ce site vise à documenter l'usage de la plateforme de gestion du TFJM², aussi
bien du côté utilisateur⋅rice que du côté organisateur⋅rice ou bien
administrateur⋅rice.
.. toctree::
:maxdepth: 3
:caption: Utiliser
user
orga
draw
.. toctree::
:maxdepth: 3
:caption: Développer
dev/index
dev/install
dev/transition

114
docs/orga.rst Normal file
View File

@ -0,0 +1,114 @@
Partie organisateur⋅rices
=========================
.. contents::
Cette page est dédiée aux organisateur⋅rices qui souhaitent utiliser la plateforme pour gérer
les différentes équipes et les inscriptions.
Ajouter un⋅e nouvelleau organisateur⋅rice
-----------------------------------------
Seul⋅es les actuel⋅les organisateur⋅rices peuvent en ajouter de nouvelleaux. Il n'est pas possible
de s'inscrire.
Pour cela, il faut se connecter, aller dans l'onglet « Utilisateur⋅rices », puis « Ajouter un⋅e organisateur⋅rice ».
Les informations suivantes sont demandées :
* Prénom
* Nom de famille
* Adresse e-mail (préférer une adresse institutionnelle)
* Rôle (Bénévole ou administrateur⋅rice)
* Activité professionnelle (permet de savoir d'où viennent les bénévoles)
Les bénévoles peuvent gérer ce qui les concernent (tournois, jurys,…), les administrateur⋅rices ont un accès
intégral à l'ensemble de la plateforme, non restreint. Ce dernier statut ne devrait être réservé qu'aux membres du CNO.
Une fois le formulaire validé, un mail est envoyé permettant de définir son mot de passe. Iel peut ensuite se
connecter en utilisant son adresse e-mail et son mot de passe.
Gestion des tournois
--------------------
Créer un tournoi
""""""""""""""""
.. important::
Seul⋅es les administrateur⋅rices peuvent créer des tournois.
Pour créer un tournoi, il suffit de cliquer dans l'onglet « Tournois » puis « Ajouter un tournoi ».
Les descriptions des différents paramètres sont dans la section suivante.
Modifier un tournoi
"""""""""""""""""""
.. important::
Seul⋅es les administrateur⋅rices ainsi que les organisateur⋅rices dudit tournoi peuvent modifier le tournoi.
Pour modifier un tournoi, il faut déjà se rendre sur la page du tournoi : onglet « Tournois » puis cliquer sur
le bon tournoi. Le bouton « Modifier le tournoi » devrait être accessible.
.. warning::
Si le bouton n'est pas visible, vérifiez que vous êtes bien connecté⋅e, et que vous êtes bien marqué⋅es parmi
les organisateur⋅rices. N'hésitez pas à les contacter si ce n'est pas le cas.
Les informations suivantes peuvent être modifiées :
* Nom du tournoi
* Date de début (le samedi)
* Date de fin (le dimanche)
* Adresse du lieu physique
* Nombre indicatif maximal d'équipes autorisées
(un multiple de 3, n'est là qu'à titre indicatif et n'est pas bloquant pour la suite)
* Prix demandé aux participant⋅es, normalement 21 € sauf pour la finale 35 € (hors boursièr⋅es et tournois en visio)
* La case « À distance » doit rester décochée tant que les tournois en visio n'ont pas repris
* Date limite d'inscription : date jusqu'à laquelle les équipes peuvent finaliser leur inscription (non bloquant).
En général le mois précédent le tournoi
* Date limite pour envoyer les solutions : date jusqu'à laquelle les équipes peuvent soumettre leurs solutions
(au-delà, le remplacement de solutions déjà soumises n'est plus permis mais l'envoi de nouvelles reste possible en
cas de besoin, à décourager). En général le dimanche avant le tournoi vers 22h
* Tirage au sort : date indicative qui dit quand le tirage au sort va se dérouler. En général le mardi précédent
le tournoi vers 20h
* Date limite pour envoyer les notes de synthèses pour la première/seconde phase : même règle que pour les solutions,
mais pour les notes de synthèse. Généralement le vendredi à 22h pour le premier tour et le dimanche à 10h pour le
second tour
* Date à laquelle les solutions pour le second tour sont accessibles : seules les solutions pour le premier tour sont
directement accessible après le tirage au sort, celles pour le second tour sont libérées automatiquement une fois
cette date passée. Généralement le samedi entre 17h et 18h (à adapter)
* Description
* Organisateur⋅rices : liste des personnes qui organisent le tournoi et peuvent le gérer numériquement. N'inclut pas
les juré⋅es
* Finale (admin uniquement) : cette case ne doit être cochée que pour le tournoi de la finale.
Liste des équipes
"""""""""""""""""
Lorsque les équipes choisissent leur tournoi, elle est répertoriée sur la page du tournoi.
Valider une équipe
""""""""""""""""""
Lorsqu'une équipe a finalisé son inscription et a demandé à être validée, un mail est envoyé à l'ensemble des
organisateur⋅rices. Sur l'interface du tournoi, il est possible de cliquer sur l'équipe et accéder à l'ensemble
de ses informations :
* Nom de l'équipe
* Trigramme
* Encadrant⋅es
* Participant⋅es
* Diverses autorisations
* Lettre de motivation
Lorsqu'il est temps de valider l'équipe, un formulaire dédié apparaît en bas. Un texte peut être envoyé à l'équipe,
et le choix est proposé de valider l'équipe ou non.
.. TODO
.. note::
Cette documentation sera complétée à l'avenir pour prendre en compte les enjeux du tournoi au moment venu.

3
docs/requirements.txt Normal file
View File

@ -0,0 +1,3 @@
sphinx>=3.3
sphinx-rtd-theme>=2.0
sphinx_rtd_dark_mode>=1.3.0

330
docs/user.rst Normal file
View File

@ -0,0 +1,330 @@
Utiliser la plateforme
======================
.. contents::
Cette plateforme est conçue pour que les différentes équipes puissent s'inscrire au TFJM² et envoyer
leurs solutions.
Si vous êtes ici, c'est que vous avez des questions sur l'utilisation du site en tant que participant⋅e.
Les inscriptions ouvrent dans le courant du mois de janvier, généralement une semaine après la publication
des problèmes. Pour l'édition 2024, les inscriptions ouvrent le 17 janvier 2024.
Tout se passe sur le site https://inscription.tfjm.org/.
S'inscrire
----------
Il est important de noter que chaque personne d'une équipe doit s'inscrire, y compris les encadrant⋅es.
Rendez-vous sur le site d'inscription, bouton « S'inscrire » en haut à droite.
Les informations suivantes sont requises pour tout le monde :
* Prénom d'usage
* Nom de famille (ou d'usage)
* Adresse électronique (sera vérifiée)
* Mot de passe et confirmation
* Rôle (participant⋅e ou encadrant⋅e)
* Date de naissance
* Genre
* Adresse postale
* Numéro de téléphone de contact
* Problèmes de santé à déclarer (allergies,…)
* Contraintes de logement à déclarer (problèmes médicaux, contraintes horaires, questions de genre,…)
* Donne son consentement pour se faire recontacter par Animath
Informations demandées exclusivement aux élèves :
* Classe (seconde ou avant/première/terminale)
* Établissement scolaire
* Nom d'un⋅e responsable légal⋅e
* Numéro de téléphone d'un⋅e responsable légal⋅e
* Adresse e-mail d'un⋅e responsable légal⋅e
Informations exclusivement demandées aux encadrant⋅es :
* Activité professionnelle
Une fois inscrit⋅e, vous recevrez par mail un lien de confirmation, qu'il vous faudra cliquer.
Connexion
---------
Une fois inscrit⋅e, vous pouvez vous connecter en utilisant le bouton en haut à droite.
Dans le champ « nom d'utilisateur », rentrez votre adresse mail. Si vous avez oublié votre mot de
passe, un formulaire est disponible pour vous aider à le réinitialiser. Vous pouvez ensuite vous
connecter. Votre prénom et votre nom apparaîtra en haut à droite.
Créer une équipe
----------------
Il suffit d'une seule personne (participant⋅e ou encadrant⋅e) pour créer une équipe. Pour créer une
équipe, il faut cliquer sur le bouton « créer une équipe ». Un nom d'équipe et un trigramme seront
demandés. Le trigramme est composé de 3 lettres majuscules, c'est ce qui permettra aux
organisateur⋅rices d'identifier rapidement votre équipe.
.. image:: /_static/img/create_team.png
:alt: Création d'une équipe
Une fois l'équipe créée, vous obtenez un code à 6 caractères, lettres ou chiffre. Ce code est à
transettre à l'ensemble des membres de votre équipe (et seulement à elleux).
.. image:: /_static/img/team_info.png
:alt: Information sur l'équipe nouvellement créée
Rejoindre une équipe
--------------------
Si l'équipe est déjà créée, vous aurez besoin du code d'accès transmis par la personne ayant créé
l'équipe. Vous pouvez cliquer sur « Rejoindre une équipe », et entrer le code.
.. image:: /_static/img/join_team.png
:alt: Rejoindre une équipe par son code d'accès
Vous avez désormais normalement rejoint l'équipe.
En cas de problème, ou si vous ne savez pas de quel code on parle, contactez-nous à l'adresse
contact@tfjm.org.
Informations sur les tournois
-----------------------------
Les tournois peuvent être trouvés dans l'onglet « Tournois ». Vous avez accès, pour chaque tournoi,
à l'ensemble des dates importantes et les lieux des tournois.
.. image:: /_static/img/tournament_info.png
:alt: Informations sur un tournoi
Davantage d'informations peuvent être trouvées sur le site vitrine : https://tfjm.org/infos-tournois/.
Choisir un tournoi
------------------
Pour accéder aux paramètres de votre équipe, vous pouvez aller sur l'onglet « Mon équipe », dans la
barre de navigation.
Pour choisir votre tournoi, il vous suffit de vous rendre sur la page de votre équipe et de cliquer
sur « Modifier ». Un formulaire vous permet alors de choisir votre tournoi.
.. image:: /_static/img/choose_tournament.png
:alt: Formulaire de mise à jour de l'équipe permettant de choisir un tournoi
Attention cependant : cela ne confirme pas votre inscription. Vous devez pour cela envoyer l'ensemble
de vos documents (voir ci-dessous).
Transmettre ses documents
-------------------------
Pour valider votre inscription, vous devez :
* Avoir choisi un tournoi ;
* Que chaque membre de l'équipe ait transmis :
* Autorisation de droit à l'image ;
* Fiche sanitaire de liaison et carnet de vaccination (pour les mineur⋅es) ;
* Autorisation parentale (pour les mineur⋅es) ;
* Transmettre une lettre de motivation.
La lettre de motivation doit être envoyée une seule fois pour toute l'équipe, peut être envoyée
depuis l'interface « Mon équipe », au format PDF, dont le contenu est défini dans le
règlement : https://tfjm.org/reglement/.
Concernant les documents personnels, ils peuvent être envoyés depuis le menu « Mon compte », qui
peut être trouvé en haut à droite dans la barre de navigation. Chaque fichier doit être envoyé
au format PDF et peser moins de 2 Mo.
.. image:: /_static/img/user_info.png
:alt: Informations sur l'utilisateur⋅rice
En cas de besoin, contactez-nous à l'adresse contact@tfjm.org.
Valider son équipe
------------------
Pour prétendre à la validation, il faut que l'équipe compte au moins 1 encadrant⋅e et 4 participant⋅es.
Il faut ensuite que la lettre de motivation soit transmise, le tournoi choisi et que tous les documents
nécessaires ont été transmis (voir section précédente).
Une fois tous les prérequis réunis, sur la page « Mon équipe », il est possible de cliquer sur le bouton
pour demander la validation.
.. image:: /_static/img/validate_team.png
:alt: Formulaire de validation d'équipe
.. warning::
Les places étant limitées, rien ne garantit que vous pourrez avoir votre place dans le tournoi. Nous
vous encourageons à respecter un maximum les critères définis dans le règlement :
https://tfjm.org/reglement/. Selon les disponiblités et votre position géographique, il pourra
vous être proposé de participer à un tournoi voisin.
Une fois les deadlines dépassées, rien ne vous garantit une place au TFJM², alors attention aux dates.
Vous recevrez par mail une réponse des organisateur⋅rices locaux⋅ales. En cas de besoin, contactez-nous
à l'adresse contact@tfjm.org.
Payer son inscription
---------------------
Une fois votre inscription validée, il vous faudra payer votre participation. Les frais s'élèvent à
21 € par élève, sauf pour les élèves boursièr⋅es qui en sont exonéré⋅es. Les encadrant⋅es n'ont pas
à payer. Pour la finale, les frais sont de 35 € par élève.
.. note::
Ces frais couvrent une partie des frais de restauration et d'hébergement. L'organisation reste
bénévole.
Il est possible de payer par carte bancaire ou virement bancaire. Pour d'autres types de paiement,
merci de nous contacter.
Pour payer, si votre équipe est bien validée, vous pouvez vous rendre sur la page de votre compte
ou celle de votre équipe, et cliquer sur le bouton « Modifier le paiement », qui devrais désormais
apparaître. Vous pouvez également utiliser le lien présent dans le volet « Informations ».
.. image:: /_static/img/payment_index.png
:alt: Page de paiement
.. note::
Vous recevrez un mail de rappel chaque semaine. Le paiement doit être effectué avant le début du
tournoi, sans quoi votre participation pourrait être refusée. En cas de difficultés de paiement,
merci de nous contacter.
Carte bancaire
""""""""""""""
La façon la plus simple de payer son inscription est de payer par carte bancaire. Animath utilise
`Hello Asso <https://helloasso.com/>`_ en guise de solution de paiements en ligne.
Il vous suffit de cliquer sur le bouton « Aller à la page Hello Asso ». Vous serez redirigé⋅e ensuite
vers la page de paiement.
.. warning::
Pour procéder au paiement, si vous êtes mineur⋅e, vous devrez demander à un⋅e adulte de payer à
votre place. Il est important dans la suite de bien mettre les coordonnées du payeur ou de la payeuse,
majeur⋅e, et non celles de l'élève.
.. image:: /_static/img/payment_hello_asso_step_1.png
:alt: Formulaire de paiement Hello Asso
La personne qui paie peut rentrer ses informations demandées (nom, prénom, e-mail, date de naissance).
Notez que, par défaut, Hello Asso ajoute automatiquement une participation à ses frais de fonctionnement,
d'environ 15 à 20 % du prix payé. Ces frais ne sont pas obligatoires, ne sont pas versés à Animath et
représentent la seule source de revenus à Hello Asso. En effet : Animath ne verse aucune commission lors
de ses transactions, et seules les contributions volontaires financent leur service.
Sur la page suivante, vous pouvez indiquer vos coordonnées bancaires :
.. image:: /_static/img/payment_hello_asso_step_2.png
:alt: Formulaire de paiement Hello Asso - coordonnées bancaires
Vous devez ensuite éventuellement confirmer votre paiement auprès de votre banque.
Une fois ceci fait, vous êtes automatiquement redirigé⋅es vers la plateforme du TFJM² :
.. image:: /_static/img/payment_hello_asso_confirmation.png
:alt: Confirmation de paiement Hello Asso
Il se peut que la validation ne soit pas instantanée. Elle peut prendre au plus quelques minutes.
Si le délai est plus long, merci de nous contacter.
Vous recevrez ensuite un mail de confirmation de la plateforme, ainsi qu'un justificatif de paiement
de la part de Hello Asso.
Carte bancaire - paiement par un tiers
""""""""""""""""""""""""""""""""""""""
Il est possible, si nécessaire, de faire payer l'inscription par carte bancaire par un tiers. Pour cela,
vous pouvez lui transmettre le lien de paiement qui apparaît au centre de l'écran. Cela est notamment
utile pour faire payer l'inscription par un établissement scolaire, ou par des parents.
L'interface de paiement sera ensuite identique.
Virement bancaire
"""""""""""""""""
Il est possible de payer par virement bancaire. Pour cela, vous pouvez ouvrir l'onglet virement bancaire :
.. image:: /_static/img/payment_bank_transfer.png
:alt: Formulaire de paiement par virement bancaire
Pour effectuer le virement, merci de mettre en référence du virement « TFJMpu » suivi du nom et du prénom de l'élève.
Les coordonnées bancaires sont :
* IBAN : FR76 1027 8065 0000 0206 4290 127
* BIC : CMCIFR2A
Une fois le paiment effectué, vous pouvez envoyer une preuve de virement via le formulaire ci-dessus. Le paiement
sera ensuite validé manuellement par les organisateur⋅rices après réception.
Si vous avez besoin d'une facture, merci de nous contacter.
Exonération - boursièr⋅es
"""""""""""""""""""""""""
Si vous bénéficiez d'une bourse, vous pouvez être exonéré⋅es des frais de participation. Pour cela, il vous suffit
de nous envoyer une copie de votre notification de bourse, ou tout autre document justifiant de votre situation.
Vous pouvez envoyer ce document en vous rendant sur l'onglet dédié :
.. image:: /_static/img/payment_scholarship.png
:alt: Formulaire de soumission de notification de bourse
Paiements groupés
"""""""""""""""""
Il est possible de payer en une seule fois pour toute l'équipe. Cela est notamment utile si l'inscription est
payée par l'établissement. Pour cela, il suffit de cliquer sur le bouton « Regrouper les paiements de mon équipe ».
Cela a pour effet d'unifier les paiements de l'équipe, et de ne pas demander à chaque membre de payer individuellement.
Attention : cette fonction n'est possible que si aucun membre de l'équipe n'a encore payé son inscription.
.. image:: /_static/img/payment_grouped.png
:alt: Page de paiement groupé
Envoyer ses solutions
---------------------
.. TODO
.. note::
Cette section sera mise à jour plus tard.
Participer au tirage au sort
----------------------------
La documentation des tirages au sort est disponible sur `la page dédiée <draw.html>`_.
Envoyer ses notes de synthèse
-----------------------------
.. TODO
.. note::
Cette section sera mise à jour plus tard.
Récupérer les solutions adverses
--------------------------------
.. TODO
.. note::
Cette section sera mise à jour plus tard.

Some files were not shown because too many files have changed in this diff Show More