1
0
mirror of https://gitlab.com/animath/si/plateforme.git synced 2025-05-16 23:32:45 +00:00

Compare commits

...

228 Commits

Author SHA1 Message Date
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
bd416318ac
Fix unread messages count
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:35 +02:00
Emmy D'Anello
90bec6bf5e
Remove debug code
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:34 +02:00
Emmy D'Anello
ed5944e044
Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:34 +02:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
1a641cb2d7
Store what messages are read
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:34 +02:00
Emmy D'Anello
8f3929875f
Improve context menus
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:34 +02:00
Emmy D'Anello
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
Emmy D'Anello
1e5d0ebcfc
Editing and deleting is working
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:33 +02:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
d6aa5eb0cc
Manage private chats
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:33 +02:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
784002c085
Open channels list by swiping
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:32 +02:00
Emmy D'Anello
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
Emmy D'Anello
7bb0f78f34
Improve mobile chat
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:32 +02:00
Emmy D'Anello
bfd1a76a2d
Notifications use the PNG logo
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:32 +02:00
Emmy D'Anello
b86dfe7351
Automatically scroll to bottom
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:32 +02:00
Emmy D'Anello
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
Emmy D'Anello
181bb86e49
Simplify chat views
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:31 +02:00
Emmy D'Anello
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
Emmy D'Anello
2d706b2b81
Add fullscreen mode for chat
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:31 +02:00
Emmy D'Anello
ca91842c2d
Fill channel selector using JavaScript
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:31 +02:00
Emmy D'Anello
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
Emmy D'Anello
d59bb75dce
Fetching last messages is working
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:30 +02:00
Emmy D'Anello
4a78e80399
Send messages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:30 +02:00
Emmy D'Anello
f3a4a99b78
Setup chat UI
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:30 +02:00
Emmy D'Anello
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
Emmy D'Anello
b464e7df1d
Manage channels permissions
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:30 +02:00
Emmy D'Anello
7498677bbd
Permissions are strings, not integers
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:30 +02:00
Emmy D'Anello
ea8007aa07
Initialize chat interface
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-29 00:39:29 +02:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
943276ef71
Round is an integer
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-21 07:46:20 +02:00
Emmy D'Anello
13c815c62c
Allow to parse empty mentions
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-20 21:43:37 +02:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
91a1837c99
Fix 5-teams pools passages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-17 21:56:46 +02:00
Emmy D'Anello
b24201c529
Rapporteure => Rapportrice
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-17 00:58:56 +02:00
Emmy D'Anello
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
Emmy D'Anello
49fda3df49
Add mentions
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-17 00:38:18 +02:00
Emmy D'Anello
3a0a98a331
Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-17 00:02:48 +02:00
Emmy D'Anello
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
Emmy D'Anello
338a19ec32
Remove observer status
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-16 23:59:18 +02:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
7edd43f626
Rapporteure => Rapportrice
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-13 12:48:13 +02:00
Emmy D'Anello
aca23eaf8b
Fix under 18 calculus
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-09 17:45:41 +02:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
6c76f1e633
Fix final selection
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 18:30:06 +02:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
1493df0078
Implement final selection
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-07 11:41:14 +02:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
ec0419a6d7
Fix expected GDrive channel ID
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 22:43:48 +02:00
Emmy D'Anello
54016a1fbf
Remove test code
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 22:37:33 +02:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
40d24740ed
Fix import orders
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 22:05:48 +02:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
aac4fc59e6
Fix parsing tweaks
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 19:16:32 +02:00
Emmy D'Anello
78a43148a8
Fetch registrations by user id
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-06 19:12:10 +02:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
78e8a92c3a
Fix solution link
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-03 23:06:11 +02:00
Emmy D'Anello
424dee4aea
Fix solution path name
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-03 22:56:45 +02:00
Emmy D'Anello
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
Emmy D'Anello
867ee7efe1
Fix passage view for participants
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-03 22:22:16 +02:00
Emmy D'Anello
32b2d7239c
Fix important information for participants
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-03 22:19:09 +02:00
Emmy D'Anello
6ce179bd60
Fix important information for volunteers
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-01 19:01:17 +02:00
Emmy D'Anello
dba937fb03
Administrateurs => Administrateur⋅rices
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-04-01 18:59:25 +02:00
Emmy D'Anello
4efce6e325
Display datetimes with local timezone
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 22:46:40 +02:00
Emmy D'Anello
10a42d3633
Only harmonize valid participations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 22:12:54 +02:00
Emmy D'Anello
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
Emmy D'Anello
d7b4233282
Rapporteure -> Rapportrice
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 21:47:14 +02:00
Emmy D'Anello
9092cf1846
Improve edit buttons
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-31 21:36:09 +02:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
29b01ebb13
Fix information for juries
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 22:27:38 +01:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
6867c2cc2d
Fix tests
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 20:43:04 +01:00
Emmy D'Anello
1e7bd209a1
Add harmonization view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 20:38:13 +01:00
Emmy D'Anello
109b603b7a
Update Font Awesome
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 19:28:45 +01:00
Emmy D'Anello
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
Emmy D'Anello
f1012efcaa
Consider tweaks in notation sheet
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 18:57:05 +01:00
Emmy D'Anello
5261a52401
Add final ranking sheet
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-30 18:28:54 +01:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
0006ecc90d
Display trigrams in note interface
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-29 19:22:20 +01:00
Emmy D'Anello
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
Emmy D'Anello
a44439671e
Organizers can edit payments
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-29 17:44:38 +01:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
a865361117
More data in CSV file
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-26 23:03:11 +01:00
Emmy D'Anello
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
Emmy D'Anello
8777c562dd
Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-26 21:18:03 +01:00
Emmy D'Anello
4ea70e5ab9
Add juries => Edit jury
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 22:22:16 +01:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
bee04b0522
Update synthesis sheets templates
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 20:24:57 +01:00
Emmy D'Anello
b6d54d27cd
Update ODS note sheets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 20:05:07 +01:00
Emmy D'Anello
3465da4c36
Update bareme
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 19:19:55 +01:00
Emmy D'Anello
4f129280c3
Add buttons to publish notes
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 18:14:43 +01:00
Emmy D'Anello
d2c1a826a8
Update permissions for juries presidents
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 17:42:09 +01:00
Emmy D'Anello
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
Emmy D'Anello
6fa3a08a72
Add button to update notes
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 11:39:29 +01:00
Emmy D'Anello
64b7644e5e
Admin users can manage juries
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 10:47:35 +01:00
Emmy D'Anello
50d8bc2aed
Better jury autocomplete
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-24 10:33:42 +01:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
81c2df7f10
Restructure add juree page
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-23 11:23:02 +01:00
Emmy D'Anello
833b300fde
Fix motivation letter validation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-21 20:28:12 +01:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
71e33b2177
Typo
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-03 16:18:04 +01:00
Emmy D'Anello
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
Emmy D'Anello
0530441452
Fix receipt file name
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-02 19:44:35 +01:00
Emmy D'Anello
4ff53e08db
Add privacy policy
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-03-02 12:52:47 +01:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
1684c079e3
Fix payment group permission
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-29 22:59:54 +01:00
Emmy D'Anello
0c45a88246
Tournament.amount => Tournament.price
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-26 23:49:57 +01:00
Emmy D'Anello
de22a12e85
Activating translation is not needed
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 19:22:55 +01:00
Emmy D'Anello
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
Emmy D'Anello
eb7e7c1579
Compile messages in tox tests
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 19:09:34 +01:00
Emmy D'Anello
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
Emmy D'Anello
9829541289
Add information about reminders
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 18:44:54 +01:00
Emmy D'Anello
1e1fef7a7b
Add documentation dark theme
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 18:41:12 +01:00
Emmy D'Anello
d0c9256c5b
Add payment user documentation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-25 18:40:58 +01:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
207af441a0
Add payment interface tests
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-24 23:05:21 +01:00
Emmy D'Anello
2a2786ba6d
Add payment information after payment
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-24 22:58:06 +01:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
9380fbaaf7
Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-24 09:22:27 +01:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
7b4e867e33
Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-23 23:05:10 +01:00
Emmy D'Anello
2c54f315f6
Add payments table page
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-23 22:58:23 +01:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
54dafe1cec
Improve payment messages
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-21 23:12:01 +01:00
Emmy D'Anello
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
Emmy D'Anello
8d08b18d08
Configure Hello Asso return endpoint
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-20 22:54:12 +01:00
Emmy D'Anello
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
Emmy D'Anello
b3555a7807
Create Hello Asso checkout intents
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-19 00:17:14 +01:00
Emmy D'Anello
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
Emmy D'Anello
4d157b2bd7
Setup payment interface
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-18 22:36:01 +01:00
Emmy D'Anello
7c9083a6b8
Restructure payment model
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-12 22:58:48 +01:00
Emmy D'Anello
ece128836a
Temporary disable payment form
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 23:51:33 +01:00
Emmy D'Anello
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
Emmy D'Anello
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
Emmy D'Anello
672529382d
Fix payment view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 23:12:48 +01:00
Emmy D'Anello
c1ce7cb70f
Display pending validations for organizers
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 22:39:11 +01:00
Emmy D'Anello
bc67d1cf1f
Add information about team registration
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 22:24:22 +01:00
Emmy D'Anello
652e913f49
Fix user update view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 21:41:37 +01:00
Emmy D'Anello
089374b937
Fix join team view
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 21:40:06 +01:00
Emmy D'Anello
226e5620f9
Better footer on small screens
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 21:06:22 +01:00
Emmy D'Anello
ca9652cc60
Collapse sidebar on small screens
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 20:59:34 +01:00
Emmy D'Anello
acd1d80c75
First important informations
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 20:20:28 +01:00
Emmy D'Anello
e7c207d2af
Sidebar structure
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 19:23:16 +01:00
Emmy D'Anello
196ccb69ad
Remove headers on index page
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-02-11 19:15:36 +01:00
Emmy D'Anello
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
178 changed files with 30413 additions and 2348 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

@ -7,7 +7,7 @@ py311:
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
- apk add --no-cache gettext git # Useful for django-haystack, remove when the newer versions are in PyPI
- pip install tox --no-cache-dir
script: tox -e py311
@ -16,7 +16,7 @@ py312:
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 git # Useful for django-haystack, remove when the newer versions are in PyPI
- pip install tox --no-cache-dir
script: tox -e py312

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

22
chat/admin.py Normal file
View File

@ -0,0 +1,22 @@
# 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):
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):
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")

251
chat/consumers.py Normal file
View File

@ -0,0 +1,251 @@
# 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):
"""
This consumer manages the websocket of the chat interface.
"""
async def connect(self) -> None:
"""
This function is called when a new websocket is trying to connect to the server.
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']
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
await self.accept()
self.read_channels = await Channel.get_accessible_channels(user, 'read')
self.write_channels = await Channel.get_accessible_channels(user, 'write')
async for channel in self.read_channels.all():
await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
await self.channel_layer.group_add(f"user-{user.id}", self.channel_name)
async def disconnect(self, close_code) -> None:
"""
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
async for channel in self.read_channels.all():
await self.channel_layer.group_discard(f"chat-{channel.id}", self.channel_name)
await self.channel_layer.group_discard(f"user-{self.scope['user'].id}", self.channel_name)
async def receive_json(self, content, **kwargs):
"""
Called when the client sends us some data, parsed as JSON.
:param content: The sent data, decoded from JSON text. Must content a `type` field.
"""
match content['type']:
case 'fetch_channels':
await self.fetch_channels()
case 'send_message':
await self.receive_message(**content)
case 'edit_message':
await self.edit_message(**content)
case 'delete_message':
await self.delete_message(**content)
case 'fetch_messages':
await self.fetch_messages(**content)
case 'mark_read':
await self.mark_read(**content)
case 'start_private_chat':
await self.start_private_chat(**content)
case unknown:
print("Unknown message type:", unknown)
async def fetch_channels(self) -> None:
user = self.scope['user']
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 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()
]
}
await self.send_json(message)
async def receive_message(self, channel_id: int, content: str, **kwargs) -> None:
user = self.scope['user']
channel = await Channel.objects.prefetch_related('tournament__pools__juries', 'pool', 'team', 'invited') \
.aget(id=channel_id)
if not await self.write_channels.acontains(channel):
return
message = await Message.objects.acreate(
author=user,
channel=channel,
content=content,
)
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:
message = await Message.objects.aget(id=message_id)
user = self.scope['user']
if user.id != message.author_id and not user.is_superuser:
return
message.content = content
await message.asave()
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:
message = await Message.objects.aget(id=message_id)
user = self.scope['user']
if user.id != message.author_id and not user.is_superuser:
return
await message.adelete()
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:
channel = await Channel.objects.aget(id=channel_id)
if not await self.read_channels.acontains(channel):
return
limit = min(limit, 200) # Fetch only maximum 200 messages at the time
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()
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:
messages = Message.objects.filter(id__in=message_ids)
async for message in messages.all():
await message.users_read.aadd(self.scope['user'])
unread_messages_by_channel = Message.objects.exclude(users_read=self.scope['user']).values('channel_id') \
.annotate(unread_messages=Count('channel_id'))
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:
user = self.scope['user']
other_user = await User.objects.aget(id=user_id)
channel_qs = Channel.objects.filter(private=True).filter(invited=user).filter(invited=other_user)
if not await channel_qs.aexists():
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])
await self.channel_layer.group_add(f"chat-{channel.id}", self.channel_name)
if user != other_user:
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:
channel = await channel_qs.afirst()
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:
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:
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:
await self.send_json({'type': 'delete_message', 'id': message['id'], 'channel_id': message['channel_id']})
async def chat_start_private_chat(self, message) -> None:
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,143 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.core.management import BaseCommand
from django.utils.translation import activate
from participation.models import Team, Tournament
from tfjm.permissions import PermissionType
from ...models import Channel
class Command(BaseCommand):
def handle(self, *args, **kwargs):
activate('fr')
Channel.objects.update_or_create(
name="Annonces",
defaults=dict(
category=Channel.ChannelCategory.GENERAL,
read_access=PermissionType.AUTHENTICATED,
write_access=PermissionType.ADMIN,
),
)
Channel.objects.update_or_create(
name="Aide jurys et orgas",
defaults=dict(
category=Channel.ChannelCategory.GENERAL,
read_access=PermissionType.VOLUNTEER,
write_access=PermissionType.VOLUNTEER,
),
)
Channel.objects.update_or_create(
name="Général",
defaults=dict(
category=Channel.ChannelCategory.GENERAL,
read_access=PermissionType.AUTHENTICATED,
write_access=PermissionType.AUTHENTICATED,
),
)
Channel.objects.update_or_create(
name="Je cherche une équipe",
defaults=dict(
category=Channel.ChannelCategory.GENERAL,
read_access=PermissionType.AUTHENTICATED,
write_access=PermissionType.AUTHENTICATED,
),
)
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():
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,
),
)
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:
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():
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():
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,2 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later

273
chat/models.py Normal file
View File

@ -0,0 +1,273 @@
# 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):
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"),
)
category = models.CharField(
max_length=255,
verbose_name=_("category"),
choices=ChannelCategory,
default=ChannelCategory.GENERAL,
)
read_access = models.CharField(
max_length=16,
verbose_name=_("read permission"),
choices=PermissionType,
)
write_access = models.CharField(
max_length=16,
verbose_name=_("write permission"),
choices=PermissionType,
)
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:
if self.private:
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)
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"]:
permission_type = 'write_access' if 'write' in permission_type.lower() else 'read_access'
qs = Channel.objects.none()
if user.is_anonymous:
return Channel.objects.filter(**{permission_type: PermissionType.ANONYMOUS})
qs |= Channel.objects.filter(**{permission_type: PermissionType.AUTHENTICATED})
registration = await Registration.objects.prefetch_related('user').aget(user_id=user.id)
if registration.is_admin:
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)
qs |= Channel.objects.filter(**{permission_type: PermissionType.VOLUNTEER})
qs |= Channel.objects.filter(Q(tournament__in=registration.interesting_tournaments),
**{permission_type: PermissionType.TOURNAMENT_MEMBER})
qs |= Channel.objects.filter(Q(tournament__in=registration.organized_tournaments.all()),
**{permission_type: PermissionType.TOURNAMENT_ORGANIZER})
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})
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})
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))
qs |= Channel.objects.filter(Q(tournament__in=tournaments),
**{permission_type: PermissionType.TOURNAMENT_MEMBER})
qs |= Channel.objects.filter(Q(pool__in=team.participation.pools.all()),
**{permission_type: PermissionType.POOL_MEMBER})
qs |= Channel.objects.filter(Q(team=team),
**{permission_type: PermissionType.TEAM_MEMBER})
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):
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):
registration = self.author.registration
author_name = f"{self.author.first_name} {self.author.last_name}"
if registration.is_volunteer:
if registration.is_admin:
author_name += " (CNO)"
if self.channel.pool:
if registration == self.channel.pool.jury_president:
author_name += " (P. jury)"
elif registration in self.channel.pool.juries.all():
author_name += " (Juré⋅e)"
elif registration in self.channel.pool.tournament.organizers.all():
author_name += " (CRO)"
else:
author_name += " (Bénévole)"
elif self.channel.tournament:
if registration in self.channel.tournament.organizers.all():
author_name += " (CRO)"
elif any([registration.id == pool.jury_president
for pool in self.channel.tournament.pools.all()]):
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()]):
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:
author_name += " (Bénévole)"
else:
if registration.organized_tournaments.exists():
tournaments = ", ".join([tournament.name
for tournament in registration.organized_tournaments.all()])
author_name += f" (CRO {tournaments})"
if Pool.objects.filter(jury_president=registration).exists():
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():
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:
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):
return await sync_to_async(self.get_author_name)()
class Meta:
verbose_name = _("message")
verbose_name_plural = _("messages")
ordering = ('created_at',)

100
chat/signals.py Normal file
View File

@ -0,0 +1,100 @@
# 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):
tournament = instance
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,
),
)
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:
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):
pool = instance
tournament = pool.tournament
if tournament.remote:
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):
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,
),
)

595
chat/static/chat.js Normal file
View File

@ -0,0 +1,595 @@
(async () => {
// check notification permission
// This is useful to alert people that they should do something
await Notification.requestPermission()
})()
const MAX_MESSAGES = 50
const channel_categories = ['general', 'tournament', 'team', 'private']
let channels = {}
let messages = {}
let selected_channel_id = null
/**
* Display a new notification with the given title and the given body.
* @param title The title of the notification
* @param body The body of the notification
* @param timeout The time (in milliseconds) after that the notification automatically closes. 0 to make indefinite. Default to 5000 ms.
* @return Notification
*/
function showNotification(title, body, timeout = 5000) {
Notification.requestPermission().then((status) => {
if (status === 'granted')
new Notification(title, {'body': body, 'icon': "/static/tfjm-192.png"})
})
}
function selectChannel(channel_id) {
let channel = channels[channel_id]
if (!channel) {
console.error('Channel not found:', channel_id)
return
}
selected_channel_id = channel_id
localStorage.setItem('chat.last-channel-id', channel_id)
let channelTitle = document.getElementById('channel-title')
channelTitle.innerText = channel['name']
let messageInput = document.getElementById('input-message')
messageInput.disabled = !channel['write_access']
redrawMessages()
}
function sendMessage() {
let messageInput = document.getElementById('input-message')
let message = messageInput.value
messageInput.value = ''
if (!message) {
return
}
socket.send(JSON.stringify({
'type': 'send_message',
'channel_id': selected_channel_id,
'content': message,
}))
}
function setChannels(new_channels) {
channels = {}
let categoryLists = {}
for (let category of channel_categories) {
categoryLists[category] = document.getElementById(`nav-${category}-channels-tab`)
categoryLists[category].innerHTML = ''
categoryLists[category].parentElement.classList.add('d-none')
}
for (let channel of new_channels)
addChannel(channel, categoryLists)
if (new_channels && (!selected_channel_id || !channels[selected_channel_id])) {
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])
}
}
async function addChannel(channel, categoryLists) {
channels[channel['id']] = channel
if (!messages[channel['id']])
messages[channel['id']] = new Map()
let categoryList = categoryLists[channel['category']]
categoryList.parentElement.classList.remove('d-none')
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)
let channelButton = document.createElement('button')
channelButton.classList.add('nav-link')
channelButton.type = 'button'
channelButton.innerText = channel['name']
navItem.appendChild(channelButton)
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)
if (document.getElementById('sort-by-unread-switch').checked)
navItem.style.order = `${-channel.unread_messages}`
fetchMessages(channel['id'])
}
function receiveMessage(message) {
let scrollableContent = document.getElementById('chat-messages')
let isScrolledToBottom = scrollableContent.scrollHeight - scrollableContent.clientHeight <= scrollableContent.scrollTop + 1
messages[message['channel_id']].set(message['id'], message)
redrawMessages()
// Scroll to bottom if the user was already at the bottom
if (isScrolledToBottom)
scrollableContent.scrollTop = scrollableContent.scrollHeight - scrollableContent.clientHeight
if (message['content'].includes("@everyone"))
showNotification(channels[message['channel_id']]['name'], `${message['author']} : ${message['content']}`)
}
function editMessage(data) {
messages[data['channel_id']].get(data['id'])['content'] = data['content']
redrawMessages()
}
function deleteMessage(data) {
messages[data['channel_id']].delete(data['id'])
redrawMessages()
}
function fetchMessages(channel_id, offset = 0, limit = MAX_MESSAGES) {
socket.send(JSON.stringify({
'type': 'fetch_messages',
'channel_id': channel_id,
'offset': offset,
'limit': limit,
}))
}
function fetchPreviousMessages() {
let channel_id = selected_channel_id
let offset = messages[channel_id].size
fetchMessages(channel_id, offset, MAX_MESSAGES)
}
function receiveFetchedMessages(data) {
let channel_id = data['channel_id']
let new_messages = data['messages']
if (!messages[channel_id])
messages[channel_id] = new Map()
for (let message of new_messages)
messages[channel_id].set(message['id'], message)
// Sort messages by timestamp
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]))
redrawMessages()
}
function markMessageAsRead(data) {
for (let message of data['messages']) {
let stored_message = messages[message['channel_id']].get(message['id'])
if (stored_message)
stored_message['read'] = true
}
redrawMessages()
updateUnreadBadges(data['unread_messages'])
}
function updateUnreadBadges(unreadMessages) {
const sortByUnread = document.getElementById('sort-by-unread-switch').checked
for (let channel of Object.values(channels)) {
let unreadMessagesChannel = unreadMessages[channel['id']] || 0
channel.unread_messages = unreadMessagesChannel
let unreadBadge = document.getElementById(`unread-messages-${channel['id']}`)
unreadBadge.innerText = unreadMessagesChannel
if (unreadMessagesChannel)
unreadBadge.classList.remove('d-none')
else
unreadBadge.classList.add('d-none')
if (sortByUnread)
document.getElementById(`tab-channel-${channel['id']}`).style.order = `${-unreadMessagesChannel}`
}
}
function startPrivateChat(data) {
let channel = data['channel']
if (!channel) {
console.error('Private chat not found:', data)
return
}
if (!channels[channel['id']]) {
channels[channel['id']] = channel
messages[channel['id']] = new Map()
setChannels(Object.values(channels))
}
selectChannel(channel['id'])
}
function redrawMessages() {
let messageList = document.getElementById('message-list')
messageList.innerHTML = ''
let lastMessage = null
let lastContentDiv = null
for (let message of messages[selected_channel_id].values()) {
if (lastMessage && lastMessage['author'] === message['author']) {
let lastTimestamp = new Date(lastMessage['timestamp'])
let newTimestamp = new Date(message['timestamp'])
if ((newTimestamp - lastTimestamp) / 1000 < 60 * 10) {
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)
registerMessageContextMenu(message, messageContentDiv, messageContentSpan)
continue
}
}
let messageElement = document.createElement('li')
messageElement.classList.add('list-group-item')
messageList.appendChild(messageElement)
let authorDiv = document.createElement('div')
messageElement.appendChild(authorDiv)
let authorSpan = document.createElement('span')
authorSpan.classList.add('text-muted', 'fw-bold')
authorSpan.innerText = message['author']
authorDiv.appendChild(authorSpan)
registerSendPrivateMessageContextMenu(message, authorDiv, authorSpan)
let dateSpan = document.createElement('span')
dateSpan.classList.add('text-muted', 'float-end')
dateSpan.innerText = new Date(message['timestamp']).toLocaleString()
authorDiv.appendChild(dateSpan)
let contentDiv = document.createElement('div')
messageElement.appendChild(contentDiv)
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)
registerMessageContextMenu(message, messageContentDiv, messageContentSpan)
lastMessage = message
lastContentDiv = contentDiv
}
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')
messageList.dispatchEvent(new CustomEvent('updatemessages'))
}
function markdownToHTML(text) {
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) {
let htmlLine = line
.replaceAll(/_(.*)_/gim, '<span class="text-decoration-underline">$1</span>') // Underline
.replaceAll(/\*\*(.*)\*\*/gim, '<span class="fw-bold">$1</span>') // Bold
.replaceAll(/\*(.*)\*/gim, '<span class="fst-italic">$1</span>') // Italic
.replaceAll(/`(.*)`/gim, '<pre>$1</pre>') // Code
.replaceAll(/(https?:\/\/\S+)/g, '<a href="$1" target="_blank">$1</a>') // Links
htmlLines.push(htmlLine)
}
return htmlLines.join('<br>')
}
function removeAllPopovers() {
for (let popover of document.querySelectorAll('*[aria-describedby*="popover"]')) {
let instance = bootstrap.Popover.getInstance(popover)
if (instance)
instance.dispose()
}
}
function registerSendPrivateMessageContextMenu(message, div, span) {
div.addEventListener('contextmenu', (menu_event) => {
menu_event.preventDefault()
removeAllPopovers()
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()
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'],
}))
})
})
}
function registerMessageContextMenu(message, div, span) {
div.addEventListener('contextmenu', (menu_event) => {
menu_event.preventDefault()
removeAllPopovers()
let content = `<a id="send-private-message-link-msg-${message['id']}" class="nav-link" href="#" tabindex="0">Envoyer un message privé</a>`
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()
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) {
document.getElementById('edit-message-' + message['id']).addEventListener('click', event => {
event.preventDefault()
popover.dispose()
let new_message = prompt("Modifier le message", message['content'])
if (new_message) {
socket.send(JSON.stringify({
'type': 'edit_message',
'message_id': message['id'],
'content': new_message,
}))
}
})
document.getElementById('delete-message-' + message['id']).addEventListener('click', event => {
event.preventDefault()
popover.dispose()
if (confirm(`Supprimer le message ?\n${message['content']}`)) {
socket.send(JSON.stringify({
'type': 'delete_message',
'message_id': message['id'],
}))
}
})
}
})
}
function toggleFullscreen() {
let chatContainer = document.getElementById('chat-container')
if (!chatContainer.getAttribute('data-fullscreen')) {
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 {
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', () => {
document.addEventListener('click', removeAllPopovers)
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)
item.style.order = `${-channel.unread_messages}`
else
item.style.removeProperty('order')
}
localStorage.setItem('chat.sort-by-unread', sortByUnread)
})
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'))
}
/**
* Process the received data from the server.
* @param data The received message
*/
function processMessage(data) {
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:
console.log(data)
console.error('Unknown message type:', data['type'])
break
}
}
function setupSocket(nextDelay = 1000) {
// Open a global websocket
socket = new WebSocket(
(document.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/ws/chat/'
)
let socketOpen = false
// Listen on websockets and process messages from the server
socket.addEventListener('message', e => {
// Parse received data as JSON
const data = JSON.parse(e.data)
processMessage(data)
})
// Manage errors
socket.addEventListener('close', e => {
console.error('Chat socket closed unexpectedly, restarting…')
setTimeout(() => setupSocket(socketOpen ? 1000 : 2 * nextDelay), nextDelay)
})
socket.addEventListener('open', e => {
socketOpen = true
socket.send(JSON.stringify({
'type': 'fetch_channels',
}))
})
}
function setupSwipeOffscreen() {
const offcanvas = new bootstrap.Offcanvas(document.getElementById('channelSelector'))
let lastX = null
document.addEventListener('touchstart', (event) => {
if (event.touches.length === 1)
lastX = event.touches[0].clientX
})
document.addEventListener('touchmove', (event) => {
if (event.touches.length === 1 && lastX !== null) {
const diff = event.touches[0].clientX - lastX
if (diff > window.innerWidth / 10 && lastX < window.innerWidth / 4) {
offcanvas.show()
lastX = null
}
else if (diff < -window.innerWidth / 10) {
offcanvas.hide()
lastX = null
}
}
})
document.addEventListener('touchend', () => {
lastX = null
})
}
function setupReadTracker() {
const scrollableContent = document.getElementById('chat-messages')
const messagesList = document.getElementById('message-list')
let markReadBuffer = []
let markReadTimeout = null
scrollableContent.addEventListener('scroll', () => {
if (scrollableContent.clientHeight - scrollableContent.scrollTop === scrollableContent.scrollHeight
&& !document.getElementById('fetch-previous-messages').classList.contains('d-none')) {
// If the user is at the top of the chat, fetch previous messages
fetchPreviousMessages()}
markVisibleMessagesAsRead()
})
messagesList.addEventListener('updatemessages', () => markVisibleMessagesAsRead())
function markVisibleMessagesAsRead() {
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) {
let rect = item.getBoundingClientRect()
if (rect.top >= viewport.top && rect.bottom <= viewport.bottom) {
message.read = true
markReadBuffer.push(message['id'])
if (markReadTimeout)
clearTimeout(markReadTimeout)
markReadTimeout = setTimeout(() => {
socket.send(JSON.stringify({
'type': 'mark_read',
'message_ids': markReadBuffer,
}))
markReadBuffer = []
markReadTimeout = null
}, 3000)
}
}
}
}
markVisibleMessagesAsRead()
}
function setupPWAPrompt() {
let deferredPrompt = null
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault()
deferredPrompt = e
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 () {
deferredPrompt.prompt()
deferredPrompt.userChoice.then((choiceResult) => {
if (choiceResult.outcome === 'accepted') {
deferredPrompt = null
btn.classList.add('d-none')
alert.classList.add('d-none')
}
})
}
})
}
setupSocket()
setupSwipeOffscreen()
setupReadTracker()
setupPWAPrompt()
})

View File

@ -0,0 +1,29 @@
{
"background_color": "white",
"description": "Chat pour le TFJM²",
"display": "standalone",
"icons": [
{
"src": "tfjm-square.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable"
},
{
"src": "tfjm-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "tfjm-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
}
],
"name": "Chat TFJM²",
"short_name": "Chat TFJM²",
"start_url": "/chat/fullscreen",
"theme_color": "black"
}

View File

@ -0,0 +1,19 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block extracss %}
<link rel="manifest" href="{% static "chat.webmanifest" %}">
{% endblock %}
{% block content-title %}{% endblock %}
{% block content %}
{% include "chat/content.html" %}
{% endblock %}
{% block extrajavascript %}
{# This script contains all data for the chat management #}
<script src="{% static 'chat.js' %}"></script>
{% endblock %}

View File

@ -0,0 +1,99 @@
{% load i18n %}
<noscript>
{% 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">
<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">
<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">
<li class="list-group-item d-none">
<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">
<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">
<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">
<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">
{% trans "You can install a shortcut to the chat on your home screen using the download button on the header." %}
</div>
<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 %}
{# Logout button must be present in a form. The form must includes the whole line. #}
<form action="{% url 'chat:logout' %}" method="post">
{% csrf_token %}
{% endif %}
<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>
{% if not fullscreen %}
<button class="btn float-end" type="button" onclick="toggleFullscreen()" title="{% trans "Toggle fullscreen mode" %}">
<i class="fas fa-expand"></i>
</button>
{% else %}
<button class="btn float-end" title="{% trans "Log out" %}">
<i class="fas fa-sign-out-alt"></i>
</button>
{% endif %}
<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>
<div class="card-body d-flex flex-column-reverse flex-grow-0 overflow-y-scroll" id="chat-messages">
<ul class="list-group list-group-flush" id="message-list"></ul>
<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>
<div class="card-footer mt-auto">
<form onsubmit="event.preventDefault(); sendMessage()">
<div class="input-group">
<label for="input-message" class="input-group-text">
<i class="fas fa-comment"></i>
</label>
<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>
const USER_ID = {{ request.user.id }}
const IS_ADMIN = {{ request.user.registration.is_admin|yesno:"true,false" }}
</script>

View File

@ -0,0 +1,34 @@
{% load i18n 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>
Chat du TFJM²
</title>
<meta name="description" content="Chat du TFJM²">
{# Favicon #}
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
<meta name="theme-color" content="#ffffff">
{# Bootstrap CSS #}
<link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'fontawesome/css/all.css' %}">
<link rel="stylesheet" href="{% static 'fontawesome/css/v4-shims.css' %}">
{# Bootstrap JavaScript #}
<script src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script>
<link rel="manifest" href="{% static "chat.webmanifest" %}">
</head>
<body class="d-flex w-100 h-100 flex-column">
{% include "chat/content.html" with fullscreen=True %}
<script src="{% static 'theme.js' %}"></script>
<script src="{% static 'chat.js' %}"></script>
</body>
</html>

View File

@ -0,0 +1,36 @@
{% load i18n 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>
Chat du TFJM² - {% trans "Log in" %}
</title>
<meta name="description" content="Chat du TFJM²">
{# Favicon #}
<link rel="shortcut icon" href="{% static "favicon.ico" %}">
<meta name="theme-color" content="#ffffff">
{# Bootstrap CSS #}
<link rel="stylesheet" href="{% static 'bootstrap/css/bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'fontawesome/css/all.css' %}">
<link rel="stylesheet" href="{% static 'fontawesome/css/v4-shims.css' %}">
{# Bootstrap JavaScript #}
<script src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script>
<link rel="manifest" href="{% static "chat.webmanifest" %}">
</head>
<body class="d-flex w-100 h-100 flex-column">
<div class="container">
<h1>{% trans "Log in" %}</h1>
{% include "registration/includes/login.html" %}
</div>
<script src="{% static 'theme.js' %}"></script>
</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

@ -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)
@ -152,7 +166,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')
@ -416,10 +430,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,34 +440,62 @@ 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.
# 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:
tds_copy = tds.copy()
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
@ -465,8 +505,9 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
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."
msg += "Les ordres de passage pour le premier tour sont déterminés à partir des scores des dés, "
msg += "dans l'ordre croissant. Pour le deuxième tour, les ordres de passage sont déterminés à partir "
msg += "des ordres de passage du premier tour."
self.tournament.draw.last_message = msg
await self.tournament.draw.asave()
@ -599,6 +640,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
@ -899,7 +941,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
if already_refused:
msg += "Cela n'ajoute pas de pénalité."
else:
msg += "Cela ajoute une pénalité de 0.5 sur le coefficient de l'oral de la défense."
msg += "Cela ajoute une pénalité de 25&nbsp;% sur le coefficient de l'oral de la défense."
self.tournament.draw.last_message = msg
await self.tournament.draw.asave()
@ -953,15 +995,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):
@ -977,7 +1023,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
r2 = await self.tournament.draw.round_set.filter(number=2).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."
"L'ordre de passage est déterminé à partir du classement du premier tour, " \
"de sorte à mélanger les équipes entre les deux jours."
self.tournament.draw.last_message = msg
await self.tournament.draw.asave()

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

@ -1,6 +1,8 @@
# 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.validators import MaxValueValidator, MinValueValidator
@ -89,6 +91,7 @@ class Draw(models.Model):
return 'WAITING_DRAW_PROBLEM'
else:
return 'WAITING_CHOOSE_PROBLEM'
get_state.short_description = _('State')
@property
def information(self):
@ -145,7 +148,7 @@ class Draw(models.Model):
# 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 += "Refuser ce problème ajoutera une nouvelle pénalité de 25 % sur le coefficient de l'oral de la défense."
else:
s += f"Il reste {len(settings.PROBLEMS) - 5 - len(td.rejected)} refus sans pénalité."
case 'WAITING_FINAL':
@ -289,7 +292,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 +349,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 +361,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:
@ -377,26 +391,34 @@ class Pool(models.Model):
table = [
[0, 2, 3],
[1, 3, 4],
[2, 0, 1],
[3, 4, 0],
[2, 4, 0],
[3, 0, 1],
[4, 1, 2],
]
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
# 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,
)
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
@ -510,9 +532,9 @@ class TeamDraw(models.Model):
@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 defender 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 +545,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

@ -40,6 +40,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 +217,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 +234,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 +252,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')
}
}
/**
@ -582,6 +614,11 @@ 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
}
}
/**
@ -622,14 +659,14 @@ document.addEventListener('DOMContentLoaded', () => {
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 more than P - 5 problems were rejected, add a penalty of 25% of the coefficient of the oral defender
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 - 5))} %`
} else {
// Eventually remove this div
if (penaltyDiv !== null)
@ -736,7 +773,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 +790,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

@ -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>
@ -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>
@ -284,14 +345,14 @@
{% 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 class="text-center">Opp</td>
<td></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">Rap</td>
<td class="text-center">Opp</td>
{% elif forloop.counter == 3 %}
<td class="text-center">Opp</td>
@ -308,10 +369,9 @@
{% 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">Déf</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)
@ -75,7 +75,7 @@ class TestDraw(TestCase):
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')
@ -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
@ -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

@ -7,11 +7,74 @@ from django.utils.translation import gettext_lazy as _
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament, Tweak
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 SynthesisInline(admin.TabularInline):
model = Synthesis
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 = ('defender', 'opponent', 'reporter',)
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)
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, SynthesisInline,)
@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):
@ -49,28 +114,30 @@ 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')
'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', 'defender', 'opponent', 'reporter',)
inlines = (NoteInline,)
@admin.display(description=_("defender"))
@admin.display(description=_("defender"), ordering='defender__team__trigram')
def defender_trigram(self, record: Passage):
return record.defender.team.trigram
@admin.display(description=_("opponent"))
@admin.display(description=_("opponent"), ordering='opponent__team__trigram')
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"))
@admin.display(description=_("pool"), ordering='pool__letter')
def pool_abbr(self, record):
return f"{record.pool.get_letter_display()}{record.pool.round}"
return f"{record.pool.short_name}"
@admin.display(description=_("tournament"))
@admin.display(description=_("tournament"), ordering='pool__tournament__name')
def tournament(self, record: Passage):
return record.pool.tournament
@ -87,7 +154,7 @@ class NoteAdmin(admin.ModelAdmin):
@admin.display(description=_("pool"))
def pool(self, record):
return record.passage.pool.get_letter_display()
return record.passage.pool.short_name
@admin.register(Solution)
@ -124,12 +191,16 @@ class SynthesisAdmin(admin.ModelAdmin):
@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

@ -61,3 +61,9 @@ class TournamentSerializer(serializers.ModelSerializer):
'inscription_limit', 'solution_limit', 'solutions_draw', 'syntheses_first_phase_limit',
'solutions_available_second_phase', 'syntheses_second_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, SynthesisViewSet, TeamViewSet, TournamentViewSet, TweakViewSet
def register_participation_urls(router, path):
@ -17,3 +17,4 @@ def register_participation_urls(router, path):
router.register(path + "/synthesis", SynthesisViewSet)
router.register(path + "/team", TeamViewSet)
router.register(path + "/tournament", TournamentViewSet)
router.register(path + "/tweak", TweakViewSet)

View File

@ -4,8 +4,8 @@ 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, SynthesisSerializer, TeamSerializer, TournamentSerializer, TweakSerializer
from ..models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament, Tweak
class NoteViewSet(ModelViewSet):
@ -67,3 +67,11 @@ class TournamentViewSet(ModelViewSet):
'inscription_limit', 'solution_limit', 'solutions_draw', 'syntheses_first_phase_limit',
'solutions_available_second_phase', 'syntheses_second_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,22 +1,19 @@
# 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, 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 .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament
@ -55,6 +52,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):
@ -79,7 +80,7 @@ class ParticipationForm(forms.ModelForm):
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:
@ -126,7 +127,7 @@ class ValidateParticipationForm(forms.Form):
class TournamentForm(forms.ModelForm):
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'),
@ -175,8 +176,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 +191,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 +227,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 +240,82 @@ 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]
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] == 'Problème':
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 + 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"]:
continue
notes = line[1:line_length]
notes = line[2:line_length]
print(name, notes)
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))
print(notes)
max_notes = pool_size * ([20, 16, 9, 10, 9, 10] + ([4] if pool_size == 4 else []))
max_notes = pool_size * [20, 20, 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
@ -340,7 +336,7 @@ class PassageForm(forms.ModelForm):
class Meta:
model = Passage
fields = ('position', 'solution_number', 'defender', 'opponent', 'reporter', 'observer', 'defender_penalties',)
fields = ('position', 'solution_number', 'defender', 'opponent', 'reporter', 'defender_penalties',)
class SynthesisForm(forms.ModelForm):
@ -371,4 +367,4 @@ class NoteForm(forms.ModelForm):
class Meta:
model = Note
fields = ('defender_writing', 'defender_oral', 'opponent_writing',
'opponent_oral', 'reporter_writing', 'reporter_oral', 'observer_oral', )
'opponent_oral', 'reporter_writing', 'reporter_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

@ -22,11 +22,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('fr')
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()
defender_passage_1 = Passage.objects.get(pool__tournament=tournament, pool__round=1, defender=team2)
opponent_passage_1 = Passage.objects.get(pool__tournament=tournament, pool__round=1, opponent=team2)
reporter_passage_1 = Passage.objects.get(pool__tournament=tournament, pool__round=1, reporter=team2)
pool2 = tournament.pools.filter(round=2, participations=team2).first()
defender_passage_2 = Passage.objects.get(pool__tournament=tournament, pool__round=2, defender=team2)
opponent_passage_2 = Passage.objects.get(pool__tournament=tournament, pool__round=2, opponent=team2)
reporter_passage_2 = Passage.objects.get(pool__tournament=tournament, pool__round=2, reporter=team2)
line.append(team2.team.trigram)
line.append(str(pool1.jury_president or ""))
line.append(f"Pb. {defender_passage_1.solution_number}")
line.extend([defender_passage_1.average_defender_writing, defender_passage_1.average_defender_oral,
opponent_passage_1.average_opponent_writing, opponent_passage_1.average_opponent_oral,
reporter_passage_1.average_reporter_writing, reporter_passage_1.average_reporter_oral])
line.append(str(pool2.jury_president or ""))
line.append(f"Pb. {defender_passage_2.solution_number}")
line.extend([defender_passage_2.average_defender_writing, defender_passage_2.average_defender_oral,
opponent_passage_2.average_opponent_writing, opponent_passage_2.average_opponent_oral,
reporter_passage_2.average_reporter_writing, reporter_passage_2.average_reporter_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",
),
),
]

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,8 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Union
from participation.models import Note, Participation, Passage, Pool, Team
from participation.models import Note, Participation, Passage, Pool, Team, Tournament
from registration.models import Payment
from tfjm.lists import get_sympa_client
@ -36,6 +37,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:
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()) \
@ -137,10 +138,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',)
'reporter_writing', 'reporter_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.defender.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">
@ -25,11 +34,6 @@
<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>
{% 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>
@ -49,9 +53,8 @@
{% 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">
@ -70,46 +73,63 @@
<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>
<dt class="col-sm-8">
{% trans "Average points for the defender writing" %}
({{ passage.defender.team.trigram }}) :
</dt>
<dd class="col-sm-4">{{ passage.average_defender_writing|floatformat }}/20</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 defender oral" %}
({{ passage.defender.team.trigram }}) :
</dt>
<dd class="col-sm-4">{{ passage.average_defender_oral|floatformat }}/20</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 reporter writing" %}
({{ passage.reporter.team.trigram }}) :
</dt>
<dd class="col-sm-4">{{ passage.average_reporter_writing|floatformat }}/10</dd>
<dt class="col-sm-8">{% trans "Average points for the reporter oral:" %}</dt>
<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 }}/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>
{% endif %}
</dl>
<hr>
<dl class="row">
<dt class="col-sm-8">{% trans "Defender points:" %}</dt>
<dt class="col-sm-8">
{% trans "Defender points" %}
({{ passage.defender.team.trigram }}) :
</dt>
<dd class="col-sm-4">{{ passage.average_defender|floatformat }}/52</dd>
<dt class="col-sm-8">{% trans "Opponent points:" %}</dt>
<dt class="col-sm-8">
{% trans "Opponent points" %}
({{ passage.opponent.team.trigram }}) :
</dt>
<dd class="col-sm-4">{{ passage.average_opponent|floatformat }}/29</dd>
<dt class="col-sm-8">{% trans "Reporter points:" %}</dt>
<dt class="col-sm-8">
{% trans "Reporter points" %}
({{ passage.reporter.team.trigram }}) :
</dt>
<dd class="col-sm-4">{{ passage.average_reporter|floatformat }}/19</dd>
{% if passage.observer %}
<dt class="col-sm-8">{% trans "Observer points:" %}</dt>
<dd class="col-sm-4">{{ passage.average_observer|floatformat }}/4</dd>
{% endif %}
</dl>
</div>
</div>
@ -121,12 +141,12 @@
{% 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" as modal_button %}
@ -141,9 +161,9 @@
{% 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 %}")
{% endif %}

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">
@ -18,6 +26,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">
{% for participation in pool.participations.all %}
@ -28,8 +41,8 @@
<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>
@ -38,7 +51,7 @@
{% 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 %}
{% 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>
@ -57,13 +70,45 @@
</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_syntheses' 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,8 +60,21 @@
{% endfor %}
</dd>
{% if team.participation.final %}
<dt class="col-sm-6 text-sm-end">{% trans "Photo authorizations (final):" %}</dt>
<dd class="col-sm-6">
{% 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 %}
{% if not team.participation.tournament.remote %}
<dt class="col-sm-6 text-end">{% trans "Health sheets:" %}</dt>
<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 %}
@ -74,7 +87,7 @@
{% endfor %}
</dd>
<dt class="col-sm-6 text-end">{% trans "Vaccine sheets:" %}</dt>
<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 %}
@ -87,7 +100,7 @@
{% endfor %}
</dd>
<dt class="col-sm-6 text-end">{% trans "Parental authorizations:" %}</dt>
<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,9 +112,24 @@
{% 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>
<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>
@ -112,17 +140,40 @@
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadMotivationLetterModal">{% trans "Replace" %}</button>
{% endif %}
</dd>
</dl>
{% 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>

View File

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

View File

@ -1,4 +1,4 @@
\documentclass[12pt,a4paper,landscape]{article}
\documentclass[11pt,a4paper,landscape]{article}
\usepackage[T1]{fontenc}
\usepackage[utf8x]{inputenc}
@ -22,7 +22,7 @@
\addtolength{\textwidth}{4cm}
\setlength{\parindent}{0mm}
\geometry{left=1.6cm,right=1.6cm,top=1.2cm,bottom=2cm}
\geometry{left=1.6cm,right=1.6cm,top=1.2cm,bottom=1.2cm}
\newcommand{\tfjm}{$\mathbb{TFJM}^2$}
\pagestyle{empty}
@ -49,78 +49,75 @@
\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
\begin{tabular}{|c|p{24mm}|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 %}& P.{{ forloop.counter }} - {{ passage.defender.team.trigram }} {% 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 }}}
\multirow{6}{3mm}{\centering \bf\'E\\ C\\ R\\ I\\ T} & \multirow{3}{20mm}{Partie scientifique} & Profondeur et difficulté des éléments présentés & [0,6] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Présence, exactitude et justesse des démonstrations et algorithmes & [0,6] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Pertinence, efficacité et élégance & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multirow{3}{20mm}{Forme}& Clarté du raisonnement (explications, exemples, illustrations, schémas, etc.) & [0,3]{{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}}
&& Présentation (lisibilité, respect du format, etc.) & [0,2] {{ 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
\multirow{8}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{4}{20mm}{Présentation orale} & Compréhension du matériel présenté, connaissance et maîtrise des sujets mathématiques utilisés \emph{lors de la présentation} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Pertinence des choix (démonstrations, exemples, profondeur au regard de la solution écrite) & [0,4] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Pédagogie et clarté du discours (explications, illustrations, etc.) & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Brieveté et propreté de la présentation & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multirow{2}{20mm}{Débats} & Réponses correctes aux questions posées & [0,5] {{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}}
&& Capacité de faire avancer le débat (expliquer les limites de ses connaissances, des conjectures, rechercher en direct, etc.) & [0,4] {{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}}
&\multirow{2}{20mm}{Malus} & Attitude irrespectueuse ? & [--6,0] {{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}}
&& Non-conformité de la présentation avec le matériel écrit ? & [--6,0] {{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf TOTAL ORAL (/20)} {{ esp|safe }} \\ \hline
\end{tabular}
\newpage
%%%%%%%%%%%%%%%%%OPPOSANT
\begin{tabular}{|c|p{20mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
\begin{tabular}{|c|p{24mm}|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
{% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.opponent.team.trigram }} {% 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
\multirow{4}{3mm}{\centering\bf\'E\\ C\\ R\\ I\\ T} &\multirow{3}{20mm}{Partie scientifique} & Recul et esprit critique par rapport à la solution proposée & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Validité des erreurs et points positifs soulevés & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Repérer les erreurs et points positifs les plus importants et les hiérarchiser & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& Forme & Pr\'esentation (lisibilité, respect du format, etc.) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/10)} {{ 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 }}}
\multirow{6}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{3}{20mm}{Questions et discours de l'opposant\textperiodcentered{}e} & Pertinence des questions (importance des sujets abordés, des points soulevés) & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Gestion de l'échange (formulation des questions, réaction aux réponses, articulation entre les questions, gestion du temps) & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Capacité à évaluer la qualité de la prestation de læ Défenseur⋅se (présentation et réponses à l'Opposant⋅e) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&& Réponses aux questions de læ Rapporteur\textperiodcentered{}rice et du jury (fond et capacité à faire avancer le débat) & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& Malus & Attitude irrespectueuse ? & [-3,0] {{ 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
%%%%%%%%%%%%%%%%%%%%%%RAPPORTEUR.RICE
\begin{tabular}{|c|p{24mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
\multicolumn{4}{|l|}{{\bf Rapporteur\textperiodcentered{}rice} \normalsize \'evalue le d\'ebat entre læ D\'efenseur\textperiodcentered{}se et l'Opposant\textperiodcentered{}e.} {% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.reporter.team.trigram }} {% 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
\multirow{4}{3mm}{\centering\bf\'E\\ C\\ R\\ I\\ T} &\multirow{3}{20mm}{Partie scientifique} & Recul et esprit critique par rapport à la solution proposée & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Validité des erreurs et points positifs soulevés & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Repérer les erreurs et points positifs les plus importants et les hiérarchiser & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& Forme & Présentation (lisibilité, respect du format, etc.) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/10)} {{ 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
\multirow{6}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{3}{20mm}{Questions et discours de læ rapporteur\textperiodcentered{}rice} & \footnotesize Faire prendre de la hauteur au débat (par les sujets abordés, la pertinence des questions posées, les points soulevés, gestion du temps) & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& \footnotesize Créer un échange constructif entre les participants (formulation des questions, réaction aux réponses, articulation entre les questions, circulation de la parole) & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Capacité à évaluer la qualité des échanges (Défenseur⋅se-Opposant⋅e et à trois) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&& Réponses aux questions de læ Rapporteur\textperiodcentered{}rice et du jury (fond et capacité à faire avancer le débat) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& Malus & Attitude irrespectueuse ? & [-3,0] {{ 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

@ -37,52 +37,36 @@
\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 %}
Tour {{ pool.round }} \;-- Poule {{ pool.get_letter_display }}{% if pool.participations.count == 5 %} \;-- {{ pool.get_room_display }}{% endif %} \;-- {% 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 }}}
\begin{tabular}{|p{40mm}{% 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}{40mm}{\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$
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq 20$
{% 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$
& \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 }}}
\multirow{2}{35mm}{\LARGE Rapporteur\textperiodcentered{}rice} {% 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$
& \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 %}
{% if jury %}\underline{ {{ jury.user.first_name|safe }} {{ jury.user.last_name|safe }} }{% else %}\underline{\phantom{Phrase suffisamment longue pour le nom}}{% endif %}
$\qquad$ Signature : \underline{\phantom{Phrase moins longue}}
\newpage

View File

@ -9,60 +9,62 @@
</div>
<div class="card-body">
<dl class="row">
<dt class="col-xl-6 text-end">{% trans 'organizers'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.organizers.all|join:", " }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans 'organizers'|capfirst %}</dt>
<dd class="col-sm-6">{{ tournament.organizers.all|join:", " }}</dd>
<dt class="col-xl-6 text-end">{% trans 'size'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.max_teams }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans 'size'|capfirst %}</dt>
<dd class="col-sm-6">{{ tournament.max_teams }}</dd>
<dt class="col-xl-6 text-end">{% trans 'place'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.place }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans 'place'|capfirst %}</dt>
<dd class="col-sm-6">{{ tournament.place }}</dd>
<dt class="col-xl-6 text-end">{% trans 'price'|capfirst %}</dt>
<dd class="col-xl-6">{% if tournament.price %}{{ tournament.price }} €{% else %}{% trans "Free" %}{% endif %}</dd>
<dt class="col-sm-6 text-sm-end">{% trans 'price'|capfirst %}</dt>
<dd class="col-sm-6">{% if tournament.price %}{{ tournament.price }} €{% else %}{% trans "Free" %}{% endif %}</dd>
<dt class="col-xl-6 text-end">{% trans 'remote'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.remote|yesno }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans 'remote'|capfirst %}</dt>
<dd class="col-sm-6">{{ tournament.remote|yesno }}</dd>
<dt class="col-xl-6 text-end">{% trans 'dates'|capfirst %}</dt>
<dd class="col-xl-6">{% trans "From" %} {{ tournament.date_start }} {% trans "to" %} {{ tournament.date_end }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans 'dates'|capfirst %}</dt>
<dd class="col-sm-6">{% trans "From" %} {{ tournament.date_start }} {% trans "to" %} {{ tournament.date_end }}</dd>
<dt class="col-xl-6 text-end">{% trans 'date of registration closing'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.inscription_limit }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans 'date of registration closing'|capfirst %}</dt>
<dd class="col-sm-6">{{ tournament.inscription_limit }}</dd>
<dt class="col-xl-6 text-end">{% trans 'date of maximal solution submission'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.solution_limit }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans 'date of maximal solution submission'|capfirst %}</dt>
<dd class="col-sm-6">{{ tournament.solution_limit }}</dd>
<dt class="col-xl-6 text-end">{% trans 'date of the random draw'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.solutions_draw }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans 'date of the random draw'|capfirst %}</dt>
<dd class="col-sm-6">{{ tournament.solutions_draw }}</dd>
<dt class="col-xl-6 text-end">{% trans 'date of maximal syntheses submission for the first round'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.syntheses_first_phase_limit }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans 'date of maximal syntheses submission for the first round'|capfirst %}</dt>
<dd class="col-sm-6">{{ tournament.syntheses_first_phase_limit }}</dd>
<dt class="col-xl-6 text-end">{% trans 'date when solutions of round 2 are available'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.solutions_available_second_phase }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans 'date when solutions of round 2 are available'|capfirst %}</dt>
<dd class="col-sm-6">{{ tournament.solutions_available_second_phase }}</dd>
<dt class="col-xl-6 text-end">{% trans 'date of maximal syntheses submission for the second round'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.syntheses_second_phase_limit }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans 'date of maximal syntheses submission for the second round'|capfirst %}</dt>
<dd class="col-sm-6">{{ tournament.syntheses_second_phase_limit }}</dd>
<dt class="col-xl-6 text-end">{% trans 'description'|capfirst %}</dt>
<dd class="col-xl-6">{{ tournament.description }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans 'description'|capfirst %}</dt>
<dd class="col-sm-6">{{ tournament.description }}</dd>
<dt class="col-xl-6 text-end">{% trans 'To contact organizers' %}</dt>
<dd class="col-xl-6"><a href="mailto:{{ tournament.organizers_email }}">{{ tournament.organizers_email }}</a></dd>
<dt class="col-sm-6 text-sm-end">{% trans 'To contact organizers' %}</dt>
<dd class="col-sm-6"><a href="mailto:{{ tournament.organizers_email }}">{{ tournament.organizers_email }}</a></dd>
<dt class="col-xl-6 text-end">{% trans 'To contact juries' %}</dt>
<dd class="col-xl-6"><a href="mailto:{{ tournament.jurys_email }}">{{ tournament.jurys_email }}</a></dd>
<dt class="col-sm-6 text-sm-end">{% trans 'To contact juries' %}</dt>
<dd class="col-sm-6"><a href="mailto:{{ tournament.jurys_email }}">{{ tournament.jurys_email }}</a></dd>
<dt class="col-xl-6 text-end">{% trans 'To contact valid teams' %}</dt>
<dd class="col-xl-6"><a href="mailto:{{ tournament.teams_email }}">{{ tournament.teams_email }}</a></dd>
<dt class="col-sm-6 text-sm-end">{% trans 'To contact valid teams' %}</dt>
<dd class="col-sm-6"><a href="mailto:{{ tournament.teams_email }}">{{ tournament.teams_email }}</a></dd>
</dl>
</div>
{% if user.registration.is_admin or user.registration in tournament.organizers.all %}
<div class="card-footer text-center">
<a href="{% url "participation:tournament_update" pk=tournament.pk %}"><button class="btn btn-secondary">{% trans "Edit tournament" %}</button></a>
<a href="{% url "participation:tournament_csv" pk=tournament.pk %}"><button class="btn btn-success">{% trans "Export as CSV" %}</button></a>
<a class="btn btn-secondary" href="{% url "participation:tournament_update" pk=tournament.pk %}">
<i class="fas fa-edit"></i>
{% trans "Edit tournament" %}
</a>
</div>
{% endif %}
</div>
@ -74,6 +76,14 @@
{% render_table teams %}
</div>
{% if user.registration.is_admin or user.registration in tournament.organizers.all %}
<div class="text-center">
<a href="{% url "participation:tournament_payments" pk=tournament.pk %}" class="btn btn-secondary">
<i class="fas fa-money-bill-wave"></i> {% trans "Access to payments list" %}
</a>
</div>
{% endif %}
{% if pools.data %}
<hr>
@ -83,12 +93,6 @@
</div>
{% endif %}
{% if user.registration.is_admin %}
<div class="d-grid">
<button class="btn gap-0 btn-success" data-bs-toggle="modal" data-bs-target="#addPoolModal">{% trans "Add new pool" %}</button>
</div>
{% endif %}
{% if notes %}
<hr>
@ -99,27 +103,159 @@
<div class="card-body">
<ul>
{% for participation, note in notes %}
<li><strong>{{ participation.team }} :</strong> {{ note|floatformat }}</li>
<li>
<strong>{{ participation.team }} :</strong> {{ note|floatformat }}
{% if available_notes_2 %}
{% if not tournament.final and participation.mention %}
— {{ participation.mention }}
{% endif %}
{% if tournament.final and participation.mention_final %}
— {{ participation.mention_final }}
{% endif %}
{% endif %}
{% if participation.final %}
<span class="badge badge-sm text-bg-warning">
<i class="fas fa-medal"></i>
{% trans "Selected for final tournament" %}
</span>
{% endif %}
{% if user.registration.is_admin or user.registration in tournament.organizers.all %}
{% if team_selectable_for_final == participation %}
<a href="{% url 'participation:select_team_final' pk=tournament.pk participation_id=participation.pk %}"
class="badge badge-sm text-bg-success">
<i class="fas fa-medal"></i>
{% trans "Select for final tournament" %}
</a>
{% endif %}
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% if user.registration.is_admin or user.registration in tournament.organizers.all %}
<div class="card-footer text-center">
<div class="btn-group">
<a href="{% url 'participation:tournament_harmonize' pk=tournament.pk round=1 %}" class="btn btn-secondary">
<i class="fas fa-ranking-star"></i>
{% trans "Harmonize" %} - {% trans "Day" %} 1
</a>
<a href="{% url 'participation:tournament_harmonize' pk=tournament.pk round=2 %}" class="btn btn-secondary">
<i class="fas fa-ranking-star"></i>
{% trans "Harmonize" %} - {% trans "Day" %} 2
</a>
</div>
</div>
<div class="card-footer text-center">
<div class="btn-group">
{% if not available_notes_1 %}
<a href="{% url 'participation:tournament_publish_notes' pk=tournament.pk round=1 %}" class="btn btn-sm btn-info">
<i class="fas fa-eye"></i>
{% trans "Publish notes for first round" %}
</a>
{% else %}
<a href="{% url 'participation:tournament_publish_notes' pk=tournament.pk round=1 %}?hide" class="btn btn-sm btn-danger">
<i class="fas fa-eye-slash"></i>
{% trans "Unpublish notes for first round" %}
</a>
{% endif %}
{% if not available_notes_2 %}
<a href="{% url 'participation:tournament_publish_notes' pk=tournament.pk round=2 %}" class="btn btn-sm btn-info">
<i class="fas fa-eye"></i>
{% trans "Publish notes for second round" %}
</a>
{% else %}
<a href="{% url 'participation:tournament_publish_notes' pk=tournament.pk round=2 %}?hide" class="btn btn-sm btn-danger">
<i class="fas fa-eye-slash"></i>
{% trans "Unpublish notes for second round" %}
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endif %}
{% if user.registration.is_admin or user.registration in tournament.organizers.all %}
<hr>
<h3>{% trans "Files available for download" %}</h3>
<div class="alert alert-warning fade show files-to-download-collapse" id="files-to-download-popup">
<h4>IMPORTANT</h4>
<p>
Les fichiers accessibles ci-dessous peuvent contenir des informations personnelles.
Par conformité avec le droit européen et par respect de la confidentialité des données
des participant⋅es, vous ne devez utiliser ces données que dans un cadre strictement
nécessaire en lien avec l'organisation du tournoi.
</p>
<p>
De plus, il est de votre responsabilité de supprimer ces fichiers une fois que vous
n'en avez plus besoin, notamment à la fin du tournoi.
</p>
<p class="text-center">
<button class="btn btn-warning" data-bs-toggle="collapse" href=".files-to-download-collapse"
role="button" aria-expanded="false" aria-controls="files-to-download files-to-download-popup">
Je m'engage à ne pas divulguer les données des participant⋅es
et de les supprimer à l'issue du tournoi
</button>
</p>
</div>
<div class="card bg-body shadow fade collapse files-to-download-collapse" id="files-to-download">
<div class="card-body">
<ul>
<li>
<a href="{% url "participation:tournament_csv" pk=tournament.pk %}">
Tableur de données des participant⋅es des équipes validées
</a>
</li>
<li>
<a href="{% url "participation:tournament_csv" pk=tournament.pk %}?all">
Tableur de données des participant⋅es de toutes les équipes
</a>
</li>
<li>
<a href="{% url "participation:tournament_authorizations" tournament_id=tournament.id %}">
Archive de toutes les autorisations triées par équipe et par personne
</a>
</li>
<li>
<a href="{% url "participation:tournament_solutions" tournament_id=tournament.id %}">
Archive de toutes les solutions envoyées triées par équipe
</a>
</li>
<li>
<a href="{% url "participation:tournament_solutions" tournament_id=tournament.id %}?sort_by=problem">
Archive de toutes les solutions envoyées triées par problème
</a>
</li>
<li>
<a href="{% url "participation:tournament_solutions" tournament_id=tournament.id %}?sort_by=pool">
Archive de toutes les solutions envoyées triées par poule
</a>
</li>
<li>
<a href="{% url "participation:tournament_syntheses" tournament_id=tournament.id %}?sort_by=pool">
Archive de toutes les notes de synthèse triées par poule et par passage
</a>
</li>
<li>
<a href="https://docs.google.com/spreadsheets/d/{{ tournament.notes_sheet_id }}/edit">
<i class="fas fa-table"></i>
Tableur de notes sur Google Sheets
</a>
</li>
<li>
<a href="{% url "participation:tournament_notation_sheets" tournament_id=tournament.id %}">
Archive de toutes les feuilles de notes à imprimer triées par poule
</a>
</li>
</ul>
</div>
</div>
{% endif %}
{% if user.registration.is_admin %}
{% trans "Add pool" as modal_title %}
{% trans "Add" as modal_button %}
{% url "participation:pool_create" as modal_action %}
{% include "base_modal.html" with modal_id="addPool" %}
{% endif %}
{% endblock %}
{% block extrajavascript %}
<script>
document.addEventListener('DOMContentLoaded', () => {
{% if user.registration.is_admin %}
initModal("addPool", "{% url "participation:pool_create" %}")
{% endif %}
});
</script>
{% endblock %}

View File

@ -0,0 +1,52 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="card bg-body shadow">
<div class="card-header text-center">
<h5>{% trans "Ranking" %}</h5>
</div>
<div class="card-body">
<table class="table table-striped text-center">
<thead>
<tr>
<th>{% trans "Rank" %}</th>
<th>{% trans "team"|capfirst %}</th>
<th>{% trans "Note" %}</th>
<th>{% trans "Including bonus / malus" %}</th>
<th>{% trans "Add bonus / malus" %}</th>
</tr>
</thead>
<tbody>
{% for participation, note in notes %}
<tr>
<td>{{ forloop.counter }}</td>
<td>{{ participation.team }}</td>
<td>{{ note.note|floatformat }}</td>
<td>{% if note.tweak >= 0 %}+{% endif %}{{ note.tweak }}</td>
<td>
<div class="btn-group">
<a href="{% url 'participation:tournament_harmonize_note' pk=tournament.pk round=round action="add" trigram=participation.team.trigram %}"
class="btn btn-sm btn-success">
+1
</a>
<a href="{% url 'participation:tournament_harmonize_note' pk=tournament.pk round=round action="remove" trigram=participation.team.trigram %}"
class="btn btn-sm btn-danger">
-1
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card-footer text-center">
<a class="btn btn-secondary" href="{% url 'participation:tournament_detail' pk=tournament.pk %}">
<i class="fas fa-arrow-left-long"></i>
{% trans "Back to tournament page" %}
</a>
</div>
</div>
{% endblock %}

View File

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

View File

@ -0,0 +1,11 @@
{% extends "base.html" %}
{% load django_tables2 i18n %}
{% block content %}
{% render_table table %}
<a href="{% url "participation:tournament_detail" pk=tournament.pk %}" class="btn btn-primary">
<i class="fas fa-long-arrow-alt-left"></i> {% trans "Back to tournament page" %}
</a>
{% endblock %}

View File

@ -6,9 +6,16 @@
{% block content %}
<form method="post" enctype="multipart/form-data">
<div id="form-content">
<div class="alert alert-warning">
{% url 'participation:pool_jury' pk=pool.jury as jury_url %}
{% blocktrans trimmed with jury_url=jury_url %}
Rows that are full of zeros are ignored.
Unknown juries are not considered.
{% endblocktrans %}
</div>
<div class="alert alert-info">
<a class="alert-link" href="{% url "participation:pool_notes_template" pk=pool.pk %}">
{% trans "Download empty notation sheet" %}
{% trans "Download empty notation sheet" %}
</a>
</div>
{% csrf_token %}

View File

@ -9,6 +9,7 @@
{% trans "Templates:" %}
<a class="alert-link" href="{% static "Fiche_synthèse.pdf" %}"> PDF</a>
<a class="alert-link" href="{% static "Fiche_synthèse.tex" %}"> TEX</a>
<a class="alert-link" href="{% static "Fiche_synthèse.odt" %}"> ODT</a>
<a class="alert-link" href="{% static "Fiche_synthèse.docx" %}" title="{% trans "Warning: non-free format" %}"> DOCX</a>
</div>
{% csrf_token %}

View File

@ -4,10 +4,12 @@
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.models import Site
from django.core import mail
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.management import call_command
from django.test import TestCase
from django.test import LiveServerTestCase, override_settings, TestCase
from django.urls import reverse
from registration.models import CoachRegistration, StudentRegistration
from registration.models import CoachRegistration, Payment, StudentRegistration
from .models import Participation, Team, Tournament
@ -438,6 +440,7 @@ class TestStudentParticipation(TestCase):
self.user.registration.save()
# Team is valid
self.team.participation.tournament = self.tournament
self.team.participation.valid = True
self.team.participation.save()
response = self.client.post(reverse("participation:team_leave"))
@ -479,6 +482,7 @@ class TestStudentParticipation(TestCase):
reverse("participation:participation_detail", args=(self.team.participation.pk,)),
302, 403)
self.team.participation.tournament = self.tournament
self.team.participation.valid = True
self.team.participation.save()
response = self.client.get(reverse("participation:my_participation_detail"))
@ -513,6 +517,616 @@ class TestStudentParticipation(TestCase):
self.assertEqual(resp.status_code, 403)
class TestPayment(TestCase):
"""
Tests that are relative to a payment
"""
def setUp(self):
self.superuser = User.objects.create_superuser(
username="admin",
email="admin@example.com",
password="admin",
)
self.tournament = Tournament.objects.create(
name="France",
place="Here",
price=21,
)
self.team = Team.objects.create(
name="Super team",
trigram="AAA",
access_code="azerty",
)
self.user = User.objects.create(
first_name="Toto",
last_name="Toto",
email="toto@example.com",
password="toto",
)
StudentRegistration.objects.create(
user=self.user,
team=self.team,
student_class=12,
address="1 Rue de Rivoli",
zip_code=75001,
city="Paris",
school="Earth",
give_contact_to_animath=True,
email_confirmed=True,
)
self.second_user = User.objects.create(
first_name="Lalala",
last_name="Lalala",
email="lalala@example.com",
password="lalala",
)
StudentRegistration.objects.create(
user=self.second_user,
team=self.team,
student_class=11,
address="1 Rue de Rivoli",
zip_code=75001,
city="Paris",
school="Moon",
give_contact_to_animath=True,
email_confirmed=True,
)
self.coach = User.objects.create(
first_name="Coach",
last_name="Coach",
email="coach@example.com",
password="coach",
)
CoachRegistration.objects.create(
user=self.coach,
team=self.team,
address="1 Rue de Rivoli",
zip_code=75001,
city="Paris",
)
self.team.participation.tournament = self.tournament
self.team.participation.valid = True
self.team.participation.save()
self.client.force_login(self.user)
def test_check_payments_exists(self):
"""
Check that users in a validated team have an invalid payment, but not for the final,
and that coaches are not concerned.
"""
self.assertTrue(Payment.objects.filter(final=False, valid=False, type='',
registrations=self.user.registration).exists())
self.assertTrue(Payment.objects.filter(final=False, valid=False, type='',
registrations=self.second_user.registration).exists())
self.assertFalse(Payment.objects.filter(final=False, valid=False, type='',
registrations=self.coach.registration).exists())
self.assertFalse(Payment.objects.filter(final=True, valid=False, type='',
registrations=self.user.registration).exists())
self.assertFalse(Payment.objects.filter(final=True, valid=False, type='',
registrations=self.second_user.registration).exists())
self.assertFalse(Payment.objects.filter(final=True, valid=False, type='',
registrations=self.coach.registration).exists())
def test_load_payment_page(self):
"""
Ensure that the payment page loads correctly.
"""
response = self.client.get(reverse('participation:team_detail', args=(self.team.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse('registration:user_detail', args=(self.user.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse('participation:tournament_payments', args=(self.tournament.pk,)))
self.assertEqual(response.status_code, 403)
payment = Payment.objects.get(registrations=self.user.registration, final=False)
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
self.assertEqual(response.status_code, 200)
def test_bank_transfer_payment(self):
"""
Try to send a bank transfer.
"""
payment = Payment.objects.get(registrations=self.user.registration, final=False)
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
data={'type': "bank_transfer",
'additional_information': "This is a bank transfer"})
self.assertEqual(response.status_code, 200)
self.assertFormError(response.context['form'], 'receipt',
["This field is required.", "You must upload your receipt."])
payment.refresh_from_db()
self.assertFalse(payment.valid)
self.assertEqual(payment.type, "")
# README is not a valid PDF file
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
data={'type': "bank_transfer",
'additional_information': "This is a bank transfer",
'receipt': open("README.md", "rb")})
self.assertEqual(response.status_code, 200)
self.assertFormError(response.context['form'], 'receipt',
["The uploaded file must be a PDF, PNG of JPEG file."])
self.assertFalse(payment.valid)
self.assertEqual(payment.type, "")
# Don't send too large files
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
data={'type': "bank_transfer",
'additional_information': "This is a bank transfer",
'receipt': SimpleUploadedFile(
"file.pdf",
content=int(0).to_bytes(2000001, "big"),
content_type="application/pdf"),
})
self.assertEqual(response.status_code, 200)
self.assertFormError(response.context['form'], 'receipt',
["The uploaded file size must be under 2 Mo."])
self.assertFalse(payment.valid)
self.assertEqual(payment.type, "")
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
data={'type': "bank_transfer",
'additional_information': "This is a bank transfer",
'receipt': open("tfjm/static/Fiche_sanitaire.pdf", "rb")})
self.assertRedirects(response, reverse('participation:team_detail', args=(self.team.pk,)), 302, 200)
payment.refresh_from_db()
self.assertIsNone(payment.valid)
self.assertEqual(payment.type, "bank_transfer")
self.assertEqual(payment.additional_information, "This is a bank transfer")
self.assertIsNotNone(payment.receipt)
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
self.assertEqual(response.status_code, 200)
def test_scholarship(self):
"""
Try to don't pay because of a scholarship.
"""
payment = Payment.objects.get(registrations=self.user.registration, final=False)
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
data={'type': "scholarship",
'additional_information': "I don't have to pay because I have a scholarship"})
self.assertEqual(response.status_code, 200)
self.assertFormError(response.context['form'], 'receipt',
["This field is required.", "You must upload your receipt."])
payment.refresh_from_db()
self.assertFalse(payment.valid)
self.assertEqual(payment.type, "")
self.assertEqual(payment.amount, self.tournament.price)
# README is not a valid PDF file
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
data={'type': "scholarship",
'additional_information': "I don't have to pay because I have a scholarship",
'receipt': open("README.md", "rb")})
self.assertEqual(response.status_code, 200)
self.assertFormError(response.context['form'], 'receipt',
["The uploaded file must be a PDF, PNG of JPEG file."])
self.assertFalse(payment.valid)
self.assertEqual(payment.type, "")
self.assertEqual(payment.amount, self.tournament.price)
# Don't send too large files
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
data={'type': "scholarship",
'additional_information': "I don't have to pay because I have a scholarship",
'receipt': SimpleUploadedFile(
"file.pdf",
content=int(0).to_bytes(2000001, "big"),
content_type="application/pdf"),
})
self.assertEqual(response.status_code, 200)
self.assertFormError(response.context['form'], 'receipt',
["The uploaded file size must be under 2 Mo."])
self.assertFalse(payment.valid)
self.assertEqual(payment.type, "")
self.assertEqual(payment.amount, self.tournament.price)
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
data={'type': "scholarship",
'additional_information': "I don't have to pay because I have a scholarship",
'receipt': open("tfjm/static/Fiche_sanitaire.pdf", "rb")})
self.assertRedirects(response, reverse('participation:team_detail', args=(self.team.pk,)), 302, 200)
payment.refresh_from_db()
self.assertIsNone(payment.valid)
self.assertEqual(payment.type, "scholarship")
self.assertEqual(payment.additional_information, "I don't have to pay because I have a scholarship")
self.assertIsNotNone(payment.receipt)
self.assertEqual(payment.amount, 0)
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
self.assertEqual(response.status_code, 200)
def test_other(self):
"""
Try to send a different type of payment.
"""
payment = Payment.objects.get(registrations=self.user.registration, final=False)
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
data={'type': "other"})
self.assertEqual(response.status_code, 200)
self.assertFormError(response.context['form'], 'additional_information',
["This field is required."])
payment.refresh_from_db()
self.assertFalse(payment.valid)
self.assertEqual(payment.type, "")
self.assertEqual(payment.amount, self.tournament.price)
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
data={'type': "other",
'additional_information': "Why should I pay"})
self.assertRedirects(response, reverse('participation:team_detail', args=(self.team.pk,)), 302, 200)
payment.refresh_from_db()
self.assertIsNone(payment.valid)
self.assertEqual(payment.type, "other")
self.assertEqual(payment.additional_information, "Why should I pay")
self.assertIsNotNone(payment.receipt)
self.assertEqual(payment.amount, self.tournament.price)
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
self.assertEqual(response.status_code, 200)
def test_group(self):
payment = Payment.objects.get(registrations=self.user.registration, final=False)
self.assertFalse(payment.grouped)
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse('registration:update_payment_group_mode', args=(payment.pk,)))
self.assertRedirects(response, reverse('registration:update_payment', args=(payment.pk,)), 302, 200)
payment.refresh_from_db()
self.assertTrue(payment.grouped)
self.assertEqual(Payment.objects.count(), 1)
self.assertIn(self.user.registration, payment.registrations.all())
self.assertIn(self.second_user.registration, payment.registrations.all())
self.assertEqual(payment.amount, 2 * self.tournament.price)
def test_ungroup(self):
"""
Test to ungroup payments
"""
payment = Payment.objects.get(registrations=self.user.registration, final=False)
self.client.get(reverse('registration:update_payment_group_mode', args=(payment.pk,)))
payment.refresh_from_db()
self.assertTrue(payment.grouped)
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse('registration:update_payment_group_mode', args=(payment.pk,)))
self.assertRedirects(response, reverse('registration:update_payment', args=(payment.pk,)), 302, 200)
payment.refresh_from_db()
self.assertFalse(payment.grouped)
self.assertEqual(Payment.objects.count(), 2)
self.assertIn(self.user.registration, payment.registrations.all())
self.assertNotIn(self.second_user.registration, payment.registrations.all())
self.assertEqual(payment.amount, self.tournament.price)
def test_group_forbidden(self):
"""
Payment grouping is forbidden if at least one payment is already valid.
"""
payment = Payment.objects.get(registrations=self.user.registration, final=False)
payment.valid = True
payment.save()
payment2 = Payment.objects.get(registrations=self.second_user.registration, final=False)
response = self.client.get(reverse('registration:update_payment_group_mode', args=(payment.pk,)))
self.assertEqual(response.status_code, 403)
response = self.client.get(reverse('registration:update_payment_group_mode', args=(payment2.pk,)))
self.assertEqual(response.status_code, 403)
def test_validate_payment(self):
"""
Try to validate a payment.
"""
payment = Payment.objects.get(registrations=self.user.registration, final=False)
payment.type = "other"
payment.valid = None
payment.save()
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
data={'valid': True})
self.assertEqual(response.status_code, 403)
self.assertFalse(payment.valid)
self.client.force_login(self.superuser)
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
data={'valid': True})
self.assertRedirects(response, reverse('participation:team_detail', args=(self.team.pk,)), 302, 200)
payment.refresh_from_db()
self.assertTrue(payment.valid)
def test_invalidate_payment(self):
"""
Try to invalidate a payment.
"""
payment = Payment.objects.get(registrations=self.user.registration, final=False)
payment.type = "other"
payment.valid = None
payment.save()
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
self.assertEqual(response.status_code, 200)
self.client.force_login(self.superuser)
response = self.client.post(reverse('registration:update_payment', args=(payment.pk,)),
data={'valid': False})
self.assertRedirects(response, reverse('participation:team_detail', args=(self.team.pk,)), 302, 200)
payment.refresh_from_db()
self.assertFalse(payment.valid)
def test_payment_reminder(self):
"""
Check that the payment reminder command works correctly.
"""
self.assertEqual(len(mail.outbox), 0)
call_command('remind_payments')
self.assertEqual(len(mail.outbox), 2)
self.assertEqual(mail.outbox[0].subject, "[TFJM²] Rappel pour votre paiement")
payment = Payment.objects.get(registrations=self.user.registration, final=False)
payment2 = Payment.objects.get(registrations=self.second_user.registration, final=False)
payment.type = 'other'
payment.valid = True
payment.save()
payment2.type = 'bank_transfer'
payment2.valid = None
payment2.save()
mail.outbox = []
call_command('remind_payments')
self.assertEqual(len(mail.outbox), 0)
@override_settings(HELLOASSO_TEST_ENDPOINT=True, ROOT_URLCONF="tfjm.helloasso.test_urls")
class TestHelloAssoPayment(LiveServerTestCase):
"""
Tests that are relative to a HelloAsso
"""
def setUp(self):
self.superuser = User.objects.create_superuser(
username="admin",
email="admin@example.com",
password="admin",
)
self.tournament = Tournament.objects.create(
name="France",
place="Here",
price=21,
)
self.team = Team.objects.create(
name="Super team",
trigram="AAA",
access_code="azerty",
)
self.user = User.objects.create(
first_name="Toto",
last_name="Toto",
email="toto@example.com",
password="toto",
)
StudentRegistration.objects.create(
user=self.user,
team=self.team,
student_class=12,
address="1 Rue de Rivoli",
zip_code=75001,
city="Paris",
school="Earth",
give_contact_to_animath=True,
email_confirmed=True,
)
self.coach = User.objects.create(
first_name="Coach",
last_name="Coach",
email="coach@example.com",
password="coach",
)
CoachRegistration.objects.create(
user=self.coach,
team=self.team,
address="1 Rue de Rivoli",
zip_code=75001,
city="Paris",
)
self.team.participation.tournament = self.tournament
self.team.participation.valid = True
self.team.participation.save()
self.client.force_login(self.user)
Site.objects.update(domain=self.live_server_url.replace("http://", ""))
def test_create_checkout_intent(self):
with self.settings(HELLOASSO_TEST_ENDPOINT_URL=self.live_server_url):
payment = Payment.objects.get(registrations=self.user.registration, final=False)
checkout_intent = payment.create_checkout_intent()
self.assertIsNotNone(checkout_intent)
self.assertEqual(checkout_intent['metadata'], {
'payment_id': payment.pk,
'users': [
{
'user_id': self.user.pk,
'first_name': self.user.first_name,
'last_name': self.user.last_name,
'email': self.user.email,
}
],
'final': False,
'tournament_id': self.tournament.pk,
})
self.assertNotIn('order', checkout_intent)
checkout_intent_fetched = payment.get_checkout_intent()
self.assertEqual(checkout_intent, checkout_intent_fetched)
# Don't create a new checkout intent if one already exists
checkout_intent_new = payment.create_checkout_intent()
self.assertEqual(checkout_intent, checkout_intent_new)
payment.refresh_from_db()
self.assertEqual(payment.checkout_intent_id, checkout_intent['id'])
self.assertFalse(payment.valid)
def test_helloasso_payment_success(self):
"""
Simulates the redirection to Hello Asso and the return for a successful payment.
"""
with self.settings(HELLOASSO_TEST_ENDPOINT_URL=self.live_server_url):
payment = Payment.objects.get(registrations=self.user.registration, final=False)
self.assertIsNone(payment.checkout_intent_id)
self.assertFalse(payment.valid)
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse('registration:payment_hello_asso', args=(payment.pk,)),
follow=True)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.redirect_chain[-1],
(reverse('participation:team_detail', args=(self.team.pk,)), 302))
self.assertIn("type=return", response.redirect_chain[1][0])
self.assertIn("code=succeeded", response.redirect_chain[1][0])
payment.refresh_from_db()
self.assertIsNotNone(payment.checkout_intent_id)
self.assertTrue(payment.valid)
checkout_intent = payment.get_checkout_intent()
self.assertIn('order', checkout_intent)
def test_helloasso_payment_refused(self):
"""
Simulates the redirection to Hello Asso and the return for a refused payment.
"""
with self.settings(HELLOASSO_TEST_ENDPOINT_URL=self.live_server_url):
payment = Payment.objects.get(registrations=self.user.registration, final=False)
checkout_intent = payment.create_checkout_intent()
self.assertFalse(payment.valid)
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.get(checkout_intent['redirectUrl'] + "?refused", follow=True)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.redirect_chain[-1],
(reverse('registration:update_payment', args=(payment.pk,)), 302))
self.assertIn("type=return", response.redirect_chain[0][0])
self.assertIn("code=refused", response.redirect_chain[0][0])
payment.refresh_from_db()
self.assertFalse(payment.valid)
checkout_intent = payment.get_checkout_intent()
self.assertNotIn('order', checkout_intent)
def test_helloasso_payment_error(self):
"""
Simulates the redirection to Hello Asso and the return for an errored payment.
"""
with self.settings(HELLOASSO_TEST_ENDPOINT_URL=self.live_server_url):
payment = Payment.objects.get(registrations=self.user.registration, final=False)
checkout_intent = payment.create_checkout_intent()
self.assertFalse(payment.valid)
response = self.client.get(reverse('registration:update_payment', args=(payment.pk,)))
self.assertEqual(response.status_code, 200)
response = self.client.get(checkout_intent['redirectUrl'] + "?error", follow=True)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.redirect_chain[-1],
(reverse('registration:update_payment', args=(payment.pk,)), 302))
self.assertIn("type=error", response.redirect_chain[0][0])
self.assertIn("error=", response.redirect_chain[0][0])
payment.refresh_from_db()
self.assertFalse(payment.valid)
checkout_intent = payment.get_checkout_intent()
self.assertNotIn('order', checkout_intent)
def test_anonymous_payment(self):
"""
Test to make a successful payment from an anonymous user, authenticated by token.
"""
self.client.logout()
with self.settings(HELLOASSO_TEST_ENDPOINT_URL=self.live_server_url):
payment = Payment.objects.get(registrations=self.user.registration, final=False)
self.assertIsNone(payment.checkout_intent_id)
self.assertFalse(payment.valid)
response = self.client.get(reverse('registration:payment_hello_asso', args=(payment.pk,)),
follow=True)
self.assertRedirects(response,
f"{reverse('login')}?next="
f"{reverse('registration:payment_hello_asso', args=(payment.pk,))}")
response = self.client.get(
reverse('registration:payment_hello_asso', args=(payment.pk,)) + "?token=" + payment.token,
follow=True)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.redirect_chain[-1], (reverse('index'), 302))
self.assertIn("type=return", response.redirect_chain[1][0])
self.assertIn("code=succeeded", response.redirect_chain[1][0])
payment.refresh_from_db()
self.assertIsNotNone(payment.checkout_intent_id)
self.assertTrue(payment.valid)
checkout_intent = payment.get_checkout_intent()
self.assertIn('order', checkout_intent)
def test_hello_asso_payment_verification(self):
"""
Check that a payment that is pending verification can be verified.
"""
with self.settings(HELLOASSO_TEST_ENDPOINT_URL=self.live_server_url):
payment = Payment.objects.get(registrations=self.user.registration, final=False)
self.assertFalse(payment.valid)
call_command('check_hello_asso')
payment.refresh_from_db()
self.assertFalse(payment.valid)
self.client.get(reverse('registration:payment_hello_asso', args=(payment.pk,)),
follow=True)
payment.refresh_from_db()
payment.valid = None
payment.additional_information = ""
payment.save()
self.assertIsNone(payment.valid)
call_command('check_hello_asso')
payment.refresh_from_db()
self.assertTrue(payment.valid)
self.assertTrue(payment.additional_information)
class TestAdmin(TestCase):
def setUp(self) -> None:
self.user = User.objects.create_superuser(

View File

@ -2,15 +2,17 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path
from django.views.generic import TemplateView
from .views import CreateTeamView, FinalNotationSheetTemplateView, JoinTeamView, MyParticipationDetailView, \
MyTeamDetailView, NoteUpdateView, ParticipationDetailView, PassageCreateView, PassageDetailView, \
PassageUpdateView, PoolAddJurysView, PoolCreateView, PoolDetailView, PoolDownloadView, PoolNotesTemplateView, \
PoolUpdateTeamsView, PoolUpdateView, PoolUploadNotesView, ScaleNotationSheetTemplateView, SolutionUploadView, \
SynthesisUploadView, TeamAuthorizationsView, TeamDetailView, TeamLeaveView, TeamListView, TeamUpdateView, \
from .views import CreateTeamView, FinalNotationSheetTemplateView, GSheetNotificationsView, JoinTeamView, \
MyParticipationDetailView, MyTeamDetailView, NotationSheetsArchiveView, NoteUpdateView, ParticipationDetailView, \
PassageDetailView, PassageUpdateView, PoolCreateView, PoolDetailView, PoolJuryView, PoolNotesTemplateView, \
PoolPresideJuryView, PoolRemoveJuryView, PoolUpdateView, PoolUploadNotesView, \
ScaleNotationSheetTemplateView, SelectTeamFinalView, \
SolutionsDownloadView, SolutionUploadView, SynthesisUploadView, \
TeamAuthorizationsView, TeamDetailView, TeamLeaveView, TeamListView, TeamUpdateView, \
TeamUploadMotivationLetterView, TournamentCreateView, TournamentDetailView, TournamentExportCSVView, \
TournamentListView, TournamentUpdateView
TournamentHarmonizeNoteView, TournamentHarmonizeView, TournamentListView, TournamentPaymentsView, \
TournamentPublishNotesView, TournamentUpdateView
app_name = "participation"
@ -24,31 +26,51 @@ urlpatterns = [
path("team/<int:pk>/update/", TeamUpdateView.as_view(), name="update_team"),
path("team/<int:pk>/upload-motivation-letter/", TeamUploadMotivationLetterView.as_view(),
name="upload_team_motivation_letter"),
path("team/<int:pk>/authorizations/", TeamAuthorizationsView.as_view(), name="team_authorizations"),
path("team/<int:team_id>/authorizations/", TeamAuthorizationsView.as_view(), name="team_authorizations"),
path("team/leave/", TeamLeaveView.as_view(), name="team_leave"),
path("detail/", MyParticipationDetailView.as_view(), name="my_participation_detail"),
path("detail/<int:pk>/", ParticipationDetailView.as_view(), name="participation_detail"),
path("detail/<int:pk>/solution/", SolutionUploadView.as_view(), name="upload_solution"),
path("detail/<int:team_id>/solutions/", SolutionsDownloadView.as_view(), name="participation_solutions"),
path("tournament/", TournamentListView.as_view(), name="tournament_list"),
path("tournament/create/", TournamentCreateView.as_view(), name="tournament_create"),
path("tournament/<int:pk>/", TournamentDetailView.as_view(), name="tournament_detail"),
path("tournament/<int:pk>/update/", TournamentUpdateView.as_view(), name="tournament_update"),
path("tournament/<int:pk>/payments/", TournamentPaymentsView.as_view(), name="tournament_payments"),
path("tournament/<int:pk>/csv/", TournamentExportCSVView.as_view(), name="tournament_csv"),
path("tournament/<int:tournament_id>/authorizations/", TeamAuthorizationsView.as_view(),
name="tournament_authorizations"),
path("tournament/<int:tournament_id>/solutions/", SolutionsDownloadView.as_view(),
name="tournament_solutions"),
path("tournament/<int:tournament_id>/syntheses/", SolutionsDownloadView.as_view(),
name="tournament_syntheses"),
path("tournament/<int:tournament_id>/notation/sheets/", NotationSheetsArchiveView.as_view(),
name="tournament_notation_sheets"),
path("tournament/<int:pk>/notation/notifications/", GSheetNotificationsView.as_view(),
name="tournament_gsheet_notifications"),
path("tournament/<int:pk>/publish-notes/<int:round>/", TournamentPublishNotesView.as_view(),
name="tournament_publish_notes"),
path("tournament/<int:pk>/harmonize/<int:round>/", TournamentHarmonizeView.as_view(),
name="tournament_harmonize"),
path("tournament/<int:pk>/harmonize/<int:round>/<str:action>/<str:trigram>/", TournamentHarmonizeNoteView.as_view(),
name="tournament_harmonize_note"),
path("tournament/<int:pk>/select-final/<int:participation_id>/", SelectTeamFinalView.as_view(),
name="select_team_final"),
path("pools/create/", PoolCreateView.as_view(), name="pool_create"),
path("pools/<int:pk>/", PoolDetailView.as_view(), name="pool_detail"),
path("pools/<int:pk>/update/", PoolUpdateView.as_view(), name="pool_update"),
path("pools/<int:pk>/solutions/", PoolDownloadView.as_view(), name="pool_download_solutions"),
path("pools/<int:pk>/syntheses/", PoolDownloadView.as_view(), name="pool_download_syntheses"),
path("pools/<int:pool_id>/solutions/", SolutionsDownloadView.as_view(), name="pool_download_solutions"),
path("pools/<int:pool_id>/syntheses/", SolutionsDownloadView.as_view(), name="pool_download_syntheses"),
path("pools/<int:pk>/notation/scale/", ScaleNotationSheetTemplateView.as_view(), name="pool_scale_note_sheet"),
path("pools/<int:pk>/notation/final/", FinalNotationSheetTemplateView.as_view(), name="pool_final_note_sheet"),
path("pools/<int:pk>/update-teams/", PoolUpdateTeamsView.as_view(), name="pool_update_teams"),
path("pools/<int:pk>/add-jurys/", PoolAddJurysView.as_view(), name="pool_add_jurys"),
path("pools/<int:pool_id>/notation/sheets/", NotationSheetsArchiveView.as_view(), name="pool_notation_sheets"),
path("pools/<int:pk>/jury/", PoolJuryView.as_view(), name="pool_jury"),
path("pools/<int:pk>/jury/remove/<int:jury_id>/", PoolRemoveJuryView.as_view(), name="pool_remove_jury"),
path("pools/<int:pk>/jury/preside/<int:jury_id>/", PoolPresideJuryView.as_view(), name="pool_preside"),
path("pools/<int:pk>/upload-notes/", PoolUploadNotesView.as_view(), name="pool_upload_notes"),
path("pools/<int:pk>/upload-notes/template/", PoolNotesTemplateView.as_view(), name="pool_notes_template"),
path("pools/passages/add/<int:pk>/", PassageCreateView.as_view(), name="passage_create"),
path("pools/passages/<int:pk>/", PassageDetailView.as_view(), name="passage_detail"),
path("pools/passages/<int:pk>/update/", PassageUpdateView.as_view(), name="passage_update"),
path("pools/passages/<int:pk>/solution/", SynthesisUploadView.as_view(), name="upload_synthesis"),
path("pools/passages/notes/<int:pk>/", NoteUpdateView.as_view(), name="update_notes"),
path("chat/", TemplateView.as_view(template_name="participation/chat.html"), name="chat")
]

File diff suppressed because it is too large Load Diff

View File

@ -3,13 +3,45 @@
from django.contrib import admin
from django.contrib.admin import ModelAdmin
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _
from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicChildModelFilter, PolymorphicParentModelAdmin
from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicChildModelFilter, PolymorphicInlineSupportMixin, \
PolymorphicParentModelAdmin, StackedPolymorphicInline
from .models import CoachRegistration, ParticipantRegistration, Payment, Registration, \
StudentRegistration, VolunteerRegistration
class RegistrationInline(StackedPolymorphicInline):
class StudentRegistrationInline(StackedPolymorphicInline.Child):
model = StudentRegistration
autocomplete_fields = ('team',)
show_change_link = True
class CoachRegistrationInline(StackedPolymorphicInline.Child):
model = CoachRegistration
autocomplete_fields = ('team',)
show_change_link = True
class VolunteerRegistrationInline(StackedPolymorphicInline.Child):
model = VolunteerRegistration
show_change_link = True
model = Registration
child_inlines = (
StudentRegistrationInline,
CoachRegistrationInline,
VolunteerRegistrationInline,
)
class PaymentInline(admin.TabularInline):
model = Payment
extra = 0
autocomplete_fields = ('registrations',)
@admin.register(Registration)
class RegistrationAdmin(PolymorphicParentModelAdmin):
child_models = (StudentRegistration, CoachRegistration, VolunteerRegistration,)
@ -97,11 +129,34 @@ class VolunteerRegistrationAdmin(PolymorphicChildModelAdmin):
@admin.register(Payment)
class PaymentAdmin(ModelAdmin):
list_display = ('registration', 'registration_type', 'type', 'valid', )
search_fields = ('registration__user__last_name', 'registration__user__first_name', 'registration__user__email',)
list_filter = ('registration__team__participation__valid', 'type', 'type', 'valid',)
autocomplete_fields = ('registration',)
list_display = ('concerned_people', 'tournament', 'team', 'grouped', 'type', 'amount', 'final', 'valid', )
search_fields = ('registrations__user__last_name', 'registrations__user__first_name', 'registrations__user__email',
'registrations__team__name', 'registrations__team__participation__team__trigram',)
list_filter = ('registrations__team__participation__valid', 'type',
'grouped', 'valid', 'registrations__team__participation__tournament', 'final',)
autocomplete_fields = ('registrations',)
actions = ('mark_as_valid', 'mark_as_pending', 'mark_as_invalid',)
@admin.display(description=_('registration type'), ordering='registration__polymorphic_ctype')
def registration_type(self, record: Payment):
return record.registration.get_real_instance().type
@admin.display(description=_('concerned people'))
def concerned_people(self, record: Payment):
return ", ".join(f"{reg.user.first_name} {reg.user.last_name}" for reg in record.registrations.all())
@admin.action(description=_('Mark as valid'))
def mark_as_valid(self, request, queryset):
queryset.update(valid=True)
@admin.action(description=_('Mark as pending'))
def mark_as_pending(self, request, queryset):
queryset.update(valid=None)
@admin.action(description=_('Mark as invalid'))
def mark_as_invalid(self, request, queryset):
queryset.update(valid=False)
admin.site.unregister(User)
@admin.register(User)
class UserCustomAdmin(PolymorphicInlineSupportMixin, UserAdmin):
inlines = [RegistrationInline]

View File

@ -1,11 +1,12 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.models import User
from rest_framework import serializers
from rest_polymorphic.serializers import PolymorphicSerializer
from ..models import CoachRegistration, ParticipantRegistration, \
StudentRegistration, VolunteerRegistration
Payment, StudentRegistration, VolunteerRegistration
class CoachSerializer(serializers.ModelSerializer):
@ -38,3 +39,15 @@ class RegistrationSerializer(PolymorphicSerializer):
StudentRegistration: StudentSerializer,
VolunteerRegistration: VolunteerSerializer,
}
class PaymentSerializer(serializers.ModelSerializer):
class Meta:
model = Payment
fields = '__all__'
class BasicUserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'first_name', 'last_name', 'email', ]

View File

@ -1,11 +1,13 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from .views import RegistrationViewSet
from .views import PaymentViewSet, RegistrationViewSet, VolunteersViewSet
def register_registration_urls(router, path):
"""
Configure router for registration REST API.
"""
router.register(path + "/payment", PaymentViewSet)
router.register(path + "/registration", RegistrationViewSet)
router.register(path + "/volunteers", VolunteersViewSet)

View File

@ -1,11 +1,14 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.models import User
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.viewsets import ModelViewSet
from rest_framework.filters import SearchFilter
from rest_framework.permissions import BasePermission, IsAdminUser, IsAuthenticated, SAFE_METHODS
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from .serializers import RegistrationSerializer
from ..models import Registration
from .serializers import BasicUserSerializer, PaymentSerializer, RegistrationSerializer
from ..models import Payment, Registration
class RegistrationViewSet(ModelViewSet):
@ -13,3 +16,25 @@ class RegistrationViewSet(ModelViewSet):
serializer_class = RegistrationSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['user', 'participantregistration__team', ]
class PaymentViewSet(ModelViewSet):
queryset = Payment.objects.all()
serializer_class = PaymentSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['registrations', 'grouped', 'amount', 'final', 'type', 'valid', ]
class IsTournamentOrganizer(BasePermission):
def has_permission(self, request, view):
reg = request.user.registration
return request.method in SAFE_METHODS and reg.is_volunteer and reg.organized_tournaments.exists()
class VolunteersViewSet(ReadOnlyModelViewSet):
queryset = User.objects.filter(registration__volunteerregistration__isnull=False)
serializer_class = BasicUserSerializer
permission_classes = [IsAdminUser | (IsAuthenticated & IsTournamentOrganizer)]
filter_backends = [DjangoFilterBackend, SearchFilter]
filterset_fields = ['first_name', 'last_name', 'email', ]
search_fields = ['$first_name', '$last_name', '$email', ]

View File

@ -12,11 +12,8 @@ class RegistrationConfig(AppConfig):
name = 'registration'
def ready(self):
from registration.signals import create_admin_registration, create_payment, \
set_username, send_email_link
pre_save.connect(set_username, "auth.User")
pre_save.connect(send_email_link, "auth.User")
post_save.connect(create_admin_registration, "auth.User")
post_save.connect(create_payment, "registration.Registration")
post_save.connect(create_payment, "registration.StudentRegistration")
post_save.connect(create_payment, "registration.CoachRegistration")
from registration import signals
pre_save.connect(signals.set_username, 'auth.User')
pre_save.connect(signals.send_email_link, 'auth.User')
pre_save.connect(signals.update_payment_amount, 'registration.Payment')
post_save.connect(signals.create_admin_registration, 'auth.User')

View File

@ -133,6 +133,28 @@ class PhotoAuthorizationForm(forms.ModelForm):
fields = ('photo_authorization',)
class PhotoAuthorizationFinalForm(forms.ModelForm):
"""
Form to send a photo authorization.
"""
def clean_photo_authorization_final(self):
if "photo_authorization_final" in self.files:
file = self.files["photo_authorization_final"]
if file.size > 2e6:
raise ValidationError(_("The uploaded file size must be under 2 Mo."))
if file.content_type not in ["application/pdf", "image/png", "image/jpeg"]:
raise ValidationError(_("The uploaded file must be a PDF, PNG of JPEG file."))
return self.cleaned_data["photo_authorization_final"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["photo_authorization_final"].widget = FileInput()
class Meta:
model = ParticipantRegistration
fields = ('photo_authorization_final',)
class HealthSheetForm(forms.ModelForm):
"""
Form to send a health sheet.
@ -199,6 +221,28 @@ class ParentalAuthorizationForm(forms.ModelForm):
fields = ('parental_authorization',)
class ParentalAuthorizationFinalForm(forms.ModelForm):
"""
Form to send a parental authorization.
"""
def clean_parental_authorization(self):
if "parental_authorization_final" in self.files:
file = self.files["parental_authorization_final"]
if file.size > 2e6:
raise ValidationError(_("The uploaded file size must be under 2 Mo."))
if file.content_type not in ["application/pdf", "image/png", "image/jpeg"]:
raise ValidationError(_("The uploaded file must be a PDF, PNG of JPEG file."))
return self.cleaned_data["parental_authorization"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["parental_authorization_final"].widget = FileInput()
class Meta:
model = StudentRegistration
fields = ('parental_authorization_final',)
class CoachRegistrationForm(forms.ModelForm):
"""
A coach can tell its professional activity.
@ -219,32 +263,68 @@ class VolunteerRegistrationForm(forms.ModelForm):
fields = ('professional_activity', 'admin', 'give_contact_to_animath', 'email_confirmed',)
class PaymentForm(forms.ModelForm):
class PaymentAdminForm(forms.ModelForm):
"""
Indicate payment information
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["valid"].widget.choices[0] = ('unknown', _("Pending"))
payment_type = kwargs.get('data', {}).get('type', "")
self.fields['receipt'].required = payment_type in ["scholarship", "bank_transfer"]
self.fields['additional_information'].required = payment_type in ["other"]
def clean_scholarship_file(self):
if "scholarship_file" in self.files:
file = self.files["scholarship_file"]
def clean_receipt(self):
if "receipt" in self.files:
file = self.files["receipt"]
if file.size > 2e6:
raise ValidationError(_("The uploaded file size must be under 2 Mo."))
if file.content_type not in ["application/pdf", "image/png", "image/jpeg"]:
raise ValidationError(_("The uploaded file must be a PDF, PNG of JPEG file."))
return self.cleaned_data["scholarship_file"]
return self.cleaned_data["receipt"]
def clean(self):
cleaned_data = super().clean()
if "type" in cleaned_data and cleaned_data["type"] == "scholarship" \
and "scholarship_file" not in cleaned_data and not self.instance.scholarship_file:
self.add_error("scholarship_file", _("You must upload your scholarship attestation."))
if "type" in cleaned_data and cleaned_data['type'] in ["scholarship", "bank_transfer"] \
and "receipt" not in self.files and not self.instance.receipt:
self.add_error("receipt", _("You must upload your receipt."))
return cleaned_data
class Meta:
model = Payment
fields = ('type', 'scholarship_file', 'additional_information', 'valid',)
fields = ('type', 'receipt', 'additional_information', 'valid',)
class PaymentForm(forms.ModelForm):
"""
Indicate payment information
"""
def __init__(self, payment_type, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['type'].widget = forms.HiddenInput(attrs={'value': payment_type})
self.fields['receipt'].required = payment_type in ["scholarship", "bank_transfer"]
self.fields['additional_information'].required = payment_type in ["other"]
def clean_receipt(self):
if "receipt" in self.files:
file = self.files["receipt"]
if file.size > 2e6:
raise ValidationError(_("The uploaded file size must be under 2 Mo."))
if file.content_type not in ["application/pdf", "image/png", "image/jpeg"]:
raise ValidationError(_("The uploaded file must be a PDF, PNG of JPEG file."))
return self.cleaned_data["receipt"]
def clean(self):
cleaned_data = super().clean()
if "type" in cleaned_data and cleaned_data['type'] in ["scholarship", "bank_transfer"] \
and "receipt" not in self.files and not self.instance.receipt:
self.add_error("receipt", _("You must upload your receipt."))
return cleaned_data
class Meta:
model = Payment
fields = ('type', 'receipt', 'additional_information',)

View File

@ -0,0 +1,25 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
import json
from django.core.management import BaseCommand
from ...models import Payment
class Command(BaseCommand):
"""
This command checks if the initiated Hello Asso payments are validated or not.
"""
help = "Vérifie si les paiements Hello Asso initiés sont validés ou non. Si oui, valide les inscriptions."
def handle(self, *args, **options):
for payment in Payment.objects.exclude(valid=True).filter(checkout_intent_id__isnull=False).all():
checkout_intent = payment.get_checkout_intent()
if checkout_intent is not None and 'order' in checkout_intent:
payment.type = 'helloasso'
payment.valid = True
payment.additional_information = json.dumps(checkout_intent['order'])
payment.save()
payment.send_helloasso_payment_confirmation_mail()

View File

@ -0,0 +1,17 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.core.management import BaseCommand
from ...models import Payment
class Command(BaseCommand):
"""
This command sends a mail to each participant who has not yet paid.
"""
help = "Envoie un mail de rappel à toustes les participant⋅es qui n'ont pas encore payé ou déclaré de paiement."
def handle(self, *args, **options):
for payment in Payment.objects.filter(valid=False).filter(registrations__team__participation__valid=True).all():
payment.send_remind_mail()

View File

@ -113,7 +113,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('type', models.CharField(blank=True, choices=[('', 'No payment'), ('helloasso', 'Hello Asso'), ('scholarship', 'Scholarship'), ('bank_transfer', 'Bank transfer'), ('other', 'Other (please indicate)'), ('free', 'The tournament is free')], default='', max_length=16, verbose_name='type')),
('scholarship_file', models.FileField(blank=True, default='', help_text='only if you have a scholarship.', upload_to=registration.models.get_scholarship_filename, verbose_name='scholarship file')),
('scholarship_file', models.FileField(blank=True, default='', help_text='only if you have a scholarship.', upload_to=registration.models.get_receipt_filename, verbose_name='scholarship file')),
('additional_information', models.TextField(blank=True, default='', help_text='To help us to find your payment.', verbose_name='additional information')),
('valid', models.BooleanField(default=False, null=True, verbose_name='valid')),
('registration', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='payment', to='registration.participantregistration', verbose_name='registration')),

View File

@ -0,0 +1,76 @@
# Generated by Django 5.0.1 on 2024-02-12 20:40
import registration.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registration", "0010_coachregistration_last_degree"),
]
operations = [
migrations.RemoveField(
model_name="payment",
name="registration",
),
migrations.RemoveField(
model_name="payment",
name="scholarship_file",
),
migrations.AddField(
model_name="payment",
name="amount",
field=models.PositiveSmallIntegerField(
default=0,
help_text="Corresponds to the total required amount to pay, in euros.",
verbose_name="total amount",
),
),
migrations.AddField(
model_name="payment",
name="checkout_intent_id",
field=models.IntegerField(
blank=True,
default=None,
null=True,
verbose_name="Hello Asso checkout intent ID",
),
),
migrations.AddField(
model_name="payment",
name="final",
field=models.BooleanField(
default=False, verbose_name="for final tournament"
),
),
migrations.AddField(
model_name="payment",
name="grouped",
field=models.BooleanField(
default=False,
help_text="If set to true, then one payment is made for the full team, for example if the school pays for all.",
verbose_name="grouped",
),
),
migrations.AddField(
model_name="payment",
name="receipt",
field=models.FileField(
blank=True,
default="",
help_text="only if you have a scholarship or if you chose a bank transfer.",
upload_to=registration.models.get_receipt_filename,
verbose_name="receipt",
),
),
migrations.AddField(
model_name="payment",
name="registrations",
field=models.ManyToManyField(
related_name="payments",
to="registration.participantregistration",
verbose_name="registrations",
),
),
]

View File

@ -0,0 +1,41 @@
# Generated by Django 5.0.1 on 2024-02-20 22:48
import registration.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registration", "0011_remove_payment_registration_and_more"),
]
operations = [
migrations.AddField(
model_name="payment",
name="token",
field=models.CharField(
default=registration.models.get_random_token,
help_text="A token to authorize external users to make this payment.",
max_length=32,
verbose_name="token",
),
),
migrations.AlterField(
model_name="payment",
name="type",
field=models.CharField(
blank=True,
choices=[
("", "No payment"),
("helloasso", "Credit card"),
("scholarship", "Scholarship"),
("bank_transfer", "Bank transfer"),
("other", "Other (please indicate)"),
("free", "The tournament is free"),
],
default="",
max_length=16,
verbose_name="type",
),
),
]

View File

@ -0,0 +1,34 @@
# Generated by Django 5.0.3 on 2024-04-07 08:34
import registration.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registration", "0012_payment_token_alter_payment_type"),
]
operations = [
migrations.AddField(
model_name="participantregistration",
name="photo_authorization_final",
field=models.FileField(
blank=True,
default="",
upload_to=registration.models.get_random_photo_filename,
verbose_name="photo authorization (final)",
),
),
migrations.AddField(
model_name="studentregistration",
name="parental_authorization_final",
field=models.FileField(
blank=True,
default="",
upload_to=registration.models.get_random_parental_filename,
verbose_name="parental authorization (final)",
),
),
]

View File

@ -1,20 +1,24 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import date
from datetime import date, datetime
from django.contrib.sites.models import Site
from django.core.mail import send_mail
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.template import loader
from django.urls import reverse_lazy
from django.utils import timezone
from django.urls import reverse, reverse_lazy
from django.utils import timezone, translation
from django.utils.crypto import get_random_string
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
from django.utils.text import format_lazy
from django.utils.timezone import localtime
from django.utils.translation import gettext_lazy as _
from phonenumber_field.modelfields import PhoneNumberField
from polymorphic.models import PolymorphicModel
from tfjm import helloasso
from tfjm.tokens import email_validation_token
@ -77,6 +81,14 @@ class Registration(PolymorphicModel):
def participates(self):
return isinstance(self, ParticipantRegistration)
@property
def is_student(self):
return isinstance(self, StudentRegistration)
@property
def is_coach(self):
return isinstance(self, CoachRegistration)
@property
def is_admin(self):
return isinstance(self, VolunteerRegistration) and self.admin or self.user.is_superuser
@ -88,6 +100,28 @@ class Registration(PolymorphicModel):
def get_absolute_url(self):
return reverse_lazy("registration:user_detail", args=(self.user_id,))
def registration_informations(self):
return []
def important_informations(self):
informations = []
if not self.email_confirmed:
text = _("Your email address is not validated. Please click on the link you received by email. "
"You can resend a mail by clicking on <a href=\"{send_email_url}\">this link</a>.")
send_email_url = reverse_lazy("registration:email_validation_resend", args=(self.user_id,))
content = format_lazy(text, send_email_url=send_email_url)
informations.append({
'title': "Validation e-mail",
'type': "warning",
'priority': 0,
'content': content,
})
informations.extend(self.registration_informations())
informations.sort(key=lambda info: (info['priority'], info['title']))
return informations
def __str__(self):
return f"{self.user.first_name} {self.user.last_name}"
@ -176,17 +210,39 @@ class ParticipantRegistration(Registration):
default="",
)
photo_authorization_final = models.FileField(
verbose_name=_("photo authorization (final)"),
upload_to=get_random_photo_filename,
blank=True,
default="",
)
@property
def under_18(self):
if isinstance(self, CoachRegistration):
return False # In normal case
important_date = timezone.now().date()
important_date = localtime(timezone.now()).date()
if self.team and self.team.participation.tournament:
important_date = self.team.participation.tournament.date_start
if self.team.participation.final:
from participation.models import Tournament
important_date = Tournament.final_tournament().date_start
return (important_date - self.birth_date).days < 18 * 365.24
birth_date = self.birth_date
if birth_date.month == 2 and birth_date.day == 29:
# If the birth date is the 29th of February, we consider it as the 1st of March
birth_date = birth_date.replace(month=3, day=1)
over_18_on = birth_date.replace(year=birth_date.year + 18)
return important_date < over_18_on
@property
def under_18_final(self):
if isinstance(self, CoachRegistration):
return False # In normal case
from participation.models import Tournament
important_date = Tournament.final_tournament().date_start
birth_date = self.birth_date
if birth_date.month == 2 and birth_date.day == 29:
# If the birth date is the 29th of February, we consider it as the 1st of March
birth_date = birth_date.replace(month=3, day=1)
over_18_on = birth_date.replace(year=birth_date.year + 18)
return important_date < over_18_on
@property
def type(self): # pragma: no cover
@ -196,6 +252,77 @@ class ParticipantRegistration(Registration):
def form_class(self): # pragma: no cover
raise NotImplementedError
def registration_informations(self):
informations = []
if not self.team:
text = _("You are not in a team. You can <a href=\"{create_url}\">create one</a> "
"or <a href=\"{join_url}\">join an existing one</a> to participate.")
create_url = reverse_lazy("participation:create_team")
join_url = reverse_lazy("participation:join_team")
content = format_lazy(text, create_url=create_url, join_url=join_url)
informations.append({
'title': _("No team"),
'type': "danger",
'priority': 1,
'content': content,
})
else:
if self.team.participation.tournament:
if not self.photo_authorization:
text = _("You have not uploaded your photo authorization. "
"You can do it by clicking on <a href=\"{photo_url}\">this link</a>.")
photo_url = reverse_lazy("registration:upload_user_photo_authorization", args=(self.id,))
content = format_lazy(text, photo_url=photo_url)
informations.append({
'title': _("Photo authorization"),
'type': "danger",
'priority': 5,
'content': content,
})
if self.team.participation.final:
if not self.photo_authorization_final:
text = _("You have not uploaded your photo authorization for the final tournament. "
"You can do it by clicking on <a href=\"{photo_url}\">this link</a>.")
photo_url = reverse_lazy("registration:upload_user_photo_authorization_final", args=(self.id,))
content = format_lazy(text, photo_url=photo_url)
informations.append({
'title': _("Photo authorization"),
'type': "danger",
'priority': 5,
'content': content,
})
informations.extend(self.team.important_informations())
return informations
def send_email_final_selection(self):
"""
The team is selected for final.
"""
translation.activate('fr')
subject = "[TFJM²] " + str(_("Team selected for the final tournament"))
site = Site.objects.first()
from participation.models import Tournament
tournament = Tournament.final_tournament()
payment = self.payments.filter(final=True).first() if self.is_student else None
message = loader.render_to_string('registration/mails/final_selection.txt',
{
'user': self.user,
'domain': site.domain,
'tournament': tournament,
'payment': payment,
})
html = loader.render_to_string('registration/mails/final_selection.html',
{
'user': self.user,
'domain': site.domain,
'tournament': tournament,
'payment': payment,
})
self.user.email_user(subject, message, html_message=html)
class Meta:
verbose_name = _("participant registration")
verbose_name_plural = _("participant registrations")
@ -248,6 +375,13 @@ class StudentRegistration(ParticipantRegistration):
default="",
)
parental_authorization_final = models.FileField(
verbose_name=_("parental authorization (final)"),
upload_to=get_random_parental_filename,
blank=True,
default="",
)
health_sheet = models.FileField(
verbose_name=_("health sheet"),
upload_to=get_random_health_filename,
@ -271,6 +405,83 @@ class StudentRegistration(ParticipantRegistration):
from registration.forms import StudentRegistrationForm
return StudentRegistrationForm
def registration_informations(self):
informations = super().registration_informations()
if self.team and self.team.participation.tournament and self.under_18:
if not self.parental_authorization:
text = _("You have not uploaded your parental authorization. "
"You can do it by clicking on <a href=\"{parental_url}\">this link</a>.")
parental_url = reverse_lazy("registration:upload_user_parental_authorization", args=(self.id,))
content = format_lazy(text, parental_url=parental_url)
informations.append({
'title': _("Parental authorization"),
'type': "danger",
'priority': 5,
'content': content,
})
if not self.health_sheet:
text = _("You have not uploaded your health sheet. "
"You can do it by clicking on <a href=\"{health_url}\">this link</a>.")
health_url = reverse_lazy("registration:upload_user_health_sheet", args=(self.id,))
content = format_lazy(text, health_url=health_url)
informations.append({
'title': _("Health sheet"),
'type': "danger",
'priority': 5,
'content': content,
})
if not self.vaccine_sheet:
text = _("You have not uploaded your vaccine sheet. "
"You can do it by clicking on <a href=\"{vaccine_url}\">this link</a>.")
vaccine_url = reverse_lazy("registration:upload_user_vaccine_sheet", args=(self.id,))
content = format_lazy(text, vaccine_url=vaccine_url)
informations.append({
'title': _("Vaccine sheet"),
'type': "danger",
'priority': 5,
'content': content,
})
if self.team and self.team.participation.valid:
for payment in self.payments.all():
if payment.valid is False:
text = _("You have to pay {amount} € for your registration, or send a scholarship "
"notification or a payment proof. "
"You can do it on <a href=\"{url}\">the payment page</a>.")
url = reverse_lazy("registration:update_payment", args=(payment.id,))
content = format_lazy(text, amount=payment.amount, url=url)
informations.append({
'title': _("Payment"),
'type': "danger",
'priority': 3,
'content': content,
})
elif payment.valid is None:
text = _("Your payment is under approval.")
content = text
informations.append({
'title': _("Payment"),
'type': "warning",
'priority': 3,
'content': content,
})
if self.team.participation.final:
if self.under_18_final and not self.parental_authorization_final:
text = _("You have not uploaded your parental authorization for the final tournament. "
"You can do it by clicking on <a href=\"{parental_url}\">this link</a>.")
parental_url = reverse_lazy("registration:upload_user_parental_authorization_final",
args=(self.id,))
content = format_lazy(text, parental_url=parental_url)
informations.append({
'title': _("Parental authorization"),
'type': "danger",
'priority': 5,
'content': content,
})
return informations
class Meta:
verbose_name = _("student registration")
verbose_name_plural = _("student registrations")
@ -334,21 +545,157 @@ class VolunteerRegistration(Registration):
from registration.forms import VolunteerRegistrationForm
return VolunteerRegistrationForm
def important_informations(self):
informations = []
for tournament in self.organized_tournaments.all():
if timezone.now() < tournament.inscription_limit \
or tournament.participations.filter(valid=True).count() < tournament.max_teams:
text = _("Registrations for tournament {tournament} are closing on {date:%Y-%m-%d %H:%M}. "
"There are for now {validated_teams} validated teams (+ {pending_teams} pending) "
"on {max_teams} expected.")
content = format_lazy(text, tournament=tournament.name, date=localtime(tournament.inscription_limit),
validated_teams=tournament.participations.filter(valid=True).count(),
pending_teams=tournament.participations.filter(valid=False).count(),
max_teams=tournament.max_teams)
informations.append({
'title': _("Registrations"),
'type': "info",
'priority': 2,
'content': content,
})
for pending_participation in tournament.participations.filter(valid=False).all():
text = _("The team {trigram} requested to be validated for the tournament of {tournament}. "
"You can check the status of the team on the <a href=\"{url}\">team page</a>.")
url = reverse_lazy("participation:team_detail", args=(pending_participation.team.id,))
content = format_lazy(text, trigram=pending_participation.team.trigram,
tournament=tournament.name, url=url)
informations.append({
'title': _("Pending validation"),
'type': "warning",
'priority': 4,
'content': content,
})
payments = Payment.objects.filter(registrations__team__participation__tournament=tournament).all()
valid = payments.filter(valid=True).count()
pending = payments.filter(valid=None).count()
invalid = payments.filter(valid=False).count()
if pending + invalid > 0:
text = _("There are {valid} validated payments, {pending} pending and {invalid} invalid for the "
"tournament of {tournament}. You can check the status of the payments on the "
"<a href=\"{url}\">payments list</a>.")
url = reverse_lazy("participation:tournament_payments", args=(tournament.id,))
content = format_lazy(text, valid=valid, pending=pending, invalid=invalid, tournament=tournament.name,
url=url)
informations.append({
'title': _("Payments"),
'type': "info",
'priority': 5,
'content': content,
})
if timezone.now() > tournament.solution_limit and timezone.now() < tournament.solutions_draw:
text = _("<p>The draw of the solutions for the tournament {tournament} is planned on the "
"{date:%Y-%m-%d %H:%M}. You can join it on <a href='{url}'>this link</a>.</p>")
url = reverse_lazy("draw:index")
content = format_lazy(text, tournament=tournament.name,
date=localtime(tournament.solutions_draw),
url=url)
informations.append({
'title': _("Draw of solutions"),
'type': "info",
'priority': 1,
'content': content,
})
for tournament in self.interesting_tournaments:
pools = tournament.pools.filter(juries=self).order_by('round').all()
for pool in pools:
if pool.round == 1 and timezone.now().date() <= tournament.date_start:
text = _("<p>You are in the jury of the pool {pool} for the tournament of {tournament}. "
"You can find the pool page <a href='{pool_url}'>here</a>.</p>")
pool_url = reverse_lazy("participation:pool_detail", args=(pool.id,))
content = format_lazy(text, pool=pool.short_name, tournament=tournament.name, pool_url=pool_url)
informations.append({
'title': _("First round"),
'type': "info",
'priority': 1,
'content': content,
})
elif pool.round == 2 and timezone.now().date() <= tournament.date_end:
text = _("<p>You are in the jury of the pool {pool} for the tournament of {tournament}. "
"You can find the pool page <a href='{pool_url}'>here</a>.</p>")
pool_url = reverse_lazy("participation:pool_detail", args=(pool.id,))
content = format_lazy(text, pool=pool.short_name, tournament=tournament.name, pool_url=pool_url)
informations.append({
'title': _("Second round"),
'type': "info",
'priority': 2,
'content': content,
})
for note in self.notes.filter(passage__pool=pool).all():
if not note.has_any_note():
text = _("<p>You don't have given any note as a jury for the passage {passage} "
"in the pool {pool} of {tournament}. "
"You can set your notes <a href='{passage_url}'>here</a>.</p>")
passage_url = reverse_lazy("participation:passage_detail", args=(note.passage.id,))
content = format_lazy(text, passage=note.passage.position, pool=pool.short_name,
tournament=tournament.name, passage_url=passage_url)
informations.append({
'title': _("Note"),
'type': "warning",
'priority': 3 + note.passage.position,
'content': content,
})
return informations
class Meta:
verbose_name = _("volunteer registration")
verbose_name_plural = _("volunteer registrations")
def get_scholarship_filename(instance, filename):
return f"authorization/scholarship/scholarship_{instance.registration.pk}"
def get_receipt_filename(instance, filename):
return f"authorization/receipt/receipt_{instance.id}"
def get_random_token():
return get_random_string(32)
class Payment(models.Model):
registration = models.OneToOneField(
registrations = models.ManyToManyField(
ParticipantRegistration,
on_delete=models.CASCADE,
related_name="payment",
verbose_name=_("registration"),
related_name="payments",
verbose_name=_("registrations"),
)
grouped = models.BooleanField(
verbose_name=_("grouped"),
default=False,
help_text=_("If set to true, then one payment is made for the full team, "
"for example if the school pays for all."),
)
amount = models.PositiveSmallIntegerField(
verbose_name=_("total amount"),
help_text=_("Corresponds to the total required amount to pay, in euros."),
default=0,
)
token = models.CharField(
verbose_name=_("token"),
max_length=32,
default=get_random_token,
help_text=_("A token to authorize external users to make this payment."),
)
final = models.BooleanField(
verbose_name=_("for final tournament"),
default=False,
)
type = models.CharField(
@ -356,7 +703,7 @@ class Payment(models.Model):
max_length=16,
choices=[
('', _("No payment")),
('helloasso', "Hello Asso"),
('helloasso', _("Credit card")),
('scholarship', _("Scholarship")),
('bank_transfer', _("Bank transfer")),
('other', _("Other (please indicate)")),
@ -366,10 +713,17 @@ class Payment(models.Model):
default="",
)
scholarship_file = models.FileField(
verbose_name=_("scholarship file"),
help_text=_("only if you have a scholarship."),
upload_to=get_scholarship_filename,
checkout_intent_id = models.IntegerField(
verbose_name=_("Hello Asso checkout intent ID"),
blank=True,
null=True,
default=None,
)
receipt = models.FileField(
verbose_name=_("receipt"),
help_text=_("only if you have a scholarship or if you chose a bank transfer."),
upload_to=get_receipt_filename,
blank=True,
default="",
)
@ -387,11 +741,95 @@ class Payment(models.Model):
default=False,
)
@property
def team(self):
return self.registrations.first().team
team.fget.short_description = _("team")
team.fget.admin_order_field = 'registrations__team__trigram'
@property
def tournament(self):
if self.final:
from participation.models import Tournament
return Tournament.final_tournament()
return self.registrations.first().team.participation.tournament
tournament.fget.short_description = _("tournament")
tournament.fget.admin_order_field = 'registrations__team__participation__tournament'
def get_checkout_intent(self, none_if_link_disabled=False):
if self.checkout_intent_id is None:
return None
return helloasso.get_checkout_intent(self.checkout_intent_id, none_if_link_disabled=none_if_link_disabled)
def create_checkout_intent(self):
checkout_intent = self.get_checkout_intent(none_if_link_disabled=True)
if checkout_intent is not None:
return checkout_intent
tournament = self.tournament
year = datetime.now().year
base_site = "https://" + Site.objects.first().domain
checkout_intent = helloasso.create_checkout_intent(
amount=100 * self.amount,
name=f"Participation au TFJM² {year} - {tournament.name} - {self.team.trigram}",
back_url=base_site + reverse('registration:update_payment', args=(self.id,)),
error_url=f"{base_site}{reverse('registration:payment_hello_asso_return', args=(self.id,))}?type=error",
return_url=f"{base_site}{reverse('registration:payment_hello_asso_return', args=(self.id,))}?type=return",
contains_donation=False,
metadata=dict(
users=[
dict(user_id=registration.user.id,
first_name=registration.user.first_name,
last_name=registration.user.last_name,
email=registration.user.email,)
for registration in self.registrations.all()
],
payment_id=self.id,
final=self.final,
tournament_id=tournament.id,
)
)
self.checkout_intent_id = checkout_intent["id"]
self.save()
return checkout_intent
def send_remind_mail(self):
translation.activate('fr')
subject = "[TFJM²] " + str(_("Reminder for your payment"))
site = Site.objects.first()
for registration in self.registrations.all():
message = loader.render_to_string('registration/mails/payment_reminder.txt',
dict(registration=registration, payment=self, domain=site.domain))
html = loader.render_to_string('registration/mails/payment_reminder.html',
dict(registration=registration, payment=self, domain=site.domain))
registration.user.email_user(subject, message, html_message=html)
def send_helloasso_payment_confirmation_mail(self):
translation.activate('fr')
subject = "[TFJM²] " + str(_("Payment confirmation"))
site = Site.objects.first()
for registration in self.registrations.all():
message = loader.render_to_string('registration/mails/payment_confirmation.txt',
dict(registration=registration, payment=self, domain=site.domain))
html = loader.render_to_string('registration/mails/payment_confirmation.html',
dict(registration=registration, payment=self, domain=site.domain))
registration.user.email_user(subject, message, html_message=html)
payer = self.get_checkout_intent()['order']['payer']
payer_name = f"{payer['firstName']} {payer['lastName']}"
if not self.registrations.filter(user__email=payer['email']).exists():
message = loader.render_to_string('registration/mails/payment_confirmation.txt',
dict(registration=payer_name, payment=self, domain=site.domain))
html = loader.render_to_string('registration/mails/payment_confirmation.html',
dict(registration=payer_name, payment=self, domain=site.domain))
send_mail(subject, message, None, [payer['email']], html_message=html)
def get_absolute_url(self):
return reverse_lazy("registration:user_detail", args=(self.registration.user.id,))
return reverse_lazy("registration:update_payment", args=(self.pk,))
def __str__(self):
return _("Payment of {registration}").format(registration=self.registration)
return _("Payment of {registrations}").format(registrations=", ".join(map(str, self.registrations.all())))
class Meta:
verbose_name = _("payment")

View File

@ -4,7 +4,7 @@
from django.contrib.auth.models import User
from tfjm.lists import get_sympa_client
from .models import Payment, Registration, VolunteerRegistration
from .models import Registration, VolunteerRegistration
def set_username(instance, **_):
@ -43,14 +43,11 @@ def create_admin_registration(instance, **_):
VolunteerRegistration.objects.get_or_create(user=instance, admin=True)
def create_payment(instance: Registration, raw, **_):
def update_payment_amount(instance, **_):
"""
When a user is saved, create the associated payment.
For a free tournament, the payment is valid.
When a payment got created, ensure that the amount is set.
"""
if instance.participates and not raw:
payment = Payment.objects.get_or_create(registration=instance)[0]
if instance.team and instance.team.participation.valid and instance.team.participation.tournament.price == 0:
payment.valid = True
payment.type = "free"
payment.save()
if instance.type == 'free' or instance.type == 'scholarship':
instance.amount = 0
elif instance.pk and instance.registrations.exists():
instance.amount = instance.registrations.count() * instance.tournament.price

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