1
0
mirror of https://gitlab.com/animath/si/plateforme.git synced 2025-07-01 11:21:20 +02:00

Compare commits

..

645 Commits

Author SHA1 Message Date
8aec72d712 Correction mot Coefficient 2025-05-31 17:38:45 +02:00
6a521b6121 Noms des fichiers en français 2025-05-31 12:18:12 +02:00
62abfa94d6 Correction liens bandeau Informations pour la finale 2025-05-29 21:49:59 +02:00
952315ea4d Correction publication des notes pour le dernier tour 2025-05-05 10:28:22 +02:00
2e613799c9 Remplacement de yuglify par uglify, plus récent 2025-04-28 23:32:49 +02:00
08805a6360 Correction non-affichage des colonnes d'observation sans observateur 2025-04-28 22:44:08 +02:00
6841659e41 Plus de AdminRegistration à indexer 2025-04-28 22:14:40 +02:00
a84ffcf0a3 Bouton pour rendre les solutions accessibles pour le second tour en 1 clic 2025-04-28 22:01:26 +02:00
203fc3cd54 On n'affiche pas les paiements pour la finale sur la liste des paiements d'un tournoi régional 2025-04-28 20:43:36 +02:00
60f5236dee Affichage du tournoi dans la liste des réponses à un questionnaire 2025-04-28 20:35:23 +02:00
ab459ecc17 On n'affiche pas les données de l'équipe observatrice quand on a pas 2025-04-28 20:34:06 +02:00
7ad7659d78 Use solution number instead of passage index in scale sheets 2025-04-28 20:06:53 +02:00
84eb08ec46 Correction formulaire saisie notes s'il n'y a pas d'observateur⋅rice 2025-04-26 19:20:54 +02:00
3750828883 Send mails using the runmailer_pg command 2025-04-25 00:00:25 +02:00
ba36ad4071 Update coefficients 2025-04-24 21:57:52 +02:00
626433c464 Prevent some errors 2025-04-24 21:29:08 +02:00
032b67ac51 Don't generate spreadhseet if there is no team in a pool 2025-04-23 20:40:14 +02:00
f3bd479fdc Fix final sheet layout for 4-teams pools 2025-04-22 23:17:20 +02:00
bc06cf4903 Fix draw issues with translated strings 2025-04-22 22:58:12 +02:00
6d43c4b97e annulé != terminé 2025-04-22 20:59:53 +02:00
0499885fc8 Fix problem names for 2025 2025-04-22 20:20:22 +02:00
63c96ff2d2 Refetch search query when the input is updated 2025-04-22 19:52:07 +02:00
efeb2628ad Fix notation sheet when there is no observer 2025-04-22 19:44:21 +02:00
56aad288f4 Simplify elasticsearch index to make it work better 2025-04-22 19:19:22 +02:00
b33a69410a Bump dependencies for Django 5.2 2025-04-21 18:57:23 +02:00
0a80e03b58 Add Docker build in CI 2025-04-21 18:57:16 +02:00
73b94d5578 Remove default gender value
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2025-03-27 20:19:11 +01:00
97eea3b11a Add survey notification in the menu 2025-03-19 23:56:53 +01:00
702c8d8c9e Add survey feature 2025-03-19 23:18:45 +01:00
ca0601fb24 Autorisation parentale particulière pour Lyon 2025-03-16 19:39:38 +01:00
d315c8371a Update Bootstrap to v5.3.3 and fix light mode hamburger button in chat 2025-03-09 13:08:58 +01:00
7488d3eae1 Ensure that all mails are translated 2025-03-09 12:35:04 +01:00
cfaf7c4287 Add API documentation link for GDrive notifications 2025-03-09 12:01:06 +01:00
e3c216e44e Update crons 2025-03-09 11:54:37 +01:00
73012bd61e Remove "new in 2025" section 2025-03-09 11:05:39 +01:00
bdf181e7e4 Use slugs for email addresses instead of lower names 2025-03-09 10:46:29 +01:00
c57ad854fe Add signature field in parental authorization templates 2025-03-05 20:01:29 +01:00
a2e5ab5f6a Fix participation form layout 2025-03-05 19:49:25 +01:00
758a2c9a00 Fix registration dates test 2025-03-05 19:41:09 +01:00
fb10df77e5 Allow admins to create users outside registration period 2025-03-05 18:57:01 +01:00
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
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
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
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
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
e2d5a55173 Merge branch 'dev' into 'master'
Dev

See merge request animath/si/plateforme-tfjm!14
2021-01-24 22:57:37 +00: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
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
f83b4c094e Merge branch 'dev' into 'master'
Dev

See merge request animath/si/plateforme-tfjm!11
2021-01-23 13:33:54 +00: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
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
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
641e53e617 Merge branch 'dev' into 'master'
Dev

See merge request animath/si/plateforme-tfjm!7
2021-01-22 08:51:59 +00: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
402 changed files with 48879 additions and 6971 deletions

18
.gitignore vendored
View File

@ -15,16 +15,6 @@ coverage
*.mo
*.pot
# Jupyter Notebook
.ipynb_checkpoints
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# PyCharm project settings
.idea
@ -32,13 +22,13 @@ coverage
.vscode
# Local data
secrets.py
settings_local.py
*.log
media/
output/
/static/
# Virtualenv
env/
venv/
db.sqlite3
# Don't git index
whoosh_index/

View File

@ -1,22 +1,30 @@
stages:
- test
- quality-assurance
- build
- release
py38:
variables:
CONTAINER_TEST_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
CONTAINER_RELEASE_IMAGE: $CI_REGISTRY_IMAGE:latest
py312:
stage: test
image: python:3.8-alpine
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 py38
script: tox -e py312
py39:
py313:
stage: test
image: python:3.9-alpine
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 py39
script: tox -e py313
linters:
stage: quality-assurance
@ -25,3 +33,29 @@ linters:
- pip install tox --no-cache-dir
script: tox -e linters
allow_failure: true
build-image:
image: docker
stage: build
services:
- docker:dind
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
script:
- docker build --pull -t $CONTAINER_TEST_IMAGE .
- docker push $CONTAINER_TEST_IMAGE
release-image:
image: docker
stage: release
services:
- docker:dind
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
script:
- docker pull $CONTAINER_TEST_IMAGE
- docker tag $CONTAINER_TEST_IMAGE $CONTAINER_RELEASE_IMAGE
- docker push $CONTAINER_RELEASE_IMAGE
rules:
- if: $CI_COMMIT_BRANCH == "main"

View File

@ -1,9 +1,10 @@
FROM python:3.8-alpine
FROM python:3.13-alpine
ENV PYTHONUNBUFFERED 1
ENV DJANGO_ALLOW_ASYNC_UNSAFE 1
RUN apk add --no-cache gettext nginx gcc libc-dev libffi-dev libxml2-dev libxslt-dev postgresql-dev libmagic texlive
RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libpq-dev libxml2-dev libxslt-dev \
libmagic texlive texmf-dist-fontsrecommended texmf-dist-lang texmf-dist-latexextra uglify-js
RUN apk add --no-cache bash
@ -23,10 +24,8 @@ RUN python manage.py collectstatic --noinput && \
python manage.py compilemessages
# Configure nginx
RUN mkdir /run/nginx
RUN ln -sf /dev/stdout /var/log/nginx/access.log && ln -sf /dev/stderr /var/log/nginx/error.log
RUN ln -sf /code/nginx_tfjm.conf /etc/nginx/conf.d/tfjm.conf
RUN rm /etc/nginx/conf.d/default.conf
RUN ln -sf /code/nginx_tfjm.conf /etc/nginx/http.d/tfjm.conf && rm /etc/nginx/http.d/default.conf
RUN crontab /code/tfjm.cron
@ -36,4 +35,4 @@ RUN ln -s /code/.bashrc /root/.bashrc
ENTRYPOINT ["/code/entrypoint.sh"]
EXPOSE 80
CMD ["./manage.py", "shell_plus", "--ipython"]
CMD ["./manage.py", "shell"]

View File

@ -54,14 +54,13 @@ 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
SYNAPSE_PASSWORD= # Mot de passe du robot Matrix
```
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. Les intégrations mail et Matrix
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.

View File

@ -2,7 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.conf.urls import include, url
from django.urls import include, path
from rest_framework import routers
from .viewsets import UserViewSet
@ -16,11 +16,19 @@ 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 = [
url('^', include(router.urls)),
url('^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
path('', include(router.urls)),
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
]

View File

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

View File

@ -1,8 +0,0 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
class EastereggsConfig(AppConfig):
name = 'eastereggs'

View File

@ -1,19 +0,0 @@
{% extends "index.html" %}
{% block content %}
<div id="index-content"></div>
{% include "eastereggs/xp_modal.html" %}
{% endblock %}
{% block extrajavascript %}
<script>
$(document).ready(function() {
$("#index-content").load("{% url "index" %} #content");
function displayModal() {
$("#xpModal").modal('toggle');
setTimeout(displayModal, 400);
}
displayModal();
});
</script>
{% endblock %}

View File

@ -1,20 +0,0 @@
{% load crispy_forms_filters i18n %}
<div id="xpModal" class="modal fade" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans "Error" %}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
{% trans "This task failed successfully." %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{% trans "Close" %}</button>
</div>
</div>
</div>
</div>

View File

@ -1,11 +0,0 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path
from django.views.generic import TemplateView
app_name = "eastereggs"
urlpatterns = [
path("xp/", TemplateView.as_view(template_name="eastereggs/xp.html")),
]

View File

@ -1,54 +0,0 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Participation, Passage, Pool, Solution, Synthesis, Team, Tournament
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ('name', 'trigram', 'valid',)
search_fields = ('name', 'trigram',)
list_filter = ('participation__valid',)
def valid(self, team):
return team.participation.valid
valid.short_description = _('valid')
@admin.register(Participation)
class ParticipationAdmin(admin.ModelAdmin):
list_display = ('team', 'valid',)
search_fields = ('team__name', 'team__trigram',)
list_filter = ('valid',)
@admin.register(Pool)
class PoolAdmin(admin.ModelAdmin):
search_fields = ('participations__team__name', 'participations__team__trigram',)
@admin.register(Passage)
class PassageAdmin(admin.ModelAdmin):
search_fields = ('pool__participations__team__name', 'pool__participations__team__trigram',)
@admin.register(Solution)
class SolutionAdmin(admin.ModelAdmin):
list_display = ('participation',)
search_fields = ('participation__team__name', 'participation__team__trigram',)
@admin.register(Synthesis)
class SynthesisAdmin(admin.ModelAdmin):
list_display = ('participation',)
search_fields = ('participation__team__name', 'participation__team__trigram',)
@admin.register(Tournament)
class TournamentAdmin(admin.ModelAdmin):
list_display = ('name',)
search_fields = ('name',)

View File

@ -1,19 +0,0 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.db.models.signals import post_save, pre_save
class ParticipationConfig(AppConfig):
"""
The participation app contains the data about the teams, solutions, ...
"""
name = 'participation'
def ready(self):
from participation.signals import create_notes, create_team_participation, update_mailing_list
pre_save.connect(update_mailing_list, "participation.Team")
post_save.connect(create_team_participation, "participation.Team")
post_save.connect(create_notes, "participation.Passage")
post_save.connect(create_notes, "participation.Pool")

View File

@ -1,232 +0,0 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
import re
from bootstrap_datepicker_plus import DatePickerInput, DateTimePickerInput
from django import forms
from django.core.exceptions import ValidationError
from django.utils import formats
from django.utils.translation import gettext_lazy as _
from PyPDF3 import PdfFileReader
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament
class TeamForm(forms.ModelForm):
"""
Form to create a team, with the name and the trigram,...
"""
def clean_name(self):
if "name" in self.cleaned_data:
name = self.cleaned_data["name"].upper()
if not self.instance.pk and Team.objects.filter(name=name).exists():
raise ValidationError(_("This name is already used."))
return name
def clean_trigram(self):
if "trigram" in self.cleaned_data:
trigram = self.cleaned_data["trigram"].upper()
if not re.match("[A-Z]{3}", trigram):
raise ValidationError(_("The trigram must be composed of three uppercase letters."))
if not self.instance.pk and Team.objects.filter(trigram=trigram).exists():
raise ValidationError(_("This trigram is already used."))
return trigram
class Meta:
model = Team
fields = ('name', 'trigram',)
class JoinTeamForm(forms.ModelForm):
"""
Form to join a team by the access code.
"""
def clean_access_code(self):
access_code = self.cleaned_data["access_code"]
if not Team.objects.filter(access_code=access_code).exists():
raise ValidationError(_("No team was found with this access code."))
return access_code
def clean(self):
cleaned_data = super().clean()
if "access_code" in cleaned_data:
team = Team.objects.get(access_code=cleaned_data["access_code"])
self.instance = team
return cleaned_data
class Meta:
model = Team
fields = ('access_code',)
class ParticipationForm(forms.ModelForm):
"""
Form to update the problem of a team participation.
"""
class Meta:
model = Participation
fields = ('tournament',)
class MotivationLetterForm(forms.ModelForm):
def clean_file(self):
if "motivation_letter" in self.files:
file = self.files["motivation_letter"]
if file.size > 2e6:
raise ValidationError(_("The uploaded file size must be under 2 Mo."))
if file.content_type not in ["application/pdf", "image/png", "image/jpeg"]:
raise ValidationError(_("The uploaded file must be a PDF, PNG of JPEG file."))
return self.cleaned_data["motivation_letter"]
class Meta:
model = Team
fields = ('motivation_letter',)
class RequestValidationForm(forms.Form):
"""
Form to ask about validation.
"""
_form_type = forms.CharField(
initial="RequestValidationForm",
widget=forms.HiddenInput(),
)
engagement = forms.BooleanField(
label=_("I engage myself to participate to the whole TFJM²."),
required=True,
)
class ValidateParticipationForm(forms.Form):
"""
Form to let administrators to accept or refuse a team.
"""
_form_type = forms.CharField(
initial="ValidateParticipationForm",
widget=forms.HiddenInput(),
)
message = forms.CharField(
label=_("Message to address to the team:"),
widget=forms.Textarea(),
)
class TournamentForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["date_start"].widget = DatePickerInput(
format=formats.get_format_lazy(format_type="DATE_INPUT_FORMATS", use_l10n=True)[0])
self.fields["date_end"].widget = DatePickerInput(
format=formats.get_format_lazy(format_type="DATE_INPUT_FORMATS", use_l10n=True)[0])
self.fields["inscription_limit"].widget = DateTimePickerInput(
format=formats.get_format_lazy(format_type="DATETIME_INPUT_FORMATS", use_l10n=True)[0])
self.fields["solution_limit"].widget = DateTimePickerInput(
format=formats.get_format_lazy(format_type="DATETIME_INPUT_FORMATS", use_l10n=True)[0])
self.fields["solutions_draw"].widget = DateTimePickerInput(
format=formats.get_format_lazy(format_type="DATETIME_INPUT_FORMATS", use_l10n=True)[0])
self.fields["syntheses_first_phase_limit"].widget = DateTimePickerInput(
format=formats.get_format_lazy(format_type="DATETIME_INPUT_FORMATS", use_l10n=True)[0])
self.fields["solutions_available_second_phase"].widget = DateTimePickerInput(
format=formats.get_format_lazy(format_type="DATETIME_INPUT_FORMATS", use_l10n=True)[0])
self.fields["syntheses_second_phase_limit"].widget = DateTimePickerInput(
format=formats.get_format_lazy(format_type="DATETIME_INPUT_FORMATS", use_l10n=True)[0])
self.fields["organizers"].widget = forms.CheckboxSelectMultiple()
class Meta:
model = Tournament
fields = '__all__'
class SolutionForm(forms.ModelForm):
def clean_file(self):
if "file" in self.files:
file = self.files["file"]
if file.size > 5e6:
raise ValidationError(_("The uploaded file size must be under 5 Mo."))
if file.content_type != "application/pdf":
raise ValidationError(_("The uploaded file must be a PDF file."))
pdf_reader = PdfFileReader(file)
pages = len(pdf_reader.pages)
if pages > 30:
raise ValidationError(_("The PDF file must not have more than 30 pages."))
return self.cleaned_data["photo_authorization"]
def save(self, commit=True):
"""
Don't save a solution with this way. Use a view instead
"""
class Meta:
model = Solution
fields = ('problem', 'file',)
class PoolForm(forms.ModelForm):
class Meta:
model = Pool
fields = ('tournament', 'round', 'bbb_url', 'juries',)
widgets = {
"juries": forms.CheckboxSelectMultiple,
}
class PoolTeamsForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["participations"].queryset = self.instance.tournament.participations.all()
class Meta:
model = Pool
fields = ('participations',)
widgets = {
"participations": forms.CheckboxSelectMultiple,
}
class PassageForm(forms.ModelForm):
def clean(self):
cleaned_data = super().clean()
if "defender" in cleaned_data and "opponent" in cleaned_data and "reporter" in cleaned_data \
and len({cleaned_data["defender"], cleaned_data["opponent"], cleaned_data["reporter"]}) < 3:
self.add_error(None, _("The defender, the opponent and the reporter must be different."))
if "defender" in self.cleaned_data and "solution_number" in self.cleaned_data \
and not Solution.objects.filter(participation=cleaned_data["defender"],
problem=cleaned_data["solution_number"]).exists():
self.add_error("solution_number", _("This defender did not work on this problem."))
return cleaned_data
class Meta:
model = Passage
fields = ('solution_number', 'place', 'defender', 'opponent', 'reporter',)
class SynthesisForm(forms.ModelForm):
def clean_file(self):
if "file" in self.files:
file = self.files["file"]
if file.size > 2e6:
raise ValidationError(_("The uploaded file size must be under 2 Mo."))
if file.content_type != "application/pdf":
raise ValidationError(_("The uploaded file must be a PDF file."))
return self.cleaned_data["photo_authorization"]
def save(self, commit=True):
"""
Don't save a synthesis with this way. Use a view instead
"""
class Meta:
model = Synthesis
fields = ('type', 'file',)
class NoteForm(forms.ModelForm):
class Meta:
model = Note
fields = ('defender_writing', 'defender_oral', 'opponent_writing',
'opponent_oral', 'reporter_writing', 'reporter_oral', )

View File

@ -1,54 +0,0 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from django.contrib.auth.models import User
from django.core.management import BaseCommand
import requests
class Command(BaseCommand):
def handle(self, *args, **options): # noqa: C901
organization = "animath"
form_slug = "tfjmm-2018"
from_date = "2000-01-01"
url = f"https://api.helloasso.com/v5/organizations/{organization}/forms/Event/{form_slug}/payments" \
f"?from={from_date}&pageIndex=1&pageSize=10000&retrieveOfflineDonations=false"
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {os.getenv('HELLO_ASSO_TOKEN', '')}",
}
http_response = requests.get(url, headers=headers)
response = http_response.json()
if http_response.status_code != 200:
message = response["message"]
self.stderr.write(f"Error while querying Hello Asso: {message}")
return
for payment in response:
if payment["state"] != "Authorized":
continue
payer = payment["payer"]
email = payer["email"]
qs = User.objects.filter(email=email)
if not qs.exists():
self.stderr.write(f"Warning: a payment was found by the email address {email}, "
"but this user is unknown.")
continue
user = qs.get()
if not user.registration.participates:
self.stderr.write(f"Warning: a payment was found by the email address {email}, "
"but this user is not a participant.")
continue
payment_obj = user.registration.payment
payment_obj.valid = True
payment_obj.type = "helloasso"
payment_obj.additional_information = f"Identifiant de transation : {payment['id']}\n" \
f"Date : {payment['date']}\n" \
f"Reçu : {payment['paymentReceiptUrl']}\n" \
f"Montant : {payment['amount'] / 100:.2f}"
payment_obj.save()
self.stdout.write(f"{payment_obj} is validated")

View File

@ -1,399 +0,0 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from asgiref.sync import async_to_sync
from django.core.management import BaseCommand
from django.utils.http import urlencode
from django.utils.translation import activate
from participation.models import Team, Tournament
from registration.models import AdminRegistration, Registration, VolunteerRegistration
from tfjm.matrix import Matrix, RoomPreset, RoomVisibility
class Command(BaseCommand):
def handle(self, *args, **options): # noqa: C901
activate("fr")
Matrix.set_display_name("Bot du TFJM²")
if not os.getenv("SYNAPSE_PASSWORD"):
avatar_uri = "plop"
else: # pragma: no cover
if not os.path.isfile(".matrix_avatar"):
avatar_uri = Matrix.get_avatar()
if isinstance(avatar_uri, str):
with open(".matrix_avatar", "w") as f:
f.write(avatar_uri)
else:
stat_file = os.stat("tfjm/static/logo.png")
with open("tfjm/static/logo.png", "rb") as f:
resp = Matrix.upload(f, filename="logo.png", content_type="image/png",
filesize=stat_file.st_size)[0][0]
avatar_uri = resp.content_uri
with open(".matrix_avatar", "w") as f:
f.write(avatar_uri)
Matrix.set_avatar(avatar_uri)
with open(".matrix_avatar", "r") as f:
avatar_uri = f.read().rstrip(" \t\r\n")
# Create basic channels
if not async_to_sync(Matrix.resolve_room_alias)("#aide-jurys-orgas:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias="aide-jurys-orgas",
name="Aide jurys & orgas",
topic="Pour discuter de propblèmes d'organisation",
federate=False,
preset=RoomPreset.private_chat,
)
if not async_to_sync(Matrix.resolve_room_alias)("#annonces:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias="annonces",
name="Annonces",
topic="Informations importantes du TFJM²",
federate=False,
preset=RoomPreset.public_chat,
)
if not async_to_sync(Matrix.resolve_room_alias)("#bienvenue:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias="bienvenue",
name="Bienvenue",
topic="Bienvenue au TFJM² 2021 !",
federate=False,
preset=RoomPreset.public_chat,
)
if not async_to_sync(Matrix.resolve_room_alias)("#bot:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias="bot",
name="Bot",
topic="Vive les r0b0ts",
federate=False,
preset=RoomPreset.public_chat,
)
if not async_to_sync(Matrix.resolve_room_alias)("#cno:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias="cno",
name="CNO",
topic="Channel des dieux",
federate=False,
preset=RoomPreset.private_chat,
)
if not async_to_sync(Matrix.resolve_room_alias)("#dev-bot:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias="dev-bot",
name="Bot - développement",
topic="Vive le bot",
federate=False,
preset=RoomPreset.private_chat,
)
if not async_to_sync(Matrix.resolve_room_alias)("#faq:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias="faq",
name="FAQ",
topic="Posez toutes vos questions ici !",
federate=False,
preset=RoomPreset.public_chat,
)
if not async_to_sync(Matrix.resolve_room_alias)("#flood:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias="flood",
name="Flood",
topic="Discutez de tout et de rien !",
federate=False,
preset=RoomPreset.public_chat,
)
if not async_to_sync(Matrix.resolve_room_alias)("#je-cherche-une-equipe:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias="je-cherche-une-equipe",
name="Je cherche une équipe",
topic="Le Tinder du TFJM²",
federate=False,
preset=RoomPreset.public_chat,
)
# Setup avatars
Matrix.set_room_avatar("#aide-jurys-orgas:tfjm.org", avatar_uri)
Matrix.set_room_avatar("#annonces:tfjm.org", avatar_uri)
Matrix.set_room_avatar("#bienvenue:tfjm.org", avatar_uri)
Matrix.set_room_avatar("#bot:tfjm.org", avatar_uri)
Matrix.set_room_avatar("#cno:tfjm.org", avatar_uri)
Matrix.set_room_avatar("#dev-bot:tfjm.org", avatar_uri)
Matrix.set_room_avatar("#faq:tfjm.org", avatar_uri)
Matrix.set_room_avatar("#flood:tfjm.org", avatar_uri)
Matrix.set_room_avatar("#je-cherche-une-equipe:tfjm.org", avatar_uri)
# Read-only channels
Matrix.set_room_power_level_event("#annonces:tfjm.org", "events_default", 50)
Matrix.set_room_power_level_event("#bienvenue:tfjm.org", "events_default", 50)
# Invite everyone to public channels
for r in Registration.objects.all():
Matrix.invite("#annonces:tfjm.org", f"@{r.matrix_username}:tfjm.org")
Matrix.invite("#bienvenue:tfjm.org", f"@{r.matrix_username}:tfjm.org")
Matrix.invite("#bot:tfjm.org", f"@{r.matrix_username}:tfjm.org")
Matrix.invite("#faq:tfjm.org", f"@{r.matrix_username}:tfjm.org")
Matrix.invite("#flood:tfjm.org", f"@{r.matrix_username}:tfjm.org")
Matrix.invite("#je-cherche-une-equipe:tfjm.org",
f"@{r.matrix_username}:tfjm.org")
self.stdout.write(f"Invite {r} in most common channels...")
# Volunteers have access to the help channel
for volunteer in VolunteerRegistration.objects.all():
Matrix.invite("#aide-jurys-orgas:tfjm.org", f"@{volunteer.matrix_username}:tfjm.org")
self.stdout.write(f"Invite {volunteer} in #aide-jury-orgas...")
# Admins are admins
for admin in AdminRegistration.objects.all():
self.stdout.write(f"Invite {admin} in #cno and #dev-bot...")
Matrix.invite("#cno:tfjm.org", f"@{admin.matrix_username}:tfjm.org")
Matrix.invite("#dev-bot:tfjm.org", f"@{admin.matrix_username}:tfjm.org")
self.stdout.write(f"Give admin permissions for {admin}...")
Matrix.set_room_power_level("#aide-jurys-orgas:tfjm.org", f"@{admin.matrix_username}:tfjm.org", 95)
Matrix.set_room_power_level("#annonces:tfjm.org", f"@{admin.matrix_username}:tfjm.org", 95)
Matrix.set_room_power_level("#bienvenue:tfjm.org", f"@{admin.matrix_username}:tfjm.org", 95)
Matrix.set_room_power_level("#bot:tfjm.org", f"@{admin.matrix_username}:tfjm.org", 95)
Matrix.set_room_power_level("#cno:tfjm.org", f"@{admin.matrix_username}:tfjm.org", 95)
Matrix.set_room_power_level("#dev-bot:tfjm.org", f"@{admin.matrix_username}:tfjm.org", 95)
Matrix.set_room_power_level("#faq:tfjm.org", f"@{admin.matrix_username}:tfjm.org", 95)
Matrix.set_room_power_level("#flood:tfjm.org", f"@{admin.matrix_username}:tfjm.org", 95)
Matrix.set_room_power_level("#je-cherche-une-equipe:tfjm.org", f"@{admin.matrix_username}:tfjm.org", 95)
# Create tournament-specific channels
for tournament in Tournament.objects.all():
self.stdout.write(f"Managing tournament of {tournament.name}.")
name = tournament.name
slug = name.lower().replace(" ", "-")
if not async_to_sync(Matrix.resolve_room_alias)(f"#annonces-{slug}:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias=f"annonces-{slug}",
name=f"{name} - Annonces",
topic=f"Annonces du tournoi de {name}",
federate=False,
preset=RoomPreset.private_chat,
)
if not async_to_sync(Matrix.resolve_room_alias)(f"#general-{slug}:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias=f"general-{slug}",
name=f"{name} - Général",
topic=f"Accueil du tournoi de {name}",
federate=False,
preset=RoomPreset.private_chat,
)
if not async_to_sync(Matrix.resolve_room_alias)(f"#flood-{slug}:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias=f"flood-{slug}",
name=f"{name} - Flood",
topic=f"Discussion libre du tournoi de {name}",
federate=False,
preset=RoomPreset.private_chat,
)
if not async_to_sync(Matrix.resolve_room_alias)(f"#jury-{slug}:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias=f"jury-{slug}",
name=f"{name} - Jury",
topic=f"Discussion entre les orgas et jurys du tournoi de {name}",
federate=False,
preset=RoomPreset.private_chat,
)
if not async_to_sync(Matrix.resolve_room_alias)(f"#orga-{slug}:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias=f"orga-{slug}",
name=f"{name} - Organisateurs",
topic=f"Discussion entre les orgas du tournoi de {name}",
federate=False,
preset=RoomPreset.private_chat,
)
if not async_to_sync(Matrix.resolve_room_alias)(f"#tirage-au-sort-{slug}:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias=f"tirage-au-sort-{slug}",
name=f"{name} - Tirage au sort",
topic=f"Tirage au sort du tournoi de {name}",
federate=False,
preset=RoomPreset.private_chat,
)
# Setup avatars
Matrix.set_room_avatar(f"#annonces-{slug}:tfjm.org", avatar_uri)
Matrix.set_room_avatar(f"#flood-{slug}:tfjm.org", avatar_uri)
Matrix.set_room_avatar(f"#general-{slug}:tfjm.org", avatar_uri)
Matrix.set_room_avatar(f"#jury-{slug}:tfjm.org", avatar_uri)
Matrix.set_room_avatar(f"#orga-{slug}:tfjm.org", avatar_uri)
Matrix.set_room_avatar(f"#tirage-au-sort-{slug}:tfjm.org", avatar_uri)
# Invite admins and give permissions
for admin in AdminRegistration.objects.all():
self.stdout.write(f"Invite {admin} in all channels of the tournament {name}...")
Matrix.invite(f"#annonces-{slug}:tfjm.org", f"@{admin.matrix_username}:tfjm.org")
Matrix.invite(f"#flood-{slug}:tfjm.org", f"@{admin.matrix_username}:tfjm.org")
Matrix.invite(f"#general-{slug}:tfjm.org", f"@{admin.matrix_username}:tfjm.org")
Matrix.invite(f"#jury-{slug}:tfjm.org", f"@{admin.matrix_username}:tfjm.org")
Matrix.invite(f"#orga-{slug}:tfjm.org", f"@{admin.matrix_username}:tfjm.org")
Matrix.invite(f"#tirage-au-sort-{slug}:tfjm.org", f"@{admin.matrix_username}:tfjm.org")
self.stdout.write(f"Give permissions to {admin} in all channels of the tournament {name}...")
Matrix.set_room_power_level(f"#annonces-{slug}:tfjm.org", f"@{admin.matrix_username}:tfjm.org", 95)
Matrix.set_room_power_level(f"#flood-{slug}:tfjm.org", f"@{admin.matrix_username}:tfjm.org", 95)
Matrix.set_room_power_level(f"#general-{slug}:tfjm.org", f"@{admin.matrix_username}:tfjm.org", 95)
Matrix.set_room_power_level(f"#jury-{slug}:tfjm.org", f"@{admin.matrix_username}:tfjm.org", 95)
Matrix.set_room_power_level(f"#orga-{slug}:tfjm.org", f"@{admin.matrix_username}:tfjm.org", 95)
Matrix.set_room_power_level(f"#tirage-au-sort-{slug}:tfjm.org", f"@{admin.matrix_username}:tfjm.org", 95)
# Invite organizers and give permissions
for orga in tournament.organizers.all():
self.stdout.write(f"Invite organizer {orga} in all channels of the tournament {name}...")
Matrix.invite(f"#annonces-{slug}:tfjm.org", f"@{orga.matrix_username}:tfjm.org")
Matrix.invite(f"#flood-{slug}:tfjm.org", f"@{orga.matrix_username}:tfjm.org")
Matrix.invite(f"#general-{slug}:tfjm.org", f"@{orga.matrix_username}:tfjm.org")
Matrix.invite(f"#jury-{slug}:tfjm.org", f"@{orga.matrix_username}:tfjm.org")
Matrix.invite(f"#orga-{slug}:tfjm.org", f"@{orga.matrix_username}:tfjm.org")
Matrix.invite(f"#tirage-au-sort-{slug}:tfjm.org", f"@{orga.matrix_username}:tfjm.org")
if not orga.is_admin:
Matrix.set_room_power_level(f"#annonces-{slug}:tfjm.org", f"@{orga.matrix_username}:tfjm.org", 50)
Matrix.set_room_power_level(f"#flood-{slug}:tfjm.org", f"@{orga.matrix_username}:tfjm.org", 50)
Matrix.set_room_power_level(f"#general-{slug}:tfjm.org", f"@{orga.matrix_username}:tfjm.org", 50)
Matrix.set_room_power_level(f"#jury-{slug}:tfjm.org", f"@{orga.matrix_username}:tfjm.org", 50)
Matrix.set_room_power_level(f"#orga-{slug}:tfjm.org", f"@{orga.matrix_username}:tfjm.org", 50)
Matrix.set_room_power_level(f"#tirage-au-sort-{slug}:tfjm.org",
f"@{orga.matrix_username}:tfjm.org", 50)
# Invite participants
for participation in tournament.participations.filter(valid=True).all():
for participant in participation.team.participants.all():
self.stdout.write(f"Invite {participant} in public channels of the tournament {name}...")
Matrix.invite(f"#annonces-{slug}:tfjm.org", f"@{participant.matrix_username}:tfjm.org")
Matrix.invite(f"#flood-{slug}:tfjm.org", f"@{participant.matrix_username}:tfjm.org")
Matrix.invite(f"#general-{slug}:tfjm.org", f"@{participant.matrix_username}:tfjm.org")
Matrix.invite(f"#tirage-au-sort-{slug}:tfjm.org", f"@{participant.matrix_username}:tfjm.org")
# Create pool-specific channels
for pool in tournament.pools.all():
self.stdout.write(f"Managing {pool}...")
if not async_to_sync(Matrix.resolve_room_alias)(f"#poule-{slug}-{pool.id}:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias=f"poule-{slug}-{pool.id}",
name=f"{name} - Jour {pool.round} - Poule "
f"{', '.join(participation.team.trigram for participation in pool.participations.all())}",
topic=f"Discussion avec les équipes - {pool}",
federate=False,
preset=RoomPreset.private_chat,
)
if not async_to_sync(Matrix.resolve_room_alias)(f"#poule-{slug}-{pool.id}-jurys:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias=f"poule-{slug}-{pool.id}-jurys",
name=f"{name} - Jour {pool.round} - Jurys poule "
f"{', '.join(participation.team.trigram for participation in pool.participations.all())}",
topic=f"Discussion avec les jurys - {pool}",
federate=False,
preset=RoomPreset.private_chat,
)
Matrix.set_room_avatar(f"#poule-{slug}-{pool.id}:tfjm.org", avatar_uri)
Matrix.set_room_avatar(f"#poule-{slug}-{pool.id}-jurys:tfjm.org", avatar_uri)
url_params = urlencode(dict(url=pool.bbb_url,
isAudioConf='false', displayName='$matrix_display_name',
avatarUrl='$matrix_avatar_url', userId='$matrix_user_id')) \
.replace("%24", "$")
Matrix.add_integration(f"#poule-{slug}-{pool.id}:tfjm.org",
f"https://scalar.vector.im/api/widgets/bigbluebutton.html?{url_params}",
f"bbb-{slug}-{pool.id}", "bigbluebutton", "BigBlueButton", str(pool))
Matrix.add_integration(f"#poule-{slug}-{pool.id}:tfjm.org",
f"https://board.tfjm.org/boards/{slug}-{pool.id}", f"board-{slug}-{pool.id}",
"customwidget", "Tableau", str(pool))
# Invite admins and give permissions
for admin in AdminRegistration.objects.all():
Matrix.invite(f"#poule-{slug}-{pool.id}:tfjm.org", f"@{admin.matrix_username}:tfjm.org")
Matrix.invite(f"#poule-{slug}-{pool.id}-jurys:tfjm.org", f"@{admin.matrix_username}:tfjm.org")
Matrix.set_room_power_level(f"#poule-{slug}-{pool.id}:tfjm.org",
f"@{admin.matrix_username}:tfjm.org", 95)
Matrix.set_room_power_level(f"#poule-{slug}-{pool.id}-jurys:tfjm.org",
f"@{admin.matrix_username}:tfjm.org", 95)
# Invite organizers and give permissions
for orga in VolunteerRegistration.objects.all():
Matrix.invite(f"#poule-{slug}-{pool.id}:tfjm.org", f"@{orga.matrix_username}:tfjm.org")
Matrix.invite(f"#poule-{slug}-{pool.id}-jurys:tfjm.org", f"@{orga.matrix_username}:tfjm.org")
if not orga.is_admin:
Matrix.set_room_power_level(f"#poule-{slug}-{pool.id}:tfjm.org",
f"@{orga.matrix_username}:tfjm.org", 50)
Matrix.set_room_power_level(f"#poule-{slug}-{pool.id}-jurys:tfjm.org",
f"@{orga.matrix_username}:tfjm.org", 50)
# Invite the jury, give good permissions
for jury in pool.juries.all():
Matrix.invite(f"#annonces-{slug}:tfjm.org", f"@{jury.matrix_username}:tfjm.org")
Matrix.invite(f"#general-{slug}:tfjm.org", f"@{jury.matrix_username}:tfjm.org")
Matrix.invite(f"#flood-{slug}:tfjm.org", f"@{jury.matrix_username}:tfjm.org")
Matrix.invite(f"#jury-{slug}:tfjm.org", f"@{jury.matrix_username}:tfjm.org")
Matrix.invite(f"#orga-{slug}:tfjm.org", f"@{jury.matrix_username}:tfjm.org")
Matrix.invite(f"#poule-{slug}-{pool.id}:tfjm.org", f"@{jury.matrix_username}:tfjm.org")
Matrix.invite(f"#poule-{slug}-{pool.id}-jurys:tfjm.org", f"@{jury.matrix_username}:tfjm.org")
Matrix.invite(f"#tirage-au-sort-{slug}:tfjm.org", f"@{jury.matrix_username}:tfjm.org")
if not jury.is_admin:
Matrix.set_room_power_level(f"#jury-{slug}:tfjm.org", f"@{jury.matrix_username}:tfjm.org", 50)
Matrix.set_room_power_level(f"#poule-{slug}-{pool.id}:tfjm.org",
f"@{jury.matrix_username}:tfjm.org", 50)
Matrix.set_room_power_level(f"#poule-{slug}-{pool.id}-jurys:tfjm.org",
f"@{jury.matrix_username}:tfjm.org", 50)
# Invite participants to the right pool
for participation in pool.participations.all():
for participant in participation.team.participants.all():
Matrix.invite(f"#poule-{slug}-{pool.id}:tfjm.org", f"@{participant.matrix_username}:tfjm.org")
# Create private channels for teams
for team in Team.objects.all():
self.stdout.write(f"Create private channel for {team}...")
if not async_to_sync(Matrix.resolve_room_alias)(f"#equipe-{team.trigram.lower()}:tfjm.org"):
Matrix.create_room(
visibility=RoomVisibility.public,
alias=f"equipe-{team.trigram.lower()}",
name=f"Équipe {team.trigram}",
topic=f"Discussion interne de l'équipe {team.name}",
federate=False,
preset=RoomPreset.private_chat,
)
for participant in team.participants.all():
Matrix.invite(f"#equipe-{team.trigram.lower}:tfjm.org", f"@{participant.matrix_username}:tfjm.org")
Matrix.set_room_power_level(f"#equipe-{team.trigram.lower()}:tfjm.org",
f"@{participant.matrix_username}:tfjm.org", 50)

View File

@ -1,18 +0,0 @@
# Generated by Django 3.0.11 on 2021-01-23 18:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('participation', '0002_auto_20210122_1926'),
]
operations = [
migrations.AddField(
model_name='tournament',
name='remote',
field=models.BooleanField(default=False, verbose_name='remote'),
),
]

View File

@ -1,658 +0,0 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import date
import os
from address.models import AddressField
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.db import models
from django.db.models import Index
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _
from registration.models import VolunteerRegistration
from tfjm.lists import get_sympa_client
from tfjm.matrix import Matrix, RoomPreset, RoomVisibility
def get_motivation_letter_filename(instance, filename):
return f"authorization/motivation_letters/motivation_letter_{instance.trigram}"
class Team(models.Model):
"""
The Team model represents a real team that participates to the TFJM².
This only includes the registration detail.
"""
name = models.CharField(
max_length=255,
verbose_name=_("name"),
unique=True,
)
trigram = models.CharField(
max_length=3,
verbose_name=_("trigram"),
help_text=_("The trigram must be composed of three uppercase letters."),
unique=True,
validators=[RegexValidator("[A-Z]{3}")],
)
access_code = models.CharField(
max_length=6,
verbose_name=_("access code"),
help_text=_("The access code let other people to join the team."),
)
motivation_letter = models.FileField(
verbose_name=_("motivation letter"),
upload_to=get_motivation_letter_filename,
blank=True,
default="",
)
@property
def students(self):
return self.participants.filter(studentregistration__isnull=False)
@property
def coaches(self):
return self.participants.filter(coachregistration__isnull=False)
@property
def email(self):
"""
:return: The mailing list to contact the team members.
"""
return f"equipe-{self.trigram.lower()}@{os.getenv('SYMPA_HOST', 'localhost')}"
def create_mailing_list(self):
"""
Create a new Sympa mailing list to contact the team.
"""
get_sympa_client().create_list(
f"equipe-{self.trigram.lower()}",
f"Equipe {self.name} ({self.trigram})",
"hotline", # TODO Use a custom sympa template
f"Liste de diffusion pour contacter l'equipe {self.name} du TFJM2",
"education",
raise_error=False,
)
def delete_mailing_list(self):
"""
Drop the Sympa mailing list, if the team is empty or if the trigram changed.
"""
if self.participation.valid: # pragma: no cover
get_sympa_client().unsubscribe(
self.email, f"equipes-{self.participation.tournament.name.lower().replace(' ', '-')}", False)
else:
get_sympa_client().unsubscribe(self.email, "equipes-non-valides", False)
get_sympa_client().delete_list(f"equipe-{self.trigram}")
def save(self, *args, **kwargs):
if not self.access_code:
# if the team got created, generate the access code, create the contact mailing list
# and create a dedicated Matrix room.
self.access_code = get_random_string(6)
self.create_mailing_list()
Matrix.create_room(
visibility=RoomVisibility.private,
name=f"#équipe-{self.trigram.lower()}",
alias=f"equipe-{self.trigram.lower()}",
topic=f"Discussion de l'équipe {self.name}",
preset=RoomPreset.private_chat,
)
return super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse_lazy("participation:team_detail", args=(self.pk,))
def __str__(self):
return _("Team {name} ({trigram})").format(name=self.name, trigram=self.trigram)
class Meta:
verbose_name = _("team")
verbose_name_plural = _("teams")
indexes = [
Index(fields=("trigram", )),
]
class Tournament(models.Model):
name = models.CharField(
max_length=255,
verbose_name=_("name"),
unique=True,
)
date_start = models.DateField(
verbose_name=_("start"),
default=date.today,
)
date_end = models.DateField(
verbose_name=_("end"),
default=date.today,
)
place = AddressField(
verbose_name=_("place"),
)
max_teams = models.PositiveSmallIntegerField(
verbose_name=_("max team count"),
default=9,
)
price = models.PositiveSmallIntegerField(
verbose_name=_("price"),
default=21,
)
remote = models.BooleanField(
verbose_name=_("remote"),
default=False,
)
inscription_limit = models.DateTimeField(
verbose_name=_("limit date for registrations"),
default=timezone.now,
)
solution_limit = models.DateTimeField(
verbose_name=_("limit date to upload solutions"),
default=timezone.now,
)
solutions_draw = models.DateTimeField(
verbose_name=_("random draw for solutions"),
default=timezone.now,
)
syntheses_first_phase_limit = models.DateTimeField(
verbose_name=_("limit date to upload the syntheses for the first phase"),
default=timezone.now,
)
solutions_available_second_phase = models.DateTimeField(
verbose_name=_("date when the solutions for the second round become available"),
default=timezone.now,
)
syntheses_second_phase_limit = models.DateTimeField(
verbose_name=_("limit date to upload the syntheses for the second phase"),
default=timezone.now,
)
description = models.TextField(
verbose_name=_("description"),
blank=True,
)
organizers = models.ManyToManyField(
VolunteerRegistration,
verbose_name=_("organizers"),
related_name="organized_tournaments",
)
final = models.BooleanField(
verbose_name=_("final"),
default=False,
)
@property
def teams_email(self):
"""
:return: The mailing list to contact the team members.
"""
return f"equipes-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}"
@property
def organizers_email(self):
"""
:return: The mailing list to contact the team members.
"""
return f"organisateurs-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}"
@property
def jurys_email(self):
"""
:return: The mailing list to contact the team members.
"""
return f"jurys-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}"
def create_mailing_lists(self):
"""
Create a new Sympa mailing list to contact the team.
"""
get_sympa_client().create_list(
f"equipes-{self.name.lower().replace(' ', '-')}",
f"Equipes du tournoi de {self.name}",
"hotline", # TODO Use a custom sympa template
f"Liste de diffusion pour contacter les equipes du tournoi {self.name} du TFJM²",
"education",
raise_error=False,
)
get_sympa_client().create_list(
f"organisateurs-{self.name.lower().replace(' ', '-')}",
f"Organisateurs du tournoi de {self.name}",
"hotline", # TODO Use a custom sympa template
f"Liste de diffusion pour contacter les equipes du tournoi {self.name} du TFJM²",
"education",
raise_error=False,
)
@staticmethod
def final_tournament():
qs = Tournament.objects.filter(final=True)
if qs.exists():
return qs.get()
@property
def participations(self):
if self.final:
return Participation.objects.filter(final=True)
return self.participation_set
@property
def solutions(self):
if self.final:
return Solution.objects.filter(final_solution=True)
return Solution.objects.filter(participation__tournament=self)
@property
def syntheses(self):
if self.final:
return Synthesis.objects.filter(final_solution=True)
return Synthesis.objects.filter(participation__tournament=self)
def get_absolute_url(self):
return reverse_lazy("participation:tournament_detail", args=(self.pk,))
def __str__(self):
return self.name
class Meta:
verbose_name = _("tournament")
verbose_name_plural = _("tournaments")
indexes = [
Index(fields=("name", "date_start", "date_end", )),
]
class Participation(models.Model):
"""
The Participation model contains all data that are related to the participation:
chosen problem, validity status, solutions,...
"""
team = models.OneToOneField(
Team,
on_delete=models.CASCADE,
verbose_name=_("team"),
)
tournament = models.ForeignKey(
Tournament,
on_delete=models.SET_NULL,
null=True,
blank=True,
default=None,
verbose_name=_("tournament"),
)
valid = models.BooleanField(
null=True,
default=None,
verbose_name=_("valid"),
help_text=_("The participation got the validation of the organizers."),
)
final = models.BooleanField(
default=False,
verbose_name=_("selected for final"),
help_text=_("The team is selected for the final tournament."),
)
def get_absolute_url(self):
return reverse_lazy("participation:participation_detail", args=(self.pk,))
def __str__(self):
return _("Participation of the team {name} ({trigram})").format(name=self.team.name, trigram=self.team.trigram)
class Meta:
verbose_name = _("participation")
verbose_name_plural = _("participations")
class Pool(models.Model):
tournament = models.ForeignKey(
Tournament,
on_delete=models.CASCADE,
related_name="pools",
verbose_name=_("tournament"),
)
round = models.PositiveSmallIntegerField(
verbose_name=_("round"),
choices=[
(1, format_lazy(_("Round {round}"), round=1)),
(2, format_lazy(_("Round {round}"), round=2)),
]
)
participations = models.ManyToManyField(
Participation,
related_name="pools",
verbose_name=_("participations"),
)
juries = models.ManyToManyField(
VolunteerRegistration,
related_name="jury_in",
verbose_name=_("juries"),
)
bbb_url = models.CharField(
max_length=255,
blank=True,
default="",
verbose_name=_("BigBlueButton URL"),
help_text=_("The link of the BBB visio for this pool."),
)
@property
def solutions(self):
return Solution.objects.filter(participation__in=self.participations, final_solution=self.tournament.final)
def average(self, participation):
return sum(passage.average(participation) for passage in self.passages.all())
def get_absolute_url(self):
return reverse_lazy("participation:pool_detail", args=(self.pk,))
def __str__(self):
return _("Pool {round} for tournament {tournament} with teams {teams}")\
.format(round=self.round,
tournament=str(self.tournament),
teams=", ".join(participation.team.trigram for participation in self.participations.all()))
class Meta:
verbose_name = _("pool")
verbose_name_plural = _("pools")
class Passage(models.Model):
pool = models.ForeignKey(
Pool,
on_delete=models.CASCADE,
verbose_name=_("pool"),
related_name="passages",
)
place = models.CharField(
verbose_name=_("place"),
max_length=255,
help_text=_("Where the solution is presented?"),
default="Non indiqué",
)
solution_number = models.PositiveSmallIntegerField(
verbose_name=_("defended solution"),
choices=[
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, settings.PROBLEM_COUNT + 1)
],
)
defender = models.ForeignKey(
Participation,
on_delete=models.PROTECT,
verbose_name=_("defender"),
related_name="+",
)
opponent = models.ForeignKey(
Participation,
on_delete=models.PROTECT,
verbose_name=_("opponent"),
related_name="+",
)
reporter = models.ForeignKey(
Participation,
on_delete=models.PROTECT,
verbose_name=_("reporter"),
related_name="+",
)
@property
def defended_solution(self) -> "Solution":
return Solution.objects.get(
participation=self.defender,
problem=self.solution_number,
final_solution=self.pool.tournament.final)
def avg(self, iterator) -> int:
items = [i for i in iterator if i]
return sum(items) / len(items) if items else 0
@property
def average_defender_writing(self) -> int:
return self.avg(note.defender_writing for note in self.notes.all())
@property
def average_defender_oral(self) -> int:
return self.avg(note.defender_oral for note in self.notes.all())
@property
def average_defender(self) -> int:
return self.average_defender_writing + 2 * self.average_defender_oral
@property
def average_opponent_writing(self) -> int:
return self.avg(note.opponent_writing for note in self.notes.all())
@property
def average_opponent_oral(self) -> int:
return self.avg(note.opponent_oral for note in self.notes.all())
@property
def average_opponent(self) -> int:
return self.average_opponent_writing + 2 * self.average_opponent_oral
@property
def average_reporter_writing(self) -> int:
return self.avg(note.reporter_writing for note in self.notes.all())
@property
def average_reporter_oral(self) -> int:
return self.avg(note.reporter_oral for note in self.notes.all())
@property
def average_reporter(self) -> int:
return self.average_reporter_writing + self.average_reporter_oral
def average(self, participation):
return self.average_defender if participation == self.defender else self.average_opponent \
if participation == self.opponent else self.average_reporter if participation == self.reporter else 0
def get_absolute_url(self):
return reverse_lazy("participation:passage_detail", args=(self.pk,))
def clean(self):
if self.defender not in self.pool.participations.all():
raise ValidationError(_("Team {trigram} is not registered in the pool.")
.format(trigram=self.defender.team.trigram))
if self.opponent not in self.pool.participations.all():
raise ValidationError(_("Team {trigram} is not registered in the pool.")
.format(trigram=self.opponent.team.trigram))
if self.reporter not in self.pool.participations.all():
raise ValidationError(_("Team {trigram} is not registered in the pool.")
.format(trigram=self.reporter.team.trigram))
return super().clean()
def __str__(self):
return _("Passage of {defender} for problem {problem}")\
.format(defender=self.defender.team, problem=self.solution_number)
class Meta:
verbose_name = _("passage")
verbose_name_plural = _("passages")
def get_solution_filename(instance, filename):
return f"solutions/{instance.participation.team.trigram}_{instance.problem}" \
+ ("final" if instance.final_solution else "")
def get_synthesis_filename(instance, filename):
return f"syntheses/{instance.participation.team.trigram}_{instance.type}_{instance.passage.pk}"
class Solution(models.Model):
participation = models.ForeignKey(
Participation,
on_delete=models.CASCADE,
verbose_name=_("participation"),
related_name="solutions",
)
problem = models.PositiveSmallIntegerField(
verbose_name=_("problem"),
choices=[
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, settings.PROBLEM_COUNT + 1)
],
)
final_solution = models.BooleanField(
verbose_name=_("solution for the final tournament"),
default=False,
)
file = models.FileField(
verbose_name=_("file"),
upload_to=get_solution_filename,
unique=True,
blank=True,
default="",
)
def __str__(self):
return _("Solution of team {team} for problem {problem}")\
.format(team=self.participation.team.name, problem=self.problem)
class Meta:
verbose_name = _("solution")
verbose_name_plural = _("solutions")
unique_together = (('participation', 'problem', 'final_solution', ), )
class Synthesis(models.Model):
participation = models.ForeignKey(
Participation,
on_delete=models.CASCADE,
verbose_name=_("participation"),
)
passage = models.ForeignKey(
Passage,
on_delete=models.CASCADE,
related_name="syntheses",
verbose_name=_("passage"),
)
type = models.PositiveSmallIntegerField(
choices=[
(1, _("opponent"), ),
(2, _("reporter"), ),
]
)
file = models.FileField(
verbose_name=_("file"),
upload_to=get_synthesis_filename,
unique=True,
blank=True,
default="",
)
def __str__(self):
return _("Synthesis for the {type} of the {passage}").format(type=self.get_type_display(), passage=self.passage)
class Meta:
verbose_name = _("synthesis")
verbose_name_plural = _("syntheses")
unique_together = (('participation', 'passage', 'type', ), )
class Note(models.Model):
jury = models.ForeignKey(
VolunteerRegistration,
on_delete=models.CASCADE,
verbose_name=_("jury"),
related_name="notes",
)
passage = models.ForeignKey(
Passage,
on_delete=models.CASCADE,
verbose_name=_("passage"),
related_name="notes",
)
defender_writing = models.PositiveSmallIntegerField(
verbose_name=_("defender writing note"),
choices=[(i, i) for i in range(0, 21)],
default=0,
)
defender_oral = models.PositiveSmallIntegerField(
verbose_name=_("defender oral note"),
choices=[(i, i) for i in range(0, 17)],
default=0,
)
opponent_writing = models.PositiveSmallIntegerField(
verbose_name=_("opponent writing note"),
choices=[(i, i) for i in range(0, 10)],
default=0,
)
opponent_oral = models.PositiveSmallIntegerField(
verbose_name=_("opponent oral note"),
choices=[(i, i) for i in range(0, 11)],
default=0,
)
reporter_writing = models.PositiveSmallIntegerField(
verbose_name=_("reporter writing note"),
choices=[(i, i) for i in range(0, 10)],
default=0,
)
reporter_oral = models.PositiveSmallIntegerField(
verbose_name=_("reporter oral note"),
choices=[(i, i) for i in range(0, 11)],
default=0,
)
def get_absolute_url(self):
return reverse_lazy("participation:passage_detail", args=(self.passage.pk,))
def __str__(self):
return _("Notes of {jury} for {passage}").format(jury=self.jury, passage=self.passage)
def __bool__(self):
return any((self.defender_writing, self.defender_oral, self.opponent_writing, self.opponent_oral,
self.reporter_writing, self.reporter_oral))
class Meta:
verbose_name = _("note")
verbose_name_plural = _("notes")

View File

@ -1,46 +0,0 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Union
from participation.models import Note, Participation, Passage, Pool, Team
from tfjm.lists import get_sympa_client
def create_team_participation(instance, created, **_):
"""
When a team got created, create an associated participation.
"""
participation = Participation.objects.get_or_create(team=instance)[0]
participation.save()
if not created:
participation.team.create_mailing_list()
def update_mailing_list(instance: Team, **_):
"""
When a team name or trigram got updated, update mailing lists and Matrix rooms
"""
if instance.pk:
old_team = Team.objects.get(pk=instance.pk)
if old_team.trigram != instance.trigram:
# TODO Rename Matrix room
# Delete old mailing list, create a new one
old_team.delete_mailing_list()
instance.create_mailing_list()
# Subscribe all team members in the mailing list
for student in instance.students.all():
get_sympa_client().subscribe(student.user.email, f"equipe-{instance.trigram.lower()}", False,
f"{student.user.first_name} {student.user.last_name}")
for coach in instance.coaches.all():
get_sympa_client().subscribe(coach.user.email, f"equipe-{instance.trigram.lower()}", False,
f"{coach.user.first_name} {coach.user.last_name}")
def create_notes(instance: Union[Passage, Pool], **_):
if isinstance(instance, Pool):
for passage in instance.passages.all():
create_notes(passage)
return
for jury in instance.pool.juries.all():
Note.objects.get_or_create(jury=jury, passage=instance)

View File

@ -1,39 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
The chat is located on the dedicated Matrix server:
{% endblocktrans %}
</div>
<div class="alert text-center">
<a class="btn btn-success" href="https://element.tfjm.org/#/room/#faq:tfjm.org" target="_blank">
<i class="fas fa-server"></i> {% trans "Access to the Matrix server" %}
</a>
</div>
<div class="alert alert-info">
<p>
{% blocktrans trimmed %}
To connect to the server, you can select "Log in", then use your credentials of this platform to connect
with the central authentication server, then you must trust the connection between the Matrix account and the
platform. Finally, you will be able to access to the chat platform.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
You will be invited in some basic rooms. You must confirm the invitations to join channels.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
If you have any trouble, don't hesitate to contact us :)
{% endblocktrans %}
</p>
</div>
{% endblock %}

View File

@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Équipe validée TFJM²</title>
</head>
<body>
Bonjour,<br/>
<br/>
Félicitations ! Votre équipe « {{ team.name }} » ({{ team.trigram }}) est désormais validée ! Vous êtes désormais apte
à travailler sur vos problèmes. Vous pourrez ensuite envoyer vos solutions sur la plateforme.<br>
Les organisateurs vous adressent ce message :<br/>
<br/>
{{ message }}<br />
<br/>
Cordialement,<br/>
<br/>
Le comité d'organisation du TFJM²
</body>
</html>

View File

@ -1,12 +0,0 @@
Bonjour,
Félicitations ! Votre équipe « {{ team.name }} » ({{ team.trigram }}) est désormais validée ! Vous êtes désormais apte
à travailler sur vos problèmes. Vous pourrez ensuite envoyer vos solutions sur la plateforme.
Les organisateurs vous adressent ce message :
{{ message }}
Cordialement,
Le comité d'organisation du TFJM²

View File

@ -1,140 +0,0 @@
{% extends "base.html" %}
{% load django_tables2 i18n %}
{% block content %}
{% trans "any" as any %}
<div class="card bg-light shadow">
<div class="card-header text-center">
<h4>{{ passage }}</h4>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-3">{% trans "Pool:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.pool.get_absolute_url }}">{{ passage.pool }}</a></dd>
<dt class="col-sm-3">{% trans "Defender:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.defender.get_absolute_url }}">{{ passage.defender.team }}</a></dd>
<dt class="col-sm-3">{% trans "Opponent:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.opponent.get_absolute_url }}">{{ passage.opponent.team }}</a></dd>
<dt class="col-sm-3">{% trans "Reporter:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.reporter.get_absolute_url }}">{{ passage.reporter.team }}</a></dd>
<dt class="col-sm-3">{% trans "Defended solution:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.defended_solution.file.url }}" data-turbolinks="false">{{ passage.defended_solution }}</a></dd>
<dt class="col-sm-3">{% trans "Place:" %}</dt>
<dd class="col-sm-9">{{ passage.place }}</dd>
<dt class="col-sm-3">{% trans "Syntheses:" %}</dt>
<dd class="col-sm-9">
{% for synthesis in passage.syntheses.all %}
<a href="{{ synthesis.file.url }}" data-turbolinks="false">{{ synthesis }}{% if not forloop.last %}, {% endif %}</a>
{% empty %}
{% trans "No synthesis was uploaded yet." %}
{% endfor %}
</dd>
</dl>
</div>
{% if user.registration.is_admin %}
<div class="card-footer text-center">
<button class="btn btn-info" data-toggle="modal" data-target="#updateNotesModal">{% trans "Update notes" %}</button>
<button class="btn btn-primary" data-toggle="modal" data-target="#updatePassageModal">{% trans "Update" %}</button>
</div>
{% elif user.registration.participates %}
<div class="card-footer text-center">
<button class="btn btn-primary" data-toggle="modal" data-target="#uploadSynthesisModal">{% trans "Upload synthesis" %}</button>
</div>
{% endif %}
</div>
{% if notes %}
<hr>
<h2>{% trans "Notes detail" %}</h2>
{% render_table notes %}
<div class="card bg-light shadow">
<div class="card-body">
<dl class="row">
<dt class="col-sm-8">{% trans "Average points for the defender writing:" %}</dt>
<dd class="col-sm-4">{{ passage.average_defender_writing }}/20</dd>
<dt class="col-sm-8">{% trans "Average points for the defender oral:" %}</dt>
<dd class="col-sm-4">{{ passage.average_defender_oral }}/16</dd>
<dt class="col-sm-8">{% trans "Average points for the opponent writing:" %}</dt>
<dd class="col-sm-4">{{ passage.average_opponent_writing }}/9</dd>
<dt class="col-sm-8">{% trans "Average points for the opponent oral:" %}</dt>
<dd class="col-sm-4">{{ passage.average_opponent_oral }}/10</dd>
<dt class="col-sm-8">{% trans "Average points for the reporter writing:" %}</dt>
<dd class="col-sm-4">{{ passage.average_reporter_writing }}/9</dd>
<dt class="col-sm-8">{% trans "Average points for the reporter oral:" %}</dt>
<dd class="col-sm-4">{{ passage.average_reporter_oral }}/10</dd>
</dl>
<hr>
<dl class="row">
<dt class="col-sm-8">{% trans "Defender points:" %}</dt>
<dd class="col-sm-4">{{ passage.average_defender }}/52</dd>
<dt class="col-sm-8">{% trans "Opponent points:" %}</dt>
<dd class="col-sm-4">{{ passage.average_opponent }}/29</dd>
<dt class="col-sm-8">{% trans "Reporter points:" %}</dt>
<dd class="col-sm-4">{{ passage.average_reporter }}/19</dd>
</dl>
</div>
</div>
{% endif %}
{% if user.registration.is_admin %}
{% trans "Update passage" as modal_title %}
{% trans "Update" as modal_button %}
{% url "participation:passage_update" pk=passage.pk as modal_action %}
{% include "base_modal.html" with modal_id="updatePassage" %}
{% trans "Update notes" as modal_title %}
{% trans "Update" as modal_button %}
{% url "participation:update_notes" pk=my_note.pk as modal_action %}
{% include "base_modal.html" with modal_id="updateNotes" %}
{% elif user.registration.participates %}
{% trans "Upload synthesis" as modal_title %}
{% trans "Upload" as modal_button %}
{% url "participation:upload_synthesis" pk=passage.pk as modal_action %}
{% include "base_modal.html" with modal_id="uploadSynthesis" modal_enctype="multipart/form-data" %}
{% endif %}
{% endblock %}
{% block extrajavascript %}
<script>
$(document).ready(function () {
{% if user.registration.is_admin %}
$('button[data-target="#updatePassageModal"]').click(function() {
let modalBody = $("#updatePassageModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:passage_update" pk=passage.pk %} #form-content")
});
$('button[data-target="#updateNotesModal"]').click(function() {
let modalBody = $("#updateNotesModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:update_notes" pk=my_note.pk %} #form-content")
});
{% elif user.registration.participates %}
$('button[data-target="#uploadSynthesisModal"]').click(function() {
let modalBody = $("#uploadSynthesisModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:upload_synthesis" pk=passage.pk %} #form-content")
});
{% endif %}
});
</script>
{% endblock %}

View File

@ -1,105 +0,0 @@
{% extends "base.html" %}
{% load django_tables2 i18n %}
{% block content %}
<div class="card bg-light shadow">
<div class="card-header text-center">
<h4>{{ pool }}</h4>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-3">{% trans "Tournament:" %}</dt>
<dd class="col-sm-9"><a href="{{ pool.tournament.get_absolute_url }}">{{ pool.tournament }}</a></dd>
<dt class="col-sm-3">{% trans "Round:" %}</dt>
<dd class="col-sm-9">{{ pool.get_round_display }}</dd>
<dt class="col-sm-3">{% trans "Teams:" %}</dt>
<dd class="col-sm-9">
{% for participation in pool.participations.all %}
<a href="{{ participation.get_absolute_url }}" data-turbolinks="false">{{ participation.team }}{% if not forloop.last %}, {% endif %}</a>
{% endfor %}
</dd>
<dt class="col-sm-3">{% trans "Juries:" %}</dt>
<dd class="col-sm-9">{{ pool.juries.all|join:", " }}</dd>
<dt class="col-sm-3">{% trans "Defended solutions:" %}</dt>
<dd class="col-sm-9">
{% for passage in pool.passages.all %}
<a href="{{ passage.defended_solution.file.url }}" data-turbolinks="false">{{ passage.defended_solution }}{% if not forloop.last %}, {% endif %}</a>
{% endfor %}
</dd>
<dt class="col-sm-3">{% trans "BigBlueButton link:" %}</dt>
<dd class="col-sm-9"><a href="{{ pool.bbb_url }}">{{ pool.bbb_url }}</a></dd>
</dl>
<div class="card bg-light shadow">
<div class="card-header text-center">
<h5>{% trans "Ranking" %}</h5>
</div>
<div class="card-body">
<ul>
{% for participation, note in notes %}
<li><strong>{{ participation.team }} :</strong> {{ note }}</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% if user.registration.is_admin %}
<div class="card-footer text-center">
<button class="btn btn-success" data-toggle="modal" data-target="#addPassageModal">{% trans "Add passage" %}</button>
<button class="btn btn-primary" data-toggle="modal" data-target="#updatePoolModal">{% trans "Update" %}</button>
<button class="btn btn-primary" data-toggle="modal" data-target="#updateTeamsModal">{% trans "Update teams" %}</button>
</div>
{% endif %}
</div>
<hr>
<h3>{% trans "Passages" %}</h3>
{% render_table passages %}
{% trans "Add passage" as modal_title %}
{% trans "Add" as modal_button %}
{% url "participation:passage_create" pk=pool.pk as modal_action %}
{% include "base_modal.html" with modal_id="addPassage" modal_button_type="success" %}
{% trans "Update pool" as modal_title %}
{% trans "Update" as modal_button %}
{% url "participation:pool_update" pk=pool.pk as modal_action %}
{% include "base_modal.html" with modal_id="updatePool" %}
{% trans "Update teams" as modal_title %}
{% trans "Update" as modal_button %}
{% url "participation:pool_update_teams" pk=pool.pk as modal_action %}
{% include "base_modal.html" with modal_id="updateTeams" %}
{% endblock %}
{% block extrajavascript %}
<script>
$(document).ready(function () {
$('button[data-target="#updatePoolModal"]').click(function() {
let modalBody = $("#updatePoolModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:pool_update" pk=pool.pk %} #form-content")
});
$('button[data-target="#updateTeamsModal"]').click(function() {
let modalBody = $("#updateTeamsModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:pool_update_teams" pk=pool.pk %} #form-content")
});
$('button[data-target="#addPassageModal"]').click(function() {
let modalBody = $("#addPassageModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:passage_create" pk=pool.pk %} #form-content")
});
});
</script>
{% endblock %}

View File

@ -1,205 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% load crispy_forms_filters %}
{% block content %}
<div class="card bg-light shadow">
<div class="card-header text-center">
<h4>{{ team.name }}</h4>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-6 text-right">{% trans "Name:" %}</dt>
<dd class="col-sm-6">{{ team.name }}</dd>
<dt class="col-sm-6 text-right">{% trans "Trigram:" %}</dt>
<dd class="col-sm-6">{{ team.trigram }}</dd>
<dt class="col-sm-6 text-right">{% trans "Email:" %}</dt>
<dd class="col-sm-6"><a href="mailto:{{ team.email }}">{{ team.email }}</a></dd>
<dt class="col-sm-6 text-right">{% trans "Access code:" %}</dt>
<dd class="col-sm-6">{{ team.access_code }}</dd>
<dt class="col-sm-6 text-right">{% trans "Coaches:" %}</dt>
<dd class="col-sm-6">
{% for coach in team.coaches.all %}
<a href="{% url "registration:user_detail" pk=coach.user.pk %}">{{ coach }}</a>{% if not forloop.last %},{% endif %}
{% empty %}
{% trans "any" %}
{% endfor %}
</dd>
<dt class="col-sm-6 text-right">{% trans "Participants:" %}</dt>
<dd class="col-sm-6">
{% for student in team.students.all %}
<a href="{% url "registration:user_detail" pk=student.user.pk %}">{{ student }}</a>{% if not forloop.last %},{% endif %}
{% empty %}
{% trans "any" %}
{% endfor %}
</dd>
<dt class="col-sm-6 text-right">{% trans "Tournament:" %}</dt>
<dd class="col-sm-6">
{% if team.participation.tournament %}
<a href="{% url "participation:tournament_detail" pk=team.participation.tournament.pk %}">{{ team.participation.tournament }}</a>
{% else %}
{% trans "any" %}
{% endif %}
</dd>
<dt class="col-sm-6 text-right">{% trans "Photo authorizations:" %}</dt>
<dd class="col-sm-6">
{% for participant in team.participants.all %}
{% if participant.photo_authorization %}
<a href="{{ participant.photo_authorization.url }}" data-turbolinks="false">{{ participant }}</a>{% if not forloop.last %},{% endif %}
{% else %}
{{ participant }} ({% trans "Not uploaded yet" %}){% if not forloop.last %},{% endif %}
{% endif %}
{% endfor %}
</dd>
{% if not team.participation.tournament.remote %}
<dt class="col-sm-6 text-right">{% trans "Health sheets:" %}</dt>
<dd class="col-sm-6">
{% for student in team.students.all %}
{% if student.under_18 %}
{% if student.health_sheet %}
<a href="{{ student.health_sheet.url }}" data-turbolinks="false">{{ student }}</a>{% if not forloop.last %},{% endif %}
{% else %}
{{ student }} ({% trans "Not uploaded yet" %}){% if not forloop.last %},{% endif %}
{% endif %}
{% endif %}
{% endfor %}
</dd>
<dt class="col-sm-6 text-right">{% trans "Parental authorizations:" %}</dt>
<dd class="col-sm-6">
{% for student in team.students.all %}
{% if student.under_18 %}
{% if student.parental_authorization %}
<a href="{{ student.parental_authorization.url }}" data-turbolinks="false">{{ student }}</a>{% if not forloop.last %},{% endif %}
{% else %}
{{ student }} ({% trans "Not uploaded yet" %}){% if not forloop.last %},{% endif %}
{% endif %}
{% endif %}
{% endfor %}
</dd>
{% endif %}
<dt class="col-sm-6 text-right">{% trans "Motivation letter:" %}</dt>
<dd class="col-sm-6">
{% if team.motivation_letter %}
<a href="{{ team.motivation_letter.url }}" data-turbolinks="false">{% trans "Download" %}</a>
{% else %}
<em>{% trans "Not uploaded yet" %}</em>
{% endif %}
{% if user.registration.team == team and not user.registration.team.participation.valid or user.registration.is_admin %}
<button class="btn btn-primary" data-toggle="modal" data-target="#uploadMotivationLetterModal">{% trans "Replace" %}</button>
{% endif %}
</dd>
</dl>
<div class="text-center">
<a class="btn btn-info" href="{% url "participation:team_authorizations" pk=team.pk %}" data-turbolinks="false">
<i class="fas fa-file-archive"></i> {% trans "Download all submitted authorizations" %}
</a>
</div>
</div>
<div class="card-footer text-center">
<button class="btn btn-primary" data-toggle="modal" data-target="#updateTeamModal">{% trans "Update" %}</button>
{% if not team.participation.valid %}
<button class="btn btn-danger" data-toggle="modal" data-target="#leaveTeamModal">{% trans "Leave" %}</button>
{% endif %}
</div>
</div>
<hr>
{% if team.participation.valid %}
<div class="text-center">
<a class="btn btn-info" href="{% url "participation:participation_detail" pk=team.participation.pk %}">
<i class="fas fa-file-pdf"></i> {% trans "Access to team participation" %}
</a>
</div>
{% elif team.participation.valid == None %} {# Team did not ask for validation #}
{% if user.registration.participates %}
{% if can_validate %}
<div class="alert alert-info">
{% trans "Your team has at least 4 members and a coach and all authorizations were given: the team can be validated." %}
<div class="text-center">
<form method="post">
{% csrf_token %}
{{ request_validation_form|crispy }}
<button class="btn btn-success" name="request-validation">{% trans "Submit my team to validation" %}</button>
</form>
</div>
</div>
{% else %}
<div class="alert alert-warning">
{% trans "Your team must be composed of 4 members and a coach and each member must upload their authorizations and confirm its email address." %}
</div>
{% endif %}
{% else %}
<div class="alert alert-warning">
{% trans "This team didn't ask for validation yet." %}
</div>
{% endif %}
{% else %} {# Team is waiting for validation #}
{% if user.registration.participates %}
<div class="alert alert-warning">
{% trans "Your validation is pending." %}
</div>
{% else %}
<div class="alert alert-info">
{% trans "The team requested to be validated. You may now control the authorizations and confirm that they can participate." %}
</div>
<form method="post">
{% csrf_token %}
{{ validation_form|crispy }}
<div class="input-group btn-group">
<button class="btn btn-success" name="validate" type="submit">{% trans "Validate" %}</button>
<button class="btn btn-danger" name="invalidate" type="submit">{% trans "Invalidate" %}</button>
</div>
</form>
{% endif %}
{% endif %}
{% trans "Upload motivation letter" as modal_title %}
{% trans "Upload" as modal_button %}
{% url "participation:upload_team_motivation_letter" pk=team.pk as modal_action %}
{% include "base_modal.html" with modal_id="uploadMotivationLetter" modal_enctype="multipart/form-data" %}
{% trans "Update team" as modal_title %}
{% trans "Update" as modal_button %}
{% url "participation:update_team" pk=team.pk as modal_action %}
{% include "base_modal.html" with modal_id="updateTeam" %}
{% trans "Leave team" as modal_title %}
{% trans "Leave" as modal_button %}
{% url "participation:team_leave" as modal_action %}
{% include "base_modal.html" with modal_id="leaveTeam" modal_button_type="danger" %}
{% endblock %}
{% block extrajavascript %}
<script>
$(document).ready(function() {
$('button[data-target="#uploadMotivationLetterModal"]').click(function() {
let modalBody = $("#uploadMotivationLetterModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:upload_team_motivation_letter" pk=team.pk %} #form-content");
});
$('button[data-target="#updateTeamModal"]').click(function() {
let modalBody = $("#updateTeamModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:update_team" pk=team.pk %} #form-content");
});
$('button[data-target="#leaveTeamModal"]').click(function() {
let modalBody = $("#leaveTeamModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:team_leave" %} #form-content");
});
});
</script>
{% endblock %}

View File

@ -1,126 +0,0 @@
{% extends "base.html" %}
{% load getconfig i18n django_tables2 %}
{% block content %}
<div class="card bg-light shadow">
<div class="card-header text-center">
<h4>{{ tournament.name }}</h4>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-xl-6 text-right">{% trans 'organizers'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.organizers.all|join:", " }}</dd>
<dt class="col-xl-6 text-right">{% trans 'size'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.max_teams }}</dd>
<dt class="col-xl-6 text-right">{% trans 'place'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.place }}</dd>
<dt class="col-xl-6 text-right">{% trans 'price'|capfirst %}</dt>
<dd class="col-xl-6">{% if tournament.price %}{{ tournament.price }} €{% else %}{% trans "Free" %}{% endif %}</dd>
<dt class="col-xl-6 text-right">{% trans 'remote'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.remote|yesno }}</dd>
<dt class="col-xl-6 text-right">{% trans 'dates'|capfirst %}</dt>
<dd class="col-xl-6">{% trans "From" %} {{ tournament.date_start }} {% trans "to" %} {{ tournament.date_end }}</dd>
<dt class="col-xl-6 text-right">{% trans 'date of registration closing'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.inscription_limit }}</dd>
<dt class="col-xl-6 text-right">{% trans 'date of maximal solution submission'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.solution_limit }}</dd>
<dt class="col-xl-6 text-right">{% trans 'date of the random draw'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.solutions_draw }}</dd>
<dt class="col-xl-6 text-right">{% trans 'date of maximal syntheses submission for the first round'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.syntheses_first_phase_limit }}</dd>
<dt class="col-xl-6 text-right">{% trans 'date when solutions of round 2 are available'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.solutions_available_second_phase }}</dd>
<dt class="col-xl-6 text-right">{% trans 'date of maximal syntheses submission for the second round'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.syntheses_second_phase_limit }}</dd>
<dt class="col-xl-6 text-right">{% trans 'description'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.description }}</dd>
<dt class="col-xl-6 text-right">{% trans 'To contact organizers' %}</dt>
<dd class="col-xl-6"><a href="mailto:{{ tournament.organizers_email }}">{{ tournament.organizers_email }}</a></dd>
<dt class="col-xl-6 text-right">{% trans 'To contact juries' %}</dt>
<dd class="col-xl-6"><a href="mailto:{{ tournament.jurys_email }}">{{ tournament.jurys_email }}</a></dd>
<dt class="col-xl-6 text-right">{% trans 'To contact valid teams' %}</dt>
<dd class="col-xl-6"><a href="mailto:{{ tournament.teams_email }}">{{ tournament.teams_email }}</a></dd>
</dl>
</div>
{% if user.registration.is_admin or user.registration in tournament.organizers.all %}
<div class="card-footer text-center">
<a href="{% url "participation:tournament_update" pk=tournament.pk %}"><button class="btn btn-secondary">{% trans "Edit tournament" %}</button></a>
</div>
{% endif %}
</div>
<hr>
<h3>{% trans "Teams" %}</h3>
<div id="teams_table">
{% render_table teams %}
</div>
{% if pools.data %}
<hr>
<h3>{% trans "Pools" %}</h3>
<div id="pools_table">
{% render_table pools %}
</div>
{% endif %}
{% if user.registration.is_admin %}
<button class="btn btn-block btn-success" data-toggle="modal" data-target="#addPoolModal">{% trans "Add new pool" %}</button>
{% endif %}
{% if notes %}
<hr>
<div class="card bg-light shadow">
<div class="card-header text-center">
<h5>{% trans "Ranking" %}</h5>
</div>
<div class="card-body">
<ul>
{% for participation, note in notes %}
<li><strong>{{ participation.team }} :</strong> {{ note }}</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
{% if user.registration.is_admin %}
{% trans "Add pool" as modal_title %}
{% trans "Add" as modal_button %}
{% url "participation:pool_create" as modal_action %}
{% include "base_modal.html" with modal_id="addPool" %}
{% endif %}
{% endblock %}
{% block extrajavascript %}
<script>
$(document).ready(function () {
{% if user.registration.is_admin %}
$('button[data-target="#addPoolModal"]').click(function() {
let modalBody = $("#addPoolModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "participation:pool_create" %} #form-content")
});
{% endif %}
});
</script>
{% endblock %}

View File

@ -1,15 +0,0 @@
{% extends "base.html" %}
{% load crispy_forms_filters i18n %}
{% block content %}
<form method="post">
<div id="form-content">
{% csrf_token %}
{{ form|crispy }}
{{ participation_form|crispy }}
</div>
<button class="btn btn-success" type="submit">{% trans "Update" %}</button>
</form>
{% endblock content %}

View File

@ -1,13 +0,0 @@
{% extends "base.html" %}
{% load crispy_forms_filters i18n %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<div id="form-content">
{% csrf_token %}
{{ form|crispy }}
</div>
<button class="btn btn-primary" type="submit">{% trans "Upload" %}</button>
</form>
{% endblock content %}

View File

@ -1,5 +0,0 @@
{{ object.link }}
{{ object.participation.team.name }}
{{ object.participation.team.trigram }}
{{ object.participation.problem }}
{{ object.participation.get_problem_display }}

View File

@ -1,600 +0,0 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.models import Site
from django.core.management import call_command
from django.test import TestCase
from django.urls import reverse
from registration.models import CoachRegistration, StudentRegistration
from .models import Participation, Team, Tournament
class TestStudentParticipation(TestCase):
def setUp(self) -> None:
self.superuser = User.objects.create_superuser(
username="admin",
email="admin@example.com",
password="toto1234",
)
self.user = User.objects.create(
first_name="Toto",
last_name="Toto",
email="toto@example.com",
password="toto",
)
StudentRegistration.objects.create(
user=self.user,
student_class=12,
school="Earth",
give_contact_to_animath=True,
email_confirmed=True,
)
self.team = Team.objects.create(
name="Super team",
trigram="AAA",
access_code="azerty",
)
self.client.force_login(self.user)
self.second_user = User.objects.create(
first_name="Lalala",
last_name="Lalala",
email="lalala@example.com",
password="lalala",
)
StudentRegistration.objects.create(
user=self.second_user,
student_class=11,
school="Moon",
give_contact_to_animath=True,
email_confirmed=True,
)
self.second_team = Team.objects.create(
name="Poor team",
trigram="FFF",
access_code="qwerty",
)
self.coach = User.objects.create(
first_name="Coach",
last_name="Coach",
email="coach@example.com",
password="coach",
)
CoachRegistration.objects.create(user=self.coach)
self.tournament = Tournament.objects.create(
name="France",
place="Here",
)
def test_admin_pages(self):
"""
Load Django-admin pages.
"""
self.client.force_login(self.superuser)
# Test team pages
response = self.client.get(reverse("admin:index") + "participation/team/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index")
+ f"participation/team/{self.team.pk}/change/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index") +
f"r/{ContentType.objects.get_for_model(Team).id}/"
f"{self.team.pk}/")
self.assertRedirects(response, "http://" + Site.objects.get().domain +
str(self.team.get_absolute_url()), 302, 200)
# Test participation pages
self.team.participation.valid = True
self.team.participation.save()
response = self.client.get(reverse("admin:index") + "participation/participation/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index")
+ f"participation/participation/{self.team.participation.pk}/change/")
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("admin:index") +
f"r/{ContentType.objects.get_for_model(Participation).id}/"
f"{self.team.participation.pk}/")
self.assertRedirects(response, "http://" + Site.objects.get().domain +
str(self.team.participation.get_absolute_url()), 302, 200)
def test_create_team(self):
"""
Try to create a team.
"""
response = self.client.get(reverse("participation:create_team"))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("participation:create_team"), data=dict(
name="Test team",
trigram="123",
))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("participation:create_team"), data=dict(
name="Test team",
trigram="TES",
))
self.assertTrue(Team.objects.filter(trigram="TES").exists())
team = Team.objects.get(trigram="TES")
self.assertRedirects(response, reverse("participation:team_detail", args=(team.pk,)), 302, 200)
# Already in a team
response = self.client.post(reverse("participation:create_team"), data=dict(
name="Test team 2",
trigram="TET",
))
self.assertEqual(response.status_code, 403)
def test_join_team(self):
"""
Try to join an existing team.
"""
response = self.client.get(reverse("participation:join_team"))
self.assertEqual(response.status_code, 200)
team = Team.objects.create(name="Test", trigram="TES")
response = self.client.post(reverse("participation:join_team"), data=dict(
access_code="éééééé",
))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("participation:join_team"), data=dict(
access_code=team.access_code,
))
self.assertRedirects(response, reverse("participation:team_detail", args=(team.pk,)), 302, 200)
self.assertTrue(Team.objects.filter(trigram="TES").exists())
# Already joined
response = self.client.post(reverse("participation:join_team"), data=dict(
access_code=team.access_code,
))
self.assertEqual(response.status_code, 403)
def test_team_list(self):
"""
Test to display the list of teams.
"""
response = self.client.get(reverse("participation:team_list"))
self.assertTrue(response.status_code, 200)
def test_no_myteam_redirect_noteam(self):
"""
Test redirection.
"""
response = self.client.get(reverse("participation:my_team_detail"))
self.assertTrue(response.status_code, 200)
def test_team_detail(self):
"""
Try to display the information of a team.
"""
self.user.registration.team = self.team
self.user.registration.save()
response = self.client.get(reverse("participation:my_team_detail"))
self.assertRedirects(response, reverse("participation:team_detail", args=(self.team.pk,)), 302, 200)
response = self.client.get(reverse("participation:team_detail", args=(self.team.pk,)))
self.assertEqual(response.status_code, 200)
# Can't see other teams
self.second_user.registration.team = self.second_team
self.second_user.registration.save()
self.client.force_login(self.second_user)
response = self.client.get(reverse("participation:team_detail", args=(self.team.participation.pk,)))
self.assertEqual(response.status_code, 403)
def test_request_validate_team(self):
"""
The team ask for validation.
"""
self.user.registration.team = self.team
self.user.registration.save()
second_user = User.objects.create(
first_name="Blublu",
last_name="Blublu",
email="blublu@example.com",
password="blublu",
)
StudentRegistration.objects.create(
user=second_user,
student_class=12,
school="Jupiter",
give_contact_to_animath=True,
email_confirmed=True,
team=self.team,
photo_authorization="authorization/photo/mai-linh",
health_sheet="authorization/health/mai-linh",
parental_authorization="authorization/parental/mai-linh",
)
third_user = User.objects.create(
first_name="Zupzup",
last_name="Zupzup",
email="zupzup@example.com",
password="zupzup",
)
StudentRegistration.objects.create(
user=third_user,
student_class=10,
school="Sun",
give_contact_to_animath=False,
email_confirmed=True,
team=self.team,
photo_authorization="authorization/photo/yohann",
health_sheet="authorization/health/yohann",
parental_authorization="authorization/parental/yohann",
)
fourth_user = User.objects.create(
first_name="tfjm",
last_name="tfjm",
email="tfjm@example.com",
password="tfjm",
)
StudentRegistration.objects.create(
user=fourth_user,
student_class=10,
school="Sun",
give_contact_to_animath=False,
email_confirmed=True,
team=self.team,
photo_authorization="authorization/photo/tfjm",
health_sheet="authorization/health/tfjm",
parental_authorization="authorization/parental/tfjm",
)
self.coach.registration.team = self.team
self.coach.registration.health_sheet = "authorization/health/coach"
self.coach.registration.photo_authorization = "authorization/photo/coach"
self.coach.registration.email_confirmed = True
self.coach.registration.save()
self.client.force_login(self.superuser)
# Admin users can't ask for validation
resp = self.client.post(reverse("participation:team_detail", args=(self.team.pk,)), data=dict(
_form_type="RequestValidationForm",
engagement=True,
))
self.assertEqual(resp.status_code, 200)
self.client.force_login(self.user)
self.assertIsNone(self.team.participation.valid)
resp = self.client.get(reverse("participation:team_detail", args=(self.team.pk,)))
self.assertEqual(resp.status_code, 200)
self.assertFalse(resp.context["can_validate"])
# Can't validate
resp = self.client.post(reverse("participation:team_detail", args=(self.team.pk,)), data=dict(
_form_type="RequestValidationForm",
engagement=True,
))
self.assertEqual(resp.status_code, 200)
self.user.registration.photo_authorization = "authorization/photo/ananas"
self.user.registration.health_sheet = "authorization/health/ananas"
self.user.registration.parental_authorization = "authorization/parental/ananas"
self.user.registration.save()
resp = self.client.get(reverse("participation:team_detail", args=(self.team.pk,)))
self.assertEqual(resp.status_code, 200)
self.assertFalse(resp.context["can_validate"])
self.team.participation.tournament = self.tournament
self.team.participation.save()
self.team.motivation_letter = "i_am_motivated.pdf"
self.team.save()
resp = self.client.get(reverse("participation:team_detail", args=(self.team.pk,)))
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.context["can_validate"])
resp = self.client.post(reverse("participation:team_detail", args=(self.team.pk,)), data=dict(
_form_type="RequestValidationForm",
engagement=True,
))
self.assertRedirects(resp, reverse("participation:team_detail", args=(self.team.pk,)), 302, 200)
self.team.participation.refresh_from_db()
self.assertFalse(self.team.participation.valid)
self.assertIsNotNone(self.team.participation.valid)
# Team already asked for validation
resp = self.client.post(reverse("participation:team_detail", args=(self.team.pk,)), data=dict(
_form_type="RequestValidationForm",
engagement=True,
))
self.assertEqual(resp.status_code, 200)
def test_validate_team(self):
"""
A team asked for validation. Try to validate it.
"""
self.team.participation.valid = False
self.team.participation.tournament = self.tournament
self.team.participation.save()
self.tournament.organizers.add(self.superuser.registration)
self.tournament.save()
# No right to do that
resp = self.client.post(reverse("participation:team_detail", args=(self.team.pk,)), data=dict(
_form_type="ValidateParticipationForm",
message="J'ai 4 ans",
validate=True,
))
self.assertEqual(resp.status_code, 200)
self.client.force_login(self.superuser)
resp = self.client.get(reverse("participation:team_detail", args=(self.team.pk,)))
self.assertEqual(resp.status_code, 200)
resp = self.client.post(reverse("participation:team_detail", args=(self.team.pk,)), data=dict(
_form_type="ValidateParticipationForm",
message="Woops I didn't said anything",
))
self.assertEqual(resp.status_code, 200)
# Test invalidate team
resp = self.client.post(reverse("participation:team_detail", args=(self.team.pk,)), data=dict(
_form_type="ValidateParticipationForm",
message="Wsh nope",
invalidate=True,
))
self.assertRedirects(resp, reverse("participation:team_detail", args=(self.team.pk,)), 302, 200)
self.team.participation.refresh_from_db()
self.assertIsNone(self.team.participation.valid)
# Team did not ask validation
resp = self.client.post(reverse("participation:team_detail", args=(self.team.pk,)), data=dict(
_form_type="ValidateParticipationForm",
message="Bienvenue ça va être trop cool",
validate=True,
))
self.assertEqual(resp.status_code, 200)
self.team.participation.tournament = self.tournament
self.team.participation.valid = False
self.team.participation.save()
# Test validate team
resp = self.client.post(reverse("participation:team_detail", args=(self.team.pk,)), data=dict(
_form_type="ValidateParticipationForm",
message="Bienvenue ça va être trop cool",
validate=True,
))
self.assertRedirects(resp, reverse("participation:team_detail", args=(self.team.pk,)), 302, 200)
self.team.participation.refresh_from_db()
self.assertTrue(self.team.participation.valid)
def test_update_team(self):
"""
Try to update team information.
"""
self.user.registration.team = self.team
self.user.registration.save()
self.coach.registration.team = self.team
self.coach.registration.save()
self.team.participation.tournament = self.tournament
self.team.participation.save()
response = self.client.get(reverse("participation:update_team", args=(self.team.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("participation:update_team", args=(self.team.pk,)), data=dict(
name="Updated team name",
trigram="BBB",
))
self.assertRedirects(response, reverse("participation:team_detail", args=(self.team.pk,)), 302, 200)
self.assertTrue(Team.objects.filter(trigram="BBB").exists())
def test_leave_team(self):
"""
A user is in a team, and leaves it.
"""
# User is not in a team
response = self.client.post(reverse("participation:team_leave"))
self.assertEqual(response.status_code, 403)
self.user.registration.team = self.team
self.user.registration.save()
# Team is valid
self.team.participation.valid = True
self.team.participation.save()
response = self.client.post(reverse("participation:team_leave"))
self.assertEqual(response.status_code, 403)
# Unauthenticated users are redirected to login page
self.client.logout()
response = self.client.get(reverse("participation:team_leave"))
self.assertRedirects(response, reverse("login") + "?next=" + reverse("participation:team_leave"), 302, 200)
self.client.force_login(self.user)
self.team.participation.valid = None
self.team.participation.save()
response = self.client.post(reverse("participation:team_leave"))
self.assertRedirects(response, reverse("index"), 302, 200)
self.user.registration.refresh_from_db()
self.assertIsNone(self.user.registration.team)
self.assertFalse(Team.objects.filter(pk=self.team.pk).exists())
def test_no_myparticipation_redirect_nomyparticipation(self):
"""
Ensure a permission denied when we search my team participation when we are in no team.
"""
response = self.client.get(reverse("participation:my_participation_detail"))
self.assertEqual(response.status_code, 403)
def test_participation_detail(self):
"""
Try to display the detail of a team participation.
"""
self.user.registration.team = self.team
self.user.registration.save()
# Can't see the participation if it is not valid
response = self.client.get(reverse("participation:my_participation_detail"))
self.assertRedirects(response,
reverse("participation:participation_detail", args=(self.team.participation.pk,)),
302, 403)
self.team.participation.valid = True
self.team.participation.save()
response = self.client.get(reverse("participation:my_participation_detail"))
self.assertRedirects(response,
reverse("participation:participation_detail", args=(self.team.participation.pk,)),
302, 200)
response = self.client.get(reverse("participation:participation_detail", args=(self.team.participation.pk,)))
self.assertEqual(response.status_code, 200)
# Can't see other participations
self.second_user.registration.team = self.second_team
self.second_user.registration.save()
self.client.force_login(self.second_user)
response = self.client.get(reverse("participation:participation_detail", args=(self.team.participation.pk,)))
self.assertEqual(response.status_code, 403)
def test_forbidden_access(self):
"""
Load personal pages and ensure that these are protected.
"""
self.user.registration.team = self.team
self.user.registration.save()
resp = self.client.get(reverse("participation:team_detail", args=(self.second_team.pk,)))
self.assertEqual(resp.status_code, 403)
resp = self.client.get(reverse("participation:update_team", args=(self.second_team.pk,)))
self.assertEqual(resp.status_code, 403)
resp = self.client.get(reverse("participation:team_authorizations", args=(self.second_team.pk,)))
self.assertEqual(resp.status_code, 403)
resp = self.client.get(reverse("participation:participation_detail", args=(self.second_team.pk,)))
self.assertEqual(resp.status_code, 403)
def test_cover_matrix(self):
"""
Load matrix scripts, to cover them and ensure that they can run.
"""
self.user.registration.team = self.team
self.user.registration.save()
self.second_user.registration.team = self.second_team
self.second_user.registration.save()
self.team.participation.valid = True
self.team.participation.received_participation = self.second_team.participation
self.team.participation.save()
call_command('fix_matrix_channels')
class TestAdmin(TestCase):
def setUp(self) -> None:
self.user = User.objects.create_superuser(
username="admin@example.com",
email="admin@example.com",
password="admin",
)
self.client.force_login(self.user)
self.team1 = Team.objects.create(
name="Toto",
trigram="TOT",
)
self.team1.participation.valid = True
self.team1.participation.problem = 1
self.team1.participation.save()
self.team2 = Team.objects.create(
name="Bliblu",
trigram="BIU",
)
self.team2.participation.valid = True
self.team2.participation.problem = 1
self.team2.participation.save()
self.team3 = Team.objects.create(
name="Zouplop",
trigram="ZPL",
)
self.team3.participation.valid = True
self.team3.participation.problem = 1
self.team3.participation.save()
self.other_team = Team.objects.create(
name="I am different",
trigram="IAD",
)
self.other_team.participation.valid = True
self.other_team.participation.problem = 2
self.other_team.participation.save()
def test_research(self):
"""
Try to search some things.
"""
call_command("rebuild_index", "--noinput", "--verbosity", 0)
response = self.client.get(reverse("haystack_search") + "?q=" + self.team1.name)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context["object_list"])
response = self.client.get(reverse("haystack_search") + "?q=" + self.team2.trigram)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context["object_list"])
def test_create_team_forbidden(self):
"""
Ensure that an admin can't create a team.
"""
response = self.client.post(reverse("participation:create_team"), data=dict(
name="Test team",
trigram="TES",
))
self.assertEqual(response.status_code, 403)
def test_join_team_forbidden(self):
"""
Ensure that an admin can't join a team.
"""
team = Team.objects.create(name="Test", trigram="TES")
response = self.client.post(reverse("participation:join_team"), data=dict(
access_code=team.access_code,
))
self.assertTrue(response.status_code, 403)
def test_leave_team_forbidden(self):
"""
Ensure that an admin can't leave a team.
"""
response = self.client.get(reverse("participation:team_leave"))
self.assertTrue(response.status_code, 403)
def test_my_team_forbidden(self):
"""
Ensure that an admin can't access to "My team".
"""
response = self.client.get(reverse("participation:my_team_detail"))
self.assertEqual(response.status_code, 403)
def test_my_participation_forbidden(self):
"""
Ensure that an admin can't access to "My participation".
"""
response = self.client.get(reverse("participation:my_participation_detail"))
self.assertEqual(response.status_code, 403)

View File

@ -1,44 +0,0 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path
from django.views.generic import TemplateView
from .views import CreateTeamView, JoinTeamView, MyParticipationDetailView, MyTeamDetailView, NoteUpdateView, \
ParticipationDetailView, PassageCreateView, PassageDetailView, PassageUpdateView, PoolCreateView, PoolDetailView, \
PoolUpdateTeamsView, PoolUpdateView, SolutionUploadView, SynthesisUploadView, TeamAuthorizationsView, \
TeamDetailView, TeamLeaveView, TeamListView, TeamUpdateView, TeamUploadMotivationLetterView, TournamentCreateView, \
TournamentDetailView, TournamentListView, TournamentUpdateView
app_name = "participation"
urlpatterns = [
path("create_team/", CreateTeamView.as_view(), name="create_team"),
path("join_team/", JoinTeamView.as_view(), name="join_team"),
path("teams/", TeamListView.as_view(), name="team_list"),
path("team/", MyTeamDetailView.as_view(), name="my_team_detail"),
path("team/<int:pk>/", TeamDetailView.as_view(), name="team_detail"),
path("team/<int:pk>/update/", TeamUpdateView.as_view(), name="update_team"),
path("team/<int:pk>/upload-motivation-letter/", TeamUploadMotivationLetterView.as_view(),
name="upload_team_motivation_letter"),
path("team/<int:pk>/authorizations/", TeamAuthorizationsView.as_view(), name="team_authorizations"),
path("team/leave/", TeamLeaveView.as_view(), name="team_leave"),
path("detail/", MyParticipationDetailView.as_view(), name="my_participation_detail"),
path("detail/<int:pk>/", ParticipationDetailView.as_view(), name="participation_detail"),
path("detail/<int:pk>/solution/", SolutionUploadView.as_view(), name="upload_solution"),
path("tournament/", TournamentListView.as_view(), name="tournament_list"),
path("tournament/create/", TournamentCreateView.as_view(), name="tournament_create"),
path("tournament/<int:pk>/", TournamentDetailView.as_view(), name="tournament_detail"),
path("tournament/<int:pk>/update/", TournamentUpdateView.as_view(), name="tournament_update"),
path("pools/create/", PoolCreateView.as_view(), name="pool_create"),
path("pools/<int:pk>/", PoolDetailView.as_view(), name="pool_detail"),
path("pools/<int:pk>/update/", PoolUpdateView.as_view(), name="pool_update"),
path("pools/<int:pk>/update-teams/", PoolUpdateTeamsView.as_view(), name="pool_update_teams"),
path("pools/passages/add/<int:pk>/", PassageCreateView.as_view(), name="passage_create"),
path("pools/passages/<int:pk>/", PassageDetailView.as_view(), name="passage_detail"),
path("pools/passages/<int:pk>/update/", PassageUpdateView.as_view(), name="passage_update"),
path("pools/passages/<int:pk>/solution/", SynthesisUploadView.as_view(), name="upload_synthesis"),
path("pools/passages/notes/<int:pk>/", NoteUpdateView.as_view(), name="update_notes"),
path("chat/", TemplateView.as_view(template_name="participation/chat.html"), name="chat")
]

View File

@ -1,790 +0,0 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from io import BytesIO
import os
from zipfile import ZipFile
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.sites.models import Site
from django.core.exceptions import PermissionDenied
from django.core.mail import send_mail
from django.db import transaction
from django.http import FileResponse, Http404, 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 CreateView, DetailView, FormView, RedirectView, TemplateView, UpdateView, View
from django.views.generic.edit import FormMixin, ProcessFormView
from django_tables2 import SingleTableView
from magic import Magic
from registration.models import StudentRegistration
from tfjm.lists import get_sympa_client
from tfjm.matrix import Matrix
from tfjm.views import AdminMixin, VolunteerMixin
from .forms import JoinTeamForm, MotivationLetterForm, NoteForm, ParticipationForm, PassageForm, PoolForm, \
PoolTeamsForm, RequestValidationForm, SolutionForm, SynthesisForm, TeamForm, TournamentForm, \
ValidateParticipationForm
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament
from .tables import NoteTable, ParticipationTable, PassageTable, PoolTable, TeamTable, TournamentTable
class CreateTeamView(LoginRequiredMixin, CreateView):
"""
Display the page to create a team for new users.
"""
model = Team
form_class = TeamForm
extra_context = dict(title=_("Create team"))
template_name = "participation/create_team.html"
def dispatch(self, request, *args, **kwargs):
user = request.user
if not user.is_authenticated:
return super().handle_no_permission()
registration = user.registration
if not registration.participates:
raise PermissionDenied(_("You don't participate, so you can't create a team."))
elif registration.team:
raise PermissionDenied(_("You are already in a team."))
return super().dispatch(request, *args, **kwargs)
@transaction.atomic
def form_valid(self, form):
"""
When a team is about to be created, the user automatically
joins the team, a mailing list got created and the user is
automatically subscribed to this mailing list, and finally
a Matrix room is created and the user is invited in this room.
"""
ret = super().form_valid(form)
# The user joins the team
user = self.request.user
registration = user.registration
registration.team = form.instance
registration.save()
# Subscribe the user mail address to the team mailing list
get_sympa_client().subscribe(user.email, f"equipe-{form.instance.trigram.lower()}", False,
f"{user.first_name} {user.last_name}")
# Invite the user in the team Matrix room
Matrix.invite(f"#equipe-{form.instance.trigram.lower()}:tfjm.org",
f"@{user.registration.matrix_username}:tfjm.org")
return ret
class JoinTeamView(LoginRequiredMixin, FormView):
"""
Participants can join a team with the access code of the team.
"""
model = Team
form_class = JoinTeamForm
extra_context = dict(title=_("Join team"))
template_name = "participation/create_team.html"
def dispatch(self, request, *args, **kwargs):
user = request.user
if not user.is_authenticated:
return super().handle_no_permission()
registration = user.registration
if not registration.participates:
raise PermissionDenied(_("You don't participate, so you can't create a team."))
elif registration.team:
raise PermissionDenied(_("You are already in a team."))
return super().dispatch(request, *args, **kwargs)
@transaction.atomic
def form_valid(self, form):
"""
When a user joins a team, the user is automatically subscribed to
the team mailing list,the user is invited in the team Matrix room.
"""
self.object = form.instance
ret = super().form_valid(form)
# Join the team
user = self.request.user
registration = user.registration
registration.team = form.instance
registration.save()
# Subscribe to the team mailing list
get_sympa_client().subscribe(user.email, f"equipe-{form.instance.trigram.lower()}", False,
f"{user.first_name} {user.last_name}")
# Invite the user in the team Matrix room
Matrix.invite(f"#equipe-{form.instance.trigram.lower()}:tfjm.org",
f"@{user.registration.matrix_username}:tfjm.org")
return ret
def get_success_url(self):
return reverse_lazy("participation:team_detail", args=(self.object.pk,))
class TeamListView(AdminMixin, SingleTableView):
"""
Display the whole list of teams
"""
model = Team
table_class = TeamTable
ordering = ('trigram',)
class MyTeamDetailView(LoginRequiredMixin, RedirectView):
"""
Redirect to the detail of the team in which the user is.
"""
def get_redirect_url(self, *args, **kwargs):
user = self.request.user
registration = user.registration
if registration.participates:
if registration.team:
return reverse_lazy("participation:team_detail", args=(registration.team_id,))
raise PermissionDenied(_("You are not in a team."))
raise PermissionDenied(_("You don't participate, so you don't have any team."))
class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView):
"""
Display the detail of a team.
"""
model = Team
def get(self, request, *args, **kwargs):
user = request.user
self.object = self.get_object()
# Ensure that the user is an admin or a volunteer or a member of the team
if user.registration.is_admin or user.registration.participates and \
user.registration.team and user.registration.team.pk == kwargs["pk"] \
or user.registration.is_volunteer \
and self.object.participation.tournament in user.registration.interesting_tournaments:
return super().get(request, *args, **kwargs)
raise PermissionDenied
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
team = self.get_object()
context["title"] = _("Detail of team {trigram}").format(trigram=self.object.trigram)
context["request_validation_form"] = RequestValidationForm(self.request.POST or None)
context["validation_form"] = ValidateParticipationForm(self.request.POST or None)
# A team is complete when there are at least 4 members plus a coache that have sent their authorizations,
# their health sheet, they confirmed their email address and under-18 people sent their parental authorization.
context["can_validate"] = team.students.count() >= 4 and team.coaches.exists() and \
team.participation.tournament and \
all(r.email_confirmed for r in team.students.all()) and \
all(r.photo_authorization for r in team.participants.all()) and \
(team.participation.tournament.remote
or all(r.health_sheet for r in team.students.all() if r.under_18)) and \
(team.participation.tournament.remote
or all(r.parental_authorization for r in team.students.all() if r.under_18)) and \
team.motivation_letter
return context
def get_form_class(self):
if not self.request.POST:
return RequestValidationForm
elif self.request.POST["_form_type"] == "RequestValidationForm":
return RequestValidationForm
elif self.request.POST["_form_type"] == "ValidateParticipationForm":
return ValidateParticipationForm
def form_valid(self, form):
self.object = self.get_object()
if isinstance(form, RequestValidationForm):
return self.handle_request_validation(form)
elif isinstance(form, ValidateParticipationForm):
return self.handle_validate_participation(form)
def handle_request_validation(self, form):
"""
A team requests to be validated
"""
if not self.request.user.registration.participates:
form.add_error(None, _("You don't participate, so you can't request the validation of the team."))
return self.form_invalid(form)
if self.object.participation.valid is not None:
form.add_error(None, _("The validation of the team is already done or pending."))
return self.form_invalid(form)
if not self.get_context_data()["can_validate"]:
form.add_error(None, _("The team can't be validated: missing email address confirmations, "
"authorizations, people, motivation letter or the tournament is not set."))
return self.form_invalid(form)
self.object.participation.valid = False
self.object.participation.save()
mail_context = dict(team=self.object, domain=Site.objects.first().domain)
mail_plain = render_to_string("participation/mails/request_validation.txt", mail_context)
mail_html = render_to_string("participation/mails/request_validation.html", mail_context)
send_mail("[TFJM²] Validation d'équipe", mail_plain, settings.DEFAULT_FROM_EMAIL,
[self.object.participation.tournament.organizers_email], html_message=mail_html)
return super().form_valid(form)
def handle_validate_participation(self, form):
"""
An admin validates the team (or not)
"""
if not self.request.user.registration.is_admin and \
(not self.object.participation.tournament
or self.request.user.registration not in self.object.participation.tournament.organizers.all()):
form.add_error(None, _("You are not an organizer of the tournament."))
return self.form_invalid(form)
elif self.object.participation.valid is not False:
form.add_error(None, _("This team has no pending validation."))
return self.form_invalid(form)
if "validate" in self.request.POST:
self.object.participation.valid = True
self.object.participation.save()
mail_context = dict(team=self.object, message=form.cleaned_data["message"])
mail_plain = render_to_string("participation/mails/team_validated.txt", mail_context)
mail_html = render_to_string("participation/mails/team_validated.html", mail_context)
send_mail("[TFJM²] Équipe validée", mail_plain, None, [self.object.email], html_message=mail_html)
if self.object.participation.tournament.price == 0:
for registration in self.object.participants.all():
registration.payment.type = "free"
registration.payment.valid = True
registration.payment.save()
else:
for coach in self.object.coaches.all():
coach.payment.type = "free"
coach.payment.valid = True
coach.payment.save()
elif "invalidate" in self.request.POST:
self.object.participation.valid = None
self.object.participation.save()
mail_context = dict(team=self.object, message=form.cleaned_data["message"])
mail_plain = render_to_string("participation/mails/team_not_validated.txt", mail_context)
mail_html = render_to_string("participation/mails/team_not_validated.html", mail_context)
send_mail("[TFJM²] Équipe non validée", mail_plain, None, [self.object.email],
html_message=mail_html)
else:
form.add_error(None, _("You must specify if you validate the registration or not."))
return self.form_invalid(form)
return super().form_valid(form)
def get_success_url(self):
return self.request.path
class TeamUpdateView(LoginRequiredMixin, UpdateView):
"""
Update the detail of a team
"""
model = Team
form_class = TeamForm
template_name = "participation/update_team.html"
def dispatch(self, request, *args, **kwargs):
user = request.user
if not user.is_authenticated:
return super().handle_no_permission()
if user.registration.is_admin or user.registration.participates and \
user.registration.team and user.registration.team.pk == kwargs["pk"] \
or user.registration.is_volunteer \
and self.object.participation.tournament in user.registration.interesting_tournaments:
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["participation_form"] = ParticipationForm(data=self.request.POST or None,
instance=self.object.participation)
context["title"] = _("Update team {trigram}").format(trigram=self.object.trigram)
return context
@transaction.atomic
def form_valid(self, form):
participation_form = ParticipationForm(data=self.request.POST or None, instance=self.object.participation)
if not participation_form.is_valid():
return self.form_invalid(form)
participation_form.save()
return super().form_valid(form)
class TeamUploadMotivationLetterView(LoginRequiredMixin, UpdateView):
"""
A team can send its motivation letter.
"""
model = Team
form_class = MotivationLetterForm
template_name = "participation/upload_motivation_letter.html"
extra_context = dict(title=_("Upload motivation letter"))
def dispatch(self, request, *args, **kwargs):
if not self.request.user.is_authenticated or \
not self.request.user.registration.is_admin \
and self.request.user.registration.team != self.get_object():
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)
@transaction.atomic
def form_valid(self, form):
old_instance = Team.objects.get(pk=self.object.pk)
if old_instance.motivation_letter:
old_instance.motivation_letter.delete()
old_instance.save()
return super().form_valid(form)
class MotivationLetterView(LoginRequiredMixin, View):
"""
Display the sent motivation letter.
"""
def get(self, request, *args, **kwargs):
filename = kwargs["filename"]
path = f"media/authorization/motivation_letters/{filename}"
if not os.path.exists(path):
raise Http404
team = Team.objects.get(motivation_letter__endswith=filename)
user = request.user
if not (user.registration in team.participants.all() or user.registration.is_admin
or user.registration.is_volunteer
and team.participation.tournament in user.registration.organized_tournaments.all()):
raise PermissionDenied
# Guess mime type of the file
mime = Magic(mime=True)
mime_type = mime.from_file(path)
ext = mime_type.split("/")[1].replace("jpeg", "jpg")
# Replace file name
true_file_name = _("Motivation letter of {team}.{ext}").format(team=str(team), ext=ext)
return FileResponse(open(path, "rb"), content_type=mime_type, filename=true_file_name)
class TeamAuthorizationsView(LoginRequiredMixin, DetailView):
"""
Get as a ZIP archive all the authorizations that are sent
"""
model = Team
def dispatch(self, request, *args, **kwargs):
user = request.user
if not user.is_authenticated:
return super().handle_no_permission()
if user.registration.is_admin or user.registration.participates and user.registration.team.pk == kwargs["pk"] \
or user.registration.is_volunteer \
and self.object.participation.tournament in user.registration.interesting_tournaments:
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
def get(self, request, *args, **kwargs):
team = self.get_object()
magic = Magic(mime=True)
output = BytesIO()
zf = ZipFile(output, "w")
for participant in team.participants.all():
if participant.photo_authorization:
mime_type = magic.from_file("media/" + participant.photo_authorization.name)
ext = mime_type.split("/")[1].replace("jpeg", "jpg")
zf.write("media/" + participant.photo_authorization.name,
_("Photo authorization of {participant}.{ext}").format(participant=str(participant), ext=ext))
if isinstance(participant, StudentRegistration) and participant.parental_authorization:
mime_type = magic.from_file("media/" + participant.parental_authorization.name)
ext = mime_type.split("/")[1].replace("jpeg", "jpg")
zf.write("media/" + participant.parental_authorization.name,
_("Parental authorization of {participant}.{ext}")
.format(participant=str(participant), ext=ext))
if isinstance(participant, StudentRegistration) and participant.health_sheet:
mime_type = magic.from_file("media/" + participant.health_sheet.name)
ext = mime_type.split("/")[1].replace("jpeg", "jpg")
zf.write("media/" + participant.health_sheet.name,
_("Health sheet of {participant}.{ext}").format(participant=str(participant), ext=ext))
if team.motivation_letter:
mime_type = magic.from_file("media/" + team.motivation_letter.name)
ext = mime_type.split("/")[1].replace("jpeg", "jpg")
zf.write("media/" + team.motivation_letter.name,
_("Motivation letter of {team}.{ext}").format(team=str(team), ext=ext))
zf.close()
response = HttpResponse(content_type="application/zip")
response["Content-Disposition"] = "attachment; filename=\"{filename}\"" \
.format(filename=_("Photo authorizations of team {trigram}.zip").format(trigram=team.trigram))
response.write(output.getvalue())
return response
class TeamLeaveView(LoginRequiredMixin, TemplateView):
"""
A team member leaves a team
"""
template_name = "participation/team_leave.html"
extra_context = dict(title=_("Leave team"))
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
if not request.user.registration.participates or not request.user.registration.team:
raise PermissionDenied(_("You are not in a team."))
if request.user.registration.team.participation.valid:
raise PermissionDenied(_("The team is already validated or the validation is pending."))
return super().dispatch(request, *args, **kwargs)
@transaction.atomic()
def post(self, request, *args, **kwargs):
"""
When the team is left, the user is unsubscribed from the team mailing list
and kicked from the team room.
"""
team = request.user.registration.team
request.user.registration.team = None
request.user.registration.save()
get_sympa_client().unsubscribe(request.user.email, f"equipe-{team.trigram.lower()}", False)
Matrix.kick(f"#equipe-{team.trigram.lower()}:tfjm.org",
f"@{request.user.registration.matrix_username}:tfjm.org",
"Équipe quittée")
if team.students.count() + team.coaches.count() == 0:
team.delete()
return redirect(reverse_lazy("index"))
class MyParticipationDetailView(LoginRequiredMixin, RedirectView):
"""
Redirects to the detail view of the participation of the team.
"""
def get_redirect_url(self, *args, **kwargs):
user = self.request.user
registration = user.registration
if registration.participates:
if registration.team:
return reverse_lazy("participation:participation_detail", args=(registration.team.participation.id,))
raise PermissionDenied(_("You are not in a team."))
raise PermissionDenied(_("You don't participate, so you don't have any team."))
class ParticipationDetailView(LoginRequiredMixin, DetailView):
"""
Display detail about the participation of a team, and manage the solution submission.
"""
model = Participation
def dispatch(self, request, *args, **kwargs):
user = request.user
if not user.is_authenticated:
return super().handle_no_permission()
if not self.get_object().valid:
raise PermissionDenied(_("The team is not validated yet."))
if user.registration.is_admin or user.registration.participates \
and user.registration.team.participation \
and user.registration.team.participation.pk == kwargs["pk"] \
or user.registration.is_volunteer \
and self.object.tournament in user.registration.interesting_tournaments:
return super().dispatch(request, *args, **kwargs)
raise PermissionDenied
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["title"] = lambda: _("Participation of team {trigram}").format(trigram=self.object.team.trigram)
return context
class TournamentListView(SingleTableView):
"""
Display the list of all tournaments.
"""
model = Tournament
table_class = TournamentTable
class TournamentCreateView(AdminMixin, CreateView):
"""
Create a new tournament.
"""
model = Tournament
form_class = TournamentForm
def get_success_url(self):
return reverse_lazy("participation:tournament_detail", args=(self.object.pk,))
class TournamentUpdateView(VolunteerMixin, UpdateView):
"""
Update tournament detail.
"""
model = Tournament
form_class = TournamentForm
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated or not self.request.user.registration.is_admin \
and not (self.request.user.registration.is_volunteer
and self.request.user.registration.organized_tournaments.all()):
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)
class TournamentDetailView(DetailView):
"""
Display tournament detail.
"""
model = Tournament
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["teams"] = ParticipationTable(self.object.participations.all())
context["pools"] = PoolTable(self.object.pools.all())
notes = dict()
for participation in self.object.participations.all():
note = sum(pool.average(participation)
for pool in self.object.pools.filter(participations=participation).all())
if note:
notes[participation] = note
context["notes"] = sorted(notes.items(), key=lambda x: x[1], reverse=True)
return context
class SolutionUploadView(LoginRequiredMixin, FormView):
template_name = "participation/upload_solution.html"
form_class = SolutionForm
def dispatch(self, request, *args, **kwargs):
qs = Participation.objects.filter(pk=self.kwargs["pk"])
if not qs.exists():
raise Http404
self.participation = qs.get()
if not self.request.user.is_authenticated or not self.request.user.registration.is_admin \
and not (self.request.user.registration.participates
and self.request.user.registration.team == self.participation.team):
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
"""
When a solution is submitted, it replaces a previous solution if existing,
otherwise it creates a new solution.
It is discriminating whenever the team is selected for the final tournament or not.
"""
form_sol = form.instance
sol_qs = Solution.objects.filter(participation=self.participation,
problem=form_sol.problem,
final_solution=self.participation.final)
tournament = Tournament.final_tournament() if self.participation.final else self.participation.final
if timezone.now() > tournament.solution_limit and sol_qs.exists():
form.add_error(None, _("You can't upload a solution after the deadline."))
return self.form_invalid(form)
# Drop previous solution if existing
for sol in sol_qs.all():
sol.file.delete()
sol.save()
sol.delete()
form_sol.participation = self.participation
form_sol.final = self.participation.final
form_sol.save()
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy("participation:participation_detail", args=(self.participation.pk,))
class PoolCreateView(AdminMixin, CreateView):
model = Pool
form_class = PoolForm
class PoolDetailView(LoginRequiredMixin, DetailView):
model = Pool
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
if request.user.registration.is_admin or request.user.registration.participates \
and request.user.registration.team \
and request.user.registration.team.participation in self.get_object().participations.all() \
or request.user.registration.is_volunteer \
and self.get_object().tournament in request.user.registration.interesting_tournaments:
return super().dispatch(request, *args, **kwargs)
return self.handle_no_permission()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["passages"] = PassageTable(self.object.passages.all())
notes = dict()
for participation in self.object.participations.all():
note = self.object.average(participation)
if note:
notes[participation] = note
context["notes"] = sorted(notes.items(), key=lambda x: x[1], reverse=True)
return context
class PoolUpdateView(VolunteerMixin, UpdateView):
model = Pool
form_class = PoolForm
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
if request.user.registration.is_admin or request.user.registration.is_volunteer \
and (self.get_object().tournament in request.user.registration.organized_tournaments.all()
or request.user.registration in self.get_object().juries.all()):
return super().dispatch(request, *args, **kwargs)
return self.handle_no_permission()
class PoolUpdateTeamsView(VolunteerMixin, UpdateView):
model = Pool
form_class = PoolTeamsForm
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
if request.user.registration.is_admin or request.user.registration.is_volunteer \
and (self.get_object().tournament in request.user.registration.organized_tournaments.all()
or request.user.registration in self.get_object().juries.all()):
return super().dispatch(request, *args, **kwargs)
return self.handle_no_permission()
class PassageCreateView(VolunteerMixin, CreateView):
model = Passage
form_class = PassageForm
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
qs = Pool.objects.filter(pk=self.kwargs["pk"])
if not qs.exists():
raise Http404
self.pool = qs.get()
if request.user.registration.is_admin or request.user.registration.is_volunteer \
and (self.pool.tournament in request.user.registration.organized_tournaments.all()
or request.user.registration in self.pool.juries.all()):
return super().dispatch(request, *args, **kwargs)
return self.handle_no_permission()
def get_form(self, form_class=None):
form = super().get_form(form_class)
form.instance.pool = self.pool
form.fields["defender"].queryset = self.pool.participations.all()
form.fields["opponent"].queryset = self.pool.participations.all()
form.fields["reporter"].queryset = self.pool.participations.all()
return form
class PassageDetailView(LoginRequiredMixin, DetailView):
model = Passage
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
if request.user.registration.is_admin or request.user.registration.is_volunteer \
and (self.get_object().pool.tournament in request.user.registration.organized_tournaments.all()
or request.user.registration in self.get_object().pool.juries.all()) \
or request.user.registration.participates and request.user.registration.team \
and request.user.registration.team.participation in [self.get_object().defender,
self.get_object().opponent,
self.get_object().reporter]:
return super().dispatch(request, *args, **kwargs)
return self.handle_no_permission()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.user.registration in self.object.pool.juries.all():
context["my_note"] = Note.objects.get(passage=self.object, jury=self.request.user.registration)
context["notes"] = NoteTable([note for note in self.object.notes.all() if note])
return context
class PassageUpdateView(VolunteerMixin, UpdateView):
model = Passage
form_class = PassageForm
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
if request.user.registration.is_admin or request.user.registration.is_volunteer \
and (self.get_object().pool.tournament in request.user.registration.organized_tournaments.all()
or request.user.registration in self.get_object().pool.juries.all()):
return super().dispatch(request, *args, **kwargs)
return self.handle_no_permission()
class SynthesisUploadView(LoginRequiredMixin, FormView):
template_name = "participation/upload_synthesis.html"
form_class = SynthesisForm
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated or not request.user.registration.participates:
return self.handle_no_permission()
qs = Passage.objects.filter(pk=self.kwargs["pk"])
if not qs.exists():
raise Http404
self.participation = self.request.user.registration.team.participation
self.passage = qs.get()
if self.participation not in [self.passage.defender, self.passage.opponent, self.passage.reporter]:
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
"""
When a solution is submitted, it replaces a previous solution if existing,
otherwise it creates a new solution.
It is discriminating whenever the team is selected for the final tournament or not.
"""
form_syn = form.instance
syn_qs = Synthesis.objects.filter(participation=self.participation,
passage=self.passage,
type=form_syn.type).all()
deadline = self.passage.pool.tournament.syntheses_first_phase_limit if self.passage.pool.round == 1 \
else self.passage.pool.tournament.syntheses_second_phase_limit
if syn_qs.exists() and timezone.now() > deadline:
form.add_error(None, _("You can't upload a synthesis after the deadline."))
return self.form_invalid(form)
# Drop previous solution if existing
for syn in syn_qs.all():
syn.file.delete()
syn.save()
syn.delete()
form_syn.participation = self.participation
form_syn.passage = self.passage
form_syn.save()
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy("participation:passage_detail", args=(self.passage.pk,))
class NoteUpdateView(VolunteerMixin, UpdateView):
model = Note
form_class = NoteForm
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
if request.user.registration.is_admin or request.user.registration.is_volunteer \
and self.get_object().jury == request.user.registration:
return super().dispatch(request, *args, **kwargs)
return self.handle_no_permission()

View File

@ -1,37 +0,0 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
from django.contrib.admin import ModelAdmin
from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicParentModelAdmin
from .models import AdminRegistration, CoachRegistration, Payment, Registration, StudentRegistration
@admin.register(Registration)
class RegistrationAdmin(PolymorphicParentModelAdmin):
child_models = (StudentRegistration, CoachRegistration, AdminRegistration,)
list_display = ("user", "type", "email_confirmed",)
polymorphic_list = True
@admin.register(StudentRegistration)
class StudentRegistrationAdmin(PolymorphicChildModelAdmin):
pass
@admin.register(CoachRegistration)
class CoachRegistrationAdmin(PolymorphicChildModelAdmin):
pass
@admin.register(AdminRegistration)
class AdminRegistrationAdmin(PolymorphicChildModelAdmin):
pass
@admin.register(Payment)
class PaymentAdmin(ModelAdmin):
list_display = ('registration', 'type', 'valid', )
search_fields = ('registration__user__last_name', 'registration__user__first_name', 'registration__user__email',)
list_filter = ('type', 'valid',)

View File

@ -1,23 +0,0 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.db.models.signals import post_save, pre_save
class RegistrationConfig(AppConfig):
"""
Registration app contains the detail about users only.
"""
name = 'registration'
def ready(self):
from registration.signals import create_admin_registration, create_payment, \
set_username, send_email_link
pre_save.connect(set_username, "auth.User")
pre_save.connect(send_email_link, "auth.User")
post_save.connect(create_admin_registration, "auth.User")
post_save.connect(create_payment, "registration.Registration")
post_save.connect(create_payment, "registration.StudentRegistration")
post_save.connect(create_payment, "registration.CoachRegistration")
post_save.connect(create_payment, "registration.AdminRegistration")

View File

@ -1,17 +0,0 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from cas_server.auth import DjangoAuthUser # pragma: no cover
class CustomAuthUser(DjangoAuthUser): # pragma: no cover
"""
Override Django Auth User model to define a custom Matrix username.
"""
def attributs(self):
d = super().attributs()
if self.user:
d["matrix_username"] = self.user.registration.matrix_username
d["display_name"] = str(self.user.registration)
return d

View File

@ -1,26 +0,0 @@
[
{
"model": "cas_server.servicepattern",
"pk": 1,
"fields": {
"pos": 100,
"name": "Plateforme du TFJM²",
"pattern": "^https://tfjm.org:8448/.*$",
"user_field": "matrix_username",
"restrict_users": false,
"proxy": true,
"proxy_callback": true,
"single_log_out": true,
"single_log_out_callback": ""
}
},
{
"model": "cas_server.replaceattributname",
"pk": 1,
"fields": {
"name": "display_name",
"replace": "",
"service_pattern": 1
}
}
]

View File

@ -1,18 +0,0 @@
# Generated by Django 3.0.11 on 2021-01-23 20:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('registration', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='participantregistration',
name='health_issues',
field=models.TextField(blank=True, help_text='You can indicate here your allergies or anything that is important to know for organizers', verbose_name='health issues'),
),
]

View File

@ -1,371 +0,0 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import date
from address.models import AddressField
from django.contrib.sites.models import Site
from django.db import models
from django.template import loader
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from polymorphic.models import PolymorphicModel
from tfjm.tokens import email_validation_token
class Registration(PolymorphicModel):
"""
Registrations store extra content that are not asked in the User Model.
This is specific to the role of the user, see StudentRegistration,
ClassRegistration or AdminRegistration..
"""
user = models.OneToOneField(
"auth.User",
on_delete=models.CASCADE,
verbose_name=_("user"),
)
give_contact_to_animath = models.BooleanField(
default=False,
verbose_name=_("Grant Animath to contact me in the future about other actions"),
)
email_confirmed = models.BooleanField(
default=False,
verbose_name=_("email confirmed"),
)
def send_email_validation_link(self):
"""
The account got created or the email got changed.
Send an email that contains a link to validate the address.
"""
subject = "[TFJM²] " + str(_("Activate your TFJM² account"))
token = email_validation_token.make_token(self.user)
uid = urlsafe_base64_encode(force_bytes(self.user.pk))
site = Site.objects.first()
message = loader.render_to_string('registration/mails/email_validation_email.txt',
{
'user': self.user,
'domain': site.domain,
'token': token,
'uid': uid,
})
html = loader.render_to_string('registration/mails/email_validation_email.html',
{
'user': self.user,
'domain': site.domain,
'token': token,
'uid': uid,
})
self.user.email_user(subject, message, html_message=html)
@property
def type(self): # pragma: no cover
raise NotImplementedError
@property
def form_class(self): # pragma: no cover
raise NotImplementedError
@property
def participates(self):
return isinstance(self, ParticipantRegistration)
@property
def is_admin(self):
return isinstance(self, AdminRegistration) or self.user.is_superuser
@property
def is_volunteer(self):
return isinstance(self, VolunteerRegistration)
@property
def matrix_username(self):
return f"tfjm_{self.user.pk}"
def get_absolute_url(self):
return reverse_lazy("registration:user_detail", args=(self.user_id,))
def __str__(self):
return f"{self.user.first_name} {self.user.last_name}"
class Meta:
verbose_name = _("registration")
verbose_name_plural = _("registrations")
def get_random_photo_filename(instance, filename):
return "authorization/photo/" + get_random_string(64)
def get_random_health_filename(instance, filename):
return "authorization/health/" + get_random_string(64)
def get_random_parental_filename(instance, filename):
return "authorization/parental/" + get_random_string(64)
class ParticipantRegistration(Registration):
team = models.ForeignKey(
"participation.Team",
related_name="participants",
on_delete=models.PROTECT,
blank=True,
null=True,
default=None,
verbose_name=_("team"),
)
birth_date = models.DateField(
verbose_name=_("birth date"),
default=date.today,
)
gender = models.CharField(
max_length=6,
verbose_name=_("gender"),
choices=[
("female", _("Female")),
("male", _("Male")),
("other", _("Other")),
],
default="other",
)
address = AddressField(
verbose_name=_("address"),
null=True,
default=None,
)
phone_number = PhoneNumberField(
verbose_name=_("phone number"),
blank=True,
)
health_issues = models.TextField(
verbose_name=_("health issues"),
blank=True,
help_text=_("You can indicate here your allergies or anything that is important to know for organizers"),
)
photo_authorization = models.FileField(
verbose_name=_("photo authorization"),
upload_to=get_random_photo_filename,
blank=True,
default="",
)
@property
def under_18(self):
important_date = timezone.now().date()
if self.team and self.team.participation.tournament:
important_date = self.team.participation.tournament.date_start
if self.team.participation.final:
from participation.models import Tournament
important_date = Tournament.final_tournament().date_start
return (important_date - self.birth_date).days < 18 * 365.24
@property
def type(self): # pragma: no cover
raise NotImplementedError
@property
def form_class(self): # pragma: no cover
raise NotImplementedError
class StudentRegistration(ParticipantRegistration):
"""
Specific registration for students.
They have a team, a student class and a school.
"""
student_class = models.IntegerField(
choices=[
(12, _("12th grade")),
(11, _("11th grade")),
(10, _("10th grade or lower")),
],
verbose_name=_("student class"),
)
school = models.CharField(
max_length=255,
verbose_name=_("school"),
)
responsible_name = models.CharField(
max_length=255,
verbose_name=_("responsible name"),
default="",
)
responsible_phone = PhoneNumberField(
verbose_name=_("responsible phone number"),
blank=True,
)
responsible_email = models.EmailField(
verbose_name=_("responsible email address"),
default="",
)
parental_authorization = models.FileField(
verbose_name=_("parental authorization"),
upload_to=get_random_parental_filename,
blank=True,
default="",
)
health_sheet = models.FileField(
verbose_name=_("health sheet"),
upload_to=get_random_health_filename,
blank=True,
default="",
)
@property
def type(self):
return _("student")
@property
def form_class(self):
from registration.forms import StudentRegistrationForm
return StudentRegistrationForm
class Meta:
verbose_name = _("student registration")
verbose_name_plural = _("student registrations")
class CoachRegistration(ParticipantRegistration):
"""
Specific registration for coaches.
They have a team and a professional activity.
"""
professional_activity = models.TextField(
verbose_name=_("professional activity"),
)
@property
def type(self):
return _("coach")
@property
def form_class(self):
from registration.forms import CoachRegistrationForm
return CoachRegistrationForm
class Meta:
verbose_name = _("coach registration")
verbose_name_plural = _("coach registrations")
class VolunteerRegistration(Registration):
"""
Specific registration for organizers and juries.
"""
professional_activity = models.TextField(
verbose_name=_("professional activity"),
)
@property
def interesting_tournaments(self) -> set:
return set(self.organized_tournaments.all()).union(map(lambda pool: pool.tournament, self.jury_in.all()))
@property
def type(self):
return _('volunteer')
@property
def form_class(self):
from registration.forms import VolunteerRegistrationForm
return VolunteerRegistrationForm
class AdminRegistration(VolunteerRegistration):
"""
Specific registration for admins.
They have a field to justify they status.
"""
role = models.TextField(
verbose_name=_("role of the administrator"),
)
@property
def type(self):
return _("admin")
@property
def form_class(self):
from registration.forms import AdminRegistrationForm
return AdminRegistrationForm
class Meta:
verbose_name = _("admin registration")
verbose_name_plural = _("admin registrations")
def get_scholarship_filename(instance, filename):
return f"authorization/scholarship/scholarship_{instance.registration.pk}"
class Payment(models.Model):
registration = models.OneToOneField(
ParticipantRegistration,
on_delete=models.CASCADE,
related_name="payment",
verbose_name=_("registration"),
)
type = models.CharField(
verbose_name=_("type"),
max_length=16,
choices=[
('', _("No payment")),
('helloasso', "Hello Asso"),
('scholarship', _("Scholarship")),
('bank_transfer', _("Bank transfer")),
('free', _("The tournament is free")),
],
blank=True,
default="",
)
scholarship_file = models.FileField(
verbose_name=_("scholarship file"),
help_text=_("only if you have a scholarship."),
upload_to=get_scholarship_filename,
blank=True,
default="",
)
additional_information = models.TextField(
verbose_name=_("additional information"),
help_text=_("To help us to find your payment."),
blank=True,
default="",
)
valid = models.BooleanField(
verbose_name=_("valid"),
null=True,
default=False,
)
def get_absolute_url(self):
return reverse_lazy("registration:user_detail", args=(self.registration.user.id,))
def __str__(self):
return _("Payment of {registration}").format(registration=self.registration)
class Meta:
verbose_name = _("payment")
verbose_name_plural = _("payments")

View File

@ -1,32 +0,0 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
from .models import Registration
class RegistrationTable(tables.Table):
"""
Table of all registrations.
"""
last_name = tables.LinkColumn(
'registration:user_detail',
args=[tables.A("user_id")],
verbose_name=lambda: _("last name").capitalize(),
accessor="user__last_name",
)
def order_type(self, queryset, desc):
types = ["volunteerregistration__adminregistration", "volunteerregistration", "participantregistration"]
return queryset.order_by(*(("-" if desc else "") + t for t in types)), True
class Meta:
attrs = {
'class': 'table table-condensed table-striped',
}
model = Registration
fields = ('last_name', 'user__first_name', 'user__email', 'type',)
order_by = ('type', 'last_name', 'first_name',)
template_name = 'django_tables2/bootstrap4.html'

View File

@ -1,52 +0,0 @@
{% extends "base.html" %}
{% load crispy_forms_filters i18n %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<div id="form-content">
<div class="alert alert-info text-justify">
<p>
{% blocktrans trimmed with price=payment.registration.team.participation.tournament.price %}
The price of the tournament is {{ price }} €. The participation fee is offered for coaches
and for students who have a scholarship. If so, please send us your scholarship attestation.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
You can pay with a credit card through
<a class="alert-link" href="https://www.helloasso.com/associations/animath/evenements/tfjmm-2018">our Hello Asso page</a>.
To make the validation of the payment easier, <span class="text-danger">please use the same e-mail
address that you use on this platform.</span> The payment verification will be checked automatically
under 10 minutes, you don't necessary need to fill this form.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
You can also send a bank transfer to the bank account of Animath. You must put in the reference of the
transfer the mention "TFJMpu" followed by the last name and the first name of the student.
{% endblocktrans %}
</p>
<p>
IBAN : FR76 1027 8065 0000 0206 4290 127<br>
BIC : CMCIFR2A
</p>
<p>
{% blocktrans trimmed %}
If any payment mean is available to you, please contact us at <a class="alert-link" href="mailto:contact@tfjm.org">contact@tfjm.org</a>
to find a solution to your difficulties.
{% endblocktrans %}
</p>
</div>
{% csrf_token %}
{{ form|crispy }}
</div>
<button class="btn btn-primary" type="submit">{% trans "Update" %}</button>
</form>
{% endblock content %}

View File

@ -1,43 +0,0 @@
<!-- templates/signup.html -->
{% extends 'base.html' %}
{% load crispy_forms_filters %}
{% load i18n %}
{% block title %}{% trans "Sign up" %}{% endblock %}
{% block extracss %}
{{ student_registration_form.media }}
{% endblock %}
{% block content %}
<h2>{% trans "Sign up" %}</h2>
<form method="post">
{% csrf_token %}
{{ form|crispy }}
<div id="registration_form"></div>
<button class="btn btn-success" type="submit">
{% trans "Sign up" %}
</button>
</form>
<div id="student_registration_form" class="d-none">
{{ student_registration_form|crispy }}
</div>
<div id="coach_registration_form" class="d-none">
{{ coach_registration_form|crispy }}
</div>
{% endblock %}
{% block extrajavascript %}
<script>
$("#id_role").change(function() {
let selected_role = $("#id_role :selected");
if (selected_role.val() === "participant") {
$("#registration_form").html($("#student_registration_form").html());
}
else {
$("#registration_form").html($("#coach_registration_form").html());
}
}).change();
</script>
{% endblock %}

View File

@ -1,68 +0,0 @@
\documentclass[a4paper,french,11pt]{article}
\usepackage[T1]{fontenc}
\usepackage[utf8]{inputenc}
\usepackage{lmodern}
\usepackage[french]{babel}
\usepackage{fancyhdr}
\usepackage{graphicx}
\usepackage{amsmath}
\usepackage{amssymb}
%\usepackage{anyfontsize}
\usepackage{fancybox}
\usepackage{eso-pic,graphicx}
\usepackage{xcolor}
% Specials
\newcommand{\writingsep}{\vrule height 4ex width 0pt}
% Page formating
\hoffset -1in
\voffset -1in
\textwidth 180 mm
\textheight 250 mm
\oddsidemargin 15mm
\evensidemargin 15mm
\pagestyle{fancy}
% Headers and footers
\fancyfoot{}
\lhead{}
\rhead{}
\renewcommand{\headrulewidth}{0pt}
\lfoot{\footnotesize 11 rue Pierre et Marie Curie, 75231 Paris Cedex 05\\ Numéro siret 431 598 366 00018}
\rfoot{\footnotesize Association agréée par\\le Ministère de l'éducation nationale.}
\begin{document}
\includegraphics[height=2cm]{/code/static/logo_animath.png}\hfill{\fontsize{55pt}{55pt}{$\mathbb{TFJM}^2$}}
\vfill
\begin{center}
\Large \bf Autorisation parentale pour les mineurs ({{ tournament.name }})
\end{center}
Je soussigné(e) \hrulefill,\\
responsable légal, demeurant \writingsep\hrulefill\\
\writingsep\hrulefill,\\
\writingsep autorise {{ registration|default:"\hrulefill" }},\\
né(e) le {{ registration.birth_date }},
à participer au Tournoi Français des Jeunes Mathématiciennes et Mathématiciens ($\mathbb{TFJM}^2$) organisé \`a :
{{ tournament.place }}, du {{ tournament.date_start }} au {{ tournament.date_end }}.
Iel se rendra au lieu indiqu\'e ci-dessus le vendredi matin et quittera les lieux l'après-midi du dimanche par
ses propres moyens et sous la responsabilité du représentant légal.
\vspace{8ex}
Fait à \vrule width 10cm height 0pt depth 0.4pt, le \phantom{232323}/\phantom{XXX}/{% now "Y" %},
\vfill
\vfill
\end{document}

View File

@ -1,220 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
{% trans "any" as any %}
<div class="card bg-light shadow">
<div class="card-header text-center">
<h4>{{ user_object.first_name }} {{ user_object.last_name }}</h4>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-6 text-right">{% trans "Last name:" %}</dt>
<dd class="col-sm-6">{{ user_object.last_name }}</dd>
<dt class="col-sm-6 text-right">{% trans "First name:" %}</dt>
<dd class="col-sm-6">{{ user_object.first_name }}</dd>
<dt class="col-sm-6 text-right">{% trans "Email:" %}</dt>
<dd class="col-sm-6"><a href="mailto:{{ user_object.email }}">{{ user_object.email }}</a>
{% if not user_object.registration.email_confirmed %} (<em>{% trans "Not confirmed" %}, <a href="{% url "registration:email_validation_resend" pk=user_object.pk %}">{% trans "resend the validation link" %}</a></em>){% endif %}</dd>
{% if user_object == user %}
<dt class="col-sm-6 text-right">{% trans "Password:" %}</dt>
<dd class="col-sm-6">
<a href="{% url 'password_change' %}" class="btn-sm btn-secondary" data-turbolinks="false">
<i class="fas fa-edit"></i> {% trans "Change password" %}
</a>
</dd>
{% endif %}
{% if user_object.registration.participates %}
<dt class="col-sm-6 text-right">{% trans "Team:" %}</dt>
{% trans "any" as any %}
<dd class="col-sm-6">
<a href="{% if user_object.registration.team %}{% url "participation:team_detail" pk=user_object.registration.team.pk %}{% else %}#{% endif %}">
{{ user_object.registration.team|default:any }}
</a>
</dd>
<dt class="col-sm-6 text-right">{% trans "Birth date:" %}</dt>
<dd class="col-sm-6">{{ user_object.registration.birth_date }}</dd>
<dt class="col-sm-6 text-right">{% trans "Gender:" %}</dt>
<dd class="col-sm-6">{{ user_object.registration.get_gender_display }}</dd>
<dt class="col-sm-6 text-right">{% trans "Address:" %}</dt>
<dd class="col-sm-6">{{ user_object.registration.address }}</dd>
<dt class="col-sm-6 text-right">{% trans "Phone number:" %}</dt>
<dd class="col-sm-6">{{ user_object.registration.phone_number }}</dd>
<dt class="col-sm-6 text-right">{% trans "Health issues:" %}</dt>
<dd class="col-sm-6">{{ user_object.registration.health_issues|default:any }}</dd>
<dt class="col-sm-6 text-right">{% trans "Photo authorization:" %}</dt>
<dd class="col-sm-6">
{% if user_object.registration.photo_authorization %}
<a href="{{ user_object.registration.photo_authorization.url }}" data-turbolinks="false">{% trans "Download" %}</a>
{% endif %}
{% if user_object.registration.team and not user_object.registration.team.participation.valid %}
<button class="btn btn-primary" data-toggle="modal" data-target="#uploadPhotoAuthorizationModal">{% trans "Replace" %}</button>
{% endif %}
</dd>
{% endif %}
{% if user_object.registration.studentregistration %}
{% if user_object.registration.under_18 and user_object.registration.team.participation.tournament and not user_object.registration.team.participation.tournament.remote %}
<dt class="col-sm-6 text-right">{% trans "Health sheet:" %}</dt>
<dd class="col-sm-6">
{% if user_object.registration.health_sheet %}
<a href="{{ user_object.registration.health_sheet.url }}" data-turbolinks="false">{% trans "Download" %}</a>
{% endif %}
{% if user_object.registration.team and not user_object.registration.team.participation.valid %}
<button class="btn btn-primary" data-toggle="modal" data-target="#uploadHealthSheetModal">{% trans "Replace" %}</button>
{% endif %}
</dd>
<dt class="col-sm-6 text-right">{% trans "Parental authorization:" %}</dt>
<dd class="col-sm-6">
{% if user_object.registration.parental_authorization %}
<a href="{{ user_object.registration.parental_authorization.url }}" data-turbolinks="false">{% trans "Download" %}</a>
{% endif %}
{% if user_object.registration.team and not user_object.registration.team.participation.valid %}
<button class="btn btn-primary" data-toggle="modal" data-target="#uploadParentalAuthorizationModal">{% trans "Replace" %}</button>
{% endif %}
</dd>
{% endif %}
<dt class="col-sm-6 text-right">{% trans "Student class:" %}</dt>
<dd class="col-sm-6">{{ user_object.registration.get_student_class_display }}</dd>
<dt class="col-sm-6 text-right">{% trans "School:" %}</dt>
<dd class="col-sm-6">{{ user_object.registration.school }}</dd>
<dt class="col-sm-6 text-right">{% trans "Responsible name:" %}</dt>
<dd class="col-sm-6">{{ user_object.registration.responsible_name }}</dd>
<dt class="col-sm-6 text-right">{% trans "Responsible phone number:" %}</dt>
<dd class="col-sm-6">{{ user_object.registration.responsible_phone }}</dd>
<dt class="col-sm-6 text-right">{% trans "Responsible email address:" %}</dt>
{% with user_object.registration.responsible_email as email %}
<dd class="col-sm-6"><a href="mailto:{{ email }}">{{ email }}</a></dd>
{% endwith %}
{% elif user_object.registration.is_admin %}
<dt class="col-sm-6 text-right">{% trans "Role:" %}</dt>
<dd class="col-sm-6">{{ user_object.registration.role }}</dd>
{% elif user_object.registration.coachregistration or user_object.registration.is_volunteer %}
<dt class="col-sm-6 text-right">{% trans "Profesional activity:" %}</dt>
<dd class="col-sm-6">{{ user_object.registration.professional_activity }}</dd>
{% endif %}
<dt class="col-sm-6 text-right">{% trans "Grant Animath to contact me in the future about other actions:" %}</dt>
<dd class="col-sm-6">{{ user_object.registration.give_contact_to_animath|yesno }}</dd>
</dl>
{% if user_object.registration.participates and user_object.registration.team.participation.valid %}
<hr>
<dl class="row">
<dt class="col-sm-6 text-right">{% trans "Payment information:" %}</dt>
<dd class="col-sm-6">
{% trans "yes,no,pending" as yesnodefault %}
{% with info=user_object.registration.payment.additional_information %}
{% if info %}
<abbr title="{{ info }}">
{{ user_object.registration.payment.get_type_display }}, {% trans "valid:" %} {{ user_object.registration.payment.valid|yesno:yesnodefault }}
</abbr>
{% else %}
{{ user_object.registration.payment.get_type_display }}, {% trans "valid:" %} {{ user_object.registration.payment.valid|yesno:yesnodefault }}
{% endif %}
{% if user.registration.is_admin or user_object.registration.payment.valid is False %}
<button class="btn-sm btn-secondary" data-toggle="modal" data-target="#updatePaymentModal">
<i class="fas fa-money-bill-wave"></i> {% trans "Update payment" %}
</button>
{% endif %}
{% if user_object.registration.payment.type == "scholarship" %}
{% if user.registration.is_admin or user == user_object %}
<a href="{{ user_object.registration.payment.scholarship_file.url }}" class="btn btn-info" data-turbolinks="false">
<i class="fas fa-file-pdf"></i> {% trans "Download scholarship attestation" %}
</a>
{% endif %}
{% endif %}
{% endwith %}
</dd>
</dl>
{% endif %}
</div>
{% if user.pk == user_object.pk or user.registration.is_admin %}
<div class="card-footer text-center">
<a class="btn btn-primary" href="{% url "registration:update_user" pk=user_object.pk %}">{% trans "Update" %}</a>
{% if user.registration.is_admin %}
<a class="btn btn-info" href="{% url "registration:user_impersonate" pk=user_object.pk %}">{% trans "Impersonate" %}</a>
{% endif %}
</div>
{% endif %}
</div>
{% if user_object.registration.team and not user_object.registration.team.participation.valid %}
{% trans "Upload photo authorization" as modal_title %}
{% trans "Upload" as modal_button %}
{% url "registration:upload_user_photo_authorization" pk=user_object.registration.pk as modal_action %}
{% include "base_modal.html" with modal_id="uploadPhotoAuthorization" modal_enctype="multipart/form-data" %}
{% trans "Upload health sheet" as modal_title %}
{% trans "Upload" as modal_button %}
{% url "registration:upload_user_health_sheet" pk=user_object.registration.pk as modal_action %}
{% include "base_modal.html" with modal_id="uploadHealthSheet" modal_enctype="multipart/form-data" %}
{% trans "Upload parental authorization" as modal_title %}
{% trans "Upload" as modal_button %}
{% url "registration:upload_user_parental_authorization" pk=user_object.registration.pk as modal_action %}
{% include "base_modal.html" with modal_id="uploadParentalAuthorization" modal_enctype="multipart/form-data" %}
{% trans "Upload parental authorization" as modal_title %}
{% trans "Upload" as modal_button %}
{% url "registration:upload_user_parental_authorization" pk=user_object.registration.pk as modal_action %}
{% include "base_modal.html" with modal_id="uploadParentalAuthorization" modal_enctype="multipart/form-data" %}
{% endif %}
{% if user_object.registration.team.participation.valid %}
{% trans "Update payment" as modal_title %}
{% trans "Update" as modal_button %}
{% url "registration:update_payment" pk=user_object.registration.payment.pk as modal_action %}
{% include "base_modal.html" with modal_id="updatePayment" modal_additional_class="modal-xl" modal_enctype="multipart/form-data" %}
{% endif %}
{% endblock %}
{% block extrajavascript %}
<script>
$(document).ready(function() {
{% if user_object.registration.team and not user_object.registration.team.participation.valid %}
$('button[data-target="#uploadPhotoAuthorizationModal"]').click(function() {
let modalBody = $("#uploadPhotoAuthorizationModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "registration:upload_user_photo_authorization" pk=user_object.registration.pk %} #form-content");
});
$('button[data-target="#uploadHealthSheetModal"]').click(function() {
let modalBody = $("#uploadHealthSheetModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "registration:upload_user_health_sheet" pk=user_object.registration.pk %} #form-content");
});
$('button[data-target="#uploadParentalAuthorizationModal"]').click(function() {
let modalBody = $("#uploadParentalAuthorizationModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "registration:upload_user_parental_authorization" pk=user_object.registration.pk %} #form-content");
});
{% endif %}
{% if user_object.registration.team.participation.valid %}
$('button[data-target="#updatePaymentModal"]').click(function() {
let modalBody = $("#updatePaymentModal div.modal-body");
if (!modalBody.html().trim())
modalBody.load("{% url "registration:update_payment" pk=user_object.registration.payment.pk %} #form-content");
});
{% endif %}
});
</script>
{% endblock %}

View File

@ -1,14 +0,0 @@
{% extends "base.html" %}
{% load django_tables2 i18n %}
{% block content %}
{% if user.registration.is_volunteer %}
<a href="{% url "registration:add_organizer" %}" class="btn btn-block btn-secondary">
<i class="fas fa-user-plus"></i> {% trans "Add organizer" %}
</a>
<hr>
{% endif %}
{% render_table table %}
{% endblock %}

View File

@ -1,10 +0,0 @@
{{ object.user.first_name }}
{{ object.user.last_name }}
{{ object.user.email }}
{{ object.type }}
{{ object.professional_activity }}
{{ object.birth_date }}
{{ object.address }}
{{ object.phone_number }}
{{ object.team.name }}
{{ object.team.trigram }}

View File

@ -1,2 +1,2 @@
# Copyright (C) 2020 by Animath
# 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

View File

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

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

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