From 702c8d8c9edac221da5f5c5e02ca28780b9db6e9 Mon Sep 17 00:00:00 2001 From: Emmy D'Anello Date: Wed, 19 Mar 2025 23:18:45 +0100 Subject: [PATCH] Add survey feature --- locale/fr/LC_MESSAGES/django.po | 308 ++++++++++++------ participation/models.py | 8 +- requirements.txt | 1 + survey/__init__.py | 0 survey/admin.py | 13 + survey/apps.py | 6 + survey/forms.py | 28 ++ .../commands/fetch_survey_completion_data.py | 13 + survey/migrations/0001_initial.py | 83 +++++ survey/migrations/__init__.py | 0 survey/models.py | 134 ++++++++ survey/tables.py | 31 ++ survey/templates/survey/survey_detail.html | 84 +++++ survey/templates/survey/survey_form.html | 17 + survey/templates/survey/survey_list.html | 14 + survey/tests.py | 3 + survey/urls.py | 18 + survey/views.py | 56 ++++ tfjm.cron | 3 + tfjm/context_processors.py | 1 + tfjm/lists.py | 8 +- tfjm/settings.py | 11 + tfjm/templates/navbar.html | 3 + tfjm/urls.py | 1 + 24 files changed, 744 insertions(+), 100 deletions(-) create mode 100644 survey/__init__.py create mode 100644 survey/admin.py create mode 100644 survey/apps.py create mode 100644 survey/forms.py create mode 100644 survey/management/commands/fetch_survey_completion_data.py create mode 100644 survey/migrations/0001_initial.py create mode 100644 survey/migrations/__init__.py create mode 100644 survey/models.py create mode 100644 survey/tables.py create mode 100644 survey/templates/survey/survey_detail.html create mode 100644 survey/templates/survey/survey_form.html create mode 100644 survey/templates/survey/survey_list.html create mode 100644 survey/tests.py create mode 100644 survey/urls.py create mode 100644 survey/views.py diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 17ced43..94750a7 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -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 \n" "Language-Team: LANGUAGE \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" diff --git a/participation/models.py b/participation/models.py index 8d32d12..fddb2fe 100644 --- a/participation/models.py +++ b/participation/models.py @@ -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): """ diff --git a/requirements.txt b/requirements.txt index 22bf2fc..1b81436 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/survey/__init__.py b/survey/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/survey/admin.py b/survey/admin.py new file mode 100644 index 0000000..02fe66d --- /dev/null +++ b/survey/admin.py @@ -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',) diff --git a/survey/apps.py b/survey/apps.py new file mode 100644 index 0000000..273de2b --- /dev/null +++ b/survey/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SurveyConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "survey" diff --git a/survey/forms.py b/survey/forms.py new file mode 100644 index 0000000..5c6f2e9 --- /dev/null +++ b/survey/forms.py @@ -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', + }), + } diff --git a/survey/management/commands/fetch_survey_completion_data.py b/survey/management/commands/fetch_survey_completion_data.py new file mode 100644 index 0000000..d8c7d61 --- /dev/null +++ b/survey/management/commands/fetch_survey_completion_data.py @@ -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() diff --git a/survey/migrations/0001_initial.py b/survey/migrations/0001_initial.py new file mode 100644 index 0000000..3a1f423 --- /dev/null +++ b/survey/migrations/0001_initial.py @@ -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", + }, + ), + ] diff --git a/survey/migrations/__init__.py b/survey/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/survey/models.py b/survey/models.py new file mode 100644 index 0000000..e08a85b --- /dev/null +++ b/survey/models.py @@ -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 + participant⋅es 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") diff --git a/survey/tables.py b/survey/tables.py new file mode 100644 index 0000000..3327ac8 --- /dev/null +++ b/survey/tables.py @@ -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',) diff --git a/survey/templates/survey/survey_detail.html b/survey/templates/survey/survey_detail.html new file mode 100644 index 0000000..ca3be9b --- /dev/null +++ b/survey/templates/survey/survey_detail.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} + +{% load i18n %} +{% load crispy_forms_filters %} + +{% block content %} +
+
+

+ {% trans "survey"|capfirst %} {{ survey.survey_id }} + +

+
+
+
+
{% trans "Name:" %}
+
{{ survey.name }}
+ +
{% trans "One answer per team:" %}
+
{{ survey.invite_team|yesno }}
+ + {% if not survey.invite_team %} +
{% trans "Coaches can answer the survey:" %}
+
{{ survey.invite_coaches|yesno }}
+ {% endif %} + + {% if survey.tournament %} +
{% trans "Tournament restriction:" %}
+
{{ survey.tournament }}
+ {% endif %} + +
{% trans "Completion rate:" %}
+
+ {{ survey.completed.count }}/{{ survey.participants.count }} + +
+
+
+ +
+ +
+ + + + + + + + + + {% for participant in survey.participants %} + + {% if survey.invite_team %} + + {% else %} + + {% endif %} + {% if participant in survey.completed.all %} + + {% else %} + + {% endif %} + + {% endfor %} + +
{% trans "participant"|capfirst %}{% trans "completed"|capfirst %}
{% trans "Team" %} {{ participant.name }} ({{ participant.trigram }}){{ participant.user.first_name }} {{ participant.user.last_name }} ({% trans "team" %} {{ participant.team.trigram }}){% trans "Yes" %}{% trans "No" %}
+ + {% 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 %} + +{% endblock %} diff --git a/survey/templates/survey/survey_form.html b/survey/templates/survey/survey_form.html new file mode 100644 index 0000000..cde426d --- /dev/null +++ b/survey/templates/survey/survey_form.html @@ -0,0 +1,17 @@ +{% extends request.content_only|yesno:"empty.html,base.html" %} + +{% load crispy_forms_filters i18n %} + +{% block content %} +
+
+ {% csrf_token %} + {{ form|crispy }} +
+ {% if object.pk %} + + {% else %} + + {% endif %} +
+{% endblock content %} diff --git a/survey/templates/survey/survey_list.html b/survey/templates/survey/survey_list.html new file mode 100644 index 0000000..22da21f --- /dev/null +++ b/survey/templates/survey/survey_list.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% load django_tables2 i18n %} + +{% block content %} + +
+ + {% render_table table %} +{% endblock %} diff --git a/survey/tests.py b/survey/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/survey/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/survey/urls.py b/survey/urls.py new file mode 100644 index 0000000..99d1f45 --- /dev/null +++ b/survey/urls.py @@ -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("/", SurveyDetailView.as_view(), name="survey_detail"), + path("/invite/", SurveyInviteView.as_view(), name="survey_invite"), + path("/refresh/", SurveyRefreshCompletedView.as_view(), name="survey_refresh_completed"), + path("/update/", SurveyUpdateView.as_view(), name="survey_update"), +] diff --git a/survey/views.py b/survey/views.py new file mode 100644 index 0000000..a8d3fe0 --- /dev/null +++ b/survey/views.py @@ -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 diff --git a/tfjm.cron b/tfjm.cron index 62e24a9..148d539 100644 --- a/tfjm.cron +++ b/tfjm.cron @@ -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/* diff --git a/tfjm/context_processors.py b/tfjm/context_processors.py index 24f84eb..711d269 100644 --- a/tfjm/context_processors.py +++ b/tfjm/context_processors.py @@ -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, diff --git a/tfjm/lists.py b/tfjm/lists.py index 7fcfc72..76ea8af 100644 --- a/tfjm/lists.py +++ b/tfjm/lists.py @@ -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 diff --git a/tfjm/settings.py b/tfjm/settings.py index 624ffad..e06e707 100644 --- a/tfjm/settings.py +++ b/tfjm/settings.py @@ -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", diff --git a/tfjm/templates/navbar.html b/tfjm/templates/navbar.html index 25dc01c..8d3ee01 100644 --- a/tfjm/templates/navbar.html +++ b/tfjm/templates/navbar.html @@ -74,6 +74,9 @@ {% endif %} {% if user.registration.is_admin %} + diff --git a/tfjm/urls.py b/tfjm/urls.py index 3fbfc81..2685e22 100644 --- a/tfjm/urls.py +++ b/tfjm/urls.py @@ -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//', PhotoAuthorizationView.as_view(), name='photo_authorization'),