1
0
mirror of https://gitlab.com/animath/si/plateforme.git synced 2025-06-21 23:18:25 +02:00

Compare commits

...

22 Commits

Author SHA1 Message Date
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
55 changed files with 1409 additions and 624 deletions

View File

@ -1,6 +1,12 @@
stages: stages:
- test - test
- quality-assurance - quality-assurance
- build
- release
variables:
CONTAINER_TEST_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
CONTAINER_RELEASE_IMAGE: $CI_REGISTRY_IMAGE:latest
py312: py312:
stage: test stage: test
@ -27,3 +33,29 @@ linters:
- pip install tox --no-cache-dir - pip install tox --no-cache-dir
script: tox -e linters script: tox -e linters
allow_failure: true allow_failure: true
build-image:
image: docker
stage: build
services:
- docker:dind
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
script:
- docker build --pull -t $CONTAINER_TEST_IMAGE .
- docker push $CONTAINER_TEST_IMAGE
release-image:
image: docker
stage: release
services:
- docker:dind
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
script:
- docker pull $CONTAINER_TEST_IMAGE
- docker tag $CONTAINER_TEST_IMAGE $CONTAINER_RELEASE_IMAGE
- docker push $CONTAINER_RELEASE_IMAGE
rules:
- if: $CI_COMMIT_BRANCH == "main"

View File

@ -37,4 +37,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

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

File diff suppressed because it is too large Load Diff

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

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

@ -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}")
@ -391,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²",
@ -420,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²",
@ -846,6 +846,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)
@ -864,6 +866,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:
@ -1399,8 +1414,8 @@ class Pool(models.Model):
if has_observer: if has_observer:
merge_cells.append(f"{getcol(9 + i * passage_width)}2:{getcol(10 + i * passage_width)}2") merge_cells.append(f"{getcol(9 + i * passage_width)}2:{getcol(10 + i * passage_width)}2")
merge_cells.append(f"{getcol(9 + i * passage_width)}{max_row + 3}" merge_cells.append(f"{getcol(9 + i * passage_width)}{max_row + 3}"
f":{getcol(10 + i * passage_width)}{max_row + 3}") f":{getcol(10 + i * passage_width)}{max_row + 3}")
merge_cells.append(f"A{max_row + 1}:B{max_row + 1}") merge_cells.append(f"A{max_row + 1}:B{max_row + 1}")
merge_cells.append(f"A{max_row + 2}:B{max_row + 2}") merge_cells.append(f"A{max_row + 2}:B{max_row + 2}")
merge_cells.append(f"A{max_row + 3}:B{max_row + 3}") merge_cells.append(f"A{max_row + 3}:B{max_row + 3}")

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

@ -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,10 +230,11 @@ 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)
mail_plain = render_to_string("participation/mails/request_validation.txt", mail_context) with translation.override(settings.PREFERRED_LANGUAGE_CODE):
mail_html = render_to_string("participation/mails/request_validation.html", mail_context) mail_plain = render_to_string("participation/mails/request_validation.txt", mail_context)
send_mail(f"[{settings.APP_NAME}] {_('Team validation')}", mail_plain, settings.DEFAULT_FROM_EMAIL, mail_html = render_to_string("participation/mails/request_validation.html", mail_context)
[self.object.participation.tournament.organizers_email], html_message=mail_html) send_mail(f"[{settings.APP_NAME}] {_('Team validation')}", mail_plain, settings.DEFAULT_FROM_EMAIL,
[self.object.participation.tournament.organizers_email], html_message=mail_html)
return super().form_valid(form) return super().form_valid(form)
@ -264,19 +266,21 @@ 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>'))
mail_plain = render_to_string("participation/mails/team_validated.txt", mail_context_plain) with translation.override(settings.PREFERRED_LANGUAGE_CODE):
mail_html = render_to_string("participation/mails/team_validated.html", mail_context_html) mail_plain = render_to_string("participation/mails/team_validated.txt", mail_context_plain)
registration.user.email_user(f"[{settings.APP_NAME}] {_('Team validated')}", mail_plain, mail_html = render_to_string("participation/mails/team_validated.html", mail_context_html)
html_message=mail_html) registration.user.email_user(f"[{settings.APP_NAME}] {_('Team validated')}", mail_plain,
html_message=mail_html)
elif "invalidate" in self.request.POST: elif "invalidate" in self.request.POST:
self.object.participation.valid = None self.object.participation.valid = None
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>'))
mail_plain = render_to_string("participation/mails/team_not_validated.txt", mail_context_plain) with translation.override(settings.PREFERRED_LANGUAGE_CODE):
mail_html = render_to_string("participation/mails/team_not_validated.html", mail_context_html) mail_plain = render_to_string("participation/mails/team_not_validated.txt", mail_context_plain)
send_mail(f"[{settings.APP_NAME}] {_('Team not validated')}", mail_plain, mail_html = render_to_string("participation/mails/team_not_validated.html", mail_context_html)
None, [self.object.email], html_message=mail_html) send_mail(f"[{settings.APP_NAME}] {_('Team not validated')}", mail_plain,
None, [self.object.email], html_message=mail_html)
else: else:
form.add_error(None, _("You must specify if you validate the registration or not.")) form.add_error(None, _("You must specify if you validate the registration or not."))
return self.form_invalid(form) return self.form_invalid(form)
@ -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"))
@ -1144,15 +1150,18 @@ 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):
inviter=self.request.user, message = render_to_string('registration/mails/add_organizer.txt',
password=password, dict(user=user,
domain=site.domain)) inviter=self.request.user,
html = render_to_string('registration/mails/add_organizer.html', dict(user=user, password=password,
inviter=self.request.user, domain=site.domain))
password=password, html = render_to_string('registration/mails/add_organizer.html',
domain=site.domain)) dict(user=user,
user.email_user(subject, message, html_message=html) inviter=self.request.user,
password=password,
domain=site.domain))
user.email_user(subject, message, html_message=html)
# Add the user in the jury # Add the user in the jury
self.object.juries.add(reg) self.object.juries.add(reg)
@ -1842,10 +1851,9 @@ 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]
tex = render_to_string(template_name, context=context, request=self.request) with translation.override(settings.PREFERRED_LANGUAGE_CODE):
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:
f.write(tex) f.write(tex)
@ -1952,6 +1960,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)

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

@ -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,27 +324,27 @@ 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
tournament = Tournament.final_tournament() tournament = Tournament.final_tournament()
payment = self.payments.filter(final=True).first() if self.is_student else None payment = self.payments.filter(final=True).first() if self.is_student else None
message = loader.render_to_string('registration/mails/final_selection.txt', message = loader.render_to_string('registration/mails/final_selection.txt',
{ {
'user': self.user, 'user': self.user,
'domain': site.domain, 'domain': site.domain,
'tournament': tournament, 'tournament': tournament,
'payment': payment, 'payment': payment,
}) })
html = loader.render_to_string('registration/mails/final_selection.html', html = loader.render_to_string('registration/mails/final_selection.html',
{ {
'user': self.user, 'user': self.user,
'domain': site.domain, 'domain': site.domain,
'tournament': tournament, 'tournament': tournament,
'payment': payment, 'payment': payment,
}) })
self.user.email_user(subject, message, html_message=html) self.user.email_user(subject, message, html_message=html)
class Meta: class Meta:
verbose_name = _("participant registration") verbose_name = _("participant registration")
@ -802,35 +818,35 @@ 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():
message = loader.render_to_string('registration/mails/payment_reminder.txt', message = loader.render_to_string('registration/mails/payment_reminder.txt',
dict(registration=registration, payment=self, domain=site.domain)) dict(registration=registration, payment=self, domain=site.domain))
html = loader.render_to_string('registration/mails/payment_reminder.html', html = loader.render_to_string('registration/mails/payment_reminder.html',
dict(registration=registration, payment=self, domain=site.domain)) dict(registration=registration, payment=self, domain=site.domain))
registration.user.email_user(subject, message, html_message=html) 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():
message = loader.render_to_string('registration/mails/payment_confirmation.txt', message = loader.render_to_string('registration/mails/payment_confirmation.txt',
dict(registration=registration, payment=self, domain=site.domain)) dict(registration=registration, payment=self, domain=site.domain))
html = loader.render_to_string('registration/mails/payment_confirmation.html', html = loader.render_to_string('registration/mails/payment_confirmation.html',
dict(registration=registration, payment=self, domain=site.domain)) dict(registration=registration, payment=self, domain=site.domain))
registration.user.email_user(subject, message, html_message=html) registration.user.email_user(subject, message, html_message=html)
payer = self.get_checkout_intent()['order']['payer'] payer = self.get_checkout_intent()['order']['payer']
payer_name = f"{payer['firstName']} {payer['lastName']}" payer_name = f"{payer['firstName']} {payer['lastName']}"
if not self.registrations.filter(user__email=payer['email']).exists(): if not self.registrations.filter(user__email=payer['email']).exists():
message = loader.render_to_string('registration/mails/payment_confirmation.txt', message = loader.render_to_string('registration/mails/payment_confirmation.txt',
dict(registration=payer_name, payment=self, domain=site.domain)) dict(registration=payer_name, payment=self, domain=site.domain))
html = loader.render_to_string('registration/mails/payment_confirmation.html', html = loader.render_to_string('registration/mails/payment_confirmation.html',
dict(registration=payer_name, payment=self, domain=site.domain)) dict(registration=payer_name, payment=self, domain=site.domain))
send_mail(subject, message, None, [payer['email']], html_message=html) send_mail(subject, message, None, [payer['email']], html_message=html)
def get_absolute_url(self): def get_absolute_url(self):
return reverse_lazy("registration:update_payment", args=(self.pk,)) return reverse_lazy("registration:update_payment", args=(self.pk,))

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

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

@ -66,11 +66,19 @@ organisé \`a :
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/de la représentant\cdt{}e légal\cdt{}e. 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 +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.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,16 +137,17 @@ class AddOrganizerView(VolunteerMixin, CreateView):
form.instance.set_password(password) form.instance.set_password(password)
form.instance.save() form.instance.save()
subject = f"[{settings.APP_NAME}] " + str(_("New organizer account")) with translation.override(settings.PREFERRED_LANGUAGE_CODE):
site = Site.objects.first() subject = f"[{settings.APP_NAME}] " + str(_("New organizer account"))
message = render_to_string('registration/mails/add_organizer.txt', dict(user=registration.user, site = Site.objects.first()
inviter=self.request.user, message = render_to_string('registration/mails/add_organizer.txt', dict(user=registration.user,
password=password, inviter=self.request.user,
domain=site.domain)) password=password,
html = render_to_string('registration/mails/add_organizer.html', dict(user=registration.user, domain=site.domain))
inviter=self.request.user, html = render_to_string('registration/mails/add_organizer.html', dict(user=registration.user,
password=password, inviter=self.request.user,
domain=site.domain)) password=password,
domain=site.domain))
registration.user.email_user(subject, message, html_message=html) registration.user.email_user(subject, message, html_message=html)
if registration.is_admin: if registration.is_admin:
@ -445,10 +462,9 @@ 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]
tex = render_to_string(template_name, context=context, request=self.request) with translation.override(settings.PREFERRED_LANGUAGE_CODE):
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:
f.write(tex) f.write(tex)

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.all()
else:
if self.invite_coaches:
registrations = ParticipantRegistration.objects.filter(team__participation__valid=True)
else:
registrations = StudentRegistration.objects.filter(team__participation__valid=True)
if self.tournament:
registrations = registrations.filter(team__participation__tournament=self.tournament)
return registrations.all()
@property
def completed(self):
if self.invite_team:
return self.completed_teams
else:
return self.completed_registrations
def get_absolute_url(self):
return reverse_lazy("survey:survey_detail", args=(self.survey_id,))
def generate_participants_data(self):
participants_data = []
if self.invite_team:
for team in self.participants:
participant_data = {"firstname": team.name, "lastname": f"(équipe {team.trigram})", "email": team.email}
participants_data.append(participant_data)
else:
for reg in self.participants:
participant_data = {"firstname": reg.user.first_name, "lastname": reg.user.last_name, "email": reg.user.email}
participants_data.append(participant_data)
return participants_data
def invite_all(self):
participants_data = self.generate_participants_data()
with Client(f"{settings.LIMESURVEY_URL}/index.php/admin/remotecontrol", settings.LIMESURVEY_USER, settings.LIMESURVEY_PASSWORD) as client:
try:
current_participants = client.list_participants(self.survey_id, limit=10000)
except:
current_participants = []
current_participants_email = set(participant['participant_info']['email'] for participant in current_participants)
participants_data = [participant_data for participant_data in participants_data if participant_data['email'] not in current_participants_email]
try:
client.activate_tokens(self.survey_id)
except:
pass
new_participants = client.add_participants(self.survey_id, participant_data=participants_data)
if new_participants:
client.invite_participants(self.survey_id, token_ids=[participant['tid'] for participant in new_participants])
return new_participants
def fetch_completion_data(self):
with Client(f"{settings.LIMESURVEY_URL}/index.php/admin/remotecontrol", settings.LIMESURVEY_USER, settings.LIMESURVEY_PASSWORD) as client:
participants = client.list_participants(self.survey_id, limit=10000, attributes=['completed'])
if self.invite_team:
team_names = [participant['participant_info']['firstname'] for participant in participants if participant['completed'] != 'N']
self.completed_teams.set(list(Team.objects.filter(name__in=team_names).values_list('id', flat=True)))
else:
mails = [participant['participant_info']['email'] for participant in participants if participant['completed'] != 'N']
self.completed_registrations.set(list(ParticipantRegistration.objects.filter(user__email__in=mails).values_list('id', flat=True)))
self.save()
class Meta:
verbose_name = _("survey")
verbose_name_plural = _("surveys")

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,84 @@
{% 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 "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>
{% else %}
<td>{{ participant.user.first_name }} {{ participant.user.last_name }} ({% trans "team" %} {{ participant.team.trigram }})</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

@ -11,15 +11,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',
] ]
@ -300,6 +300,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 +328,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 +389,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

@ -30,15 +30,6 @@
</div> </div>
</div> </div>
<div class="alert alert-warning">
<h3 class="alert-heading"><i class="fas fa-warning"></i> {% trans "New in 2025" %}</h3>
{% blocktrans trimmed %}
Registration for Ile-de-France tournaments is now unified.
If you live in or near the Ile-de-France region, your registration will be pooled with each of the region's tournaments,
and the organizers will take care of team allocation. However, date constraints can be indicated in the motivation letter.
{% endblocktrans %}
</div>
<div class="jumbotron p-5 border rounded-5"> <div class="jumbotron p-5 border rounded-5">
<h5 class="display-4">{% trans "How does it work?" %}</h5> <h5 class="display-4">{% trans "How does it work?" %}</h5>
<p> <p>

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

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