1
0
mirror of https://gitlab.com/animath/si/plateforme.git synced 2025-07-05 12:03:58 +02:00

Compare commits

..

51 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
87 changed files with 2117 additions and 957 deletions

View File

@ -1,15 +1,12 @@
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
- pip install tox --no-cache-dir
script: tox -e py311
py312: py312:
stage: test stage: test
@ -36,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

@ -4,12 +4,10 @@ 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 libpq-dev libxml2-dev libxslt-dev \ RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libpq-dev libxml2-dev libxslt-dev \
npm libmagic texlive texmf-dist-fontsrecommended texmf-dist-lang texmf-dist-latexextra libmagic texlive texmf-dist-fontsrecommended texmf-dist-lang texmf-dist-latexextra uglify-js
RUN apk add --no-cache bash RUN 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
@ -37,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"]

View File

@ -30,9 +30,9 @@ Il faut maintenant récupérer ce dossier. Sortir du conteneur, et exécuter dan
.. code:: bash .. code:: bash
sudo docker cp tfjm_plateforme_1:/code/output/photo_authorizations . sudo docker cp tfjm-inscription-1:/code/output/photo_authorizations .
sudo mv photo_authorizations/* "data/owncloud/files/Emmy/Autorisations de droit à l'image/Autorisations de droit à l'image 2024/" 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/files/Emmy/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 sudo rmdir photo_authorizations
Il faut enfin réactualiser Owncloud. Exécuter en tant que www-data : Il faut enfin réactualiser Owncloud. Exécuter en tant que www-data :
@ -60,26 +60,43 @@ Sauvegardez alors le dossier ``/srv/TFJM/data/inscription/media`` et exportez la
.. code:: bash .. code:: bash
sudo cp -r data/inscription/media data/inscription/media-2024 sudo cp -r data/inscription/media data/inscription/media-2024
sudo docker compose exec -u postgres postgres pg_dump inscription | sudo tee inscription_bkp_2024.sql sudo docker compose exec -u postgres postgres pg_dump inscription_tfjm | sudo tee inscription_tfjm_bkp_2024.sql > /dev/null
Réinitialisation effective Réinitialisation effective
"""""""""""""""""""""""""" """"""""""""""""""""""""""
Il est désormais possible de réinitialiser la base de données. Rendez-vous dans le conteneur de Il est désormais possible de réinitialiser la base de données, après avoir éteint le serveur :
la plateforme, et exécutez :
.. code:: bash .. code:: bash
rm -r media/* sudo docker compose stop inscription
./manage.py reset_db 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
Créez enfin un nouveau compte administrateur⋅rice : Redémarrez enfin le serveur (les migrations seront créées automatiquement)
et créez un nouveau compte administrateur⋅rice :
.. code:: bash .. code:: bash
sudo docker compose up -d inscription
sudo docker compose exec inscription bash
./manage.py createsuperuser ./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 Nouveaux paramètres pour la nouvelle année
------------------------------------------ ------------------------------------------
@ -162,9 +179,9 @@ On sort du conteneur et on récupère les solutions pour les déplacer dans Ownc
.. code:: bash .. code:: bash
sudo docker cp tfjm_plateforme_1:/code/output/solutions . sudo docker cp tfjm-inscription-1:/code/output/solutions .
sudo mv solutions/* "data/owncloud/files/Emmy/Solutions écrites 2024/" sudo mv solutions/* "data/owncloud/data/Emmy/files/Solutions écrites 2024/"
sudo chown -R www-data:root "data/owncloud/files/Emmy/Solutions écrites 2024" sudo chown -R www-data:root "data/owncloud/data/Emmy/files/Solutions écrites 2024"
sudo rmdir solutions sudo rmdir solutions
Il faut enfin réactualiser Owncloud. Exécuter en tant que www-data : Il faut enfin réactualiser Owncloud. Exécuter en tant que www-data :

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

@ -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}",
@ -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):
@ -1039,7 +1039,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# 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': str(_("The draw of the second round is starting!"))}) 'body': str(_("The draw of the second round is starting!"))})
if settings.TFJM_APP == "TFJM": if settings.TFJM_APP == "TFJM":
@ -1092,8 +1092,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-{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: else:
async for td in r2.team_draws.prefetch_related('participation__team'): async for td in r2.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}",

View File

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

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

@ -5,13 +5,17 @@ from pathlib import Path
from django.conf import settings from django.conf import settings
from django.core.management import BaseCommand from django.core.management import BaseCommand
from django.utils.translation import activate
from participation.models import Solution, Tournament from participation.models import Solution, Tournament
class Command(BaseCommand): class Command(BaseCommand):
def handle(self, *args, **kwargs): def handle(self, *args, **kwargs):
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" base_dir /= "solutions"
if not base_dir.is_dir(): if not base_dir.is_dir():
base_dir.mkdir() base_dir.mkdir()

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,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 "
@ -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 "
@ -1041,10 +1065,10 @@ class Participation(models.Model):
}) })
elif settings.NB_ROUNDS >= 3 \ 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 "
@ -1234,6 +1258,10 @@ class Pool(models.Model):
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()
@ -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)
@ -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:

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

@ -44,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}
@ -52,7 +52,7 @@
\begin{tabular}{|p{40mm}{% for passage in passages.all %}{% if passages.count <= 3 %}|p{3cm}|p{3cm}{% else %}|p{2.8cm}|p{2.5cm}{% endif %}{% endfor %}|}\hline \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

@ -58,7 +58,7 @@
%%%%%%%%%%%%%%%%%%%%%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{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 }}} \multirow{7}{3mm}{\bf \begin{turn}{90}WRITING\end{turn}} & \multirow{3}{20mm}{ {% trans "Scientific part" %}} & {% trans "Depth and difficulty of the elements presented" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
@ -86,7 +86,7 @@
%%%%%%%%%%%%%%%%%OPPOSANT⋅E %%%%%%%%%%%%%%%%%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{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 }}} \multirow{6}{3mm}{\bf \begin{turn}{90}WRITING\end{turn}} &\multirow{4}{25mm}{ {% trans "Scientific part" %}} & {% trans "Critical thinking and perspective on the proposed solution" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
@ -108,7 +108,7 @@
%%%%%%%%%%%%%%%%%%%%%%RAPPORTEUR⋅RICE %%%%%%%%%%%%%%%%%%%%%%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{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 }}} \multirow{6}{3mm}{\bf \begin{turn}{90}WRITING\end{turn}} &\multirow{4}{25mm}{ {% trans "Scientific part" %}} & {% trans "Critical thinking and perspective on the proposed solution" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
@ -131,7 +131,7 @@
{% if TFJM.APP == "ETEAM" and pool.participations.count >= 4 %} {% 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{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 }}} \multirow{6}{3mm}{\bf \begin{turn}{90}WRITING\end{turn}} &\multirow{4}{25mm}{ {% trans "Scientific part" %}} & {% trans "Critical thinking and perspective on the proposed solution" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}

View File

@ -52,7 +52,7 @@
%%%%%%%%%%%%%%%%%%%%%DEFENSEUR %%%%%%%%%%%%%%%%%%%%%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{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 }}} \multirow{7}{3mm}{\bf \begin{turn}{90}ÉCRIT\end{turn}} & \multirow{3}{24mm}{Partie scientifique} & Profondeur et difficulté des éléments présentés & [0,6] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
@ -80,7 +80,7 @@
%%%%%%%%%%%%%%%%%OPPOSANT %%%%%%%%%%%%%%%%%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{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 }}} \multirow{6}{3mm}{\bf \begin{turn}{90}ÉCRIT\end{turn}} &\multirow{4}{24mm}{Partie scientifique} & Recul et esprit critique par rapport à la solution proposée & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
@ -102,7 +102,7 @@
%%%%%%%%%%%%%%%%%%%%%%RAPPORTEUR.RICE %%%%%%%%%%%%%%%%%%%%%%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{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 }}} \multirow{6}{3mm}{\bf \begin{turn}{90}ÉCRIT\end{turn}} &\multirow{4}{24mm}{Partie scientifique} & Recul et esprit critique par rapport à la solution proposée & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
@ -115,7 +115,7 @@
\multirow{9}{3mm}{\bf \begin{turn}{90}ORAL\end{turn}} & \multirow{5}{24mm}{Questions et discours de læ rapporteur⋅rice} & \footnotesize Faire prendre de la hauteur au débat (par les sujets abordés, la pertinence des questions posées, les points soulevés, gestion du temps) & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}} \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))
@ -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

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

@ -7,8 +7,6 @@ from django.contrib.auth.forms import UserCreationForm
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.forms import FileInput from django.forms import FileInput
from django.utils import timezone
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import CoachRegistration, ParticipantRegistration, Payment, \ from .models import CoachRegistration, ParticipantRegistration, Payment, \
@ -38,19 +36,6 @@ class SignupForm(UserCreationForm):
self.add_error("email", _("This email address is already used.")) self.add_error("email", _("This email address is already used."))
return email return email
def clean(self):
# Check that registrations are opened
now = timezone.now()
if now < settings.REGISTRATION_DATES['open']:
self.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']:
self.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 super().clean()
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["first_name"].required = True self.fields["first_name"].required = True

View File

@ -14,6 +14,8 @@ class Command(BaseCommand):
def handle(self, *args, **kwargs): def handle(self, *args, **kwargs):
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 /= "photo_authorizations" base_dir /= "photo_authorizations"
if not base_dir.is_dir(): if not base_dir.is_dir():
base_dir.mkdir() base_dir.mkdir()

View File

@ -0,0 +1,22 @@
# Generated by Django 5.1.5 on 2025-03-27 19:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("registration", "0014_participantregistration_country"),
]
operations = [
migrations.AlterField(
model_name="participantregistration",
name="gender",
field=models.CharField(
choices=[("female", "Female"), ("male", "Male"), ("other", "Other")],
max_length=6,
verbose_name="gender",
),
),
]

View File

@ -8,6 +8,7 @@ from django.contrib.sites.models import Site
from django.core.mail import send_mail from django.core.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
@ -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

@ -10,13 +10,13 @@
{% block content %} {% block content %}
{% now "c" as now %} {% now "c" as now %}
{% if now < TFJM.REGISTRATION_DATES.open.isoformat %} {% if now < TFJM.REGISTRATION_DATES.open.isoformat and not user.registration.is_admin %}
<div class="alert alert-warning"> <div class="alert alert-warning">
{% trans "Thank you for your great interest, but registrations are not opened yet!" %} {% 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 "They will open on:" %} {{ TFJM.REGISTRATION_DATES.open|date:'DATETIME_FORMAT' }}.
{% trans "Please come back at this time to register!" %} {% trans "Please come back at this time to register!" %}
</div> </div>
{% elif now > TFJM.REGISTRATION_DATES.close.isoformat %} {% elif now > TFJM.REGISTRATION_DATES.close.isoformat and not user.registration.is_admin %}
<div class="alert alert-danger"> <div class="alert alert-danger">
{% trans "Registrations are closed for this year. We hope to see you next year!" %} {% trans "Registrations are closed for this year. We hope to see you next year!" %}
{% trans "If needed, you can contact us by mail." %} {% trans "If needed, you can contact us by mail." %}

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

@ -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,
@ -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.1.0 channels[daphne]~=4.2.2
channels-redis~=4.2.0 channels-redis~=4.2.1
crispy-bootstrap5~=2024.10 citric~=1.4.0
Django>=5.1.2,<6.0 crispy-bootstrap5~=2025.4
django-crispy-forms~=2.3 Django>=5.2,<6.0
django-extensions~=3.2.3 django-crispy-forms~=2.4
django-filter~=24.3 django-filter~=25.1
django-haystack~=3.3.0 django-haystack~=3.3.0
django-mailer~=2.3.2 django-mailer~=2.3.2
django-phonenumber-field~=8.0.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.15.2 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.4 gspread~=6.2.0
gunicorn~=23.0.0 gunicorn~=23.0.0
odfpy~=1.4.1 odfpy~=1.4.1
pandas~=2.2.3 pandas~=2.2.3
phonenumbers~=8.13.47 phonenumbers~=9.0.3
psycopg~=3.2.3 psycopg~=3.2.6
pypdf~=5.0.1 pypdf~=5.4.0
ipython~=8.28.0
python-magic~=0.4.27 python-magic~=0.4.27
requests~=2.32.3 requests~=2.32.3
sympasoap~=1.1 sympasoap~=1.1
uvicorn~=0.32.0 uvicorn~=0.34.2
websockets~=13.1 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

@ -13,6 +13,7 @@ def tfjm_context(request):
'HAS_OBSERVER': settings.HAS_OBSERVER, 'HAS_OBSERVER': settings.HAS_OBSERVER,
'HAS_FINAL': settings.HAS_FINAL, 'HAS_FINAL': settings.HAS_FINAL,
'HOME_PAGE_LINK': settings.HOME_PAGE_LINK, 'HOME_PAGE_LINK': settings.HOME_PAGE_LINK,
'LIMESURVEY_URL': settings.LIMESURVEY_URL,
'LOGO_PATH': "tfjm/img/" + settings.LOGO_FILE, '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,

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

@ -74,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',
] ]
@ -213,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': (
@ -300,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')
@ -322,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",
@ -379,14 +390,14 @@ if TFJM_APP == "TFJM":
) )
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'

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

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

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

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

@ -44,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,6 +1,5 @@
[tox] [tox]
envlist = envlist =
py311
py312 py312
py313 py313