mirror of
				https://gitlab.com/animath/si/plateforme.git
				synced 2025-10-31 13:00:01 +01:00 
			
		
		
		
	Compare commits
	
		
			36 Commits
		
	
	
		
			ca0601fb24
			...
			dev
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 8af11cd56f | ||
|  | 5c372f7582 | ||
|  | bd230ccaf6 | ||
|  | 46779488c1 | ||
|  | f49897cd5b | ||
|  | 399e223b33 | ||
|  | 004d54cb67 | ||
|  | 8aec72d712 | ||
|  | 6a521b6121 | ||
|  | 62abfa94d6 | ||
|  | 952315ea4d | ||
|  | 2e613799c9 | ||
|  | 08805a6360 | ||
|  | 6841659e41 | ||
|  | a84ffcf0a3 | ||
|  | 203fc3cd54 | ||
|  | 60f5236dee | ||
|  | ab459ecc17 | ||
|  | 7ad7659d78 | ||
|  | 84eb08ec46 | ||
|  | 3750828883 | ||
|  | ba36ad4071 | ||
|  | 626433c464 | ||
|  | 032b67ac51 | ||
|  | f3bd479fdc | ||
|  | bc06cf4903 | ||
|  | 6d43c4b97e | ||
|  | 0499885fc8 | ||
|  | 63c96ff2d2 | ||
|  | efeb2628ad | ||
|  | 56aad288f4 | ||
|  | b33a69410a | ||
|  | 0a80e03b58 | ||
|  | 73b94d5578 | ||
|  | 97eea3b11a | ||
|  | 702c8d8c9e | 
| @@ -1,6 +1,12 @@ | ||||
| stages: | ||||
|   - test | ||||
|   - quality-assurance | ||||
|   - build | ||||
|   - release | ||||
|  | ||||
| variables: | ||||
|   CONTAINER_TEST_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG | ||||
|   CONTAINER_RELEASE_IMAGE: $CI_REGISTRY_IMAGE:latest | ||||
|  | ||||
| py312: | ||||
|   stage: test | ||||
| @@ -20,10 +26,44 @@ py313: | ||||
|     - pip install tox --no-cache-dir | ||||
|   script: tox -e py313 | ||||
|  | ||||
| py314: | ||||
|   stage: test | ||||
|   image: python:3.14-alpine | ||||
|   before_script: | ||||
|     - apk add --no-cache libmagic | ||||
|     - apk add --no-cache gettext | ||||
|     - pip install tox --no-cache-dir | ||||
|   script: tox -e py314 | ||||
|  | ||||
| linters: | ||||
|   stage: quality-assurance | ||||
|   image: python:3-alpine | ||||
|   image: python:3.13-alpine | ||||
|   before_script: | ||||
|     - pip install tox --no-cache-dir | ||||
|   script: tox -e linters | ||||
|   allow_failure: true | ||||
|  | ||||
| build-image: | ||||
|   image: docker | ||||
|   stage: build | ||||
|   services: | ||||
|     - docker:dind | ||||
|   before_script: | ||||
|     - echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin | ||||
|   script: | ||||
|     - docker build --pull -t $CONTAINER_TEST_IMAGE . | ||||
|     - docker push $CONTAINER_TEST_IMAGE | ||||
|  | ||||
| release-image: | ||||
|   image: docker | ||||
|   stage: release | ||||
|   services: | ||||
|     - docker:dind | ||||
|   before_script: | ||||
|     - echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin | ||||
|   script: | ||||
|     - docker pull $CONTAINER_TEST_IMAGE | ||||
|     - docker tag $CONTAINER_TEST_IMAGE $CONTAINER_RELEASE_IMAGE | ||||
|     - docker push $CONTAINER_RELEASE_IMAGE | ||||
|   rules: | ||||
|     - if: $CI_COMMIT_BRANCH == "main" | ||||
|   | ||||
| @@ -4,12 +4,10 @@ ENV PYTHONUNBUFFERED 1 | ||||
| ENV DJANGO_ALLOW_ASYNC_UNSAFE 1 | ||||
|  | ||||
| RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libpq-dev libxml2-dev libxslt-dev \ | ||||
|     npm libmagic texlive texmf-dist-fontsrecommended texmf-dist-lang texmf-dist-latexextra | ||||
|     libmagic texlive texmf-dist-fontsrecommended texmf-dist-lang texmf-dist-latexextra uglify-js | ||||
|  | ||||
| RUN apk add --no-cache bash | ||||
|  | ||||
| RUN npm install -g yuglify | ||||
|  | ||||
| RUN mkdir /code /code/docs | ||||
| WORKDIR /code | ||||
| COPY requirements.txt /code/requirements.txt | ||||
| @@ -37,4 +35,4 @@ RUN ln -s /code/.bashrc /root/.bashrc | ||||
| ENTRYPOINT ["/code/entrypoint.sh"] | ||||
| EXPOSE 80 | ||||
|  | ||||
| CMD ["./manage.py", "shell_plus", "--ipython"] | ||||
| CMD ["./manage.py", "shell"] | ||||
|   | ||||
| @@ -18,7 +18,7 @@ | ||||
| # -- Project information ----------------------------------------------------- | ||||
|  | ||||
| project = 'Plateforme du TFJM²' | ||||
| copyright = "2020-2024" | ||||
| copyright = "2020-2026" | ||||
| author = "Animath" | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -9,7 +9,7 @@ Présentation | ||||
| La plateforme d'inscription du TFJM² actuelle est née lors de l'édition 2020. Elle n'est | ||||
| pas la première à exister, elle succède à une précédente, moins fonctionnelle, dont les | ||||
| sources ont été perdues. Elle a été développée par Emmy D'Anello, bénévole pour Animath, | ||||
| qui la maintient au moins jusqu'en 2024. | ||||
| qui la maintient au moins jusqu'en 2026. | ||||
|  | ||||
| La plateforme est développée en Python, utilisant le framework web | ||||
| `Django <https://www.djangoproject.com/>`_. Elle est diponible librement sous licence GPLv3 | ||||
|   | ||||
| @@ -145,10 +145,38 @@ Paramètres des tournois | ||||
|  | ||||
| Il faut enfin paramétrer les différentes dates des tournois. | ||||
|  | ||||
| Pour cela, connectez-vous sur la plateforme (avec un compte administrateur⋅rice), et dans l'onglet | ||||
| Pour cela, connectez-vous sur la plateforme (avec un compte administrateurice), et dans l'onglet | ||||
| « Tournois », vous pouvez créer les différents tournois avec les différentes dates pour chaque tournoi. | ||||
| Plus d'information sur les différents paramètres dans la `section concernée | ||||
| <../orga.html#creer-un-tournoi>`_ | ||||
| <../orga.html#creer-un-tournoi>`_. | ||||
|  | ||||
|  | ||||
| Dossier Google Drive des feuilles de notes | ||||
| """""""""""""""""""""""""""""""""""""""""" | ||||
|  | ||||
| Les tableurs Google Sheets de notes sont créés automatiquement vers le Google Drive du TFJM². | ||||
| Pour que les tableurs se créent au bon endroit, il faut modifier l'identifiant du dossier où se créent | ||||
| ces tableurs. Il faut donc se rendre dans les variables d'environnement de la plateforme, et | ||||
| modifier la variable ``NOTES_DRIVE_FOLDER_ID`` pour mettre à jour l'identifiant du dossier. | ||||
| Pour le trouver, il suffit simplement de se rendre sur Google Drive et de récupérer l'identifiant | ||||
| présent à la fin de l'URL, après ``https://drive.google.com/drive/u/X/folders/``. | ||||
|  | ||||
| Ne pas oublier de partager le dossier en écriture à l'adresse | ||||
| ``plateforme-tfjm@plateforme-tfjm.iam.gserviceaccount.com``. | ||||
|  | ||||
|  | ||||
| Anciennes listes de diffusion | ||||
| """"""""""""""""""""""""""""" | ||||
|  | ||||
| Les listes Sympa doivent être fermées pour être correctement recréées. Un script permet | ||||
| de supprimer toutes les listes commençant par ``equipe``, ``orga`` ou ``jury`` : | ||||
|  | ||||
| .. code:: bash | ||||
|  | ||||
|    ./manage.py delete_old_sympa_lists | ||||
|  | ||||
| Attention : les listes closes ne sont pas supprimées. Rendez-vous sur la page | ||||
| `https://lists.tfjm.org/sympa/get_closed_lists`_ pour supprimer les listes ainsi fermées. | ||||
|  | ||||
|  | ||||
| À la fin du tournoi | ||||
|   | ||||
| @@ -178,7 +178,7 @@ Seuls les refus distincts comptent : refuser une deuxième fois un problème | ||||
| déjà refusé ne compte pas. Au-delà de ces refus gratuits, l'équipe se verra | ||||
| dotée d'une pénalité de 25 % sur le coefficient de l'oral de défense, par | ||||
| refus. Par exemple, si une équipe refuse 4 problèmes avec un coefficient | ||||
| sur l'oral de défense normalement à ``1.6``, son coefficient passera à ``1.2``. | ||||
| sur l'oral de défense normalement à ``1.5``, son coefficient passera à ``1.125``. | ||||
|  | ||||
| Une fois que toutes les équipes de la poule ont tiré leur problème, on passe | ||||
| à la poule suivante. Une fois que toutes les poules ont vu leurs problèmes | ||||
|   | ||||
| @@ -224,7 +224,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer): | ||||
|  | ||||
|         # Update user interface | ||||
|         await self.channel_layer.group_send(f"tournament-{self.tournament.id}", | ||||
|                                             {'tid': self.tournament_id, 'type': 'draw.start', 'fmt': fmt, 'draw': draw}) | ||||
|                                             {'tid': self.tournament_id, 'type': 'draw.start', 'fmt': fmt}) | ||||
|         await self.channel_layer.group_send(f"tournament-{self.tournament.id}", | ||||
|                                             {'tid': self.tournament_id, 'type': 'draw.set_info', | ||||
|                                              'info': await self.tournament.draw.ainformation()}) | ||||
| @@ -235,7 +235,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer): | ||||
|         await self.channel_layer.group_send(f"tournament-{self.tournament.id}", | ||||
|                                             {'tid': self.tournament_id, 'type': 'draw.notify', | ||||
|                                              'title': 'Tirage au sort du TFJM²', | ||||
|                                              'body': _("The draw of tournament {tournament} started!") | ||||
|                                              'body': str(_("The draw of tournament {tournament} started!")) | ||||
|                                             .format(tournament=self.tournament.name)}) | ||||
|  | ||||
|     async def draw_start(self, content) -> None: | ||||
| @@ -405,15 +405,15 @@ class DrawConsumer(AsyncJsonWebsocketConsumer): | ||||
|                     await self.channel_layer.group_send( | ||||
|                         f"team-{dup.participation.team.trigram}", | ||||
|                         {'tid': self.tournament_id, 'type': 'draw.notify', 'title': 'Tirage au sort du TFJM²', | ||||
|                          'body': _("Your dice score is identical to the one of one or multiple teams. " | ||||
|                                    "Please relaunch it.")} | ||||
|                          'body': str(_("Your dice score is identical to the one of one or multiple teams. " | ||||
|                                        "Please relaunch it."))} | ||||
|                     ) | ||||
|                 # Alert the tournament | ||||
|                 await self.channel_layer.group_send( | ||||
|                     f"tournament-{self.tournament.id}", | ||||
|                     {'tid': self.tournament_id, 'type': 'draw.alert', | ||||
|                      'message': _('Dices from teams {teams} are identical. Please relaunch your dices.').format( | ||||
|                          teams=', '.join(td.participation.team.trigram for td in dups)), | ||||
|                      'message': str(_('Dices from teams {teams} are identical. Please relaunch your dices.').format( | ||||
|                         teams=', '.join(td.participation.team.trigram for td in dups))), | ||||
|                      'alert_type': 'warning'}) | ||||
|                 error = True | ||||
|  | ||||
| @@ -537,7 +537,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer): | ||||
|         async for next_round in self.tournament.draw.round_set.filter(number__gte=2).all(): | ||||
|             await self.channel_layer.group_send(f"tournament-{self.tournament.id}", | ||||
|                                                 {'tid': self.tournament_id, 'type': 'draw.send_poules', | ||||
|                                                  'round': r.number, | ||||
|                                                  'round': next_round.number, | ||||
|                                                  'poules': [ | ||||
|                                                      { | ||||
|                                                          'letter': pool.get_letter_display(), | ||||
| @@ -612,8 +612,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer): | ||||
|         # Notify the team that it can draw a problem | ||||
|         await self.channel_layer.group_send(f"team-{tds[0].participation.team.trigram}", | ||||
|                                             {'tid': self.tournament_id, 'type': 'draw.notify', | ||||
|                                              'title': _("Your turn!"), | ||||
|                                              'body': _("It's your turn to draw a problem!")}) | ||||
|                                              'title': str(_("Your turn!")), | ||||
|                                              'body': str(_("It's your turn to draw a problem!"))}) | ||||
|  | ||||
|     async def select_problem(self, **kwargs): | ||||
|         """ | ||||
| @@ -752,8 +752,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer): | ||||
|             # Notify the team that it can draw a problem | ||||
|             await self.channel_layer.group_send(f"team-{new_trigram}", | ||||
|                                                 {'tid': self.tournament_id, 'type': 'draw.notify', | ||||
|                                                  'title': _("Your turn!"), | ||||
|                                                  'body': _("It's your turn to draw a problem!")}) | ||||
|                                                  'title': str(_("Your turn!")), | ||||
|                                                  'body': str(_("It's your turn to draw a problem!"))}) | ||||
|         else: | ||||
|             # Pool is ended | ||||
|             await self.end_pool(pool) | ||||
| @@ -829,8 +829,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer): | ||||
|                 # Notify the team that it can draw a dice | ||||
|                 await self.channel_layer.group_send(f"team-{td.participation.team.trigram}", | ||||
|                                                     {'tid': self.tournament_id, 'type': 'draw.notify', | ||||
|                                                      'title': _("Your turn!"), | ||||
|                                                      'body': _("It's your turn to launch the dice!")}) | ||||
|                                                      'title': str(_("Your turn!")), | ||||
|                                                      'body': str(_("It's your turn to launch the dice!"))}) | ||||
|  | ||||
|             await self.channel_layer.group_send(f"tournament-{self.tournament.id}", | ||||
|                                                 {'tid': self.tournament_id, 'type': 'draw.dice_visibility', | ||||
| @@ -863,8 +863,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer): | ||||
|                 # Notify the team that it can draw a dice | ||||
|                 await self.channel_layer.group_send(f"team-{participation.team.trigram}", | ||||
|                                                     {'tid': self.tournament_id, 'type': 'draw.notify', | ||||
|                                                      'title': _("Your turn!"), | ||||
|                                                      'body': _("It's your turn to launch the dice!")}) | ||||
|                                                      'title': str(_("Your turn!")), | ||||
|                                                      'body': str(_("It's your turn to launch the dice!"))}) | ||||
|  | ||||
|             # Reorder dices | ||||
|             await self.channel_layer.group_send(f"tournament-{self.tournament.id}", | ||||
| @@ -988,8 +988,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer): | ||||
|         # Notify the team that it can draw a problem | ||||
|         await self.channel_layer.group_send(f"team-{new_trigram}", | ||||
|                                             {'tid': self.tournament_id, 'type': 'draw.notify', | ||||
|                                              'title': _("Your turn!"), | ||||
|                                              'body': _("It's your turn to draw a problem!")}) | ||||
|                                              'title': str(_("Your turn!")), | ||||
|                                              'body': str(_("It's your turn to draw a problem!"))}) | ||||
|  | ||||
|     @ensure_orga | ||||
|     async def export(self, **kwargs): | ||||
| @@ -1039,7 +1039,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer): | ||||
|         # Send notification to everyone | ||||
|         await self.channel_layer.group_send(f"tournament-{self.tournament.id}", | ||||
|                                             {'tid': self.tournament_id, 'type': 'draw.notify', | ||||
|                                              'title': _("Draw") + " " + settings.APP_NAME, | ||||
|                                              'title': str(_("Draw")) + " " + settings.APP_NAME, | ||||
|                                              'body': str(_("The draw of the second round is starting!"))}) | ||||
|  | ||||
|         if settings.TFJM_APP == "TFJM": | ||||
| @@ -1092,8 +1092,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer): | ||||
|                 # Notify the team that it can draw a problem | ||||
|                 await self.channel_layer.group_send(f"team-{td.participation.team.trigram}", | ||||
|                                                     {'tid': self.tournament_id, 'type': 'draw.notify', | ||||
|                                                      'title': _("Your turn!"), | ||||
|                                                      'body': _("It's your turn to draw a problem!")}) | ||||
|                                                      'title': str(_("Your turn!")), | ||||
|                                                      'body': str(_("It's your turn to draw a problem!"))}) | ||||
|         else: | ||||
|             async for td in r2.team_draws.prefetch_related('participation__team'): | ||||
|                 await self.channel_layer.group_send(f"team-{td.participation.team.trigram}", | ||||
|   | ||||
| @@ -221,9 +221,10 @@ document.addEventListener('DOMContentLoaded', () => { | ||||
|             elem.innerText = `${trigram} 🎲 ${result}` | ||||
|         } | ||||
|  | ||||
|         let nextTeam = document.querySelector(` div[id="dices-${tid}"] > div > div[class*="text-bg-warning"]`).getAttribute("data-team") | ||||
|         if (nextTeam) { | ||||
|         let nextTeamDiv = document.querySelector(` div[id="dices-${tid}"] > div > div[class*="text-bg-warning"]`) | ||||
|         if (nextTeamDiv) { | ||||
|             // If there is one team that does not have launched its dice, then we update the debug section | ||||
|             let nextTeam = nextTeamDiv.getAttribute("data-team") | ||||
|             let debugSpan = document.getElementById(`debug-dice-${tid}-team`) | ||||
|             if (debugSpan) | ||||
|                 debugSpan.innerText = nextTeam | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| # Copyright (C) 2023 by Animath | ||||
| # SPDX-License-Identifier: GPL-3.0-or-later | ||||
| import asyncio | ||||
| from random import shuffle | ||||
|  | ||||
| from asgiref.sync import sync_to_async | ||||
| @@ -712,15 +711,12 @@ class TestDraw(TestCase): | ||||
|                          {'tid': tid, 'type': 'export_visibility', 'visible': False}) | ||||
|  | ||||
|         # Cancel all steps and reset all | ||||
|         for i in range(1000): | ||||
|         for i in range(150): | ||||
|             await communicator.send_json_to({'tid': tid, 'type': 'cancel'}) | ||||
|  | ||||
|         # Purge receive queue | ||||
|         while True: | ||||
|             try: | ||||
|                 await communicator.receive_json_from() | ||||
|             except asyncio.TimeoutError: | ||||
|                 break | ||||
|         while (await communicator.receive_json_from())['type'] != "abort": | ||||
|             pass | ||||
|  | ||||
|         if await Draw.objects.filter(tournament_id=tid).aexists(): | ||||
|             print((await Draw.objects.filter(tournament_id=tid).aexists())) | ||||
|   | ||||
| @@ -4,6 +4,7 @@ crond -l 0 | ||||
|  | ||||
| python manage.py migrate | ||||
| python manage.py update_index | ||||
| python manage.py runmailer_pg & | ||||
|  | ||||
| nginx | ||||
|  | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,7 +1,9 @@ | ||||
| # Copyright (C) 2020 by Animath | ||||
| # SPDX-License-Identifier: GPL-3.0-or-later | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.contrib import admin | ||||
| from django.http import HttpRequest | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
| from .models import Note, Participation, Passage, Pool, Solution, Team, Tournament, Tweak, WrittenReview | ||||
| @@ -51,9 +53,14 @@ class PassageInline(admin.TabularInline): | ||||
|     model = Passage | ||||
|     extra = 0 | ||||
|     ordering = ('position',) | ||||
|     autocomplete_fields = ('reporter', 'opponent', 'reviewer', 'observer',) | ||||
|     show_change_link = True | ||||
|  | ||||
|     def get_autocomplete_fields(self, request: HttpRequest) -> tuple[str]: | ||||
|         fields = ('reporter', 'opponent', 'reviewer',) | ||||
|         if settings.HAS_OBSERVER: | ||||
|             fields += ('observer',) | ||||
|         return fields | ||||
|  | ||||
|  | ||||
| class NoteInline(admin.TabularInline): | ||||
|     model = Note | ||||
| @@ -113,12 +120,9 @@ class PoolAdmin(admin.ModelAdmin): | ||||
|  | ||||
| @admin.register(Passage) | ||||
| class PassageAdmin(admin.ModelAdmin): | ||||
|     list_display = ('__str__', 'reporter_trigram', 'solution_number', 'opponent_trigram', 'reviewer_trigram', | ||||
|                     'observer_trigram', 'pool_abbr', 'position', 'tournament') | ||||
|     list_filter = ('pool__tournament', 'pool__round', 'pool__letter', 'solution_number',) | ||||
|     search_fields = ('pool__participations__team__name', 'pool__participations__team__trigram',) | ||||
|     ordering = ('pool__tournament', 'pool__round', 'pool__letter', 'position',) | ||||
|     autocomplete_fields = ('pool', 'reporter', 'opponent', 'reviewer', 'observer',) | ||||
|     inlines = (NoteInline,) | ||||
|  | ||||
|     @admin.display(description=_("reporter"), ordering='reporter__team__trigram') | ||||
| @@ -135,7 +139,7 @@ class PassageAdmin(admin.ModelAdmin): | ||||
|  | ||||
|     @admin.display(description=_("observer"), ordering='observer__team__trigram') | ||||
|     def observer_trigram(self, record: Passage): | ||||
|         return record.observer.team.trigram | ||||
|         return record.observer.team.trigram if record.observer else None | ||||
|  | ||||
|     @admin.display(description=_("pool"), ordering='pool__letter') | ||||
|     def pool_abbr(self, record): | ||||
| @@ -145,15 +149,23 @@ class PassageAdmin(admin.ModelAdmin): | ||||
|     def tournament(self, record: Passage): | ||||
|         return record.pool.tournament | ||||
|  | ||||
|     def get_list_display(self, request: HttpRequest) -> tuple[str]: | ||||
|         if settings.HAS_OBSERVER: | ||||
|             return ('__str__', 'reporter_trigram', 'solution_number', 'opponent_trigram', | ||||
|                     'reviewer_trigram', 'observer_trigram', 'pool_abbr', 'position', 'tournament') | ||||
|         else: | ||||
|             return ('__str__', 'reporter_trigram', 'solution_number', 'opponent_trigram', | ||||
|                     'reviewer_trigram', 'pool_abbr', 'position', 'tournament') | ||||
|  | ||||
|     def get_autocomplete_fields(self, request: HttpRequest) -> tuple[str]: | ||||
|         fields = ('pool', 'reporter', 'opponent', 'reviewer',) | ||||
|         if settings.HAS_OBSERVER: | ||||
|             fields += ('observer',) | ||||
|         return fields | ||||
|  | ||||
|  | ||||
| @admin.register(Note) | ||||
| class NoteAdmin(admin.ModelAdmin): | ||||
|     list_display = ('passage', 'pool', 'jury', 'reporter_writing', 'reporter_oral', | ||||
|                     'opponent_writing', 'opponent_oral', 'reviewer_writing', 'reviewer_oral', | ||||
|                     'observer_writing', 'observer_oral',) | ||||
|     list_filter = ('passage__pool__letter', 'passage__solution_number', 'jury', | ||||
|                    'reporter_writing', 'reporter_oral', 'opponent_writing', 'opponent_oral', | ||||
|                    'reviewer_writing', 'reviewer_oral', 'observer_writing', 'observer_oral') | ||||
|     search_fields = ('jury__user__last_name', 'jury__user__first_name', 'passage__reporter__team__trigram',) | ||||
|     autocomplete_fields = ('jury', 'passage',) | ||||
|  | ||||
| @@ -161,6 +173,21 @@ class NoteAdmin(admin.ModelAdmin): | ||||
|     def pool(self, record): | ||||
|         return record.passage.pool.short_name | ||||
|  | ||||
|     def get_list_display(self, request: HttpRequest) -> tuple[str]: | ||||
|         fields = ('passage', 'pool', 'jury', 'reporter_writing', 'reporter_oral', | ||||
|                   'opponent_writing', 'opponent_oral', 'reviewer_writing', 'reviewer_oral',) | ||||
|         if settings.HAS_OBSERVER: | ||||
|             fields += ('observer_writing', 'observer_oral',) | ||||
|         return fields | ||||
|  | ||||
|     def get_list_filter(self, request: HttpRequest) -> tuple[str]: | ||||
|         fields = ('passage__pool__letter', 'passage__solution_number', 'jury', | ||||
|                   'reporter_writing', 'reporter_oral', 'opponent_writing', 'opponent_oral', | ||||
|                   'reviewer_writing', 'reviewer_oral',) | ||||
|         if settings.HAS_OBSERVER: | ||||
|             fields += ('observer_writing', 'observer_oral',) | ||||
|         return fields | ||||
|  | ||||
|  | ||||
| @admin.register(Solution) | ||||
| class SolutionAdmin(admin.ModelAdmin): | ||||
|   | ||||
| @@ -3,6 +3,7 @@ | ||||
|  | ||||
| from django.apps import AppConfig | ||||
| from django.db.models.signals import post_save, pre_save | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
|  | ||||
| class ParticipationConfig(AppConfig): | ||||
| @@ -10,6 +11,7 @@ class ParticipationConfig(AppConfig): | ||||
|     The participation app contains the data about the teams, solutions, ... | ||||
|     """ | ||||
|     name = 'participation' | ||||
|     verbose_name = _("participations") | ||||
|  | ||||
|     def ready(self): | ||||
|         from participation import signals | ||||
|   | ||||
| @@ -7,6 +7,7 @@ import re | ||||
| from crispy_forms.helper import FormHelper | ||||
| from crispy_forms.layout import Div, Field, HTML, Layout, Submit | ||||
| from django import forms | ||||
| from django.conf import settings | ||||
| from django.contrib.auth.models import User | ||||
| from django.core.exceptions import ValidationError | ||||
| from django.core.validators import FileExtensionValidator | ||||
| @@ -14,7 +15,6 @@ from django.utils.translation import gettext_lazy as _ | ||||
| import pandas | ||||
| from pypdf import PdfReader | ||||
| from registration.models import VolunteerRegistration | ||||
| from tfjm import settings | ||||
|  | ||||
| from .models import Note, Participation, Passage, Pool, Solution, Team, Tournament, WrittenReview | ||||
|  | ||||
| @@ -405,6 +405,12 @@ class WrittenReviewForm(forms.ModelForm): | ||||
|  | ||||
|  | ||||
| class NoteForm(forms.ModelForm): | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|         if not settings.HAS_OBSERVER: | ||||
|             del self.fields['observer_writing'] | ||||
|             del self.fields['observer_oral'] | ||||
|  | ||||
|     class Meta: | ||||
|         model = Note | ||||
|         fields = ('reporter_writing', 'reporter_oral', 'opponent_writing', | ||||
|   | ||||
							
								
								
									
										24
									
								
								participation/management/commands/delete_old_sympa_lists.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								participation/management/commands/delete_old_sympa_lists.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| # 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 tfjm.lists import get_sympa_client | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     def handle(self, *args, **options): | ||||
|         """ | ||||
|         Supprime les listes de diffusion Sympa. | ||||
|         Toutes les listess commençant par "equipe", "orga" ou "jury" sont fermées. | ||||
|         Attention : la fermeture n'est pas définitive, il faut ensuite se rendre sur Sympa | ||||
|         pour supprimer les listes fermées. | ||||
|         """ | ||||
|         if not settings.ML_MANAGEMENT: | ||||
|             return | ||||
|  | ||||
|         sympa = get_sympa_client() | ||||
|  | ||||
|         for mailing_list in sympa.all_lists(): | ||||
|             address = mailing_list.list_address | ||||
|             if address.startswith("equipe") or address.startswith("orga") or address.startswith("jury"): | ||||
|                 sympa.delete_list(address) | ||||
| @@ -5,11 +5,13 @@ from pathlib import Path | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.core.management import BaseCommand | ||||
| from django.utils.translation import activate | ||||
| from participation.models import Solution, Tournament | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     def handle(self, *args, **kwargs): | ||||
|         activate(settings.PREFERRED_LANGUAGE_CODE) | ||||
|         base_dir = Path(__file__).parent.parent.parent.parent | ||||
|         base_dir /= "output" | ||||
|         if not base_dir.is_dir(): | ||||
|   | ||||
| @@ -3,13 +3,12 @@ | ||||
|  | ||||
| from datetime import date, timedelta | ||||
| import math | ||||
| import os | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.core.exceptions import ValidationError | ||||
| from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator | ||||
| from django.db import models | ||||
| from django.db.models import Index | ||||
| from django.db.models import Index, Q | ||||
| from django.template.defaultfilters import slugify | ||||
| from django.urls import reverse_lazy | ||||
| from django.utils import timezone, translation | ||||
| @@ -211,7 +210,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 +391,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): | ||||
|         """ | ||||
| @@ -441,6 +440,10 @@ class Tournament(models.Model): | ||||
|             return Participation.objects.filter(final=True) | ||||
|         return self.participation_set | ||||
|  | ||||
|     @property | ||||
|     def organizers_and_presidents(self): | ||||
|         return VolunteerRegistration.objects.filter(Q(admin=True) | Q(organized_tournaments=self) | Q(pools_presided__tournament=self)) | ||||
|  | ||||
|     @property | ||||
|     def solutions(self): | ||||
|         if self.final: | ||||
| @@ -847,6 +850,8 @@ class Participation(models.Model): | ||||
|         return _("Participation of the team {name} ({trigram})").format(name=self.team.name, trigram=self.team.trigram) | ||||
|  | ||||
|     def important_informations(self): | ||||
|         from survey.models import Survey | ||||
|  | ||||
|         informations = [] | ||||
|  | ||||
|         missing_payments = Payment.objects.filter(registrations__in=self.team.participants.all(), valid=False) | ||||
| @@ -865,6 +870,19 @@ class Participation(models.Model): | ||||
|                 'content': content, | ||||
|             }) | ||||
|  | ||||
|         if self.valid: | ||||
|             for survey in Survey.objects.filter(Q(tournament__isnull=True) | Q(tournament=self.tournament), Q(invite_team=True), | ||||
|                                                 ~Q(completed_teams=self.team)).all(): | ||||
|                 text = _("Please answer to the survey \"{name}\". You can go to the survey on <a href=\"{survey_link}\">that link</a>, " | ||||
|                          "using the token code you received by mail.") | ||||
|                 content = format_lazy(text, name=survey.name, survey_link=f"{settings.LIMESURVEY_URL}/index.php/{survey.survey_id}") | ||||
|                 informations.append({ | ||||
|                     'title': _("Required answer to survey"), | ||||
|                     'type': "warning", | ||||
|                     'priority': 12, | ||||
|                     'content': content | ||||
|                 }) | ||||
|  | ||||
|         if self.tournament: | ||||
|             informations.extend(self.informations_for_tournament(self.tournament)) | ||||
|             if self.final: | ||||
| @@ -918,10 +936,10 @@ class Participation(models.Model): | ||||
|                 'content': content, | ||||
|             }) | ||||
|         elif timezone.now() <= tournament.reviews_first_phase_limit + timedelta(hours=2): | ||||
|             reporter_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=1, reporter=self) | ||||
|             opponent_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=1, opponent=self) | ||||
|             reviewer_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=1, reviewer=self) | ||||
|             observer_passage = Passage.objects.filter(pool__tournament=self.tournament, pool__round=1, observer=self) | ||||
|             reporter_passage = Passage.objects.get(pool__tournament=tournament, pool__round=1, reporter=self) | ||||
|             opponent_passage = Passage.objects.get(pool__tournament=tournament, pool__round=1, opponent=self) | ||||
|             reviewer_passage = Passage.objects.get(pool__tournament=tournament, pool__round=1, reviewer=self) | ||||
|             observer_passage = Passage.objects.filter(pool__tournament=tournament, pool__round=1, observer=self) | ||||
|             observer_passage = observer_passage.get() if observer_passage.exists() else None | ||||
|  | ||||
|             reporter_text = _("<p>The solutions draw is ended. You can check the result on " | ||||
| @@ -983,10 +1001,10 @@ class Participation(models.Model): | ||||
|                 'content': content, | ||||
|             }) | ||||
|         elif timezone.now() <= tournament.reviews_second_phase_limit + timedelta(hours=2): | ||||
|             reporter_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=2, reporter=self) | ||||
|             opponent_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=2, opponent=self) | ||||
|             reviewer_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=2, reviewer=self) | ||||
|             observer_passage = Passage.objects.filter(pool__tournament=self.tournament, pool__round=2, observer=self) | ||||
|             reporter_passage = Passage.objects.get(pool__tournament=tournament, pool__round=2, reporter=self) | ||||
|             opponent_passage = Passage.objects.get(pool__tournament=tournament, pool__round=2, opponent=self) | ||||
|             reviewer_passage = Passage.objects.get(pool__tournament=tournament, pool__round=2, reviewer=self) | ||||
|             observer_passage = Passage.objects.filter(pool__tournament=tournament, pool__round=2, observer=self) | ||||
|             observer_passage = observer_passage.get() if observer_passage.exists() else None | ||||
|  | ||||
|             reporter_text = _("<p>For the second round, you will present " | ||||
| @@ -1047,10 +1065,10 @@ class Participation(models.Model): | ||||
|             }) | ||||
|         elif settings.NB_ROUNDS >= 3 \ | ||||
|                 and timezone.now() <= tournament.reviews_third_phase_limit + timedelta(hours=2): | ||||
|             reporter_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=3, reporter=self) | ||||
|             opponent_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=3, opponent=self) | ||||
|             reviewer_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=3, reviewer=self) | ||||
|             observer_passage = Passage.objects.filter(pool__tournament=self.tournament, pool__round=3, observer=self) | ||||
|             reporter_passage = Passage.objects.get(pool__tournament=tournament, pool__round=3, reporter=self) | ||||
|             opponent_passage = Passage.objects.get(pool__tournament=tournament, pool__round=3, opponent=self) | ||||
|             reviewer_passage = Passage.objects.get(pool__tournament=tournament, pool__round=3, reviewer=self) | ||||
|             observer_passage = Passage.objects.filter(pool__tournament=tournament, pool__round=3, observer=self) | ||||
|             observer_passage = observer_passage.get() if observer_passage.exists() else None | ||||
|  | ||||
|             reporter_text = _("<p>For the third round, you will present " | ||||
| @@ -1240,6 +1258,10 @@ class Pool(models.Model): | ||||
|         passage_width = 6 + (2 if has_observer else 0) | ||||
|         passages = self.passages.all() | ||||
|  | ||||
|         if not pool_size or not passages.count(): | ||||
|             # Not initialized yet | ||||
|             return | ||||
|  | ||||
|         # Create tournament sheet if it does not exist | ||||
|         self.tournament.create_spreadsheet() | ||||
|  | ||||
| @@ -1400,8 +1422,8 @@ class Pool(models.Model): | ||||
|  | ||||
|             if has_observer: | ||||
|                 merge_cells.append(f"{getcol(9 + i * passage_width)}2:{getcol(10 + i * passage_width)}2") | ||||
|             merge_cells.append(f"{getcol(9 + i * passage_width)}{max_row + 3}" | ||||
|                                f":{getcol(10 + i * passage_width)}{max_row + 3}") | ||||
|                 merge_cells.append(f"{getcol(9 + i * passage_width)}{max_row + 3}" | ||||
|                                    f":{getcol(10 + i * passage_width)}{max_row + 3}") | ||||
|         merge_cells.append(f"A{max_row + 1}:B{max_row + 1}") | ||||
|         merge_cells.append(f"A{max_row + 2}:B{max_row + 2}") | ||||
|         merge_cells.append(f"A{max_row + 3}:B{max_row + 3}") | ||||
| @@ -1609,6 +1631,10 @@ class Pool(models.Model): | ||||
|         worksheet.client.batch_update(spreadsheet.id, body) | ||||
|  | ||||
|     def update_juries_lines_spreadsheet(self): | ||||
|         if not self.participations.count() or not self.passages.count(): | ||||
|             # Not initialized yet | ||||
|             return | ||||
|  | ||||
|         translation.activate(settings.PREFERRED_LANGUAGE_CODE) | ||||
|  | ||||
|         gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT) | ||||
| @@ -1759,7 +1785,7 @@ class Passage(models.Model): | ||||
|  | ||||
|     @property | ||||
|     def coeff_reporter_oral(self) -> float: | ||||
|         coeff = 1.6 if settings.TFJM_APP == "TFJM" else 3 | ||||
|         coeff = 1.5 if settings.TFJM_APP == "TFJM" else 3 | ||||
|         coeff *= 1 - 0.25 * self.reporter_penalties | ||||
|         return coeff | ||||
|  | ||||
| @@ -1803,7 +1829,7 @@ class Passage(models.Model): | ||||
|  | ||||
|     @property | ||||
|     def coeff_reviewer_oral(self): | ||||
|         return 1 if settings.TFJM_APP == "TFJM" else 1.2 | ||||
|         return 1.2 | ||||
|  | ||||
|     @property | ||||
|     def average_reviewer(self) -> float: | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| # Copyright (C) 2020 by Animath | ||||
| # SPDX-License-Identifier: GPL-3.0-or-later | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.utils import formats | ||||
| from django.utils.safestring import mark_safe | ||||
| from django.utils.text import format_lazy | ||||
| @@ -106,8 +107,6 @@ class PoolTable(tables.Table): | ||||
|  | ||||
|  | ||||
| class PassageTable(tables.Table): | ||||
|     # FIXME Ne pas afficher l'équipe observatrice si non nécessaire | ||||
|  | ||||
|     reporter = tables.LinkColumn( | ||||
|         "participation:passage_detail", | ||||
|         args=[tables.A("id")], | ||||
| @@ -131,7 +130,9 @@ class PassageTable(tables.Table): | ||||
|             'class': 'table table-condensed table-striped text-center', | ||||
|         } | ||||
|         model = Passage | ||||
|         fields = ('reporter', 'opponent', 'reviewer', 'observer', 'solution_number', ) | ||||
|         fields = ('reporter', 'opponent', 'reviewer',) \ | ||||
|             + (('observer',) if settings.HAS_OBSERVER else ()) \ | ||||
|             + ('solution_number', ) | ||||
|  | ||||
|  | ||||
| class NoteTable(tables.Table): | ||||
| @@ -160,4 +161,6 @@ class NoteTable(tables.Table): | ||||
|         } | ||||
|         model = Note | ||||
|         fields = ('jury', 'reporter_writing', 'reporter_oral', 'opponent_writing', 'opponent_oral', | ||||
|                   'reviewer_writing', 'reviewer_oral', 'observer_writing', 'observer_oral', 'update',) | ||||
|                   'reviewer_writing', 'reviewer_oral',) + \ | ||||
|                  (('observer_writing', 'observer_oral') if settings.HAS_OBSERVER else ()) + \ | ||||
|                  ('update',) | ||||
|   | ||||
| @@ -44,7 +44,7 @@ | ||||
|     \Large {\bf \tfjmedition$^{st}$ European Tournament of Enthusiastic Apprentice Mathematicians}\\ | ||||
| {% endif %} | ||||
| \vspace{3mm} | ||||
| {% trans "Round" %} {{ pool.round }} \;-- {% trans "Pool" %} {{ pool.get_letter_display }}{% if pool.participations.count == 5 %} \;-- {{ pool.get_room_display }}{% endif %} \;-- {% if pool.round == 1 %}{{ pool.tournament.date_first_phase }}{% elif pool.round == 2 %}{{ pool.tournament.date_second_phase }}{% else %}{{ pool.tournament.date_third_phase }}{% endif %} | ||||
| {% trans "round"|capfirst %} {{ pool.round }} \;-- {% trans "pool"|capfirst %} {{ pool.get_letter_display }}{% if pool.participations.count == 5 %} \;-- {{ pool.get_room_display }}{% endif %} \;-- {% if pool.round == 1 %}{{ pool.tournament.date_first_phase }}{% elif pool.round == 2 %}{{ pool.tournament.date_second_phase }}{% else %}{{ pool.tournament.date_third_phase }}{% endif %} | ||||
|  | ||||
|  | ||||
| \vspace{15mm} | ||||
| @@ -52,7 +52,7 @@ | ||||
|  | ||||
| \begin{tabular}{|p{40mm}{% for passage in passages.all %}{% if passages.count <= 3 %}|p{3cm}|p{3cm}{% else %}|p{2.8cm}|p{2.5cm}{% endif %}{% endfor %}|}\hline | ||||
| \multirow{2}{40mm}{\LARGE {% trans "Role" %}} {% for passage in passages.all %}& \multicolumn{2}{c|}{ \Large {% trans "Problem" %} {{ passage.solution_number }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}} | ||||
| {% for passage in passages.all %}& \hspace{4mm} {\Large {% trans "Writing"|upper %}} & \hspace{4mm} {\Large {% trans "Oral"|upper %}}{% endfor %} \\ \hline | ||||
| {% for passage in passages.all %}& \multicolumn{1}{c|}{\Large {% trans "Writing"|upper %}} & \multicolumn{1}{c|}{\Large {% trans "Oral"|upper %}}{% endfor %} \\ \hline | ||||
| \multirow{2}{35mm}{\LARGE {% trans "Reporter" %}} {% for passage in passages.all %}& \multicolumn{2}{c|}{\Large {{ passage.reporter.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}} | ||||
| {% for passage in passages.all %} | ||||
| & \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq {% if TFJM.APP == "TFJM" %}20{% else %}10{% endif %}$ | ||||
|   | ||||
| @@ -58,7 +58,7 @@ | ||||
|  | ||||
| %%%%%%%%%%%%%%%%%%%%%DEFENSEUR | ||||
| \begin{tabular}{|c|p{25mm}|p{11cm}|c|{% for passage in passages.all %}p{2cm}|{% endfor %}}\hline | ||||
| \multicolumn{4}{|l|}{The {\bf {% trans "Reporter" %}} \normalsize presents their ideas and major results for the solution of the problem.} {% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.reporter.team.trigram }} {% endfor %}\\ \hline \hline | ||||
| \multicolumn{4}{|l|}{The {\bf {% trans "Reporter" %}} \normalsize presents their ideas and major results for the solution of the problem.} {% for passage in passages.all %}& Pb. {{ passage.solution_number }} - {{ passage.reporter.team.trigram }} {% endfor %}\\ \hline \hline | ||||
|  | ||||
| %ECRIT | ||||
| \multirow{7}{3mm}{\bf \begin{turn}{90}WRITING\end{turn}} & \multirow{3}{20mm}{ {% trans "Scientific part" %}} & {% trans "Depth and difficulty of the elements presented" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} | ||||
| @@ -86,7 +86,7 @@ | ||||
| %%%%%%%%%%%%%%%%%OPPOSANT⋅E | ||||
| \begin{tabular}{|c|p{25mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline | ||||
| \multicolumn{4}{|l|}{The {\bf {% trans "Opponent" %}} \normalsize provides a critical analysis of the solution and presentation.} | ||||
| {% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.opponent.team.trigram }} {% endfor %} \\ \hline \hline | ||||
| {% for passage in passages.all %}& Pb. {{ passage.solution_number }} - {{ passage.opponent.team.trigram }} {% endfor %} \\ \hline \hline | ||||
|  | ||||
| %ECRIT | ||||
| \multirow{6}{3mm}{\bf \begin{turn}{90}WRITING\end{turn}} &\multirow{4}{25mm}{ {% trans "Scientific part" %}} & {% trans "Critical thinking and perspective on the proposed solution" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} | ||||
| @@ -108,7 +108,7 @@ | ||||
|  | ||||
| %%%%%%%%%%%%%%%%%%%%%%RAPPORTEUR⋅RICE | ||||
| \begin{tabular}{|c|p{25mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline | ||||
| \multicolumn{4}{|l|}{The {\bf {% trans "Reviewer" %}} \normalsize evaluates the debate between the Reporter and the Opponent.} {% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.reviewer.team.trigram }} {% endfor %}\\ \hline \hline | ||||
| \multicolumn{4}{|l|}{The {\bf {% trans "Reviewer" %}} \normalsize evaluates the debate between the Reporter and the Opponent.} {% for passage in passages.all %}& Pb. {{ passage.solution_number }} - {{ passage.reviewer.team.trigram }} {% endfor %}\\ \hline \hline | ||||
|  | ||||
| %ECRIT | ||||
| \multirow{6}{3mm}{\bf \begin{turn}{90}WRITING\end{turn}} &\multirow{4}{25mm}{ {% trans "Scientific part" %}} & {% trans "Critical thinking and perspective on the proposed solution" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} | ||||
| @@ -131,7 +131,7 @@ | ||||
| {% if TFJM.APP == "ETEAM" and pool.participations.count >= 4 %} | ||||
| %%%%%%%%%%%%%%%%%%%%%%OBSERVATEUR⋅RICE | ||||
| \begin{tabular}{|c|p{25mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline | ||||
| \multicolumn{4}{|l|}{The {\bf {% trans "Observer" %}} \normalsize makes useful remarks on crucial points missed by the other participants.} {% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.observer.team.trigram }} {% endfor %}\\ \hline \hline | ||||
| \multicolumn{4}{|l|}{The {\bf {% trans "Observer" %}} \normalsize makes useful remarks on crucial points missed by the other participants.} {% for passage in passages.all %}& Pb. {{ passage.solution_number }} - {{ passage.observer.team.trigram }} {% endfor %}\\ \hline \hline | ||||
|  | ||||
| %ECRIT | ||||
| \multirow{6}{3mm}{\bf \begin{turn}{90}WRITING\end{turn}} &\multirow{4}{25mm}{ {% trans "Scientific part" %}} & {% trans "Critical thinking and perspective on the proposed solution" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} | ||||
|   | ||||
| @@ -52,7 +52,7 @@ | ||||
|  | ||||
| %%%%%%%%%%%%%%%%%%%%%DEFENSEUR | ||||
| \begin{tabular}{|c|p{24mm}|p{11cm}|c|{% for passage in passages.all %}p{2cm}|{% endfor %}}\hline | ||||
| \multicolumn{4}{|l|}{Læ {\bf D\'efenseur⋅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.reporter.team.trigram }} {% endfor %}\\ \hline \hline | ||||
| \multicolumn{4}{|l|}{Læ {\bf D\'efenseur⋅se} \normalsize pr\'esente les id\'ees et r\'esultats principaux pour la solution du probl\`eme.} {% for passage in passages.all %}& Pb. {{ passage.solution_number }} - {{ passage.reporter.team.trigram }} {% endfor %}\\ \hline \hline | ||||
|  | ||||
| %ECRIT | ||||
| \multirow{7}{3mm}{\bf \begin{turn}{90}ÉCRIT\end{turn}} & \multirow{3}{24mm}{Partie scientifique} & Profondeur et difficulté des éléments présentés & [0,6] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} | ||||
| @@ -80,7 +80,7 @@ | ||||
| %%%%%%%%%%%%%%%%%OPPOSANT | ||||
| \begin{tabular}{|c|p{24mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline | ||||
| \multicolumn{4}{|l|}{L' {\bf Opposant⋅e} \normalsize fournit une analyse critique de la solution et de la pr\'esentation.} | ||||
| {% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.opponent.team.trigram }} {% endfor %} \\ \hline \hline | ||||
| {% for passage in passages.all %}& Pb. {{ passage.solution_number }} - {{ passage.opponent.team.trigram }} {% endfor %} \\ \hline \hline | ||||
|  | ||||
| %ECRIT | ||||
| \multirow{6}{3mm}{\bf \begin{turn}{90}ÉCRIT\end{turn}} &\multirow{4}{24mm}{Partie scientifique} & Recul et esprit critique par rapport à la solution proposée & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} | ||||
| @@ -102,7 +102,7 @@ | ||||
|  | ||||
| %%%%%%%%%%%%%%%%%%%%%%RAPPORTEUR.RICE | ||||
| \begin{tabular}{|c|p{24mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline | ||||
| \multicolumn{4}{|l|}{Læ {\bf Rapporteur⋅rice} \normalsize \'evalue le d\'ebat entre læ D\'efenseur⋅se et l'Opposant⋅e.} {% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.reviewer.team.trigram }} {% endfor %}\\ \hline \hline | ||||
| \multicolumn{4}{|l|}{Læ {\bf Rapporteur⋅rice} \normalsize \'evalue le d\'ebat entre læ D\'efenseur⋅se et l'Opposant⋅e.} {% for passage in passages.all %}& Pb. {{ passage.solution_number }} - {{ passage.reviewer.team.trigram }} {% endfor %}\\ \hline \hline | ||||
|  | ||||
| %ECRIT | ||||
| \multirow{6}{3mm}{\bf \begin{turn}{90}ÉCRIT\end{turn}} &\multirow{4}{24mm}{Partie scientifique} & Recul et esprit critique par rapport à la solution proposée & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} | ||||
| @@ -115,7 +115,7 @@ | ||||
| \multirow{9}{3mm}{\bf \begin{turn}{90}ORAL\end{turn}} & \multirow{5}{24mm}{Questions et discours de læ rapporteur⋅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⋅rice et du jury (fond et capacité à faire avancer le débat) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} | ||||
| && Réponses aux questions 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} | ||||
|   | ||||
| @@ -23,44 +23,80 @@ | ||||
|                     <dd class="col-sm-6">{% if tournament.price %}{{ tournament.price }} €{% else %}{% trans "Free" %}{% endif %}</dd> | ||||
|                 {% endif %} | ||||
|  | ||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'remote'|capfirst %}</dt> | ||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "remote"|capfirst %}</dt> | ||||
|                 <dd class="col-sm-6">{{ tournament.remote|yesno }}</dd> | ||||
|  | ||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'dates'|capfirst %}</dt> | ||||
|                 <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-sm-6 text-sm-end">{% trans 'date of registration closing'|capfirst %}</dt> | ||||
|                 <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-sm-6 text-sm-end">{% trans 'date of maximal solution submission'|capfirst %}</dt> | ||||
|                 <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-sm-6 text-sm-end">{% trans 'date of the random draw'|capfirst %}</dt> | ||||
|                 <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-sm-6 text-sm-end">{% trans 'date of maximal written reviews submission for the first round'|capfirst %}</dt> | ||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "date of maximal written reviews submission for the first round"|capfirst %}</dt> | ||||
|                 <dd class="col-sm-6">{{ tournament.reviews_first_phase_limit }}</dd> | ||||
|  | ||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'date of maximal written reviews submission for the second round'|capfirst %}</dt> | ||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "Solutions available for the second round" %}</dt> | ||||
|                 <dd class="col-sm-6"> | ||||
|                     {{ tournament.solutions_available_second_phase|yesno }} | ||||
|                     {% if user.is_authenticated and user.registration in tournament.organizers_and_presidents.all %} | ||||
|                         {% now 'Y-m-d' as today %} | ||||
|                         {% if not tournament.solutions_available_second_phase %} | ||||
|                             {% if today >= tournament.date_first_phase|date:"Y-m-d" %} | ||||
|                                 <a href="{% url 'participation:tournament_publish_solutions' pk=tournament.pk round=2 %}" class="btn btn-sm btn-info"><i class="fas fa-eye"></i> {% trans "Publish" %}</a> | ||||
|                             {% endif %} | ||||
|                         {% else %} | ||||
|                             {% if today <= tournament.date_second_phase|date:"Y-m-d" %} | ||||
|                                 <a href="{% url 'participation:tournament_publish_solutions' pk=tournament.pk round=2 %}?hide" class="btn btn-sm bg-danger"><i class="fas fa-eye-slash"></i> {% trans "Unpublish" %}</a> | ||||
|                             {% endif %} | ||||
|                         {% endif %} | ||||
|                     {% endif %} | ||||
|                 </dd> | ||||
|  | ||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "date of maximal written reviews submission for the second round"|capfirst %}</dt> | ||||
|                 <dd class="col-sm-6">{{ tournament.reviews_second_phase_limit }}</dd> | ||||
|  | ||||
|                 {% if TFJM.APP == "ETEAM" %} | ||||
|                     <dt class="col-sm-6 text-sm-end">{% trans 'date of maximal written reviews submission for the third round'|capfirst %}</dt> | ||||
|                 {% if TFJM.NB_ROUNDS == 3 %} | ||||
|                     <dt class="col-sm-6 text-sm-end">{% trans "Solutions available for the third round" %}</dt> | ||||
|                     <dd class="col-sm-6"> | ||||
|                         {{ tournament.solutions_available_third_phase|yesno }} | ||||
|                         {% if tournament.solutions_available_second_phase and user.is_authenticated and user.registration in tournament.organizers_and_presidents.all %} | ||||
|                             {% now 'Y-m-d' as today %} | ||||
|                             {% if not tournament.solutions_available_third_phase %} | ||||
|                                 {% if today >= tournament.date_second_phase|date:"Y-m-d" %} | ||||
|                                     <a href="{% url 'participation:tournament_publish_solutions' pk=tournament.pk round=3 %}" class="btn btn-sm btn-info"><i class="fas fa-eye"></i> {% trans "Publish" %}</a> | ||||
|                                 {% endif %} | ||||
|                             {% else %} | ||||
|                                 {% if today <= tournament.date_third_phase|date:"Y-m-d" %} | ||||
|                                     <a href="{% url 'participation:tournament_publish_solutions' pk=tournament.pk round=3 %}?hide" class="btn btn-sm bg-danger"><i class="fas fa-eye-slash"></i> {% trans "Unpublish" %}</a> | ||||
|                                 {% endif %} | ||||
|                             {% endif %} | ||||
|                         {% endif %} | ||||
|                     </dd> | ||||
|  | ||||
|                     <dt class="col-sm-6 text-sm-end">{% trans "date of maximal written reviews submission for the third round"|capfirst %}</dt> | ||||
|                     <dd class="col-sm-6">{{ tournament.reviews_third_phase_limit }}</dd> | ||||
|                 {% endif %} | ||||
|  | ||||
|                 <dt class="col-sm-6 text-sm-end">{% trans 'description'|capfirst %}</dt> | ||||
|                 <dt class="col-sm-6 text-sm-end">{% trans "description"|capfirst %}</dt> | ||||
|                 <dd class="col-sm-6">{{ tournament.description }}</dd> | ||||
|  | ||||
|                 {% if TFJM.ML_MANAGEMENT %} | ||||
|                     <dt class="col-sm-6 text-sm-end">{% trans 'To contact organizers' %}</dt> | ||||
|                     <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-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> | ||||
|                     {% if user.is_authenticated and user.registration.is_volunteer %} | ||||
|                         <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-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> | ||||
|                         <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> | ||||
|                     {% endif %} | ||||
|                 {% endif %} | ||||
|             </dl> | ||||
|         </div> | ||||
| @@ -199,7 +235,7 @@ | ||||
|                     </div> | ||||
|                 </div> | ||||
|             {% endif %} | ||||
|         </div> | ||||
|             </div> | ||||
|     {% endif %} | ||||
|  | ||||
|     {% if user.registration.is_admin or user.registration in tournament.organizers.all %} | ||||
|   | ||||
| @@ -1,3 +1,2 @@ | ||||
| {{ object.name }} | ||||
| {{ object.place }} | ||||
| {{ object.description }} | ||||
|   | ||||
| @@ -1,5 +0,0 @@ | ||||
| {{ object.link }} | ||||
| {{ object.participation.team.name }} | ||||
| {{ object.participation.team.trigram }} | ||||
| {{ object.participation.problem }} | ||||
| {{ object.participation.get_problem_display }} | ||||
| @@ -12,7 +12,7 @@ from .views import CreateTeamView, FinalNotationSheetTemplateView, GSheetNotific | ||||
|     TeamAuthorizationsView, TeamDetailView, TeamLeaveView, TeamListView, TeamUpdateView, \ | ||||
|     TeamUploadMotivationLetterView, TournamentCreateView, TournamentDetailView, TournamentExportCSVView, \ | ||||
|     TournamentHarmonizeNoteView, TournamentHarmonizeView, TournamentListView, TournamentPaymentsView, \ | ||||
|     TournamentPublishNotesView, TournamentUpdateView, WrittenReviewUploadView | ||||
|     TournamentPublishNotesView, TournamentPublishSolutionsView, TournamentUpdateView, WrittenReviewUploadView | ||||
|  | ||||
|  | ||||
| app_name = "participation" | ||||
| @@ -48,6 +48,8 @@ urlpatterns = [ | ||||
|          name="tournament_notation_sheets"), | ||||
|     path("tournament/<int:pk>/notation/notifications/", GSheetNotificationsView.as_view(), | ||||
|          name="tournament_gsheet_notifications"), | ||||
|     path("tournament/<int:pk>/publish-solutions/<int:round>/", TournamentPublishSolutionsView.as_view(), | ||||
|          name="tournament_publish_solutions"), | ||||
|     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(), | ||||
|   | ||||
| @@ -234,7 +234,7 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView) | ||||
|             mail_plain = render_to_string("participation/mails/request_validation.txt", mail_context) | ||||
|             mail_html = render_to_string("participation/mails/request_validation.html", mail_context) | ||||
|             send_mail(f"[{settings.APP_NAME}] {_('Team validation')}", mail_plain, settings.DEFAULT_FROM_EMAIL, | ||||
|                     [self.object.participation.tournament.organizers_email], html_message=mail_html) | ||||
|                       [self.object.participation.tournament.organizers_email], html_message=mail_html) | ||||
|  | ||||
|         return super().form_valid(form) | ||||
|  | ||||
| @@ -270,7 +270,7 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView) | ||||
|                     mail_plain = render_to_string("participation/mails/team_validated.txt", mail_context_plain) | ||||
|                     mail_html = render_to_string("participation/mails/team_validated.html", mail_context_html) | ||||
|                     registration.user.email_user(f"[{settings.APP_NAME}] {_('Team validated')}", mail_plain, | ||||
|                                                 html_message=mail_html) | ||||
|                                                  html_message=mail_html) | ||||
|         elif "invalidate" in self.request.POST: | ||||
|             self.object.participation.valid = None | ||||
|             self.object.participation.save() | ||||
| @@ -280,7 +280,7 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView) | ||||
|                 mail_plain = render_to_string("participation/mails/team_not_validated.txt", mail_context_plain) | ||||
|                 mail_html = render_to_string("participation/mails/team_not_validated.html", mail_context_html) | ||||
|                 send_mail(f"[{settings.APP_NAME}] {_('Team not validated')}", mail_plain, | ||||
|                         None, [self.object.email], html_message=mail_html) | ||||
|                           None, [self.object.email], html_message=mail_html) | ||||
|         else: | ||||
|             form.add_error(None, _("You must specify if you validate the registration or not.")) | ||||
|             return self.form_invalid(form) | ||||
| @@ -557,7 +557,7 @@ class ParticipationDetailView(LoginRequiredMixin, DetailView): | ||||
|         if not self.get_object().valid: | ||||
|             raise PermissionDenied(_("The team is not validated yet.")) | ||||
|         if user.registration.is_admin or user.registration.participates \ | ||||
|                 and user.registration.team.participation \ | ||||
|                 and user.registration.team \ | ||||
|                 and user.registration.team.participation.pk == kwargs["pk"] \ | ||||
|                 or user.registration.is_volunteer \ | ||||
|                 and (self.get_object().tournament in user.registration.interesting_tournaments | ||||
| @@ -672,7 +672,7 @@ class TournamentPaymentsView(VolunteerMixin, SingleTableMixin, DetailView): | ||||
|         if self.object.final: | ||||
|             payments = Payment.objects.filter(final=True) | ||||
|         else: | ||||
|             payments = Payment.objects.filter(registrations__team__participation__tournament=self.get_object()) | ||||
|             payments = Payment.objects.filter(registrations__team__participation__tournament=self.get_object(), final=False) | ||||
|         return payments.annotate(team_id=F('registrations__team')).order_by('-valid', 'registrations__team__trigram') \ | ||||
|             .distinct().all() | ||||
|  | ||||
| @@ -747,12 +747,12 @@ class TournamentPublishNotesView(VolunteerMixin, SingleObjectMixin, RedirectView | ||||
|             return self.handle_no_permission() | ||||
|         tournament = self.get_object() | ||||
|         reg = request.user.registration | ||||
|         if not reg.is_admin and (not reg.is_volunteer or tournament not in reg.organized_tournaments.all()): | ||||
|         if not reg.is_volunteer or reg not in tournament.organizers_and_presidents.all(): | ||||
|             return self.handle_no_permission() | ||||
|         return super().dispatch(request, *args, **kwargs) | ||||
|  | ||||
|     def get(self, request, *args, **kwargs): | ||||
|         if int(kwargs["round"]) not in (1, 2): | ||||
|         if int(kwargs["round"]) not in range(1, settings.NB_ROUNDS + 1): | ||||
|             raise Http404 | ||||
|  | ||||
|         tournament = Tournament.objects.get(pk=kwargs["pk"]) | ||||
| @@ -767,6 +767,45 @@ class TournamentPublishNotesView(VolunteerMixin, SingleObjectMixin, RedirectView | ||||
|         return reverse_lazy("participation:tournament_detail", args=(kwargs['pk'],)) | ||||
|  | ||||
|  | ||||
| class TournamentPublishSolutionsView(VolunteerMixin, SingleObjectMixin, RedirectView): | ||||
|     """ | ||||
|     On rend les solutions du tour suivant accessibles aux équipes. | ||||
|     """ | ||||
|     model = Tournament | ||||
|  | ||||
|     def dispatch(self, request, *args, **kwargs): | ||||
|         """ | ||||
|         Les admins, orgas et PJ peuvent rendre les solutions accessibles. | ||||
|         """ | ||||
|         if not request.user.is_authenticated: | ||||
|             return self.handle_no_permission() | ||||
|         tournament = self.get_object() | ||||
|         reg = request.user.registration | ||||
|         if not reg.is_volunteer or reg not in tournament.organizers_and_presidents.all(): | ||||
|             return self.handle_no_permission() | ||||
|         return super().dispatch(request, *args, **kwargs) | ||||
|  | ||||
|     def get(self, request, *args, **kwargs): | ||||
|         if int(kwargs["round"]) not in range(2, settings.NB_ROUNDS + 1): | ||||
|             raise Http404 | ||||
|  | ||||
|         tournament = Tournament.objects.get(pk=kwargs["pk"]) | ||||
|         publish_solutions = 'hide' not in request.GET | ||||
|         if int(kwargs['round']) == 2: | ||||
|             tournament.solutions_available_second_phase = publish_solutions | ||||
|         elif int(kwargs['round']) == 3: | ||||
|             tournament.solutions_available_third_phase = publish_solutions | ||||
|         tournament.save() | ||||
|         if 'hide' not in request.GET: | ||||
|             messages.success(request, _("Solutions are now available to teams!")) | ||||
|         else: | ||||
|             messages.warning(request, _("Solutions are not available to teams anymore.")) | ||||
|         return super().get(request, *args, **kwargs) | ||||
|  | ||||
|     def get_redirect_url(self, *args, **kwargs): | ||||
|         return reverse_lazy("participation:tournament_detail", args=(kwargs['pk'],)) | ||||
|  | ||||
|  | ||||
| class TournamentHarmonizeView(VolunteerMixin, DetailView): | ||||
|     """ | ||||
|     Harmonize the notes of a tournament. | ||||
| @@ -779,7 +818,7 @@ class TournamentHarmonizeView(VolunteerMixin, DetailView): | ||||
|             return self.handle_no_permission() | ||||
|         tournament = self.get_object() | ||||
|         reg = request.user.registration | ||||
|         if not reg.is_admin and (not reg.is_volunteer or tournament not in reg.organized_tournaments.all()): | ||||
|         if not reg.is_volunteer or reg not in tournament.organizers_and_presidents.all(): | ||||
|             return self.handle_no_permission() | ||||
|         if self.kwargs['round'] not in range(1, settings.NB_ROUNDS + 1): | ||||
|             raise Http404 | ||||
| @@ -812,7 +851,7 @@ class TournamentHarmonizeNoteView(VolunteerMixin, DetailView): | ||||
|             return self.handle_no_permission() | ||||
|         tournament = self.get_object() | ||||
|         reg = request.user.registration | ||||
|         if not reg.is_admin and (not reg.is_volunteer or tournament not in reg.organized_tournaments.all()): | ||||
|         if not reg.is_volunteer or reg not in tournament.organizers_and_presidents.all(): | ||||
|             return self.handle_no_permission() | ||||
|         if self.kwargs['round'] not in range(1, settings.NB_ROUNDS + 1) \ | ||||
|                 or self.kwargs['action'] not in ('add', 'remove') \ | ||||
| @@ -852,7 +891,7 @@ class SelectTeamFinalView(VolunteerMixin, DetailView): | ||||
|             return self.handle_no_permission() | ||||
|         tournament = self.get_object() | ||||
|         reg = request.user.registration | ||||
|         if not reg.is_admin and (not reg.is_volunteer or tournament not in reg.organized_tournaments.all()): | ||||
|         if not reg.is_volunteer or reg not in tournament.organizers_and_presidents.all(): | ||||
|             return self.handle_no_permission() | ||||
|         participation_qs = tournament.participations.filter(pk=self.kwargs["participation_id"]) | ||||
|         if not participation_qs.exists(): | ||||
| @@ -1003,17 +1042,14 @@ class SolutionsDownloadView(VolunteerMixin, View): | ||||
|                 return super().dispatch(request, *args, **kwargs) | ||||
|         elif 'tournament_id' in kwargs: | ||||
|             tournament = Tournament.objects.get(pk=kwargs["tournament_id"]) | ||||
|             if reg.is_volunteer \ | ||||
|                     and (tournament in reg.organized_tournaments.all() | ||||
|                          or reg.pools_presided.filter(tournament=tournament).exists()): | ||||
|             if reg.is_volunteer and reg in tournament.organizers_and_presidents.all(): | ||||
|                 return super().dispatch(request, *args, **kwargs) | ||||
|         else: | ||||
|             pool = Pool.objects.get(pk=kwargs["pool_id"]) | ||||
|             tournament = pool.tournament | ||||
|             if reg.is_volunteer \ | ||||
|                     and (reg in tournament.organizers.all() | ||||
|                          or reg in pool.juries.all() | ||||
|                          or reg.pools_presided.filter(tournament=tournament).exists()): | ||||
|                     and (reg in tournament.organizers_and_presidents.all() | ||||
|                          or reg in pool.juries.all()): | ||||
|                 return super().dispatch(request, *args, **kwargs) | ||||
|  | ||||
|         return self.handle_no_permission() | ||||
| @@ -2001,7 +2037,7 @@ class PassageDetailView(LoginRequiredMixin, DetailView): | ||||
|         reg = request.user.registration | ||||
|         passage = self.get_object() | ||||
|         if reg.is_admin or reg.is_volunteer \ | ||||
|                 and (self.get_object().pool.tournament in reg.organized_tournaments.all() | ||||
|                 and (reg in self.get_object().pool.tournament.organizers_and_presidents.all() | ||||
|                      or reg in passage.pool.juries.all() | ||||
|                      or reg.pools_presided.filter(tournament=passage.pool.tournament).exists()) \ | ||||
|                 or reg.participates and reg.team \ | ||||
| @@ -2128,8 +2164,9 @@ class NoteUpdateView(VolunteerMixin, UpdateView): | ||||
|         form.fields['opponent_oral'].label += f" ({self.object.passage.opponent.team.trigram})" | ||||
|         form.fields['reviewer_writing'].label += f" ({self.object.passage.reviewer.team.trigram})" | ||||
|         form.fields['reviewer_oral'].label += f" ({self.object.passage.reviewer.team.trigram})" | ||||
|         form.fields['observer_writing'].label += f" ({self.object.passage.observer.team.trigram})" | ||||
|         form.fields['observer_oral'].label += f" ({self.object.passage.observer.team.trigram})" | ||||
|         if settings.HAS_OBSERVER: | ||||
|             form.fields['observer_writing'].label += f" ({self.object.passage.observer.team.trigram})" | ||||
|             form.fields['observer_oral'].label += f" ({self.object.passage.observer.team.trigram})" | ||||
|         return form | ||||
|  | ||||
|     def form_valid(self, form): | ||||
|   | ||||
| @@ -3,6 +3,7 @@ | ||||
|  | ||||
| from django.apps import AppConfig | ||||
| from django.db.models.signals import post_save, pre_save | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
|  | ||||
| class RegistrationConfig(AppConfig): | ||||
| @@ -10,6 +11,7 @@ class RegistrationConfig(AppConfig): | ||||
|     Registration app contains the detail about users only. | ||||
|     """ | ||||
|     name = 'registration' | ||||
|     verbose_name = _("registrations") | ||||
|  | ||||
|     def ready(self): | ||||
|         from registration import signals | ||||
|   | ||||
| @@ -0,0 +1,22 @@ | ||||
| # Generated by Django 5.1.5 on 2025-03-27 19:18 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("registration", "0014_participantregistration_country"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="participantregistration", | ||||
|             name="gender", | ||||
|             field=models.CharField( | ||||
|                 choices=[("female", "Female"), ("male", "Male"), ("other", "Other")], | ||||
|                 max_length=6, | ||||
|                 verbose_name="gender", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @@ -8,6 +8,7 @@ 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.db.models import Q | ||||
| from django.template import loader | ||||
| from django.urls import reverse, reverse_lazy | ||||
| from django.utils import timezone, translation | ||||
| @@ -166,7 +167,6 @@ class ParticipantRegistration(Registration): | ||||
|             ("male", _("Male")), | ||||
|             ("other", _("Other")), | ||||
|         ], | ||||
|         default="other", | ||||
|     ) | ||||
|  | ||||
|     address = models.CharField( | ||||
| @@ -260,6 +260,8 @@ class ParticipantRegistration(Registration): | ||||
|         raise NotImplementedError | ||||
|  | ||||
|     def registration_informations(self): | ||||
|         from survey.models import Survey | ||||
|  | ||||
|         informations = [] | ||||
|         if not self.team: | ||||
|             text = _("You are not in a team. You can <a href=\"{create_url}\">create one</a> " | ||||
| @@ -300,6 +302,20 @@ class ParticipantRegistration(Registration): | ||||
|                             'content': content, | ||||
|                         }) | ||||
|  | ||||
|                 if self.team.participation.valid: | ||||
|                     for survey in Survey.objects.filter(Q(tournament__isnull=True) | Q(tournament=self.team.participation.tournament), | ||||
|                                                         Q(invite_team=False), Q(invite_coaches=True) | Q(invite_coaches=self.is_coach), | ||||
|                                                         ~Q(completed_registrations=self)): | ||||
|                         text = _("Please answer to the survey \"{name}\". You can go to the survey on <a href=\"{survey_link}\">that link</a>, " | ||||
|                                  "using the token code you received by mail.") | ||||
|                         content = format_lazy(text, name=survey.name, survey_link=f"{settings.LIMESURVEY_URL}/index.php/{survey.survey_id}") | ||||
|                         informations.append({ | ||||
|                             'title': _("Required answer to survey"), | ||||
|                             'type': "warning", | ||||
|                             'priority': 12, | ||||
|                             'content': content | ||||
|                         }) | ||||
|  | ||||
|             informations.extend(self.team.important_informations()) | ||||
|  | ||||
|         return informations | ||||
| @@ -315,19 +331,19 @@ class ParticipantRegistration(Registration): | ||||
|             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, | ||||
|                                             }) | ||||
|                                               { | ||||
|                                                   '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, | ||||
|                                         }) | ||||
|                                            { | ||||
|                                                'user': self.user, | ||||
|                                                'domain': site.domain, | ||||
|                                                'tournament': tournament, | ||||
|                                                'payment': payment, | ||||
|                                            }) | ||||
|             self.user.email_user(subject, message, html_message=html) | ||||
|  | ||||
|     class Meta: | ||||
| @@ -807,9 +823,9 @@ class Payment(models.Model): | ||||
|             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)) | ||||
|                                                   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)) | ||||
|                                                dict(registration=registration, payment=self, domain=site.domain)) | ||||
|                 registration.user.email_user(subject, message, html_message=html) | ||||
|  | ||||
|     def send_helloasso_payment_confirmation_mail(self): | ||||
| @@ -818,18 +834,18 @@ class Payment(models.Model): | ||||
|             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)) | ||||
|                                                   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)) | ||||
|                                                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)) | ||||
|                                                   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)) | ||||
|                                                dict(registration=payer_name, payment=self, domain=site.domain)) | ||||
|                 send_mail(subject, message, None, [payer['email']], html_message=html) | ||||
|  | ||||
|     def get_absolute_url(self): | ||||
|   | ||||
| @@ -66,7 +66,7 @@ Cochez la/les cases correspondantes.\\ | ||||
|  | ||||
| \fbox{\textcolor{white}{A}}  Autorise l'association Animath, \`a l'occasion du $\mathbb{TFJM}^2$ | ||||
| {% if tournament.unified_registration %} dans | ||||
| l'un des tournois d'Île-de-France (selon sélection : du 26 au 27 avril 2025, du 3 au 4 mai 2025, ou du 10 au 11 mai 2025) | ||||
| l'un des tournois d'Île-de-France (selon sélection : du 4 au 5 mai 2026, du 28 au 29 mars 2026, ou TBA 2026) | ||||
| {% else %} de | ||||
| {{ tournament.name }} du {{ tournament.date_start }} au {{ tournament.date_end }} à : {{ tournament.place }}, | ||||
| {% endif %} \`a | ||||
|   | ||||
| @@ -68,7 +68,7 @@ Cochez la/les cases correspondantes.\\ | ||||
|  | ||||
|  \fbox{\textcolor{white}{A}}  Autorise l'association Animath, \`a l'occasion du $\mathbb{TFJM}^2$ | ||||
|  {% if tournament.unified_registration %} dans | ||||
|  l'un des tournois d'Île-de-France (selon sélection : du 26 au 27 avril 2025, du 3 au 4 mai 2025, ou du 10 au 11 mai 2025) | ||||
|  l'un des tournois d'Île-de-France (selon sélection : du 4 au 5 mai 2026, du 28 au 29 mars 2026, ou TBA 2026) | ||||
|  {% else %} de | ||||
|  {{ tournament.name }} du {{ tournament.date_start }} au {{ tournament.date_end }} à : {{ tournament.place }}, | ||||
|  {% endif %} \`a | ||||
|   | ||||
| @@ -54,9 +54,9 @@ né\cdt{}e le {{ registration.birth_date|default:"\underline{\phantom{dd/mm/aaaa | ||||
| à participer au Tournoi Français des Jeunes Mathématiciennes et Mathématiciens ($\mathbb{TFJM}^2$) | ||||
| {% if tournament.unified_registration %} dans l'un des tournois d'Île-de-France selon sélection : | ||||
| \begin{itemize} | ||||
|     \item Île-de-France 1, du 26 au 27 avril 2025 ; | ||||
|     \item Île-de-France 2, du 3 au 4 mai 2025 ; | ||||
|     \item Île-de-France 3, du 10 au 11 mai 2025.  | ||||
|     \item Île-de-France 1, du 4 au 5 avril 2026 ; | ||||
|     \item Île-de-France 2, du 28 au 29 mars 2026 ; | ||||
|     \item Île-de-France 3, du TBA 2026.  | ||||
| \end{itemize} | ||||
| {% else %} | ||||
| organisé \`a : | ||||
| @@ -67,7 +67,7 @@ Iel se rendra au lieu indiqu\'e ci-dessus le samedi matin et quittera les lieux | ||||
| ses propres moyens et sous la responsabilité du/de la représentant\cdt{}e légal\cdt{}e. | ||||
|  | ||||
| {% if tournament.name == "Lyon" %} | ||||
| Un hébergement à titre gratuit sera organisée la nuit du 10 au 11 mai 2025. | ||||
| Un hébergement à titre gratuit sera organisée la nuit du {{ tournament.date_start }} au {{ tournament.date_end }}. | ||||
| Le/la participant\cdt{}e sera logé\cdt{}e soit dans les résidences de l'ENS de Lyon situées | ||||
| sur les campus de l'école soit dans l'hotel Ibis Gerland Mérieux situé 246 rue Marcel Mérieux – 69007 LYON. | ||||
| {% endif %} | ||||
|   | ||||
| @@ -1,5 +0,0 @@ | ||||
| {{ object.user.last_name }} | ||||
| {{ object.user.first_name }} | ||||
| {{ object.user.email }} | ||||
| {{ object.type }} | ||||
| {{ object.role }} | ||||
| @@ -1,11 +1,4 @@ | ||||
| {{ object.user.first_name }} | ||||
| {{ object.user.last_name }} | ||||
| {{ object.user.email }} | ||||
| {{ object.type }} | ||||
| {{ object.professional_activity }} | ||||
| {{ object.address }} | ||||
| {{ object.zip_code }} | ||||
| {{ object.city }} | ||||
| {{ object.phone_number }} | ||||
| {{ object.team.name }} | ||||
| {{ object.team.trigram }} | ||||
|   | ||||
| @@ -1,16 +1,7 @@ | ||||
| {{ object.user.first_name }} | ||||
| {{ object.user.last_name }} | ||||
| {{ object.user.email }} | ||||
| {{ object.type }} | ||||
| {{ object.get_student_class_display }} | ||||
| {{ object.school }} | ||||
| {{ object.birth_date }} | ||||
| {{ object.address }} | ||||
| {{ object.zip_code }} | ||||
| {{ object.city }} | ||||
| {{ object.phone_number }} | ||||
| {{ object.responsible_name }} | ||||
| {{ object.reponsible_phone }} | ||||
| {{ object.reponsible_email }} | ||||
| {{ object.team.name }} | ||||
| {{ object.team.trigram }} | ||||
|   | ||||
| @@ -1,5 +1,3 @@ | ||||
| {{ object.user.last_name }} | ||||
| {{ object.user.first_name }} | ||||
| {{ object.user.email }} | ||||
| {{ object.type }} | ||||
| {{ object.professional_activity }} | ||||
|   | ||||
| @@ -145,9 +145,9 @@ class AddOrganizerView(VolunteerMixin, CreateView): | ||||
|                                                                                     password=password, | ||||
|                                                                                     domain=site.domain)) | ||||
|             html = render_to_string('registration/mails/add_organizer.html', dict(user=registration.user, | ||||
|                                                                                 inviter=self.request.user, | ||||
|                                                                                 password=password, | ||||
|                                                                                 domain=site.domain)) | ||||
|                                                                                   inviter=self.request.user, | ||||
|                                                                                   password=password, | ||||
|                                                                                   domain=site.domain)) | ||||
|         registration.user.email_user(subject, message, html_message=html) | ||||
|  | ||||
|         if registration.is_admin: | ||||
| @@ -726,10 +726,11 @@ class PhotoAuthorizationView(LoginRequiredMixin, View): | ||||
|     def get(self, request, *args, **kwargs): | ||||
|         filename = kwargs["filename"] | ||||
|         path = f"media/authorization/photo/{filename}" | ||||
|         if not os.path.exists(path): | ||||
|         student_qs = ParticipantRegistration.objects.filter(Q(photo_authorization__endswith=filename) | ||||
|                                                             | Q(photo_authorization_final__endswith=filename)) | ||||
|         if not os.path.exists(path) or not student_qs.exists(): | ||||
|             raise Http404 | ||||
|         student = ParticipantRegistration.objects.get(Q(photo_authorization__endswith=filename) | ||||
|                                                       | Q(photo_authorization_final__endswith=filename)) | ||||
|         student = student_qs.get() | ||||
|         user = request.user | ||||
|         if not (student.user == user or user.registration.is_admin or user.registration.is_volunteer and student.team | ||||
|                 and student.team.participation.tournament in user.registration.organized_tournaments.all()): | ||||
|   | ||||
| @@ -1,29 +1,28 @@ | ||||
| channels[daphne]~=4.1.0 | ||||
| channels-redis~=4.2.0 | ||||
| crispy-bootstrap5~=2024.10 | ||||
| Django>=5.1.2,<6.0 | ||||
| django-crispy-forms~=2.3 | ||||
| django-extensions~=3.2.3 | ||||
| django-filter~=24.3 | ||||
| channels[daphne]~=4.3.1 | ||||
| channels-redis~=4.3.0 | ||||
| citric~=2.0.0 | ||||
| crispy-bootstrap5~=2025.6 | ||||
| Django>=5.2,<6.0 | ||||
| django-crispy-forms~=2.4 | ||||
| django-filter~=25.2 | ||||
| django-haystack~=3.3.0 | ||||
| django-mailer~=2.3.2 | ||||
| django-phonenumber-field~=8.0.0 | ||||
| django-pipeline~=3.1.0 | ||||
| django-polymorphic~=3.1.0 | ||||
| django-tables2~=2.7.0 | ||||
| djangorestframework~=3.15.2 | ||||
| django-phonenumber-field~=8.3.0 | ||||
| django-pipeline~=4.1.0 | ||||
| django-polymorphic~=4.1.0 | ||||
| django-tables2~=2.7.5 | ||||
| djangorestframework~=3.16.1 | ||||
| django-rest-polymorphic~=0.1.10 | ||||
| elasticsearch~=7.17.9 | ||||
| gspread~=6.1.4 | ||||
| gspread~=6.2.1 | ||||
| gunicorn~=23.0.0 | ||||
| odfpy~=1.4.1 | ||||
| pandas~=2.2.3 | ||||
| phonenumbers~=8.13.47 | ||||
| psycopg~=3.2.3 | ||||
| pypdf~=5.0.1 | ||||
| ipython~=8.28.0 | ||||
| pandas~=2.3.3 | ||||
| phonenumbers~=9.0.17 | ||||
| psycopg~=3.2.12 | ||||
| pypdf~=6.1.3 | ||||
| python-magic~=0.4.27 | ||||
| requests~=2.32.3 | ||||
| sympasoap~=1.1 | ||||
| uvicorn~=0.32.0 | ||||
| websockets~=13.1 | ||||
| requests~=2.32.5 | ||||
| sympasoap~=1.1.3 | ||||
| uvicorn~=0.38.0 | ||||
| websockets~=15.0.1 | ||||
							
								
								
									
										0
									
								
								survey/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								survey/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										13
									
								
								survey/admin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								survey/admin.py
									
									
									
									
									
										Normal 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',) | ||||
							
								
								
									
										11
									
								
								survey/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								survey/apps.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| # Copyright (C) 2025 by Animath | ||||
| # SPDX-License-Identifier: GPL-3.0-or-later | ||||
|  | ||||
| from django.apps import AppConfig | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
|  | ||||
|  | ||||
| class SurveyConfig(AppConfig): | ||||
|     default_auto_field = "django.db.models.BigAutoField" | ||||
|     name = "survey" | ||||
|     verbose_name = _("surveys") | ||||
							
								
								
									
										28
									
								
								survey/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								survey/forms.py
									
									
									
									
									
										Normal 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', | ||||
|             }), | ||||
|         } | ||||
							
								
								
									
										13
									
								
								survey/management/commands/fetch_survey_completion_data.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								survey/management/commands/fetch_survey_completion_data.py
									
									
									
									
									
										Normal 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() | ||||
							
								
								
									
										83
									
								
								survey/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								survey/migrations/0001_initial.py
									
									
									
									
									
										Normal 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", | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,53 @@ | ||||
| # Generated by Django 5.1.5 on 2025-03-19 22:51 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ( | ||||
|             "participation", | ||||
|             "0023_tournament_unified_registration", | ||||
|         ), | ||||
|         ("registration", "0014_participantregistration_country"), | ||||
|         ("survey", "0001_initial"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="survey", | ||||
|             name="completed_registrations", | ||||
|             field=models.ManyToManyField( | ||||
|                 blank=True, | ||||
|                 related_name="completed_surveys", | ||||
|                 to="registration.participantregistration", | ||||
|                 verbose_name="participants that completed the survey", | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name="survey", | ||||
|             name="completed_teams", | ||||
|             field=models.ManyToManyField( | ||||
|                 blank=True, | ||||
|                 related_name="completed_surveys", | ||||
|                 to="participation.team", | ||||
|                 verbose_name="teams that completed the survey", | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name="survey", | ||||
|             name="tournament", | ||||
|             field=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, | ||||
|                 related_name="surveys", | ||||
|                 to="participation.tournament", | ||||
|                 verbose_name="tournament restriction", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										0
									
								
								survey/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								survey/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										137
									
								
								survey/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								survey/models.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,137 @@ | ||||
| # 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, | ||||
|         related_name="surveys", | ||||
|         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, | ||||
|         blank=True, | ||||
|         related_name="completed_surveys", | ||||
|         verbose_name=_("participants that completed the survey"), | ||||
|     ) | ||||
|  | ||||
|     completed_teams = models.ManyToManyField( | ||||
|         Team, | ||||
|         blank=True, | ||||
|         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.order_by('participation__tournament__name', 'trigram').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.order_by('team__participation__tournament__name', 'team__trigram').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
									
								
							
							
						
						
									
										31
									
								
								survey/tables.py
									
									
									
									
									
										Normal 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',) | ||||
							
								
								
									
										87
									
								
								survey/templates/survey/survey_detail.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								survey/templates/survey/survey_detail.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | ||||
| {% 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 "tournament"|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> | ||||
|                         <td>{{ participant.participation.tournament.name }}</td> | ||||
|                     {% else %} | ||||
|                         <td>{{ participant.user.first_name }} {{ participant.user.last_name }} ({% trans "team" %} {{ participant.team.trigram }})</td> | ||||
|                         <td>{{ participant.team.participation.tournament.name }}</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 %} | ||||
							
								
								
									
										17
									
								
								survey/templates/survey/survey_form.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								survey/templates/survey/survey_form.html
									
									
									
									
									
										Normal 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 %} | ||||
							
								
								
									
										14
									
								
								survey/templates/survey/survey_list.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								survey/templates/survey/survey_list.html
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										3
									
								
								survey/tests.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| from django.test import TestCase | ||||
|  | ||||
| # Create your tests here. | ||||
							
								
								
									
										18
									
								
								survey/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								survey/urls.py
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										56
									
								
								survey/views.py
									
									
									
									
									
										Normal 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 | ||||
| @@ -1,9 +1,4 @@ | ||||
| # min   hour    day     month   weekday command | ||||
| # Send pending mails | ||||
| *       *       *       *       *       cd /code && python manage.py send_mail -c 1 | ||||
| *       *       *       *       *       cd /code && python manage.py retry_deferred -c 1 | ||||
| 0       0       *       *       *       cd /code && python manage.py purge_mail_log 7 -c 1 | ||||
|  | ||||
| # Update search index | ||||
| */2     *       *       *       *       cd /code && python manage.py update_index &> /dev/null | ||||
|  | ||||
| @@ -19,5 +14,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/* | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| # Copyright (C) 2020 by Animath | ||||
| # SPDX-License-Identifier: GPL-3.0-or-later | ||||
|  | ||||
| import os | ||||
| from django.conf import settings | ||||
|  | ||||
| _client = None | ||||
|  | ||||
| @@ -9,10 +9,10 @@ _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 | ||||
|   | ||||
| @@ -74,11 +74,11 @@ INSTALLED_APPS = [ | ||||
|     'draw', | ||||
|     'registration', | ||||
|     'participation', | ||||
|     'survey', | ||||
| ] | ||||
|  | ||||
| if "test" not in sys.argv:  # pragma: no cover | ||||
|     INSTALLED_APPS += [ | ||||
|         'django_extensions', | ||||
|         'mailer', | ||||
|     ] | ||||
|  | ||||
| @@ -213,6 +213,7 @@ STATICFILES_FINDERS = ( | ||||
|  | ||||
| PIPELINE = { | ||||
|     'DISABLE_WRAPPER': True, | ||||
|     'JS_COMPRESSOR': 'pipeline.compressors.uglifyjs.UglifyJSCompressor', | ||||
|     'JAVASCRIPT': { | ||||
|         'main': { | ||||
|             'source_filenames': ( | ||||
| @@ -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", | ||||
| @@ -379,14 +390,14 @@ if TFJM_APP == "TFJM": | ||||
|     ) | ||||
|  | ||||
|     PROBLEMS = [ | ||||
|         "Triominos", | ||||
|         "Rassemblements mathématiques", | ||||
|         "Tournoi de ping-pong", | ||||
|         "Dépollution de la Seine", | ||||
|         "Électron libre", | ||||
|         "Pièces truquées", | ||||
|         "Drôles de cookies", | ||||
|         "Création d'un jeu", | ||||
|         "Une bonne humeur contagieuse", | ||||
|         "Drôles de toboggans", | ||||
|         "Plats à tarte gradués", | ||||
|         "Transformation de papillons", | ||||
|         "Gerrymandering", | ||||
|         "Le cauchemar de la ligne 20-25", | ||||
|         "Taxes routières", | ||||
|         "Points colorés sur un cercle", | ||||
|     ] | ||||
| elif TFJM_APP == "ETEAM": | ||||
|     PREFERRED_LANGUAGE_CODE = 'en' | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| function initModal(target, url, content_id = 'form-content') { | ||||
| function initModal(target, url, content_id = 'form-content', always_refetch = false) { | ||||
|     document.querySelectorAll('[data-bs-target="#' + target + 'Modal"]') | ||||
|       .forEach(elem => elem.addEventListener('click', () => { | ||||
|         let modalBody = document.querySelector("#" + target + "Modal div.modal-body") | ||||
|  | ||||
|         if (!modalBody.innerHTML.trim()) { | ||||
|         if (!modalBody.innerHTML.trim() || always_refetch) { | ||||
|             if (url instanceof Function) url = url() | ||||
|  | ||||
|             fetch(url, {headers: {'CONTENT-ONLY': '1'}}) | ||||
|   | ||||
| @@ -106,7 +106,7 @@ | ||||
|         {% if user.is_authenticated and user.registration.is_admin %} | ||||
|             initModal("search", | ||||
|                 () => "{% url "haystack_search" %}?q=" + encodeURI(document.getElementById("search-term").value), | ||||
|                 "search-results") | ||||
|                 "search-results", true) | ||||
|         {% endif %} | ||||
|  | ||||
|         {% if not user.is_authenticated %} | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -23,7 +23,7 @@ | ||||
|         </div> | ||||
|  | ||||
|         <div id="sidebar-card" class="collapse d-lg-block"> | ||||
|             <div class="card-body"> | ||||
|             <div class="card-body px-2 py-1"> | ||||
|                 {% for information in user.registration.important_informations %} | ||||
|                     <div class="card my-2"> | ||||
|                         <div class="card-header bg-dark-subtle"> | ||||
|   | ||||
| @@ -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'), | ||||
|   | ||||
		Reference in New Issue
	
	Block a user