1
0
mirror of https://gitlab.com/animath/si/plateforme.git synced 2025-06-21 12:38:26 +02:00

Compare commits

..

14 Commits

43 changed files with 1323 additions and 542 deletions

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:

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

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

@ -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,5 +1,6 @@
channels[daphne]~=4.1.0 channels[daphne]~=4.1.0
channels-redis~=4.2.0 channels-redis~=4.2.0
citric~=1.4.0
crispy-bootstrap5~=2024.10 crispy-bootstrap5~=2024.10
Django>=5.1.2,<6.0 Django>=5.1.2,<6.0
django-crispy-forms~=2.3 django-crispy-forms~=2.3

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,6 +74,7 @@ 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
@ -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",

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

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