1
0
mirror of https://gitlab.com/animath/si/plateforme.git synced 2025-06-22 18:38:29 +02:00

Compare commits

...

75 Commits

Author SHA1 Message Date
8aec72d712 Correction mot Coefficient 2025-05-31 17:38:45 +02:00
6a521b6121 Noms des fichiers en français 2025-05-31 12:18:12 +02:00
62abfa94d6 Correction liens bandeau Informations pour la finale 2025-05-29 21:49:59 +02:00
952315ea4d Correction publication des notes pour le dernier tour 2025-05-05 10:28:22 +02:00
2e613799c9 Remplacement de yuglify par uglify, plus récent 2025-04-28 23:32:49 +02:00
08805a6360 Correction non-affichage des colonnes d'observation sans observateur 2025-04-28 22:44:08 +02:00
6841659e41 Plus de AdminRegistration à indexer 2025-04-28 22:14:40 +02:00
a84ffcf0a3 Bouton pour rendre les solutions accessibles pour le second tour en 1 clic 2025-04-28 22:01:26 +02:00
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
60f5236dee Affichage du tournoi dans la liste des réponses à un questionnaire 2025-04-28 20:35:23 +02:00
ab459ecc17 On n'affiche pas les données de l'équipe observatrice quand on a pas 2025-04-28 20:34:06 +02:00
7ad7659d78 Use solution number instead of passage index in scale sheets 2025-04-28 20:06:53 +02:00
84eb08ec46 Correction formulaire saisie notes s'il n'y a pas d'observateur⋅rice 2025-04-26 19:20:54 +02:00
3750828883 Send mails using the runmailer_pg command 2025-04-25 00:00:25 +02:00
ba36ad4071 Update coefficients 2025-04-24 21:57:52 +02:00
626433c464 Prevent some errors 2025-04-24 21:29:08 +02:00
032b67ac51 Don't generate spreadhseet if there is no team in a pool 2025-04-23 20:40:14 +02:00
f3bd479fdc Fix final sheet layout for 4-teams pools 2025-04-22 23:17:20 +02:00
bc06cf4903 Fix draw issues with translated strings 2025-04-22 22:58:12 +02:00
6d43c4b97e annulé != terminé 2025-04-22 20:59:53 +02:00
0499885fc8 Fix problem names for 2025 2025-04-22 20:20:22 +02:00
63c96ff2d2 Refetch search query when the input is updated 2025-04-22 19:52:07 +02:00
efeb2628ad Fix notation sheet when there is no observer 2025-04-22 19:44:21 +02:00
56aad288f4 Simplify elasticsearch index to make it work better 2025-04-22 19:19:22 +02:00
b33a69410a Bump dependencies for Django 5.2 2025-04-21 18:57:23 +02:00
0a80e03b58 Add Docker build in CI 2025-04-21 18:57:16 +02:00
73b94d5578 Remove default gender value
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2025-03-27 20:19:11 +01:00
97eea3b11a Add survey notification in the menu 2025-03-19 23:56:53 +01:00
702c8d8c9e Add survey feature 2025-03-19 23:18:45 +01:00
ca0601fb24 Autorisation parentale particulière pour Lyon 2025-03-16 19:39:38 +01:00
d315c8371a Update Bootstrap to v5.3.3 and fix light mode hamburger button in chat 2025-03-09 13:08:58 +01:00
7488d3eae1 Ensure that all mails are translated 2025-03-09 12:35:04 +01:00
cfaf7c4287 Add API documentation link for GDrive notifications 2025-03-09 12:01:06 +01:00
e3c216e44e Update crons 2025-03-09 11:54:37 +01:00
73012bd61e Remove "new in 2025" section 2025-03-09 11:05:39 +01:00
bdf181e7e4 Use slugs for email addresses instead of lower names 2025-03-09 10:46:29 +01:00
c57ad854fe Add signature field in parental authorization templates 2025-03-05 20:01:29 +01:00
a2e5ab5f6a Fix participation form layout 2025-03-05 19:49:25 +01:00
758a2c9a00 Fix registration dates test 2025-03-05 19:41:09 +01:00
fb10df77e5 Allow admins to create users outside registration period 2025-03-05 18:57:01 +01:00
905b96fbcf Translate GDPR warning 2025-01-15 13:35:41 +01:00
be2e258948 Correction ETEAM => TFJM² 2025-01-15 13:31:05 +01:00
882570800c Revert "Update 2 files"
This reverts commit 1977ffdbc9.
2025-01-15 13:30:06 +01:00
df31968a77 Revert "Update 2 files"
This reverts commit 7c83ae8730.
2025-01-15 13:29:17 +01:00
df6fb3b3f3 Drop support of Python 3.11 2025-01-14 20:21:57 +01:00
3807fbcf45 Linting 2025-01-14 20:16:04 +01:00
8433390e19 Update authorization templates for unified registration 2025-01-14 20:14:49 +01:00
ec85f62ab6 Add unified registration for Île-de-France 2025-01-14 19:32:05 +01:00
74b2a0c095 Restauration des mails du TFJM²
This reverts commit 21d4ac9d8d.
2025-01-14 18:20:03 +01:00
67958335ab Fix year transitioning documentation
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-29 00:17:58 +01:00
20410cc17f Fix photo authorization export
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 23:53:44 +01:00
a5aff5ff21 Fix default storage
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 23:45:36 +01:00
196dbc8275 Delay registration opening by one week
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 23:42:59 +01:00
0847e5a308 Update Staticfiles storage for Django 5
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 23:40:13 +01:00
e5aa3ef059 Fix logo path
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 23:15:35 +01:00
e1b4e1bb6b Fix psycopg
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 22:57:40 +01:00
ecc59a6c8c Add documentation for year transitioning
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 22:24:11 +01:00
b053a47a19 Add export photo authorizations script
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 21:33:58 +01:00
ab2e49e8fb Add tests for registration ability outer registration dates
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 21:11:35 +01:00
fe399c869d Prevent registration when we are not between registration dates
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 20:21:02 +01:00
9de8a2ed0e Store registration dates
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-28 19:39:31 +01:00
d24f8cab16 Fix API router with newer version
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-21 19:40:19 +02:00
6cdf6331db Upgrade dependencies + add support for Python 3.13 and Django 5.1
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-21 19:36:08 +02:00
65c6158b52 TFJM² has not a single tournament
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-21 19:20:46 +02:00
4a5f48a834 Fix single tournament render
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-21 19:17:03 +02:00
4ab706d219 Fix TFJM_settings dictionary
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-21 19:09:24 +02:00
70f2be8b17 Linting
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-20 20:15:29 +02:00
4317947501 More ETEAM parametrization
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-10-20 20:13:49 +02:00
f327a4c9c4 Patch observer oral min note
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-11 10:27:52 +02:00
1b24e90635 Fix team reorder for 5-teams pools in draw recap
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-09 13:51:50 +02:00
338f0d456a Fix undo draw step
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-09 13:47:59 +02:00
2c4de8cec3 Adapt the random draw for the next rounds of ETEAM
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-09 13:26:39 +02:00
6b7d52c79b Fix the passage table with observers
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-09 12:44:04 +02:00
f398bedcf3 Fix upload review URL
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-07 08:53:40 +02:00
fdffe2331f Better notation sheets
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2024-07-07 00:01:24 +02:00
94 changed files with 2771 additions and 1079 deletions

View File

@ -1,25 +1,31 @@
stages: stages:
- test - test
- quality-assurance - quality-assurance
- build
- release
py311: variables:
stage: test CONTAINER_TEST_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
image: python:3.11-alpine CONTAINER_RELEASE_IMAGE: $CI_REGISTRY_IMAGE:latest
before_script:
- apk add --no-cache libmagic
- apk add --no-cache gettext git # Useful for django-haystack, remove when the newer versions are in PyPI
- pip install tox --no-cache-dir
script: tox -e py311
py312: py312:
stage: test stage: test
image: python:3.12-alpine image: python:3.12-alpine
before_script: before_script:
- apk add --no-cache libmagic - apk add --no-cache libmagic
- apk add --no-cache gettext git # Useful for django-haystack, remove when the newer versions are in PyPI - apk add --no-cache gettext
- pip install tox --no-cache-dir - pip install tox --no-cache-dir
script: tox -e py312 script: tox -e py312
py313:
stage: test
image: python:3.13-alpine
before_script:
- apk add --no-cache libmagic
- apk add --no-cache gettext
- pip install tox --no-cache-dir
script: tox -e py313
linters: linters:
stage: quality-assurance stage: quality-assurance
image: python:3-alpine image: python:3-alpine
@ -27,3 +33,29 @@ linters:
- pip install tox --no-cache-dir - pip install tox --no-cache-dir
script: tox -e linters script: tox -e linters
allow_failure: true 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

@ -1,14 +1,13 @@
FROM python:3.12-alpine FROM python:3.13-alpine
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
ENV DJANGO_ALLOW_ASYNC_UNSAFE 1 ENV DJANGO_ALLOW_ASYNC_UNSAFE 1
RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libxml2-dev libxslt-dev npm postgresql-dev libmagic texlive texmf-dist-fontsrecommended texmf-dist-lang texmf-dist-latexextra RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libpq-dev libxml2-dev libxslt-dev \
libmagic texlive texmf-dist-fontsrecommended texmf-dist-lang texmf-dist-latexextra uglify-js
RUN apk add --no-cache bash RUN apk add --no-cache bash
RUN npm install -g yuglify
RUN mkdir /code /code/docs RUN mkdir /code /code/docs
WORKDIR /code WORKDIR /code
COPY requirements.txt /code/requirements.txt COPY requirements.txt /code/requirements.txt
@ -36,4 +35,4 @@ RUN ln -s /code/.bashrc /root/.bashrc
ENTRYPOINT ["/code/entrypoint.sh"] ENTRYPOINT ["/code/entrypoint.sh"]
EXPOSE 80 EXPOSE 80
CMD ["./manage.py", "shell_plus", "--ipython"] CMD ["./manage.py", "shell"]

211
docs/dev/transition.rst Normal file
View File

@ -0,0 +1,211 @@
Transition d'années
===================
Entre deux sessions du TFJM², certaines opérations doivent être effectuées chaque année,
afin de réinitialiser les données et de passer à l'année suivante.
Réinitialisation de la base de données
--------------------------------------
Conservation des autorisations de droit à l'image
"""""""""""""""""""""""""""""""""""""""""""""""""
La base de données du TFJM² est supprimée chaque année, avant chaque tournoi. Il n'y a
pas de conservation de données personnelles à l'exception des autorisations de droit
à l'image qui doivent être conservées pour des raisons légales pendant 5 ans.
Elles doivent alors être stockées sur Owncloud. Pour cela, il faut commencer par créer
un dossier dans Owncloud, qui stockera lesdites autorisations.
Rendez-vous ensuite dans le conteneur Docker et exécuter le script :
.. code:: bash
./manage.py export_photo_authorizations
Cela a pour effet de générer un dossier dans ``output/photo_authorizations``, qui contient
un dossier par équipe avec les différentes autorisations de droit à l'image.
Il faut maintenant récupérer ce dossier. Sortir du conteneur, et exécuter dans ``/srv/TFJM`` :
.. code:: bash
sudo docker cp tfjm-inscription-1:/code/output/photo_authorizations .
sudo mv photo_authorizations/* "data/owncloud/data/Emmy/files/Autorisations de droit à l'image/Autorisations de droit à l'image 2024/"
sudo chown -R www-data:root "data/owncloud/data/Emmy/files/Autorisations de droit à l'image/Autorisations de droit à l'image 2024"
sudo rmdir photo_authorizations
Il faut enfin réactualiser Owncloud. Exécuter en tant que www-data :
.. code:: bash
sudo docker compose exec -u www-data cloud php occ files:scan Emmy
Vérifiez enfin que les fichiers sont bien accessibles dans l'interface Web.
Ne pas oublier enfin de partager le dossier.
Sauvegarde de secours
"""""""""""""""""""""
Si les données doivent être supprimées, il peut être utile de réaliser une sauvegarde à conserver
quelques mois.
.. danger::
Cette sauvegarde ne doit être faite qu'à des fins utiles et supprimée dès que plus nécessaire.
Sauvegardez alors le dossier ``/srv/TFJM/data/inscription/media`` et exportez la base de données :
.. code:: bash
sudo cp -r data/inscription/media data/inscription/media-2024
sudo docker compose exec -u postgres postgres pg_dump inscription_tfjm | sudo tee inscription_tfjm_bkp_2024.sql > /dev/null
Réinitialisation effective
""""""""""""""""""""""""""
Il est désormais possible de réinitialiser la base de données, après avoir éteint le serveur :
.. code:: bash
sudo docker compose stop inscription
sudo rm -r data/inscription/media/*
sudo docker compose exec -u postgres postgres dropdb inscription_tfjm
sudo docker compose exec -u postgres postgres createdb -O inscription_tfjm inscription_tfjm
Redémarrez enfin le serveur (les migrations seront créées automatiquement)
et créez un nouveau compte administrateur⋅rice :
.. code:: bash
sudo docker compose up -d inscription
sudo docker compose exec inscription bash
./manage.py createsuperuser
Vérifiez finalement le bon fonctionnement du site.
Sites Django
""""""""""""
Après avoir réinitialisé les données, il faut mettre à jour le site Django, qui permettra
d'avoir notamment des noms de domaine correct dans les mails envoyés.
Se connecter alors sur le site réouvert, puis dans la partie « Administration », chercher la
section « Sites » et modifier l'unique site présent. Vous pouvez ensuite effectuer les modifications
à réaliser.
Nouveaux paramètres pour la nouvelle année
------------------------------------------
Certains paramètres doivent être modifiés pour prendre en compte la nouvelle année.
Dates d'inscription
"""""""""""""""""""
Les inscriptions sont permises uniquement entre l'ouverture et la fermeture, afin d'éviter
d'avoir des personnes s'inscrivant en dehors du TFJM².
Pour cela, dans votre projet local, rendez-vous dans ``tfjm/settings.py`` et cherchez
le paramètre ``REGISTRATION_DATES`` (pour le TFJM²). Modifiez alors les sous-paramètres
``open`` et ``close`` pour définir les dates pendant lesquelles les inscriptions des
participant⋅es sont permises pour cette nouvelle année. Elles doivent être au format ISO.
Exemple pour l'année 2025 où les inscriptions ouvrent au 8 janvier midi pour fermer
le 2 mars à 22h :
.. code:: python
REGISTRATION_DATES = dict(
open=datetime.fromisoformat("2025-01-15T12:00:00+0100"),
close=datetime.fromisoformat("2025-03-02T22:00:00+0100"),
)
Il faudra ensuite commiter la modification et redémarrer le serveur pour que la modification
prenne effet.
Noms des problèmes
""""""""""""""""""
Toujours dans la configuration dans ``tfjm/settings.py``, la liste des problèmes doit être
modifiée pour que leurs noms s'affichent correctement lors du tirage au sort.
Cherchez le paramètre ``PROBLEMS`` et mettez alors à jour la liste, dans l'ordre, des noms
des problèmes.
À nouveau, il est nécessaire de commiter la modification et redémarrer le serveur.
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
« 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>`_
À la fin du tournoi
-------------------
Lorsque le tournoi est terminé, il faut récupérer les informations à stocker de façon pérenne,
notamment les solutions des équipes, les résultats ainsi que les autorisation de droit à l'image
comme indiqué précédemment.
Conservation des autorisations de droit à l'image
"""""""""""""""""""""""""""""""""""""""""""""""""
Se référer à la section plus haut.
Conservation des solutions des équipes
""""""""""""""""""""""""""""""""""""""
Le processus est très similaire à la conservation des autorisations de droit à l'image.
Il faut d'abord, dans le conteneur, lancer le script dédié pour récupérer les solutions
dans ``/code/output/solutions`` :
.. code:: bash
./manage.py export_solutions
On sort du conteneur et on récupère les solutions pour les déplacer dans Owncloud :
.. code:: bash
sudo docker cp tfjm-inscription-1:/code/output/solutions .
sudo mv solutions/* "data/owncloud/data/Emmy/files/Solutions écrites 2024/"
sudo chown -R www-data:root "data/owncloud/data/Emmy/files/Solutions écrites 2024"
sudo rmdir solutions
Il faut enfin réactualiser Owncloud. Exécuter en tant que www-data :
.. code:: bash
sudo docker compose exec -u www-data cloud php occ files:scan Emmy
Vérifiez enfin que les fichiers sont bien accessibles dans l'interface Web.
Ne pas oublier enfin de partager le dossier.
Génération de la page de résultats Wordpress
""""""""""""""""""""""""""""""""""""""""""""
Pour finir, il est possible de récupérer les notes pour chaque tournoi afin de générer
la page Wordpress dans la section *Éditions précédentes*.
Il suffit de lancer le script ``./manage.py export_results``, qui donne le texte brut pour
Wordpress à ajouter sur la page de l'édition qui vient de se terminer dans l'onglet
*Éditions précédentes*.
Pensez à bien inclure sur cette page le lien vers les problèmes de l'année, ainsi que le
lien vers le dossier partagé dans le Owncloud concernant les solutions des équipes.
Assurez-vous de mettre à jour la page *Éditions précédentes* afin d'inclure le lien vers
la page nouvellement créée.

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 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 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 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 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 à la poule suivante. Une fois que toutes les poules ont vu leurs problèmes

View File

@ -21,3 +21,4 @@ administrateur⋅rice.
dev/index dev/index
dev/install dev/install
dev/transition

View File

@ -224,7 +224,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Update user interface # Update user interface
await self.channel_layer.group_send(f"tournament-{self.tournament.id}", 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}", await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.set_info', {'tid': self.tournament_id, 'type': 'draw.set_info',
'info': await self.tournament.draw.ainformation()}) 'info': await self.tournament.draw.ainformation()})
@ -235,7 +235,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
await self.channel_layer.group_send(f"tournament-{self.tournament.id}", await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.notify', {'tid': self.tournament_id, 'type': 'draw.notify',
'title': 'Tirage au sort du TFJM²', '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)}) .format(tournament=self.tournament.name)})
async def draw_start(self, content) -> None: async def draw_start(self, content) -> None:
@ -405,15 +405,15 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
await self.channel_layer.group_send( await self.channel_layer.group_send(
f"team-{dup.participation.team.trigram}", f"team-{dup.participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify', 'title': 'Tirage au sort du TFJM²', {'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. " 'body': str(_("Your dice score is identical to the one of one or multiple teams. "
"Please relaunch it.")} "Please relaunch it."))}
) )
# Alert the tournament # Alert the tournament
await self.channel_layer.group_send( await self.channel_layer.group_send(
f"tournament-{self.tournament.id}", f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.alert', {'tid': self.tournament_id, 'type': 'draw.alert',
'message': _('Dices from teams {teams} are identical. Please relaunch your dices.').format( 'message': str(_('Dices from teams {teams} are identical. Please relaunch your dices.').format(
teams=', '.join(td.participation.team.trigram for td in dups)), teams=', '.join(td.participation.team.trigram for td in dups))),
'alert_type': 'warning'}) 'alert_type': 'warning'})
error = True error = True
@ -537,7 +537,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
async for next_round in self.tournament.draw.round_set.filter(number__gte=2).all(): 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}", await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.send_poules', {'tid': self.tournament_id, 'type': 'draw.send_poules',
'round': r.number, 'round': next_round.number,
'poules': [ 'poules': [
{ {
'letter': pool.get_letter_display(), 'letter': pool.get_letter_display(),
@ -612,8 +612,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Notify the team that it can draw a problem # Notify the team that it can draw a problem
await self.channel_layer.group_send(f"team-{tds[0].participation.team.trigram}", await self.channel_layer.group_send(f"team-{tds[0].participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify', {'tid': self.tournament_id, 'type': 'draw.notify',
'title': _("Your turn!"), 'title': str(_("Your turn!")),
'body': _("It's your turn to draw a problem!")}) 'body': str(_("It's your turn to draw a problem!"))})
async def select_problem(self, **kwargs): async def select_problem(self, **kwargs):
""" """
@ -752,8 +752,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Notify the team that it can draw a problem # Notify the team that it can draw a problem
await self.channel_layer.group_send(f"team-{new_trigram}", await self.channel_layer.group_send(f"team-{new_trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify', {'tid': self.tournament_id, 'type': 'draw.notify',
'title': _("Your turn!"), 'title': str(_("Your turn!")),
'body': _("It's your turn to draw a problem!")}) 'body': str(_("It's your turn to draw a problem!"))})
else: else:
# Pool is ended # Pool is ended
await self.end_pool(pool) await self.end_pool(pool)
@ -829,8 +829,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Notify the team that it can draw a dice # Notify the team that it can draw a dice
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}", await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify', {'tid': self.tournament_id, 'type': 'draw.notify',
'title': _("Your turn!"), 'title': str(_("Your turn!")),
'body': _("It's your turn to launch the dice!")}) 'body': str(_("It's your turn to launch the dice!"))})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}", await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.dice_visibility', {'tid': self.tournament_id, 'type': 'draw.dice_visibility',
@ -863,8 +863,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Notify the team that it can draw a dice # Notify the team that it can draw a dice
await self.channel_layer.group_send(f"team-{participation.team.trigram}", await self.channel_layer.group_send(f"team-{participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify', {'tid': self.tournament_id, 'type': 'draw.notify',
'title': _("Your turn!"), 'title': str(_("Your turn!")),
'body': _("It's your turn to launch the dice!")}) 'body': str(_("It's your turn to launch the dice!"))})
# Reorder dices # Reorder dices
await self.channel_layer.group_send(f"tournament-{self.tournament.id}", await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
@ -891,7 +891,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}", await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.dice_visibility', {'tid': self.tournament_id, 'type': 'draw.dice_visibility',
'visible': True}) 'visible': True})
elif r.number == 1 and (self.tournament.final or settings.TFJM_APP == "ETEAM"): elif r.number == 1 and (self.tournament.final or not settings.HAS_FINAL):
# For the final tournament, we wait for a manual update between the two rounds. # For the final tournament, we wait for a manual update between the two rounds.
msg += "<br><br>" + _("The draw of the first round is ended.") msg += "<br><br>" + _("The draw of the first round is ended.")
self.tournament.draw.last_message = msg self.tournament.draw.last_message = msg
@ -988,8 +988,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Notify the team that it can draw a problem # Notify the team that it can draw a problem
await self.channel_layer.group_send(f"team-{new_trigram}", await self.channel_layer.group_send(f"team-{new_trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify', {'tid': self.tournament_id, 'type': 'draw.notify',
'title': _("Your turn!"), 'title': str(_("Your turn!")),
'body': _("It's your turn to draw a problem!")}) 'body': str(_("It's your turn to draw a problem!"))})
@ensure_orga @ensure_orga
async def export(self, **kwargs): async def export(self, **kwargs):
@ -1021,23 +1021,28 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
if not await Draw.objects.filter(tournament=self.tournament).aexists(): if not await Draw.objects.filter(tournament=self.tournament).aexists():
return await self.alert(_("The draw has not started yet."), 'danger') return await self.alert(_("The draw has not started yet."), 'danger')
if not self.tournament.final: if not self.tournament.final and settings.TFJM_APP == "TFJM":
return await self.alert(_("This is only available for the final tournament."), 'danger') return await self.alert(_("This is only available for the final tournament."), 'danger')
r2 = await self.tournament.draw.round_set.filter(number=2).aget() r2 = await self.tournament.draw.round_set.filter(number=self.tournament.draw.current_round.number + 1).aget()
self.tournament.draw.current_round = r2 self.tournament.draw.current_round = r2
msg = _("The draw of the round 2 is starting. " if settings.TFJM_APP == "TFJM":
msg = str(_("The draw of the round {round} is starting. "
"The passage order is determined from the ranking of the first round, " "The passage order is determined from the ranking of the first round, "
"in order to mix the teams between the two days.") "in order to mix the teams between the two days.").format(round=r2.number))
else:
msg = str(_("The draw of the round {round} is starting. "
"The passage order is another time randomly drawn.").format(round=r2.number))
self.tournament.draw.last_message = msg self.tournament.draw.last_message = msg
await self.tournament.draw.asave() await self.tournament.draw.asave()
# Send notification to everyone # Send notification to everyone
await self.channel_layer.group_send(f"tournament-{self.tournament.id}", await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.notify', {'tid': self.tournament_id, 'type': 'draw.notify',
'title': _("Draw") + " " + settings.APP_NAME, 'title': str(_("Draw")) + " " + settings.APP_NAME,
'body': _("The draw of the second round is starting!")}) 'body': str(_("The draw of the second round is starting!"))})
if settings.TFJM_APP == "TFJM":
# Set the first pool of the second round as the active pool # Set the first pool of the second round as the active pool
pool = await Pool.objects.filter(round=self.tournament.draw.current_round, letter=1).aget() pool = await Pool.objects.filter(round=self.tournament.draw.current_round, letter=1).aget()
r2.current_pool = pool r2.current_pool = pool
@ -1078,6 +1083,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
f"tournament-{self.tournament.id}", f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.dice', 'team': participation.team.trigram, 'result': None}) {'tid': self.tournament_id, 'type': 'draw.dice', 'team': participation.team.trigram, 'result': None})
if settings.TFJM_APP == "TFJM":
async for td in r2.current_pool.team_draws.prefetch_related('participation__team'): async for td in r2.current_pool.team_draws.prefetch_related('participation__team'):
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}", await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.dice_visibility', {'tid': self.tournament_id, 'type': 'draw.dice_visibility',
@ -1086,8 +1092,13 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Notify the team that it can draw a problem # Notify the team that it can draw a problem
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}", await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify', {'tid': self.tournament_id, 'type': 'draw.notify',
'title': _("Your turn!"), 'title': str(_("Your turn!")),
'body': _("It's your turn to draw a problem!")}) '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}",
{'tid': self.tournament_id, 'type': 'draw.dice_visibility',
'visible': True})
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}", await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.dice_visibility', {'tid': self.tournament_id, 'type': 'draw.dice_visibility',
@ -1102,7 +1113,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
await self.channel_layer.group_send(f"tournament-{self.tournament.id}", await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.set_active', {'tid': self.tournament_id, 'type': 'draw.set_active',
'round': r2.number, 'round': r2.number,
'pool': r2.current_pool.get_letter_display()}) 'pool': r2.current_pool.get_letter_display() if r2.current_pool else None})
@ensure_orga @ensure_orga
async def cancel_last_step(self, **kwargs): async def cancel_last_step(self, **kwargs):
@ -1376,7 +1387,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
'round': r.number, 'round': r.number,
'team': td.participation.team.trigram, 'team': td.participation.team.trigram,
'problem': td.accepted}) 'problem': td.accepted})
elif r.number >= 2: elif r.number >= 2 and settings.TFJM_APP == "TFJM":
if not self.tournament.final: if not self.tournament.final:
# Go to the previous round # Go to the previous round
previous_round = await self.tournament.draw.round_set \ previous_round = await self.tournament.draw.round_set \
@ -1390,21 +1401,6 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
'team': td.participation.team.trigram, 'team': td.participation.team.trigram,
'result': td.choice_dice}) 'result': td.choice_dice})
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}",
{
'tid': self.tournament_id,
'type': 'draw.send_poules',
'round': previous_round.number,
'poules': [
{
'letter': pool.get_letter_display(),
'teams': await pool.atrigrams(),
}
async for pool in previous_round.pool_set.order_by('letter').all()
]
})
previous_pool = previous_round.current_pool previous_pool = previous_round.current_pool
td = previous_pool.current_team td = previous_pool.current_team
@ -1468,8 +1464,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
'visible': True}) 'visible': True})
else: else:
# Go to the dice order # Go to the dice order
async for r0 in self.tournament.draw.round_set.all(): async for td in r.teamdraw_set.all():
async for td in r0.teamdraw_set.all():
td.pool = None td.pool = None
td.passage_index = None td.passage_index = None
td.choose_index = None td.choose_index = None
@ -1479,6 +1474,21 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
r.current_pool = None r.current_pool = None
await r.asave() await r.asave()
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}",
{
'tid': self.tournament_id,
'type': 'draw.send_poules',
'round': r.number,
'poules': [
{
'letter': pool.get_letter_display(),
'teams': await pool.atrigrams(),
}
async for pool in r.pool_set.order_by('letter').all()
]
})
round_tds = {td.id: td async for td in r.team_draws.prefetch_related('participation__team')} round_tds = {td.id: td async for td in r.team_draws.prefetch_related('participation__team')}
# Reset the last dice # Reset the last dice
@ -1548,8 +1558,45 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
'team': last_td.participation.team.trigram, 'team': last_td.participation.team.trigram,
'result': None}) 'result': None})
break break
else: elif r.number == 1:
# Cancel the draw if it is the first round
await self.abort() await self.abort()
else:
# Go back to the first round after resetting all
previous_round = await self.tournament.draw.round_set \
.prefetch_related('current_pool__current_team__participation__team').aget(number=r.number - 1)
self.tournament.draw.current_round = previous_round
await self.tournament.draw.asave()
async for td in previous_round.team_draws.prefetch_related('participation__team').all():
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}", {'tid': self.tournament_id, 'type': 'draw.dice',
'team': td.participation.team.trigram,
'result': td.choice_dice})
previous_pool = previous_round.current_pool
td = previous_pool.current_team
td.purposed = td.accepted
td.accepted = None
await td.asave()
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.dice_visibility',
'visible': False})
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.buttons_visibility',
'visible': True})
await self.channel_layer.group_send(f"volunteer-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.buttons_visibility',
'visible': True})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.set_problem',
'round': previous_round.number,
'team': td.participation.team.trigram,
'problem': td.accepted})
async def draw_alert(self, content): async def draw_alert(self, content):
""" """

View File

@ -0,0 +1,27 @@
# Generated by Django 5.0.6 on 2024-07-09 11:07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("draw", "0005_alter_round_number_alter_teamdraw_accepted_and_more"),
]
operations = [
migrations.AlterField(
model_name="round",
name="current_pool",
field=models.ForeignKey(
default=None,
help_text="The current pool where teams select their problems.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="draw.pool",
verbose_name="current pool",
),
),
]

View File

@ -82,7 +82,7 @@ class Draw(models.Model):
elif self.current_round.current_pool.current_team is None: elif self.current_round.current_pool.current_team is None:
return 'DICE_ORDER_POULE' return 'DICE_ORDER_POULE'
elif self.current_round.current_pool.current_team.accepted is not None: elif self.current_round.current_pool.current_team.accepted is not None:
if self.current_round.number == 1: if self.current_round.number < settings.NB_ROUNDS:
# The last step can be the last problem acceptation after the first round # The last step can be the last problem acceptation after the first round
# only for the final between the two rounds # only for the final between the two rounds
return 'WAITING_FINAL' return 'WAITING_FINAL'
@ -163,7 +163,7 @@ class Draw(models.Model):
"\"My participation\".") "\"My participation\".")
s += "<br><br>" if s else "" s += "<br><br>" if s else ""
rules_link = "https://tfjm.org/reglement" if settings.TFJM_APP == "TFJM" else "https://eteam.tfjm.org/rules/" rules_link = settings.RULES_LINK
s += _("For more details on the draw, the rules are available on " s += _("For more details on the draw, the rules are available on "
"<a class=\"alert-link\" href=\"{link}\">{link}</a>.").format(link=rules_link) "<a class=\"alert-link\" href=\"{link}\">{link}</a>.").format(link=rules_link)
return s return s
@ -205,7 +205,7 @@ class Round(models.Model):
current_pool = models.ForeignKey( current_pool = models.ForeignKey(
'Pool', 'Pool',
on_delete=models.CASCADE, on_delete=models.SET_NULL,
null=True, null=True,
default=None, default=None,
related_name='+', related_name='+',
@ -419,7 +419,7 @@ class Pool(models.Model):
reporter = tds[line[0]].participation reporter = tds[line[0]].participation
opponent = tds[line[1]].participation opponent = tds[line[1]].participation
reviewer = tds[line[2]].participation reviewer = tds[line[2]].participation
observer = tds[line[3]].participation if self.size >= 4 and settings.TFJM_APP == "ETEAM" else None observer = tds[line[3]].participation if self.size >= 4 and settings.HAS_OBSERVER else None
# Create the passage # Create the passage
await Passage.objects.acreate( await Passage.objects.acreate(

View File

@ -4,8 +4,8 @@
await Notification.requestPermission() await Notification.requestPermission()
})() })()
// TODO ETEAM Mieux paramétriser (5 pour le TFJM², 6 pour l'ETEAM) const TFJM = JSON.parse(document.getElementById('TFJM_settings').textContent)
const RECOMMENDED_SOLUTIONS_COUNT = 6 const RECOMMENDED_SOLUTIONS_COUNT = TFJM.RECOMMENDED_SOLUTIONS_COUNT
const problems_count = JSON.parse(document.getElementById('problems_count').textContent) const problems_count = JSON.parse(document.getElementById('problems_count').textContent)
@ -221,9 +221,10 @@ document.addEventListener('DOMContentLoaded', () => {
elem.innerText = `${trigram} 🎲 ${result}` elem.innerText = `${trigram} 🎲 ${result}`
} }
let nextTeam = document.querySelector(` div[id="dices-${tid}"] > div > div[class*="text-bg-warning"]`).getAttribute("data-team") let nextTeamDiv = document.querySelector(` div[id="dices-${tid}"] > div > div[class*="text-bg-warning"]`)
if (nextTeam) { if (nextTeamDiv) {
// If there is one team that does not have launched its dice, then we update the debug section // 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`) let debugSpan = document.getElementById(`debug-dice-${tid}-team`)
if (debugSpan) if (debugSpan)
debugSpan.innerText = nextTeam debugSpan.innerText = nextTeam
@ -700,6 +701,9 @@ document.addEventListener('DOMContentLoaded', () => {
let problem = problems[i] let problem = problems[i]
setProblemAccepted(tid, round, team, problem) setProblemAccepted(tid, round, team, problem)
let recapTeam = document.getElementById(`recap-${tid}-round-${round}-team-${team}`)
recapTeam.style.order = i.toString()
} }
} }

View File

@ -176,7 +176,7 @@
📁 {% trans "Export" %} 📁 {% trans "Export" %}
</button> </button>
</div> </div>
{% if tournament.final %} {% if tournament.final or not TFJM.HAS_FINAL %}
{# Volunteers can continue the second round for the final tournament #} {# Volunteers can continue the second round for the final tournament #}
<div id="continue-{{ tournament.id }}" <div id="continue-{{ tournament.id }}"
class="card-footer text-center{% if tournament.draw.get_state != 'WAITING_FINAL' %} d-none{% endif %}"> class="card-footer text-center{% if tournament.draw.get_state != 'WAITING_FINAL' %} d-none{% endif %}">
@ -322,21 +322,21 @@
{% elif pool.size == 4 %} {% elif pool.size == 4 %}
{% if forloop.counter == 1 %} {% if forloop.counter == 1 %}
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
<td class="text-center">{% if TFJM.APP == "ETEAM" %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td> <td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
{% elif forloop.counter == 2 %} {% elif forloop.counter == 2 %}
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
<td class="text-center">{% if TFJM.APP == "ETEAM" %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td> <td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
{% elif forloop.counter == 3 %} {% elif forloop.counter == 3 %}
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
<td class="text-center">{% if TFJM.APP == "ETEAM" %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td> <td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
{% elif forloop.counter == 4 %} {% elif forloop.counter == 4 %}
<td class="text-center">{% if TFJM.APP == "ETEAM" %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td> <td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
@ -344,33 +344,33 @@
{% elif pool.size == 5 %} {% elif pool.size == 5 %}
{% if forloop.counter == 1 %} {% if forloop.counter == 1 %}
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
<td class="text-center"></td> <td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
<td class="text-center">{% if TFJM.APP == "ETEAM" %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
{% elif forloop.counter == 2 %}
<td class="text-center">{% if TFJM.APP == "ETEAM" %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
<td class="text-center"></td> <td class="text-center"></td>
{% elif forloop.counter == 2 %}
<td class="text-center"></td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
{% elif forloop.counter == 3 %} {% elif forloop.counter == 3 %}
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
<td class="text-center">{% if TFJM.APP == "ETEAM" %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
<td class="text-center"></td> <td class="text-center"></td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
<td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
{% elif forloop.counter == 4 %} {% elif forloop.counter == 4 %}
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
<td class="text-center">{% if TFJM.APP == "ETEAM" %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td> <td class="text-center"></td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
<td class="text-center"></td> <td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
{% elif forloop.counter == 5 %} {% elif forloop.counter == 5 %}
<td class="text-center"></td> <td class="text-center">{% if TFJM.HAS_OBSERVER %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td>
<td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Rev" context "Role abbreviation" %}</td>
<td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Opp" context "Role abbreviation" %}</td>
<td class="text-center">{% if TFJM.APP == "ETEAM" %}{% trans "Obs" context "Role abbreviation" %}{% endif %}</td> <td class="text-center"></td>
<td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td> <td class="text-center">{% trans "Rep" context "Role abbreviation" %}</td>
{% endif %} {% endif %}
{% endif %} {% endif %}

View File

@ -4,6 +4,7 @@ crond -l 0
python manage.py migrate python manage.py migrate
python manage.py update_index python manage.py update_index
python manage.py runmailer_pg &
nginx nginx

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,9 @@
# Copyright (C) 2020 by Animath # Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import Note, Participation, Passage, Pool, Solution, Team, Tournament, Tweak, WrittenReview from .models import Note, Participation, Passage, Pool, Solution, Team, Tournament, Tweak, WrittenReview
@ -51,9 +53,14 @@ class PassageInline(admin.TabularInline):
model = Passage model = Passage
extra = 0 extra = 0
ordering = ('position',) ordering = ('position',)
autocomplete_fields = ('reporter', 'opponent', 'reviewer', 'observer',)
show_change_link = True 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): class NoteInline(admin.TabularInline):
model = Note model = Note
@ -113,12 +120,9 @@ class PoolAdmin(admin.ModelAdmin):
@admin.register(Passage) @admin.register(Passage)
class PassageAdmin(admin.ModelAdmin): 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',) list_filter = ('pool__tournament', 'pool__round', 'pool__letter', 'solution_number',)
search_fields = ('pool__participations__team__name', 'pool__participations__team__trigram',) search_fields = ('pool__participations__team__name', 'pool__participations__team__trigram',)
ordering = ('pool__tournament', 'pool__round', 'pool__letter', 'position',) ordering = ('pool__tournament', 'pool__round', 'pool__letter', 'position',)
autocomplete_fields = ('pool', 'reporter', 'opponent', 'reviewer', 'observer',)
inlines = (NoteInline,) inlines = (NoteInline,)
@admin.display(description=_("reporter"), ordering='reporter__team__trigram') @admin.display(description=_("reporter"), ordering='reporter__team__trigram')
@ -135,7 +139,7 @@ class PassageAdmin(admin.ModelAdmin):
@admin.display(description=_("observer"), ordering='observer__team__trigram') @admin.display(description=_("observer"), ordering='observer__team__trigram')
def observer_trigram(self, record: Passage): 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') @admin.display(description=_("pool"), ordering='pool__letter')
def pool_abbr(self, record): def pool_abbr(self, record):
@ -145,15 +149,23 @@ class PassageAdmin(admin.ModelAdmin):
def tournament(self, record: Passage): def tournament(self, record: Passage):
return record.pool.tournament 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) @admin.register(Note)
class NoteAdmin(admin.ModelAdmin): 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',) search_fields = ('jury__user__last_name', 'jury__user__first_name', 'passage__reporter__team__trigram',)
autocomplete_fields = ('jury', 'passage',) autocomplete_fields = ('jury', 'passage',)
@ -161,6 +173,21 @@ class NoteAdmin(admin.ModelAdmin):
def pool(self, record): def pool(self, record):
return record.passage.pool.short_name 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) @admin.register(Solution)
class SolutionAdmin(admin.ModelAdmin): class SolutionAdmin(admin.ModelAdmin):

View File

@ -3,6 +3,7 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.db.models.signals import post_save, pre_save from django.db.models.signals import post_save, pre_save
from django.utils.translation import gettext_lazy as _
class ParticipationConfig(AppConfig): class ParticipationConfig(AppConfig):
@ -10,6 +11,7 @@ class ParticipationConfig(AppConfig):
The participation app contains the data about the teams, solutions, ... The participation app contains the data about the teams, solutions, ...
""" """
name = 'participation' name = 'participation'
verbose_name = _("participations")
def ready(self): def ready(self):
from participation import signals from participation import signals

View File

@ -5,8 +5,9 @@ from io import StringIO
import re import re
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Div, Field, Submit from crispy_forms.layout import Div, Field, HTML, Layout, Submit
from django import forms from django import forms
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator from django.core.validators import FileExtensionValidator
@ -14,7 +15,6 @@ from django.utils.translation import gettext_lazy as _
import pandas import pandas
from pypdf import PdfReader from pypdf import PdfReader
from registration.models import VolunteerRegistration from registration.models import VolunteerRegistration
from tfjm import settings
from .models import Note, Participation, Passage, Pool, Solution, Team, Tournament, WrittenReview from .models import Note, Participation, Passage, Pool, Solution, Team, Tournament, WrittenReview
@ -77,9 +77,30 @@ class ParticipationForm(forms.ModelForm):
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if settings.TFJM_APP == "ETEAM": if settings.SINGLE_TOURNAMENT:
# One single tournament only
del self.fields['tournament'] del self.fields['tournament']
self.helper = FormHelper()
idf_warning_banner = f"""
<div class=\"alert alert-warning\">
<h5 class=\"alert-heading\">{_("IMPORTANT")}</h4>
{_("""For the tournaments in the region "Île-de-France": registration is
unified for each tournament. By choosing a tournament "Île-de-France",
you're accepting that your team may be selected for one of these tournaments.
In case of date conflict, please write them in your motivation letter.""")}
</div>
"""
unified_registration_tournament_ids = ",".join(
str(tournament.id) for tournament in Tournament.objects.filter(
unified_registration=True).all())
self.helper.layout = Layout(
'tournament',
Div(
HTML(idf_warning_banner),
css_id="idf_warning_banner",
data_tid_unified=unified_registration_tournament_ids,
),
'final',
)
class Meta: class Meta:
model = Participation model = Participation
@ -384,6 +405,12 @@ class WrittenReviewForm(forms.ModelForm):
class NoteForm(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: class Meta:
model = Note model = Note
fields = ('reporter_writing', 'reporter_oral', 'opponent_writing', fields = ('reporter_writing', 'reporter_oral', 'opponent_writing',

View File

@ -11,10 +11,12 @@ from participation.models import Solution, Tournament
class Command(BaseCommand): class Command(BaseCommand):
def handle(self, *args, **kwargs): def handle(self, *args, **kwargs):
activate(settings.PROBLEMS) activate(settings.PREFERRED_LANGUAGE_CODE)
base_dir = Path(__file__).parent.parent.parent.parent base_dir = Path(__file__).parent.parent.parent.parent
base_dir /= "output" base_dir /= "output"
if not base_dir.is_dir():
base_dir.mkdir()
base_dir /= "solutions"
if not base_dir.is_dir(): if not base_dir.is_dir():
base_dir.mkdir() base_dir.mkdir()
base_dir /= "Par équipe" base_dir /= "Par équipe"

View File

@ -3,6 +3,7 @@
from django.conf import settings from django.conf import settings
from django.core.management import BaseCommand from django.core.management import BaseCommand
from django.db.models import Q from django.db.models import Q
from django.template.defaultfilters import slugify
from participation.models import Team, Tournament from participation.models import Team, Tournament
from registration.models import ParticipantRegistration, VolunteerRegistration from registration.models import ParticipantRegistration, VolunteerRegistration
from tfjm.lists import get_sympa_client from tfjm.lists import get_sympa_client
@ -36,7 +37,7 @@ class Command(BaseCommand):
"education", raise_error=False) "education", raise_error=False)
for tournament in Tournament.objects.all(): for tournament in Tournament.objects.all():
slug = tournament.name.lower().replace(" ", "-") slug = slugify(tournament.name)
sympa.create_list(f"equipes-{slug}", f"Equipes du tournoi {tournament.name}", "hotline", sympa.create_list(f"equipes-{slug}", f"Equipes du tournoi {tournament.name}", "hotline",
f"Liste de diffusion pour contacter toutes les equipes du tournoi {tournament.name}" f"Liste de diffusion pour contacter toutes les equipes du tournoi {tournament.name}"
" du TFJM2.", "education", raise_error=False) " du TFJM2.", "education", raise_error=False)
@ -54,7 +55,7 @@ class Command(BaseCommand):
for team in Team.objects.filter(participation__valid=True).all(): for team in Team.objects.filter(participation__valid=True).all():
team.create_mailing_list() team.create_mailing_list()
sympa.unsubscribe(team.email, "equipes-non-valides", True) sympa.unsubscribe(team.email, "equipes-non-valides", True)
sympa.subscribe(team.email, f"equipes-{team.participation.tournament.name.lower().replace(' ', '-')}", sympa.subscribe(team.email, f"equipes-{slugify(team.participation.tournament.name)}",
True, f"Equipe {team.name}") True, f"Equipe {team.name}")
for team in Team.objects.filter(Q(participation__valid=False) | Q(participation__valid__isnull=True)).all(): for team in Team.objects.filter(Q(participation__valid=False) | Q(participation__valid__isnull=True)).all():
@ -62,16 +63,16 @@ class Command(BaseCommand):
sympa.subscribe(team.email, "equipes-non-valides", True, f"Equipe {team.name}") sympa.subscribe(team.email, "equipes-non-valides", True, f"Equipe {team.name}")
for participant in ParticipantRegistration.objects.filter(team__isnull=False).all(): for participant in ParticipantRegistration.objects.filter(team__isnull=False).all():
sympa.subscribe(participant.user.email, f"equipe-{participant.team.trigram.lower()}", sympa.subscribe(participant.user.email, f"equipe-{slugify(participant.team.trigram)}",
True, f"{participant}") True, f"{participant}")
for volunteer in VolunteerRegistration.objects.all(): for volunteer in VolunteerRegistration.objects.all():
for organized_tournament in volunteer.organized_tournaments.all(): for organized_tournament in volunteer.organized_tournaments.all():
slug = organized_tournament.name.lower().replace(" ", "-") slug = slugify(organized_tournament.name)
sympa.subscribe(volunteer.user.email, f"organisateurs-{slug}", True) sympa.subscribe(volunteer.user.email, f"organisateurs-{slug}", True)
for jury_in in volunteer.jury_in.all(): for jury_in in volunteer.jury_in.all():
slug = jury_in.tournament.name.lower().replace(" ", "-") slug = slugify(jury_in.tournament.name)
sympa.subscribe(volunteer.user.email, f"jurys-{slug}", True) sympa.subscribe(volunteer.user.email, f"jurys-{slug}", True)
for admin in VolunteerRegistration.objects.filter(admin=True).all(): for admin in VolunteerRegistration.objects.filter(admin=True).all():

View File

@ -15,6 +15,12 @@ from ...models import Tournament
class Command(BaseCommand): class Command(BaseCommand):
"""
Création de notifications Google Drive pour récupérer les modifications sur les tableurs de notes.
Documentation de l'API : https://developers.google.com/calendar/api/guides/push?hl=fr
"""
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument( parser.add_argument(
'--tournament', '-t', help="Tournament name to update (if not set, all tournaments will be updated)", '--tournament', '-t', help="Tournament name to update (if not set, all tournaments will be updated)",

View File

@ -0,0 +1,44 @@
# Generated by Django 5.0.6 on 2024-07-11 08:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("participation", "0021_rename_defender_oral_note_reporter_oral_and_more"),
]
operations = [
migrations.AlterField(
model_name="note",
name="observer_oral",
field=models.SmallIntegerField(
choices=[
(-10, -10),
(-9, -9),
(-8, -8),
(-7, -7),
(-6, -6),
(-5, -5),
(-4, -4),
(-3, -3),
(-2, -2),
(-1, -1),
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 4),
(5, 5),
(6, 6),
(7, 7),
(8, 8),
(9, 9),
(10, 10),
],
default=0,
verbose_name="observer oral note",
),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 5.1.5 on 2025-01-14 18:06
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("participation", "0022_alter_note_observer_oral"),
]
operations = [
migrations.AddField(
model_name="tournament",
name="unified_registration",
field=models.BooleanField(
default=False, verbose_name="unified registration"
),
),
]

View File

@ -3,13 +3,13 @@
from datetime import date, timedelta from datetime import date, timedelta
import math import math
import os
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models 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.urls import reverse_lazy
from django.utils import timezone, translation from django.utils import timezone, translation
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
@ -210,14 +210,14 @@ class Team(models.Model):
""" """
:return: The mailing list to contact the team members. :return: The mailing list to contact the team members.
""" """
return f"equipe-{self.trigram.lower()}@{os.getenv('SYMPA_HOST', 'localhost')}" return f"equipe-{slugify(self.trigram)}@{settings.SYMPA_HOST}"
def create_mailing_list(self): def create_mailing_list(self):
""" """
Create a new Sympa mailing list to contact the team. Create a new Sympa mailing list to contact the team.
""" """
get_sympa_client().create_list( get_sympa_client().create_list(
f"equipe-{self.trigram.lower()}", f"equipe-{slugify(self.trigram)}",
f"Equipe {self.name} ({self.trigram})", f"Equipe {self.name} ({self.trigram})",
"hotline", # TODO Use a custom sympa template "hotline", # TODO Use a custom sympa template
f"Liste de diffusion pour contacter l'equipe {self.name} du TFJM2", f"Liste de diffusion pour contacter l'equipe {self.name} du TFJM2",
@ -231,7 +231,7 @@ class Team(models.Model):
""" """
if self.participation.valid: # pragma: no cover if self.participation.valid: # pragma: no cover
get_sympa_client().unsubscribe( get_sympa_client().unsubscribe(
self.email, f"equipes-{self.participation.tournament.name.lower().replace(' ', '-')}", False) self.email, f"equipes-{slugify(self.participation.tournament.name)}", False)
else: else:
get_sympa_client().unsubscribe(self.email, "equipes-non-valides", False) get_sympa_client().unsubscribe(self.email, "equipes-non-valides", False)
get_sympa_client().delete_list(f"equipe-{self.trigram}") get_sympa_client().delete_list(f"equipe-{self.trigram}")
@ -283,6 +283,11 @@ class Tournament(models.Model):
default=date.today, default=date.today,
) )
unified_registration = models.BooleanField(
verbose_name=_("unified registration"),
default=False,
)
place = models.CharField( place = models.CharField(
max_length=255, max_length=255,
verbose_name=_("place"), verbose_name=_("place"),
@ -386,28 +391,28 @@ class Tournament(models.Model):
""" """
:return: The mailing list to contact the team members. :return: The mailing list to contact the team members.
""" """
return f"equipes-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}" return f"equipes-{slugify(self.name)}@{settings.SYMPA_HOST}"
@property @property
def organizers_email(self): def organizers_email(self):
""" """
:return: The mailing list to contact the team members. :return: The mailing list to contact the team members.
""" """
return f"organisateurs-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}" return f"organisateurs-{slugify(self.name)}@{settings.SYMPA_HOST}"
@property @property
def jurys_email(self): def jurys_email(self):
""" """
:return: The mailing list to contact the team members. :return: The mailing list to contact the team members.
""" """
return f"jurys-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}" return f"jurys-{slugify(self.name)}@{settings.SYMPA_HOST}"
def create_mailing_lists(self): def create_mailing_lists(self):
""" """
Create a new Sympa mailing list to contact the team. Create a new Sympa mailing list to contact the team.
""" """
get_sympa_client().create_list( get_sympa_client().create_list(
f"equipes-{self.name.lower().replace(' ', '-')}", f"equipes-{slugify(self.name)}",
f"Equipes du tournoi de {self.name}", f"Equipes du tournoi de {self.name}",
"hotline", # TODO Use a custom sympa template "hotline", # TODO Use a custom sympa template
f"Liste de diffusion pour contacter les equipes du tournoi {self.name} du TFJM²", f"Liste de diffusion pour contacter les equipes du tournoi {self.name} du TFJM²",
@ -415,7 +420,7 @@ class Tournament(models.Model):
raise_error=False, raise_error=False,
) )
get_sympa_client().create_list( get_sympa_client().create_list(
f"organisateurs-{self.name.lower().replace(' ', '-')}", f"organisateurs-{slugify(self.name)}",
f"Organisateurs du tournoi de {self.name}", f"Organisateurs du tournoi de {self.name}",
"hotline", # TODO Use a custom sympa template "hotline", # TODO Use a custom sympa template
f"Liste de diffusion pour contacter les equipes du tournoi {self.name} du TFJM²", f"Liste de diffusion pour contacter les equipes du tournoi {self.name} du TFJM²",
@ -435,6 +440,10 @@ class Tournament(models.Model):
return Participation.objects.filter(final=True) return Participation.objects.filter(final=True)
return self.participation_set 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 @property
def solutions(self): def solutions(self):
if self.final: if self.final:
@ -841,6 +850,8 @@ class Participation(models.Model):
return _("Participation of the team {name} ({trigram})").format(name=self.team.name, trigram=self.team.trigram) return _("Participation of the team {name} ({trigram})").format(name=self.team.name, trigram=self.team.trigram)
def important_informations(self): def important_informations(self):
from survey.models import Survey
informations = [] informations = []
missing_payments = Payment.objects.filter(registrations__in=self.team.participants.all(), valid=False) missing_payments = Payment.objects.filter(registrations__in=self.team.participants.all(), valid=False)
@ -859,6 +870,19 @@ class Participation(models.Model):
'content': content, '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: if self.tournament:
informations.extend(self.informations_for_tournament(self.tournament)) informations.extend(self.informations_for_tournament(self.tournament))
if self.final: if self.final:
@ -912,10 +936,10 @@ class Participation(models.Model):
'content': content, 'content': content,
}) })
elif timezone.now() <= tournament.reviews_first_phase_limit + timedelta(hours=2): 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) reporter_passage = Passage.objects.get(pool__tournament=tournament, pool__round=1, reporter=self)
opponent_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=1, opponent=self) opponent_passage = Passage.objects.get(pool__tournament=tournament, pool__round=1, opponent=self)
reviewer_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=1, reviewer=self) reviewer_passage = Passage.objects.get(pool__tournament=tournament, pool__round=1, reviewer=self)
observer_passage = Passage.objects.filter(pool__tournament=self.tournament, pool__round=1, observer=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 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 " reporter_text = _("<p>The solutions draw is ended. You can check the result on "
@ -966,7 +990,7 @@ class Participation(models.Model):
reviews_template_begin = f"{settings.STATIC_URL}eteam/Written_review." reviews_template_begin = f"{settings.STATIC_URL}eteam/Written_review."
reviews_templates = "".join(f"<a href='{reviews_template_begin}{ext}'>{ext.upper()}</a>" reviews_templates = "".join(f"<a href='{reviews_template_begin}{ext}'>{ext.upper()}</a>"
for ext in ["pdf", "tex"]) for ext in ["pdf", "tex"])
reviews_templates_content = "<p>" + _('Templates:') + " {reviews_templates}</p>" reviews_templates_content = "<p>" + _('Templates:') + f" {reviews_templates}</p>"
content = reporter_content + opponent_content + reviewer_content + observer_content \ content = reporter_content + opponent_content + reviewer_content + observer_content \
+ reviews_templates_content + reviews_templates_content
@ -977,10 +1001,10 @@ class Participation(models.Model):
'content': content, 'content': content,
}) })
elif timezone.now() <= tournament.reviews_second_phase_limit + timedelta(hours=2): 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) reporter_passage = Passage.objects.get(pool__tournament=tournament, pool__round=2, reporter=self)
opponent_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=2, opponent=self) opponent_passage = Passage.objects.get(pool__tournament=tournament, pool__round=2, opponent=self)
reviewer_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=2, reviewer=self) reviewer_passage = Passage.objects.get(pool__tournament=tournament, pool__round=2, reviewer=self)
observer_passage = Passage.objects.filter(pool__tournament=self.tournament, pool__round=2, observer=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 observer_passage = observer_passage.get() if observer_passage.exists() else None
reporter_text = _("<p>For the second round, you will present " reporter_text = _("<p>For the second round, you will present "
@ -1039,12 +1063,12 @@ class Participation(models.Model):
'priority': 1, 'priority': 1,
'content': content, 'content': content,
}) })
elif settings.TFJM_APP == "ETEAM" \ elif settings.NB_ROUNDS >= 3 \
and timezone.now() <= tournament.reviews_third_phase_limit + timedelta(hours=2): 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) reporter_passage = Passage.objects.get(pool__tournament=tournament, pool__round=3, reporter=self)
opponent_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=3, opponent=self) opponent_passage = Passage.objects.get(pool__tournament=tournament, pool__round=3, opponent=self)
reviewer_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=3, reviewer=self) reviewer_passage = Passage.objects.get(pool__tournament=tournament, pool__round=3, reviewer=self)
observer_passage = Passage.objects.filter(pool__tournament=self.tournament, pool__round=3, observer=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 observer_passage = observer_passage.get() if observer_passage.exists() else None
reporter_text = _("<p>For the third round, you will present " reporter_text = _("<p>For the third round, you will present "
@ -1230,10 +1254,14 @@ class Pool(models.Model):
translation.activate(settings.PREFERRED_LANGUAGE_CODE) translation.activate(settings.PREFERRED_LANGUAGE_CODE)
pool_size = self.participations.count() pool_size = self.participations.count()
has_observer = settings.TFJM_APP == "ETEAM" and pool_size >= 4 has_observer = settings.HAS_OBSERVER and pool_size >= 4
passage_width = 6 + (2 if has_observer else 0) passage_width = 6 + (2 if has_observer else 0)
passages = self.passages.all() passages = self.passages.all()
if not pool_size or not passages.count():
# Not initialized yet
return
# Create tournament sheet if it does not exist # Create tournament sheet if it does not exist
self.tournament.create_spreadsheet() self.tournament.create_spreadsheet()
@ -1545,7 +1573,7 @@ class Pool(models.Model):
for i in range(passages.count()): for i in range(passages.count()):
for j in range(passage_width): for j in range(passage_width):
column = getcol(min_column + i * passage_width + j) column = getcol(min_column + i * passage_width + j)
min_note = 0 min_note = 0 if j < 7 else -10
max_note = 20 if j < 2 and settings.TFJM_APP == "TFJM" else 10 max_note = 20 if j < 2 and settings.TFJM_APP == "TFJM" else 10
format_requests.append({ format_requests.append({
"setDataValidation": { "setDataValidation": {
@ -1603,6 +1631,10 @@ class Pool(models.Model):
worksheet.client.batch_update(spreadsheet.id, body) worksheet.client.batch_update(spreadsheet.id, body)
def update_juries_lines_spreadsheet(self): 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) translation.activate(settings.PREFERRED_LANGUAGE_CODE)
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT) gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
@ -1638,7 +1670,7 @@ class Pool(models.Model):
if not data or not data[0]: if not data or not data[0]:
return return
has_observer = settings.TFJM_APP == "ETEAM" and self.participations.count() >= 4 has_observer = settings.HAS_OBSERVER and self.participations.count() >= 4
passage_width = 6 + (2 if has_observer else 0) passage_width = 6 + (2 if has_observer else 0)
for line in data: for line in data:
jury_name = line[0] jury_name = line[0]
@ -1753,7 +1785,7 @@ class Passage(models.Model):
@property @property
def coeff_reporter_oral(self) -> float: 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 coeff *= 1 - 0.25 * self.reporter_penalties
return coeff return coeff
@ -1797,7 +1829,7 @@ class Passage(models.Model):
@property @property
def coeff_reviewer_oral(self): def coeff_reviewer_oral(self):
return 1 if settings.TFJM_APP == "TFJM" else 1.2 return 1.2
@property @property
def average_reviewer(self) -> float: def average_reviewer(self) -> float:
@ -2055,7 +2087,7 @@ class Note(models.Model):
default=0, default=0,
) )
observer_oral = models.PositiveSmallIntegerField( observer_oral = models.SmallIntegerField(
verbose_name=_("observer oral note"), verbose_name=_("observer oral note"),
choices=[(i, i) for i in range(-10, 11)], choices=[(i, i) for i in range(-10, 11)],
default=0, default=0,

View File

@ -4,6 +4,7 @@
from typing import Union from typing import Union
from django.conf import settings from django.conf import settings
from django.template.defaultfilters import slugify
from participation.models import Note, Participation, Passage, Pool, Team, Tournament from participation.models import Note, Participation, Passage, Pool, Team, Tournament
from registration.models import Payment from registration.models import Payment
from tfjm.lists import get_sympa_client from tfjm.lists import get_sympa_client
@ -34,10 +35,10 @@ def update_mailing_list(instance: Team, raw, **_):
instance.create_mailing_list() instance.create_mailing_list()
# Subscribe all team members in the mailing list # Subscribe all team members in the mailing list
for student in instance.students.all(): for student in instance.students.all():
get_sympa_client().subscribe(student.user.email, f"equipe-{instance.trigram.lower()}", False, get_sympa_client().subscribe(student.user.email, f"equipe-{slugify(instance.trigram)}", False,
f"{student.user.first_name} {student.user.last_name}") f"{student.user.first_name} {student.user.last_name}")
for coach in instance.coaches.all(): for coach in instance.coaches.all():
get_sympa_client().subscribe(coach.user.email, f"equipe-{instance.trigram.lower()}", False, get_sympa_client().subscribe(coach.user.email, f"equipe-{slugify(instance.trigram)}", False,
f"{coach.user.first_name} {coach.user.last_name}") f"{coach.user.first_name} {coach.user.last_name}")

View File

@ -1,6 +1,7 @@
# Copyright (C) 2020 by Animath # Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.utils import formats from django.utils import formats
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.text import format_lazy from django.utils.text import format_lazy
@ -106,8 +107,6 @@ class PoolTable(tables.Table):
class PassageTable(tables.Table): class PassageTable(tables.Table):
# FIXME Ne pas afficher l'équipe observatrice si non nécessaire
reporter = tables.LinkColumn( reporter = tables.LinkColumn(
"participation:passage_detail", "participation:passage_detail",
args=[tables.A("id")], args=[tables.A("id")],
@ -131,7 +130,9 @@ class PassageTable(tables.Table):
'class': 'table table-condensed table-striped text-center', 'class': 'table table-condensed table-striped text-center',
} }
model = Passage 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): class NoteTable(tables.Table):
@ -160,4 +161,6 @@ class NoteTable(tables.Table):
} }
model = Note model = Note
fields = ('jury', 'reporter_writing', 'reporter_oral', 'opponent_writing', 'opponent_oral', 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

@ -2,28 +2,28 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Validation request - ETEAM</title> <title>Demande de validation - TFJM²</title>
</head> </head>
<body> <body>
<p> <p>
Hi, Bonjour,
</p> </p>
<p> <p>
The team "{{ team.name }}" ({{ team.trigram }}) has just asked to validate his team to take part L'équipe « {{ team.name }} » ({{ team.trigram }}) vient de demander à valider son équipe pour participer
in ETEAM. au {{ team.participation.get_problem_display }} du TFJM².
You can decide whether or not to accept the team by going to the team page: Vous pouvez décider d'accepter ou de refuser l'équipe en vous rendant sur la page de l'équipe :
<a href="https://{{ domain }}{% url "participation:team_detail" pk=team.pk %}"> <a href="https://{{ domain }}{% url "participation:team_detail" pk=team.pk %}">
https://{{ domain }}{% url "participation:team_detail" pk=team.pk %} https://{{ domain }}{% url "participation:team_detail" pk=team.pk %}
</a> </a>
</p> </p>
<p> <p>
Sincerely yours, Cordialement,
</p> </p>
<p> <p>
The ETEAM team L'organisation du TFJM²
</p> </p>
</body> </body>
</html> </html>

View File

@ -1,10 +1,10 @@
Hi {{ user }}, Bonjour {{ user }},
The team "{{ team.name }}" ({{ team.trigram }}) has just asked to validate his team to take part L'équipe « {{ team.name }} » ({{ team.trigram }}) vient de demander à valider son équipe pour participer
in ETEAM. au {{ team.participation.get_problem_display }} du TFJM².
You can decide whether or not to accept the team by going to the team page: Vous pouvez décider d'accepter ou de refuser l'équipe en vous rendant sur la page de l'équipe :
https://{{ domain }}{% url "participation:team_detail" pk=team.pk %} https://{{ domain }}{% url "participation:team_detail" pk=team.pk %}
Sincerely yours, Cordialement,
The ETEAM team L'organisation du TFJM²

View File

@ -2,21 +2,21 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Team not validated ETEAM</title> <title>Équipe non validée TFJM²</title>
</head> </head>
<body> <body>
Hi,<br/> Bonjour,<br/>
<br /> <br />
Unfortunately, your team "{{ team.name }}" ({{ team.trigram }}) has not been validated. Maleureusement, votre équipe « {{ team.name }} » ({{ team.trigram }}) n'a pas été validée. Veuillez vérifier que vos autorisations
Please check that your authorisations are correctly filled in. de droit à l'image sont correctes. Les organisateurs vous adressent ce message :<br />
The organisers are sending you this message:<br />
<br /> <br />
{{ message }}<br /> {{ message }}<br />
<br /> <br />
Please contact us at <a href="mailto:eteam_moc@proton.me">eteam_moc@proton.me</a> if you need further information. N'hésitez pas à nous contacter à l'adresse <a href="mailto:contact@tfjm.org">contact@tfjm.org</a>
pour plus d'informations.
<br/> <br/>
Sincerely yours,<br/> Cordialement,<br/>
<br/> <br/>
The ETEAM team Le comité d'organisation du TFJM²
</body> </body>
</html> </html>

View File

@ -1,13 +1,12 @@
Hi, Bonjour,
Unfortunately, your team "{{ team.name }}" ({{ team.trigram }}) has not been validated. Maleureusement, votre équipe « {{ team.name }} » ({{ team.trigram }}) n'a pas été validée. Veuillez vérifier que vos
Please check that your authorisations are correctly filled in. autorisations de droit à l'image sont correctes. Les organisateurs vous adressent ce message :
The organisers are sending you this message:<br />
{{ message }} {{ message }}
Please contact us at eteam_moc@proton.me if you need further information. N'hésitez pas à nous contacter à l'adresse contact@tfjm.org pour plus d'informations.
Sincerely yours, Cordialement,
The ETEAM team Le comité d'organisation du TFJM²

View File

@ -2,36 +2,37 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Team validated ETEAM</title> <title>Équipe validée TFJM²</title>
</head> </head>
<body> <body>
<p> <p>
Hello {{ registration }}, Bonjour {{ registration }},
</p> </p>
<p> <p>
Congratulations! Your team "{{ team.name }}" ({{ team.trigram }}) is now validated! You are now ready to Félicitations ! Votre équipe « {{ team.name }} » ({{ team.trigram }}) est désormais validée ! Vous êtes désormais
to work on your problems. You can then upload your solutions to the platform. apte à travailler sur vos problèmes. Vous pourrez ensuite envoyer vos solutions sur la plateforme.
</p> </p>
{% if payment %} {% if payment %}
<p> <p>
You must now pay your participation fee of € {{ payment.amount }}. Vous devez désormais vous acquitter de vos frais de participation, de {{ payment.amount }} € par élève.
You can pay by credit card or bank transfer. You'll find information Vous pouvez payer par carte bancaire ou par virement bancaire. Vous trouverez les informations
on the payment page which you can find on sur <a href="https://{{ domain }}{% url 'registration:update_payment' pk=payment.pk %}">la page de paiement</a>.
<a href="https://{{ domain }}{% url 'registration:my_account_detail' %}">your account</a>. Si vous disposez d'une bourse, l'inscription est gratuite, mais vous devez soumettre un justificatif
If you have a scholarship, registration is free, but you must submit a justification on the same page. sur la même page.
</p> </p>
{% elif registration.is_coach and team.participation.tournament.price %} {% elif registration.is_coach and team.participation.tournament.price %}
<p> <p>
Your team must now pay a participation fee of {{ team.participation.tournament.price }} € per student (supervisors are exempt). Students with scholarships are exempt⋅es from these fees. Votre équipe doit désormais s'acquitter des frais de participation de {{ team.participation.tournament.price }} €
You can track the status of payments on par élève (les encadrant⋅es sont exonéré⋅es). Les élèves qui disposent d'une bourse sont exonéré⋅es de ces frais.
<a href="https://{{ domain }}{% url 'participation:team_detail' pk=team.pk %}">your team page</a>. Vous pouvez suivre l'état des paiements sur
<a href="https://{{ domain }}{% url 'participation:team_detail' pk=team.pk %}">la page de votre équipe</a>.
</p> </p>
{% endif %} {% endif %}
{% if message %} {% if message %}
<p> <p>
The organisers send you this message: Les organisateur⋅ices vous adressent ce message :
</p> </p>
<p> <p>
{{ message }} {{ message }}
@ -39,7 +40,7 @@
{% endif %} {% endif %}
<p> <p>
The ETEAM team Le comité d'organisation du TFJM²
</p> </p>
</body> </body>
</html> </html>

View File

@ -1,21 +1,23 @@
Hello {{registration }}, Bonjour {{ registration }},
Congratulations! Your team "{{ team.name }}" ({{ team.trigram }}) is now validated! You are now ready to Félicitations ! Votre équipe « {{ team.name }} » ({{ team.trigram }}) est désormais validée ! Vous êtes désormais apte
to work on your problems. You can then upload your solutions to the platform. à travailler sur vos problèmes. Vous pourrez ensuite envoyer vos solutions sur la plateforme.
{% if payment %} {% if team.participation.amount %}
You must now pay your participation fee of € {{ payment.amount }}. Vous devez désormais vous acquitter de vos frais de participation, de {{ team.participation.amount }}.
You can pay by credit card or bank transfer. You'll find information Vous pouvez payer par carte bancaire ou par virement bancaire. Vous trouverez les informations
on the payment page which you can find on your account: sur la page de paiement que vous pouvez retrouver sur votre compte :
https://{{ domain }}{% url 'registration:my_account_detail' %} https://{{ domain }}{% url 'registration:my_account_detail' %}
If you have a scholarship, registration is free, but you must submit a justification on the same page. Si vous disposez d'une bourse, l'inscription est gratuite, mais vous devez soumettre un justificatif
sur la même page.
{% elif registration.is_coach and team.participation.tournament.price %} {% elif registration.is_coach and team.participation.tournament.price %}
Your team must now pay a participation fee of {{ team.participation.tournament.price }} € per student (supervisors are exempt). Students with scholarships are exempt⋅es from these fees. Votre équipe doit désormais s'acquitter des frais de participation de {{ team.participation.tournament.price }} €
You can track the status of payments on your team page: par élève (les encadrant⋅es sont exonéré⋅es). Les élèves qui disposent d'une bourse sont exonéré⋅es de ces frais.
Vous pouvez suivre l'état des paiements sur la page de votre équipe :
https://{{ domain }}{% url 'participation:team_detail' pk=team.pk %} https://{{ domain }}{% url 'participation:team_detail' pk=team.pk %}
{% endif %} {% endif %}
{% if message %} {% if message %}
The organisers send you this message: Les organisateurices vous adressent ce message :
{{ message }} {{ message }}
{% endif %} {% endif %}
The ETEAM team Le comité d'organisation du TFJM²

View File

@ -186,7 +186,7 @@
{% elif user.registration.participates %} {% elif user.registration.participates %}
{% trans "Upload review" as modal_title %} {% trans "Upload review" as modal_title %}
{% trans "Upload" as modal_button %} {% trans "Upload" as modal_button %}
{% url "participation:upload_review" pk=passage.pk as modal_action %} {% url "participation:upload_written_review" pk=passage.pk as modal_action %}
{% include "base_modal.html" with modal_id="uploadWrittenReview" modal_enctype="multipart/form-data" %} {% include "base_modal.html" with modal_id="uploadWrittenReview" modal_enctype="multipart/form-data" %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@ -201,7 +201,7 @@
initModal("{{ note.modal_name }}", "{% url "participation:update_notes" pk=note.pk %}") initModal("{{ note.modal_name }}", "{% url "participation:update_notes" pk=note.pk %}")
{% endfor %} {% endfor %}
{% elif user.registration.participates %} {% elif user.registration.participates %}
initModal("uploadWrittenReview", "{% url "participation:upload_review" pk=passage.pk %}") initModal("uploadWrittenReview", "{% url "participation:upload_written_review" pk=passage.pk %}")
{% endif %} {% endif %}
}) })
</script> </script>

View File

@ -20,7 +20,6 @@
\usepackage{multirow} \usepackage{multirow}
\usepackage{footnote} \usepackage{footnote}
\usepackage{tabularx} \usepackage{tabularx}
\usepackage{xintexpr}
\addtolength{\textwidth}{6cm} \addtolength{\textwidth}{6cm}
\addtolength{\oddsidemargin}{-3cm} \addtolength{\oddsidemargin}{-3cm}
@ -45,7 +44,7 @@
\Large {\bf \tfjmedition$^{st}$ European Tournament of Enthusiastic Apprentice Mathematicians}\\ \Large {\bf \tfjmedition$^{st}$ European Tournament of Enthusiastic Apprentice Mathematicians}\\
{% endif %} {% endif %}
\vspace{3mm} \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} \vspace{15mm}
@ -53,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 \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 }}} \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 }}} \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 %} {% 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 %}$ & \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq {% if TFJM.APP == "TFJM" %}20{% else %}10{% endif %}$

View File

@ -19,13 +19,15 @@
\usepackage{array} \usepackage{array}
\usepackage{multirow} \usepackage{multirow}
\usepackage{footnote} \usepackage{footnote}
\usepackage{xintexpr} \usepackage{rotating}
\addtolength{\textwidth}{4cm} \addtolength{\textwidth}{4cm}
\setlength{\parindent}{0mm} \setlength{\parindent}{0mm}
\geometry{left=1.6cm,right=1.6cm,top=1.2cm,bottom=1.2cm} \geometry{left=1.6cm,right=1.6cm,top=1.2cm,bottom=1.2cm}
\DeclareUnicodeCharacter{22C5}{\textperiodcentered{}}
\newcommand{\tfjm}{$\mathbb{TFJM}^2$} \newcommand{\tfjm}{$\mathbb{TFJM}^2$}
\pagestyle{empty} \pagestyle{empty}
\renewcommand{\leq}{\leqslant} \renewcommand{\leq}{\leqslant}
@ -56,10 +58,10 @@
%%%%%%%%%%%%%%%%%%%%%DEFENSEUR %%%%%%%%%%%%%%%%%%%%%DEFENSEUR
\begin{tabular}{|c|p{25mm}|p{11cm}|c|{% for passage in passages.all %}p{2cm}|{% endfor %}}\hline \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 %ECRIT
\multirow{6}{3mm}{\centering \bf W\\ R\\ I\\ T\\ I \\ N \\ G} & \multirow{3}{20mm}{ {% trans "Scientific part" %}} & {% trans "Depth and difficulty of the elements presented" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} \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 }}}
&& {% trans "Presence, accuracy and correctness of proofs and algorithms" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} && {% trans "Presence, accuracy and correctness of proofs and algorithms" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& {% trans "Relevance, efficiency and elegance" %} & [0,1] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} && {% trans "Relevance, efficiency and elegance" %} & [0,1] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multirow{3}{20mm}{ {% trans "Formal aspects" %}}& {% trans "Clarity of reasoning (explanations, examples, illustrations, diagrams, etc.)" %} & [0,2]{{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}} &\multirow{3}{20mm}{ {% trans "Formal aspects" %}}& {% trans "Clarity of reasoning (explanations, examples, illustrations, diagrams, etc.)" %} & [0,2]{{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}}
@ -67,11 +69,11 @@
&\multicolumn{3}{|l|}{\bf {% trans "TOTAL WRITING" %} (/10)} {{ esp|safe }} \\ \hline \hline &\multicolumn{3}{|l|}{\bf {% trans "TOTAL WRITING" %} (/10)} {{ esp|safe }} \\ \hline \hline
%ORAL %ORAL
\multirow{8}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{4}{20mm}{Oral presentation} & {% trans "Understanding of the material presented, knowledge and mastery of the mathematical subjects used during the presentation" %}} & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} \multirow{11}{3mm}{\bf \begin{turn}{90}ORAL\end{turn}} & \multirow{6}{20mm}{Oral presentation} & {% trans "Understanding of the material presented, knowledge and mastery of the mathematical subjects used during the presentation" %}} & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& {% trans "Relevance of choices (proofs, examples, depth in relation to the written solution)" %} & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} && {% trans "Relevance of choices (proofs, examples, depth in relation to the written solution)" %} & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& {% trans "Pedagogy and clarity of speech (explanations, illustrations, etc.)" %} & [0,1] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} && {% trans "Pedagogy and clarity of speech (explanations, illustrations, etc.)" %} & [0,1] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& {% trans "Brevity and cleanliness of the presentation" %} & [0,1] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} && {% trans "Brevity and cleanliness of the presentation" %} & [0,1] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multirow{2}{20mm}{ {% trans "Debates " %}} & {% trans "Correct answers to the questions asked" %} & [0,2] {{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}} &\multirow{3}{20mm}{ {% trans "Debates " %}} & {% trans "Correct answers to the questions asked" %} & [0,2] {{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}}
&& {% trans "Ability to move the debate forward (explaining the limits of one's knowledge, conjectures, live research, etc.)" %} & [0,2] {{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}} && {% trans "Ability to move the debate forward (explaining the limits of one's knowledge, conjectures, live research, etc.)" %} & [0,2] {{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}}
&\multirow{2}{20mm}{ {% trans "Penalty" %}} & {% trans "Ethical behaviour" %} & [--3,0] {{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}} &\multirow{2}{20mm}{ {% trans "Penalty" %}} & {% trans "Ethical behaviour" %} & [--3,0] {{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}}
&& {% trans "Correspondence to the written material" %} & [--3,0] {{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}} && {% trans "Correspondence to the written material" %} & [--3,0] {{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}}
@ -84,17 +86,17 @@
%%%%%%%%%%%%%%%%%OPPOSANT⋅E %%%%%%%%%%%%%%%%%OPPOSANT⋅E
\begin{tabular}{|c|p{25mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline \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.} \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 %ECRIT
\multirow{4}{3mm}{\centering\bf W\\ R\\ I\\ T\\ I \\ N \\ G} &\multirow{3}{20mm}{ {% trans "Scientific part" %}} & {% trans "Critical thinking and perspective on the proposed solution" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} \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 }}}
&& {% trans "Validity of errors and positive points raised" %} & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} && {% trans "Validity of errors and positive points raised" %} & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& {% trans "Identifying and prioritizing the most important errors and positive points" %} & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} && {% trans "Identifying and prioritizing the most important errors and positive points" %} & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& {% trans "Formal aspects" %} & {% trans "Presentation (readability, compliance with the format, etc.)" %} & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} & {% trans "Formal aspects" %} & {% trans "Presentation (readability, compliance with the format, etc.)" %} & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf {% trans "TOTAL WRITING" %} (/10)} {{ esp|safe }} \\ \hline \hline &\multicolumn{3}{|l|}{\bf {% trans "TOTAL WRITING" %} (/10)} {{ esp|safe }} \\ \hline \hline
%ORAL %ORAL
\multirow{6}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{3}{20mm}{ {% trans "Discussion" %}} & {% trans "Relevance of questions (importance of the topics covered, points raised)" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} \multirow{9}{3mm}{\bf \begin{turn}{90}ORAL\end{turn}} & \multirow{6}{20mm}{ {% trans "Discussion" %}} & {% trans "Relevance of questions (importance of the topics covered, points raised)" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& {% trans "Questioning skills (formulation of questions, reaction to answers, articulation between questions, time management)" %} & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} && {% trans "Questioning skills (formulation of questions, reaction to answers, articulation between questions, time management)" %} & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& {% trans "Ability to assess the quality of the Defender's presentation (presentation and answers to the Opponent) (0-2)" %} & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} && {% trans "Ability to assess the quality of the Defender's presentation (presentation and answers to the Opponent) (0-2)" %} & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& {% trans "Understanding" %} & {% trans "Answers to the questions of the Reporter and the jury (substance and ability to move the debate forward)" %} & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} & {% trans "Understanding" %} & {% trans "Answers to the questions of the Reporter and the jury (substance and ability to move the debate forward)" %} & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
@ -106,17 +108,17 @@
%%%%%%%%%%%%%%%%%%%%%%RAPPORTEUR⋅RICE %%%%%%%%%%%%%%%%%%%%%%RAPPORTEUR⋅RICE
\begin{tabular}{|c|p{25mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline \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 %ECRIT
\multirow{4}{3mm}{\centering\bf W\\ R\\ I\\ T\\ I \\ N \\ G} &\multirow{3}{20mm}{ {% trans "Scientific part" %}} & {% trans "Critical thinking and perspective on the proposed solution" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} \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 }}}
&& {% trans "Validity of errors and positive points raised" %} & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} && {% trans "Validity of errors and positive points raised" %} & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& {% trans "Identifying and prioritizing the most important errors and positive points" %} & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} && {% trans "Identifying and prioritizing the most important errors and positive points" %} & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& {% trans "Formal aspects" %} & {% trans "Presentation (readability, compliance with the format, etc.)" %} & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} & {% trans "Formal aspects" %} & {% trans "Presentation (readability, compliance with the format, etc.)" %} & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf {% trans "TOTAL WRITING" %} (/10)} {{ esp|safe }} \\ \hline \hline &\multicolumn{3}{|l|}{\bf {% trans "TOTAL WRITING" %} (/10)} {{ esp|safe }} \\ \hline \hline
%ORAL %ORAL
\multirow{6}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{3}{20mm}{ {% trans "Discussion" %}} & {% trans "Taking the debate to a higher level (through the topics covered, the relevance of the questions asked, the points raised, time management)" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} \multirow{12}{3mm}{\bf \begin{turn}{90}ORAL\end{turn}} & \multirow{8}{20mm}{ {% trans "Discussion" %}} & {% trans "Taking the debate to a higher level (through the topics covered, the relevance of the questions asked, the points raised, time management)" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& {% trans "Creating a constructive dialogue between the participants (formulation of questions, reaction to answers, articulation between questions, speaking time)" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} && {% trans "Creating a constructive dialogue between the participants (formulation of questions, reaction to answers, articulation between questions, speaking time)" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& {% trans "Ability to assess the quality of the exchanges (Reporter-Opponent, and three-way)" %} & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} && {% trans "Ability to assess the quality of the exchanges (Reporter-Opponent, and three-way)" %} & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& {% trans "Understanding" %} & {% trans "Answers to the jury's questions (substance and ability to move the debate forward)" %} & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} & {% trans "Understanding" %} & {% trans "Answers to the jury's questions (substance and ability to move the debate forward)" %} & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
@ -129,17 +131,17 @@
{% if TFJM.APP == "ETEAM" and pool.participations.count >= 4 %} {% if TFJM.APP == "ETEAM" and pool.participations.count >= 4 %}
%%%%%%%%%%%%%%%%%%%%%%OBSERVATEUR⋅RICE %%%%%%%%%%%%%%%%%%%%%%OBSERVATEUR⋅RICE
\begin{tabular}{|c|p{25mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline \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 %ECRIT
\multirow{4}{3mm}{\centering\bf W\\ R\\ I\\ T\\ I \\ N \\ G} &\multirow{3}{20mm}{ {% trans "Scientific part" %}} & {% trans "Critical thinking and perspective on the proposed solution" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} \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 }}}
&& {% trans "Validity of errors and positive points raised" %} & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} && {% trans "Validity of errors and positive points raised" %} & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& {% trans "Identifying and prioritizing the most important errors and positive points" %} & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} && {% trans "Identifying and prioritizing the most important errors and positive points" %} & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& {% trans "Formal aspects" %} & {% trans "Presentation (readability, compliance with the format, etc.)" %} & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} & {% trans "Formal aspects" %} & {% trans "Presentation (readability, compliance with the format, etc.)" %} & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf {% trans "TOTAL WRITING" %} (/10)} {{ esp|safe }} \\ \hline \hline &\multicolumn{3}{|l|}{\bf {% trans "TOTAL WRITING" %} (/10)} {{ esp|safe }} \\ \hline \hline
%ORAL %ORAL
\multirow{6}{3mm}{\centering\bf O\\ R\\ A\\ L} & {% trans "Scientific part" %} & {% trans "Significance of the remarks and questions (positive mark only if the other players omitted crucial matter)" %} & [--5,5] {{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}} \multirow{6}{3mm}{\bf \begin{turn}{90}ORAL\end{turn}} & {% trans "Scientific part" %} & {% trans "Significance of the remarks and questions (positive mark only if the other players omitted crucial matter)" %} & [--5,5] {{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}}
& {% trans "Formal aspects" %} & {% trans "Relevance of the remarks and questions (positive mark only if the other players omitted crucial matter)" %} & [--5,5] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} & {% trans "Formal aspects" %} & {% trans "Relevance of the remarks and questions (positive mark only if the other players omitted crucial matter)" %} & [--5,5] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& {% trans "Penalty" %} & {% trans "Ethical behavior" %} & [-3,0] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} & {% trans "Penalty" %} & {% trans "Ethical behavior" %} & [-3,0] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf {% trans "TOTAL ORAL" %} (/10)} {{ esp|safe }}\\ \hline &\multicolumn{3}{|l|}{\bf {% trans "TOTAL ORAL" %} (/10)} {{ esp|safe }}\\ \hline

View File

@ -17,7 +17,7 @@
\usepackage{array} \usepackage{array}
\usepackage{multirow} \usepackage{multirow}
\usepackage{footnote} \usepackage{footnote}
\usepackage{xintexpr} \usepackage{rotating}
\addtolength{\textwidth}{4cm} \addtolength{\textwidth}{4cm}
\setlength{\parindent}{0mm} \setlength{\parindent}{0mm}
@ -52,24 +52,24 @@
%%%%%%%%%%%%%%%%%%%%%DEFENSEUR %%%%%%%%%%%%%%%%%%%%%DEFENSEUR
\begin{tabular}{|c|p{24mm}|p{11cm}|c|{% for passage in passages.all %}p{2cm}|{% endfor %}}\hline \begin{tabular}{|c|p{24mm}|p{11cm}|c|{% for passage in passages.all %}p{2cm}|{% endfor %}}\hline
\multicolumn{4}{|l|}{{\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 %ECRIT
\multirow{6}{3mm}{\centering \bf\'E\\ C\\ R\\ I\\ T} & \multirow{3}{20mm}{Partie scientifique} & Profondeur et difficulté des éléments présentés & [0,6] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} \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 }}}
&& Présence, exactitude et justesse des démonstrations et algorithmes & [0,6] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} && Présence, exactitude et justesse des démonstrations et algorithmes & [0,6] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Pertinence, efficacité et élégance & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} && Pertinence, efficacité et élégance & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multirow{3}{20mm}{Forme}& Clarté du raisonnement (explications, exemples, illustrations, schémas, etc.) & [0,3]{{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}} &\multirow{3}{24mm}{Forme}& Clarté du raisonnement (explications, exemples, illustrations, schémas, etc.) & [0,3]{{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}}
&& Présentation (lisibilité, respect du format, etc.) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} && Présentation (lisibilité, respect du format, etc.) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/20)} {{ esp|safe }} \\ \hline \hline &\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/20)} {{ esp|safe }} \\ \hline \hline
%ORAL %ORAL
\multirow{8}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{4}{20mm}{Présentation orale} & Compréhension du matériel présenté, connaissance et maîtrise des sujets mathématiques utilisés \emph{lors de la présentation} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} \multirow{11}{3mm}{\bf \begin{turn}{90}ORAL\end{turn}} & \multirow{6}{24mm}{Présentation orale} & Compréhension du matériel présenté, connaissance et maîtrise des sujets mathématiques utilisés \emph{lors de la présentation} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Pertinence des choix (démonstrations, exemples, profondeur au regard de la solution écrite) & [0,4] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} && Pertinence des choix (démonstrations, exemples, profondeur au regard de la solution écrite) & [0,4] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Pédagogie et clarté du discours (explications, illustrations, etc.) & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} && Pédagogie et clarté du discours (explications, illustrations, etc.) & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Brieveté et propreté de la présentation & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} && Brieveté et propreté de la présentation & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multirow{2}{20mm}{Débats} & Réponses correctes aux questions posées & [0,5] {{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}} &\multirow{3}{24mm}{Débats} & Réponses correctes aux questions posées & [0,5] {{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}}
&& Capacité de faire avancer le débat (expliquer les limites de ses connaissances, des conjectures, rechercher en direct, etc.) & [0,4] {{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}} && Capacité de faire avancer le débat (expliquer les limites de ses connaissances, des conjectures, rechercher en direct, etc.) & [0,4] {{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}}
&\multirow{2}{20mm}{Malus} & Attitude irrespectueuse ? & [--6,0] {{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}} &\multirow{2}{24mm}{Malus} & Attitude irrespectueuse ? & [--6,0] {{ esp|safe }} \\ \cline{3-{{ passages.count|add:4 }}}
&& Non-conformité de la présentation avec le matériel écrit ? & [--6,0] {{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}} && Non-conformité de la présentation avec le matériel écrit ? & [--6,0] {{ esp|safe }} \\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf TOTAL ORAL (/20)} {{ esp|safe }} \\ \hline &\multicolumn{3}{|l|}{\bf TOTAL ORAL (/20)} {{ esp|safe }} \\ \hline
@ -80,17 +80,17 @@
%%%%%%%%%%%%%%%%%OPPOSANT %%%%%%%%%%%%%%%%%OPPOSANT
\begin{tabular}{|c|p{24mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline \begin{tabular}{|c|p{24mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
\multicolumn{4}{|l|}{L' {\bf Opposant⋅e} \normalsize fournit une analyse critique de la solution et de la pr\'esentation.} \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 %ECRIT
\multirow{4}{3mm}{\centering\bf\'E\\ C\\ R\\ I\\ T} &\multirow{3}{20mm}{Partie scientifique} & Recul et esprit critique par rapport à la solution proposée & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} \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 }}}
&& Validité des erreurs et points positifs soulevés & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} && Validité des erreurs et points positifs soulevés & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Repérer les erreurs et points positifs les plus importants et les hiérarchiser & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} && Repérer les erreurs et points positifs les plus importants et les hiérarchiser & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& Forme & Pr\'esentation (lisibilité, respect du format, etc.) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} & Forme & Pr\'esentation (lisibilité, respect du format, etc.) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/10)} {{ esp|safe }} \\ \hline \hline &\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/10)} {{ esp|safe }} \\ \hline \hline
%ORAL %ORAL
\multirow{6}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{3}{20mm}{Questions et discours de l'opposant⋅e} & Pertinence des questions (importance des sujets abordés, des points soulevés) & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} \multirow{10}{3mm}{\bf \begin{turn}{90}ORAL\end{turn}} & \multirow{5}{24mm}{Questions et discours de l'opposant⋅e} & Pertinence des questions (importance des sujets abordés, des points soulevés) & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Gestion de l'échange (formulation des questions, réaction aux réponses, articulation entre les questions, gestion du temps) & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} && Gestion de l'échange (formulation des questions, réaction aux réponses, articulation entre les questions, gestion du temps) & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Capacité à évaluer la qualité de la prestation de læ Défenseur⋅se (présentation et réponses à l'Opposant⋅e) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} && Capacité à évaluer la qualité de la prestation de læ Défenseur⋅se (présentation et réponses à l'Opposant⋅e) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&& Réponses aux questions de læ Rapporteur⋅rice et du jury (fond et capacité à faire avancer le débat) & [0,3] {{ 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,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
@ -102,20 +102,20 @@
%%%%%%%%%%%%%%%%%%%%%%RAPPORTEUR.RICE %%%%%%%%%%%%%%%%%%%%%%RAPPORTEUR.RICE
\begin{tabular}{|c|p{24mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline \begin{tabular}{|c|p{24mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
\multicolumn{4}{|l|}{{\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 %ECRIT
\multirow{4}{3mm}{\centering\bf\'E\\ C\\ R\\ I\\ T} &\multirow{3}{20mm}{Partie scientifique} & Recul et esprit critique par rapport à la solution proposée & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} \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 }}}
&& Validité des erreurs et points positifs soulevés & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} && Validité des erreurs et points positifs soulevés & [0,2] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Repérer les erreurs et points positifs les plus importants et les hiérarchiser & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} && Repérer les erreurs et points positifs les plus importants et les hiérarchiser & [0,3] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& Forme & Présentation (lisibilité, respect du format, etc.) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}} & Forme & Présentation (lisibilité, respect du format, etc.) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/10)} {{ esp|safe }}\\ \hline \hline &\multicolumn{3}{|l|}{\bf TOTAL \'ECRIT (/10)} {{ esp|safe }}\\ \hline \hline
%ORAL %ORAL
\multirow{6}{3mm}{\centering\bf O\\ R\\ A\\ L} & \multirow{3}{20mm}{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 }}} \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 }}} && \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 }}} && 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 }}} & Malus & Attitude irrespectueuse ? & [-3,0] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf TOTAL ORAL (/10)} {{ esp|safe }}\\ \hline &\multicolumn{3}{|l|}{\bf TOTAL ORAL (/10)} {{ esp|safe }}\\ \hline
\end{tabular} \end{tabular}

View File

@ -23,45 +23,81 @@
<dd class="col-sm-6">{% if tournament.price %}{{ tournament.price }} €{% else %}{% trans "Free" %}{% endif %}</dd> <dd class="col-sm-6">{% if tournament.price %}{{ tournament.price }} €{% else %}{% trans "Free" %}{% endif %}</dd>
{% endif %} {% 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> <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> <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> <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> <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> <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> <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> <dd class="col-sm-6">{{ tournament.reviews_second_phase_limit }}</dd>
{% if TFJM.APP == "ETEAM" %} {% if TFJM.NB_ROUNDS == 3 %}
<dt class="col-sm-6 text-sm-end">{% trans 'date of maximal written reviews submission for the third round'|capfirst %}</dt> <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> <dd class="col-sm-6">{{ tournament.reviews_third_phase_limit }}</dd>
{% endif %} {% 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> <dd class="col-sm-6">{{ tournament.description }}</dd>
{% if TFJM.ML_MANAGEMENT %} {% 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> <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> <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> <dd class="col-sm-6"><a href="mailto:{{ tournament.teams_email }}">{{ tournament.teams_email }}</a></dd>
{% endif %} {% endif %}
{% endif %}
</dl> </dl>
</div> </div>
@ -208,22 +244,26 @@
<h3>{% trans "Files available for download" %}</h3> <h3>{% trans "Files available for download" %}</h3>
<div class="alert alert-warning fade show files-to-download-collapse" id="files-to-download-popup"> <div class="alert alert-warning fade show files-to-download-collapse" id="files-to-download-popup">
<h4>IMPORTANT</h4> <h4>{% trans "IMPORTANT" %}</h4>
<p> <p>
{% blocktrans trimmed %}
The files accessible below may contain personal information. The files accessible below may contain personal information.
In compliance with European law and out of respect for the confidentiality of participants' data, In compliance with European law and out of respect for the confidentiality of participants data,
you may only use this data for purposes strictly necessary to the organization of the tournament. you may only use this data for purposes strictly necessary to the organization of the tournament.
{% endblocktrans %}
</p> </p>
<p> <p>
{% blocktrans trimmed %}
Moreover, it is your responsibility to delete these files once you no longer need them, especially at the end of the tournament. Moreover, it is your responsibility to delete these files once you no longer need them, especially at the end of the tournament.
{% endblocktrans %}
</p> </p>
<p class="text-center"> <p class="text-center">
<button class="btn btn-warning" data-bs-toggle="collapse" href=".files-to-download-collapse" <button class="btn btn-warning" data-bs-toggle="collapse" href=".files-to-download-collapse"
role="button" aria-expanded="false" aria-controls="files-to-download files-to-download-popup"> role="button" aria-expanded="false" aria-controls="files-to-download files-to-download-popup">
I agree not to divulge participants' data and to delete them at the end of the tournament. {% trans "I agree not to divulge participants data and to delete them at the end of the tournament." %}
</button> </button>
</p> </p>
</div> </div>

View File

@ -1,15 +1,37 @@
{% extends request.content_only|yesno:"empty.html,base.html" %} {% extends request.content_only|yesno:"empty.html,base.html" %}
{% load crispy_forms_filters i18n %} {% load crispy_forms_filters crispy_forms_tags i18n %}
{% block content %} {% block content %}
<form method="post"> <form method="post">
<div id="form-content"> <div id="form-content">
{% csrf_token %} {% csrf_token %}
{{ form|crispy }} {{ form|crispy }}
{{ participation_form|crispy }} {% crispy participation_form %}
</div> </div>
<button class="btn btn-success" type="submit">{% trans "Update" %}</button> <button class="btn btn-success" type="submit">{% trans "Update" %}</button>
</form> </form>
{% endblock content %} {% endblock content %}
{% block extrajavascript %}
<script>
const tournamentSelect = document.getElementById('id_tournament')
const idfWarningBanner = document.getElementById('idf_warning_banner')
const unifiedRegistrationTournamentIds = idfWarningBanner.getAttribute('data-tid-unified').split(',')
if (idfWarningBanner.getAttribute('data-tid-unified') !== "") {
function updateIDFWarningBannerVisibility() {
const tid = tournamentSelect.value
if (unifiedRegistrationTournamentIds.includes(tid))
idfWarningBanner.classList.remove('d-none')
else
idfWarningBanner.classList.add('d-none')
}
tournamentSelect.addEventListener('change', updateIDFWarningBannerVisibility)
updateIDFWarningBannerVisibility()
}
else {
idfWarningBanner.classList.add('d-none')
}
</script>
{% endblock %}

View File

@ -1,3 +1,2 @@
{{ object.name }} {{ object.name }}
{{ object.place }}
{{ object.description }} {{ 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, \ TeamAuthorizationsView, TeamDetailView, TeamLeaveView, TeamListView, TeamUpdateView, \
TeamUploadMotivationLetterView, TournamentCreateView, TournamentDetailView, TournamentExportCSVView, \ TeamUploadMotivationLetterView, TournamentCreateView, TournamentDetailView, TournamentExportCSVView, \
TournamentHarmonizeNoteView, TournamentHarmonizeView, TournamentListView, TournamentPaymentsView, \ TournamentHarmonizeNoteView, TournamentHarmonizeView, TournamentListView, TournamentPaymentsView, \
TournamentPublishNotesView, TournamentUpdateView, WrittenReviewUploadView TournamentPublishNotesView, TournamentPublishSolutionsView, TournamentUpdateView, WrittenReviewUploadView
app_name = "participation" app_name = "participation"
@ -48,6 +48,8 @@ urlpatterns = [
name="tournament_notation_sheets"), name="tournament_notation_sheets"),
path("tournament/<int:pk>/notation/notifications/", GSheetNotificationsView.as_view(), path("tournament/<int:pk>/notation/notifications/", GSheetNotificationsView.as_view(),
name="tournament_gsheet_notifications"), 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(), path("tournament/<int:pk>/publish-notes/<int:round>/", TournamentPublishNotesView.as_view(),
name="tournament_publish_notes"), name="tournament_publish_notes"),
path("tournament/<int:pk>/harmonize/<int:round>/", TournamentHarmonizeView.as_view(), path("tournament/<int:pk>/harmonize/<int:round>/", TournamentHarmonizeView.as_view(),

View File

@ -22,6 +22,7 @@ from django.db import transaction
from django.db.models import F from django.db.models import F
from django.http import FileResponse, Http404, HttpResponse from django.http import FileResponse, Http404, HttpResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.template.defaultfilters import slugify
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils import timezone, translation from django.utils import timezone, translation
@ -88,7 +89,7 @@ class CreateTeamView(LoginRequiredMixin, CreateView):
registration.save() registration.save()
# Subscribe the user mail address to the team mailing list # Subscribe the user mail address to the team mailing list
get_sympa_client().subscribe(user.email, f"equipe-{form.instance.trigram.lower()}", False, get_sympa_client().subscribe(user.email, f"equipe-{slugify(form.instance.trigram)}", False,
f"{user.first_name} {user.last_name}") f"{user.first_name} {user.last_name}")
return ret return ret
@ -130,7 +131,7 @@ class JoinTeamView(LoginRequiredMixin, FormView):
registration.save() registration.save()
# Subscribe to the team mailing list # Subscribe to the team mailing list
get_sympa_client().subscribe(user.email, f"equipe-{form.instance.trigram.lower()}", False, get_sympa_client().subscribe(user.email, f"equipe-{slugify(form.instance.trigram)}", False,
f"{user.first_name} {user.last_name}") f"{user.first_name} {user.last_name}")
return ret return ret
@ -229,6 +230,7 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView)
self.object.participation.save() self.object.participation.save()
mail_context = dict(team=self.object, domain=Site.objects.first().domain) mail_context = dict(team=self.object, domain=Site.objects.first().domain)
with translation.override(settings.PREFERRED_LANGUAGE_CODE):
mail_plain = render_to_string("participation/mails/request_validation.txt", mail_context) mail_plain = render_to_string("participation/mails/request_validation.txt", mail_context)
mail_html = render_to_string("participation/mails/request_validation.html", 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, send_mail(f"[{settings.APP_NAME}] {_('Team validation')}", mail_plain, settings.DEFAULT_FROM_EMAIL,
@ -264,6 +266,7 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView)
message=form.cleaned_data["message"]) message=form.cleaned_data["message"])
mail_context_html = dict(domain=domain, registration=registration, team=self.object, payment=payment, mail_context_html = dict(domain=domain, registration=registration, team=self.object, payment=payment,
message=form.cleaned_data["message"].replace('\n', '<br>')) message=form.cleaned_data["message"].replace('\n', '<br>'))
with translation.override(settings.PREFERRED_LANGUAGE_CODE):
mail_plain = render_to_string("participation/mails/team_validated.txt", mail_context_plain) 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) 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, registration.user.email_user(f"[{settings.APP_NAME}] {_('Team validated')}", mail_plain,
@ -273,6 +276,7 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView)
self.object.participation.save() self.object.participation.save()
mail_context_plain = dict(team=self.object, message=form.cleaned_data["message"]) mail_context_plain = dict(team=self.object, message=form.cleaned_data["message"])
mail_context_html = dict(team=self.object, message=form.cleaned_data["message"].replace('\n', '<br>')) mail_context_html = dict(team=self.object, message=form.cleaned_data["message"].replace('\n', '<br>'))
with translation.override(settings.PREFERRED_LANGUAGE_CODE):
mail_plain = render_to_string("participation/mails/team_not_validated.txt", mail_context_plain) 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) 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, send_mail(f"[{settings.APP_NAME}] {_('Team not validated')}", mail_plain,
@ -313,6 +317,7 @@ class TeamUpdateView(LoginRequiredMixin, UpdateView):
instance=self.object.participation) instance=self.object.participation)
if not self.request.user.registration.is_volunteer: if not self.request.user.registration.is_volunteer:
del context["participation_form"].fields['final'] del context["participation_form"].fields['final']
context["participation_form"].helper.layout.remove('final')
context["title"] = _("Update team {trigram}").format(trigram=self.object.trigram) context["title"] = _("Update team {trigram}").format(trigram=self.object.trigram)
return context return context
@ -321,6 +326,7 @@ class TeamUpdateView(LoginRequiredMixin, UpdateView):
participation_form = ParticipationForm(data=self.request.POST or None, instance=self.object.participation) participation_form = ParticipationForm(data=self.request.POST or None, instance=self.object.participation)
if not self.request.user.registration.is_volunteer: if not self.request.user.registration.is_volunteer:
del participation_form.fields['final'] del participation_form.fields['final']
participation_form.helper.layout.remove('final')
if not participation_form.is_valid(): if not participation_form.is_valid():
return self.form_invalid(form) return self.form_invalid(form)
@ -517,7 +523,7 @@ class TeamLeaveView(LoginRequiredMixin, TemplateView):
team = request.user.registration.team team = request.user.registration.team
request.user.registration.team = None request.user.registration.team = None
request.user.registration.save() request.user.registration.save()
get_sympa_client().unsubscribe(request.user.email, f"equipe-{team.trigram.lower()}", False) get_sympa_client().unsubscribe(request.user.email, f"equipe-{slugify(team.trigram)}", False)
if team.students.count() + team.coaches.count() == 0: if team.students.count() + team.coaches.count() == 0:
team.delete() team.delete()
return redirect(reverse_lazy("index")) return redirect(reverse_lazy("index"))
@ -551,7 +557,7 @@ class ParticipationDetailView(LoginRequiredMixin, DetailView):
if not self.get_object().valid: if not self.get_object().valid:
raise PermissionDenied(_("The team is not validated yet.")) raise PermissionDenied(_("The team is not validated yet."))
if user.registration.is_admin or user.registration.participates \ 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"] \ and user.registration.team.participation.pk == kwargs["pk"] \
or user.registration.is_volunteer \ or user.registration.is_volunteer \
and (self.get_object().tournament in user.registration.interesting_tournaments and (self.get_object().tournament in user.registration.interesting_tournaments
@ -666,7 +672,7 @@ class TournamentPaymentsView(VolunteerMixin, SingleTableMixin, DetailView):
if self.object.final: if self.object.final:
payments = Payment.objects.filter(final=True) payments = Payment.objects.filter(final=True)
else: 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') \ return payments.annotate(team_id=F('registrations__team')).order_by('-valid', 'registrations__team__trigram') \
.distinct().all() .distinct().all()
@ -741,12 +747,12 @@ class TournamentPublishNotesView(VolunteerMixin, SingleObjectMixin, RedirectView
return self.handle_no_permission() return self.handle_no_permission()
tournament = self.get_object() tournament = self.get_object()
reg = request.user.registration 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 self.handle_no_permission()
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get(self, 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 raise Http404
tournament = Tournament.objects.get(pk=kwargs["pk"]) tournament = Tournament.objects.get(pk=kwargs["pk"])
@ -761,6 +767,45 @@ class TournamentPublishNotesView(VolunteerMixin, SingleObjectMixin, RedirectView
return reverse_lazy("participation:tournament_detail", args=(kwargs['pk'],)) 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): class TournamentHarmonizeView(VolunteerMixin, DetailView):
""" """
Harmonize the notes of a tournament. Harmonize the notes of a tournament.
@ -773,7 +818,7 @@ class TournamentHarmonizeView(VolunteerMixin, DetailView):
return self.handle_no_permission() return self.handle_no_permission()
tournament = self.get_object() tournament = self.get_object()
reg = request.user.registration 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 self.handle_no_permission()
if self.kwargs['round'] not in range(1, settings.NB_ROUNDS + 1): if self.kwargs['round'] not in range(1, settings.NB_ROUNDS + 1):
raise Http404 raise Http404
@ -806,7 +851,7 @@ class TournamentHarmonizeNoteView(VolunteerMixin, DetailView):
return self.handle_no_permission() return self.handle_no_permission()
tournament = self.get_object() tournament = self.get_object()
reg = request.user.registration 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 self.handle_no_permission()
if self.kwargs['round'] not in range(1, settings.NB_ROUNDS + 1) \ if self.kwargs['round'] not in range(1, settings.NB_ROUNDS + 1) \
or self.kwargs['action'] not in ('add', 'remove') \ or self.kwargs['action'] not in ('add', 'remove') \
@ -846,7 +891,7 @@ class SelectTeamFinalView(VolunteerMixin, DetailView):
return self.handle_no_permission() return self.handle_no_permission()
tournament = self.get_object() tournament = self.get_object()
reg = request.user.registration 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 self.handle_no_permission()
participation_qs = tournament.participations.filter(pk=self.kwargs["participation_id"]) participation_qs = tournament.participations.filter(pk=self.kwargs["participation_id"])
if not participation_qs.exists(): if not participation_qs.exists():
@ -997,17 +1042,14 @@ class SolutionsDownloadView(VolunteerMixin, View):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
elif 'tournament_id' in kwargs: elif 'tournament_id' in kwargs:
tournament = Tournament.objects.get(pk=kwargs["tournament_id"]) tournament = Tournament.objects.get(pk=kwargs["tournament_id"])
if reg.is_volunteer \ if reg.is_volunteer and reg in tournament.organizers_and_presidents.all():
and (tournament in reg.organized_tournaments.all()
or reg.pools_presided.filter(tournament=tournament).exists()):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
else: else:
pool = Pool.objects.get(pk=kwargs["pool_id"]) pool = Pool.objects.get(pk=kwargs["pool_id"])
tournament = pool.tournament tournament = pool.tournament
if reg.is_volunteer \ if reg.is_volunteer \
and (reg in tournament.organizers.all() and (reg in tournament.organizers_and_presidents.all()
or reg in pool.juries.all() or reg in pool.juries.all()):
or reg.pools_presided.filter(tournament=tournament).exists()):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
return self.handle_no_permission() return self.handle_no_permission()
@ -1144,11 +1186,14 @@ class PoolJuryView(VolunteerMixin, FormView, DetailView):
# Send welcome mail # Send welcome mail
subject = f"[{settings.APP_NAME}] " + str(_("New jury account")) subject = f"[{settings.APP_NAME}] " + str(_("New jury account"))
site = Site.objects.first() site = Site.objects.first()
message = render_to_string('registration/mails/add_organizer.txt', dict(user=user, with translation.override(settings.PREFERRED_LANGUAGE_CODE):
message = render_to_string('registration/mails/add_organizer.txt',
dict(user=user,
inviter=self.request.user, inviter=self.request.user,
password=password, password=password,
domain=site.domain)) domain=site.domain))
html = render_to_string('registration/mails/add_organizer.html', dict(user=user, html = render_to_string('registration/mails/add_organizer.html',
dict(user=user,
inviter=self.request.user, inviter=self.request.user,
password=password, password=password,
domain=site.domain)) domain=site.domain))
@ -1259,7 +1304,7 @@ class PoolUploadNotesView(VolunteerMixin, FormView, DetailView):
return self.form_invalid(form) return self.form_invalid(form)
for vr, notes in parsed_notes.items(): for vr, notes in parsed_notes.items():
notes_count = 6 + (2 if pool.participations.count() >= 4 and settings.TFJM_APP == "ETEAM" else 0) notes_count = 6 + (2 if pool.participations.count() >= 4 and settings.HAS_OBSERVER else 0)
for i, passage in enumerate(pool.passages.all()): for i, passage in enumerate(pool.passages.all()):
note = Note.objects.get_or_create(jury=vr, passage=passage)[0] note = Note.objects.get_or_create(jury=vr, passage=passage)[0]
passage_notes = notes[notes_count * i:notes_count * (i + 1)] passage_notes = notes[notes_count * i:notes_count * (i + 1)]
@ -1297,7 +1342,7 @@ class PoolNotesTemplateView(VolunteerMixin, DetailView):
translation.activate(settings.PREFERRED_LANGUAGE_CODE) translation.activate(settings.PREFERRED_LANGUAGE_CODE)
pool_size = self.object.passages.count() pool_size = self.object.passages.count()
has_observer = self.object.participations.count() >= 4 and settings.TFJM_APP == "ETEAM" has_observer = self.object.participations.count() >= 4 and settings.HAS_OBSERVER
passage_width = 6 + (2 if has_observer else 0) passage_width = 6 + (2 if has_observer else 0)
line_length = pool_size * passage_width line_length = pool_size * passage_width
@ -1842,9 +1887,8 @@ class NotationSheetTemplateView(VolunteerMixin, DetailView):
return context return context
def render_to_response(self, context, **response_kwargs): def render_to_response(self, context, **response_kwargs):
translation.activate(settings.PREFERRED_LANGUAGE_CODE)
template_name = self.get_template_names()[0] template_name = self.get_template_names()[0]
with translation.override(settings.PREFERRED_LANGUAGE_CODE):
tex = render_to_string(template_name, context=context, request=self.request) tex = render_to_string(template_name, context=context, request=self.request)
temp_dir = mkdtemp() temp_dir = mkdtemp()
with open(os.path.join(temp_dir, "texput.tex"), "w") as f: with open(os.path.join(temp_dir, "texput.tex"), "w") as f:
@ -1952,6 +1996,13 @@ class NotationSheetsArchiveView(VolunteerMixin, DetailView):
@method_decorator(csrf_exempt, name='dispatch') @method_decorator(csrf_exempt, name='dispatch')
class GSheetNotificationsView(View): class GSheetNotificationsView(View):
"""
Cette vue gère les notifications envoyées par Google Drive en cas de
modifications d'un tableur de notes sur Google Sheets.
Documentation de l'API : https://developers.google.com/calendar/api/guides/push?hl=fr
"""
async def post(self, request, *args, **kwargs): async def post(self, request, *args, **kwargs):
if not await Tournament.objects.filter(pk=kwargs['pk']).aexists(): if not await Tournament.objects.filter(pk=kwargs['pk']).aexists():
return HttpResponse(status=404) return HttpResponse(status=404)
@ -1986,7 +2037,7 @@ class PassageDetailView(LoginRequiredMixin, DetailView):
reg = request.user.registration reg = request.user.registration
passage = self.get_object() passage = self.get_object()
if reg.is_admin or reg.is_volunteer \ 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 in passage.pool.juries.all()
or reg.pools_presided.filter(tournament=passage.pool.tournament).exists()) \ or reg.pools_presided.filter(tournament=passage.pool.tournament).exists()) \
or reg.participates and reg.team \ or reg.participates and reg.team \
@ -2113,6 +2164,7 @@ class NoteUpdateView(VolunteerMixin, UpdateView):
form.fields['opponent_oral'].label += f" ({self.object.passage.opponent.team.trigram})" 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_writing'].label += f" ({self.object.passage.reviewer.team.trigram})"
form.fields['reviewer_oral'].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_writing'].label += f" ({self.object.passage.observer.team.trigram})"
form.fields['observer_oral'].label += f" ({self.object.passage.observer.team.trigram})" form.fields['observer_oral'].label += f" ({self.object.passage.observer.team.trigram})"
return form return form

View File

@ -10,4 +10,4 @@ def register_registration_urls(router, path):
""" """
router.register(path + "/payment", PaymentViewSet) router.register(path + "/payment", PaymentViewSet)
router.register(path + "/registration", RegistrationViewSet) router.register(path + "/registration", RegistrationViewSet)
router.register(path + "/volunteers", VolunteersViewSet) router.register(path + "/volunteers", VolunteersViewSet, basename="volunteers")

View File

@ -3,6 +3,7 @@
from django.apps import AppConfig from django.apps import AppConfig
from django.db.models.signals import post_save, pre_save from django.db.models.signals import post_save, pre_save
from django.utils.translation import gettext_lazy as _
class RegistrationConfig(AppConfig): class RegistrationConfig(AppConfig):
@ -10,6 +11,7 @@ class RegistrationConfig(AppConfig):
Registration app contains the detail about users only. Registration app contains the detail about users only.
""" """
name = 'registration' name = 'registration'
verbose_name = _("registrations")
def ready(self): def ready(self):
from registration import signals from registration import signals

View File

@ -0,0 +1,37 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
from django.core.management import BaseCommand
from participation.models import Team
class Command(BaseCommand):
help = """Cette commande permet d'exporter dans le dossier output/photo_authorizations l'ensemble des
autorisations de droit à l'image des participant⋅es, triées par équipe, incluant aussi celles de la finale."""
def handle(self, *args, **kwargs):
base_dir = Path(__file__).parent.parent.parent.parent
base_dir /= "output"
if not base_dir.is_dir():
base_dir.mkdir()
base_dir /= "photo_authorizations"
if not base_dir.is_dir():
base_dir.mkdir()
for team in Team.objects.filter(participation__valid=True).all():
team_dir = base_dir / f"{team.trigram} - {team.name}"
if not team_dir.is_dir():
team_dir.mkdir()
for participant in team.participants.all():
if participant.photo_authorization:
with participant.photo_authorization.file as file_input:
with open(team_dir / f"{participant}.pdf", 'wb') as file_output:
file_output.write(file_input.read())
if participant.photo_authorization_final:
with participant.photo_authorization_final.file as file_input:
with open(team_dir / f"{participant} (finale).pdf", 'wb') as file_output:
file_output.write(file_input.read())

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

@ -1,13 +1,14 @@
# Copyright (C) 2020 by Animath # Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from datetime import date, datetime from datetime import date
from django.conf import settings from django.conf import settings
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.mail import send_mail from django.core.mail import send_mail
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import Q
from django.template import loader from django.template import loader
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone, translation from django.utils import timezone, translation
@ -166,7 +167,6 @@ class ParticipantRegistration(Registration):
("male", _("Male")), ("male", _("Male")),
("other", _("Other")), ("other", _("Other")),
], ],
default="other",
) )
address = models.CharField( address = models.CharField(
@ -260,6 +260,8 @@ class ParticipantRegistration(Registration):
raise NotImplementedError raise NotImplementedError
def registration_informations(self): def registration_informations(self):
from survey.models import Survey
informations = [] informations = []
if not self.team: if not self.team:
text = _("You are not in a team. You can <a href=\"{create_url}\">create one</a> " 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, '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()) informations.extend(self.team.important_informations())
return informations return informations
@ -308,7 +324,7 @@ class ParticipantRegistration(Registration):
""" """
The team is selected for final. The team is selected for final.
""" """
translation.activate(settings.PREFERRED_LANGUAGE_CODE) with translation.override(settings.PREFERRED_LANGUAGE_CODE):
subject = f"[{settings.APP_NAME}] " + str(_("Team selected for the final tournament")) subject = f"[{settings.APP_NAME}] " + str(_("Team selected for the final tournament"))
site = Site.objects.first() site = Site.objects.first()
from participation.models import Tournament from participation.models import Tournament
@ -774,7 +790,7 @@ class Payment(models.Model):
return checkout_intent return checkout_intent
tournament = self.tournament tournament = self.tournament
year = datetime.now().year year = timezone.now().year
base_site = "https://" + Site.objects.first().domain base_site = "https://" + Site.objects.first().domain
checkout_intent = helloasso.create_checkout_intent( checkout_intent = helloasso.create_checkout_intent(
amount=100 * self.amount, amount=100 * self.amount,
@ -802,7 +818,7 @@ class Payment(models.Model):
return checkout_intent return checkout_intent
def send_remind_mail(self): def send_remind_mail(self):
translation.activate(settings.PREFERRED_LANGUAGE_CODE) with translation.override(settings.PREFERRED_LANGUAGE_CODE):
subject = f"[{settings.APP_NAME}] " + str(_("Reminder for your payment")) subject = f"[{settings.APP_NAME}] " + str(_("Reminder for your payment"))
site = Site.objects.first() site = Site.objects.first()
for registration in self.registrations.all(): for registration in self.registrations.all():
@ -813,7 +829,7 @@ class Payment(models.Model):
registration.user.email_user(subject, message, html_message=html) registration.user.email_user(subject, message, html_message=html)
def send_helloasso_payment_confirmation_mail(self): def send_helloasso_payment_confirmation_mail(self):
translation.activate(settings.PREFERRED_LANGUAGE_CODE) with translation.override(settings.PREFERRED_LANGUAGE_CODE):
subject = f"[{settings.APP_NAME}] " + str(_("Payment confirmation")) subject = f"[{settings.APP_NAME}] " + str(_("Payment confirmation"))
site = Site.objects.first() site = Site.objects.first()
for registration in self.registrations.all(): for registration in self.registrations.all():

View File

@ -2,6 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.template.defaultfilters import slugify
from tfjm.lists import get_sympa_client from tfjm.lists import get_sympa_client
from .models import Registration, VolunteerRegistration from .models import Registration, VolunteerRegistration
@ -29,8 +30,8 @@ def send_email_link(instance, **_):
registration.send_email_validation_link() registration.send_email_validation_link()
if registration.participates and registration.team: if registration.participates and registration.team:
get_sympa_client().unsubscribe(old_instance.email, f"equipe-{registration.team.trigram.lower()}", False) get_sympa_client().unsubscribe(old_instance.email, f"equipe-{slugify(registration.team.trigram)}", False)
get_sympa_client().subscribe(instance.email, f"equipe-{registration.team.trigram.lower()}", False, get_sympa_client().subscribe(instance.email, f"equipe-{slugify(registration.team.trigram)}", False,
f"{instance.first_name} {instance.last_name}") f"{instance.first_name} {instance.last_name}")

View File

@ -9,29 +9,29 @@
<body> <body>
<p> <p>
Hi {{ user.registration }}, Bonjour {{ user.registration }},
</p> </p>
<p> <p>
You have been invited by {{ inviter.registration }} to join the ETEAM platform, available at Vous avez été invités par {{ inviter.registration }} à rejoindre la plateforme du TFJM², accessible à l'adresse
<a href="https://{{ domain }}/">https://{{ domain }}/</a>. You have a volunteer account. <a href="https://{{ domain }}/">https://{{ domain }}/</a>. Vous disposez d'un compte de bénévole.
</p> </p>
<p> <p>
A random password has been set: <strong>{{ password }}</strong>. Un mot de passe aléatoire a été défini : <strong>{{ password }}</strong>.
For security reasons, please change it as soon as you log in the first time. Par sécurité, merci de le changer dès votre connexion.
</p> </p>
<p> <p>
In the event of a problem, please contact us by e-mail at the following address En cas de problème, merci de nous contacter soit par mail à l'adresse
<a href="mailto:eteam_moc@proton.me">eteam_moc@proton.me</a>. <a href="mailto:contact@tfjm.org">contact@tfjm.org</a>.
</p> </p>
<p> <p>
Sincerely yours, Bien cordialement,
</p> </p>
-- --
<p> <p>
{% trans "The ETEAM team." %}<br> {% trans "The TFJM² team." %}<br>
</p> </p>

View File

@ -1,14 +1,17 @@
{% load i18n %} {% load i18n %}
Hi {{ user.registration }}, Bonjour {{ user.registration }},
You have been invited by {{ inviter.registration }} to join the ETEAM platform, available at https://{{ domain }}. You have a volunteer account. Vous avez été invités par {{ inviter.registration }} à rejoindre la plateforme du TFJM², accessible à l'adresse
A random password has been set: {{ password }}. https://{{ domain }}/. Vous disposez d'un compte de bénévole.
For security reasons, please change it as soon as you log in the first time.
In the event of a problem, please contact us by e-mail at the following address eteam_moc@proton.me. Un mot de passe aléatoire a été défini : {{ password }}.
Par sécurité, merci de le changer dès votre connexion.
Sincerely yours, En cas de problème, merci de nous contacter soit par mail à l'adresse contact@tfjm.org, soit sur la plateforme
de chat accessible sur https://element.tfjm.org/ en vous connectant avec les mêmes identifiants.
Bien cordialement,
-- --
{% trans "The ETEAM team." %} {% trans "The TFJM² team." %}

View File

@ -13,7 +13,7 @@
</p> </p>
<p> <p>
{% trans "You recently registered on the ETEAM platform. Please click on the link below to confirm your registration." %} {% trans "You recently registered on the TFJM² platform. Please click on the link below to confirm your registration." %}
</p> </p>
<p> <p>
@ -36,5 +36,5 @@
-- --
<p> <p>
{% trans "The ETEAM team." %}<br> {% trans "The TFJM² team." %}<br>
</p> </p>

View File

@ -2,7 +2,7 @@
{% trans "Hi" %} {{ user.registration }}, {% trans "Hi" %} {{ user.registration }},
{% trans "You recently registered on the ETEAM platform. Please click on the link below to confirm your registration." %} {% trans "You recently registered on the TFJM² platform. Please click on the link below to confirm your registration." %}
https://{{ domain }}{% url 'registration:email_validation' uidb64=uid token=token %} https://{{ domain }}{% url 'registration:email_validation' uidb64=uid token=token %}
@ -12,4 +12,4 @@ https://{{ domain }}{% url 'registration:email_validation' uidb64=uid token=toke
{% trans "Thanks" %}, {% trans "Thanks" %},
{% trans "The ETEAM team." %} {% trans "The TFJM² team." %}

View File

@ -14,7 +14,7 @@
<p> <p>
{% blocktrans trimmed with amount=payment.amount team=payment.team.trigram tournament=payment.tournament.name %} {% blocktrans trimmed with amount=payment.amount team=payment.team.trigram tournament=payment.tournament.name %}
We successfully received the payment of {{ amount }} € for your participation for the ETEAM in the team {{ team }}! We successfully received the payment of {{ amount }} € for your participation for the TFJM² in the team {{ team }} for the tournament {{ tournament }}!
{% endblocktrans %} {% endblocktrans %}
</p> </p>
@ -32,13 +32,17 @@
</ul> </ul>
</p> </p>
<p>
{% trans "Please note that these dates may be subject to change. If your local organizers gave you different dates, trust them." %}
</p>
<p> <p>
{% trans "NB: This mail don't represent a payment receipt. The payer should receive a mail from Hello Asso. If it is not the case, please contact us if necessary" %} {% trans "NB: This mail don't represent a payment receipt. The payer should receive a mail from Hello Asso. If it is not the case, please contact us if necessary" %}
</p> </p>
-- --
<p> <p>
{% trans "The ETEAM team." %}<br> {% trans "The TFJM² team." %}<br>
</p> </p>
</body> </body>
</html> </html>

View File

@ -2,7 +2,7 @@
{% trans "Hi" %} {{ registration|safe }}, {% trans "Hi" %} {{ registration|safe }},
{% blocktrans trimmed with amount=payment.amount team=payment.team.trigram tournament=payment.tournament.name %} {% blocktrans trimmed with amount=payment.amount team=payment.team.trigram tournament=payment.tournament.name %}
We successfully received the payment of {{ amount }} € for your participation for the ETEAM in the team {{ team }}! We successfully received the payment of {{ amount }} € for your participation for the TFJM² in the team {{ team }} for the tournament {{ tournament }}!
{% endblocktrans %} {% endblocktrans %}
{% trans "Your registration is now fully completed, and you can work on your solutions." %} {% trans "Your registration is now fully completed, and you can work on your solutions." %}
@ -13,8 +13,10 @@ We successfully received the payment of {{ amount }} € for your participation
* {% trans "Problems draw:" %} {{ payment.tournament.solutions_draw|date }} * {% trans "Problems draw:" %} {{ payment.tournament.solutions_draw|date }}
* {% trans "Tournament dates:" %} {% trans "From" %} {{ payment.tournament.date_start|date }} {% trans "to" %} {{ payment.tournament.date_end|date }} * {% trans "Tournament dates:" %} {% trans "From" %} {{ payment.tournament.date_start|date }} {% trans "to" %} {{ payment.tournament.date_end|date }}
{% trans "Please note that these dates may be subject to change. If your local organizers gave you different dates, trust them." %}
{% trans "NB: This mail don't represent a payment receipt. The payer should receive a mail from Hello Asso. If it is not the case, please contact us if necessary" %} {% trans "NB: This mail don't represent a payment receipt. The payer should receive a mail from Hello Asso. If it is not the case, please contact us if necessary" %}
-- --
{% trans "The ETEAM team" %} {% trans "The TFJM² team" %}

View File

@ -14,7 +14,7 @@
<p> <p>
{% blocktrans trimmed with amount=payment.amount team=payment.team.trigram tournament=payment.tournament %} {% blocktrans trimmed with amount=payment.amount team=payment.team.trigram tournament=payment.tournament %}
You are registered for the ETEAM. Your team {{ team }} has been successfully validated. You are registered for the TFJM² of {{ tournament }}. Your team {{ team }} has been successfully validated.
To end your inscription, you must pay the amount of {{ amount }} €. To end your inscription, you must pay the amount of {{ amount }} €.
{% endblocktrans %} {% endblocktrans %}
</p> </p>
@ -49,7 +49,7 @@
-- --
<p> <p>
{% trans "The ETEAM team." %}<br> {% trans "The TFJM² team." %}<br>
</p> </p>
</body> </body>
</html> </html>

View File

@ -2,7 +2,7 @@
{% trans "Hi" %} {{ registration|safe }}, {% trans "Hi" %} {{ registration|safe }},
{% blocktrans trimmed with amount=payment.amount team=payment.team.trigram tournament=payment.tournament %} {% blocktrans trimmed with amount=payment.amount team=payment.team.trigram tournament=payment.tournament %}
You are registered for the ETEAM. Your team {{ team }} has been successfully validated. You are registered for the TFJM² of {{ tournament }}. Your team {{ team }} has been successfully validated.
To end your inscription, you must pay the amount of {{ amount }} €. To end your inscription, you must pay the amount of {{ amount }} €.
{% endblocktrans %} {% endblocktrans %}
{% if payment.grouped %} {% if payment.grouped %}
@ -19,4 +19,4 @@ https://{{ domain }}{% url "registration:update_payment" pk=payment.pk %}
{% trans "If you have any problem, feel free to contact us." %} {% trans "If you have any problem, feel free to contact us." %}
-- --
The ETEAM team The TFJM² team

View File

@ -9,8 +9,19 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h2>{% trans "Sign up" %}</h2> {% now "c" as now %}
{% if now < TFJM.REGISTRATION_DATES.open.isoformat and not user.registration.is_admin %}
<div class="alert alert-warning">
{% trans "Thank you for your great interest, but registrations are not opened yet!" %}
{% trans "They will open on:" %} {{ TFJM.REGISTRATION_DATES.open|date:'DATETIME_FORMAT' }}.
{% trans "Please come back at this time to register!" %}
</div>
{% elif now > TFJM.REGISTRATION_DATES.close.isoformat and not user.registration.is_admin %}
<div class="alert alert-danger">
{% trans "Registrations are closed for this year. We hope to see you next year!" %}
{% trans "If needed, you can contact us by mail." %}
</div>
{% else %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ form|crispy }} {{ form|crispy }}
@ -33,6 +44,7 @@
<div id="coach_registration_form" class="d-none"> <div id="coach_registration_form" class="d-none">
{{ coach_registration_form|crispy }} {{ coach_registration_form|crispy }}
</div> </div>
{% endif %}
{% endblock %} {% endblock %}
{% block extrajavascript %} {% block extrajavascript %}

View File

@ -17,6 +17,7 @@
% Specials % Specials
\newcommand{\writingsep}{\vrule height 4ex width 0pt} \newcommand{\writingsep}{\vrule height 4ex width 0pt}
\newcommand{\cdt}{\kern-0.5pt\ensuremath\cdot\kern-0.5pt}
% Page formating % Page formating
\hoffset -1in \hoffset -1in
@ -56,19 +57,23 @@ Autorisation d'enregistrement et de diffusion de l'image ({{ tournament.name }})
Je soussign\'e {{ registration|safe|default:"\dotfill" }}\\ Je soussign\'e\cdt{}e {{ registration|safe|default:"\dotfill" }}\\
demeurant au {{ registration.address|safe|default:"\dotfill" }} demeurant au {{ registration.address|safe|default:"\dotfill" }}
\medskip \medskip
Cochez la/les cases correspondantes.\\ Cochez la/les cases correspondantes.\\
\medskip \medskip
\fbox{\textcolor{white}{A}} Autorise l'association Animath, \`a l'occasion du $\mathbb{TFJM}^2$ de {{ tournament.name }} \fbox{\textcolor{white}{A}} Autorise l'association Animath, \`a l'occasion du $\mathbb{TFJM}^2$
du {{ tournament.date_start }} au {{ tournament.date_end }} à : {{ tournament.place }}, \`a me photographier ou \`a me {% if tournament.unified_registration %} dans
filmer et \`a diffuser les photos et/ou les vid\'eos r\'ealis\'ees \`a cette occasion sur son site et sur les sites 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)
partenaires. D\'eclare c\'eder \`a titre gracieux \`a Animath le droit dutiliser mon image sur tous ses supports {% else %} de
d'information : brochures, sites web, r\'eseaux sociaux. Animath devient, par la pr\'esente, cessionnaire des droits {{ tournament.name }} du {{ tournament.date_start }} au {{ tournament.date_end }} à : {{ tournament.place }},
pendant toute la dur\'ee pour laquelle ont \'et\'e acquis les droits d'auteur de ces photographies.\\ {% endif %} \`a
me photographier ou \`a me filmer et \`a diffuser les photos et/ou les vid\'eos r\'ealis\'ees \`a cette occasion
sur son site et sur les sites partenaires. D\'eclare c\'eder \`a titre gracieux \`a Animath le droit dutiliser mon
image sur tous ses supports d'information : brochures, sites web, r\'eseaux sociaux. Animath devient, par la pr\'esente,
cessionnaire des droits pendant toute la dur\'ee pour laquelle ont \'et\'e acquis les droits d'auteur de ces photographies.\\
\medskip \medskip
Animath s'engage, conform\'ement aux dispositions l\'egales en vigueur relatives au droit \`a l'image, \`a ce que la Animath s'engage, conform\'ement aux dispositions l\'egales en vigueur relatives au droit \`a l'image, \`a ce que la
@ -98,7 +103,7 @@ Animath, IHP, 11 rue Pierre et Marie Curie, 75231 Paris cedex 05.\\
\bigskip \bigskip
Signature pr\'ec\'ed\'ee de la mention \og lu et approuv\'e \fg{} Signature pr\'ec\'ed\'ee de la mention « lu et approuv\'e »
\medskip \medskip
@ -106,7 +111,7 @@ Signature pr\'ec\'ed\'ee de la mention \og lu et approuv\'e \fg{}
\begin{minipage}[c]{0.5\textwidth} \begin{minipage}[c]{0.5\textwidth}
\underline{Le participant :}\\ \underline{La/le participant\cdt{}e :}\\
Fait \`a :\\ Fait \`a :\\
le le

View File

@ -17,6 +17,7 @@
% Specials % Specials
\newcommand{\writingsep}{\vrule height 4ex width 0pt} \newcommand{\writingsep}{\vrule height 4ex width 0pt}
\newcommand{\cdt}{\kern-0.5pt\ensuremath\cdot\kern-0.5pt}
% Page formating % Page formating
\hoffset -1in \hoffset -1in
@ -57,20 +58,25 @@ Autorisation d'enregistrement et de diffusion de l'image
Je soussign\'e \dotfill (p\`ere, m\`ere, responsable l\'egal) \\ Je soussign\'e\cdt{}e \dotfill (p\`ere, m\`ere, responsable l\'egal) \\
agissant en qualit\'e de repr\'esentant de {{ registration|safe|default:"\dotfill" }}\\ agissant en qualit\'e de repr\'esentant\cdt{}e de {{ registration|safe|default:"\dotfill" }}\\
demeurant au {{ registration.address|safe|default:"\dotfill" }} demeurant au {{ registration.address|safe|default:"\dotfill" }}
\medskip \medskip
Cochez la/les cases correspondantes.\\ Cochez la/les cases correspondantes.\\
\medskip \medskip
\fbox{\textcolor{white}{A}} Autorise l'association Animath, \`a l'occasion du $\mathbb{TFJM}^2$ de {{ tournament.name }} \fbox{\textcolor{white}{A}} Autorise l'association Animath, \`a l'occasion du $\mathbb{TFJM}^2$
du {{ tournament.date_start }} au {{ tournament.date_end }} à : {{ tournament.place }}, \`a photographier ou \`a filmer {% if tournament.unified_registration %} dans
l'enfant et \`a diffuser les photos et/ou les vid\'eos r\'ealis\'ees \`a cette occasion sur son site et sur les sites 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)
partenaires. D\'eclare c\'eder \`a titre gracieux \`a Animath le droit dutiliser l'image de l'enfant sur tous ses {% else %} de
supports d'information : brochures, sites web, r\'eseaux sociaux. Animath devient, par la pr\'esente, cessionnaire des {{ tournament.name }} du {{ tournament.date_start }} au {{ tournament.date_end }} à : {{ tournament.place }},
droits pendant toute la dur\'ee pour laquelle ont \'et\'e acquis les droits d'auteur de ces photographies.\\ {% endif %} \`a
photographier ou \`a filmer l'enfant et \`a diffuser les photos et/ou les vid\'eos r\'ealis\'ees \`a cette occasion
sur son site et sur les sites partenaires. D\'eclare c\'eder \`a titre gracieux \`a Animath le droit dutiliser l'image
de l'enfant sur tous ses supports d'information : brochures, sites web, r\'eseaux sociaux. Animath devient, par la
pr\'esente, cessionnaire des droits pendant toute la dur\'ee pour laquelle ont \'et\'e acquis les droits d'auteur de
ces photographies.\\
\medskip \medskip
Animath s'engage, conform\'ement aux dispositions l\'egales en vigueur relatives au droit \`a l'image, \`a ce que la Animath s'engage, conform\'ement aux dispositions l\'egales en vigueur relatives au droit \`a l'image, \`a ce que la
@ -100,14 +106,14 @@ Animath, IHP, 11 rue Pierre et Marie Curie, 75231 Paris cedex 05.\\
\bigskip \bigskip
Signatures pr\'ec\'ed\'ees de la mention \og lu et approuv\'e \fg{} Signatures pr\'ec\'ed\'ees de la mention « lu et approuv\'e »
\medskip \medskip
\begin{minipage}[c]{0.5\textwidth} \begin{minipage}[c]{0.5\textwidth}
\underline{Le responsable l\'egal :}\\ \underline{La/le responsable l\'egal\cdt{}e :}\\
Fait \`a :\\ Fait \`a :\\
le : le :

View File

@ -17,6 +17,7 @@
% Specials % Specials
\newcommand{\writingsep}{\vrule height 4ex width 0pt} \newcommand{\writingsep}{\vrule height 4ex width 0pt}
\newcommand{\cdt}{\kern-0.5pt\ensuremath\cdot\kern-0.5pt}
% Page formating % Page formating
\hoffset -1in \hoffset -1in
@ -45,22 +46,39 @@
\Large \bf Autorisation parentale pour les mineurs ({{ tournament.name }}) \Large \bf Autorisation parentale pour les mineurs ({{ tournament.name }})
\end{center} \end{center}
Je soussigné(e) \hrulefill,\\ Je soussigné\cdt{}e \hrulefill,\\
responsable légal, demeurant \writingsep\hrulefill\\ responsable légal\cdt{}e, demeurant \writingsep\hrulefill\\
\writingsep\hrulefill,\\ \writingsep\hrulefill,\\
\writingsep autorise {{ registration|default:"\hrulefill" }},\\ \writingsep autorise {{ registration|default:"\hrulefill" }},\\
(e) le {{ registration.birth_date }}, \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$) organisé \`a : à 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.
\end{itemize}
{% else %}
organisé \`a :
{{ tournament.place }}, du {{ tournament.date_start }} au {{ tournament.date_end }}. {{ tournament.place }}, du {{ tournament.date_start }} au {{ tournament.date_end }}.
{% endif %}
Iel se rendra au lieu indiqu\'e ci-dessus le samedi matin et quittera les lieux l'après-midi du dimanche par Iel se rendra au lieu indiqu\'e ci-dessus le samedi matin et quittera les lieux l'après-midi du dimanche par
ses propres moyens et sous la responsabilité du représentant légal. 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.
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 %}
\vspace{8ex} \vspace{8ex}
Fait à \vrule width 10cm height 0pt depth 0.4pt, le \phantom{232323}/\phantom{XXX}/{% now "Y" %}, Fait à \vrule width 10cm height 0pt depth 0.4pt, le \phantom{232323}/\phantom{XXX}/{% now "Y" %}
\vspace{4ex}
Signature :
\vfill \vfill
\vfill \vfill

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.first_name }}
{{ object.user.last_name }} {{ object.user.last_name }}
{{ object.user.email }} {{ object.user.email }}
{{ object.type }}
{{ object.professional_activity }}
{{ object.address }}
{{ object.zip_code }}
{{ object.city }}
{{ object.phone_number }} {{ object.phone_number }}
{{ object.team.name }}
{{ object.team.trigram }}

View File

@ -1,16 +1,7 @@
{{ object.user.first_name }} {{ object.user.first_name }}
{{ object.user.last_name }} {{ object.user.last_name }}
{{ object.user.email }} {{ 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.phone_number }}
{{ object.responsible_name }} {{ object.responsible_name }}
{{ object.reponsible_phone }} {{ object.reponsible_phone }}
{{ object.reponsible_email }} {{ object.reponsible_email }}
{{ object.team.name }}
{{ object.team.trigram }}

View File

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

View File

@ -1,14 +1,17 @@
# Copyright (C) 2020 by Animath # Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from datetime import timedelta
import os import os
from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase from django.test import override_settings, TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from django.utils.encoding import force_bytes from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode from django.utils.http import urlsafe_base64_encode
from participation.models import Team from participation.models import Team
@ -114,6 +117,9 @@ class TestRegistration(TestCase):
self.assertRedirects(response, "http://" + Site.objects.get().domain + self.assertRedirects(response, "http://" + Site.objects.get().domain +
str(self.coach.registration.get_absolute_url()), 302, 200) str(self.coach.registration.get_absolute_url()), 302, 200)
# Ensure that we are between registration dates
@override_settings(REGISTRATION_DATES={'open': timezone.now() - timedelta(days=1),
'close': timezone.now() + timedelta(days=1)})
def test_registration(self): def test_registration(self):
""" """
Ensure that the signup form is working successfully. Ensure that the signup form is working successfully.
@ -223,6 +229,52 @@ class TestRegistration(TestCase):
response = self.client.get(reverse("registration:email_validation_resend", args=(user.pk,))) response = self.client.get(reverse("registration:email_validation_resend", args=(user.pk,)))
self.assertRedirects(response, reverse("registration:email_validation_sent"), 302, 200) self.assertRedirects(response, reverse("registration:email_validation_sent"), 302, 200)
def test_registration_dates(self):
"""
Test that registrations are working only between registration dates.
"""
self.client.logout()
# Test that registration between open and close dates are working
with override_settings(REGISTRATION_DATES={'open': timezone.now() - timedelta(days=2),
'close': timezone.now() + timedelta(days=2)}):
response = self.client.get(reverse("registration:signup"))
self.assertEqual(response.status_code, 200)
self.assertIn("<i class=\"fas fa-user-plus\"></i> Register", response.content.decode())
self.assertNotIn("registrations are not opened", response.content.decode())
self.assertNotIn("Registrations are closed", response.content.decode())
response = self.client.post(reverse("registration:signup"))
self.assertFormError(response.context['form'], None, [])
# Test that registration before open date is not working
with override_settings(REGISTRATION_DATES={'open': timezone.now() + timedelta(days=1),
'close': timezone.now() + timedelta(days=2)}):
response = self.client.get(reverse("registration:signup"))
self.assertEqual(response.status_code, 200)
self.assertNotIn("<i class=\"fas fa-user-plus\"></i> Register", response.content.decode())
self.assertIn("registrations are not opened", response.content.decode())
response = self.client.post(reverse("registration:signup"))
self.assertEqual(response.status_code, 200)
self.assertFormError(response.context['form'], None,
"Registrations are not opened yet. They will open on the "
f"{settings.REGISTRATION_DATES['open']:%Y-%m-%d %H:%M}.")
# Test that registration after close date is not working
with override_settings(REGISTRATION_DATES={'open': timezone.now() - timedelta(days=2),
'close': timezone.now() - timedelta(days=1)}):
response = self.client.get(reverse("registration:signup"))
self.assertEqual(response.status_code, 200)
self.assertNotIn("<i class=\"fas fa-user-plus\"></i> Register", response.content.decode())
self.assertIn("Registrations are closed", response.content.decode())
response = self.client.post(reverse("registration:signup"))
self.assertEqual(response.status_code, 200)
self.assertFormError(response.context['form'], None,
"Registrations for this year are closed since "
f"{settings.REGISTRATION_DATES['close']:%Y-%m-%d %H:%M}.")
def test_login(self): def test_login(self):
""" """
With a registered user, try to log in With a registered user, try to log in

View File

@ -18,7 +18,7 @@ from django.http import FileResponse, Http404
from django.shortcuts import redirect, resolve_url from django.shortcuts import redirect, resolve_url
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils import translation from django.utils import timezone, translation
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.http import urlsafe_base64_decode from django.utils.http import urlsafe_base64_decode
from django.utils.text import format_lazy from django.utils.text import format_lazy
@ -60,6 +60,22 @@ class SignupView(CreateView):
return context return context
def get_form(self, form_class=None):
form = super().get_form(form_class)
if self.request.method in ("POST", "PUT") \
and (not self.request.user.is_authenticated or not self.request.user.registration.is_admin):
# Check that registrations are opened
now = timezone.now()
if now < settings.REGISTRATION_DATES['open']:
form.add_error(None, format_lazy(_("Registrations are not opened yet. "
"They will open on the {opening_date:%Y-%m-%d %H:%M}."),
opening_date=settings.REGISTRATION_DATES['open']))
elif now > settings.REGISTRATION_DATES['close']:
form.add_error(None, format_lazy(_("Registrations for this year are closed since "
"{closing_date:%Y-%m-%d %H:%M}."),
closing_date=settings.REGISTRATION_DATES['close']))
return form
@transaction.atomic @transaction.atomic
def form_valid(self, form): def form_valid(self, form):
role = form.cleaned_data["role"] role = form.cleaned_data["role"]
@ -121,6 +137,7 @@ class AddOrganizerView(VolunteerMixin, CreateView):
form.instance.set_password(password) form.instance.set_password(password)
form.instance.save() form.instance.save()
with translation.override(settings.PREFERRED_LANGUAGE_CODE):
subject = f"[{settings.APP_NAME}] " + str(_("New organizer account")) subject = f"[{settings.APP_NAME}] " + str(_("New organizer account"))
site = Site.objects.first() site = Site.objects.first()
message = render_to_string('registration/mails/add_organizer.txt', dict(user=registration.user, message = render_to_string('registration/mails/add_organizer.txt', dict(user=registration.user,
@ -436,8 +453,8 @@ class AuthorizationTemplateView(TemplateView):
if not Tournament.objects.filter(name__iexact=self.request.GET.get("tournament_name")).exists(): if not Tournament.objects.filter(name__iexact=self.request.GET.get("tournament_name")).exists():
raise PermissionDenied("Ce tournoi n'existe pas.") raise PermissionDenied("Ce tournoi n'existe pas.")
context["tournament"] = Tournament.objects.get(name__iexact=self.request.GET.get("tournament_name")) context["tournament"] = Tournament.objects.get(name__iexact=self.request.GET.get("tournament_name"))
elif settings.TFJM_APP == "ETEAM": elif settings.SINGLE_TOURNAMENT:
# One single tournament # One single tournament (for ETEAM)
context["tournament"] = Tournament.objects.first() context["tournament"] = Tournament.objects.first()
else: else:
raise PermissionDenied("Merci d'indiquer un tournoi.") raise PermissionDenied("Merci d'indiquer un tournoi.")
@ -445,9 +462,8 @@ class AuthorizationTemplateView(TemplateView):
return context return context
def render_to_response(self, context, **response_kwargs): def render_to_response(self, context, **response_kwargs):
translation.activate(settings.PREFERRED_LANGUAGE_CODE)
template_name = self.get_template_names()[0] template_name = self.get_template_names()[0]
with translation.override(settings.PREFERRED_LANGUAGE_CODE):
tex = render_to_string(template_name, context=context, request=self.request) tex = render_to_string(template_name, context=context, request=self.request)
temp_dir = mkdtemp() temp_dir = mkdtemp()
with open(os.path.join(temp_dir, "texput.tex"), "w") as f: with open(os.path.join(temp_dir, "texput.tex"), "w") as f:
@ -710,10 +726,11 @@ class PhotoAuthorizationView(LoginRequiredMixin, View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
filename = kwargs["filename"] filename = kwargs["filename"]
path = f"media/authorization/photo/{filename}" path = f"media/authorization/photo/{filename}"
if not os.path.exists(path): student_qs = ParticipantRegistration.objects.filter(Q(photo_authorization__endswith=filename)
raise Http404
student = ParticipantRegistration.objects.get(Q(photo_authorization__endswith=filename)
| Q(photo_authorization_final__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 user = request.user
if not (student.user == user or user.registration.is_admin or user.registration.is_volunteer and student.team 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()): and student.team.participation.tournament in user.registration.organized_tournaments.all()):

View File

@ -1,29 +1,28 @@
channels[daphne]~=4.0.0 channels[daphne]~=4.2.2
channels-redis~=4.2.0 channels-redis~=4.2.1
crispy-bootstrap5~=2023.10 citric~=1.4.0
Django>=5.0.3,<6.0 crispy-bootstrap5~=2025.4
django-crispy-forms~=2.1 Django>=5.2,<6.0
django-extensions~=3.2.3 django-crispy-forms~=2.4
django-filter~=23.5 django-filter~=25.1
git+https://github.com/django-haystack/django-haystack.git#v3.3b2 django-haystack~=3.3.0
django-mailer~=2.3.1 django-mailer~=2.3.2
django-phonenumber-field~=7.3.0 django-phonenumber-field~=8.1.0
django-pipeline~=3.1.0 django-pipeline~=4.0.0
django-polymorphic~=3.1.0 django-polymorphic~=3.1.0
django-tables2~=2.7.0 django-tables2~=2.7.5
djangorestframework~=3.14.0 djangorestframework~=3.16.0
django-rest-polymorphic~=0.1.10 django-rest-polymorphic~=0.1.10
elasticsearch~=7.17.9 elasticsearch~=7.17.9
gspread~=6.1.0 gspread~=6.2.0
gunicorn~=21.2.0 gunicorn~=23.0.0
odfpy~=1.4.1 odfpy~=1.4.1
pandas~=2.2.1 pandas~=2.2.3
phonenumbers~=8.13.27 phonenumbers~=9.0.3
psycopg2-binary~=2.9.9 psycopg~=3.2.6
pypdf~=3.17.4 pypdf~=5.4.0
ipython~=8.20.0
python-magic~=0.4.27 python-magic~=0.4.27
requests~=2.31.0 requests~=2.32.3
sympasoap~=1.1 sympasoap~=1.1
uvicorn~=0.25.0 uvicorn~=0.34.2
websockets~=12.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 # 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 # Update search index
*/2 * * * * cd /code && python manage.py update_index &> /dev/null */2 * * * * cd /code && python manage.py update_index &> /dev/null
@ -11,15 +6,16 @@
7 3 * * * cd /code && python manage.py fix_sympa_lists &> /dev/null 7 3 * * * cd /code && python manage.py fix_sympa_lists &> /dev/null
# Check payments from Hello Asso # Check payments from Hello Asso
*/6 * * * * cd /code && python manage.py check_hello_asso &> /dev/null */30 * * 03-05 * cd /code && python manage.py check_hello_asso -v 0
# Send reminders for payments
30 6 * * 1 cd /code && python manage.py remind_payments &> /dev/null
# Check notation sheets every 15 minutes from 08:00 to 23:00 on fridays to mondays in april and may # Send reminders for payments
# */15 8-23 * 4-5 5,6,7,1 cd /code && python manage.py parse_notation_sheets -v 0 30 6 * 03-05 1 cd /code && python manage.py remind_payments -v 0
# Update Google Drive notifications daily # Update Google Drive notifications daily
0 0 * * * cd /code && python manage.py renew_gdrive_notifications &> /dev/null 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 # Clean temporary files
30 * * * * rm -rf /tmp/* 30 * * * * rm -rf /tmp/*

View File

@ -10,14 +10,22 @@ def tfjm_context(request):
'TFJM': { 'TFJM': {
'APP': settings.TFJM_APP, 'APP': settings.TFJM_APP,
'APP_NAME': settings.APP_NAME, 'APP_NAME': settings.APP_NAME,
'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, 'NB_ROUNDS': settings.NB_ROUNDS,
'ML_MANAGEMENT': settings.ML_MANAGEMENT, 'ML_MANAGEMENT': settings.ML_MANAGEMENT,
'PAYMENT_MANAGEMENT': settings.PAYMENT_MANAGEMENT, 'PAYMENT_MANAGEMENT': settings.PAYMENT_MANAGEMENT,
'SINGLE_TOURNAMENT': 'RECOMMENDED_SOLUTIONS_COUNT': settings.RECOMMENDED_SOLUTIONS_COUNT,
Tournament.objects.first() if Tournament.objects.exists() and settings.TFJM_APP else None, 'REGISTRATION_DATES': settings.REGISTRATION_DATES,
'SINGLE_TOURNAMENT': settings.SINGLE_TOURNAMENT,
'HEALTH_SHEET_REQUIRED': settings.HEALTH_SHEET_REQUIRED, 'HEALTH_SHEET_REQUIRED': settings.HEALTH_SHEET_REQUIRED,
'VACCINE_SHEET_REQUIRED': settings.VACCINE_SHEET_REQUIRED, 'VACCINE_SHEET_REQUIRED': settings.VACCINE_SHEET_REQUIRED,
'MOTIVATION_LETTER_REQUIRED': settings.MOTIVATION_LETTER_REQUIRED, 'MOTIVATION_LETTER_REQUIRED': settings.MOTIVATION_LETTER_REQUIRED,
'SUGGEST_ANIMATH': settings.SUGGEST_ANIMATH, 'SUGGEST_ANIMATH': settings.SUGGEST_ANIMATH,
} },
'TFJM_TOURNAMENT':
Tournament.objects.first() if Tournament.objects.exists() and settings.SINGLE_TOURNAMENT else None,
} }

View File

@ -1,7 +1,7 @@
# Copyright (C) 2020 by Animath # Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import os from django.conf import settings
_client = None _client = None
@ -9,10 +9,10 @@ _client = None
def get_sympa_client(): def get_sympa_client():
global _client global _client
if _client is None: 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 from sympasoap import Client
_client = Client("https://" + os.getenv("SYMPA_URL")) _client = Client("https://" + settings.SYMPA_URL)
_client.login(os.getenv("SYMPA_EMAIL"), os.getenv("SYMPA_PASSWORD")) _client.login(settings.SYMPA_EMAIL, settings.SYMPA_PASSWORD)
else: else:
_client = FakeSympaSoapClient() _client = FakeSympaSoapClient()
return _client return _client

View File

@ -13,6 +13,7 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.0/ref/settings/ https://docs.djangoproject.com/en/5.0/ref/settings/
""" """
from datetime import datetime
import os import os
import sys import sys
@ -73,11 +74,11 @@ INSTALLED_APPS = [
'draw', 'draw',
'registration', 'registration',
'participation', 'participation',
'survey',
] ]
if "test" not in sys.argv: # pragma: no cover if "test" not in sys.argv: # pragma: no cover
INSTALLED_APPS += [ INSTALLED_APPS += [
'django_extensions',
'mailer', 'mailer',
] ]
@ -195,7 +196,14 @@ STATICFILES_DIRS = [
STATIC_ROOT = os.path.join(BASE_DIR, "static") STATIC_ROOT = os.path.join(BASE_DIR, "static")
STATICFILES_STORAGE = 'pipeline.storage.PipelineStorage' STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
'staticfiles': {
'BACKEND': 'pipeline.storage.PipelineStorage',
},
}
STATICFILES_FINDERS = ( STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.FileSystemFinder',
@ -205,6 +213,7 @@ STATICFILES_FINDERS = (
PIPELINE = { PIPELINE = {
'DISABLE_WRAPPER': True, 'DISABLE_WRAPPER': True,
'JS_COMPRESSOR': 'pipeline.compressors.uglifyjs.UglifyJSCompressor',
'JAVASCRIPT': { 'JAVASCRIPT': {
'main': { 'main': {
'source_filenames': ( 'source_filenames': (
@ -262,7 +271,7 @@ _db_type = os.getenv('DJANGO_DB_TYPE', 'sqlite').lower()
if _db_type == 'mysql' or _db_type.startswith('postgres') or _db_type == 'psql': # pragma: no cover if _db_type == 'mysql' or _db_type.startswith('postgres') or _db_type == 'psql': # pragma: no cover
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.mysql' if _db_type == 'mysql' else 'django.db.backends.postgresql_psycopg2', 'ENGINE': 'django.db.backends.mysql' if _db_type == 'mysql' else 'django.db.backends.postgresql',
'NAME': os.environ.get('DJANGO_DB_NAME', 'tfjm'), 'NAME': os.environ.get('DJANGO_DB_NAME', 'tfjm'),
'USER': os.environ.get('DJANGO_DB_USER', 'tfjm'), 'USER': os.environ.get('DJANGO_DB_USER', 'tfjm'),
'PASSWORD': os.environ.get('DJANGO_DB_PASSWORD', 'CHANGE_ME_IN_ENV_SETTINGS'), 'PASSWORD': os.environ.get('DJANGO_DB_PASSWORD', 'CHANGE_ME_IN_ENV_SETTINGS'),
@ -292,6 +301,12 @@ CHANNEL_LAYERS = {
PHONENUMBER_DB_FORMAT = 'NATIONAL' PHONENUMBER_DB_FORMAT = 'NATIONAL'
PHONENUMBER_DEFAULT_REGION = 'FR' 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 # Hello Asso API creds
HELLOASSO_CLIENT_ID = os.getenv('HELLOASSO_CLIENT_ID', 'CHANGE_ME_IN_ENV_SETTINGS') 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') HELLOASSO_CLIENT_SECRET = os.getenv('HELLOASSO_CLIENT_SECRET', 'CHANGE_ME_IN_ENV_SETTINGS')
@ -314,6 +329,10 @@ GOOGLE_SERVICE_CLIENT = {
# The ID of the Google Drive folder where to store the notation sheets # 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") 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 # Custom parameters
FORBIDDEN_TRIGRAMS = [ FORBIDDEN_TRIGRAMS = [
"BIT", "BIT",
@ -351,24 +370,34 @@ if TFJM_APP == "TFJM":
TEAM_CODE_LENGTH = 3 TEAM_CODE_LENGTH = 3
RECOMMENDED_SOLUTIONS_COUNT = 5 RECOMMENDED_SOLUTIONS_COUNT = 5
NB_ROUNDS = 2 NB_ROUNDS = 2
HAS_OBSERVER = False
HAS_FINAL = True HAS_FINAL = True
ML_MANAGEMENT = True ML_MANAGEMENT = True
PAYMENT_MANAGEMENT = True PAYMENT_MANAGEMENT = True
SINGLE_TOURNAMENT = False
HEALTH_SHEET_REQUIRED = True HEALTH_SHEET_REQUIRED = True
VACCINE_SHEET_REQUIRED = True VACCINE_SHEET_REQUIRED = True
MOTIVATION_LETTER_REQUIRED = True MOTIVATION_LETTER_REQUIRED = True
SUGGEST_ANIMATH = True SUGGEST_ANIMATH = True
FIRST_EDITION = 2011 FIRST_EDITION = 2011
HOME_PAGE_LINK = "https://tfjm.org/"
LOGO_FILE = "tfjm.svg"
RULES_LINK = "https://tfjm.org/reglement"
REGISTRATION_DATES = dict(
open=datetime.fromisoformat("2025-01-15T12:00:00+0100"),
close=datetime.fromisoformat("2025-03-02T22:00:00+0100"),
)
PROBLEMS = [ PROBLEMS = [
"Triominos", "Une bonne humeur contagieuse",
"Rassemblements mathématiques", "Drôles de toboggans",
"Tournoi de ping-pong", "Plats à tarte gradués",
"Dépollution de la Seine", "Transformation de papillons",
"Électron libre", "Gerrymandering",
"Pièces truquées", "Le cauchemar de la ligne 20-25",
"Drôles de cookies", "Taxes routières",
"Création d'un jeu", "Points colorés sur un cercle",
] ]
elif TFJM_APP == "ETEAM": elif TFJM_APP == "ETEAM":
PREFERRED_LANGUAGE_CODE = 'en' PREFERRED_LANGUAGE_CODE = 'en'
@ -376,14 +405,24 @@ elif TFJM_APP == "ETEAM":
TEAM_CODE_LENGTH = 4 TEAM_CODE_LENGTH = 4
RECOMMENDED_SOLUTIONS_COUNT = 6 RECOMMENDED_SOLUTIONS_COUNT = 6
NB_ROUNDS = 3 NB_ROUNDS = 3
HAS_OBSERVER = True
HAS_FINAL = False HAS_FINAL = False
ML_MANAGEMENT = False ML_MANAGEMENT = False
PAYMENT_MANAGEMENT = False PAYMENT_MANAGEMENT = False
SINGLE_TOURNAMENT = True
HEALTH_SHEET_REQUIRED = False HEALTH_SHEET_REQUIRED = False
VACCINE_SHEET_REQUIRED = False VACCINE_SHEET_REQUIRED = False
MOTIVATION_LETTER_REQUIRED = False MOTIVATION_LETTER_REQUIRED = False
SUGGEST_ANIMATH = False SUGGEST_ANIMATH = False
FIRST_EDITION = 2024 FIRST_EDITION = 2024
HOME_PAGE_LINK = "https://eteam.tfjm.org/"
LOGO_FILE = "eteam.png"
RULES_LINK = "https://eteam.tfjm.org/rules/"
REGISTRATION_DATES = dict(
open=datetime.fromisoformat("2024-06-01T12:00:00+0200"),
close=datetime.fromisoformat("2024-07-04T20:00:00+0200"),
)
PROBLEMS = [ PROBLEMS = [
"Exploring Flatland", "Exploring Flatland",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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"]') document.querySelectorAll('[data-bs-target="#' + target + 'Modal"]')
.forEach(elem => elem.addEventListener('click', () => { .forEach(elem => elem.addEventListener('click', () => {
let modalBody = document.querySelector("#" + target + "Modal div.modal-body") 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() if (url instanceof Function) url = url()
fetch(url, {headers: {'CONTENT-ONLY': '1'}}) fetch(url, {headers: {'CONTENT-ONLY': '1'}})

View File

@ -94,8 +94,10 @@
{% javascript 'main' %} {% javascript 'main' %}
{{ TFJM|json_script:'TFJM_settings' }}
<script> <script>
CSRF_TOKEN = "{{ csrf_token }}"; const CSRF_TOKEN = "{{ csrf_token }}"
document.querySelectorAll(".invalid-feedback").forEach(elem => elem.classList.add('d-block')) document.querySelectorAll(".invalid-feedback").forEach(elem => elem.classList.add('d-block'))
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@ -104,7 +106,7 @@
{% if user.is_authenticated and user.registration.is_admin %} {% if user.is_authenticated and user.registration.is_admin %}
initModal("search", initModal("search",
() => "{% url "haystack_search" %}?q=" + encodeURI(document.getElementById("search-term").value), () => "{% url "haystack_search" %}?q=" + encodeURI(document.getElementById("search-term").value),
"search-results") "search-results", true)
{% endif %} {% endif %}
{% if not user.is_authenticated %} {% if not user.is_authenticated %}

View File

@ -2,10 +2,8 @@
<nav class="navbar navbar-expand-lg fixed-navbar shadow-sm"> <nav class="navbar navbar-expand-lg fixed-navbar shadow-sm">
<div class="container-fluid"> <div class="container-fluid">
{# TODO ETEAM Plus d'uniformité #} <a class="navbar-brand" href="{{ TFJM.HOME_PAGE_LINK }}">
<a class="navbar-brand" href="https://eteam.tfjm.org/"> <img src="{% static TFJM.LOGO_PATH %}" style="height: 2em;" alt="Logo {{ TFJM.APP_NAME }}" id="navbar-logo">
{# TODO ETEAM Plus d'uniformité #}
<img src="{% static "tfjm/img/eteam.png" %}" style="height: 2em;" alt="Logo ETEAM" id="navbar-logo">
</a> </a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" <button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#navbarNavDropdown" data-bs-target="#navbarNavDropdown"
@ -20,7 +18,7 @@
</li> </li>
<li class="nav-item active"> <li class="nav-item active">
{% if TFJM.SINGLE_TOURNAMENT %} {% if TFJM.SINGLE_TOURNAMENT %}
<a href="{% url 'participation:tournament_detail' pk=TFJM.SINGLE_TOURNAMENT.pk %}" class="nav-link"> <a href="{% url 'participation:tournament_detail' pk=TFJM_TOURNAMENT.pk %}" class="nav-link">
<i class="fas fa-calendar-day"></i> {% trans "Tournament" %} <i class="fas fa-calendar-day"></i> {% trans "Tournament" %}
</a> </a>
{% else %} {% else %}
@ -76,6 +74,9 @@
</li> </li>
{% endif %} {% endif %}
{% if user.registration.is_admin %} {% 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"> <li class="nav-item active">
<a class="nav-link" href="{% url "admin:index" %}"><i class="fas fa-cog"></i> {% trans "Administration" %}</a> <a class="nav-link" href="{% url "admin:index" %}"><i class="fas fa-cog"></i> {% trans "Administration" %}</a>
</li> </li>
@ -98,9 +99,12 @@
</li> </li>
{% endif %} {% endif %}
{% if not user.is_authenticated %} {% if not user.is_authenticated %}
{% now "c" as now %}
{% if TFJM.REGISTRATION_DATES.open.isoformat <= now and now <= TFJM.REGISTRATION_DATES.close.isoformat %}
<li class="nav-item active"> <li class="nav-item active">
<a class="nav-link" href="{% url "registration:signup" %}"><i class="fas fa-user-plus"></i> {% trans "Register" %}</a> <a class="nav-link" href="{% url "registration:signup" %}"><i class="fas fa-user-plus"></i> {% trans "Register" %}</a>
</li> </li>
{% endif %}
<li class="nav-item active"> <li class="nav-item active">
<a class="nav-link" href="#" data-bs-toggle="modal" data-bs-target="#loginModal"> <a class="nav-link" href="#" data-bs-toggle="modal" data-bs-target="#loginModal">
<i class="fas fa-sign-in-alt"></i> {% trans "Log in" %} <i class="fas fa-sign-in-alt"></i> {% trans "Log in" %}

View File

@ -23,7 +23,7 @@
</div> </div>
<div id="sidebar-card" class="collapse d-lg-block"> <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 %} {% for information in user.registration.important_informations %}
<div class="card my-2"> <div class="card my-2">
<div class="card-header bg-dark-subtle"> <div class="card-header bg-dark-subtle">

View File

@ -29,7 +29,6 @@ from registration.views import HealthSheetView, ParentalAuthorizationView, Photo
from .views import AdminSearchView from .views import AdminSearchView
urlpatterns = [ urlpatterns = [
# TODO ETEAM Rendre ça plus joli
path('', TemplateView.as_view(template_name=f"index_{settings.TFJM_APP.lower()}.html", path('', TemplateView.as_view(template_name=f"index_{settings.TFJM_APP.lower()}.html",
extra_context={'title': _("Home")}), extra_context={'title': _("Home")}),
name='index'), name='index'),
@ -45,6 +44,7 @@ urlpatterns = [
path('draw/', include('draw.urls')), path('draw/', include('draw.urls')),
path('participation/', include('participation.urls')), path('participation/', include('participation.urls')),
path('registration/', include('registration.urls')), path('registration/', include('registration.urls')),
path('survey/', include('survey.urls')),
path('media/authorization/photo/<str:filename>/', PhotoAuthorizationView.as_view(), path('media/authorization/photo/<str:filename>/', PhotoAuthorizationView.as_view(),
name='photo_authorization'), name='photo_authorization'),

View File

@ -1,7 +1,7 @@
[tox] [tox]
envlist = envlist =
py311
py312 py312
py313
linters linters
skipsdist = True skipsdist = True