1
0
mirror of https://gitlab.com/animath/si/plateforme.git synced 2025-10-31 10:09:53 +01:00

Compare commits

..

36 Commits

Author SHA1 Message Date
Emmy D'Anello
8af11cd56f Clôture des listes Sympa 2025-10-30 20:00:26 +01:00
Emmy D'Anello
5c372f7582 Clôture des listes Sympa 2025-10-30 19:51:21 +01:00
Emmy D'Anello
bd230ccaf6 Utilisation de Python 3.13 par défaut, flake8-django pas encore supporté 2025-10-30 18:47:33 +01:00
Emmy D'Anello
46779488c1 Dates tournois franciliens 2025-10-30 18:39:48 +01:00
Emmy D'Anello
f49897cd5b Test sur les tirages au sort réparé 2025-10-30 18:34:20 +01:00
Emmy D'Anello
399e223b33 Mise à jour des dépendances + support Python 3.14 2025-10-30 18:01:30 +01:00
Emmy D'Anello
004d54cb67 Ajout de la mise à jour du dossier des Google Sheets de notes 2025-10-30 17:52:31 +01:00
Emmy D'Anello
8aec72d712 Correction mot Coefficient 2025-05-31 17:38:45 +02:00
Emmy D'Anello
6a521b6121 Noms des fichiers en français 2025-05-31 12:18:12 +02:00
Emmy D'Anello
62abfa94d6 Correction liens bandeau Informations pour la finale 2025-05-29 21:49:59 +02:00
Emmy D'Anello
952315ea4d Correction publication des notes pour le dernier tour 2025-05-05 10:28:22 +02:00
Emmy D'Anello
2e613799c9 Remplacement de yuglify par uglify, plus récent 2025-04-28 23:32:49 +02:00
Emmy D'Anello
08805a6360 Correction non-affichage des colonnes d'observation sans observateur 2025-04-28 22:44:08 +02:00
Emmy D'Anello
6841659e41 Plus de AdminRegistration à indexer 2025-04-28 22:14:40 +02:00
Emmy D'Anello
a84ffcf0a3 Bouton pour rendre les solutions accessibles pour le second tour en 1 clic 2025-04-28 22:01:26 +02:00
Emmy D'Anello
203fc3cd54 On n'affiche pas les paiements pour la finale sur la liste des paiements d'un tournoi régional 2025-04-28 20:43:36 +02:00
Emmy D'Anello
60f5236dee Affichage du tournoi dans la liste des réponses à un questionnaire 2025-04-28 20:35:23 +02:00
Emmy D'Anello
ab459ecc17 On n'affiche pas les données de l'équipe observatrice quand on a pas 2025-04-28 20:34:06 +02:00
Emmy D'Anello
7ad7659d78 Use solution number instead of passage index in scale sheets 2025-04-28 20:06:53 +02:00
Emmy D'Anello
84eb08ec46 Correction formulaire saisie notes s'il n'y a pas d'observateur⋅rice 2025-04-26 19:20:54 +02:00
Emmy D'Anello
3750828883 Send mails using the runmailer_pg command 2025-04-25 00:00:25 +02:00
Emmy D'Anello
ba36ad4071 Update coefficients 2025-04-24 21:57:52 +02:00
Emmy D'Anello
626433c464 Prevent some errors 2025-04-24 21:29:08 +02:00
Emmy D'Anello
032b67ac51 Don't generate spreadhseet if there is no team in a pool 2025-04-23 20:40:14 +02:00
Emmy D'Anello
f3bd479fdc Fix final sheet layout for 4-teams pools 2025-04-22 23:17:20 +02:00
Emmy D'Anello
bc06cf4903 Fix draw issues with translated strings 2025-04-22 22:58:12 +02:00
Emmy D'Anello
6d43c4b97e annulé != terminé 2025-04-22 20:59:53 +02:00
Emmy D'Anello
0499885fc8 Fix problem names for 2025 2025-04-22 20:20:22 +02:00
Emmy D'Anello
63c96ff2d2 Refetch search query when the input is updated 2025-04-22 19:52:07 +02:00
Emmy D'Anello
efeb2628ad Fix notation sheet when there is no observer 2025-04-22 19:44:21 +02:00
Emmy D'Anello
56aad288f4 Simplify elasticsearch index to make it work better 2025-04-22 19:19:22 +02:00
Emmy D'Anello
b33a69410a Bump dependencies for Django 5.2 2025-04-21 18:57:23 +02:00
Emmy D'Anello
0a80e03b58 Add Docker build in CI 2025-04-21 18:57:16 +02:00
Emmy D'Anello
73b94d5578 Remove default gender value
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2025-03-27 20:19:11 +01:00
Emmy D'Anello
97eea3b11a Add survey notification in the menu 2025-03-19 23:56:53 +01:00
Emmy D'Anello
702c8d8c9e Add survey feature 2025-03-19 23:18:45 +01:00
64 changed files with 1641 additions and 656 deletions

View File

@@ -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"

View File

@@ -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"]

View File

@@ -18,7 +18,7 @@
# -- Project information -----------------------------------------------------
project = 'Plateforme du TFJM²'
copyright = "2020-2024"
copyright = "2020-2026"
author = "Animath"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}",

View File

@@ -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

View File

@@ -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()))

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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',

View 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)

View File

@@ -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():

View File

@@ -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()
@@ -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:

View File

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

View File

@@ -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 %}$

View File

@@ -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 }}}

View File

@@ -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|}{{\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|}{{\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|}{{\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|}{{\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}

View File

@@ -23,45 +23,81 @@
<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>
{% 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>
<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>

View File

@@ -1,3 +1,2 @@
{{ object.name }}
{{ object.place }}
{{ object.description }}

View File

@@ -1,5 +0,0 @@
{{ object.link }}
{{ object.participation.team.name }}
{{ object.participation.team.trigram }}
{{ object.participation.problem }}
{{ object.participation.get_problem_display }}

View File

@@ -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(),

View File

@@ -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,6 +2164,7 @@ 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})"
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

View File

@@ -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

View File

@@ -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",
),
),
]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 %}

View File

@@ -1,5 +0,0 @@
{{ object.user.last_name }}
{{ object.user.first_name }}
{{ object.user.email }}
{{ object.type }}
{{ object.role }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -1,5 +1,3 @@
{{ object.user.last_name }}
{{ object.user.first_name }}
{{ object.user.email }}
{{ object.type }}
{{ object.professional_activity }}

View File

@@ -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):
raise Http404
student = ParticipantRegistration.objects.get(Q(photo_authorization__endswith=filename)
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 = 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()):

View File

@@ -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
View File

13
survey/admin.py Normal file
View File

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

11
survey/apps.py Normal file
View 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
View File

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

View File

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

View File

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

View File

@@ -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",
),
),
]

View File

137
survey/models.py Normal file
View 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
View File

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

View File

@@ -0,0 +1,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 %}

View File

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

View File

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

3
survey/tests.py Normal file
View File

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

18
survey/urls.py Normal file
View File

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

56
survey/views.py Normal file
View File

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

View File

@@ -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/*

View File

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

View File

@@ -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

View File

@@ -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'

View File

@@ -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'}})

View File

@@ -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 %}

View File

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

View File

@@ -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">

View File

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

View File

@@ -2,6 +2,7 @@
envlist =
py312
py313
py314
linters
skipsdist = True