1
0
mirror of https://gitlab.com/animath/si/plateforme.git synced 2025-03-31 00:51:13 +00:00

Add survey feature

This commit is contained in:
Emmy D'Anello 2025-03-19 23:18:45 +01:00
parent ca0601fb24
commit 702c8d8c9e
Signed by: ynerant
GPG Key ID: 3A75C55819C8CF85
24 changed files with 744 additions and 100 deletions

View File

@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: TFJM\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-09 11:04+0100\n"
"POT-Creation-Date: 2025-03-19 23:07+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: Emmy D'Anello <emmy.danello@animath.fr>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -114,6 +114,7 @@ msgstr ""
#: registration/models.py:158 registration/models.py:754
#: registration/tables.py:39
#: registration/templates/registration/payment_form.html:52
#: survey/templates/survey/survey_detail.html:60
msgid "team"
msgstr "équipe"
@ -217,7 +218,7 @@ msgstr ""
msgid "Toggle fullscreen mode"
msgstr "Inverse le mode plein écran"
#: chat/templates/chat/content.html:76 tfjm/templates/navbar.html:126
#: chat/templates/chat/content.html:76 tfjm/templates/navbar.html:129
msgid "Log out"
msgstr "Déconnexion"
@ -251,7 +252,7 @@ msgstr "Chat"
#: chat/templates/chat/login.html:10 chat/templates/chat/login.html:36
#: registration/templates/registration/password_reset_complete.html:10
#: tfjm/templates/base.html:89 tfjm/templates/base.html:90
#: tfjm/templates/navbar.html:107
#: tfjm/templates/navbar.html:110
#: tfjm/templates/registration/includes/login.html:22
#: tfjm/templates/registration/login.html:7
#: tfjm/templates/registration/login.html:8
@ -713,7 +714,7 @@ msgstr ""
#: draw/models.py:496 draw/models.py:519 participation/models.py:1258
#: participation/models.py:1695 participation/models.py:1933
#: participation/views.py:1495 participation/views.py:1760
#: participation/views.py:1501 participation/views.py:1766
#, python-brace-format
msgid "Problem #{problem}"
msgstr "Problème n°{problem}"
@ -911,8 +912,8 @@ msgstr "Êtes-vous sûr·e de vouloir annuler le tirage au sort ?"
msgid "Close"
msgstr "Fermer"
#: draw/views.py:31 participation/views.py:163 participation/views.py:509
#: participation/views.py:540
#: draw/views.py:31 participation/views.py:163 participation/views.py:512
#: participation/views.py:543
msgid "You are not in a team."
msgstr "Vous n'êtes pas dans une équipe."
@ -1029,7 +1030,7 @@ msgstr "Ce trigramme est déjà utilisé."
msgid "No team was found with this access code."
msgstr "Aucune équipe n'a été trouvée avec ce code d'accès."
#: participation/forms.py:59 participation/views.py:511
#: participation/forms.py:59 participation/views.py:514
msgid "The team is already validated or the validation is pending."
msgstr "La validation de l'équipe est déjà faite ou en cours."
@ -1382,7 +1383,7 @@ msgstr "finale"
msgid "Google Sheet ID"
msgstr "ID de la feuille Google Sheets"
#: participation/models.py:467 participation/views.py:1814
#: participation/models.py:467 participation/views.py:1820
msgid "Notation sheet"
msgstr "Feuille de notation"
@ -1392,7 +1393,8 @@ msgid "Final ranking"
msgstr "Classement final"
#: participation/models.py:487 participation/models.py:559
#: participation/models.py:1333 participation/views.py:1734
#: participation/models.py:1333 participation/views.py:1740
#: survey/templates/survey/survey_detail.html:58
msgid "Team"
msgstr "Équipe"
@ -1425,14 +1427,14 @@ msgid "Tweaks day 3"
msgstr "Ajustements 3"
#: participation/models.py:491 participation/models.py:1333
#: participation/views.py:1741
#: participation/views.py:1747
msgid "Total"
msgstr "Total"
#: participation/models.py:491 participation/models.py:559
#: participation/models.py:1333
#: participation/templates/participation/tournament_harmonize.html:14
#: participation/views.py:1744
#: participation/views.py:1750
msgid "Rank"
msgstr "Rang"
@ -1443,7 +1445,7 @@ msgstr "Rang"
#: participation/models.py:1360 participation/models.py:1367
#: participation/models.py:1371 participation/models.py:1616
#: participation/models.py:1638 participation/models.py:2102
#: participation/views.py:1815
#: participation/views.py:1821
msgid "Pool"
msgstr "Poule"
@ -1707,59 +1709,59 @@ msgid "The president of the jury must be part of the jury."
msgstr "Læ président⋅e du jury doit faire partie du jury."
#: participation/models.py:1259 participation/models.py:1333
#: participation/views.py:1489 participation/views.py:1738
#: participation/views.py:1495 participation/views.py:1744
msgid "Problem"
msgstr "Problème"
#: participation/models.py:1260 participation/views.py:1510
#: participation/models.py:1260 participation/views.py:1516
msgid "Reporter"
msgstr "Défenseur⋅se"
#: participation/models.py:1261 participation/views.py:1516
#: participation/models.py:1261 participation/views.py:1522
msgid "Opponent"
msgstr "Opposant⋅e"
#: participation/models.py:1262 participation/views.py:1523
#: participation/models.py:1262 participation/views.py:1529
msgid "Reviewer"
msgstr "Rapporteur⋅rice"
#: participation/models.py:1263 participation/views.py:1530
#: participation/models.py:1263 participation/views.py:1536
msgid "Observer"
msgstr "Observateur⋅rice"
#: participation/models.py:1264 participation/views.py:1504
#: participation/models.py:1264 participation/views.py:1510
msgid "Role"
msgstr "Rôle"
#: participation/models.py:1265 participation/models.py:1267
#: participation/models.py:1268 participation/views.py:1546
#: participation/views.py:1554 participation/views.py:1562
#: participation/views.py:1572
#: participation/models.py:1268 participation/views.py:1552
#: participation/views.py:1560 participation/views.py:1568
#: participation/views.py:1578
msgid "Writing"
msgstr "Écrit"
#: participation/models.py:1266 participation/models.py:1267
#: participation/models.py:1268 participation/views.py:1550
#: participation/views.py:1558 participation/views.py:1567
#: participation/views.py:1576
#: participation/models.py:1268 participation/views.py:1556
#: participation/views.py:1564 participation/views.py:1573
#: participation/views.py:1582
msgid "Oral"
msgstr "Oral"
#: participation/models.py:1269 participation/views.py:1538
#: participation/views.py:1539
#: participation/models.py:1269 participation/views.py:1544
#: participation/views.py:1545
msgid "Juree"
msgstr "Juré⋅e"
#: participation/models.py:1292 participation/models.py:1618
#: participation/models.py:1640 participation/views.py:1608
#: participation/models.py:1640 participation/views.py:1614
msgid "Average"
msgstr "Moyenne"
#: participation/models.py:1298 participation/views.py:1627
#: participation/models.py:1298 participation/views.py:1633
msgid "Coefficient"
msgstr "Coefficien"
#: participation/models.py:1299 participation/views.py:1670
#: participation/models.py:1299 participation/views.py:1676
msgid "Subtotal"
msgstr "Sous-total"
@ -1972,12 +1974,15 @@ msgstr "Pas d'équipe définie"
#: registration/templates/registration/payment_form.html:210
#: registration/templates/registration/update_user.html:16
#: registration/templates/registration/user_detail.html:220
#: survey/templates/survey/survey_detail.html:40
#: survey/templates/survey/survey_detail.html:73
#: survey/templates/survey/survey_form.html:12
msgid "Update"
msgstr "Modifier"
#: participation/templates/participation/create_team.html:11
#: participation/templates/participation/tournament_form.html:14
#: tfjm/templates/base.html:85
#: survey/templates/survey/survey_form.html:14 tfjm/templates/base.html:85
msgid "Create"
msgstr "Créer"
@ -2305,6 +2310,7 @@ msgid "Back to pool detail"
msgstr "Retour aux détails de la poule"
#: participation/templates/participation/team_detail.html:13
#: survey/templates/survey/survey_detail.html:16
msgid "Name:"
msgstr "Nom :"
@ -2465,7 +2471,7 @@ msgid "Invalidate"
msgstr "Invalider"
#: participation/templates/participation/team_detail.html:244
#: participation/views.py:341
#: participation/views.py:344
msgid "Upload motivation letter"
msgstr "Envoyer la lettre de motivation"
@ -2474,7 +2480,7 @@ msgid "Update team"
msgstr "Modifier l'équipe"
#: participation/templates/participation/team_detail.html:255
#: participation/views.py:503
#: participation/views.py:506
msgid "Leave team"
msgstr "Quitter l'équipe"
@ -2736,7 +2742,7 @@ msgstr "Vous êtes déjà dans une équipe."
msgid "Join team"
msgstr "Rejoindre une équipe"
#: participation/views.py:164 participation/views.py:541
#: participation/views.py:164 participation/views.py:544
msgid "You don't participate, so you don't have any team."
msgstr "Vous ne participez pas, vous n'avez donc pas d'équipe."
@ -2764,189 +2770,189 @@ msgstr ""
"d'adresse e-mail, soit une autorisation, soit des personnes, soit la lettre "
"de motivation, soit le tournoi n'a pas été choisi."
#: participation/views.py:235
#: participation/views.py:236
msgid "Team validation"
msgstr "Validation d'équipe"
#: participation/views.py:247
#: participation/views.py:248
msgid "You are not an organizer of the tournament."
msgstr "Vous n'êtes pas un⋅e organisateur⋅rice du tournoi."
#: participation/views.py:250
#: participation/views.py:251
msgid "This team has no pending validation."
msgstr "L'équipe n'a pas de validation en attente."
#: participation/views.py:270
#: participation/views.py:272
msgid "Team validated"
msgstr "Équipe validée"
#: participation/views.py:279
#: participation/views.py:282
msgid "Team not validated"
msgstr "Équipe non validée"
#: participation/views.py:282
#: participation/views.py:285
msgid "You must specify if you validate the registration or not."
msgstr "Vous devez spécifier si vous validez l'inscription ou non."
#: participation/views.py:318
#: participation/views.py:321
#, python-brace-format
msgid "Update team {trigram}"
msgstr "Mise à jour de l'équipe {trigram}"
#: participation/views.py:380 participation/views.py:488
#: participation/views.py:383 participation/views.py:491
#, python-brace-format
msgid "Motivation letter of {team}.{ext}"
msgstr "Lettre de motivation de {team}.{ext}"
#: participation/views.py:413
#: participation/views.py:416
#, python-brace-format
msgid "Authorizations of team {trigram}.zip"
msgstr "Autorisations de l'équipe {trigram}.zip"
#: participation/views.py:417
#: participation/views.py:420
#, python-brace-format
msgid "Authorizations of {tournament}.zip"
msgstr "Autorisations du tournoi {tournament}.zip"
#: participation/views.py:436
#: participation/views.py:439
#, python-brace-format
msgid "Photo authorization of {participant}.{ext}"
msgstr "Autorisation de droit à l'image de {participant}.{ext}"
#: participation/views.py:445
#: participation/views.py:448
#, python-brace-format
msgid "Parental authorization of {participant}.{ext}"
msgstr "Autorisation parentale de {participant}.{ext}"
#: participation/views.py:453
#: participation/views.py:456
#, python-brace-format
msgid "Health sheet of {participant}.{ext}"
msgstr "Fiche sanitaire de {participant}.{ext}"
#: participation/views.py:461
#: participation/views.py:464
#, python-brace-format
msgid "Vaccine sheet of {participant}.{ext}"
msgstr "Carnet de vaccination de {participant}.{ext}"
#: participation/views.py:472
#: participation/views.py:475
#, python-brace-format
msgid "Photo authorization of {participant} (final).{ext}"
msgstr "Autorisation de droit à l'image de {participant} (finale).{ext}"
#: participation/views.py:481
#: participation/views.py:484
#, python-brace-format
msgid "Parental authorization of {participant} (final).{ext}"
msgstr "Autorisation parentale de {participant} (finale).{ext}"
#: participation/views.py:555
#: participation/views.py:558
msgid "The team is not validated yet."
msgstr "L'équipe n'est pas encore validée."
#: participation/views.py:569
#: participation/views.py:572
#, python-brace-format
msgid "Participation of team {trigram}"
msgstr "Participation de l'équipe {trigram}"
#: participation/views.py:658
#: participation/views.py:661
#, python-brace-format
msgid "Payments of {tournament}"
msgstr "Paiements de {tournament}"
#: participation/views.py:758
#: participation/views.py:761
msgid "Notes published!"
msgstr "Notes publiées !"
#: participation/views.py:760
#: participation/views.py:763
msgid "Notes hidden!"
msgstr "Notes dissimulées !"
#: participation/views.py:791
#: participation/views.py:794
#, python-brace-format
msgid "Harmonize notes of {tournament} - Day {round}"
msgstr "Harmoniser les notes de {tournament} - Jour {round}"
#: participation/views.py:905
#: participation/views.py:908
msgid "You can't upload a solution after the deadline."
msgstr "Vous ne pouvez pas envoyer de solution après la date limite."
#: participation/views.py:1025
#: participation/views.py:1028
#, python-brace-format
msgid "Solutions of team {trigram}.zip"
msgstr "Solutions de l'équipe {trigram}.zip"
#: participation/views.py:1026
#: participation/views.py:1029
#, python-brace-format
msgid "Written reviews of team {trigram}.zip"
msgstr "Notes de synthèse de l'équipe {trigram}.zip"
#: participation/views.py:1043 participation/views.py:1059
#: participation/views.py:1046 participation/views.py:1062
#, python-brace-format
msgid "Solutions of {tournament}.zip"
msgstr "Solutions de {tournament}.zip"
#: participation/views.py:1044 participation/views.py:1060
#: participation/views.py:1047 participation/views.py:1063
#, python-brace-format
msgid "Written reviews of {tournament}.zip"
msgstr "Notes de synthèse de {tournament}.zip"
#: participation/views.py:1069
#: participation/views.py:1072
#, python-brace-format
msgid "Solutions for pool {pool} of tournament {tournament}.zip"
msgstr "Solutions pour la poule {pool} du tournoi {tournament}.zip"
#: participation/views.py:1070
#: participation/views.py:1073
#, python-brace-format
msgid "Written reviews for pool {pool} of tournament {tournament}.zip"
msgstr "Notes de synthèses pour la poule {pool} du tournoi {tournament}.zip"
#: participation/views.py:1112
#: participation/views.py:1115
#, python-brace-format
msgid "Jury of pool {pool} for {tournament} with teams {teams}"
msgstr "Jury de la poule {pool} pour {tournament} avec les équipes {teams}"
#: participation/views.py:1128
#: participation/views.py:1131
#, python-brace-format
msgid "The jury {name} is already in the pool!"
msgstr "{name} est déjà dans la poule !"
#: participation/views.py:1148
#: participation/views.py:1151
msgid "New jury account"
msgstr "Nouveau compte de juré⋅e"
#: participation/views.py:1169
#: participation/views.py:1175
#, python-brace-format
msgid "The jury {name} has been successfully added!"
msgstr "{name} a été ajouté⋅e avec succès en tant que juré⋅e !"
#: participation/views.py:1205
#: participation/views.py:1211
#, python-brace-format
msgid "The jury {name} has been successfully removed!"
msgstr "{name} a été retiré⋅e avec succès du jury !"
#: participation/views.py:1231
#: participation/views.py:1237
#, python-brace-format
msgid "The jury {name} has been successfully promoted president!"
msgstr "{name} a été nommé⋅e président⋅e du jury !"
#: participation/views.py:1259
#: participation/views.py:1265
msgid "The following user is not registered as a jury:"
msgstr "L'utilisateur⋅rice suivant n'est pas inscrit⋅e en tant que juré⋅e :"
#: participation/views.py:1275
#: participation/views.py:1281
msgid "Notes were successfully uploaded."
msgstr "Les notes ont bien été envoyées."
#: participation/views.py:1907
#: participation/views.py:1912
#, python-brace-format
msgid "Notation sheets of pool {pool} of {tournament}.zip"
msgstr "Feuilles de notations pour la poule {pool} du tournoi {tournament}.zip"
#: participation/views.py:1912
#: participation/views.py:1917
#, python-brace-format
msgid "Notation sheets of {tournament}.zip"
msgstr "Feuilles de notation de {tournament}.zip"
#: participation/views.py:2079
#: participation/views.py:2091
msgid "You can't upload a written review after the deadline."
msgstr "Vous ne pouvez pas envoyer de note de synthèse après la date limite."
@ -2981,7 +2987,7 @@ msgstr "Marquer comme invalide"
msgid "role"
msgstr "rôle"
#: registration/forms.py:25
#: registration/forms.py:25 survey/templates/survey/survey_detail.html:50
msgid "participant"
msgstr "participant⋅e"
@ -4200,22 +4206,22 @@ msgid "Impersonate"
msgstr "Impersonifier"
#: registration/templates/registration/user_detail.html:229
#: registration/views.py:333
#: registration/views.py:334
msgid "Upload photo authorization"
msgstr "Téléverser l'autorisation de droit à l'image"
#: registration/templates/registration/user_detail.html:236
#: registration/views.py:367
#: registration/views.py:368
msgid "Upload health sheet"
msgstr "Téléverser la fiche sanitaire"
#: registration/templates/registration/user_detail.html:243
#: registration/views.py:388
#: registration/views.py:389
msgid "Upload vaccine sheet"
msgstr "Téléverser le carnet de vaccination"
#: registration/templates/registration/user_detail.html:249
#: registration/views.py:408
#: registration/views.py:409
msgid "Upload parental authorization"
msgstr "Téléverser l'autorisation parentale"
@ -4242,36 +4248,36 @@ msgstr ""
"Les inscriptions pour cette année sont closes depuis le {closing_date:%d/%m/"
"%Y %H:%M}."
#: registration/views.py:140
#: registration/views.py:141
msgid "New organizer account"
msgstr "Nouveau compte organisateur⋅rice"
#: registration/views.py:166
#: registration/views.py:167
msgid "Email validation"
msgstr "Validation de l'adresse mail"
#: registration/views.py:168
#: registration/views.py:169
msgid "Validate email"
msgstr "Valider l'adresse mail"
#: registration/views.py:207
#: registration/views.py:208
msgid "Email validation unsuccessful"
msgstr "Échec de la validation de l'adresse mail"
#: registration/views.py:218
#: registration/views.py:219
msgid "Email validation email sent"
msgstr "Mail de confirmation de l'adresse mail envoyé"
#: registration/views.py:226
#: registration/views.py:227
msgid "Resend email validation link"
msgstr "Renvoyé le lien de validation de l'adresse mail"
#: registration/views.py:269
#: registration/views.py:270
#, python-brace-format
msgid "Detail of user {user}"
msgstr "Détails de l'utilisateur⋅rice {user}"
#: registration/views.py:294
#: registration/views.py:295
#, python-brace-format
msgid "Update user {user}"
msgstr "Mise à jour de l'utilisateur⋅rice {user}"
@ -4358,6 +4364,122 @@ msgstr "Autorisation parentale de {student}.{ext}"
msgid "Payment receipt of {registrations}.{ext}"
msgstr "Justificatif de paiement de {registrations}.{ext}"
#: survey/models.py:21 survey/tables.py:14
msgid "survey identifier"
msgstr "identifiant du sondage"
#: survey/models.py:22
msgid "The numeric identifier of the Limesurvey."
msgstr "L'identifiant numérique du Limesurvey."
#: survey/models.py:27
msgid "display name"
msgstr "nom affiché"
#: survey/models.py:32
msgid "invite whole team"
msgstr "inviter toute l'équipe"
#: survey/models.py:33
msgid ""
"When this field is checked, teams will get only one survey invitation "
"instead of one per person."
msgstr ""
"Lorsque ce champ est coché, les équipes n'auront qu'une seule invitation au "
"sondage au lieu de une par personne."
#: survey/models.py:38
msgid "invite coaches"
msgstr "inviter les encadrant⋅es"
#: survey/models.py:39
msgid ""
"When this field is checked, coaches will also be invited in the survey. No "
"effect when the whole team is invited."
msgstr ""
"Lorsque ce champ est coché, les encadrant⋅es seront aussi invité⋅es au "
"sondage. Aucun effet lorsque toute l'équipe est invité."
#: survey/models.py:48
msgid "tournament restriction"
msgstr "restriction de tournoi"
#: survey/models.py:49
msgid ""
"When this field is filled, the survey participants will be restricted to "
"this tournament members."
msgstr ""
"Lorsque ce champ est rempli, les participant⋅es au sondage seront "
"restreint⋅es aux membres de ce tournoi."
#: survey/models.py:55
msgid "participants that completed the survey"
msgstr "participant⋅es ayant complété le sondage"
#: survey/models.py:61
msgid "teams that completed the survey"
msgstr "équipes ayant complété le sondage"
#: survey/models.py:134 survey/templates/survey/survey_detail.html:10
msgid "survey"
msgstr "sondage"
#: survey/models.py:135 tfjm/templates/navbar.html:78
msgid "surveys"
msgstr "sondages"
#: survey/tables.py:18 survey/templates/survey/survey_detail.html:51
msgid "completed"
msgstr "complété"
#: survey/templates/survey/survey_detail.html:19
msgid "One answer per team:"
msgstr "Une réponse par équipe :"
#: survey/templates/survey/survey_detail.html:23
msgid "Coaches can answer the survey:"
msgstr "Les encadrant⋅es peuvent répondre au sondage :"
#: survey/templates/survey/survey_detail.html:28
msgid "Tournament restriction:"
msgstr "Restriction de tournoi :"
#: survey/templates/survey/survey_detail.html:32
msgid "Completion rate:"
msgstr "Taux de complétion :"
#: survey/templates/survey/survey_detail.html:41
msgid "Send invites"
msgstr "Envoyer les invitations"
#: survey/templates/survey/survey_detail.html:63
msgid "Yes"
msgstr "Oui"
#: survey/templates/survey/survey_detail.html:65
msgid "No"
msgstr "Non"
#: survey/templates/survey/survey_detail.html:72
msgid "Update survey"
msgstr "Modifier le sondage"
#: survey/templates/survey/survey_list.html:8
msgid "Add survey"
msgstr "Ajouter un sondage"
#: survey/views.py:38
msgid "Invites sent!"
msgstr "Invitations envoyées !"
#: survey/views.py:40
msgid "All invites were already sent."
msgstr "Toutes les invitations étaient déjà envoyés."
#: survey/views.py:50
msgid "Completion data refreshed!"
msgstr "Données de complétion actualisées !"
#: tfjm/permissions.py:9
msgid "Everyone, including anonymous users"
msgstr "Tout le monde, incluant les utilisateur⋅rices anonymes"
@ -4402,11 +4524,11 @@ msgstr "Privé, réservé aux utilisateur⋅rices explicitement autorisé⋅es"
msgid "Admin users"
msgstr "Administrateur⋅rices"
#: tfjm/settings.py:174
#: tfjm/settings.py:175
msgid "English"
msgstr "Anglais"
#: tfjm/settings.py:175
#: tfjm/settings.py:176
msgid "French"
msgstr "Français"
@ -4674,23 +4796,23 @@ msgstr "Mon équipe"
msgid "My participation"
msgstr "Ma participation"
#: tfjm/templates/navbar.html:78
#: tfjm/templates/navbar.html:81
msgid "Administration"
msgstr "Administration"
#: tfjm/templates/navbar.html:86
#: tfjm/templates/navbar.html:89
msgid "Search…"
msgstr "Chercher…"
#: tfjm/templates/navbar.html:95
#: tfjm/templates/navbar.html:98
msgid "Return to admin view"
msgstr "Retourner à l'interface administrateur⋅rice"
#: tfjm/templates/navbar.html:102
#: tfjm/templates/navbar.html:105
msgid "Register"
msgstr "S'inscrire"
#: tfjm/templates/navbar.html:119
#: tfjm/templates/navbar.html:122
msgid "My account"
msgstr "Mon compte"

View File

@ -211,7 +211,7 @@ class Team(models.Model):
"""
:return: The mailing list to contact the team members.
"""
return f"equipe-{slugify(self.trigram)}@{os.getenv('SYMPA_HOST', 'localhost')}"
return f"equipe-{slugify(self.trigram)}@{settings.SYMPA_HOST}"
def create_mailing_list(self):
"""
@ -392,21 +392,21 @@ class Tournament(models.Model):
"""
:return: The mailing list to contact the team members.
"""
return f"equipes-{slugify(self.name)}@{os.getenv('SYMPA_HOST', 'localhost')}"
return f"equipes-{slugify(self.name)}@{settings.SYMPA_HOST}"
@property
def organizers_email(self):
"""
:return: The mailing list to contact the team members.
"""
return f"organisateurs-{slugify(self.name)}@{os.getenv('SYMPA_HOST', 'localhost')}"
return f"organisateurs-{slugify(self.name)}@{settings.SYMPA_HOST}"
@property
def jurys_email(self):
"""
:return: The mailing list to contact the team members.
"""
return f"jurys-{slugify(self.name)}@{os.getenv('SYMPA_HOST', 'localhost')}"
return f"jurys-{slugify(self.name)}@{settings.SYMPA_HOST}"
def create_mailing_lists(self):
"""

View File

@ -1,5 +1,6 @@
channels[daphne]~=4.1.0
channels-redis~=4.2.0
citric~=1.4.0
crispy-bootstrap5~=2024.10
Django>=5.1.2,<6.0
django-crispy-forms~=2.3

0
survey/__init__.py Normal file
View File

13
survey/admin.py Normal file
View File

@ -0,0 +1,13 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
from .models import Survey
@admin.register(Survey)
class SurveyAdmin(admin.ModelAdmin):
list_display = ('survey_id', 'name', 'invite_team', 'invite_coaches', 'tournament',)
list_filter = ('invite_team', 'invite_coaches', 'tournament',)
search_fields = ('name',)

6
survey/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class SurveyConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "survey"

28
survey/forms.py Normal file
View File

@ -0,0 +1,28 @@
from django import forms
from .models import Survey
class SurveyForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'survey_id' in self.initial:
self.fields['survey_id'].disabled = True
class Meta:
model = Survey
exclude = ('completed_registrations', 'completed_teams',)
widgets = {
'completed_registrations': forms.SelectMultiple(attrs={
'class': 'selectpicker',
'data-live-search': 'true',
'data-live-search-normalize': 'true',
'data-width': 'fit',
}),
'completed_teams': forms.SelectMultiple(attrs={
'class': 'selectpicker',
'data-live-search': 'true',
'data-live-search-normalize': 'true',
'data-width': 'fit',
}),
}

View File

@ -0,0 +1,13 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.core.management import BaseCommand
from ...models import Survey
class Command(BaseCommand):
def handle(self, *args, **kwargs):
for survey in Survey.objects.all():
survey.fetch_completion_data()

View File

@ -0,0 +1,83 @@
# Generated by Django 5.1.5 on 2025-03-19 21:12
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
(
"participation",
"0023_tournament_unified_registration",
),
("registration", "0014_participantregistration_country"),
]
operations = [
migrations.CreateModel(
name="Survey",
fields=[
(
"survey_id",
models.IntegerField(
help_text="The numeric identifier of the Limesurvey.",
primary_key=True,
serialize=False,
verbose_name="survey identifier",
),
),
("name", models.CharField(max_length=255, verbose_name="display name")),
(
"invite_team",
models.BooleanField(
default=False,
help_text="When this field is checked, teams will get only one survey invitation instead of one per person.",
verbose_name="invite whole team",
),
),
(
"invite_coaches",
models.BooleanField(
default=True,
help_text="When this field is checked, coaches will also be invited in the survey. No effect when the whole team is invited.",
verbose_name="invite coaches",
),
),
(
"completed_registrations",
models.ManyToManyField(
related_name="completed_surveys",
to="registration.participantregistration",
verbose_name="participants that completed the survey",
),
),
(
"completed_teams",
models.ManyToManyField(
related_name="completed_surveys",
to="participation.team",
verbose_name="teams that completed the survey",
),
),
(
"tournament",
models.ForeignKey(
blank=True,
default=None,
help_text="When this field is filled, the survey participants will be restricted to this tournament members.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="participation.tournament",
verbose_name="tournament restriction",
),
),
],
options={
"verbose_name": "survey",
"verbose_name_plural": "surveys",
},
),
]

View File

134
survey/models.py Normal file
View File

@ -0,0 +1,134 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from citric import Client
from django.conf import settings
from django.db import models
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from participation.models import Team, Tournament
from registration.models import ParticipantRegistration, StudentRegistration
class Survey(models.Model):
"""
Ce modèle représente un sondage LimeSurvey afin de faciliter l'import des
participantes au sondage et d'effectuer le suivi.
"""
survey_id = models.IntegerField(
primary_key=True,
verbose_name=_("survey identifier"),
help_text=_("The numeric identifier of the Limesurvey."),
)
name = models.CharField(
max_length=255,
verbose_name=_("display name"),
)
invite_team = models.BooleanField(
default=False,
verbose_name=_("invite whole team"),
help_text=_("When this field is checked, teams will get only one survey invitation instead of one per person."),
)
invite_coaches = models.BooleanField(
default=True,
verbose_name=_("invite coaches"),
help_text=_("When this field is checked, coaches will also be invited in the survey. No effect when the whole team is invited."),
)
tournament = models.ForeignKey(
Tournament,
null=True,
blank=True,
default=None,
on_delete=models.SET_NULL,
verbose_name=_("tournament restriction"),
help_text=_("When this field is filled, the survey participants will be restricted to this tournament members."),
)
completed_registrations = models.ManyToManyField(
ParticipantRegistration,
related_name="completed_surveys",
verbose_name=_("participants that completed the survey"),
)
completed_teams = models.ManyToManyField(
Team,
related_name="completed_surveys",
verbose_name=_("teams that completed the survey"),
)
@property
def participants(self):
if self.invite_team:
teams = Team.objects.filter(participation__valid=True)
if self.tournament:
teams = teams.filter(participation__tournament=self.tournament)
return teams.all()
else:
if self.invite_coaches:
registrations = ParticipantRegistration.objects.filter(team__participation__valid=True)
else:
registrations = StudentRegistration.objects.filter(team__participation__valid=True)
if self.tournament:
registrations = registrations.filter(team__participation__tournament=self.tournament)
return registrations.all()
@property
def completed(self):
if self.invite_team:
return self.completed_teams
else:
return self.completed_registrations
def get_absolute_url(self):
return reverse_lazy("survey:survey_detail", args=(self.survey_id,))
def generate_participants_data(self):
participants_data = []
if self.invite_team:
for team in self.participants:
participant_data = {"firstname": team.name, "lastname": f"(équipe {team.trigram})", "email": team.email}
participants_data.append(participant_data)
else:
for reg in self.participants:
participant_data = {"firstname": reg.user.first_name, "lastname": reg.user.last_name, "email": reg.user.email}
participants_data.append(participant_data)
return participants_data
def invite_all(self):
participants_data = self.generate_participants_data()
with Client(f"{settings.LIMESURVEY_URL}/index.php/admin/remotecontrol", settings.LIMESURVEY_USER, settings.LIMESURVEY_PASSWORD) as client:
try:
current_participants = client.list_participants(self.survey_id, limit=10000)
except:
current_participants = []
current_participants_email = set(participant['participant_info']['email'] for participant in current_participants)
participants_data = [participant_data for participant_data in participants_data if participant_data['email'] not in current_participants_email]
try:
client.activate_tokens(self.survey_id)
except:
pass
new_participants = client.add_participants(self.survey_id, participant_data=participants_data)
if new_participants:
client.invite_participants(self.survey_id, token_ids=[participant['tid'] for participant in new_participants])
return new_participants
def fetch_completion_data(self):
with Client(f"{settings.LIMESURVEY_URL}/index.php/admin/remotecontrol", settings.LIMESURVEY_USER, settings.LIMESURVEY_PASSWORD) as client:
participants = client.list_participants(self.survey_id, limit=10000, attributes=['completed'])
if self.invite_team:
team_names = [participant['participant_info']['firstname'] for participant in participants if participant['completed'] != 'N']
self.completed_teams.set(list(Team.objects.filter(name__in=team_names).values_list('id', flat=True)))
else:
mails = [participant['participant_info']['email'] for participant in participants if participant['completed'] != 'N']
self.completed_registrations.set(list(ParticipantRegistration.objects.filter(user__email__in=mails).values_list('id', flat=True)))
self.save()
class Meta:
verbose_name = _("survey")
verbose_name_plural = _("surveys")

31
survey/tables.py Normal file
View File

@ -0,0 +1,31 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
from .models import Survey
class SurveyTable(tables.Table):
survey_id = tables.LinkColumn(
'survey:survey_detail',
args=[tables.A('survey_id')],
verbose_name=lambda: _("survey identifier").capitalize(),
)
nb_completed = tables.Column(
verbose_name=_("completed").capitalize,
accessor='survey_id'
)
def render_nb_completed(self, record):
return f"{record.completed.count()}/{record.participants.count()}"
class Meta:
attrs = {
'class': 'table table-condensed table-striped',
}
model = Survey
fields = ('survey_id', 'name', 'invite_team', 'invite_coaches', 'tournament', 'nb_completed',)
order_by = ('survey_id',)

View File

@ -0,0 +1,84 @@
{% extends "base.html" %}
{% load i18n %}
{% load crispy_forms_filters %}
{% block content %}
<div class="card bg-body shadow">
<div class="card-header text-center">
<h4>
{% trans "survey"|capfirst %} {{ survey.survey_id }}
<a href="{{ TFJM.LIMESURVEY_URL }}/index.php/{{ survey.survey_id }}" target="_blank"><i class="fas fa-arrow-up-right-from-square"></i></a>
</h4>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-6 text-sm-end">{% trans "Name:" %}</dt>
<dd class="col-sm-6">{{ survey.name }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans "One answer per team:" %}</dt>
<dd class="col-sm-6">{{ survey.invite_team|yesno }}</dd>
{% if not survey.invite_team %}
<dt class="col-sm-6 text-sm-end">{% trans "Coaches can answer the survey:" %}</dt>
<dd class="col-sm-6">{{ survey.invite_coaches|yesno }}</dd>
{% endif %}
{% if survey.tournament %}
<dt class="col-sm-6 text-sm-end">{% trans "Tournament restriction:" %}</dt>
<dd class="col-sm-6">{{ survey.tournament }}</dd>
{% endif %}
<dt class="col-sm-6 text-sm-end">{% trans "Completion rate:" %}</dt>
<dd class="col-sm-6">
{{ survey.completed.count }}/{{ survey.participants.count }}
<a href="{% url "survey:survey_refresh_completed" pk=survey.pk %}"><i class="fas fa-arrow-rotate-right" alt="refresh"></i></a>
</dd>
</dl>
</div>
<div class="card-footer text-center">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updateSurveyModal">{% trans "Update" %}</button>
<a class="btn btn-secondary" href="{% url "survey:survey_invite" pk=survey.pk %}">{% trans "Send invites" %}</a>
</div>
</div>
<hr>
<table class="table table-condensed table-striped">
<thead>
<tr>
<th>{% trans "participant"|capfirst %}</th>
<th>{% trans "completed"|capfirst %}</th>
</tr>
</thead>
<tbody>
{% for participant in survey.participants %}
<tr class="{% if participant in survey.completed.all %}table-success{% else %}table-danger{% endif %}">
{% if survey.invite_team %}
<td>{% trans "Team" %} {{ participant.name }} ({{ participant.trigram }})</td>
{% else %}
<td>{{ participant.user.first_name }} {{ participant.user.last_name }} ({% trans "team" %} {{ participant.team.trigram }})</td>
{% endif %}
{% if participant in survey.completed.all %}
<td>{% trans "Yes" %}</td>
{% else %}
<td>{% trans "No" %}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% trans "Update survey" as modal_title %}
{% trans "Update" as modal_button %}
{% url "survey:survey_update" pk=survey.pk as modal_action %}
{% include "base_modal.html" with modal_id="updateSurvey" %}
{% endblock %}
{% block extrajavascript %}
<script>
document.addEventListener('DOMContentLoaded', () => {
initModal("updateSurvey", "{% url "survey:survey_update" pk=survey.pk %}")
})
</script>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends request.content_only|yesno:"empty.html,base.html" %}
{% load crispy_forms_filters i18n %}
{% block content %}
<form method="post">
<div id="form-content">
{% csrf_token %}
{{ form|crispy }}
</div>
{% if object.pk %}
<button class="btn btn-primary" type="submit">{% trans "Update" %}</button>
{% else %}
<button class="btn btn-success" type="submit">{% trans "Create" %}</button>
{% endif %}
</form>
{% endblock content %}

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% load django_tables2 i18n %}
{% block content %}
<div class="d-grid">
<a href="{% url "survey:survey_create" %}" class="btn gap-0 btn-success">
<i class="fas fa-square-poll-horizontal"></i> {% trans "Add survey" %}
</a>
</div>
<hr>
{% render_table table %}
{% endblock %}

3
survey/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

18
survey/urls.py Normal file
View File

@ -0,0 +1,18 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path
from .views import SurveyCreateView, SurveyDetailView, SurveyInviteView, \
SurveyListView, SurveyRefreshCompletedView, SurveyUpdateView
app_name = "survey"
urlpatterns = [
path("", SurveyListView.as_view(), name="survey_list"),
path("create/", SurveyCreateView.as_view(), name="survey_create"),
path("<int:pk>/", SurveyDetailView.as_view(), name="survey_detail"),
path("<int:pk>/invite/", SurveyInviteView.as_view(), name="survey_invite"),
path("<int:pk>/refresh/", SurveyRefreshCompletedView.as_view(), name="survey_refresh_completed"),
path("<int:pk>/update/", SurveyUpdateView.as_view(), name="survey_update"),
]

56
survey/views.py Normal file
View File

@ -0,0 +1,56 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import messages
from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, UpdateView
from django_tables2 import SingleTableView
from tfjm.views import AdminMixin
from .forms import SurveyForm
from .models import Survey
from .tables import SurveyTable
class SurveyListView(AdminMixin, SingleTableView):
model = Survey
table_class = SurveyTable
template_name = "survey/survey_list.html"
class SurveyCreateView(AdminMixin, CreateView):
model = Survey
form_class = SurveyForm
class SurveyDetailView(AdminMixin, DetailView):
model = Survey
class SurveyInviteView(AdminMixin, DetailView):
model = Survey
def get(self, request, *args, **kwargs):
survey = self.get_object()
new_participants = survey.invite_all()
if new_participants:
messages.success(request, _("Invites sent!"))
else:
messages.warning(request, _("All invites were already sent."))
return redirect("survey:survey_detail", survey.pk)
class SurveyRefreshCompletedView(AdminMixin, DetailView):
model = Survey
def get(self, request, *args, **kwargs):
survey = self.get_object()
survey.fetch_completion_data()
messages.success(request, _("Completion data refreshed!"))
return redirect("survey:survey_detail", survey.pk)
class SurveyUpdateView(AdminMixin, UpdateView):
model = Survey
form_class = SurveyForm

View File

@ -19,5 +19,8 @@
# Update Google Drive notifications daily
0 0 * * * cd /code && python manage.py renew_gdrive_notifications -v 0
# Fetch LimeSurvey completion data
*/15 * * 03-06 * cd /code && python manage.py fetch_survey_completion_data -v 0
# Clean temporary files
30 * * * * rm -rf /tmp/*

View File

@ -13,6 +13,7 @@ def tfjm_context(request):
'HAS_OBSERVER': settings.HAS_OBSERVER,
'HAS_FINAL': settings.HAS_FINAL,
'HOME_PAGE_LINK': settings.HOME_PAGE_LINK,
'LIMESURVEY_URL': settings.LIMESURVEY_URL,
'LOGO_PATH': "tfjm/img/" + settings.LOGO_FILE,
'NB_ROUNDS': settings.NB_ROUNDS,
'ML_MANAGEMENT': settings.ML_MANAGEMENT,

View File

@ -3,16 +3,18 @@
import os
from django.conf import settings
_client = None
def get_sympa_client():
global _client
if _client is None:
if os.getenv("SYMPA_PASSWORD", None): # pragma: no cover
if settings.SYMPA_PASSWORD is not None: # pragma: no cover
from sympasoap import Client
_client = Client("https://" + os.getenv("SYMPA_URL"))
_client.login(os.getenv("SYMPA_EMAIL"), os.getenv("SYMPA_PASSWORD"))
_client = Client("https://" + settings.SYMPA_URL)
_client.login(settings.SYMPA_EMAIL, settings.SYMPA_PASSWORD)
else:
_client = FakeSympaSoapClient()
return _client

View File

@ -74,6 +74,7 @@ INSTALLED_APPS = [
'draw',
'registration',
'participation',
'survey',
]
if "test" not in sys.argv: # pragma: no cover
@ -300,6 +301,12 @@ CHANNEL_LAYERS = {
PHONENUMBER_DB_FORMAT = 'NATIONAL'
PHONENUMBER_DEFAULT_REGION = 'FR'
# Sympa configuration
SYMPA_HOST = os.getenv("SYMPA_HOST", "localhost")
SYMPA_URL = os.getenv("SYMPA_URL", "localhost")
SYMPA_EMAIL = os.getenv("SYMPA_EMAIL", "contact@localhost")
SYMPA_PASSWORD = os.getenv("SYMPA_PASSWORD", None)
# Hello Asso API creds
HELLOASSO_CLIENT_ID = os.getenv('HELLOASSO_CLIENT_ID', 'CHANGE_ME_IN_ENV_SETTINGS')
HELLOASSO_CLIENT_SECRET = os.getenv('HELLOASSO_CLIENT_SECRET', 'CHANGE_ME_IN_ENV_SETTINGS')
@ -322,6 +329,10 @@ GOOGLE_SERVICE_CLIENT = {
# The ID of the Google Drive folder where to store the notation sheets
NOTES_DRIVE_FOLDER_ID = os.getenv("NOTES_DRIVE_FOLDER_ID", "CHANGE_ME_IN_ENV_SETTINGS")
LIMESURVEY_URL = os.getenv("LIMESURVEY_URL", "https://survey.example.com")
LIMESURVEY_USER = os.getenv("LIMESURVEY_USER", "CHANGE_ME_IN_ENV_SETTINGS")
LIMESURVEY_PASSWORD = os.getenv("LIMESURVEY_PASSWORD", "CHANGE_ME_IN_ENV_SETTINGS")
# Custom parameters
FORBIDDEN_TRIGRAMS = [
"BIT",

View File

@ -74,6 +74,9 @@
</li>
{% endif %}
{% if user.registration.is_admin %}
<li class="nav-item active">
<a class="nav-link" href="{% url "survey:survey_list" %}"><i class="fas fa-square-poll-horizontal"></i> {% trans "surveys"|capfirst %}</a>
</li>
<li class="nav-item active">
<a class="nav-link" href="{% url "admin:index" %}"><i class="fas fa-cog"></i> {% trans "Administration" %}</a>
</li>

View File

@ -44,6 +44,7 @@ urlpatterns = [
path('draw/', include('draw.urls')),
path('participation/', include('participation.urls')),
path('registration/', include('registration.urls')),
path('survey/', include('survey.urls')),
path('media/authorization/photo/<str:filename>/', PhotoAuthorizationView.as_view(),
name='photo_authorization'),