1
0
mirror of https://gitlab.com/animath/si/plateforme.git synced 2025-06-22 15:58:24 +02:00

Compare commits

...

323 Commits

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

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

Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 15:36:51 +01:00
6fa3a08a72 Add button to update notes
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 11:39:29 +01:00
64b7644e5e Admin users can manage juries
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 10:47:35 +01:00
50d8bc2aed Better jury autocomplete
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 10:33:42 +01:00
7f7ac5d5e6 Users can't join a team after validation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 10:29:45 +01:00
1dd9a5cf94 Add autocomplete feature for jury form
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-23 23:04:22 +01:00
40aa2e520f Add API endpoint to get volunteers names and emails, for tournament organizers only, to easily add juries
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-23 11:47:42 +01:00
0ebee1910b Add api endpoints for tweaks and payments
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-23 11:36:09 +01:00
81c2df7f10 Restructure add juree page
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-23 11:23:02 +01:00
833b300fde Fix motivation letter validation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-21 20:28:12 +01:00
12d25b64fe Payments in the list for a tournament are distinct
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-16 10:41:48 +01:00
afbc67c413 Let coaches update payment of the team
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-16 07:31:19 +01:00
71e33b2177 Typo
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-03 16:18:04 +01:00
f95309be08 Frais d'inscription => Frais de participation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-03 15:16:43 +01:00
0530441452 Fix receipt file name
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-02 19:44:35 +01:00
4ff53e08db Add privacy policy
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-02 12:52:47 +01:00
f9645b016a Allow organizers to submit payment forms
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-29 23:05:41 +01:00
6b7b802d14 Don't update payment amount if there isn't anyone
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-29 23:00:35 +01:00
1684c079e3 Fix payment group permission
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-29 22:59:54 +01:00
0c45a88246 Tournament.amount => Tournament.price
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-26 23:49:57 +01:00
de22a12e85 Activating translation is not needed
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 19:22:55 +01:00
415d83acc7 Read tox dependencies from requirements.txt file
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 19:15:07 +01:00
eb7e7c1579 Compile messages in tox tests
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 19:09:34 +01:00
348004320c Add tests for payment management commands
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 19:01:26 +01:00
9829541289 Add information about reminders
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 18:44:54 +01:00
1e1fef7a7b Add documentation dark theme
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 18:41:12 +01:00
d0c9256c5b Add payment user documentation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 18:40:58 +01:00
83300ad4b7 Add tests for Hello Asso payments using a fake endpoint
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 17:24:52 +01:00
92408b359b Move helloasso methods in a specific module
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 15:11:33 +01:00
01ba0a1df9 Replace assertEquals by assertEqual (deprecated and removed in Python 3.12)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-24 23:10:06 +01:00
207af441a0 Add payment interface tests
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-24 23:05:21 +01:00
2a2786ba6d Add payment information after payment
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-24 22:58:06 +01:00
1d01376703 Update validate team mail with a payment reminder
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-24 09:56:57 +01:00
6e35bdc0b3 Create payments in a signal rather than in a view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-24 09:39:04 +01:00
9380fbaaf7 Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-24 09:22:27 +01:00
295717256f Grouping payments is only allowed if all members of a team have not paid yet
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-24 08:54:01 +01:00
87038dd6f4 Allow to use a local settings file
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-24 08:45:59 +01:00
2155275627 Update Haystack search index in cron
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-23 23:08:47 +01:00
7b4e867e33 Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-23 23:05:10 +01:00
2c54f315f6 Add payments table page
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-23 22:58:23 +01:00
5cbc72b41f Teams tab is only accessible to admins
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-23 21:48:39 +01:00
de504398d2 Improve Django-admin interface, inlines and filters
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-23 21:43:44 +01:00
cae1c6fdb8 Send payment confirmation mail after payment, and send weekly reminders for people that have not paid
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-23 18:02:24 +01:00
6a928ee35b Prepare mails for payment confirmations and reminders
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-22 18:43:18 +01:00
bc535f4075 Restore payment edit form for volunteers
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-21 23:56:29 +01:00
64b91cf7e0 Display payments in team detail view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-21 23:41:31 +01:00
54dafe1cec Improve payment messages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-21 23:12:01 +01:00
b16b6e422f Allow anonymous users to perform a payment using a special auth token
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-21 22:44:56 +01:00
8d08b18d08 Configure Hello Asso return endpoint
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-20 22:54:12 +01:00
8c7e9648dd Use Hello Asso sandbox instance in dev mode
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-20 18:51:38 +01:00
b3555a7807 Create Hello Asso checkout intents
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-19 00:17:14 +01:00
98d04b9093 Make the payment group button work
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-18 23:02:27 +01:00
4d157b2bd7 Setup payment interface
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-18 22:36:01 +01:00
7c9083a6b8 Restructure payment model
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-12 22:58:48 +01:00
ece128836a Temporary disable payment form
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 23:51:33 +01:00
2e574d0659 Fix participation detail test (a tournament is required)
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 23:47:01 +01:00
850659bf48 Display payment information on the sidebar
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 23:31:24 +01:00
672529382d Fix payment view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 23:12:48 +01:00
c1ce7cb70f Display pending validations for organizers
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 22:39:11 +01:00
bc67d1cf1f Add information about team registration
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 22:24:22 +01:00
652e913f49 Fix user update view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 21:41:37 +01:00
089374b937 Fix join team view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 21:40:06 +01:00
226e5620f9 Better footer on small screens
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 21:06:22 +01:00
ca9652cc60 Collapse sidebar on small screens
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 20:59:34 +01:00
acd1d80c75 First important informations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 20:20:28 +01:00
e7c207d2af Sidebar structure
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 19:23:16 +01:00
196ccb69ad Remove headers on index page
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 19:15:36 +01:00
2b941cb30f Rearrange base template with separated contents, add sidebar
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 18:43:23 +01:00
230 changed files with 35817 additions and 3347 deletions

15
.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,7 +22,7 @@ coverage
.vscode
# Local data
secrets.py
settings_local.py
*.log
media/
output/
@ -42,6 +32,3 @@ output/
env/
venv/
db.sqlite3
# Don't git index
whoosh_index/

View File

@ -2,24 +2,24 @@ stages:
- test
- quality-assurance
py311:
stage: test
image: python:3.11-alpine
before_script:
- apk add --no-cache libmagic
- apk add --no-cache git # Useful for django-haystack, remove when the newer versions are in PyPI
- pip install tox --no-cache-dir
script: tox -e py311
py312:
stage: test
image: python:3.12-alpine
before_script:
- apk add --no-cache libmagic
- apk add --no-cache git # Useful for django-haystack, remove when the newer versions are in PyPI
- apk add --no-cache gettext
- pip install tox --no-cache-dir
script: tox -e py312
py313:
stage: test
image: python:3.13-alpine
before_script:
- apk add --no-cache libmagic
- apk add --no-cache gettext
- pip install tox --no-cache-dir
script: tox -e py313
linters:
stage: quality-assurance
image: python:3-alpine

View File

@ -1,12 +1,15 @@
FROM python:3.12-alpine
FROM python:3.13-alpine
ENV PYTHONUNBUFFERED 1
ENV DJANGO_ALLOW_ASYNC_UNSAFE 1
RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libxml2-dev libxslt-dev postgresql-dev libmagic texlive texmf-dist-latexextra
RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libpq-dev libxml2-dev libxslt-dev \
npm libmagic texlive texmf-dist-fontsrecommended texmf-dist-lang texmf-dist-latexextra
RUN apk add --no-cache bash
RUN npm install -g yuglify
RUN mkdir /code /code/docs
WORKDIR /code
COPY requirements.txt /code/requirements.txt

2
chat/__init__.py Normal file
View File

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

28
chat/admin.py Normal file
View File

@ -0,0 +1,28 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
from .models import Channel, Message
@admin.register(Channel)
class ChannelAdmin(admin.ModelAdmin):
"""
Modèle d'administration des canaux de chat.
"""
list_display = ('name', 'category', 'read_access', 'write_access', 'tournament', 'private',)
list_filter = ('category', 'read_access', 'write_access', 'tournament', 'private',)
search_fields = ('name', 'tournament__name', 'team__name', 'team__trigram',)
autocomplete_fields = ('tournament', 'pool', 'team', 'invited', )
@admin.register(Message)
class MessageAdmin(admin.ModelAdmin):
"""
Modèle d'administration des messages de chat.
"""
list_display = ('channel', 'author', 'created_at', 'updated_at', 'content',)
list_filter = ('channel', 'created_at', 'updated_at',)
search_fields = ('author__username', 'author__first_name', 'author__last_name', 'content',)
autocomplete_fields = ('channel', 'author', 'users_read',)

16
chat/apps.py Normal file
View File

@ -0,0 +1,16 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.db.models.signals import post_save
class ChatConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "chat"
def ready(self):
from chat import signals
post_save.connect(signals.create_tournament_channels, "participation.Tournament")
post_save.connect(signals.create_pool_channels, "participation.Pool")
post_save.connect(signals.create_team_channel, "participation.Participation")

370
chat/consumers.py Normal file
View File

@ -0,0 +1,370 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from django.contrib.auth.models import User
from django.db.models import Count, F, Q
from registration.models import Registration
from .models import Channel, Message
class ChatConsumer(AsyncJsonWebsocketConsumer):
"""
Ce consommateur gère les connexions WebSocket pour le chat.
"""
async def connect(self) -> None:
"""
Cette fonction est appelée lorsqu'un nouveau websocket tente de se connecter au serveur.
On n'accept que si c'est un⋅e utilisateur⋅rice connecté⋅e.
"""
if '_fake_user_id' in self.scope['session']:
# Dans le cas d'une impersonification, on charge l'utilisateur⋅rice concerné
self.scope['user'] = await User.objects.aget(pk=self.scope['session']['_fake_user_id'])
# Récupération de l'utilisateur⋅rice courant⋅e
user = self.scope['user']
if user.is_anonymous:
# L'utilisateur⋅rice n'est pas connecté⋅e
await self.close()
return
reg = await Registration.objects.aget(user_id=user.id)
self.registration = reg
# Acceptation de la connexion
await self.accept()
# Récupération des canaux accessibles en lecture et/ou en écriture
self.read_channels = await Channel.get_accessible_channels(user, 'read')
self.write_channels = await Channel.get_accessible_channels(user, 'write')
# Abonnement aux canaux de diffusion Websocket pour les différents canaux de chat
async for channel in self.read_channels.all():
await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
# Abonnement à un canal de diffusion Websocket personnel, utile pour s'adresser à une unique personne
await self.channel_layer.group_add(f"user-{user.id}", self.channel_name)
async def disconnect(self, close_code: int) -> None:
"""
Cette fonction est appelée lorsqu'un websocket se déconnecte du serveur.
:param close_code: Le code d'erreur.
"""
if self.scope['user'].is_anonymous:
# L'utilisateur⋅rice n'était pas connecté⋅e, on ne fait rien
return
async for channel in self.read_channels.all():
# Désabonnement des canaux de diffusion Websocket liés aux canaux de chat
await self.channel_layer.group_discard(f"chat-{channel.id}", self.channel_name)
# Désabonnement du canal de diffusion Websocket personnel
await self.channel_layer.group_discard(f"user-{self.scope['user'].id}", self.channel_name)
async def receive_json(self, content: dict, **kwargs) -> None:
"""
Appelée lorsque le client nous envoie des données, décodées depuis du JSON.
:param content: Les données envoyées par le client, décodées depuis du JSON. Doit contenir un champ 'type'.
"""
match content['type']:
case 'fetch_channels':
# Demande de récupération des canaux disponibles
await self.fetch_channels()
case 'send_message':
# Envoi d'un message dans un canal
await self.receive_message(**content)
case 'edit_message':
# Modification d'un message
await self.edit_message(**content)
case 'delete_message':
# Suppression d'un message
await self.delete_message(**content)
case 'fetch_messages':
# Récupération des messages d'un canal (ou d'une partie)
await self.fetch_messages(**content)
case 'mark_read':
# Marquage de messages comme lus
await self.mark_read(**content)
case 'start_private_chat':
# Démarrage d'une conversation privée avec un⋅e autre utilisateur⋅rice
await self.start_private_chat(**content)
case unknown:
# Type inconnu, on soulève une erreur
raise ValueError(f"Unknown message type: {unknown}")
async def fetch_channels(self) -> None:
"""
L'utilisateur⋅rice demande à récupérer la liste des canaux disponibles.
On lui renvoie alors la liste des canaux qui lui sont accessibles en lecture,
en fournissant nom, catégorie, permission de lecture et nombre de messages non lus.
"""
user = self.scope['user']
# Récupération des canaux accessibles en lecture, avec le nombre de messages non lus
channels = self.read_channels.prefetch_related('invited') \
.annotate(total_messages=Count('messages', distinct=True)) \
.annotate(read_messages=Count('messages', filter=Q(messages__users_read=user), distinct=True)) \
.annotate(unread_messages=F('total_messages') - F('read_messages')).all()
# Envoi de la liste des canaux
message = {
'type': 'fetch_channels',
'channels': [
{
'id': channel.id,
'name': channel.get_visible_name(user),
'category': channel.category,
'read_access': True,
'write_access': await self.write_channels.acontains(channel),
'unread_messages': channel.unread_messages,
}
async for channel in channels
]
}
await self.send_json(message)
async def receive_message(self, channel_id: int, content: str, **kwargs) -> None:
"""
L'utilisateur⋅ice a envoyé un message dans un canal.
On vérifie d'abord la permission d'écriture, puis on crée le message et on l'envoie à tou⋅tes les
utilisateur⋅ices abonné⋅es au canal.
:param channel_id: Identifiant du canal où envoyer le message.
:param content: Contenu du message.
"""
user = self.scope['user']
# Récupération du canal
channel = await Channel.objects.prefetch_related('tournament__pools__juries', 'pool', 'team', 'invited') \
.aget(id=channel_id)
if not await self.write_channels.acontains(channel):
# L'utilisateur⋅ice n'a pas la permission d'écrire dans ce canal, on abandonne
return
# Création du message
message = await Message.objects.acreate(
author=user,
channel=channel,
content=content,
)
# Envoi du message à toutes les personnes connectées sur le canal
await self.channel_layer.group_send(f'chat-{channel.id}', {
'type': 'chat.send_message',
'id': message.id,
'channel_id': channel.id,
'timestamp': message.created_at.isoformat(),
'author_id': message.author_id,
'author': await message.aget_author_name(),
'content': message.content,
})
async def edit_message(self, message_id: int, content: str, **kwargs) -> None:
"""
L'utilisateur⋅ice a modifié un message.
On vérifie d'abord que l'utilisateur⋅ice a le droit de modifier le message, puis on modifie le message
et on envoie la modification à tou⋅tes les utilisateur⋅ices abonné⋅es au canal.
:param message_id: Identifiant du message à modifier.
:param content: Nouveau contenu du message.
"""
user = self.scope['user']
# Récupération du message
message = await Message.objects.aget(id=message_id)
if user.id != message.author_id and not user.is_superuser:
# Seul⋅e l'auteur⋅ice du message ou un⋅e admin peut modifier un message
return
# Modification du contenu du message
message.content = content
await message.asave()
# Envoi de la modification à tou⋅tes les personnes connectées sur le canal
await self.channel_layer.group_send(f'chat-{message.channel_id}', {
'type': 'chat.edit_message',
'id': message_id,
'channel_id': message.channel_id,
'content': content,
})
async def delete_message(self, message_id: int, **kwargs) -> None:
"""
L'utilisateur⋅ice a supprimé un message.
On vérifie d'abord que l'utilisateur⋅ice a le droit de supprimer le message, puis on supprime le message
et on envoie la suppression à tou⋅tes les utilisateur⋅ices abonné⋅es au canal.
:param message_id: Identifiant du message à supprimer.
"""
user = self.scope['user']
# Récupération du message
message = await Message.objects.aget(id=message_id)
if user.id != message.author_id and not user.is_superuser:
return
# Suppression effective du message
await message.adelete()
# Envoi de la suppression à tou⋅tes les personnes connectées sur le canal
await self.channel_layer.group_send(f'chat-{message.channel_id}', {
'type': 'chat.delete_message',
'id': message_id,
'channel_id': message.channel_id,
})
async def fetch_messages(self, channel_id: int, offset: int = 0, limit: int = 50, **_kwargs) -> None:
"""
L'utilisateur⋅ice demande à récupérer les messages d'un canal.
On vérifie la permission de lecture, puis on renvoie les messages demandés.
:param channel_id: Identifiant du canal où récupérer les messages.
:param offset: Décalage pour la pagination, à partir du dernier message.
Par défaut : 0, on commence au dernier message.
:param limit: Nombre de messages à récupérer. Par défaut, on récupère 50 messages.
"""
# Récupération du canal
channel = await Channel.objects.aget(id=channel_id)
if not await self.read_channels.acontains(channel):
# L'utilisateur⋅rice n'a pas la permission de lire ce canal, on abandonne
return
limit = min(limit, 200) # On limite le nombre de messages à 200 maximum
# Récupération des messages, avec un indicateur de lecture pour l'utilisateur⋅ice courant⋅e
messages = Message.objects \
.filter(channel=channel) \
.annotate(read=Count('users_read', filter=Q(users_read=self.scope['user']))) \
.order_by('-created_at')[offset:offset + limit].all()
# Envoi de la liste des messages, en les renvoyant dans l'ordre chronologique
await self.send_json({
'type': 'fetch_messages',
'channel_id': channel_id,
'messages': list(reversed([
{
'id': message.id,
'timestamp': message.created_at.isoformat(),
'author_id': message.author_id,
'author': await message.aget_author_name(),
'content': message.content,
'read': message.read > 0,
}
async for message in messages
]))
})
async def mark_read(self, message_ids: list[int], **_kwargs) -> None:
"""
L'utilisateur⋅ice marque des messages comme lus, après les avoir affichés à l'écran.
:param message_ids: Liste des identifiants des messages qu'il faut marquer comme lus.
"""
# Récupération des messages à marquer comme lus
messages = Message.objects.filter(id__in=message_ids)
async for message in messages.all():
# Ajout de l'utilisateur⋅ice courant⋅e à la liste des personnes ayant lu le message
await message.users_read.aadd(self.scope['user'])
# Actualisation du nombre de messages non lus par canal
unread_messages_by_channel = Message.objects.exclude(users_read=self.scope['user']).values('channel_id') \
.annotate(unread_messages=Count('channel_id'))
# Envoi des identifiants des messages non lus et du nombre de messages non lus par canal, actualisés
await self.send_json({
'type': 'mark_read',
'messages': [{'id': message.id, 'channel_id': message.channel_id} async for message in messages.all()],
'unread_messages': {group['channel_id']: group['unread_messages']
async for group in unread_messages_by_channel.all()},
})
async def start_private_chat(self, user_id: int, **kwargs) -> None:
"""
L'utilisateur⋅ice souhaite démarrer une conversation privée avec un⋅e autre utilisateur⋅ice.
Pour cela, on récupère le salon privé s'il existe, sinon on en crée un.
Dans le cas d'une création, les deux personnes sont transférées immédiatement dans ce nouveau canal.
:param user_id: L'utilisateur⋅rice avec qui démarrer la conversation privée.
"""
user = self.scope['user']
# Récupération de l'autre utilisateur⋅ice avec qui démarrer la conversation
other_user = await User.objects.aget(id=user_id)
# Vérification de l'existence d'un salon privé entre les deux personnes
channel_qs = Channel.objects.filter(private=True).filter(invited=user).filter(invited=other_user)
if not await channel_qs.aexists():
# Le salon privé n'existe pas, on le crée alors
channel = await Channel.objects.acreate(
name=f"{user.first_name} {user.last_name}, {other_user.first_name} {other_user.last_name}",
category=Channel.ChannelCategory.PRIVATE,
private=True,
)
await channel.invited.aset([user, other_user])
# On s'ajoute au salon privé
await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
if user != other_user:
# On transfère l'autre utilisateur⋅ice dans le salon privé
await self.channel_layer.group_send(f"user-{other_user.id}", {
'type': 'chat.start_private_chat',
'channel': {
'id': channel.id,
'name': f"{user.first_name} {user.last_name}",
'category': channel.category,
'read_access': True,
'write_access': True,
}
})
else:
# Récupération dudit salon privé
channel = await channel_qs.afirst()
# Invitation de l'autre utilisateur⋅rice à rejoindre le salon privé
await self.channel_layer.group_send(f"user-{user.id}", {
'type': 'chat.start_private_chat',
'channel': {
'id': channel.id,
'name': f"{other_user.first_name} {other_user.last_name}",
'category': channel.category,
'read_access': True,
'write_access': True,
}
})
async def chat_send_message(self, message) -> None:
"""
Envoi d'un message à tou⋅tes les personnes connectées sur un canal.
:param message: Dictionnaire contenant les informations du message à envoyer,
contenant l'identifiant du message "id", l'identifiant du canal "channel_id",
l'heure de création "timestamp", l'identifiant de l'auteur "author_id",
le nom de l'auteur "author" et le contenu du message "content".
"""
await self.send_json({'type': 'send_message', 'id': message['id'], 'channel_id': message['channel_id'],
'timestamp': message['timestamp'], 'author': message['author'],
'content': message['content']})
async def chat_edit_message(self, message) -> None:
"""
Envoi d'une modification de message à tou⋅tes les personnes connectées sur un canal.
:param message: Dictionnaire contenant les informations du message à modifier,
contenant l'identifiant du message "id", l'identifiant du canal "channel_id"
et le nouveau contenu "content".
"""
await self.send_json({'type': 'edit_message', 'id': message['id'], 'channel_id': message['channel_id'],
'content': message['content']})
async def chat_delete_message(self, message) -> None:
"""
Envoi d'une suppression de message à tou⋅tes les personnes connectées sur un canal.
:param message: Dictionnaire contenant les informations du message à supprimer,
contenant l'identifiant du message "id" et l'identifiant du canal "channel_id".
"""
await self.send_json({'type': 'delete_message', 'id': message['id'], 'channel_id': message['channel_id']})
async def chat_start_private_chat(self, message) -> None:
"""
Envoi d'un message pour démarrer une conversation privée à une personne connectée.
:param message: Dictionnaire contenant les informations du nouveau canal privé.
"""
await self.channel_layer.group_add(f"chat-{message['channel']['id']}", self.channel_name)
await self.send_json({'type': 'start_private_chat', 'channel': message['channel']})

View File

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

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

View File

@ -29,6 +29,7 @@ author = "Animath"
# ones.
extensions = [
"sphinx_rtd_theme",
"sphinx_rtd_dark_mode",
]
# Add any paths that contain templates here, relative to this directory.
@ -58,3 +59,5 @@ html_theme = 'sphinx_rtd_theme'
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
default_dark_mode = True

View File

@ -60,7 +60,7 @@ Dans le fichier ``docker-compose.yml``, configurer :
networks:
- tfjm
labels:
- "traefik.http.routers.inscription-tfjm2.rule=Host(`inscription.tfjm.org`, `plateforme.tfjm.org`)"
- "traefik.http.routers.inscription-tfjm2.rule=Host(`inscription.tfjm.org`, `inscriptions.tfjm.org`, `plateforme.tfjm.org`)"
- "traefik.http.routers.inscription-tfjm2.entrypoints=websecure"
- "traefik.http.routers.inscription-tfjm2.tls.certresolver=mytlschallenge"

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

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

View File

@ -21,3 +21,4 @@ administrateur⋅rice.
dev/index
dev/install
dev/transition

View File

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

View File

@ -178,17 +178,126 @@ Vous recevrez par mail une réponse des organisateur⋅rices locaux⋅ales. En c
Payer son inscription
---------------------
Une fois votre inscription validée, il vous faudra payer votre inscription. Les frais s'élèvent à
23 € par élève, sauf pour les élèves boursièr⋅es qui en sont exonéré⋅es. Les encadrant⋅es n'ont pas
à payer.
Une fois votre inscription validée, il vous faudra payer votre participation. Les frais s'élèvent à
21 € par élève, sauf pour les élèves boursièr⋅es qui en sont exonéré⋅es. Les encadrant⋅es n'ont pas
à payer. Pour la finale, les frais sont de 35 € par élève.
.. note::
Ces frais couvrent une partie des frais de restauration et d'hébergement. L'organisation reste
bénévole.
.. TODO
Il est possible de payer par carte bancaire ou virement bancaire. Pour d'autres types de paiement,
merci de nous contacter.
Pour payer, si votre équipe est bien validée, vous pouvez vous rendre sur la page de votre compte
ou celle de votre équipe, et cliquer sur le bouton « Modifier le paiement », qui devrais désormais
apparaître. Vous pouvez également utiliser le lien présent dans le volet « Informations ».
.. image:: /_static/img/payment_index.png
:alt: Page de paiement
.. note::
Cette section sera mise à jour plus tard.
Vous recevrez un mail de rappel chaque semaine. Le paiement doit être effectué avant le début du
tournoi, sans quoi votre participation pourrait être refusée. En cas de difficultés de paiement,
merci de nous contacter.
Carte bancaire
""""""""""""""
La façon la plus simple de payer son inscription est de payer par carte bancaire. Animath utilise
`Hello Asso <https://helloasso.com/>`_ en guise de solution de paiements en ligne.
Il vous suffit de cliquer sur le bouton « Aller à la page Hello Asso ». Vous serez redirigé⋅e ensuite
vers la page de paiement.
.. warning::
Pour procéder au paiement, si vous êtes mineur⋅e, vous devrez demander à un⋅e adulte de payer à
votre place. Il est important dans la suite de bien mettre les coordonnées du payeur ou de la payeuse,
majeur⋅e, et non celles de l'élève.
.. image:: /_static/img/payment_hello_asso_step_1.png
:alt: Formulaire de paiement Hello Asso
La personne qui paie peut rentrer ses informations demandées (nom, prénom, e-mail, date de naissance).
Notez que, par défaut, Hello Asso ajoute automatiquement une participation à ses frais de fonctionnement,
d'environ 15 à 20 % du prix payé. Ces frais ne sont pas obligatoires, ne sont pas versés à Animath et
représentent la seule source de revenus à Hello Asso. En effet : Animath ne verse aucune commission lors
de ses transactions, et seules les contributions volontaires financent leur service.
Sur la page suivante, vous pouvez indiquer vos coordonnées bancaires :
.. image:: /_static/img/payment_hello_asso_step_2.png
:alt: Formulaire de paiement Hello Asso - coordonnées bancaires
Vous devez ensuite éventuellement confirmer votre paiement auprès de votre banque.
Une fois ceci fait, vous êtes automatiquement redirigé⋅es vers la plateforme du TFJM² :
.. image:: /_static/img/payment_hello_asso_confirmation.png
:alt: Confirmation de paiement Hello Asso
Il se peut que la validation ne soit pas instantanée. Elle peut prendre au plus quelques minutes.
Si le délai est plus long, merci de nous contacter.
Vous recevrez ensuite un mail de confirmation de la plateforme, ainsi qu'un justificatif de paiement
de la part de Hello Asso.
Carte bancaire - paiement par un tiers
""""""""""""""""""""""""""""""""""""""
Il est possible, si nécessaire, de faire payer l'inscription par carte bancaire par un tiers. Pour cela,
vous pouvez lui transmettre le lien de paiement qui apparaît au centre de l'écran. Cela est notamment
utile pour faire payer l'inscription par un établissement scolaire, ou par des parents.
L'interface de paiement sera ensuite identique.
Virement bancaire
"""""""""""""""""
Il est possible de payer par virement bancaire. Pour cela, vous pouvez ouvrir l'onglet virement bancaire :
.. image:: /_static/img/payment_bank_transfer.png
:alt: Formulaire de paiement par virement bancaire
Pour effectuer le virement, merci de mettre en référence du virement « TFJMpu » suivi du nom et du prénom de l'élève.
Les coordonnées bancaires sont :
* IBAN : FR76 1027 8065 0000 0206 4290 127
* BIC : CMCIFR2A
Une fois le paiment effectué, vous pouvez envoyer une preuve de virement via le formulaire ci-dessus. Le paiement
sera ensuite validé manuellement par les organisateur⋅rices après réception.
Si vous avez besoin d'une facture, merci de nous contacter.
Exonération - boursièr⋅es
"""""""""""""""""""""""""
Si vous bénéficiez d'une bourse, vous pouvez être exonéré⋅es des frais de participation. Pour cela, il vous suffit
de nous envoyer une copie de votre notification de bourse, ou tout autre document justifiant de votre situation.
Vous pouvez envoyer ce document en vous rendant sur l'onglet dédié :
.. image:: /_static/img/payment_scholarship.png
:alt: Formulaire de soumission de notification de bourse
Paiements groupés
"""""""""""""""""
Il est possible de payer en une seule fois pour toute l'équipe. Cela est notamment utile si l'inscription est
payée par l'établissement. Pour cela, il suffit de cliquer sur le bouton « Regrouper les paiements de mon équipe ».
Cela a pour effet d'unifier les paiements de l'équipe, et de ne pas demander à chaque membre de payer individuellement.
Attention : cette fonction n'est possible que si aucun membre de l'équipe n'a encore payé son inscription.
.. image:: /_static/img/payment_grouped.png
:alt: Page de paiement groupé
Envoyer ses solutions

View File

@ -7,11 +7,34 @@ from django.utils.translation import gettext_lazy as _
from .models import Draw, Pool, Round, TeamDraw
class RoundInline(admin.TabularInline):
model = Round
extra = 0
autocomplete_fields = ('draw', 'current_pool',)
show_change_link = True
class PoolInline(admin.TabularInline):
model = Pool
extra = 0
autocomplete_fields = ('round', 'current_team', 'associated_pool',)
show_change_link = True
class TeamDrawInline(admin.TabularInline):
model = TeamDraw
extra = 0
autocomplete_fields = ('participation', 'round', 'pool',)
show_change_link = True
@admin.register(Draw)
class DrawAdmin(admin.ModelAdmin):
list_display = ('tournament', 'teams', 'current_round', 'get_state',)
list_filter = ('tournament', 'current_round',)
list_filter = ('tournament', 'current_round__number',)
search_fields = ('tournament__name', 'tournament__participation__team__trigram',)
autocomplete_fields = ('tournament',)
inlines = (RoundInline,)
@admin.display(description=_("teams"))
def teams(self, record: Draw):
@ -20,10 +43,16 @@ class DrawAdmin(admin.ModelAdmin):
@admin.register(Round)
class RoundAdmin(admin.ModelAdmin):
list_display = ('draw', 'number', 'teams',)
list_display = ('draw', 'tournament', 'number', 'teams',)
list_filter = ('draw__tournament', 'number',)
search_fields = ('draw__tournament__name', 'pool__teamdraw__participation__team__trigram')
ordering = ('draw__tournament__name', 'number')
autocomplete_fields = ('draw', 'current_pool',)
inlines = (PoolInline,)
@admin.display(description=_("tournament"), ordering='draw__tournament__name')
def tournament(self, record):
return record.draw.tournament
@admin.display(description=_("teams"))
def teams(self, record: Round):
@ -36,6 +65,8 @@ class PoolAdmin(admin.ModelAdmin):
list_filter = ('round__draw__tournament', 'round__number', 'letter')
ordering = ('round__draw__tournament__name', 'round', 'letter')
search_fields = ('round__draw__tournament__name', 'teamdraw__participation__team__trigram',)
autocomplete_fields = ('round', 'current_team', 'associated_pool',)
inlines = (TeamDrawInline,)
@admin.display(ordering='round__draw__tournament__name', description=_("tournament"))
def tournament(self, record):
@ -52,6 +83,7 @@ class TeamDrawAdmin(admin.ModelAdmin):
'passage_index', 'choose_index', 'passage_dice', 'choice_dice',)
list_filter = ('round__draw__tournament', 'round__number', 'pool__letter',)
search_fields = ('round__draw__tournament__name', 'participation__team__trigram',)
autocomplete_fields = ('participation', 'round', 'pool',)
@admin.display(ordering='round__draw__tournament__name', description=_("tournament"))
def tournament(self, record):

View File

@ -3,10 +3,13 @@
from collections import OrderedDict
import json
import os
from random import randint, shuffle
from asgiref.sync import sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.utils import translation
from django.utils.translation import gettext_lazy as _
@ -42,10 +45,17 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
We accept only if this is a user of a team of the associated tournament, or a volunteer
of the tournament.
"""
if '_fake_user_id' in self.scope['session']:
self.scope['user'] = await User.objects.aget(pk=self.scope['session']['_fake_user_id'])
# Fetch the registration of the current user
user = self.scope['user']
reg = await Registration.objects.aget(user=user)
if user.is_anonymous:
# User is not authenticated
await self.close()
return
reg = await Registration.objects.aget(user_id=user.id)
self.registration = reg
# Accept the connection
@ -69,6 +79,10 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
Called when the websocket got disconnected, for any reason.
:param close_code: The error code.
"""
if self.scope['user'].is_anonymous:
# User is not authenticated
return
# Unregister from channel layers
if not self.registration.is_volunteer:
await self.channel_layer.group_discard(f"team-{self.registration.team.trigram}", self.channel_name)
@ -108,6 +122,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
self.tournament = await Tournament.objects.filter(pk=self.tournament_id)\
.prefetch_related('draw__current_round__current_pool__current_team__participation__team').aget()
translation.activate(settings.PREFERRED_LANGUAGE_CODE)
match content['type']:
case 'set_language':
# Update the translation language
@ -152,7 +168,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
try:
# Parse format from string
fmt: list[int] = sorted(map(int, fmt.split('+')), reverse=True)
fmt: list[int] = sorted(map(int, fmt.split('+')))
except ValueError:
return await self.alert(_("Invalid format"), 'danger')
@ -169,7 +185,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Create the draw
draw = await Draw.objects.acreate(tournament=self.tournament)
r1 = None
for i in [1, 2]:
for i in range(1, settings.NB_ROUNDS + 1):
# Create the round
r = await Round.objects.acreate(draw=draw, number=i)
if i == 1:
@ -219,8 +235,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.notify',
'title': 'Tirage au sort du TFJM²',
'body': "Le tirage au sort du tournoi de "
f"{self.tournament.name} a commencé !"})
'body': _("The draw of tournament {tournament} started!")
.format(tournament=self.tournament.name)})
async def draw_start(self, content) -> None:
"""
@ -389,8 +405,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
await self.channel_layer.group_send(
f"team-{dup.participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify', 'title': 'Tirage au sort du TFJM²',
'body': 'Votre score de dé est identique à celui de une ou plusieurs équipes. '
'Veuillez le relancer.'}
'body': _("Your dice score is identical to the one of one or multiple teams. "
"Please relaunch it.")}
)
# Alert the tournament
await self.channel_layer.group_send(
@ -403,7 +419,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
return error
async def process_dice_select_poules(self):
async def process_dice_select_poules(self): # noqa: C901
"""
Called when all teams launched their dice.
Place teams into pools and order their passage.
@ -416,10 +432,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# For each pool of size N, put the N next teams into this pool
async for p in Pool.objects.filter(round_id=self.tournament.draw.current_round_id).order_by('letter').all():
# Fetch the N teams, then order them in a new order for the passages inside the pool
# We multiply the dice scores by 27 mod 100 (which order is 20 mod 100) for this new order
# This simulates a deterministic shuffle
pool_tds = sorted(tds_copy[:p.size], key=lambda td: (td.passage_dice * 27) % 100)
# Fetch the N teams
pool_tds = tds_copy[:p.size].copy()
# Remove the head
tds_copy = tds_copy[p.size:]
for i, td in enumerate(pool_tds):
@ -428,45 +442,74 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
td.passage_index = i
await td.asave()
# The passages of the second round are determined from the scores of the dices
# The team that has the lowest dice score goes to the first pool, then the team
# that has the second-lowest score goes to the second pool, etc.
# This also determines the passage order, in the natural order this time.
# If there is a 5-teams pool, we force the last team to be in the first pool,
# which is this specific pool since they are ordered by decreasing size.
# This is not true for the final tournament, which considers the scores of the
# first round.
if not self.tournament.final:
tds_copy = tds.copy()
# The passages of the second round are determined from the order of the passages of the first round.
# We order teams by increasing passage index, and then by decreasing pool number.
# We keep teams that were at the last position in a 5-teams pool apart, as "jokers".
# Then, we fill pools one team by one team.
# As we fill one pool for the second round, we check if we can place a joker in it.
# We can add a joker team if there is not already a team in the pool that was in the same pool
# in the first round, and such that the number of such jokers is exactly the free space of the current pool.
# Exception: if there is one only pool with 5 teams, we exchange the first and the last teams of the pool.
if not self.tournament.final and settings.TFJM_APP == "TFJM":
tds_copy = sorted(tds, key=lambda td: (td.passage_index, -td.pool.letter,))
jokers = [td for td in tds if td.passage_index == 4]
round2 = await self.tournament.draw.round_set.filter(number=2).aget()
round2_pools = [p async for p in Pool.objects.filter(round__draw__tournament=self.tournament, round=round2)
.order_by('letter').all()]
current_pool_id, current_passage_index = 0, 0
for i, td in enumerate(tds_copy):
if i == len(tds) - 1 and round2_pools[0].size == 5:
current_pool_id = 0
current_passage_index = 4
td2 = await TeamDraw.objects.filter(participation=td.participation, round=round2).aget()
td2.pool = round2_pools[current_pool_id]
td2.passage_index = current_passage_index
current_pool_id += 1
if current_pool_id == len(round2_pools):
current_pool_id = 0
current_passage_index += 1
if len(round2_pools) == 1:
# Exchange first and last team if there is only one pool
if i == 0 or i == len(tds) - 1:
td2.passage_index = len(tds) - 1 - i
current_passage_index += 1
await td2.asave()
valid_jokers = []
# A joker is valid if it was not in the same pool in the first round
# as a team that is already in the current pool in the second round
for joker in jokers:
async for td2 in round2_pools[current_pool_id].teamdraw_set.all():
if await joker.pool.teamdraw_set.filter(participation_id=td2.participation_id).aexists():
break
else:
valid_jokers.append(joker)
# We can add a joker if there is exactly enough free space in the current pool
if valid_jokers and current_passage_index + len(valid_jokers) == td2.pool.size:
for joker in valid_jokers:
tds_copy.remove(joker)
jokers.remove(joker)
td2_joker = await TeamDraw.objects.filter(participation_id=joker.participation_id,
round=round2).aget()
td2_joker.pool = round2_pools[current_pool_id]
td2_joker.passage_index = current_passage_index
current_passage_index += 1
await td2_joker.asave()
jokers = []
current_passage_index = 0
current_pool_id += 1
if current_passage_index == round2_pools[current_pool_id].size:
current_passage_index = 0
current_pool_id += 1
# The current pool is the first pool of the current (first) round
pool = await Pool.objects.filter(round=self.tournament.draw.current_round, letter=1).aget()
self.tournament.draw.current_round.current_pool = pool
await self.tournament.draw.current_round.asave()
# Display dice result in the header of the information alert
msg = "Les résultats des dés sont les suivants : "
msg += ", ".join(f"<strong>{td.participation.team.trigram}</strong> ({td.passage_dice})" for td in tds)
msg += ". L'ordre de passage et les compositions des différentes poules sont affiché⋅es sur le côté. "
msg += "Attention : les ordres de passage sont déterminés à partir des scores des dés, mais ne sont pas "
msg += "directement l'ordre croissant des dés, afin d'avoir des poules mélangées."
trigrams = ", ".join(f"<strong>{td.participation.team.trigram}</strong> ({td.passage_dice})" for td in tds)
msg = _("The dice results are the following: {trigrams}. "
"The passage order and the compositions of the different pools are displayed on the side. "
"The passage orders for the first round are determined from the dice scores, in increasing order. "
"For the second round, the passage orders are determined from the passage orders of the first round.") \
.format(trigrams=trigrams)
self.tournament.draw.last_message = msg
await self.tournament.draw.asave()
@ -490,18 +533,18 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
{'tid': self.tournament_id, 'type': 'draw.dice_visibility',
'visible': True})
# First send the second pool to have the good team order
r2 = await self.tournament.draw.round_set.filter(number=2).aget()
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.send_poules',
'round': r2.number,
'poules': [
{
'letter': pool.get_letter_display(),
'teams': await pool.atrigrams(),
}
async for pool in r2.pool_set.order_by('letter').all()
]})
# First send the pools of next rounds to have the good team order
async for next_round in self.tournament.draw.round_set.filter(number__gte=2).all():
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.send_poules',
'round': r.number,
'poules': [
{
'letter': pool.get_letter_display(),
'teams': await pool.atrigrams(),
}
async for pool in next_round.pool_set.order_by('letter').all()
]})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.send_poules',
'round': r.number,
@ -569,8 +612,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Notify the team that it can draw a problem
await self.channel_layer.group_send(f"team-{tds[0].participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify',
'title': "À votre tour !",
'body': "C'est à vous de tirer un nouveau problème !"})
'title': _("Your turn!"),
'body': _("It's your turn to draw a problem!")})
async def select_problem(self, **kwargs):
"""
@ -590,7 +633,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
.prefetch_related('team').aget()
# Ensure that the user can draws a problem at this time
if participation.id != td.participation_id:
return await self.alert("This is not your turn.", 'danger')
return await self.alert(_("This is not your turn."), 'danger')
while True:
# Choose a random problem
@ -599,6 +642,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
and isinstance(kwargs['problem'], int) and (1 <= kwargs['problem'] <= len(settings.PROBLEMS)):
# Admins can force the draw
problem = int(kwargs['problem'])
break
# Check that the user didn't already accept this problem for the first round
# if this is the second round
@ -660,19 +704,20 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
.prefetch_related('team').aget()
# Ensure that the user can accept a problem at this time
if participation.id != td.participation_id:
return await self.alert("This is not your turn.", 'danger')
return await self.alert(_("This is not your turn."), 'danger')
td.accepted = td.purposed
td.purposed = None
await td.asave()
trigram = td.participation.team.trigram
msg = f"L'équipe <strong>{trigram}</strong> a accepté le problème <strong>{td.accepted} : " \
f"{settings.PROBLEMS[td.accepted - 1]}</strong>. "
msg = _("The team <strong>{trigram}</strong> accepted the problem <string>{problem}</strong>: "
"{problem_name}. ").format(trigram=trigram, problem=td.accepted,
problem_name=settings.PROBLEMS[td.accepted - 1])
if pool.size == 5 and await pool.teamdraw_set.filter(accepted=td.accepted).acount() < 2:
msg += "Une équipe peut encore l'accepter."
msg += _("One team more can accept this problem.")
else:
msg += "Plus personne ne peut l'accepter."
msg += _("No team can accept this problem anymore.")
self.tournament.draw.last_message = msg
await self.tournament.draw.asave()
@ -707,8 +752,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Notify the team that it can draw a problem
await self.channel_layer.group_send(f"team-{new_trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify',
'title': "À votre tour !",
'body': "C'est à vous de tirer un nouveau problème !"})
'title': _("Your turn!"),
'body': _("It's your turn to draw a problem!")})
else:
# Pool is ended
await self.end_pool(pool)
@ -766,8 +811,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
'problems': [td.accepted async for td in pool.team_draws],
})
msg += f"<br><br>Le tirage de la poule {pool.get_letter_display()}{r.number} est terminé. " \
f"Le tableau récapitulatif est en bas."
msg += "<br><br>" + _("The draw of the pool {pool} is ended. The summary is below.") \
.format(pool=f"{pool.get_letter_display()}{r.number}")
self.tournament.draw.last_message = msg
await self.tournament.draw.asave()
@ -784,8 +829,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Notify the team that it can draw a dice
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify',
'title': "À votre tour !",
'body': "C'est à vous de lancer le dé !"})
'title': _("Your turn!"),
'body': _("It's your turn to launch the dice!")})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.dice_visibility',
@ -801,11 +846,11 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
"""
msg = self.tournament.draw.last_message
if r.number == 1 and not self.tournament.final:
if r.number < settings.NB_ROUNDS and not self.tournament.final and settings.TFJM_APP == "TFJM":
# Next round
r2 = await self.tournament.draw.round_set.filter(number=2).aget()
self.tournament.draw.current_round = r2
msg += "<br><br>Le tirage au sort du tour 1 est terminé."
next_round = await self.tournament.draw.round_set.filter(number=r.number + 1).aget()
self.tournament.draw.current_round = next_round
msg += "<br><br>" + _("The draw of the round {round} is ended.").format(round=r.number)
self.tournament.draw.last_message = msg
await self.tournament.draw.asave()
@ -818,26 +863,26 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Notify the team that it can draw a dice
await self.channel_layer.group_send(f"team-{participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify',
'title': "À votre tour !",
'body': "C'est à vous de lancer le dé !"})
'title': _("Your turn!"),
'body': _("It's your turn to launch the dice!")})
# Reorder dices
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.send_poules',
'round': r2.number,
'round': next_round.number,
'poules': [
{
'letter': pool.get_letter_display(),
'teams': await pool.atrigrams(),
}
async for pool in r2.pool_set.order_by('letter').all()
async for pool in next_round.pool_set.order_by('letter').all()
]})
# The passage order for the second round is already determined by the first round
# Start the first pool of the second round
p1: Pool = await r2.pool_set.filter(letter=1).aget()
r2.current_pool = p1
await r2.asave()
p1: Pool = await next_round.pool_set.filter(letter=1).aget()
next_round.current_pool = p1
await next_round.asave()
async for td in p1.teamdraw_set.prefetch_related('participation__team').all():
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
@ -846,9 +891,9 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.dice_visibility',
'visible': True})
elif r.number == 1 and self.tournament.final:
elif r.number == 1 and (self.tournament.final or not settings.HAS_FINAL):
# For the final tournament, we wait for a manual update between the two rounds.
msg += "<br><br>Le tirage au sort du tour 1 est terminé."
msg += "<br><br>" + _("The draw of the first round is ended.")
self.tournament.draw.last_message = msg
await self.tournament.draw.asave()
@ -877,7 +922,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
.prefetch_related('team').aget()
# Ensure that the user can reject a problem at this time
if participation.id != td.participation_id:
return await self.alert("This is not your turn.", 'danger')
return await self.alert(_("This is not your turn."), 'danger')
# Add the problem to the rejected problems list
problem = td.purposed
@ -887,19 +932,20 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
td.purposed = None
await td.asave()
remaining = len(settings.PROBLEMS) - 5 - len(td.rejected)
remaining = len(settings.PROBLEMS) - settings.RECOMMENDED_SOLUTIONS_COUNT - len(td.rejected)
# Update messages
trigram = td.participation.team.trigram
msg = f"L'équipe <strong>{trigram}</strong> a refusé le problème <strong>{problem} : " \
f"{settings.PROBLEMS[problem - 1]}</strong>. "
msg = _("The team <strong>{trigram}</strong> refused the problem <strong>{problem}</strong>: "
"{problem_name}.").format(trigram=trigram, problem=problem,
problem_name=settings.PROBLEMS[problem - 1]) + " "
if remaining >= 0:
msg += f"Il lui reste {remaining} refus sans pénalité."
msg += _("It remains {remaining} refusals without penalty.").format(remaining=remaining)
else:
if already_refused:
msg += "Cela n'ajoute pas de pénalité."
msg += _("This problem was already refused by this team.")
else:
msg += "Cela ajoute une pénalité de 0.5 sur le coefficient de l'oral de la défense."
msg += _("It adds a 25% penalty on the coefficient of the oral defense.")
self.tournament.draw.last_message = msg
await self.tournament.draw.asave()
@ -942,8 +988,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Notify the team that it can draw a problem
await self.channel_layer.group_send(f"team-{new_trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify',
'title': "À votre tour !",
'body': "C'est à vous de tirer un nouveau problème !"})
'title': _("Your turn!"),
'body': _("It's your turn to draw a problem!")})
@ensure_orga
async def export(self, **kwargs):
@ -953,15 +999,19 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
if not await Draw.objects.filter(tournament=self.tournament).aexists():
return await self.alert(_("The draw has not started yet."), 'danger')
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.export_visibility',
'visible': False})
# Export each exportable pool
async for r in self.tournament.draw.round_set.all():
async for pool in r.pool_set.all():
if await pool.is_exportable():
await pool.export()
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.export_visibility',
'visible': False})
# Update Google Sheets final sheet
if os.getenv('GOOGLE_PRIVATE_KEY_ID', None):
await sync_to_async(self.tournament.update_ranking_spreadsheet)()
@ensure_orga
async def continue_final(self, **kwargs):
@ -971,43 +1021,49 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
if not await Draw.objects.filter(tournament=self.tournament).aexists():
return await self.alert(_("The draw has not started yet."), 'danger')
if not self.tournament.final:
if not self.tournament.final and settings.TFJM_APP == "TFJM":
return await self.alert(_("This is only available for the final tournament."), 'danger')
r2 = await self.tournament.draw.round_set.filter(number=2).aget()
r2 = await self.tournament.draw.round_set.filter(number=self.tournament.draw.current_round.number + 1).aget()
self.tournament.draw.current_round = r2
msg = "Le tirage au sort pour le tour 2 va commencer. " \
"L'ordre de passage est déterminé à partir du classement du premier tour."
if settings.TFJM_APP == "TFJM":
msg = str(_("The draw of the round {round} is starting. "
"The passage order is determined from the ranking of the first round, "
"in order to mix the teams between the two days.").format(round=r2.number))
else:
msg = str(_("The draw of the round {round} is starting. "
"The passage order is another time randomly drawn.").format(round=r2.number))
self.tournament.draw.last_message = msg
await self.tournament.draw.asave()
# Send notification to everyone
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.notify',
'title': 'Tirage au sort du TFJM²',
'body': "Le tirage au sort pour le second tour de la finale a commencé !"})
'title': _("Draw") + " " + settings.APP_NAME,
'body': str(_("The draw of the second round is starting!"))})
# Set the first pool of the second round as the active pool
pool = await Pool.objects.filter(round=self.tournament.draw.current_round, letter=1).aget()
r2.current_pool = pool
await r2.asave()
if settings.TFJM_APP == "TFJM":
# Set the first pool of the second round as the active pool
pool = await Pool.objects.filter(round=self.tournament.draw.current_round, letter=1).aget()
r2.current_pool = pool
await r2.asave()
# Fetch notes from the first round
notes = dict()
async for participation in self.tournament.participations.filter(valid=True).prefetch_related('team').all():
notes[participation] = sum([await pool.aaverage(participation)
async for pool in self.tournament.pools.filter(participations=participation)
.prefetch_related('passages')])
# Sort notes in a decreasing order
ordered_participations = sorted(notes.keys(), key=lambda x: -notes[x])
# Define pools and passage orders from the ranking of the first round
async for pool in r2.pool_set.order_by('letter').all():
for i in range(pool.size):
participation = ordered_participations.pop(0)
td = await TeamDraw.objects.aget(round=r2, participation=participation)
td.pool = pool
td.passage_index = i
await td.asave()
# Fetch notes from the first round
notes = dict()
async for participation in self.tournament.participations.filter(valid=True).prefetch_related('team').all():
notes[participation] = sum([await pool.aaverage(participation)
async for pool in self.tournament.pools.filter(participations=participation)
.prefetch_related('passages')])
# Sort notes in a decreasing order
ordered_participations = sorted(notes.keys(), key=lambda x: -notes[x])
# Define pools and passage orders from the ranking of the first round
async for pool in r2.pool_set.order_by('letter').all():
for i in range(pool.size):
participation = ordered_participations.pop(0)
td = await TeamDraw.objects.aget(round=r2, participation=participation)
td.pool = pool
td.passage_index = i
await td.asave()
# Send pools to users
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
@ -1027,16 +1083,22 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.dice', 'team': participation.team.trigram, 'result': None})
async for td in r2.current_pool.team_draws.prefetch_related('participation__team'):
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.dice_visibility',
'visible': True})
if settings.TFJM_APP == "TFJM":
async for td in r2.current_pool.team_draws.prefetch_related('participation__team'):
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.dice_visibility',
'visible': True})
# Notify the team that it can draw a problem
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify',
'title': "À votre tour !",
'body': "C'est à vous de tirer un nouveau problème !"})
# Notify the team that it can draw a problem
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify',
'title': _("Your turn!"),
'body': _("It's your turn to draw a problem!")})
else:
async for td in r2.team_draws.prefetch_related('participation__team'):
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.dice_visibility',
'visible': True})
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.dice_visibility',
@ -1051,7 +1113,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.set_active',
'round': r2.number,
'pool': r2.current_pool.get_letter_display()})
'pool': r2.current_pool.get_letter_display() if r2.current_pool else None})
@ensure_orga
async def cancel_last_step(self, **kwargs):
@ -1325,32 +1387,21 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
'round': r.number,
'team': td.participation.team.trigram,
'problem': td.accepted})
elif r.number == 2:
elif r.number >= 2 and settings.TFJM_APP == "TFJM":
if not self.tournament.final:
# Go to the previous round
r1 = await self.tournament.draw.round_set \
.prefetch_related('current_pool__current_team__participation__team').aget(number=1)
self.tournament.draw.current_round = r1
previous_round = await self.tournament.draw.round_set \
.prefetch_related('current_pool__current_team__participation__team').aget(number=r.number - 1)
self.tournament.draw.current_round = previous_round
await self.tournament.draw.asave()
async for td in r1.team_draws.prefetch_related('participation__team').all():
async for td in previous_round.team_draws.prefetch_related('participation__team').all():
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}", {'tid': self.tournament_id, 'type': 'draw.dice',
'team': td.participation.team.trigram,
'result': td.choice_dice})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.send_poules',
'round': r1.number,
'poules': [
{
'letter': pool.get_letter_display(),
'teams': await pool.atrigrams(),
}
async for pool in r1.pool_set.order_by('letter').all()
]})
previous_pool = r1.current_pool
previous_pool = previous_round.current_pool
td = previous_pool.current_team
td.purposed = td.accepted
@ -1370,14 +1421,14 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.set_problem',
'round': r1.number,
'round': previous_round.number,
'team': td.participation.team.trigram,
'problem': td.accepted})
else:
# Don't continue the final tournament
r1 = await self.tournament.draw.round_set \
previous_round = await self.tournament.draw.round_set \
.prefetch_related('current_pool__current_team__participation__team').aget(number=1)
self.tournament.draw.current_round = r1
self.tournament.draw.current_round = previous_round
await self.tournament.draw.asave()
async for td in r.teamdraw_set.all():
@ -1399,7 +1450,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
]
})
async for td in r1.team_draws.prefetch_related('participation__team').all():
async for td in previous_round.team_draws.prefetch_related('participation__team').all():
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}", {'tid': self.tournament_id, 'type': 'draw.dice',
'team': td.participation.team.trigram,
@ -1413,17 +1464,31 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
'visible': True})
else:
# Go to the dice order
async for r0 in self.tournament.draw.round_set.all():
async for td in r0.teamdraw_set.all():
td.pool = None
td.passage_index = None
td.choose_index = None
td.choice_dice = None
await td.asave()
async for td in r.teamdraw_set.all():
td.pool = None
td.passage_index = None
td.choose_index = None
td.choice_dice = None
await td.asave()
r.current_pool = None
await r.asave()
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}",
{
'tid': self.tournament_id,
'type': 'draw.send_poules',
'round': r.number,
'poules': [
{
'letter': pool.get_letter_display(),
'teams': await pool.atrigrams(),
}
async for pool in r.pool_set.order_by('letter').all()
]
})
round_tds = {td.id: td async for td in r.team_draws.prefetch_related('participation__team')}
# Reset the last dice
@ -1493,8 +1558,45 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
'team': last_td.participation.team.trigram,
'result': None})
break
else:
elif r.number == 1:
# Cancel the draw if it is the first round
await self.abort()
else:
# Go back to the first round after resetting all
previous_round = await self.tournament.draw.round_set \
.prefetch_related('current_pool__current_team__participation__team').aget(number=r.number - 1)
self.tournament.draw.current_round = previous_round
await self.tournament.draw.asave()
async for td in previous_round.team_draws.prefetch_related('participation__team').all():
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}", {'tid': self.tournament_id, 'type': 'draw.dice',
'team': td.participation.team.trigram,
'result': td.choice_dice})
previous_pool = previous_round.current_pool
td = previous_pool.current_team
td.purposed = td.accepted
td.accepted = None
await td.asave()
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.dice_visibility',
'visible': False})
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.buttons_visibility',
'visible': True})
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.buttons_visibility',
'visible': True})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.set_problem',
'round': previous_round.number,
'team': td.participation.team.trigram,
'problem': td.accepted})
async def draw_alert(self, content):
"""

View File

@ -0,0 +1,28 @@
# Generated by Django 5.0.3 on 2024-04-22 22:11
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("draw", "0002_alter_teamdraw_purposed"),
]
operations = [
migrations.AlterModelOptions(
name="teamdraw",
options={
"ordering": (
"round__draw__tournament__name",
"round__number",
"pool__letter",
"passage_index",
"choice_dice",
"passage_dice",
),
"verbose_name": "team draw",
"verbose_name_plural": "team draws",
},
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 5.0.6 on 2024-06-07 12:46
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("draw", "0003_alter_teamdraw_options"),
]
operations = [
migrations.AlterField(
model_name="round",
name="number",
field=models.PositiveSmallIntegerField(
choices=[(1, "Round 1"), (2, "Round 2")],
help_text="The number of the round, 1 or 2 (or 3 for ETEAM)",
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(2),
],
verbose_name="number",
),
),
]

View File

@ -0,0 +1,69 @@
# Generated by Django 5.0.6 on 2024-06-13 08:53
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("draw", "0004_alter_round_number"),
]
operations = [
migrations.AlterField(
model_name="round",
name="number",
field=models.PositiveSmallIntegerField(
choices=[(1, "Round 1"), (2, "Round 2"), (3, "Round 3")],
help_text="The number of the round, 1 or 2 (or 3 for ETEAM)",
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(3),
],
verbose_name="number",
),
),
migrations.AlterField(
model_name="teamdraw",
name="accepted",
field=models.PositiveSmallIntegerField(
choices=[
(1, "Problem #1"),
(2, "Problem #2"),
(3, "Problem #3"),
(4, "Problem #4"),
(5, "Problem #5"),
(6, "Problem #6"),
(7, "Problem #7"),
(8, "Problem #8"),
(9, "Problem #9"),
(10, "Problem #10"),
],
default=None,
null=True,
verbose_name="accepted problem",
),
),
migrations.AlterField(
model_name="teamdraw",
name="purposed",
field=models.PositiveSmallIntegerField(
choices=[
(1, "Problem #1"),
(2, "Problem #2"),
(3, "Problem #3"),
(4, "Problem #4"),
(5, "Problem #5"),
(6, "Problem #6"),
(7, "Problem #7"),
(8, "Problem #8"),
(9, "Problem #9"),
(10, "Problem #10"),
],
default=None,
null=True,
verbose_name="purposed problem",
),
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 5.0.6 on 2024-07-09 11:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("draw", "0005_alter_round_number_alter_teamdraw_accepted_and_more"),
]
operations = [
migrations.AlterField(
model_name="round",
name="current_pool",
field=models.ForeignKey(
default=None,
help_text="The current pool where teams select their problems.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="draw.pool",
verbose_name="current pool",
),
),
]

View File

@ -1,8 +1,11 @@
# Copyright (C) 2023 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from asgiref.sync import sync_to_async
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import QuerySet
@ -79,7 +82,7 @@ class Draw(models.Model):
elif self.current_round.current_pool.current_team is None:
return 'DICE_ORDER_POULE'
elif self.current_round.current_pool.current_team.accepted is not None:
if self.current_round.number == 1:
if self.current_round.number < settings.NB_ROUNDS:
# The last step can be the last problem acceptation after the first round
# only for the final between the two rounds
return 'WAITING_FINAL'
@ -89,6 +92,7 @@ class Draw(models.Model):
return 'WAITING_DRAW_PROBLEM'
else:
return 'WAITING_CHOOSE_PROBLEM'
get_state.short_description = _('State')
@property
def information(self):
@ -107,58 +111,61 @@ class Draw(models.Model):
# Waiting for dices to determine pools and passage order
if self.current_round.number == 1:
# Specific information for the first round
s += """Nous allons commencer le tirage des problèmes.<br>
Vous pouvez à tout moment poser toute question si quelque chose
n'est pas clair ou ne va pas.<br><br>
Nous allons d'abord tirer les poules et l'ordre de passage
pour le premier tour avec toutes les équipes puis pour chaque poule,
nous tirerons l'ordre de tirage pour le tour et les problèmes.<br><br>"""
s += """
Les capitaines, vous pouvez désormais toustes lancer un dé 100,
en cliquant sur le gros bouton. Les poules et l'ordre de passage
lors du premier tour sera l'ordre croissant des dés, c'est-à-dire
que le plus petit lancer sera le premier à passer dans la poule A."""
s += _("We are going to start the problem draw.<br>"
"You can ask any question if something is not clear or wrong.<br><br>"
"We are going to first draw the pools and the passage order for the first round "
"with all the teams, then for each pool, we will draw the draw order and the problems.")
s += "<br><br>"
s += _("The captains, you can now all throw a 100-sided dice, by clicking on the big dice button. "
"The pools and the passage order during the first round will be the increasing order "
"of the dices, ie. the smallest dice will be the first to pass in pool A.")
case 'DICE_ORDER_POULE':
# Waiting for dices to determine the choice order
s += f"""Nous passons au tirage des problèmes pour la poule
<strong>{self.current_round.current_pool}</strong>, entre les équipes
<strong>{', '.join(td.participation.team.trigram
for td in self.current_round.current_pool.teamdraw_set.all())}</strong>.
Les capitaines peuvent lancer un dé 100 en cliquant sur le gros bouton
pour déterminer l'ordre de tirage. L'équipe réalisant le plus gros score pourra
tirer en premier."""
s += _("We are going to start the problem draw for the pool <strong>{pool}</strong>, "
"between the teams <strong>{teams}</strong>. "
"The captains can throw a 100-sided dice by clicking on the big dice button "
"to determine the order of draw. The team with the highest score will draw first.") \
.format(pool=self.current_round.current_pool,
teams=', '.join(td.participation.team.trigram
for td in self.current_round.current_pool.teamdraw_set.all()))
case 'WAITING_DRAW_PROBLEM':
# Waiting for a problem draw
td = self.current_round.current_pool.current_team
s += f"""C'est au tour de l'équipe <strong>{td.participation.team.trigram}</strong>
de choisir son problème. Cliquez sur l'urne au milieu pour tirer un problème au sort."""
s += _("The team <strong>{trigram}</strong> is going to draw a problem. "
"Click on the urn in the middle to draw a problem.") \
.format(trigram=td.participation.team.trigram)
case 'WAITING_CHOOSE_PROBLEM':
# Waiting for the team that can accept or reject the problem
td = self.current_round.current_pool.current_team
s += f"""L'équipe <strong>{td.participation.team.trigram}</strong> a tiré le problème
<strong>{td.purposed} : {settings.PROBLEMS[td.purposed - 1]}</strong>. """
s += _("The team <strong>{trigram}</strong> drew the problem <strong>{problem}: "
"{problem_name}</strong>.") \
.format(trigram=td.participation.team.trigram,
problem=td.purposed, problem_name=settings.PROBLEMS[td.purposed - 1]) + " "
if td.purposed in td.rejected:
# The problem was previously rejected
s += """Elle a déjà refusé ce problème auparavant, elle peut donc le refuser sans pénalité et
tirer un nouveau problème immédiatement, ou bien revenir sur son choix."""
s += _("It already refused this problem before, so it can refuse it without penalty and "
"draw a new problem immediately, or change its mind.")
else:
# The problem can be rejected
s += "Elle peut décider d'accepter ou de refuser ce problème. "
if len(td.rejected) >= len(settings.PROBLEMS) - 5:
s += "Refuser ce problème ajoutera une nouvelle pénalité de 0.5 sur le coefficient de l'oral de la défense."
s += _("It can decide to accept or refuse this problem.") + " "
if len(td.rejected) >= len(settings.PROBLEMS) - settings.RECOMMENDED_SOLUTIONS_COUNT:
s += _("Refusing this problem will add a new 25% penalty "
"on the coefficient of the oral defense.")
else:
s += f"Il reste {len(settings.PROBLEMS) - 5 - len(td.rejected)} refus sans pénalité."
s += _("There are still {remaining} refusals without penalty.").format(
remaining=len(settings.PROBLEMS) - settings.RECOMMENDED_SOLUTIONS_COUNT - len(td.rejected))
case 'WAITING_FINAL':
# We are between the two rounds of the final tournament
s += "Le tirage au sort pour le tour 2 aura lieu à la fin du premier tour. Bon courage !"
s += _("The draw for the second round will take place at the end of the first round. Good luck!")
case 'DRAW_ENDED':
# The draw is ended
s += "Le tirage au sort est terminé. Les solutions des autres équipes peuvent être trouvées dans l'onglet « Ma participation »."
s += _("The draw is ended. The solutions of the other teams can be found in the tab "
"\"My participation\".")
s += "<br><br>" if s else ""
s += """Pour plus de détails sur le déroulement du tirage au sort,
le règlement est accessible sur
<a class="alert-link" href="https://tfjm.org/reglement">https://tfjm.org/reglement</a>."""
rules_link = settings.RULES_LINK
s += _("For more details on the draw, the rules are available on "
"<a class=\"alert-link\" href=\"{link}\">{link}</a>.").format(link=rules_link)
return s
async def ainformation(self) -> str:
@ -190,15 +197,15 @@ class Round(models.Model):
choices=[
(1, _('Round 1')),
(2, _('Round 2')),
],
(3, _('Round 3'))],
verbose_name=_('number'),
help_text=_("The number of the round, 1 or 2"),
validators=[MinValueValidator(1), MaxValueValidator(2)],
help_text=_("The number of the round, 1 or 2 (or 3 for ETEAM)"),
validators=[MinValueValidator(1), MaxValueValidator(3)],
)
current_pool = models.ForeignKey(
'Pool',
on_delete=models.CASCADE,
on_delete=models.SET_NULL,
null=True,
default=None,
related_name='+',
@ -227,6 +234,13 @@ class Round(models.Model):
def __str__(self):
return self.get_number_display()
def clean(self):
if self.number is not None and self.number > settings.NB_ROUNDS:
raise ValidationError({'number': _("The number of the round must be between 1 and {nb}.")
.format(nb=settings.NB_ROUNDS)})
return super().clean()
class Meta:
verbose_name = _('round')
verbose_name_plural = _('rounds')
@ -289,7 +303,7 @@ class Pool(models.Model):
"""
Returns a query set ordered by passage index of all team draws in this pool.
"""
return self.teamdraw_set.order_by('passage_index').all()
return self.teamdraw_set.all()
@property
def trigrams(self) -> list[str]:
@ -346,7 +360,7 @@ class Pool(models.Model):
Translates this Pool instance in a :model:`participation.Pool` instance, with the passage orders.
"""
# Create the pool
self.associated_pool = await PPool.objects.acreate(
self.associated_pool, _created = await PPool.objects.aget_or_create(
tournament=self.round.draw.tournament,
round=self.round.number,
letter=self.letter,
@ -358,6 +372,17 @@ class Pool(models.Model):
.prefetch_related('participation')])
await self.asave()
pool2 = None
if self.size == 5:
pool2, _created = await PPool.objects.aget_or_create(
tournament=self.round.draw.tournament,
round=self.round.number,
letter=self.letter,
room=2,
)
await pool2.participations.aset([td.participation async for td in self.team_draws
.prefetch_related('participation')])
# Define the passage matrix according to the number of teams
table = []
if self.size == 3:
@ -375,28 +400,42 @@ class Pool(models.Model):
]
elif self.size == 5:
table = [
[0, 2, 3],
[1, 3, 4],
[2, 0, 1],
[3, 4, 0],
[4, 1, 2],
[0, 2, 3, 4],
[1, 3, 4, 0],
[2, 4, 0, 1],
[3, 0, 1, 2],
[4, 1, 2, 3],
]
for i, line in enumerate(table):
passage_pool = self.associated_pool
passage_position = i + 1
if self.size == 5:
# In 5-teams pools, we may create some passages in the second room
if i % 2 == 1:
passage_pool = pool2
passage_position = 1 + i // 2
reporter = tds[line[0]].participation
opponent = tds[line[1]].participation
reviewer = tds[line[2]].participation
observer = tds[line[3]].participation if self.size >= 4 and settings.HAS_OBSERVER else None
# Create the passage
passage = await Passage.objects.acreate(
pool=self.associated_pool,
position=i + 1,
await Passage.objects.acreate(
pool=passage_pool,
position=passage_position,
solution_number=tds[line[0]].accepted,
defender=tds[line[0]].participation,
opponent=tds[line[1]].participation,
reporter=tds[line[2]].participation,
defender_penalties=tds[line[0]].penalty_int,
reporter=reporter,
opponent=opponent,
reviewer=reviewer,
observer=observer,
reporter_penalties=tds[line[0]].penalty_int,
)
if self.size == 4:
# Add observer for 4-teams pools
passage.observer = tds[line[3]].participation
await passage.asave()
# Update Google Sheets
if os.getenv('GOOGLE_PRIVATE_KEY_ID', None):
await sync_to_async(self.associated_pool.update_spreadsheet)()
return self.associated_pool
@ -502,17 +541,17 @@ class TeamDraw(models.Model):
@property
def penalty_int(self):
"""
The number of penalties, which is the number of rejected problems after the P - 5 free rejects,
where P is the number of problems.
The number of penalties, which is the number of rejected problems after the P - 5 free rejects
(P - 6 for ETEAM), where P is the number of problems.
"""
return max(0, len(self.rejected) - (len(settings.PROBLEMS) - 5))
return max(0, len(self.rejected) - (len(settings.PROBLEMS) - settings.RECOMMENDED_SOLUTIONS_COUNT))
@property
def penalty(self):
"""
The penalty multiplier on the defender oral, which is a malus of 0.5 for each penalty.
The penalty multiplier on the reporter oral, in percentage, which is a malus of 25% for each penalty.
"""
return 0.5 * self.penalty_int
return 25 * self.penalty_int
def __str__(self):
return str(format_lazy(_("Draw of the team {trigram} for the pool {letter}{number}"),
@ -523,4 +562,5 @@ class TeamDraw(models.Model):
class Meta:
verbose_name = _('team draw')
verbose_name_plural = _('team draws')
ordering = ('round__draw__tournament__name', 'round__number', 'pool__letter', 'passage_index',)
ordering = ('round__draw__tournament__name', 'round__number', 'pool__letter', 'passage_index',
'choice_dice', 'passage_dice',)

View File

@ -1,10 +0,0 @@
# Copyright (C) 2023 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path
from . import consumers
websocket_urlpatterns = [
path("ws/draw/", consumers.DrawConsumer.as_asgi()),
]

View File

@ -4,6 +4,9 @@
await Notification.requestPermission()
})()
const TFJM = JSON.parse(document.getElementById('TFJM_settings').textContent)
const RECOMMENDED_SOLUTIONS_COUNT = TFJM.RECOMMENDED_SOLUTIONS_COUNT
const problems_count = JSON.parse(document.getElementById('problems_count').textContent)
const tournaments = JSON.parse(document.getElementById('tournaments_list').textContent)
@ -40,6 +43,20 @@ function drawDice(tid, trigram = null, result = null) {
socket.send(JSON.stringify({'tid': tid, 'type': 'dice', 'trigram': trigram, 'result': result}))
}
/**
* Fetch the requested dice from the buttons and request to draw it.
* Only available for debug purposes and for admins.
* @param tid The tournament id
*/
function drawDebugDice(tid) {
let dice_10 = parseInt(document.querySelector(`input[name="debug-dice-${tid}-10"]:checked`).value)
let dice_1 = parseInt(document.querySelector(`input[name="debug-dice-${tid}-1"]:checked`).value)
let result = (dice_10 + dice_1) || 100
let team_div = document.querySelector(`div[id="dices-${tid}"] > div > div[class*="text-bg-warning"]`)
let team = team_div.getAttribute("data-team")
drawDice(tid, team, result)
}
/**
* Request to draw a new problem.
* @param tid The tournament id
@ -203,6 +220,14 @@ document.addEventListener('DOMContentLoaded', () => {
elem.classList.add('text-bg-success')
elem.innerText = `${trigram} 🎲 ${result}`
}
let nextTeam = document.querySelector(` div[id="dices-${tid}"] > div > div[class*="text-bg-warning"]`).getAttribute("data-team")
if (nextTeam) {
// If there is one team that does not have launched its dice, then we update the debug section
let debugSpan = document.getElementById(`debug-dice-${tid}-team`)
if (debugSpan)
debugSpan.innerText = nextTeam
}
}
/**
@ -212,10 +237,15 @@ document.addEventListener('DOMContentLoaded', () => {
*/
function updateDiceVisibility(tid, visible) {
let div = document.getElementById(`launch-dice-${tid}`)
if (visible)
let div_debug = document.getElementById(`debug-dice-form-${tid}`)
if (visible) {
div.classList.remove('d-none')
else
div_debug.classList.remove('d-none')
}
else {
div.classList.add('d-none')
div_debug.classList.add('d-none')
}
}
/**
@ -225,10 +255,15 @@ document.addEventListener('DOMContentLoaded', () => {
*/
function updateBoxVisibility(tid, visible) {
let div = document.getElementById(`draw-problem-${tid}`)
if (visible)
let div_debug = document.getElementById(`debug-problem-form-${tid}`)
if (visible) {
div.classList.remove('d-none')
else
div_debug.classList.remove('d-none')
}
else {
div.classList.add('d-none')
div_debug.classList.add('d-none')
}
}
/**
@ -276,7 +311,7 @@ document.addEventListener('DOMContentLoaded', () => {
/**
* Set the different pools for the given round, and update the interface.
* @param tid The tournament id
* @param round The round number, as integer (1 or 2)
* @param round The round number, as integer (1 or 2, or 3 for ETEAM)
* @param poules The list of poules, which are represented with their letters and trigrams,
* [{'letter': 'A', 'teams': ['ABC', 'DEF', 'GHI']}]
*/
@ -398,7 +433,7 @@ document.addEventListener('DOMContentLoaded', () => {
/**
* Update the table for the given round and the given pool, where there will be the chosen problems.
* @param tid The tournament id
* @param round The round number, as integer (1 or 2)
* @param round The round number, as integer (1 or 2, or 3 for ETEAM)
* @param poule The current pool, which id represented with its letter and trigrams,
* {'letter': 'A', 'teams': ['ABC', 'DEF', 'GHI']}
*/
@ -486,45 +521,45 @@ document.addEventListener('DOMContentLoaded', () => {
teamTd.innerText = team
teamTr.append(teamTd)
let defenderTd = document.createElement('td')
defenderTd.classList.add('text-center')
defenderTd.innerText = 'Déf'
let reporterTd = document.createElement('td')
reporterTd.classList.add('text-center')
reporterTd.innerText = 'Déf'
let opponentTd = document.createElement('td')
opponentTd.classList.add('text-center')
opponentTd.innerText = 'Opp'
let reporterTd = document.createElement('td')
reporterTd.classList.add('text-center')
reporterTd.innerText = 'Rap'
let reviewerTd = document.createElement('td')
reviewerTd.classList.add('text-center')
reviewerTd.innerText = 'Rap'
// Put the cells in their right places, according to the pool size and the row number.
if (poule.teams.length === 3) {
switch (i) {
case 0:
teamTr.append(defenderTd, reporterTd, opponentTd)
teamTr.append(reporterTd, reviewerTd, opponentTd)
break
case 1:
teamTr.append(opponentTd, defenderTd, reporterTd)
teamTr.append(opponentTd, reporterTd, reviewerTd)
break
case 2:
teamTr.append(reporterTd, opponentTd, defenderTd)
teamTr.append(reviewerTd, opponentTd, reporterTd)
break
}
} else if (poule.teams.length === 4) {
let emptyTd = document.createElement('td')
switch (i) {
case 0:
teamTr.append(defenderTd, emptyTd, reporterTd, opponentTd)
teamTr.append(reporterTd, emptyTd, reviewerTd, opponentTd)
break
case 1:
teamTr.append(opponentTd, defenderTd, emptyTd, reporterTd)
teamTr.append(opponentTd, reporterTd, emptyTd, reviewerTd)
break
case 2:
teamTr.append(reporterTd, opponentTd, defenderTd, emptyTd)
teamTr.append(reviewerTd, opponentTd, reporterTd, emptyTd)
break
case 3:
teamTr.append(emptyTd, reporterTd, opponentTd, defenderTd)
teamTr.append(emptyTd, reviewerTd, opponentTd, reporterTd)
break
}
} else if (poule.teams.length === 5) {
@ -532,19 +567,19 @@ document.addEventListener('DOMContentLoaded', () => {
let emptyTd2 = document.createElement('td')
switch (i) {
case 0:
teamTr.append(defenderTd, emptyTd, opponentTd, reporterTd, emptyTd2)
teamTr.append(reporterTd, emptyTd, opponentTd, reviewerTd, emptyTd2)
break
case 1:
teamTr.append(emptyTd, defenderTd, reporterTd, emptyTd2, opponentTd)
teamTr.append(emptyTd, reporterTd, reviewerTd, emptyTd2, opponentTd)
break
case 2:
teamTr.append(opponentTd, emptyTd, defenderTd, emptyTd2, reporterTd)
teamTr.append(opponentTd, emptyTd, reporterTd, emptyTd2, reviewerTd)
break
case 3:
teamTr.append(reporterTd, opponentTd, emptyTd, defenderTd, emptyTd2)
teamTr.append(reviewerTd, opponentTd, emptyTd, reporterTd, emptyTd2)
break
case 4:
teamTr.append(emptyTd, reporterTd, emptyTd2, opponentTd, defenderTd)
teamTr.append(emptyTd, reviewerTd, emptyTd2, opponentTd, reporterTd)
break
}
}
@ -555,7 +590,7 @@ document.addEventListener('DOMContentLoaded', () => {
/**
* Highlight the team that is currently choosing its problem.
* @param tid The tournament id
* @param round The current round number, as integer (1 or 2)
* @param round The current round number, as integer (1 or 2, or 3 for ETEAM)
* @param pool The current pool letter (A, B, C or D) (null if non-relevant)
* @param team The current team trigram (null if non-relevant)
*/
@ -582,12 +617,17 @@ document.addEventListener('DOMContentLoaded', () => {
let teamLi = document.getElementById(`recap-${tid}-round-${round}-team-${team}`)
if (teamLi !== null)
teamLi.classList.add('list-group-item-info')
let debugSpan = document.getElementById(`debug-problem-${tid}-team`)
if (debugSpan && team) {
debugSpan.innerText = team
}
}
/**
* Update the recap and the table when a team accepts a problem.
* @param tid The tournament id
* @param round The current round, as integer (1 or 2)
* @param round The current round, as integer (1 or 2, or 3 for ETEAM)
* @param team The current team trigram
* @param problem The accepted problem, as integer
*/
@ -611,7 +651,7 @@ document.addEventListener('DOMContentLoaded', () => {
/**
* Update the recap when a team rejects a problem.
* @param tid The tournament id
* @param round The current round, as integer (1 or 2)
* @param round The current round, as integer (1 or 2, or 3 for ETEAM)
* @param team The current team trigram
* @param rejected The full list of rejected problems
*/
@ -621,15 +661,16 @@ document.addEventListener('DOMContentLoaded', () => {
recapDiv.textContent = `🗑️ ${rejected.join(', ')}`
let penaltyDiv = document.getElementById(`recap-${tid}-round-${round}-team-${team}-penalty`)
if (rejected.length > problems_count - 5) {
// If more than P - 5 problems were rejected, add a penalty of 0.5 of the coefficient of the oral defender
if (rejected.length > problems_count - RECOMMENDED_SOLUTIONS_COUNT) {
// If more than P - 5 problems were rejected, add a penalty of 25% of the coefficient of the oral reporter
// This is P - 6 for the ETEAM
if (penaltyDiv === null) {
penaltyDiv = document.createElement('div')
penaltyDiv.id = `recap-${tid}-round-${round}-team-${team}-penalty`
penaltyDiv.classList.add('badge', 'rounded-pill', 'text-bg-info')
recapDiv.parentNode.append(penaltyDiv)
}
penaltyDiv.textContent = `${0.5 * (rejected.length - (problems_count - 5))}`
penaltyDiv.textContent = `${25 * (rejected.length - (problems_count - RECOMMENDED_SOLUTIONS_COUNT))} %`
} else {
// Eventually remove this div
if (penaltyDiv !== null)
@ -641,7 +682,7 @@ document.addEventListener('DOMContentLoaded', () => {
* For a 5-teams pool, we may reorder the pool if two teams select the same problem.
* Then, we redraw the table and set the accepted problems.
* @param tid The tournament id
* @param round The current round, as integer (1 or 2)
* @param round The current round, as integer (1 or 2, or 3 for ETEAM)
* @param poule The pool represented by its letter
* @param teams The teams list represented by their trigrams, ["ABC", "DEF", "GHI", "JKL", "MNO"]
* @param problems The accepted problems in the same order than the teams, [1, 1, 2, 2, 3]
@ -659,6 +700,9 @@ document.addEventListener('DOMContentLoaded', () => {
let problem = problems[i]
setProblemAccepted(tid, round, team, problem)
let recapTeam = document.getElementById(`recap-${tid}-round-${round}-team-${team}`)
recapTeam.style.order = i.toString()
}
}
@ -736,7 +780,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
function setupSocket() {
function setupSocket(nextDelay = 1000) {
// Open a global websocket
socket = new WebSocket(
(document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/draw/'
@ -753,7 +797,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Manage errors
socket.addEventListener('close', e => {
console.error('Chat socket closed unexpectedly, restarting…')
setupSocket()
setTimeout(() => setupSocket(2 * nextDelay), nextDelay)
})
// When the socket is opened, set the language in order to receive alerts in the good language

View File

@ -2,6 +2,7 @@
{% load static %}
{% load i18n %}
{% load pipeline %}
{% block content %}
{# The navbar to select the tournament #}
@ -40,5 +41,5 @@
{{ problems|length|json_script:'problems_count' }}
{# This script contains all data for the draw management #}
<script src="{% static 'draw.js' %}"></script>
{% javascript 'draw' %}
{% endblock %}

View File

@ -37,6 +37,7 @@
{% for td in tournament.draw.current_round.team_draws %}
<div class="col-md-1" style="order: {{ forloop.counter }};">
<div id="dice-{{ tournament.id }}-{{ td.participation.team.trigram }}"
data-team="{{ td.participation.team.trigram }}"
class="badge rounded-pill text-bg-{% if td.last_dice %}success{% else %}warning{% endif %}"
{% if request.user.registration.is_volunteer %}
{# Volunteers can click on dices to launch the dice of a team #}
@ -99,7 +100,7 @@
{# If needed, add the penalty of the team #}
<div id="recap-{{ tournament.id }}-round-{{ round.number }}-team-{{ td.participation.team.trigram }}-penalty"
class="badge rounded-pill text-bg-info">
❌ {{ td.penalty }}
❌ {{ td.penalty }} %
</div>
{% endif %}
</li>
@ -175,7 +176,7 @@
📁 {% trans "Export" %}
</button>
</div>
{% if tournament.final %}
{% if tournament.final or not TFJM.HAS_FINAL %}
{# Volunteers can continue the second round for the final tournament #}
<div id="continue-{{ tournament.id }}"
class="card-footer text-center{% if tournament.draw.get_state != 'WAITING_FINAL' %} d-none{% endif %}">
@ -186,6 +187,66 @@
{% endif %}
{% endif %}
</div>
{% if user.registration.is_admin %}
<div class="card my-3">
<div class="card-header">
<div style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#debug-draw-{{ tournament.id }}-body"
aria-controls="debug-draw-{{ tournament.id }}-body" aria-expanded="false">
<h4>{% trans "Debug draw" %}</h4>
</div>
</div>
<div class="card-body collapse" id="debug-draw-{{ tournament.id }}-body">
<div id="debug-dice-form-{{ tournament.id }}" {% if tournament.draw.get_state != 'DICE_SELECT_POULES' and tournament.draw.get_state != 'DICE_ORDER_POULE' %}class="d-none"{% endif %}>
<h5>
{% trans "Draw dice for" %}
<span id="debug-dice-{{ tournament.id }}-team">
{% regroup tournament.draw.current_round.team_draws by last_dice as td_dices %}
{% for group in td_dices %}
{% if group.grouper is None %}
{{ group }}
{% with group.list|first as td %}
{{ td.participation.team.trigram }}
{% endwith %}
{% endif %}
{% endfor %}
</span>
</h5>
<div class="btn-group w-100" role="group">
{% for i in range_100 %}
<input type="radio" class="btn-check" name="debug-dice-{{ tournament.id }}-10" id="debug-dice-{{ tournament.id }}-{{ i|stringformat:"02d" }}" value="{{ i }}" {% if i == 0 %}checked{% endif %}>
<label class="btn btn-outline-warning" for="debug-dice-{{ tournament.id }}-{{ i|stringformat:"02d" }}">{{ i|stringformat:"02d" }}</label>
{% endfor %}
</div>
<div class="btn-group w-100" role="group">
{% for i in range_10 %}
<input type="radio" class="btn-check" name="debug-dice-{{ tournament.id }}-1" id="debug-dice-{{ tournament.id }}-{{ i }}" value="{{ i }}" {% if i == 0 %}checked{% endif %}>
<label class="btn btn-outline-warning" for="debug-dice-{{ tournament.id }}-{{ i }}">{{ i }}</label>
{% endfor %}
</div>
<div class="my-2 text-center">
<button class="btn btn-success" onclick="drawDebugDice({{ tournament.id }})">
{% trans "Draw dice" %} 🎲
</button>
</div>
</div>
<div id="debug-problem-form-{{ tournament.id }}" {% if tournament.draw.get_state != 'WAITING_DRAW_PROBLEM' %}class="d-none"{% endif %}>
<h5>
{% trans "Draw problem for" %}
<span id="debug-problem-{{ tournament.id }}-team">{{ tournament.draw.current_round.current_pool.current_team.participation.team.trigram }}</span>
</h5>
<div class="btn-group w-100" role="group">
{% for problem in problems %}
<button class="btn btn-outline-info" id="debug-problem-{{ tournament.id }}-{{ forloop.counter }}" onclick="drawProblem({{ tournament.id }}, {{ forloop.counter }})">
{% trans "Pb." %} {{ forloop.counter }}
</button>
{% endfor %}
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
@ -246,72 +307,71 @@
<td class="text-center">{{ td.participation.team.trigram }}</td>
{% if pool.size == 3 %}
{% if forloop.counter == 1 %}
<td class="text-center">Déf</td>
<td class="text-center">Rap</td>
<td class="text-center">Opp</td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
{% elif forloop.counter == 2 %}
<td class="text-center">Opp</td>
<td class="text-center">Déf</td>
<td class="text-center">Rap</td>
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
{% elif forloop.counter == 3 %}
<td class="text-center">Rap</td>
<td class="text-center">Opp</td>
<td class="text-center">Déf</td>
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
{% endif %}
{% elif pool.size == 4 %}
{% if forloop.counter == 1 %}
<td class="text-center">Déf</td>
<td></td>
<td class="text-center">Rap</td>
<td class="text-center">Opp</td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
{% elif forloop.counter == 2 %}
<td class="text-center">Opp</td>
<td class="text-center">Déf</td>
<td></td>
<td class="text-center">Rap</td>
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
{% elif forloop.counter == 3 %}
<td class="text-center">Rap</td>
<td class="text-center">Opp</td>
<td class="text-center">Déf</td>
<td></td>
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
{% elif forloop.counter == 4 %}
<td></td>
<td class="text-center">Rap</td>
<td class="text-center">Opp</td>
<td class="text-center">Déf</td>
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
{% endif %}
{% elif pool.size == 5 %}
{% if forloop.counter == 1 %}
<td class="text-center">Déf</td>
<td></td>
<td class="text-center">Opp</td>
<td class="text-center">Rap</td>
<td></td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
<td class="text-center"></td>
{% elif forloop.counter == 2 %}
<td></td>
<td class="text-center">Déf</td>
<td class="text-center">Rap</td>
<td></td>
<td class="text-center">Opp</td>
<td class="text-center"></td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
{% elif forloop.counter == 3 %}
<td class="text-center">Opp</td>
<td></td>
<td class="text-center">Déf</td>
<td></td>
<td class="text-center">Rap</td>
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
<td class="text-center"></td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
{% elif forloop.counter == 4 %}
<td class="text-center">Rap</td>
<td class="text-center">Opp</td>
<td></td>
<td class="text-center">Déf</td>
<td></td>
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
<td class="text-center"></td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
{% elif forloop.counter == 5 %}
<td></td>
<td class="text-center">Rap</td>
<td></td>
<td class="text-center">Opp</td>
<td class="text-center">Déf</td>
<td></td>
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
<td class="text-center"></td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
{% endif %}
{% endif %}
</tr>

View File

@ -14,8 +14,8 @@ from django.contrib.sites.models import Site
from django.test import TestCase
from django.urls import reverse
from participation.models import Team, Tournament
from tfjm import routing as websocket_routing
from . import routing
from .models import Draw, Pool, Round, TeamDraw
@ -55,7 +55,7 @@ class TestDraw(TestCase):
# Connect to Websocket
headers = [(b'cookie', self.async_client.cookies.output(header='', sep='; ').encode())]
communicator = WebsocketCommunicator(AuthMiddlewareStack(URLRouter(routing.websocket_urlpatterns)),
communicator = WebsocketCommunicator(AuthMiddlewareStack(URLRouter(websocket_routing.websocket_urlpatterns)),
"/ws/draw/", headers)
connected, subprotocol = await communicator.connect()
self.assertTrue(connected)
@ -71,11 +71,11 @@ class TestDraw(TestCase):
resp = await communicator.receive_json_from()
self.assertEqual(resp['type'], 'alert')
self.assertEqual(resp['alert_type'], 'danger')
self.assertEqual(resp['message'], "The sum must be equal to the number of teams: expected 12, got 3")
self.assertEqual(resp['message'], "La somme doit être égale au nombre d'équipes : attendu 12, obtenu 3")
self.assertFalse(await Draw.objects.filter(tournament=self.tournament).aexists())
# Now start the draw
await communicator.send_json_to({'tid': tid, 'type': 'start_draw', 'fmt': '3+4+5'})
await communicator.send_json_to({'tid': tid, 'type': 'start_draw', 'fmt': '4+5+3'})
# Receive data after the start
self.assertEqual((await communicator.receive_json_from())['type'], 'alert')
@ -93,7 +93,7 @@ class TestDraw(TestCase):
{'tid': tid, 'type': 'dice_visibility', 'visible': True})
self.assertEqual((await communicator.receive_json_from())['type'], 'alert')
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'draw_start', 'fmt': [5, 4, 3],
{'tid': tid, 'type': 'draw_start', 'fmt': [3, 4, 5],
'trigrams': ['AAA', 'BBB', 'CCC', 'DDD', 'EEE', 'FFF',
'GGG', 'HHH', 'III', 'JJJ', 'KKK', 'LLL']})
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
@ -113,7 +113,7 @@ class TestDraw(TestCase):
resp = await communicator.receive_json_from()
self.assertEqual(resp['type'], 'alert')
self.assertEqual(resp['alert_type'], 'danger')
self.assertEqual(resp['message'], "The draw is already started.")
self.assertEqual(resp['message'], "Le tirage a déjà commencé.")
draw: Draw = await Draw.objects.prefetch_related(
'current_round__current_pool__current_team__participation__team').aget(tournament=self.tournament)
@ -135,7 +135,7 @@ class TestDraw(TestCase):
await communicator.send_json_to({'tid': tid, 'type': "dice", 'trigram': team.trigram})
resp = await communicator.receive_json_from()
self.assertEqual(resp['type'], 'alert')
self.assertEqual(resp['message'], "You've already launched the dice.")
self.assertEqual(resp['message'], "Vous avez déjà lancé le dé.")
# Force exactly one duplicate
await td.arefresh_from_db()
@ -181,8 +181,8 @@ class TestDraw(TestCase):
.aget(number=1, draw=draw)
p = r.current_pool
self.assertEqual(p.letter, 1)
self.assertEqual(p.size, 5)
self.assertEqual(await p.teamdraw_set.acount(), 5)
self.assertEqual(p.size, 3)
self.assertEqual(await p.teamdraw_set.acount(), 3)
self.assertEqual(p.current_team, None)
# Render page
@ -207,7 +207,7 @@ class TestDraw(TestCase):
await communicator.send_json_to({'tid': tid, 'type': "dice", 'trigram': trigram})
resp = await communicator.receive_json_from()
self.assertEqual(resp['type'], 'alert')
self.assertEqual(resp['message'], "You've already launched the dice.")
self.assertEqual(resp['message'], "Vous avez déjà lancé le dé.")
# Force exactly one duplicate
await td.arefresh_from_db()
@ -254,7 +254,7 @@ class TestDraw(TestCase):
await communicator.send_json_to({'tid': tid, 'type': 'dice', 'trigram': None})
resp = await communicator.receive_json_from()
self.assertEqual(resp['type'], 'alert')
self.assertEqual(resp['message'], "This is not the time for this.")
self.assertEqual(resp['message'], "Ce n'est pas le moment pour cela.")
# Draw a problem
await communicator.send_json_to({'tid': tid, 'type': 'draw_problem'})
@ -277,7 +277,7 @@ class TestDraw(TestCase):
await communicator.send_json_to({'tid': tid, 'type': 'draw_problem'})
resp = await communicator.receive_json_from()
self.assertEqual(resp['type'], 'alert')
self.assertEqual(resp['message'], "This is not the time for this.")
self.assertEqual(resp['message'], "Ce n'est pas le moment pour cela.")
# Reject the first problem
await communicator.send_json_to({'tid': tid, 'type': 'reject'})
@ -292,7 +292,7 @@ class TestDraw(TestCase):
self.assertIsNone(td.purposed)
self.assertEqual(td.rejected, [purposed])
for i in range(4):
for i in range(2):
# Next team
p: Pool = await Pool.objects.prefetch_related('current_team__participation__team').aget(round=r, letter=1)
td = p.current_team
@ -411,8 +411,6 @@ class TestDraw(TestCase):
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
self.assertIsNone(td.purposed)
# Reorder the pool since there are 5 teams
self.assertEqual((await communicator.receive_json_from())['type'], 'reorder_poule')
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'dice_visibility', 'visible': True})
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
@ -510,8 +508,8 @@ class TestDraw(TestCase):
.aget(number=1, draw=draw)
p = r.current_pool
self.assertEqual(p.letter, 3)
self.assertEqual(p.size, 3)
self.assertEqual(await p.teamdraw_set.acount(), 3)
self.assertEqual(p.size, 5)
self.assertEqual(await p.teamdraw_set.acount(), 5)
self.assertEqual(p.current_team, None)
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'set_active', 'round': 1, 'poule': 'C', 'team': None})
@ -532,7 +530,7 @@ class TestDraw(TestCase):
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
for i in range(3):
for i in range(5):
# Next team
p: Pool = await Pool.objects.prefetch_related('current_team__participation__team').aget(round=r, letter=3)
td = p.current_team
@ -562,10 +560,11 @@ class TestDraw(TestCase):
self.assertIsNotNone(td.purposed)
self.assertIn(td.purposed, range(1, len(settings.PROBLEMS) + 1))
# Lower problems are already accepted
self.assertGreaterEqual(td.purposed, i + 1)
self.assertGreaterEqual(td.purposed, 1 + i // 2)
# Assume that this is the problem is i for the team i
td.purposed = i + 1
# Assume that this is the problem is i / 2 for the team i (there are 5 teams)
# We force to have duplicates
td.purposed = 1 + i // 2
await td.asave()
# Render page
@ -577,11 +576,11 @@ class TestDraw(TestCase):
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'buttons_visibility', 'visible': False})
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'set_problem', 'round': 1, 'team': trigram, 'problem': i + 1})
{'tid': tid, 'type': 'set_problem', 'round': 1, 'team': trigram, 'problem': 1 + i // 2})
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
self.assertIsNone(td.purposed)
self.assertEqual(td.accepted, i + 1)
if i == 2:
self.assertEqual(td.accepted, 1 + i // 2)
if i == 4:
break
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'box_visibility', 'visible': True})
@ -591,6 +590,9 @@ class TestDraw(TestCase):
resp = await self.async_client.get(reverse('draw:index'))
self.assertEqual(resp.status_code, 200)
# Reorder the pool since there are 5 teams
self.assertEqual((await communicator.receive_json_from())['type'], 'reorder_poule')
# Start round 2
draw: Draw = await Draw.objects.prefetch_related(
'current_round__current_pool__current_team__participation__team').aget(tournament=self.tournament)
@ -624,7 +626,7 @@ class TestDraw(TestCase):
.aget(draw=draw, number=2)
p = r.current_pool
self.assertEqual(p.letter, i + 1)
self.assertEqual(p.size, 5 - i)
self.assertEqual(p.size, i + 3)
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'set_active', 'round': 2, 'poule': chr(65 + i), 'team': None})
@ -642,7 +644,7 @@ class TestDraw(TestCase):
resp = await communicator.receive_json_from()
self.assertEqual(resp['type'], 'set_info')
for j in range(5 - i):
for j in range(3 + i):
# Next team
p: Pool = await Pool.objects.prefetch_related('current_team__participation__team').aget(round=r,
letter=i + 1)
@ -685,13 +687,13 @@ class TestDraw(TestCase):
self.assertEqual((await communicator.receive_json_from())['type'], 'set_problem')
td: TeamDraw = await TeamDraw.objects.prefetch_related('participation__team').aget(pk=td.pk)
self.assertIsNone(td.purposed)
if j == 4 - i:
if j == 2 + i:
break
self.assertEqual(await communicator.receive_json_from(),
{'tid': tid, 'type': 'box_visibility', 'visible': True})
self.assertEqual((await communicator.receive_json_from())['type'], 'set_info')
if i == 0:
if i == 2:
# Reorder the pool since there are 5 teams
self.assertEqual((await communicator.receive_json_from())['type'], 'reorder_poule')
if i < 2:
@ -738,20 +740,20 @@ class TestDraw(TestCase):
draw = Draw.objects.create(tournament=self.tournament)
r1 = Round.objects.create(draw=draw, number=1)
r2 = Round.objects.create(draw=draw, number=2)
p11 = Pool.objects.create(round=r1, letter=1, size=5)
p11 = Pool.objects.create(round=r1, letter=1, size=3)
p12 = Pool.objects.create(round=r1, letter=2, size=4)
p13 = Pool.objects.create(round=r1, letter=3, size=3)
p21 = Pool.objects.create(round=r2, letter=1, size=5)
p13 = Pool.objects.create(round=r1, letter=3, size=5)
p21 = Pool.objects.create(round=r2, letter=1, size=3)
p22 = Pool.objects.create(round=r2, letter=2, size=4)
p23 = Pool.objects.create(round=r2, letter=3, size=3)
p23 = Pool.objects.create(round=r2, letter=3, size=5)
tds = []
for i, team in enumerate(self.teams):
tds.append(TeamDraw.objects.create(participation=team.participation,
round=r1,
pool=p11 if i < 5 else p12 if i < 9 else p13))
pool=p11 if i < 3 else p12 if i < 7 else p13))
tds.append(TeamDraw.objects.create(participation=team.participation,
round=r2,
pool=p21) if i < 5 else p22 if i < 9 else p23)
pool=p21) if i < 3 else p22 if i < 7 else p23)
p11.current_team = tds[0]
p11.save()

View File

@ -40,4 +40,7 @@ class DisplayView(LoginRequiredMixin, TemplateView):
context['tournaments_simplified'] = [{'id': t.id, 'name': t.name} for t in tournaments]
context['problems'] = settings.PROBLEMS
context['range_100'] = range(0, 100, 10)
context['range_10'] = range(0, 10, 1)
return context

View File

@ -3,7 +3,6 @@
crond -l 0
python manage.py migrate
python manage.py loaddata initial
python manage.py update_index
nginx

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,69 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament, Tweak
from .models import Note, Participation, Passage, Pool, Solution, Team, Tournament, Tweak, WrittenReview
class ParticipationInline(admin.StackedInline):
model = Participation
extra = 0
autocomplete_fields = ('team', 'tournament',)
show_change_link = True
class ParticipationTabularInline(admin.TabularInline):
model = Participation
extra = 0
fields = ('team', 'valid', 'final',)
readonly_fields = ('team',)
ordering = ('final', 'valid', 'team__trigram',)
autocomplete_fields = ('tournament',)
show_change_link = True
class SolutionInline(admin.TabularInline):
model = Solution
extra = 0
ordering = ('problem',)
autocomplete_fields = ('participation',)
show_change_link = True
class WrittenReviewInline(admin.TabularInline):
model = WrittenReview
extra = 0
ordering = ('passage__solution_number', 'type',)
autocomplete_fields = ('passage',)
show_change_link = True
class PoolInline(admin.TabularInline):
model = Pool
extra = 0
autocomplete_fields = ('tournament', 'participations', 'jury_president', 'juries',)
show_change_link = True
class PassageInline(admin.TabularInline):
model = Passage
extra = 0
ordering = ('position',)
autocomplete_fields = ('reporter', 'opponent', 'reviewer', 'observer',)
show_change_link = True
class NoteInline(admin.TabularInline):
model = Note
extra = 0
autocomplete_fields = ('jury',)
show_change_link = True
class TweakInline(admin.TabularInline):
model = Tweak
extra = 0
autocomplete_fields = ('participation', 'pool',)
show_change_link = True
@admin.register(Team)
@ -12,6 +74,7 @@ class TeamAdmin(admin.ModelAdmin):
list_display = ('name', 'trigram', 'tournament', 'valid', 'final',)
search_fields = ('name', 'trigram',)
list_filter = ('participation__valid', 'participation__tournament', 'participation__final',)
inlines = (ParticipationInline,)
@admin.display(description=_("tournament"))
def tournament(self, record):
@ -30,16 +93,18 @@ class TeamAdmin(admin.ModelAdmin):
class ParticipationAdmin(admin.ModelAdmin):
list_display = ('team', 'tournament', 'valid', 'final',)
search_fields = ('team__name', 'team__trigram',)
list_filter = ('valid',)
list_filter = ('valid', 'tournament',)
autocomplete_fields = ('team', 'tournament',)
inlines = (SolutionInline, WrittenReviewInline,)
@admin.register(Pool)
class PoolAdmin(admin.ModelAdmin):
list_display = ('__str__', 'tournament', 'round', 'letter', 'teams',)
list_filter = ('tournament', 'round', 'letter',)
list_display = ('__str__', 'tournament', 'round', 'letter', 'room', 'teams', 'jury_president',)
list_filter = ('tournament', 'round', 'letter', 'room',)
search_fields = ('participations__team__name', 'participations__team__trigram',)
autocomplete_fields = ('tournament', 'participations', 'juries',)
autocomplete_fields = ('tournament', 'participations', 'jury_president', 'juries',)
inlines = (PassageInline, TweakInline,)
@admin.display(description=_("teams"))
def teams(self, record: Pool):
@ -48,46 +113,53 @@ class PoolAdmin(admin.ModelAdmin):
@admin.register(Passage)
class PassageAdmin(admin.ModelAdmin):
list_display = ('__str__', 'defender_trigram', 'solution_number', 'opponent_trigram', 'reporter_trigram',
'pool_abbr', 'tournament')
list_display = ('__str__', 'reporter_trigram', 'solution_number', 'opponent_trigram', 'reviewer_trigram',
'observer_trigram', 'pool_abbr', 'position', 'tournament')
list_filter = ('pool__tournament', 'pool__round', 'pool__letter', 'solution_number',)
search_fields = ('pool__participations__team__name', 'pool__participations__team__trigram',)
autocomplete_fields = ('pool', 'defender', 'opponent', 'reporter', 'observer',)
ordering = ('pool__tournament', 'pool__round', 'pool__letter', 'position',)
autocomplete_fields = ('pool', 'reporter', 'opponent', 'reviewer', 'observer',)
inlines = (NoteInline,)
@admin.display(description=_("defender"))
def defender_trigram(self, record: Passage):
return record.defender.team.trigram
@admin.display(description=_("opponent"))
def opponent_trigram(self, record: Passage):
return record.opponent.team.trigram
@admin.display(description=_("reporter"))
@admin.display(description=_("reporter"), ordering='reporter__team__trigram')
def reporter_trigram(self, record: Passage):
return record.reporter.team.trigram
@admin.display(description=_("pool"))
def pool_abbr(self, record):
return f"{record.pool.get_letter_display()}{record.pool.round}"
@admin.display(description=_("opponent"), ordering='opponent__team__trigram')
def opponent_trigram(self, record: Passage):
return record.opponent.team.trigram
@admin.display(description=_("tournament"))
@admin.display(description=_("reviewer"), ordering='reviewer__team__trigram')
def reviewer_trigram(self, record: Passage):
return record.reviewer.team.trigram
@admin.display(description=_("observer"), ordering='observer__team__trigram')
def observer_trigram(self, record: Passage):
return record.observer.team.trigram
@admin.display(description=_("pool"), ordering='pool__letter')
def pool_abbr(self, record):
return f"{record.pool.short_name}"
@admin.display(description=_("tournament"), ordering='pool__tournament__name')
def tournament(self, record: Passage):
return record.pool.tournament
@admin.register(Note)
class NoteAdmin(admin.ModelAdmin):
list_display = ('passage', 'pool', 'jury', 'defender_writing', 'defender_oral',
'opponent_writing', 'opponent_oral', 'reporter_writing', 'reporter_oral',)
list_display = ('passage', 'pool', 'jury', 'reporter_writing', 'reporter_oral',
'opponent_writing', 'opponent_oral', 'reviewer_writing', 'reviewer_oral',
'observer_writing', 'observer_oral',)
list_filter = ('passage__pool__letter', 'passage__solution_number', 'jury',
'defender_writing', 'defender_oral', 'opponent_writing', 'opponent_oral',
'reporter_writing', 'reporter_oral')
search_fields = ('jury__user__last_name', 'jury__user__first_name', 'passage__defender__team__trigram',)
'reporter_writing', 'reporter_oral', 'opponent_writing', 'opponent_oral',
'reviewer_writing', 'reviewer_oral', 'observer_writing', 'observer_oral')
search_fields = ('jury__user__last_name', 'jury__user__first_name', 'passage__reporter__team__trigram',)
autocomplete_fields = ('jury', 'passage',)
@admin.display(description=_("pool"))
def pool(self, record):
return record.passage.pool.get_letter_display()
return record.passage.pool.short_name
@admin.register(Solution)
@ -106,30 +178,34 @@ class SolutionAdmin(admin.ModelAdmin):
return Tournament.final_tournament() if record.final_solution else record.participation.tournament
@admin.register(Synthesis)
class SynthesisAdmin(admin.ModelAdmin):
list_display = ('participation', 'type', 'defender', 'passage',)
@admin.register(WrittenReview)
class WrittenReviewAdmin(admin.ModelAdmin):
list_display = ('participation', 'type', 'reporter', 'passage',)
list_filter = ('participation__tournament', 'type', 'passage__solution_number',)
search_fields = ('participation__team__name', 'participation__team__trigram',)
autocomplete_fields = ('participation', 'passage',)
@admin.display(description=_("defender"))
def defender(self, record: Synthesis):
return record.passage.defender
@admin.display(description=_("reporter"))
def reporter(self, record: WrittenReview):
return record.passage.reporter
@admin.display(description=_("problem"))
def problem(self, record: Synthesis):
def problem(self, record: WrittenReview):
return record.passage.solution_number
@admin.register(Tournament)
class TournamentAdmin(admin.ModelAdmin):
list_display = ('name',)
list_display = ('name', 'date_start', 'date_end',)
search_fields = ('name',)
ordering = ('date_start', 'name',)
autocomplete_fields = ('organizers',)
inlines = (ParticipationTabularInline, PoolInline,)
@admin.register(Tweak)
class TweakAdmin(admin.ModelAdmin):
list_display = ('participation', 'pool', 'diff',)
list_filter = ('pool__tournament', 'pool__round',)
search_fields = ('participation__team__name', 'participation__team__trigram',)
autocomplete_fields = ('participation', 'pool',)

View File

@ -3,7 +3,7 @@
from rest_framework import serializers
from ..models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament
from ..models import Note, Participation, Passage, Pool, Solution, Team, Tournament, WrittenReview
class NoteSerializer(serializers.ModelSerializer):
@ -38,9 +38,9 @@ class SolutionSerializer(serializers.ModelSerializer):
fields = '__all__'
class SynthesisSerializer(serializers.ModelSerializer):
class WrittenReviewSerializer(serializers.ModelSerializer):
class Meta:
model = Synthesis
model = WrittenReview
fields = '__all__'
@ -58,6 +58,13 @@ class TournamentSerializer(serializers.ModelSerializer):
class Meta:
model = Tournament
fields = ('id', 'pk', 'name', 'date_start', 'date_end', 'place', 'max_teams', 'price', 'remote',
'inscription_limit', 'solution_limit', 'solutions_draw', 'syntheses_first_phase_limit',
'solutions_available_second_phase', 'syntheses_second_phase_limit',
'inscription_limit', 'solution_limit', 'solutions_draw', 'reviews_first_phase_limit',
'solutions_available_second_phase', 'reviews_second_phase_limit',
'solutions_available_third_phase', 'reviews_third_phase_limit',
'description', 'organizers', 'final', 'participations',)
class TweakSerializer(serializers.ModelSerializer):
class Meta:
model = Team
fields = '__all__'

View File

@ -2,7 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from .views import NoteViewSet, ParticipationViewSet, PassageViewSet, PoolViewSet, \
SolutionViewSet, SynthesisViewSet, TeamViewSet, TournamentViewSet
SolutionViewSet, TeamViewSet, TournamentViewSet, TweakViewSet, WrittenReviewViewSet
def register_participation_urls(router, path):
@ -13,7 +13,8 @@ def register_participation_urls(router, path):
router.register(path + "/participation", ParticipationViewSet)
router.register(path + "/passage", PassageViewSet)
router.register(path + "/pool", PoolViewSet)
router.register(path + "/review", WrittenReviewViewSet)
router.register(path + "/solution", SolutionViewSet)
router.register(path + "/synthesis", SynthesisViewSet)
router.register(path + "/team", TeamViewSet)
router.register(path + "/tournament", TournamentViewSet)
router.register(path + "/tweak", TweakViewSet)

View File

@ -4,16 +4,16 @@ from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.viewsets import ModelViewSet
from .serializers import NoteSerializer, ParticipationSerializer, PassageSerializer, PoolSerializer, \
SolutionSerializer, SynthesisSerializer, TeamSerializer, TournamentSerializer
from ..models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament
SolutionSerializer, TeamSerializer, TournamentSerializer, TweakSerializer, WrittenReviewSerializer
from ..models import Note, Participation, Passage, Pool, Solution, Team, Tournament, Tweak, WrittenReview
class NoteViewSet(ModelViewSet):
queryset = Note.objects.all()
serializer_class = NoteSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['jury', 'passage', 'defender_writing', 'defender_oral', 'opponent_writing',
'opponent_oral', 'reporter_writing', 'reporter_oral', ]
filterset_fields = ['jury', 'passage', 'reporter_writing', 'reporter_oral', 'opponent_writing',
'opponent_oral', 'reviewer_writing', 'reviewer_oral', 'observer_writing', 'observer_oral', ]
class ParticipationViewSet(ModelViewSet):
@ -27,7 +27,7 @@ class PassageViewSet(ModelViewSet):
queryset = Passage.objects.all()
serializer_class = PassageSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['pool', 'solution_number', 'defender', 'opponent', 'reporter', 'pool_tournament', ]
filterset_fields = ['pool', 'solution_number', 'reporter', 'opponent', 'reviewer', 'observer', 'pool_tournament', ]
class PoolViewSet(ModelViewSet):
@ -44,9 +44,9 @@ class SolutionViewSet(ModelViewSet):
filterset_fields = ['participation', 'number', 'problem', 'final_solution', ]
class SynthesisViewSet(ModelViewSet):
queryset = Synthesis.objects.all()
serializer_class = SynthesisSerializer
class WrittenReviewViewSet(ModelViewSet):
queryset = WrittenReview.objects.all()
serializer_class = WrittenReviewSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['participation', 'number', 'passage', 'type', ]
@ -64,6 +64,15 @@ class TournamentViewSet(ModelViewSet):
serializer_class = TournamentSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['name', 'date_start', 'date_end', 'place', 'max_teams', 'price', 'remote',
'inscription_limit', 'solution_limit', 'solutions_draw', 'syntheses_first_phase_limit',
'solutions_available_second_phase', 'syntheses_second_phase_limit',
'inscription_limit', 'solution_limit', 'solutions_draw', 'reviews_first_phase_limit',
'solutions_available_second_phase', 'reviews_second_phase_limit',
'solutions_available_third_phase', 'reviews_third_phase_limit',
'description', 'organizers', 'final', ]
class TweakViewSet(ModelViewSet):
queryset = Tweak.objects.all()
serializer_class = TweakSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['pool', 'pool__tournament', 'pool__tournament__name', 'participation',
'participation__team__trigram', 'diff', ]

View File

@ -12,8 +12,9 @@ class ParticipationConfig(AppConfig):
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")
from participation import signals
pre_save.connect(signals.update_mailing_list, "participation.Team")
post_save.connect(signals.create_team_participation, "participation.Team")
post_save.connect(signals.create_payments, "participation.Participation")
post_save.connect(signals.create_notes, "participation.Passage")
post_save.connect(signals.create_notes, "participation.Pool")

View File

@ -1,24 +1,22 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
import csv
from io import StringIO
import re
from typing import Iterable
from crispy_forms.bootstrap import InlineField
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Div, Fieldset, Submit
from crispy_forms.layout import Div, Field, HTML, Layout, Submit
from django import forms
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator
from django.db.models import CharField, Value
from django.db.models.functions import Concat
from django.utils.translation import gettext_lazy as _
import pandas
from pypdf import PdfReader
from registration.models import VolunteerRegistration
from tfjm import settings
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament
from .models import Note, Participation, Passage, Pool, Solution, Team, Tournament, WrittenReview
class TeamForm(forms.ModelForm):
@ -55,6 +53,10 @@ class JoinTeamForm(forms.ModelForm):
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."))
else:
team = Team.objects.get(access_code=access_code)
if team.participation.valid is not None:
raise ValidationError(_("The team is already validated or the validation is pending."))
return access_code
def clean(self):
@ -73,13 +75,40 @@ class ParticipationForm(forms.ModelForm):
"""
Form to update the problem of a team participation.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if settings.SINGLE_TOURNAMENT:
del self.fields['tournament']
self.helper = FormHelper()
idf_warning_banner = f"""
<div class=\"alert alert-warning\">
<h5 class=\"alert-heading\">{_("IMPORTANT")}</h4>
{_("""For the tournaments in the region "Île-de-France": registration is
unified for each tournament. By choosing a tournament "Île-de-France",
you're accepting that your team may be selected for one of these tournaments.
In case of date conflict, please write them in your motivation letter.""")}
</div>
"""
unified_registration_tournament_ids = ",".join(
str(tournament.id) for tournament in Tournament.objects.filter(
unified_registration=True).all())
self.helper.layout = Layout(
'tournament',
Div(
HTML(idf_warning_banner),
css_id="idf_warning_banner",
data_tid_unified=unified_registration_tournament_ids,
),
'final',
)
class Meta:
model = Participation
fields = ('tournament', 'final',)
class MotivationLetterForm(forms.ModelForm):
def clean_file(self):
def clean_motivation_letter(self):
if "motivation_letter" in self.files:
file = self.files["motivation_letter"]
if file.size > 2e6:
@ -103,7 +132,7 @@ class RequestValidationForm(forms.Form):
)
engagement = forms.BooleanField(
label=_("I engage myself to participate to the whole TFJM²."),
label=_("I engage myself to participate to the whole tournament."),
required=True,
)
@ -124,21 +153,33 @@ class ValidateParticipationForm(forms.Form):
class TournamentForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if settings.NB_ROUNDS < 3:
del self.fields['date_third_phase']
del self.fields['solutions_available_third_phase']
del self.fields['reviews_third_phase_limit']
if not settings.PAYMENT_MANAGEMENT:
del self.fields['price']
class Meta:
model = Tournament
fields = '__all__'
exclude = ('notes_sheet_id', )
widgets = {
'date_start': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
'date_end': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
'inscription_limit': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%d %H:%M'),
'solution_limit': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%d %H:%M'),
'solutions_draw': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%d %H:%M'),
'syntheses_first_phase_limit': forms.DateTimeInput(attrs={'type': 'datetime-local'},
format='%Y-%m-%d %H:%M'),
'solutions_available_second_phase': forms.DateTimeInput(attrs={'type': 'datetime-local'},
format='%Y-%m-%d %H:%M'),
'syntheses_second_phase_limit': forms.DateTimeInput(attrs={'type': 'datetime-local'},
format='%Y-%m-%d %H:%M'),
'date_first_phase': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
'reviews_first_phase_limit': forms.DateTimeInput(attrs={'type': 'datetime-local'},
format='%Y-%m-%d %H:%M'),
'date_second_phase': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
'reviews_second_phase_limit': forms.DateTimeInput(attrs={'type': 'datetime-local'},
format='%Y-%m-%d %H:%M'),
'date_third_phase': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
'reviews_third_phase_limit': forms.DateTimeInput(attrs={'type': 'datetime-local'},
format='%Y-%m-%d %H:%M'),
'organizers': forms.SelectMultiple(attrs={
'class': 'selectpicker',
'data-live-search': 'true',
@ -175,8 +216,13 @@ class SolutionForm(forms.ModelForm):
class PoolForm(forms.ModelForm):
class Meta:
model = Pool
fields = ('tournament', 'round', 'letter', 'bbb_url', 'results_available', 'juries',)
fields = ('tournament', 'round', 'letter', 'bbb_url', 'results_available', 'jury_president', 'juries',)
widgets = {
"jury_president": forms.Select(attrs={
'class': 'selectpicker',
'data-live-search': 'true',
'data-live-search-normalize': 'true',
}),
"juries": forms.SelectMultiple(attrs={
'class': 'selectpicker',
'data-live-search': 'true',
@ -185,47 +231,31 @@ class PoolForm(forms.ModelForm):
}
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.SelectMultiple(attrs={
'class': 'selectpicker',
'data-live-search': 'true',
'data-live-search-normalize': 'true',
'data-width': 'fit',
}),
}
class AddJuryForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['first_name'].required = True
self.fields['last_name'].required = True
self.fields['email'].required = True
self.helper = FormHelper()
self.helper.form_class = 'form-inline'
self.helper.layout = Fieldset(
_("Add new jury"),
self.helper.layout = Div(
Div(
Div(
InlineField('first_name', autofocus="autofocus"),
css_class='col-xl-3',
Field('email', autofocus="autofocus", list="juries-email"),
css_class='col-md-5 px-1',
),
Div(
InlineField('last_name'),
css_class='col-xl-3',
Field('first_name', list="juries-first-name"),
css_class='col-md-3 px-1',
),
Div(
InlineField('email'),
css_class='col-xl-5',
Field('last_name', list="juries-last-name"),
css_class='col-md-3 px-1',
),
Div(
Submit('submit', _("Add")),
css_class='col-xl-1',
css_class='col-md-1 py-md-4 px-1',
),
css_class='row',
)
@ -237,7 +267,10 @@ class AddJuryForm(forms.ModelForm):
"""
email = self.data["email"]
if User.objects.filter(email=email).exists():
self.add_error("email", _("This email address is already used."))
self.instance = User.objects.get(email=email)
if self.instance.registration.participates:
self.add_error(None, _("This user already exists, but is a participant."))
return
return email
class Meta:
@ -247,79 +280,83 @@ class AddJuryForm(forms.ModelForm):
class UploadNotesForm(forms.Form):
file = forms.FileField(
label=_("CSV file:"),
validators=[FileExtensionValidator(allowed_extensions=["csv"])],
label=_("Spreadsheet file:"),
validators=[FileExtensionValidator(allowed_extensions=["csv", "ods"])],
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['file'].widget.attrs['accept'] = 'text/csv'
self.fields['file'].widget.attrs['accept'] = 'text/csv,application/vnd.oasis.opendocument.spreadsheet'
def clean(self):
cleaned_data = super().clean()
if 'file' in cleaned_data:
file = cleaned_data['file']
with file:
try:
data: bytes = file.read()
if file.name.endswith('.csv'):
with file:
try:
content = data.decode()
data: bytes = file.read()
try:
content = data.decode()
except UnicodeDecodeError:
# This is not UTF-8, grrrr
content = data.decode('latin1')
table = pandas.read_csv(StringIO(content), sep=None, header=None)
self.process(table, cleaned_data)
except UnicodeDecodeError:
# This is not UTF-8, grrrr
content = data.decode('latin1')
csvfile = csv.reader(StringIO(content))
self.process(csvfile, cleaned_data)
except UnicodeDecodeError:
self.add_error('file', _("This file contains non-UTF-8 and non-ISO-8859-1 content. "
"Please send your sheet as a CSV file."))
self.add_error('file', _("This file contains non-UTF-8 and non-ISO-8859-1 content. "
"Please send your sheet as a CSV file."))
elif file.name.endswith('.ods'):
table = pandas.read_excel(file, header=None, engine='odf')
self.process(table, cleaned_data)
return cleaned_data
def process(self, csvfile: Iterable[str], cleaned_data: dict):
def process(self, df: pandas.DataFrame, cleaned_data: dict):
parsed_notes = {}
valid_lengths = [1 + 6 * 3, 1 + 7 * 4, 1 + 6 * 5] # Per pool sizes
pool_size = 0
line_length = 0
for line in csvfile:
line = [s.strip() for s in line if s]
if line and line[0] == 'Problème':
for line in df.values.tolist():
# Remove NaN
line = [s for s in line if s == s]
# Strip cases
line = [str(s).strip() for s in line if str(s)]
if line and line[0] in ["Problème", "Problem"]:
pool_size = len(line) - 1
if pool_size < 3 or pool_size > 5:
self.add_error('file', _("Can't determine the pool size. Are you sure your file is correct?"))
return
line_length = valid_lengths[pool_size - 3]
line_length = 2 + (8 if df.iat[1, 8] == "Observer" else 6) * pool_size
continue
if pool_size == 0 or len(line) < line_length:
continue
name = line[0]
if name.lower() in ["rôle", "juré", "moyenne", "coefficient", "sous-total", "équipe", "equipe"]:
if name.lower() in ["rôle", "juré⋅e", "juré?e", "moyenne", "coefficient", "sous-total", "équipe", "equipe",
"role", "juree", "average", "coefficient", "subtotal", "team"]:
continue
notes = line[1:line_length]
notes = line[2:line_length]
if not all(s.isnumeric() or s[0] == '-' and s[1:].isnumeric() for s in notes):
continue
notes = list(map(int, notes))
notes = list(map(lambda x: int(float(x)), notes))
max_notes = pool_size * ([20, 16, 9, 10, 9, 10] + ([4] if pool_size == 4 else []))
max_notes = pool_size * [20 if settings.TFJM_APP == "TFJM" else 10,
20 if settings.TFJM_APP == "TFJM" else 10,
10, 10, 10, 10, 10, 10]
for n, max_n in zip(notes, max_notes):
if n > max_n:
self.add_error('file',
_("The following note is higher of the maximum expected value:")
+ str(n) + " > " + str(max_n))
# Search by "{first_name} {last_name}"
jury = User.objects.annotate(full_name=Concat('first_name', Value(' '), 'last_name',
output_field=CharField())) \
.filter(full_name=name.replace('', '\''), registration__volunteerregistration__isnull=False)
# Search by volunteer id
jury = VolunteerRegistration.objects.filter(pk=int(float(line[1])))
if jury.count() != 1:
self.add_error('file', _("The following user was not found:") + " " + name)
continue
raise ValidationError({'file': _("The following user was not found:") + " " + name})
jury = jury.get()
parsed_notes[jury] = notes
vr = jury.registration
parsed_notes[vr] = notes
print(parsed_notes)
cleaned_data['parsed_notes'] = parsed_notes
@ -329,21 +366,21 @@ class UploadNotesForm(forms.Form):
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"],
if "reporter" in cleaned_data and "opponent" in cleaned_data and "reviewer" in cleaned_data \
and len({cleaned_data["reporter"], cleaned_data["opponent"], cleaned_data["reviewer"]}) < 3:
self.add_error(None, _("The reporter, the opponent and the reviewer must be different."))
if "reporter" in self.cleaned_data and "solution_number" in self.cleaned_data \
and not Solution.objects.filter(participation=cleaned_data["reporter"],
problem=cleaned_data["solution_number"]).exists():
self.add_error("solution_number", _("This defender did not work on this problem."))
self.add_error("solution_number", _("This reporter did not work on this problem."))
return cleaned_data
class Meta:
model = Passage
fields = ('position', 'solution_number', 'defender', 'opponent', 'reporter', 'observer', 'defender_penalties',)
fields = ('position', 'solution_number', 'reporter', 'opponent', 'reviewer', 'opponent', 'reporter_penalties',)
class SynthesisForm(forms.ModelForm):
class WrittenReviewForm(forms.ModelForm):
def clean_file(self):
if "file" in self.files:
file = self.files["file"]
@ -359,16 +396,16 @@ class SynthesisForm(forms.ModelForm):
def save(self, commit=True):
"""
Don't save a synthesis with this way. Use a view instead
Don't save a written review with this way. Use a view instead
"""
class Meta:
model = Synthesis
model = WrittenReview
fields = ('file',)
class NoteForm(forms.ModelForm):
class Meta:
model = Note
fields = ('defender_writing', 'defender_oral', 'opponent_writing',
'opponent_oral', 'reporter_writing', 'reporter_oral', 'observer_oral', )
fields = ('reporter_writing', 'reporter_oral', 'opponent_writing',
'opponent_oral', 'reviewer_writing', 'reviewer_oral', 'observer_writing', 'observer_oral', )

View File

@ -1,87 +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
from django.db.models import Q
import requests
class Command(BaseCommand):
def handle(self, *args, **options): # noqa: C901
# Get access token
response = requests.post('https://api.helloasso.com/oauth2/token', headers={
'Content-Type': 'application/x-www-form-urlencoded',
}, data={
'client_id': os.getenv('HELLOASSO_CLIENT_ID', ''),
'client_secret': os.getenv('HELLOASSO_CLIENT_SECRET', ''),
'grant_type': 'client_credentials',
}).json()
token = response['access_token']
organization = "animath"
form_slug = "tfjm-2023-tournois-regionaux"
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=100&retrieveOfflineDonations=false"
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {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["data"]:
if payment["state"] != "Authorized":
continue
payer = payment["payer"]
email = payer["email"]
last_name = payer["lastName"]
first_name = payer["firstName"]
base_filter = Q(
registration__participantregistration__isnull=False,
registration__participantregistration__team__isnull=False,
registration__participantregistration__team__participation__valid=True,
)
qs = User.objects.filter(
base_filter,
email=email,
)
if not qs.exists():
qs = User.objects.filter(
base_filter,
last_name__icontains=last_name,
)
if qs.count() >= 2:
qs = qs.filter(first_name__icontains=first_name)
if not qs.exists():
self.stderr.write(f"Warning: a payment was found by {first_name} {last_name} ({email}), "
"but this user is unknown.")
continue
if qs.count() > 1:
self.stderr.write(f"Warning: a payment was found by {first_name} {last_name} ({email}), "
f"but there are {qs.count()} matching users.")
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,6 +1,7 @@
# Copyright (C) 2021 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.core.management import BaseCommand
from django.utils.formats import date_format
from django.utils.translation import activate
@ -9,7 +10,7 @@ from participation.models import Tournament
class Command(BaseCommand):
def handle(self, *args, **kwargs):
activate('fr')
activate(settings.PREFERRED_LANGUAGE_CODE)
tournaments = Tournament.objects.order_by('-date_start', 'name')
for tournament in tournaments:
@ -17,8 +18,8 @@ class Command(BaseCommand):
self.w("")
self.w("")
def w(self, msg):
self.stdout.write(msg)
def w(self, msg, prefix="", suffix=""):
self.stdout.write(f"{prefix}{msg}{suffix}")
def handle_tournament(self, tournament):
name = tournament.name
@ -40,7 +41,7 @@ class Command(BaseCommand):
if tournament.final:
self.w(f"<p>La finale a eu lieu le weekend du {date_start} au {date_end} et a été remporté par l'équipe "
f"<em>{notes[0][0].team.name}</em> suivie de l'équipe <em>{notes[1][0].team.name}</em>. "
f"Les deux premières équipes sont sélectionnées pour représenter la France lors de l'ITYM.</p>")
f"Les deux premières équipes sont sélectionnées pour représenter la France lors de l'ETEAM.</p>")
else:
self.w(f"<p>Le tournoi de {name} a eu lieu le weekend du {date_start} au {date_end} et a été remporté par "
f"l'équipe <em>{notes[0][0].team.name}</em>.</p>")
@ -52,32 +53,29 @@ class Command(BaseCommand):
self.w("<table>")
self.w("<thead>")
self.w("<tr>")
self.w("\t<th>Équipe</th>")
self.w("\t<th>Score Tour 1</th>")
self.w("\t<th>Score Tour 2</th>")
self.w("\t<th>Total</th>")
self.w("\t<th class=\"has-text-align-center\">Prix</th>")
self.w(" <th>Équipe</th>")
self.w(" <th>Score Tour 1</th>")
self.w(" <th>Score Tour 2</th>")
self.w(" <th>Total</th>")
self.w(" <th class=\"has-text-align-center\">Prix</th>")
self.w("</tr>")
self.w("</thead>")
self.w("<tbody>")
for i, (participation, note) in enumerate(notes):
self.w("<tr>")
if i < (2 if len(notes) >= 7 else 1):
self.w(f"\t<th>{participation.team.name} ({participation.team.trigram})</td>")
bold = (not tournament.final and participation.final) or (tournament.final and i < 2)
if bold:
prefix, suffix = " <td><strong>", "</strong></td>"
else:
self.w(f"\t<td>{participation.team.name} ({participation.team.trigram})</td>")
for pool in tournament.pools.filter(participations=participation).all():
pool_note = pool.average(participation)
self.w(f"\t<td>{pool_note:.01f}</td>")
self.w(f"\t<td>{note:.01f}</td>")
if i == 0:
self.w("\t<td class=\"has-text-align-center\">1<sup>er</sup> prix</td>")
elif i < (5 if tournament.final else 3):
self.w(f"\t<td class=\"has-text-align-center\">{i + 1}<sup>ème</sup> prix</td>")
elif i < 2 * len(notes) / 3:
self.w("\t<td class=\"has-text-align-center\">Mention très honorable</td>")
else:
self.w("\t<td class=\"has-text-align-center\">Mention honorable</td>")
prefix, suffix = " <td>", "</td>"
self.w(f"{participation.team.name} ({participation.team.trigram})", prefix, suffix)
for tournament_round in [1, 2]:
pool_note = sum(pool.average(participation)
for pool in tournament.pools.filter(participations=participation,
round=tournament_round).all())
self.w(f"{pool_note:.01f}", prefix, suffix)
self.w(f"{note:.01f}", prefix, suffix)
self.w(participation.mention_final if tournament.final else participation.mention, prefix, suffix)
self.w("</tr>")
self.w("</tbody>")
self.w("</table>")

View File

@ -5,16 +5,16 @@ from pathlib import Path
from django.conf import settings
from django.core.management import BaseCommand
from django.utils.translation import activate
from participation.models import Solution, Tournament
class Command(BaseCommand):
def handle(self, *args, **kwargs):
activate('fr')
base_dir = Path(__file__).parent.parent.parent.parent
base_dir /= "output"
if not base_dir.is_dir():
base_dir.mkdir()
base_dir /= "solutions"
if not base_dir.is_dir():
base_dir.mkdir()
base_dir /= "Par équipe"

View File

@ -1,6 +1,6 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.core.management import BaseCommand
from django.db.models import Q
from participation.models import Team, Tournament
@ -13,6 +13,9 @@ class Command(BaseCommand):
"""
Create Sympa mailing lists and register teams.
"""
if not settings.ML_MANAGEMENT:
return
sympa = get_sympa_client()
sympa.create_list("equipes", "Equipes du TFJM2", "hotline",
@ -22,11 +25,11 @@ class Command(BaseCommand):
"Liste de diffusion pour contacter toutes les equipes non validees du TFJM2.",
"education", raise_error=False)
sympa.create_list("admins", "Administrateurs du TFJM2", "hotline",
"Liste de diffusion pour contacter tous les administrateurs du TFJM2.",
sympa.create_list("admins", "Administrateur⋅rices du TFJM2", "hotline",
"Liste de diffusion pour contacter toustes les administrateur.rices du TFJM2.",
"education", raise_error=False)
sympa.create_list("organisateurs", "Organisateurs du TFJM2", "hotline",
"Liste de diffusion pour contacter tous les organisateurs du TFJM2.",
"Liste de diffusion pour contacter toustes les organisateur.rices du TFJM2.",
"education", raise_error=False)
sympa.create_list("jurys", "Jurys du TFJM2", "hotline",
"Liste de diffusion pour contacter tous les jurys du TFJM2.",

View File

@ -0,0 +1,149 @@
# 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
import gspread
from gspread.utils import a1_range_to_grid_range, MergeType
from ...models import Passage, Tournament
class Command(BaseCommand):
def handle(self, *args, **options):
activate(settings.PREFERRED_LANGUAGE_CODE)
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
try:
spreadsheet = gc.open("Tableau des deuxièmes", folder_id=settings.NOTES_DRIVE_FOLDER_ID)
except gspread.SpreadsheetNotFound:
spreadsheet = gc.create("Tableau des deuxièmes", folder_id=settings.NOTES_DRIVE_FOLDER_ID)
spreadsheet.update_locale("fr_FR")
spreadsheet.share(None, "anyone", "writer", with_link=True)
sheet = spreadsheet.sheet1
header1 = ["Tournoi", "Équipe 2", "Tour 1", "", "", "", "", "", "", "", "Tour 2", "", "", "", "", "", "", "",
"Score total", "Score équipe 1", "Score équipe 3"]
header2 = ["", ""] + 2 * ["PJ", "Problème", "Défenseur⋅se", "", "Opposant⋅e", "", "Rapporteur⋅rice", ""]
header2 += ["", "", ""]
header3 = ["", ""] + 2 * (["", ""] + 3 * ["Écrit", "Oral"]) + ["", "", ""]
lines = [header1, header2, header3]
nb_tournaments = Tournament.objects.filter(final=False).count()
for tournament in Tournament.objects.filter(final=False).all():
line = [tournament.name]
lines.append(line)
notes = dict()
for participation in tournament.participations.filter(valid=True).all():
note = sum(pool.average(participation)
for pool in tournament.pools.filter(participations=participation).all())
if note:
notes[participation] = note
if not notes:
continue
sorted_notes = sorted(notes.items(), key=lambda x: x[1], reverse=True)
team1, score1 = sorted_notes[0]
team2, score2 = sorted_notes[1]
team3, score3 = sorted_notes[2]
pool1 = tournament.pools.filter(round=1, participations=team2).first()
reporter_passage_1 = Passage.objects.get(pool__tournament=tournament, pool__round=1, reporter=team2)
opponent_passage_1 = Passage.objects.get(pool__tournament=tournament, pool__round=1, opponent=team2)
reviewer_passage_1 = Passage.objects.get(pool__tournament=tournament, pool__round=1, reviewer=team2)
pool2 = tournament.pools.filter(round=2, participations=team2).first()
reporter_passage_2 = Passage.objects.get(pool__tournament=tournament, pool__round=2, reporter=team2)
opponent_passage_2 = Passage.objects.get(pool__tournament=tournament, pool__round=2, opponent=team2)
reviewer_passage_2 = Passage.objects.get(pool__tournament=tournament, pool__round=2, reviewer=team2)
line.append(team2.team.trigram)
line.append(str(pool1.jury_president or ""))
line.append(f"Pb. {reporter_passage_1.solution_number}")
line.extend([reporter_passage_1.average_reporter_writing, reporter_passage_1.average_reporter_oral,
opponent_passage_1.average_opponent_writing, opponent_passage_1.average_opponent_oral,
reviewer_passage_1.average_reviewer_writing, reviewer_passage_1.average_reviewer_oral])
line.append(str(pool2.jury_president or ""))
line.append(f"Pb. {reporter_passage_2.solution_number}")
line.extend([reporter_passage_2.average_reporter_writing, reporter_passage_2.average_reporter_oral,
opponent_passage_2.average_opponent_writing, opponent_passage_2.average_opponent_oral,
reviewer_passage_2.average_reviewer_writing, reviewer_passage_2.average_reviewer_oral])
line.extend([score2, f"{score1:.1f} ({team1.team.trigram})",
f"{score3:.1f} ({team3.team.trigram})"])
sheet.update(lines)
format_requests = []
merge_cells = ["A1:A3", "B1:B3", "C1:J1", "K1:R1", "E2:F2", "G2:H2", "I2:J2", "M2:N2", "O2:P2", "Q2:R2",
"C2:C3", "D2:D3", "K2:K3", "L2:L3", "S1:S3", "T1:T3", "U1:U3"]
format_requests.append({"unmergeCells": {"range": a1_range_to_grid_range("A1:AF", sheet.id)}})
for name in merge_cells:
grid_range = a1_range_to_grid_range(name, sheet.id)
format_requests.append({"mergeCells": {"mergeType": MergeType.merge_all, "range": grid_range}})
bold_ranges = [("A1:AF", False), ("A1:U3", True), (f"A4:A{3 + nb_tournaments}", True)]
for bold_range, bold in bold_ranges:
format_requests.append({
"repeatCell": {
"range": a1_range_to_grid_range(bold_range, sheet.id),
"cell": {"userEnteredFormat": {"textFormat": {"bold": bold}}},
"fields": "userEnteredFormat(textFormat)",
}
})
border_ranges = [("A1:AF", "0000"),
(f"A1:U{3 + nb_tournaments}", "1111")]
sides_names = ['top', 'bottom', 'left', 'right']
styles = ["NONE", "SOLID", "SOLID_MEDIUM", "SOLID_THICK", "DOUBLE"]
for border_range, sides in border_ranges:
borders = {}
for side_name, side in zip(sides_names, sides):
borders[side_name] = {"style": styles[int(side)]}
format_requests.append({
"repeatCell": {
"range": a1_range_to_grid_range(border_range, sheet.id),
"cell": {
"userEnteredFormat": {
"borders": borders,
"horizontalAlignment": "CENTER",
},
},
"fields": "userEnteredFormat(borders,horizontalAlignment)",
}
})
column_widths = [("A", 120), ("B", 80), ("C", 180), ("D", 80)] + [(chr(ord("E") + i), 60) for i in range(6)]
column_widths += [("K", 180), ("L", 80)] + [(chr(ord("M") + i), 60) for i in range(6)]
column_widths += [("S", 100), ("T", 120), ("U", 120)]
for column, width in column_widths:
grid_range = a1_range_to_grid_range(column, sheet.id)
format_requests.append({
"updateDimensionProperties": {
"range": {
"sheetId": sheet.id,
"dimension": "COLUMNS",
"startIndex": grid_range['startColumnIndex'],
"endIndex": grid_range['endColumnIndex'],
},
"properties": {
"pixelSize": width,
},
"fields": "pixelSize",
}
})
# Set number format, display only one decimal
number_format_ranges = [f"E4:J{3 + nb_tournaments}", f"M4:S{3 + nb_tournaments}"]
for number_format_range in number_format_ranges:
format_requests.append({
"repeatCell": {
"range": a1_range_to_grid_range(number_format_range, sheet.id),
"cell": {"userEnteredFormat": {"numberFormat": {"type": "NUMBER", "pattern": "0.0"}}},
"fields": "userEnteredFormat.numberFormat",
}
})
body = {"requests": format_requests}
sheet.client.batch_update(spreadsheet.id, body)

View File

@ -0,0 +1,56 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from time import sleep
from django.core.management import BaseCommand
from participation.models import Tournament
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
'--tournament', '-t', help="Tournament name to update (if not set, all tournaments will be updated)",
)
parser.add_argument(
'--round', '-r', type=int, help="Round number to update (if not set, all rounds will be updated)",
)
parser.add_argument(
'--letter', '-l', help="Letter of the pool to update (if not set, all pools will be updated)",
)
def handle(self, *args, **options):
tournaments = Tournament.objects.all() if not options['tournament'] \
else Tournament.objects.filter(name=options['tournament']).all()
for tournament in tournaments:
if options['verbosity'] >= 1:
self.stdout.write(f"Parsing notation sheet for {tournament}")
if not tournament.notes_sheet_id:
if options['verbosity'] >= 1:
self.stdout.write(
self.style.WARNING(f"No spreadsheet found for {tournament}. Please create it first"))
continue
pools = tournament.pools.all()
if options['round']:
pools = pools.filter(round=options['round'])
if options['letter']:
pools = pools.filter(letter=ord(options['letter']) - 64)
for pool in pools.all():
if options['verbosity'] >= 1:
self.stdout.write(f"Parsing notation sheet for pool {pool.short_name} for {tournament}")
try:
pool.parse_spreadsheet()
except Exception as e:
if options['verbosity'] >= 1:
self.stderr.write(
self.style.ERROR(f"Error while parsing pool {pool.short_name} for {tournament.name}: {e}"))
finally:
sleep(3) # Three calls = 3s sleep
try:
tournament.parse_tweaks_spreadsheets()
except Exception as e:
if options['verbosity'] >= 1:
self.stderr.write(self.style.ERROR(f"Error while parsing tweaks for {tournament.name}: {e}"))

View File

@ -0,0 +1,61 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from hashlib import sha1
from django.conf import settings
from django.contrib.sites.models import Site
from django.core.management import BaseCommand
from django.urls import reverse
from django.utils import timezone
from django.utils.timezone import localtime
import gspread
from ...models import Tournament
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
'--tournament', '-t', help="Tournament name to update (if not set, all tournaments will be updated)",
)
def handle(self, *args, **options):
tournaments = Tournament.objects.all() if not options['tournament'] \
else Tournament.objects.filter(name=options['tournament']).all()
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
http_client = gc.http_client
http_client.login()
site = Site.objects.get(pk=settings.SITE_ID)
now = localtime(timezone.now())
tomorrow = now + timezone.timedelta(days=1)
tomorrow -= timezone.timedelta(hours=now.hour, minutes=now.minute, seconds=now.second,
microseconds=now.microsecond)
for tournament in tournaments:
if options['verbosity'] >= 1:
self.stdout.write(f"Renewing Google Drive notifications for {tournament}")
if not tournament.notes_sheet_id:
if options['verbosity'] >= 1:
self.stdout.write(
self.style.WARNING(f"No spreadsheet found for {tournament}. Please create it first"))
continue
channel_id = sha1(f"{tournament.name}-{now.date()}-{site.domain}".encode()).hexdigest()
url = f"https://www.googleapis.com/drive/v3/files/{tournament.notes_sheet_id}/watch?supportsAllDrives=true"
notif_path = reverse('participation:tournament_gsheet_notifications', args=[tournament.pk])
notif_url = f"https://{site.domain}{notif_path}"
body = {
"id": channel_id,
"type": "web_hook",
"address": notif_url,
"expiration": str(int(1000 * tomorrow.timestamp())),
}
try:
http_client.request(method="POST", endpoint=url, json=body).raise_for_status()
except Exception as e:
self.stderr.write(self.style.ERROR(f"Error while renewing notifications for {tournament.name}: {e}"))

View File

@ -0,0 +1,39 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.core.management import BaseCommand
from participation.models import Tournament
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
'--tournament', '-t', help="Tournament name to update (if not set, all tournaments will be updated)",
)
parser.add_argument(
'--round', '-r', type=int, help="Round number to update (if not set, all rounds will be updated)",
)
parser.add_argument(
'--letter', '-l', help="Letter of the pool to update (if not set, all pools will be updated)",
)
def handle(self, *args, **options):
tournaments = Tournament.objects.all() if not options['tournament'] \
else Tournament.objects.filter(name=options['tournament']).all()
for tournament in tournaments:
if options['verbosity'] >= 1:
self.stdout.write(f"Updating notation sheet for {tournament}")
tournament.create_spreadsheet()
pools = tournament.pools.all()
if options['round']:
pools = pools.filter(round=options['round'])
if options['letter']:
pools = pools.filter(letter=ord(options['letter']) - 64)
for pool in pools.all():
if options['verbosity'] >= 1:
self.stdout.write(f"Updating notation sheet for pool {pool.short_name} for {tournament}")
pool.update_spreadsheet()
tournament.update_ranking_spreadsheet()

View File

@ -0,0 +1,27 @@
# Generated by Django 5.0.2 on 2024-03-24 14:31
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("participation", "0008_alter_participation_options"),
("registration", "0012_payment_token_alter_payment_type"),
]
operations = [
migrations.AddField(
model_name="pool",
name="jury_president",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="pools_presided",
to="registration.volunteerregistration",
verbose_name="president of the jury",
),
),
]

View File

@ -0,0 +1,93 @@
# Generated by Django 5.0.3 on 2024-03-29 22:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("participation", "0009_pool_jury_president"),
]
operations = [
migrations.AddField(
model_name="tournament",
name="notes_sheet_id",
field=models.CharField(
blank=True, default="", max_length=64, verbose_name="Google Sheet ID"
),
),
migrations.AlterField(
model_name="note",
name="defender_oral",
field=models.PositiveSmallIntegerField(
choices=[
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 4),
(5, 5),
(6, 6),
(7, 7),
(8, 8),
(9, 9),
(10, 10),
(11, 11),
(12, 12),
(13, 13),
(14, 14),
(15, 15),
(16, 16),
(17, 17),
(18, 18),
(19, 19),
(20, 20),
],
default=0,
verbose_name="defender oral note",
),
),
migrations.AlterField(
model_name="note",
name="opponent_writing",
field=models.PositiveSmallIntegerField(
choices=[
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 4),
(5, 5),
(6, 6),
(7, 7),
(8, 8),
(9, 9),
(10, 10),
],
default=0,
verbose_name="opponent writing note",
),
),
migrations.AlterField(
model_name="note",
name="reporter_writing",
field=models.PositiveSmallIntegerField(
choices=[
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 4),
(5, 5),
(6, 6),
(7, 7),
(8, 8),
(9, 9),
(10, 10),
],
default=0,
verbose_name="reporter writing note",
),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 5.0.3 on 2024-04-16 21:59
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
(
"participation",
"0010_tournament_notes_sheet_id_alter_note_defender_oral_and_more",
),
]
operations = [
migrations.RemoveField(
model_name="note",
name="observer_oral",
),
migrations.RemoveField(
model_name="passage",
name="observer",
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 5.0.3 on 2024-04-16 22:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("participation", "0011_remove_note_observer_oral_remove_passage_observer"),
]
operations = [
migrations.AddField(
model_name="participation",
name="mention",
field=models.CharField(
blank=True, default="", max_length=255, verbose_name="mention"
),
),
migrations.AddField(
model_name="participation",
name="mention_final",
field=models.CharField(
blank=True, default="", max_length=255, verbose_name="mention (final)"
),
),
]

View File

@ -0,0 +1,31 @@
# Generated by Django 5.0.3 on 2024-04-17 20:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("participation", "0012_participation_mention_participation_mention_final"),
]
operations = [
migrations.AlterModelOptions(
name="pool",
options={
"ordering": ("round", "letter", "room"),
"verbose_name": "pool",
"verbose_name_plural": "pools",
},
),
migrations.AddField(
model_name="pool",
name="room",
field=models.PositiveSmallIntegerField(
choices=[(1, "Room 1"), (2, "Room 2")],
default=1,
help_text="For 5-teams pools only",
verbose_name="room",
),
),
]

View File

@ -0,0 +1,31 @@
# Generated by Django 5.0.6 on 2024-06-07 12:46
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("participation", "0013_alter_pool_options_pool_room"),
]
operations = [
migrations.AlterField(
model_name="team",
name="trigram",
field=models.CharField(
help_text="The code must be composed of 3 uppercase letters.",
max_length=3,
unique=True,
validators=[
django.core.validators.RegexValidator("^[A-Z]{3}$"),
django.core.validators.RegexValidator(
"^(?!BIT$|CNO$|CRO$|CUL$|FTG$|FCK$|FUC$|FUK$|FYS$|HIV$|IST$|MST$|KKK$|KYS$|SEX$)",
message="This team code is forbidden.",
),
],
verbose_name="code",
),
),
]

View File

@ -0,0 +1,42 @@
# Generated by Django 5.0.6 on 2024-06-07 13:51
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("participation", "0014_alter_team_trigram"),
]
operations = [
migrations.RemoveField(
model_name="tournament",
name="solutions_available_second_phase",
),
migrations.AddField(
model_name="tournament",
name="solutions_available_second_phase",
field=models.BooleanField(
default=False,
verbose_name="check this case when solutions for the second round become available",
),
),
migrations.AddField(
model_name="tournament",
name="solutions_available_third_phase",
field=models.BooleanField(
default=False,
verbose_name="check this case when solutions for the third round become available",
),
),
migrations.AddField(
model_name="tournament",
name="syntheses_third_phase_limit",
field=models.DateTimeField(
default=django.utils.timezone.now,
verbose_name="limit date to upload the syntheses for the third phase",
),
)
]

View File

@ -0,0 +1,35 @@
# Generated by Django 5.0.6 on 2024-06-07 14:01
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("participation", "0015_tournament_solutions_available_third_phase_and_more"),
]
operations = [
migrations.AddField(
model_name="tournament",
name="date_first_phase",
field=models.DateField(
default=datetime.date.today, verbose_name="first phase date"
),
),
migrations.AddField(
model_name="tournament",
name="date_second_phase",
field=models.DateField(
default=datetime.date.today, verbose_name="first second date"
),
),
migrations.AddField(
model_name="tournament",
name="date_third_phase",
field=models.DateField(
default=datetime.date.today, verbose_name="third phase date"
),
),
]

View File

@ -0,0 +1,77 @@
# Generated by Django 5.0.6 on 2024-06-13 08:53
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("participation", "0016_tournament_date_first_phase_and_more"),
]
operations = [
migrations.AlterField(
model_name="passage",
name="solution_number",
field=models.PositiveSmallIntegerField(
choices=[
(1, "Problem #1"),
(2, "Problem #2"),
(3, "Problem #3"),
(4, "Problem #4"),
(5, "Problem #5"),
(6, "Problem #6"),
(7, "Problem #7"),
(8, "Problem #8"),
(9, "Problem #9"),
(10, "Problem #10"),
],
verbose_name="defended solution",
),
),
migrations.AlterField(
model_name="pool",
name="round",
field=models.PositiveSmallIntegerField(
choices=[(1, "Round 1"), (2, "Round 2"), (3, "Round 3")],
verbose_name="round",
),
),
migrations.AlterField(
model_name="solution",
name="problem",
field=models.PositiveSmallIntegerField(
choices=[
(1, "Problem #1"),
(2, "Problem #2"),
(3, "Problem #3"),
(4, "Problem #4"),
(5, "Problem #5"),
(6, "Problem #6"),
(7, "Problem #7"),
(8, "Problem #8"),
(9, "Problem #9"),
(10, "Problem #10"),
],
verbose_name="problem",
),
),
migrations.AlterField(
model_name="team",
name="trigram",
field=models.CharField(
help_text="The code must be composed of 4 uppercase letters.",
max_length=4,
unique=True,
validators=[
django.core.validators.RegexValidator("^[A-Z]{3}[A-Z]*$"),
django.core.validators.RegexValidator(
"^(?!BIT$|CNO$|CRO$|CUL$|FTG$|FCK$|FUC$|FUK$|FYS$|HIV$|IST$|MST$|KKK$|KYS$|SEX$)",
message="This team code is forbidden.",
),
],
verbose_name="code",
),
),
]

View File

@ -0,0 +1,91 @@
# Generated by Django 5.0.6 on 2024-07-05 08:53
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"participation",
"0017_alter_passage_solution_number_alter_pool_round_and_more",
),
]
operations = [
migrations.RenameField(
model_name="note",
old_name="reporter_oral",
new_name="reviewer_oral",
),
migrations.RenameField(
model_name="note",
old_name="reporter_writing",
new_name="reviewer_writing",
),
migrations.RenameField(
model_name="passage",
old_name="reporter",
new_name="reviewer",
),
migrations.AlterField(
model_name="note",
name="reviewer_oral",
field=models.PositiveSmallIntegerField(
choices=[
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 4),
(5, 5),
(6, 6),
(7, 7),
(8, 8),
(9, 9),
(10, 10),
],
default=0,
verbose_name="reviewer oral note",
),
),
migrations.AlterField(
model_name="note",
name="reviewer_writing",
field=models.PositiveSmallIntegerField(
choices=[
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 4),
(5, 5),
(6, 6),
(7, 7),
(8, 8),
(9, 9),
(10, 10),
],
default=0,
verbose_name="reviewer writing note",
),
),
migrations.AlterField(
model_name="passage",
name="reviewer",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="+",
to="participation.participation",
verbose_name="reviewer",
),
),
migrations.AlterField(
model_name="synthesis",
name="type",
field=models.PositiveSmallIntegerField(
choices=[(1, "opponent"), (2, "reviewer")]
),
),
]

View File

@ -0,0 +1,86 @@
# Generated by Django 5.0.6 on 2024-07-05 09:47
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("participation", "0018_rename_reporter_to_reviewer"),
]
operations = [
migrations.AddField(
model_name="note",
name="observer_oral",
field=models.PositiveSmallIntegerField(
choices=[
(-10, -10),
(-9, -9),
(-8, -8),
(-7, -7),
(-6, -6),
(-5, -5),
(-4, -4),
(-3, -3),
(-2, -2),
(-1, -1),
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 4),
(5, 5),
(6, 6),
(7, 7),
(8, 8),
(9, 9),
(10, 10),
],
default=0,
verbose_name="observer oral note",
),
),
migrations.AddField(
model_name="note",
name="observer_writing",
field=models.PositiveSmallIntegerField(
choices=[
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 4),
(5, 5),
(6, 6),
(7, 7),
(8, 8),
(9, 9),
(10, 10),
],
default=0,
verbose_name="observer writing note",
),
),
migrations.AddField(
model_name="passage",
name="observer",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="participation.participation",
verbose_name="observer",
),
),
migrations.AlterField(
model_name="synthesis",
name="type",
field=models.PositiveSmallIntegerField(
choices=[(1, "opponent"), (2, "reviewer"), (3, "observer")]
),
),
]

View File

@ -0,0 +1,75 @@
# Generated by Django 5.0.6 on 2024-07-06 19:19
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("participation", "0019_note_observer_oral_note_observer_writing_and_more"),
]
operations = [
migrations.RenameModel(
old_name="Synthesis",
new_name="WrittenReview",
),
migrations.AlterModelOptions(
name="writtenreview",
options={
"ordering": ("passage__pool__round", "type"),
"verbose_name": "written review",
"verbose_name_plural": "written reviews",
},
),
migrations.RenameField(
model_name="tournament",
old_name="syntheses_first_phase_limit",
new_name="reviews_first_phase_limit",
),
migrations.RenameField(
model_name="tournament",
old_name="syntheses_second_phase_limit",
new_name="reviews_second_phase_limit",
),
migrations.RenameField(
model_name="tournament",
old_name="syntheses_third_phase_limit",
new_name="reviews_third_phase_limit",
),
migrations.AlterField(
model_name="tournament",
name="reviews_first_phase_limit",
field=models.DateTimeField(
default=django.utils.timezone.now,
verbose_name="limit date to upload the written reviews for the first phase",
),
),
migrations.AlterField(
model_name="tournament",
name="reviews_second_phase_limit",
field=models.DateTimeField(
default=django.utils.timezone.now,
verbose_name="limit date to upload the written reviews for the second phase",
),
),
migrations.AlterField(
model_name="tournament",
name="reviews_third_phase_limit",
field=models.DateTimeField(
default=django.utils.timezone.now,
verbose_name="limit date to upload the written reviews for the third phase",
),
),
migrations.AlterField(
model_name="writtenreview",
name="passage",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="written_reviews",
to="participation.passage",
verbose_name="passage",
),
),
]

View File

@ -0,0 +1,133 @@
# Generated by Django 5.0.6 on 2024-07-06 20:00
import django
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("participation", "0020_rename_synthesis_writtenreview_and_more"),
]
operations = [
migrations.RenameField(
model_name="note",
old_name="defender_oral",
new_name="reporter_oral",
),
migrations.RenameField(
model_name="note",
old_name="defender_writing",
new_name="reporter_writing",
),
migrations.RenameField(
model_name="passage",
old_name="defender",
new_name="reporter",
),
migrations.RenameField(
model_name="passage",
old_name="defender_penalties",
new_name="reporter_penalties",
),
migrations.AlterField(
model_name="passage",
name="solution_number",
field=models.PositiveSmallIntegerField(
choices=[
(1, "Problem #1"),
(2, "Problem #2"),
(3, "Problem #3"),
(4, "Problem #4"),
(5, "Problem #5"),
(6, "Problem #6"),
(7, "Problem #7"),
(8, "Problem #8"),
(9, "Problem #9"),
(10, "Problem #10"),
],
verbose_name="reported solution",
),
),
migrations.AlterField(
model_name="note",
name="reporter_oral",
field=models.PositiveSmallIntegerField(
choices=[
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 4),
(5, 5),
(6, 6),
(7, 7),
(8, 8),
(9, 9),
(10, 10),
(11, 11),
(12, 12),
(13, 13),
(14, 14),
(15, 15),
(16, 16),
(17, 17),
(18, 18),
(19, 19),
(20, 20),
],
default=0,
verbose_name="reporter oral note",
),
),
migrations.AlterField(
model_name="note",
name="reporter_writing",
field=models.PositiveSmallIntegerField(
choices=[
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 4),
(5, 5),
(6, 6),
(7, 7),
(8, 8),
(9, 9),
(10, 10),
(11, 11),
(12, 12),
(13, 13),
(14, 14),
(15, 15),
(16, 16),
(17, 17),
(18, 18),
(19, 19),
(20, 20),
],
default=0,
verbose_name="reporter writing note",
),
),
migrations.AlterField(
model_name="passage",
name="reporter",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="+",
to="participation.participation",
verbose_name="reporter",
),
),
migrations.AlterField(
model_name="passage",
name="reporter_penalties",
field=models.PositiveSmallIntegerField(
default=0,
help_text="Number of penalties for the reporter. The reporter will loose a 0.5 coefficient per penalty.",
verbose_name="penalties",
),
),
]

View File

@ -0,0 +1,44 @@
# Generated by Django 5.0.6 on 2024-07-11 08:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("participation", "0021_rename_defender_oral_note_reporter_oral_and_more"),
]
operations = [
migrations.AlterField(
model_name="note",
name="observer_oral",
field=models.SmallIntegerField(
choices=[
(-10, -10),
(-9, -9),
(-8, -8),
(-7, -7),
(-6, -6),
(-5, -5),
(-4, -4),
(-3, -3),
(-2, -2),
(-1, -1),
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 4),
(5, 5),
(6, 6),
(7, 7),
(8, 8),
(9, 9),
(10, 10),
],
default=0,
verbose_name="observer oral note",
),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 5.1.5 on 2025-01-14 18:06
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("participation", "0022_alter_note_observer_oral"),
]
operations = [
migrations.AddField(
model_name="tournament",
name="unified_registration",
field=models.BooleanField(
default=False, verbose_name="unified registration"
),
),
]

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,11 @@
# 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 django.conf import settings
from participation.models import Note, Participation, Passage, Pool, Team, Tournament
from registration.models import Payment
from tfjm.lists import get_sympa_client
@ -12,6 +15,8 @@ def create_team_participation(instance, created, raw, **_):
"""
if not raw:
participation = Participation.objects.get_or_create(team=instance)[0]
if settings.TFJM_APP == "ETEAM":
participation.tournament = Tournament.objects.first()
participation.save()
if not created:
participation.team.create_mailing_list()
@ -21,7 +26,7 @@ def update_mailing_list(instance: Team, raw, **_):
"""
When a team name or trigram got updated, update mailing lists
"""
if instance.pk and not raw:
if instance.pk and not raw and settings.ML_MANAGEMENT:
old_team = Team.objects.get(pk=instance.pk)
if old_team.trigram != instance.trigram:
# Delete old mailing list, create a new one
@ -36,6 +41,50 @@ def update_mailing_list(instance: Team, raw, **_):
f"{coach.user.first_name} {coach.user.last_name}")
def create_payments(instance: Participation, created, raw, **_):
"""
When a participation got created, create an associated payment.
"""
if instance.valid and not raw and settings.PAYMENT_MANAGEMENT:
for student in instance.team.students.all():
payment_qs = Payment.objects.filter(registrations=student, final=False)
if payment_qs.exists():
payment = payment_qs.get()
else:
payment = Payment.objects.create()
payment.registrations.add(student)
payment.save()
payment.amount = instance.tournament.price
if payment.amount == 0:
payment.type = "free"
payment.valid = True
payment.save()
if instance.final:
for student in instance.team.students.all():
payment_qs = Payment.objects.filter(registrations=student, final=True)
if payment_qs.exists():
payment = payment_qs.get()
else:
payment = Payment.objects.create(final=True)
payment.registrations.add(student)
payment_regional = Payment.objects.get(registrations=student, final=False)
if payment_regional.type == 'scholarship':
payment.type = 'scholarship'
with open(payment_regional.receipt.path, 'rb') as f:
payment.receipt.save(payment_regional.receipt.name, f)
payment.additional_information = payment_regional.additional_information
payment.fee = 0
payment.valid = payment_regional.valid
payment.save()
payment.amount = Tournament.final_tournament().price
if payment.amount == 0:
payment.type = "free"
payment.valid = True
payment.save()
def create_notes(instance: Union[Passage, Pool], raw, **_):
if not raw:
if isinstance(instance, Pool):

View File

@ -2,6 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from django.utils import formats
from django.utils.safestring import mark_safe
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
@ -90,7 +91,7 @@ class PoolTable(tables.Table):
)
def render_letter(self, record):
return format_lazy(_("Pool {letter}{round}"), letter=record.get_letter_display(), round=record.round)
return format_lazy(_("Pool {code}"), code=record.short_name)
def render_teams(self, record):
return ", ".join(participation.team.trigram for participation in record.participations.all()) \
@ -105,19 +106,24 @@ class PoolTable(tables.Table):
class PassageTable(tables.Table):
defender = tables.LinkColumn(
# FIXME Ne pas afficher l'équipe observatrice si non nécessaire
reporter = tables.LinkColumn(
"participation:passage_detail",
args=[tables.A("id")],
verbose_name=_("defender").capitalize,
verbose_name=_("reporter").capitalize,
)
def render_defender(self, value):
def render_reporter(self, value):
return value.team.trigram
def render_opponent(self, value):
return value.team.trigram
def render_reporter(self, value):
def render_reviewer(self, value):
return value.team.trigram
def render_observer(self, value):
return value.team.trigram
class Meta:
@ -125,7 +131,7 @@ class PassageTable(tables.Table):
'class': 'table table-condensed table-striped text-center',
}
model = Passage
fields = ('defender', 'opponent', 'reporter', 'solution_number', )
fields = ('reporter', 'opponent', 'reviewer', 'observer', 'solution_number', )
class NoteTable(tables.Table):
@ -137,10 +143,21 @@ class NoteTable(tables.Table):
}
)
update = tables.Column(
verbose_name=_("Update"),
accessor="id",
empty_values=(),
)
def render_update(self, record):
return mark_safe(f'<button class="btn btn-info" data-bs-toggle="modal" '
f'data-bs-target="#{record.modal_name}Modal">'
f'{_("Update")}</button>')
class Meta:
attrs = {
'class': 'table table-condensed table-striped text-center',
}
model = Note
fields = ('jury', 'defender_writing', 'defender_oral', 'opponent_writing', 'opponent_oral',
'reporter_writing', 'reporter_oral', 'observer_oral',)
fields = ('jury', 'reporter_writing', 'reporter_oral', 'opponent_writing', 'opponent_oral',
'reviewer_writing', 'reviewer_oral', 'observer_writing', 'observer_oral', 'update',)

View File

@ -1,13 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
The chat feature is now out of usage. If you feel that having a chat
feature between participants is important, for example to build a
team, please contact us.
{% endblocktrans %}
</div>
{% endblock %}

View File

@ -5,16 +5,42 @@
<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²
<p>
Bonjour {{ registration }},
</p>
<p>
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.
</p>
{% if payment %}
<p>
Vous devez désormais vous acquitter de vos frais de participation, de {{ payment.amount }} € par élève.
Vous pouvez payer par carte bancaire ou par virement bancaire. Vous trouverez les informations
sur <a href="https://{{ domain }}{% url 'registration:update_payment' pk=payment.pk %}">la page de paiement</a>.
Si vous disposez d'une bourse, l'inscription est gratuite, mais vous devez soumettre un justificatif
sur la même page.
</p>
{% elif registration.is_coach and team.participation.tournament.price %}
<p>
Votre équipe doit désormais s'acquitter des frais de participation de {{ team.participation.tournament.price }} €
par élève (les encadrant⋅es sont exonéré⋅es). Les élèves qui disposent d'une bourse sont exonéré⋅es de ces frais.
Vous pouvez suivre l'état des paiements sur
<a href="https://{{ domain }}{% url 'participation:team_detail' pk=team.pk %}">la page de votre équipe</a>.
</p>
{% endif %}
{% if message %}
<p>
Les organisateur⋅ices vous adressent ce message :
</p>
<p>
{{ message }}
</p>
{% endif %}
<p>
Le comité d'organisation du TFJM²
</p>
</body>
</html>

View File

@ -1,12 +1,23 @@
Bonjour,
Bonjour {{ registration }},
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 :
{% if team.participation.amount %}
Vous devez désormais vous acquitter de vos frais de participation, de {{ team.participation.amount }} €.
Vous pouvez payer par carte bancaire ou par virement bancaire. Vous trouverez les informations
sur la page de paiement que vous pouvez retrouver sur votre compte :
https://{{ domain }}{% url 'registration:my_account_detail' %}
Si vous disposez d'une bourse, l'inscription est gratuite, mais vous devez soumettre un justificatif
sur la même page.
{% elif registration.is_coach and team.participation.tournament.price %}
Votre équipe doit désormais s'acquitter des frais de participation de {{ team.participation.tournament.price }} €
par élève (les encadrant⋅es sont exonéré⋅es). Les élèves qui disposent d'une bourse sont exonéré⋅es de ces frais.
Vous pouvez suivre l'état des paiements sur la page de votre équipe :
https://{{ domain }}{% url 'participation:team_detail' pk=team.pk %}
{% endif %}
{% if message %}
Les organisateurices vous adressent ce message :
{{ message }}
Cordialement,
{% endif %}
Le comité d'organisation du TFJM²

View File

@ -5,6 +5,9 @@
{% block content %}
<form method="post">
<div id="form-content">
<h4>{% trans "Notes of" %} {{ note.jury }}</h4>
<h5>{% trans "Defense of" %} {{ note.passage.reporter.team.trigram }}, {% trans "Pb." %} {{ note.passage.solution_number }}</h5>
<hr>
{% csrf_token %}
{{ form|crispy }}
</div>

View File

@ -30,6 +30,12 @@
{% empty %}
<li>{% trans "No solution was uploaded yet." %}</li>
{% endfor %}
<li>
<a href="{% url "participation:participation_solutions" team_id=participation.team_id %}"
class="btn btn-sm btn-info">
<i class="fas fa-archive"></i> {% trans "Download as ZIP" %}
</a>
</li>
</ul>
</dd>

View File

@ -6,7 +6,16 @@
{% trans "any" as any %}
<div class="card bg-body shadow">
<div class="card-header text-center">
<h4>{{ passage }}</h4>
<h4>
{{ passage }}
{% if user.registration.is_admin or user.registration in passage.pool.tournament.organizers.all %}
<button class="btn btn-sm btn-secondary"
data-bs-toggle="modal" data-bs-target="#updatePassageModal">
<i class="fas fa-edit"></i>
{% trans "Update" %}
</button>
{% endif %}
</h4>
</div>
<div class="card-body">
<dl class="row">
@ -16,32 +25,32 @@
<dt class="col-sm-3">{% trans "Position:" %}</dt>
<dd class="col-sm-9">{{ passage.position }}</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 "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 "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 "Reviewer:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.reviewer.get_absolute_url }}">{{ passage.reviewer.team }}</a></dd>
{% if passage.observer %}
<dt class="col-sm-3">{% trans "Observer:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.observer.get_absolute_url }}">{{ passage.observer.team }}</a></dd>
{% endif %}
<dt class="col-sm-3">{% trans "Defended solution:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.defended_solution.file.url }}">{{ passage.defended_solution }}</a></dd>
<dt class="col-sm-3">{% trans "Reported solution:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.reported_solution.file.url }}">{{ passage.reported_solution }}</a></dd>
<dt class="col-sm-3">{% trans "Defender penalties count:" %}</dt>
<dd class="col-sm-9">{{ passage.defender_penalties }}</dd>
<dt class="col-sm-3">{% trans "Reporter penalties count:" %}</dt>
<dd class="col-sm-9">{{ passage.reporter_penalties }}</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 }}">{{ synthesis }}{% if not forloop.last %}, {% endif %}</a>
{% for review in passage.written_reviews.all %}
<a href="{{ review.file.url }}">{{ review }}{% if not forloop.last %}, {% endif %}</a>
{% empty %}
{% trans "No synthesis was uploaded yet." %}
{% trans "No review was uploaded yet." %}
{% endfor %}
</dd>
</dl>
@ -49,13 +58,12 @@
{% if notes is not None %}
<div class="card-footer text-center">
{% if my_note is not None %}
<button class="btn btn-info" data-bs-toggle="modal" data-bs-target="#updateNotesModal">{% trans "Update notes" %}</button>
<button class="btn btn-info" data-bs-toggle="modal" data-bs-target="#{{ my_note.modal_name }}Modal">{% trans "Update notes" %}</button>
{% endif %}
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updatePassageModal">{% trans "Update" %}</button>
</div>
{% elif user.registration.participates %}
<div class="card-footer text-center">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadSynthesisModal">{% trans "Upload synthesis" %}</button>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadWrittenReviewModal">{% trans "Upload review" %}</button>
</div>
{% endif %}
</div>
@ -70,45 +78,93 @@
<div class="card bg-body 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|floatformat }}/20</dd>
<dt class="col-sm-8">
{% trans "Average points for the reporter writing" %}
({{ passage.reporter.team.trigram }}) :
</dt>
<dd class="col-sm-4">
{{ passage.average_reporter_writing|floatformat }}/{% if TFJM_APP == "TFJM" %}20{% else %}10{% endif %}
</dd>
<dt class="col-sm-8">{% trans "Average points for the defender oral:" %}</dt>
<dd class="col-sm-4">{{ passage.average_defender_oral|floatformat }}/16</dd>
<dt class="col-sm-8">
{% trans "Average points for the reporter oral" %}
({{ passage.reporter.team.trigram }}) :
</dt>
<dd class="col-sm-4">
{{ passage.average_reporter_oral|floatformat }}/{% if TFJM_APP == "TFJM" %}20{% else %}10{% endif %}
</dd>
<dt class="col-sm-8">{% trans "Average points for the opponent writing:" %}</dt>
<dd class="col-sm-4">{{ passage.average_opponent_writing|floatformat }}/9</dd>
<dt class="col-sm-8">
{% trans "Average points for the opponent writing" %}
({{ passage.opponent.team.trigram }}) :
</dt>
<dd class="col-sm-4">{{ passage.average_opponent_writing|floatformat }}/10</dd>
<dt class="col-sm-8">{% trans "Average points for the opponent oral:" %}</dt>
<dt class="col-sm-8">
{% trans "Average points for the opponent oral" %}
({{ passage.opponent.team.trigram }}) :
</dt>
<dd class="col-sm-4">{{ passage.average_opponent_oral|floatformat }}/10</dd>
<dt class="col-sm-8">{% trans "Average points for the reporter writing:" %}</dt>
<dd class="col-sm-4">{{ passage.average_reporter_writing|floatformat }}/9</dd>
<dt class="col-sm-8">
{% trans "Average points for the reviewer writing" %}
({{ passage.reviewer.team.trigram }}) :
</dt>
<dd class="col-sm-4">{{ passage.average_reviewer_writing|floatformat }}/10</dd>
<dt class="col-sm-8">{% trans "Average points for the reporter oral:" %}</dt>
<dd class="col-sm-4">{{ passage.average_reporter_oral|floatformat }}/10</dd>
<dt class="col-sm-8">
{% trans "Average points for the reviewer oral" %}
({{ passage.reviewer.team.trigram }}) :
</dt>
<dd class="col-sm-4">{{ passage.average_reviewer_oral|floatformat }}/10</dd>
{% if passage.observer %}
<dt class="col-sm-8">{% trans "Average points for the observer oral:" %}</dt>
<dd class="col-sm-4">{{ passage.average_observer|floatformat }}/4</dd>
<dt class="col-sm-8">
{% trans "Average points for the observer writing" %}
({{ passage.observer.team.trigram }}) :
</dt>
<dd class="col-sm-4">{{ passage.average_observer_writing|floatformat }}/10</dd>
<dt class="col-sm-8">
{% trans "Average points for the observer oral" %}
({{ passage.observer.team.trigram }}) :
</dt>
<dd class="col-sm-4">{{ passage.average_observer_oral|floatformat }}/10</dd>
{% endif %}
</dl>
<hr>
<dl class="row">
<dt class="col-sm-8">{% trans "Defender points:" %}</dt>
<dd class="col-sm-4">{{ passage.average_defender|floatformat }}/52</dd>
<dt class="col-sm-8">
{% trans "Reporter points" %}
({{ passage.reporter.team.trigram }}) :
</dt>
<dd class="col-sm-4">
{{ passage.average_reporter|floatformat }}/{% if TFJM_APP == "TFJM" %}52{% else %}50{% endif %}
</dd>
<dt class="col-sm-8">{% trans "Opponent points:" %}</dt>
<dd class="col-sm-4">{{ passage.average_opponent|floatformat }}/29</dd>
<dt class="col-sm-8">
{% trans "Opponent points" %}
({{ passage.opponent.team.trigram }}) :
</dt>
<dd class="col-sm-4">
{{ passage.average_opponent|floatformat }}/{% if TFJM_APP == "TFJM" %}29{% else %}{% if passage.observer %}26{% else %}29{% endif %}{% endif %}
</dd>
<dt class="col-sm-8">{% trans "Reporter points:" %}</dt>
<dd class="col-sm-4">{{ passage.average_reporter|floatformat }}/19</dd>
<dt class="col-sm-8">
{% trans "reviewer points" %}
({{ passage.reviewer.team.trigram }}) :
</dt>
<dd class="col-sm-4">{{ passage.average_reviewer|floatformat }}/{% if TFJM_APP == "TFJM" %}19{% else %}{% if passage.observer %}18{% else %}21{% endif %}{% endif %}</dd>
{% if passage.observer %}
<dt class="col-sm-8">{% trans "Observer points:" %}</dt>
<dd class="col-sm-4">{{ passage.average_observer|floatformat }}/4</dd>
<dt class="col-sm-8">
{% trans "observer points" %}
({{ passage.observer.team.trigram }}) :
</dt>
<dd class="col-sm-4">{{ passage.average_observer|floatformat }}/6</dd>
{% endif %}
</dl>
</div>
@ -121,17 +177,17 @@
{% url "participation:passage_update" pk=passage.pk as modal_action %}
{% include "base_modal.html" with modal_id="updatePassage" %}
{% if my_note is not None %}
{% for note in notes.data %}
{% 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" %}
{% endif %}
{% url "participation:update_notes" pk=note.pk as modal_action %}
{% include "base_modal.html" with modal_id=note.modal_name %}
{% endfor %}
{% elif user.registration.participates %}
{% trans "Upload synthesis" as modal_title %}
{% trans "Upload review" 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" %}
{% url "participation:upload_written_review" pk=passage.pk as modal_action %}
{% include "base_modal.html" with modal_id="uploadWrittenReview" modal_enctype="multipart/form-data" %}
{% endif %}
{% endblock %}
@ -141,12 +197,12 @@
{% if notes is not None %}
initModal("updatePassage", "{% url "participation:passage_update" pk=passage.pk %}")
{% if my_note is not None %}
initModal("updateNotes", "{% url "participation:update_notes" pk=my_note.pk %}")
{% endif %}
{% for note in notes.data %}
initModal("{{ note.modal_name }}", "{% url "participation:update_notes" pk=note.pk %}")
{% endfor %}
{% elif user.registration.participates %}
initModal("uploadSynthesis", "{% url "participation:upload_synthesis" pk=passage.pk %}")
initModal("uploadWrittenReview", "{% url "participation:upload_written_review" pk=passage.pk %}")
{% endif %}
});
})
</script>
{% endblock %}

View File

@ -1,47 +0,0 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% load i18n %}
{% block content %}
<div class="alert alert-info">
<p>
{% trans "You can here register juries for the pool." %}
{% trans "Be careful: this form register new users. To add existing users into the jury, please use this form:" %}
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updatePoolModal">{% trans "Update pool" %}</button>
</p>
<p>
{% trans "For now, the registered juries for the tournament are:" %}
<ul>
{% for jury in pool.juries.all %}
<li>{{ jury.user.first_name }} {{ jury.user.last_name }} (<a class="alert-link" href="mailto:{{ jury.user.email }}">{{ jury.user.email }}</a>)</li>
{% empty %}
<li><i>{% trans "There is no jury yet." %}</i></li>
{% endfor %}
</ul>
</p>
</div>
{% crispy form %}
<hr>
<div class="row text-center">
<a href="{% url 'participation:pool_detail' pk=pool.pk %}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> {% trans "Back to pool detail" %}
</a>
</div>
{% 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" %}
{% endblock %}
{% block extrajavascript %}
<script>
document.addEventListener('DOMContentLoaded', () => {
initModal("updatePool", "{% url "participation:pool_update" pk=pool.pk %}")
})
</script>
{% endblock %}

View File

@ -5,7 +5,15 @@
{% block content %}
<div class="card bg-body shadow">
<div class="card-header text-center">
<h4>{{ pool }}</h4>
<h4>
{{ pool }}
{% if user.registration.is_admin or user.registration in pool.tournament.organizers.all %}
<button class="btn btn-sm btn-secondary" data-bs-toggle="modal" data-bs-target="#updatePoolModal">
<i class="fas fa-edit"></i>
{% trans "Update" %}
</button>
{% endif %}
</h4>
</div>
<div class="card-body">
<dl class="row">
@ -17,6 +25,11 @@
<dt class="col-sm-3">{% trans "Letter:" %}</dt>
<dd class="col-sm-9">{{ pool.get_letter_display }}</dd>
{% if pool.participations.count == 5 %}
<dt class="col-sm-3">{% trans "Room:" %}</dt>
<dd class="col-sm-9">{{ pool.get_room_display }}</dd>
{% endif %}
<dt class="col-sm-3">{% trans "Teams:" %}</dt>
<dd class="col-sm-9">
@ -28,17 +41,17 @@
<dt class="col-sm-3">{% trans "Juries:" %}</dt>
<dd class="col-sm-9">
{{ pool.juries.all|join:", " }}
<a class="badge rounded-pill text-bg-info" href="{% url 'participation:pool_add_jurys' pk=pool.pk %}">
<i class="fas fa-plus"></i> {% trans "Add jurys" %}
<a class="badge rounded-pill text-bg-info" href="{% url 'participation:pool_jury' pk=pool.pk %}">
<i class="fas fa-plus"></i> {% trans "Edit jury" %}
</a>
</dd>
<dt class="col-sm-3">{% trans "Defended solutions:" %}</dt>
<dt class="col-sm-3">{% trans "Reported solutions:" %}</dt>
<dd class="col-sm-9">
{% for passage in pool.passages.all %}
<a href="{{ passage.defended_solution.file.url }}">{{ passage.defender.team.trigram }} — {{ passage.get_solution_number_display }}</a>{% if not forloop.last %}, {% endif %}
<a href="{{ passage.reported_solution.file.url }}">{{ passage.reporter.team.trigram }} — {{ passage.get_solution_number_display }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
<a href="{% url 'participation:pool_download_solutions' pk=pool.pk %}" class="badge rounded-pill text-bg-secondary">
<a href="{% url 'participation:pool_download_solutions' pool_id=pool.id %}" class="badge rounded-pill text-bg-secondary">
<i class="fas fa-download"></i> {% trans "Download all" %}
</a>
</dd>
@ -48,22 +61,54 @@
<ul class="list-group list-group-flush">
{% for passage in pool.passages.all %}
<li class="list-group-item">
{{ passage.defender.team.trigram }} — {{ passage.get_solution_number_display }} :
{% for synthesis in passage.syntheses.all %}
<a href="{{ synthesis.file.url }}">{{ synthesis.participation.team.trigram }} ({{ synthesis.get_type_display }})</a>{% if not forloop.last %}, {% endif %}
{{ passage.reporter.team.trigram }} — {{ passage.get_solution_number_display }} :
{% for review in passage.written_reviews.all %}
<a href="{{ review.file.url }}">{{ review.participation.team.trigram }} ({{ review.get_type_display }})</a>{% if not forloop.last %}, {% endif %}
{% empty %}
{% trans "No synthesis was uploaded yet." %}
{% trans "No review was uploaded yet." %}
{% endfor %}
</li>
{% endfor %}
</ul>
<a href="{% url 'participation:pool_download_syntheses' pk=pool.pk %}" class="badge rounded-pill text-bg-secondary">
<a href="{% url 'participation:pool_download_written_reviews' pool_id=pool.id %}" class="badge rounded-pill text-bg-secondary">
<i class="fas fa-download"></i> {% trans "Download all" %}
</a>
</dd>
<dt class="col-sm-3">{% trans "BigBlueButton link:" %}</dt>
<dd class="col-sm-9">{{ pool.bbb_url|urlize }}</dd>
{% if pool.bbb_url %}
<dt class="col-sm-3">{% trans "BigBlueButton link:" %}</dt>
<dd class="col-sm-9">{{ pool.bbb_url|urlize }}</dd>
{% endif %}
{% if user.registration.is_admin or user.registration.is_volunteer %}
{% if user.registration.is_admin or user.registration in pool.tournament.organizers.all or user.registration == pool.jury_president %}
<dt class="col-sm-3">{% trans "Notation sheets:" %}</dt>
<dd class="col-sm-9">
<div class="btn-group">
<a class="btn btn-sm btn-info" href="{% url 'participation:pool_scale_note_sheet' pk=pool.pk %}">
<i class="fas fa-download"></i>
{% trans "Download the scale sheet" %}
</a>
<a class="btn btn-sm btn-info" href="{% url 'participation:pool_final_note_sheet' pk=pool.pk %}">
<i class="fas fa-download"></i>
{% trans "Download the final notation sheet" %}
</a>
<a class="btn btn-sm btn-info" href="{% url 'participation:pool_notation_sheets' pool_id=pool.id %}">
<i class="fas fa-archive"></i>
{% trans "Download all notation sheets" %}
</a>
</div>
</dd>
<dt class="col-sm-3">{% trans "Google Sheets Spreadsheet:" %}</dt>
<dd class="col-sm-9">
<a class="btn btn-sm btn-success" href="https://docs.google.com/spreadsheets/d/{{ pool.tournament.notes_sheet_id }}/edit">
<i class="fas fa-table"></i>
{% trans "Go to the Google Sheets page of the pool" %}
</a>
</dd>
{% endif %}
{% endif %}
</dl>
<div class="card bg-body shadow">
@ -77,40 +122,24 @@
{% endfor %}
</ul>
</div>
{% if user.registration.is_volunteer %}
<div class="card-footer text-center">
<div class="btn-group">
<a class="btn btn-info" href="{% url 'participation:pool_scale_note_sheet' pk=pool.pk %}">
{% trans "Download the scale sheet" %}{% if pool.passages.count == 5 %} — {% trans "Room" %} 1{% endif %}
</a>
{% if pool.passages.count == 5 %}
<a class="btn btn-info" href="{% url 'participation:pool_scale_note_sheet' pk=pool.pk %}?page=2">
{% trans "Room" %} 2
{% if user.registration.is_admin or user.registration.is_volunteer %}
{% if user.registration.is_admin or user.registration in pool.tournament.organizers.all or user.registration == pool.jury_president %}
<div class="card-footer text-center">
<div class="btn btn-group">
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#uploadNotesModal">
<i class="fas fa-upload"></i>
{% trans "Upload notes from a spreadsheet file" %}
</button>
<a class="btn btn-sm btn-info" href="{% url 'participation:pool_notes_template' pk=pool.pk %}">
<i class="fas fa-download"></i>
{% trans "Download notation spreadsheet" %}
</a>
{% endif %}
</div>
</div>
<div class="btn-group">
<a class="btn btn-info" href="{% url 'participation:pool_final_note_sheet' pk=pool.pk %}">
{% trans "Download the final notation sheet" %}{% if pool.passages.count == 5 %} — {% trans "Room" %} 1{% endif %}
</a>
{% if pool.passages.count == 5 %}
<a class="btn btn-info" href="{% url 'participation:pool_final_note_sheet' pk=pool.pk %}?page=2">
{% trans "Room" %} 2
</a>
{% endif %}
</div>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadNotesModal">{% trans "Upload notes from a CSV file" %}</button>
</div>
{% endif %}
{% endif %}
</div>
</div>
{% if user.registration.is_volunteer %}
<div class="card-footer text-center">
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#addPassageModal">{% trans "Add passage" %}</button>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updatePoolModal">{% trans "Update" %}</button>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updateTeamsModal">{% trans "Update teams" %}</button>
</div>
{% endif %}
</div>
<hr>
@ -119,21 +148,11 @@
{% 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" %}
{% trans "Upload notes" as modal_title %}
{% trans "Upload" as modal_button %}
{% url "participation:pool_upload_notes" pk=pool.pk as modal_action %}
@ -144,8 +163,6 @@
<script>
document.addEventListener('DOMContentLoaded', () => {
initModal("updatePool", "{% url "participation:pool_update" pk=pool.pk %}")
initModal("updateTeams", "{% url "participation:pool_update_teams" pk=pool.pk %}")
initModal("addPassage", "{% url "participation:passage_create" pk=pool.pk %}")
initModal("uploadNotes", "{% url "participation:pool_upload_notes" pk=pool.pk %}")
})
</script>

View File

@ -0,0 +1,141 @@
{% extends "base.html" %}
{% load crispy_forms_tags crispy_forms_filters %}
{% load i18n %}
{% block content %}
<div class="alert alert-info">
<p>
{% blocktrans trimmed %}
On this page, you can manage the juries of the pool. You can add a new jury by entering the email address
of the jury. If the jury is not registered, the account will be created automatically. If the jury already
exists, its account will be autocompleted and directly linked to the pool.
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
On this page, you can also define the president of the jury, who will have the right to see all solutions
and if necessary define the notes of other jury members.
{% endblocktrans %}
</p>
</div>
<hr>
{% for jury in pool.juries.all %}
<div class="row my-3 px-0">
<div class="col-md-5 px-1">
<input type="email" class="form-control" value="{{ jury.user.email }}" disabled>
</div>
<div class="col-md-3 px-1">
<input type="text" class="form-control" value="{{ jury.user.first_name }}" disabled>
</div>
<div class="col-md-3 px-1">
<input type="text" class="form-control" value="{{ jury.user.last_name }}" disabled>
</div>
<div class="col-md-1 px-1">
<div class="btn-group-vertical btn-group-sm">
{% if jury == pool.jury_president %}
<button class="btn btn-success">
<i class="fas fa-crown"></i> {% trans "PoJ" %}
</button>
{% else %}
<a href="{% url 'participation:pool_preside' pk=pool.pk jury_id=jury.id %}"
class="btn btn-warning">
<i class="fas fa-crown"></i> {% trans "Preside" %}
</a>
{% endif %}
<a href="{% url 'participation:pool_remove_jury' pk=pool.pk jury_id=jury.id %}"
class="btn btn-danger">
<i class="fas fa-trash"></i> {% trans "Remove" %}
</a>
</div>
</div>
</div>
{% endfor %}
{{ form|as_crispy_errors }}
{% crispy form %}
<datalist id="juries-email">
</datalist>
<datalist id="juries-first-name">
</datalist>
<datalist id="juries-last-name">
</datalist>
<hr>
<div class="row text-center">
<a href="{% url 'participation:pool_detail' pk=pool.pk %}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> {% trans "Back to pool detail" %}
</a>
</div>
{% endblock %}
{% block extrajavascript %}
<script>
const emailField = document.getElementById('id_email')
const firstNameField = document.getElementById('id_first_name')
const lastNameField = document.getElementById('id_last_name')
const juriesEmailList = document.getElementById('juries-email')
const juriesFirstNameList = document.getElementById('juries-first-name')
const juriesLastNameList = document.getElementById('juries-last-name')
function updateJuries(filter) {
fetch(`/api/registration/volunteers/?search=${filter}`)
.then(response => response.json())
.then(response => response.results)
.then(data => {
juriesEmailList.innerHTML = ''
juriesFirstNameList.innerHTML = ''
juriesLastNameList.innerHTML = ''
data.forEach(jury => {
const optionEmail = document.createElement('option')
optionEmail.value = jury.email
optionEmail.setAttribute('data-id', jury.id)
optionEmail.text = `${jury.first_name} ${jury.last_name} (${jury.email})`
juriesEmailList.appendChild(optionEmail)
const optionFirstName = document.createElement('option')
optionFirstName.value = jury.first_name
optionFirstName.setAttribute('data-id', jury.id)
optionFirstName.text = `${jury.first_name} ${jury.last_name} (${jury.email})`
juriesFirstNameList.appendChild(optionFirstName)
const optionLastName = document.createElement('option')
optionLastName.value = jury.last_name
optionLastName.setAttribute('data-id', jury.id)
optionLastName.text = `${jury.first_name} ${jury.last_name} (${jury.email})`
juriesLastNameList.appendChild(optionLastName)
})
})
}
emailField.addEventListener('input', event => {
let emailOption = document.querySelector(`datalist[id="juries-email"] > option[value="${event.target.value}"]`)
if (emailOption) {
let id = emailOption.getAttribute('data-id')
let firstNameOption = document.querySelector(`datalist[id="juries-first-name"] > option[data-id="${id}"]`)
let lastNameOption = document.querySelector(`datalist[id="juries-last-name"] > option[data-id="${id}"]`)
if (firstNameOption && lastNameOption) {
firstNameField.value = firstNameOption.value
lastNameField.value = lastNameOption.value
}
}
updateJuries(event.target.value)
})
firstNameField.addEventListener('input', event => {
updateJuries(event.target.value)
})
lastNameField.addEventListener('input', event => {
updateJuries(event.target.value)
})
</script>
{% endblock %}

View File

@ -10,19 +10,19 @@
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-6 text-end">{% trans "Name:" %}</dt>
<dt class="col-sm-6 text-sm-end">{% trans "Name:" %}</dt>
<dd class="col-sm-6">{{ team.name }}</dd>
<dt class="col-sm-6 text-end">{% trans "Trigram:" %}</dt>
<dt class="col-sm-6 text-sm-end">{% trans "Trigram:" %}</dt>
<dd class="col-sm-6">{{ team.trigram }}</dd>
<dt class="col-sm-6 text-end">{% trans "Email:" %}</dt>
<dt class="col-sm-6 text-sm-end">{% trans "Email:" %}</dt>
<dd class="col-sm-6"><a href="mailto:{{ team.email }}">{{ team.email }}</a></dd>
<dt class="col-sm-6 text-end">{% trans "Access code:" %}</dt>
<dt class="col-sm-6 text-sm-end">{% trans "Access code:" %}</dt>
<dd class="col-sm-6">{{ team.access_code }}</dd>
<dt class="col-sm-6 text-end">{% trans "Coaches:" %}</dt>
<dt class="col-sm-6 text-sm-end">{% 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 %}
@ -31,7 +31,7 @@
{% endfor %}
</dd>
<dt class="col-sm-6 text-end">{% trans "Participants:" %}</dt>
<dt class="col-sm-6 text-sm-end">{% 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 %}
@ -40,7 +40,7 @@
{% endfor %}
</dd>
<dt class="col-sm-6 text-end">{% trans "Tournament:" %}</dt>
<dt class="col-sm-6 text-sm-end">{% 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>
@ -49,7 +49,7 @@
{% endif %}
</dd>
<dt class="col-sm-6 text-end">{% trans "Photo authorizations:" %}</dt>
<dt class="col-sm-6 text-sm-end">{% trans "Photo authorizations:" %}</dt>
<dd class="col-sm-6">
{% for participant in team.participants.all %}
{% if participant.photo_authorization %}
@ -60,34 +60,51 @@
{% endfor %}
</dd>
{% if not team.participation.tournament.remote %}
<dt class="col-sm-6 text-end">{% trans "Health sheets:" %}</dt>
{% if team.participation.final %}
<dt class="col-sm-6 text-sm-end">{% trans "Photo authorizations (final):" %}</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 }}">{{ student }}</a>{% if not forloop.last %},{% endif %}
{% else %}
{{ student }} ({% trans "Not uploaded yet" %}){% if not forloop.last %},{% endif %}
{% endif %}
{% for participant in team.participants.all %}
{% if participant.photo_authorization_final %}
<a href="{{ participant.photo_authorization_final.url }}">{{ participant }}</a>{% if not forloop.last %},{% endif %}
{% else %}
{{ participant }} ({% trans "Not uploaded yet" %}){% if not forloop.last %},{% endif %}
{% endif %}
{% endfor %}
</dd>
{% endif %}
<dt class="col-sm-6 text-end">{% trans "Vaccine sheets:" %}</dt>
<dd class="col-sm-6">
{% for student in team.students.all %}
{% if student.under_18 %}
{% if student.vaccine_sheet %}
<a href="{{ student.vaccine_sheet.url }}">{{ student }}</a>{% if not forloop.last %},{% endif %}
{% else %}
{{ student }} ({% trans "Not uploaded yet" %}){% if not forloop.last %},{% endif %}
{% if not team.participation.tournament.remote %}
{% if TFJM.HEALTH_SHEET_REQUIRED %}
<dt class="col-sm-6 text-sm-end">{% 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 }}">{{ student }}</a>{% if not forloop.last %},{% endif %}
{% else %}
{{ student }} ({% trans "Not uploaded yet" %}){% if not forloop.last %},{% endif %}
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
</dd>
{% endfor %}
</dd>
{% endif %}
<dt class="col-sm-6 text-end">{% trans "Parental authorizations:" %}</dt>
{% if TFJM.VACCINE_SHEET_REQUIRED %}
<dt class="col-sm-6 text-sm-end">{% trans "Vaccine sheets:" %}</dt>
<dd class="col-sm-6">
{% for student in team.students.all %}
{% if student.under_18 %}
{% if student.vaccine_sheet %}
<a href="{{ student.vaccine_sheet.url }}">{{ 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-sm-end">{% trans "Parental authorizations:" %}</dt>
<dd class="col-sm-6">
{% for student in team.students.all %}
{% if student.under_18 %}
@ -99,30 +116,70 @@
{% endif %}
{% endfor %}
</dd>
{% if team.participation.final %}
<dt class="col-sm-6 text-sm-end">{% trans "Parental authorizations (final):" %}</dt>
<dd class="col-sm-6">
{% for student in team.students.all %}
{% if student.under_18_final %}
{% if student.parental_authorization_final %}
<a href="{{ student.parental_authorization_final.url }}">{{ student }}</a>{% if not forloop.last %},{% endif %}
{% else %}
{{ student }} ({% trans "Not uploaded yet" %}){% if not forloop.last %},{% endif %}
{% endif %}
{% endif %}
{% endfor %}
</dd>
{% endif %}
{% endif %}
<dt class="col-sm-6 text-end">{% trans "Motivation letter:" %}</dt>
<dd class="col-sm-6">
{% if team.motivation_letter %}
<a href="{{ team.motivation_letter.url }}">{% 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-bs-toggle="modal" data-bs-target="#uploadMotivationLetterModal">{% trans "Replace" %}</button>
{% endif %}
</dd>
</dl>
{% if TFJM.MOTIVATION_LETTER_REQUIRED %}
<dt class="col-sm-6 text-sm-end">{% trans "Motivation letter:" %}</dt>
<dd class="col-sm-6">
{% if team.motivation_letter %}
<a href="{{ team.motivation_letter.url }}">{% 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-bs-toggle="modal" data-bs-target="#uploadMotivationLetterModal">{% trans "Replace" %}</button>
{% endif %}
</dd>
{% endif %}
{% if user.registration.is_volunteer %}
{% if user.registration in self.team.participation.tournament.organizers or user.registration.is_admin %}
<div class="text-center">
<a class="btn btn-info" href="{% url "participation:team_authorizations" pk=team.pk %}">
<a class="btn btn-info" href="{% url "participation:team_authorizations" team_id=team.id %}">
<i class="fas fa-file-archive"></i> {% trans "Download all submitted authorizations" %}
</a>
</div>
{% endif %}
{% endif %}
{% if team.participation.valid %}
<hr class="my-3">
{% for student in team.students.all %}
{% for payment in student.payments.all %}
<dt class="col-sm-6 text-sm-end">
{% trans "Payment of" %} {{ student }}
{% if payment.grouped %}({% trans "grouped" %}){% endif %}
{% if payment.final %} ({% trans "final" %}){% endif %} :
</dt>
<dd class="col-sm-6">
Valide : {{ payment.valid|yesno }}
{% if payment.valid is False or user.registration.is_volunteer %}
{% if user.registration in payment.registrations.all or user.registration.is_coach or user.registration.is_volunteer %}
<a href="{% url "registration:update_payment" pk=payment.pk %}" class="btn btn-secondary">
<i class="fas fa-money-bill-wave"></i> {% trans "Update payment" %}
</a>
{% endif %}
{% endif %}
</dd>
{% endfor %}
{% endfor %}
{% endif %}
</dl>
</div>
<div class="card-footer text-center">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updateTeamModal">{% trans "Update" %}</button>
@ -183,10 +240,12 @@
{% 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" %}
{% if TFJM.MOTIVATION_LETTER_REQUIRED %}
{% 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" %}
{% endif %}
{% trans "Update team" as modal_title %}
{% trans "Update" as modal_button %}
@ -202,7 +261,9 @@
{% block extrajavascript %}
<script>
document.addEventListener('DOMContentLoaded', () => {
initModal("uploadMotivationLetter", "{% url "participation:upload_team_motivation_letter" pk=team.pk %}")
{% if TFJM.MOTIVATION_LETTER_REQUIRED %}
initModal("uploadMotivationLetter", "{% url "participation:upload_team_motivation_letter" pk=team.pk %}")
{% endif %}
initModal("updateTeam", "{% url "participation:update_team" pk=team.pk %}")
initModal("leaveTeam", "{% url "participation:team_leave" %}")
})

View File

@ -2,7 +2,7 @@
{% load django_tables2 i18n %}
{% block contenttitle %}
{% block content-title %}
<h1>{% trans "All teams" %}</h1>
{% endblock %}

View File

@ -1,126 +0,0 @@
\documentclass[12pt,a4paper,landscape]{article}
\usepackage[T1]{fontenc}
\usepackage[utf8x]{inputenc}
\usepackage[french]{babel}
\usepackage[a4paper]{geometry}
\usepackage{graphicx}
\usepackage{amsmath}
\usepackage{amsfonts}
\usepackage{amssymb}
\usepackage{amsthm}
\usepackage{hyperref}
\usepackage{color}
\usepackage{mathtools}
\usepackage{comment}
\usepackage{array}
\usepackage{multirow}
\usepackage{footnote}
\usepackage{xintexpr}
\addtolength{\textwidth}{4cm}
\setlength{\parindent}{0mm}
\geometry{left=1.6cm,right=1.6cm,top=1.2cm,bottom=2cm}
\newcommand{\tfjm}{$\mathbb{TFJM}^2$}
\pagestyle{empty}
\renewcommand{\leq}{\leqslant}
\def\tfjmedition{~{{ tfjm_number }}}
\begin{document}
\thispagestyle{empty}
\begin{center}
\Large {\bf \tfjmedition$^{e}$ Tournoi Fran\c cais des Jeunes Math\'ematiciennes et Math\'ematiciens \tfjm}
\end{center}
\vspace{3mm}
\begin{center}
\begin{itemize}
{% for passage in passages.all %}
\item D\'efenseur\textperiodcentered{}se au passage {{ forloop.counter }} : \underline{\texttt{~{{ passage.defender.team.trigram }}~}} $\qquad$ probl\`eme \underline{~{{ passage.solution_number }}~}
{% endfor %}
\end{itemize}
\end{center}
\vspace{6mm}
%%%%%%%%%%%%%%%%%%%%%DEFENSEUR
\begin{tabular}{|c|p{20mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
\multicolumn{4}{|l|}{{\bf D\'efenseur\textperiodcentered{}se} \normalsize pr\'esente les id\'ees et r\'esultats principaux pour la solution du probl\`eme.} {% for passage in passages.all %}& Passage {{ forloop.counter }} {% endfor %}\\ \hline \hline
%ECRIT
\multirow{6}{3mm}{\centering \bf\'E\\ C\\ R\\ I\\ T} & \multirow{3}{20mm}{Partie scientifique} & Profondeur des r\'esultats d\'emontr\'es & [0,5] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Originalit\'e et pertinence des preuves& [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Exactitude et justesse des d\'emonstrations, algorithmes, etc. & [0,7] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multirow{2}{20mm}{Forme} & Pr\'esentation (lisibilit\'e, etc.) & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Clart\'e du raisonnement : facile \`a comprendre ou compl\`etement obscur ? & [0,3]{{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/20)} {{ esp|safe }} \\ \hline \hline
%ORAL
\multirow{8}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{4}{20mm}{Partie scientifique} & Compr\'ehension du mat\'eriel, connaissance des sujets math\'ematiques correspondants \emph{lors de la pr\'esentation} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& P\'edagogie, notamment clart\'e, exactitude et justesse des d\'emonstrations \emph{lors de la pr\'esentation} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Capacit\'e \`a r\'eagir aux questions et remarques de l'Opposant\textperiodcentered{}e et de læ Rapporteur\textperiodcentered{}e & [0,4] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Capacit\'e \`a r\'eagir aux questions et remarques du jury & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multirow{3}{20mm}{Forme} & Bri\`evet\'e et propret\'e de la pr\'esentation & [0,2] {{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}}
&& Capacit\'e de faire avancer le d\'ebat & [0,2] {{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}}
&& \emph{Conformit\'e} entre la pr\'esentation et le mat\'eriel \'ecrit & [--5,0] {{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf TOTAL ORAL (/16)} {{ esp|safe }} \\ \hline
\end{tabular}
\newpage
%%%%%%%%%%%%%%%%%OPPOSANT
\begin{tabular}{|c|p{20mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
\multicolumn{4}{|l|}{L' {\bf Opposant\textperiodcentered{}e} \normalsize fournit une analyse critique de la solution et de la pr\'esentation.}
{% for passage in passages.all %}& Passage {{ forloop.counter }} {% endfor %} \\ \hline \hline
%ECRIT
\multirow{4}{3mm}{\centering\bf\'E\\ C\\ R\\ I\\ T} &\multirow{2}{20mm}{Partie scientifique} & Compr\'ehension du probl\`eme, savoir \'evaluer la qualit\'e g\'en\'erale de la solution & [0,4] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Rep\'erer les erreurs et leur importance & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& Forme & Pr\'esentation (lisibilit\'e, etc.) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/9)} {{ esp|safe }} \\ \hline \hline
%ORAL
\multirow{6}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{3}{20mm}{Partie scientifique} & Compr\'ehension du probl\`eme, savoir \'evaluer la qualit\'e g\'en\'erale de la pr\'esentation de læ D\'efenseur\textperiodcentered{}se
& [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Rep\'erer les erreurs et leur importance & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Pertinence des questions & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& Forme & M\`ene un d\'ebat de fa\c con comp\'etente et propre. & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf TOTAL ORAL (/10)} {{ esp|safe }}\\ \hline
\end{tabular}
\vfill
%%%%%%%%%%%%%%%%%%%%%%RAPPORTEUR
\begin{tabular}{|c|p{20mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
\multicolumn{4}{|l|}{{\bf Rapporteur\textperiodcentered{}e} \normalsize \'evalue le d\'ebat entre læ D\'efenseur\textperiodcentered{}se et l'Opposant\textperiodcentered{}e.} {% for passage in passages.all %}& Passage {{ forloop.counter }} {% endfor %}\\ \hline \hline
%ECRIT
\multirow{4}{3mm}{\centering\bf\'E\\ C\\ R\\ I\\ T} &\multirow{2}{20mm}{Partie scientifique} & Compr\'ehension du probl\`eme, savoir \'evaluer la qualit\'e g\'en\'erale de la solution & [0,4] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
& & Rep\'erer les erreurs et leur importance & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& Forme & Pr\'esentation (lisibilit\'e, etc.) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/9)} {{ esp|safe }}\\ \hline \hline
%ORAL
\multirow{6}{3mm}{\centering\bf O\\ R\\ A\\ L} &\multirow{4}{20mm}{Partie scientifique} & Compr\'ehension du probl\`eme, savoir \'evaluer la qualit\'e g\'en\'erale de la pr\'esentation de læ D\'efenseur\textperiodcentered{}se & [0,1] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Savoir \'evaluer la qualit\'e g\'en\'erale du d\'ebat & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Rep\'erer les points importants non abord\'es & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Pertinence des questions & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& Forme & M\`ene un d\'ebat de fa\c con comp\'etente et propre. & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& \multicolumn{3}{|l|}{\bf TOTAL ORAL (/10)} {{ esp|safe }}\\ \hline
\end{tabular}
\vfill
{% if passages.count == 4 %}
%%%%%%% INTERVENTION EXCEPTIONNELLE
\begin{tabular}{|c|p{11cm}|c|p{2cm}|p{2cm}|p{2cm}|p{2cm}|}\hline
\multicolumn{3}{|l|}{L'{\bf Intervention exceptionnelle} \normalsize permet de signaler une erreur grave omise par tous.} {% for passage in passages.all %}& Passage {{ forloop.counter }} {% endfor %}\\ \hline \hline
%ORAL
\multirow{1}{3mm}{\centering\bf O\\ R\\ A\\ L}
& Toute intervention exceptionnelle non pertinente est sanctionn\'ee par une note n\'egative, l'absence d'intervention re\c coit un z\'ero forfaitaire. \phantom{pour avoir oral en entier dans la} \phantom{colonne il} \phantom{faut blablater un peu}& [-4,4] {{ esp|safe }}\\ \hline
\end{tabular}
{% endif %}
\end{document}

View File

@ -0,0 +1,88 @@
{% load i18n %}
\documentclass[10pt,a4paper,landscape]{article}
\usepackage[T1]{fontenc}
\usepackage[utf8x]{inputenc}
\usepackage[french]{babel}
\usepackage[a4paper]{geometry}
\usepackage{graphicx}
\usepackage{amsmath}
\usepackage{amsfonts}
\usepackage{amssymb}
\usepackage{amsthm}
\usepackage{hyperref}
\usepackage{color}
\usepackage{mathtools}
\usepackage{comment}
\usepackage{array}
\usepackage{multirow}
\usepackage{footnote}
\usepackage{tabularx}
\addtolength{\textwidth}{6cm}
\addtolength{\oddsidemargin}{-3cm}
\addtolength{\textheight}{2cm}
\addtolength{\topmargin}{-0.5cm}
\setlength{\parindent}{0mm}
\DeclareUnicodeCharacter{22C5}{\textperiodcentered{}}
\newcommand{\tfjm}{$\mathbb{TFJM}^2$}
\renewcommand{\leq}{\leqslant}
\def\tfjmedition{~{{ tfjm_number }}}
\begin{document}
\pagenumbering{gobble}
\centering
{% if TFJM.APP == "TFJM" %}
\Large {\bf \tfjmedition$^{e}$ Tournoi Fran\c cais des Jeunes Math\'ematiciennes et Math\'ematiciens \tfjm}\\
{% else %}
\Large {\bf \tfjmedition$^{st}$ European Tournament of Enthusiastic Apprentice Mathematicians}\\
{% endif %}
\vspace{3mm}
{% trans "Round" %} {{ pool.round }} \;-- {% trans "Pool" %} {{ pool.get_letter_display }}{% if pool.participations.count == 5 %} \;-- {{ pool.get_room_display }}{% endif %} \;-- {% if pool.round == 1 %}{{ pool.tournament.date_first_phase }}{% elif pool.round == 2 %}{{ pool.tournament.date_second_phase }}{% else %}{{ pool.tournament.date_third_phase }}{% endif %}
\vspace{15mm}
\begin{tabular}{|p{40mm}{% for passage in passages.all %}{% if passages.count <= 3 %}|p{3cm}|p{3cm}{% else %}|p{2.8cm}|p{2.5cm}{% endif %}{% endfor %}|}\hline
\multirow{2}{40mm}{\LARGE {% trans "Role" %}} {% for passage in passages.all %}& \multicolumn{2}{c|}{ \Large {% trans "Problem" %} {{ passage.solution_number }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
{% for passage in passages.all %}& \hspace{4mm} {\Large {% trans "Writing"|upper %}} & \hspace{4mm} {\Large {% trans "Oral"|upper %}}{% endfor %} \\ \hline
\multirow{2}{35mm}{\LARGE {% trans "Reporter" %}} {% for passage in passages.all %}& \multicolumn{2}{c|}{\Large {{ passage.reporter.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
{% for passage in passages.all %}
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq {% if TFJM.APP == "TFJM" %}20{% else %}10{% endif %}$
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq {% if TFJM.APP == "TFJM" %}20{% else %}10{% endif %}$
{% endfor %} & \hline
\multirow{2}{35mm}{\LARGE {% trans "Opponent" %}} {% for passage in passages.all %}& \multicolumn{2}{c|}{\Large {{ passage.opponent.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
{% for passage in passages.all %}
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 10$
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 10$
{% endfor %} & \hline
\multirow{2}{35mm}{\LARGE {% trans "Reviewer" %}} {% for passage in passages.all %}& \multicolumn{2}{c|}{\Large {{ passage.reviewer.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
{% for passage in passages.all %}
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 10$
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 10$
{% endfor %} & \hline
{% if TFJM.APP == "ETEAM" and pool.participations.count >= 4 %}
\multirow{2}{35mm}{\LARGE {% trans "Observer" %}} {% for passage in passages.all %}& \multicolumn{2}{c|}{\Large {{ passage.observer.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
{% for passage in passages.all %}
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 10$
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 10$
{% endfor %} & \hline
{% endif %}
\end{tabular}
\vspace{15mm}
\LARGE {% trans "name"|capfirst %} {% trans "Juree"|lower %} :
{% if jury %}\underline{ {{ jury.user.first_name|safe }} {{ jury.user.last_name|safe }} }{% else %}\underline{\phantom{Phrase suffisamment longue pour le nom}}{% endif %}
$\qquad$ {% trans "Signature" %} : \underline{\phantom{Phrase moins longue}}
\newpage
%}
\end{document}

View File

@ -1,90 +0,0 @@
\documentclass[10pt,a4paper,landscape]{article}
\usepackage[T1]{fontenc}
\usepackage[utf8x]{inputenc}
\usepackage[french]{babel}
\usepackage[a4paper]{geometry}
\usepackage{graphicx}
\usepackage{amsmath}
\usepackage{amsfonts}
\usepackage{amssymb}
\usepackage{amsthm}
\usepackage{hyperref}
\usepackage{color}
\usepackage{mathtools}
\usepackage{comment}
\usepackage{array}
\usepackage{multirow}
\usepackage{footnote}
\usepackage{tabularx}
\usepackage{xintexpr}
\addtolength{\textwidth}{6cm}
\addtolength{\oddsidemargin}{-3cm}
\addtolength{\textheight}{2cm}
\addtolength{\topmargin}{-0.5cm}
\setlength{\parindent}{0mm}
\newcommand{\tfjm}{$\mathbb{TFJM}^2$}
\renewcommand{\leq}{\leqslant}
\def\tfjmedition{~{{ tfjm_number }}}
\begin{document}
\pagenumbering{gobble}
\centering
\Large {\bf \tfjmedition$^{e}$ Tournoi Fran\c cais des Jeunes Math\'ematiciennes et Math\'ematiciens \tfjm}\\
\vspace{3mm}
Tour {{ pool.round }} \;-- Poule {{ pool.get_letter_display }}{{ page }} \;-- {% if pool.round == 1 %}{{ pool.tournament.date_start }}{% else %}{{ pool.tournament.date_end }}{% endif %}
\vspace{15mm}
\begin{tabular}{|p{35mm}{% for passage in passages.all %}{% if passages.count == 3 %}|p{3cm}|p{3cm}{% else %}|p{2.5cm}|p{2.5cm}{% endif %}{% endfor %}|}\hline
\multirow{2}{35mm}{\LARGE R\^ole} {% for passage in passages.all %}& \multicolumn{2}{c|}{ \Large Probl\`eme {{ passage.solution_number }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
{% for passage in passages.all %}& \hspace{4mm} {\Large \'ECRIT} & \hspace{4mm} {\Large ORAL}{% endfor %} \\ \hline
\multirow{2}{35mm}{\LARGE D\'efenseur\textperiodcentered{}se} {% for passage in passages.all %}& \multicolumn{2}{c|}{\Large {{ passage.defender.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
{% for passage in passages.all %}
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 20$
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 16$
{% endfor %} & \hline
\multirow{2}{35mm}{\LARGE Opposant\textperiodcentered{}e} {% for passage in passages.all %}& \multicolumn{2}{c|}{\Large {{ passage.opponent.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
{% for passage in passages.all %}
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 9$
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 10$
{% endfor %} & \hline
\multirow{2}{35mm}{\LARGE Rapporteur\textperiodcentered{}e} {% for passage in passages.all %}& \multicolumn{2}{c|}{\Large {{ passage.reporter.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
{% for passage in passages.all %}
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 9$
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 10$
{% endfor %} & \hline
{% if passages.count == 4 %}
\multirow{4}{35mm}{\Large Intervention exceptionnelle}{% for passage in passages.all %} & \multicolumn{2}{c|}{\Large {{ passage.observer.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}\\
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}
& \multicolumn{2}{c|}{\phantom{asd asd} \phantom{asd asd}}\\
& \multicolumn{2}{c|}{\centering \normalsize$-4\leq x\leq 4$}
& \multicolumn{2}{c|}{\centering \normalsize$-4\leq x\leq 4$}
& \multicolumn{2}{c|}{\centering \normalsize$-4\leq x\leq 4$}
& \multicolumn{2}{c|}{\centering \normalsize$-4\leq x\leq 4$} & \hline
{% endif %}
\end{tabular}
\vspace{15mm}
\LARGE Nom jur\'e\textperiodcentered{}e :
{% if is_jury %}\underline{ {{ user.first_name|safe }} {{ user.last_name|safe }} }{% else %}\underline{\phantom{Phrase suffisamment longue pour le nom}}{% endif %}
$\qquad$ Signature : \underline{\phantom{Phrase moins longue}}
\newpage
%}
\end{document}

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