mirror of
https://gitlab.com/animath/si/plateforme.git
synced 2025-06-21 19:18:24 +02:00
Compare commits
17 Commits
main
...
56aad288f4
Author | SHA1 | Date | |
---|---|---|---|
56aad288f4
|
|||
b33a69410a
|
|||
0a80e03b58
|
|||
73b94d5578
|
|||
97eea3b11a
|
|||
702c8d8c9e
|
|||
ca0601fb24
|
|||
d315c8371a
|
|||
7488d3eae1
|
|||
cfaf7c4287
|
|||
e3c216e44e
|
|||
73012bd61e
|
|||
bdf181e7e4
|
|||
c57ad854fe
|
|||
a2e5ab5f6a
|
|||
758a2c9a00
|
|||
fb10df77e5
|
@ -1,6 +1,12 @@
|
||||
stages:
|
||||
- test
|
||||
- quality-assurance
|
||||
- build
|
||||
- release
|
||||
|
||||
variables:
|
||||
CONTAINER_TEST_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
|
||||
CONTAINER_RELEASE_IMAGE: $CI_REGISTRY_IMAGE:latest
|
||||
|
||||
py312:
|
||||
stage: test
|
||||
@ -27,3 +33,29 @@ linters:
|
||||
- pip install tox --no-cache-dir
|
||||
script: tox -e linters
|
||||
allow_failure: true
|
||||
|
||||
build-image:
|
||||
image: docker
|
||||
stage: build
|
||||
services:
|
||||
- docker:dind
|
||||
before_script:
|
||||
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
|
||||
script:
|
||||
- docker build --pull -t $CONTAINER_TEST_IMAGE .
|
||||
- docker push $CONTAINER_TEST_IMAGE
|
||||
|
||||
release-image:
|
||||
image: docker
|
||||
stage: release
|
||||
services:
|
||||
- docker:dind
|
||||
before_script:
|
||||
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
|
||||
script:
|
||||
- docker pull $CONTAINER_TEST_IMAGE
|
||||
- docker tag $CONTAINER_TEST_IMAGE $CONTAINER_RELEASE_IMAGE
|
||||
- docker push $CONTAINER_RELEASE_IMAGE
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "main"
|
||||
|
||||
|
@ -37,4 +37,4 @@ RUN ln -s /code/.bashrc /root/.bashrc
|
||||
ENTRYPOINT ["/code/entrypoint.sh"]
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["./manage.py", "shell_plus", "--ipython"]
|
||||
CMD ["./manage.py", "shell"]
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -3,6 +3,7 @@
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.db.models.signals import post_save, pre_save
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class ParticipationConfig(AppConfig):
|
||||
@ -10,6 +11,7 @@ class ParticipationConfig(AppConfig):
|
||||
The participation app contains the data about the teams, solutions, ...
|
||||
"""
|
||||
name = 'participation'
|
||||
verbose_name = _("participations")
|
||||
|
||||
def ready(self):
|
||||
from participation import signals
|
||||
|
@ -3,6 +3,7 @@
|
||||
from django.conf import settings
|
||||
from django.core.management import BaseCommand
|
||||
from django.db.models import Q
|
||||
from django.template.defaultfilters import slugify
|
||||
from participation.models import Team, Tournament
|
||||
from registration.models import ParticipantRegistration, VolunteerRegistration
|
||||
from tfjm.lists import get_sympa_client
|
||||
@ -36,7 +37,7 @@ class Command(BaseCommand):
|
||||
"education", raise_error=False)
|
||||
|
||||
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",
|
||||
f"Liste de diffusion pour contacter toutes les equipes du tournoi {tournament.name}"
|
||||
" du TFJM2.", "education", raise_error=False)
|
||||
@ -54,7 +55,7 @@ class Command(BaseCommand):
|
||||
for team in Team.objects.filter(participation__valid=True).all():
|
||||
team.create_mailing_list()
|
||||
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}")
|
||||
|
||||
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}")
|
||||
|
||||
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}")
|
||||
|
||||
for volunteer in VolunteerRegistration.objects.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)
|
||||
|
||||
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)
|
||||
|
||||
for admin in VolunteerRegistration.objects.filter(admin=True).all():
|
||||
|
@ -15,6 +15,12 @@ from ...models import Tournament
|
||||
|
||||
|
||||
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):
|
||||
parser.add_argument(
|
||||
'--tournament', '-t', help="Tournament name to update (if not set, all tournaments will be updated)",
|
||||
|
@ -3,13 +3,13 @@
|
||||
|
||||
from datetime import date, timedelta
|
||||
import math
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import Index
|
||||
from django.db.models import Index, Q
|
||||
from django.template.defaultfilters import slugify
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone, translation
|
||||
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 f"equipe-{self.trigram.lower()}@{os.getenv('SYMPA_HOST', 'localhost')}"
|
||||
return f"equipe-{slugify(self.trigram)}@{settings.SYMPA_HOST}"
|
||||
|
||||
def create_mailing_list(self):
|
||||
"""
|
||||
Create a new Sympa mailing list to contact the team.
|
||||
"""
|
||||
get_sympa_client().create_list(
|
||||
f"equipe-{self.trigram.lower()}",
|
||||
f"equipe-{slugify(self.trigram)}",
|
||||
f"Equipe {self.name} ({self.trigram})",
|
||||
"hotline", # TODO Use a custom sympa template
|
||||
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
|
||||
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:
|
||||
get_sympa_client().unsubscribe(self.email, "equipes-non-valides", False)
|
||||
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 f"equipes-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}"
|
||||
return f"equipes-{slugify(self.name)}@{settings.SYMPA_HOST}"
|
||||
|
||||
@property
|
||||
def organizers_email(self):
|
||||
"""
|
||||
:return: The mailing list to contact the team members.
|
||||
"""
|
||||
return f"organisateurs-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}"
|
||||
return f"organisateurs-{slugify(self.name)}@{settings.SYMPA_HOST}"
|
||||
|
||||
@property
|
||||
def jurys_email(self):
|
||||
"""
|
||||
:return: The mailing list to contact the team members.
|
||||
"""
|
||||
return f"jurys-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}"
|
||||
return f"jurys-{slugify(self.name)}@{settings.SYMPA_HOST}"
|
||||
|
||||
def create_mailing_lists(self):
|
||||
"""
|
||||
Create a new Sympa mailing list to contact the team.
|
||||
"""
|
||||
get_sympa_client().create_list(
|
||||
f"equipes-{self.name.lower().replace(' ', '-')}",
|
||||
f"equipes-{slugify(self.name)}",
|
||||
f"Equipes du tournoi de {self.name}",
|
||||
"hotline", # TODO Use a custom sympa template
|
||||
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,
|
||||
)
|
||||
get_sympa_client().create_list(
|
||||
f"organisateurs-{self.name.lower().replace(' ', '-')}",
|
||||
f"organisateurs-{slugify(self.name)}",
|
||||
f"Organisateurs du tournoi de {self.name}",
|
||||
"hotline", # TODO Use a custom sympa template
|
||||
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)
|
||||
|
||||
def important_informations(self):
|
||||
from survey.models import Survey
|
||||
|
||||
informations = []
|
||||
|
||||
missing_payments = Payment.objects.filter(registrations__in=self.team.participants.all(), valid=False)
|
||||
@ -864,6 +866,19 @@ class Participation(models.Model):
|
||||
'content': content,
|
||||
})
|
||||
|
||||
if self.valid:
|
||||
for survey in Survey.objects.filter(Q(tournament__isnull=True) | Q(tournament=self.tournament), Q(invite_team=True),
|
||||
~Q(completed_teams=self.team)).all():
|
||||
text = _("Please answer to the survey \"{name}\". You can go to the survey on <a href=\"{survey_link}\">that link</a>, "
|
||||
"using the token code you received by mail.")
|
||||
content = format_lazy(text, name=survey.name, survey_link=f"{settings.LIMESURVEY_URL}/index.php/{survey.survey_id}")
|
||||
informations.append({
|
||||
'title': _("Required answer to survey"),
|
||||
'type': "warning",
|
||||
'priority': 12,
|
||||
'content': content
|
||||
})
|
||||
|
||||
if self.tournament:
|
||||
informations.extend(self.informations_for_tournament(self.tournament))
|
||||
if self.final:
|
||||
|
@ -4,6 +4,7 @@
|
||||
from typing import Union
|
||||
|
||||
from django.conf import settings
|
||||
from django.template.defaultfilters import slugify
|
||||
from participation.models import Note, Participation, Passage, Pool, Team, Tournament
|
||||
from registration.models import Payment
|
||||
from tfjm.lists import get_sympa_client
|
||||
@ -34,10 +35,10 @@ def update_mailing_list(instance: Team, raw, **_):
|
||||
instance.create_mailing_list()
|
||||
# Subscribe all team members in the mailing list
|
||||
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}")
|
||||
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}")
|
||||
|
||||
|
||||
|
@ -1,3 +1,2 @@
|
||||
{{ object.name }}
|
||||
{{ object.place }}
|
||||
{{ object.description }}
|
||||
|
@ -1,5 +0,0 @@
|
||||
{{ object.link }}
|
||||
{{ object.participation.team.name }}
|
||||
{{ object.participation.team.trigram }}
|
||||
{{ object.participation.problem }}
|
||||
{{ object.participation.get_problem_display }}
|
@ -22,6 +22,7 @@ from django.db import transaction
|
||||
from django.db.models import F
|
||||
from django.http import FileResponse, Http404, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.template.defaultfilters import slugify
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone, translation
|
||||
@ -88,7 +89,7 @@ class CreateTeamView(LoginRequiredMixin, CreateView):
|
||||
registration.save()
|
||||
|
||||
# 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}")
|
||||
|
||||
return ret
|
||||
@ -130,7 +131,7 @@ class JoinTeamView(LoginRequiredMixin, FormView):
|
||||
registration.save()
|
||||
|
||||
# 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}")
|
||||
|
||||
return ret
|
||||
@ -229,6 +230,7 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView)
|
||||
self.object.participation.save()
|
||||
|
||||
mail_context = dict(team=self.object, domain=Site.objects.first().domain)
|
||||
with translation.override(settings.PREFERRED_LANGUAGE_CODE):
|
||||
mail_plain = render_to_string("participation/mails/request_validation.txt", mail_context)
|
||||
mail_html = render_to_string("participation/mails/request_validation.html", mail_context)
|
||||
send_mail(f"[{settings.APP_NAME}] {_('Team validation')}", mail_plain, settings.DEFAULT_FROM_EMAIL,
|
||||
@ -264,6 +266,7 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView)
|
||||
message=form.cleaned_data["message"])
|
||||
mail_context_html = dict(domain=domain, registration=registration, team=self.object, payment=payment,
|
||||
message=form.cleaned_data["message"].replace('\n', '<br>'))
|
||||
with translation.override(settings.PREFERRED_LANGUAGE_CODE):
|
||||
mail_plain = render_to_string("participation/mails/team_validated.txt", mail_context_plain)
|
||||
mail_html = render_to_string("participation/mails/team_validated.html", mail_context_html)
|
||||
registration.user.email_user(f"[{settings.APP_NAME}] {_('Team validated')}", mail_plain,
|
||||
@ -273,6 +276,7 @@ class TeamDetailView(LoginRequiredMixin, FormMixin, ProcessFormView, DetailView)
|
||||
self.object.participation.save()
|
||||
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>'))
|
||||
with translation.override(settings.PREFERRED_LANGUAGE_CODE):
|
||||
mail_plain = render_to_string("participation/mails/team_not_validated.txt", mail_context_plain)
|
||||
mail_html = render_to_string("participation/mails/team_not_validated.html", mail_context_html)
|
||||
send_mail(f"[{settings.APP_NAME}] {_('Team not validated')}", mail_plain,
|
||||
@ -313,6 +317,7 @@ class TeamUpdateView(LoginRequiredMixin, UpdateView):
|
||||
instance=self.object.participation)
|
||||
if not self.request.user.registration.is_volunteer:
|
||||
del context["participation_form"].fields['final']
|
||||
context["participation_form"].helper.layout.remove('final')
|
||||
context["title"] = _("Update team {trigram}").format(trigram=self.object.trigram)
|
||||
return context
|
||||
|
||||
@ -321,6 +326,7 @@ class TeamUpdateView(LoginRequiredMixin, UpdateView):
|
||||
participation_form = ParticipationForm(data=self.request.POST or None, instance=self.object.participation)
|
||||
if not self.request.user.registration.is_volunteer:
|
||||
del participation_form.fields['final']
|
||||
participation_form.helper.layout.remove('final')
|
||||
if not participation_form.is_valid():
|
||||
return self.form_invalid(form)
|
||||
|
||||
@ -517,7 +523,7 @@ class TeamLeaveView(LoginRequiredMixin, TemplateView):
|
||||
team = request.user.registration.team
|
||||
request.user.registration.team = None
|
||||
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:
|
||||
team.delete()
|
||||
return redirect(reverse_lazy("index"))
|
||||
@ -1144,11 +1150,14 @@ class PoolJuryView(VolunteerMixin, FormView, DetailView):
|
||||
# Send welcome mail
|
||||
subject = f"[{settings.APP_NAME}] " + str(_("New jury account"))
|
||||
site = Site.objects.first()
|
||||
message = render_to_string('registration/mails/add_organizer.txt', dict(user=user,
|
||||
with translation.override(settings.PREFERRED_LANGUAGE_CODE):
|
||||
message = render_to_string('registration/mails/add_organizer.txt',
|
||||
dict(user=user,
|
||||
inviter=self.request.user,
|
||||
password=password,
|
||||
domain=site.domain))
|
||||
html = render_to_string('registration/mails/add_organizer.html', dict(user=user,
|
||||
html = render_to_string('registration/mails/add_organizer.html',
|
||||
dict(user=user,
|
||||
inviter=self.request.user,
|
||||
password=password,
|
||||
domain=site.domain))
|
||||
@ -1842,9 +1851,8 @@ class NotationSheetTemplateView(VolunteerMixin, DetailView):
|
||||
return context
|
||||
|
||||
def render_to_response(self, context, **response_kwargs):
|
||||
translation.activate(settings.PREFERRED_LANGUAGE_CODE)
|
||||
|
||||
template_name = self.get_template_names()[0]
|
||||
with translation.override(settings.PREFERRED_LANGUAGE_CODE):
|
||||
tex = render_to_string(template_name, context=context, request=self.request)
|
||||
temp_dir = mkdtemp()
|
||||
with open(os.path.join(temp_dir, "texput.tex"), "w") as f:
|
||||
@ -1952,6 +1960,13 @@ class NotationSheetsArchiveView(VolunteerMixin, DetailView):
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
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):
|
||||
if not await Tournament.objects.filter(pk=kwargs['pk']).aexists():
|
||||
return HttpResponse(status=404)
|
||||
|
@ -3,6 +3,7 @@
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.db.models.signals import post_save, pre_save
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class RegistrationConfig(AppConfig):
|
||||
@ -10,6 +11,7 @@ class RegistrationConfig(AppConfig):
|
||||
Registration app contains the detail about users only.
|
||||
"""
|
||||
name = 'registration'
|
||||
verbose_name = _("registrations")
|
||||
|
||||
def ready(self):
|
||||
from registration import signals
|
||||
|
@ -7,8 +7,6 @@ from django.contrib.auth.forms import UserCreationForm
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
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 .models import CoachRegistration, ParticipantRegistration, Payment, \
|
||||
@ -38,19 +36,6 @@ class SignupForm(UserCreationForm):
|
||||
self.add_error("email", _("This email address is already used."))
|
||||
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):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["first_name"].required = True
|
||||
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
@ -8,6 +8,7 @@ from django.contrib.sites.models import Site
|
||||
from django.core.mail import send_mail
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.template import loader
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone, translation
|
||||
@ -166,7 +167,6 @@ class ParticipantRegistration(Registration):
|
||||
("male", _("Male")),
|
||||
("other", _("Other")),
|
||||
],
|
||||
default="other",
|
||||
)
|
||||
|
||||
address = models.CharField(
|
||||
@ -260,6 +260,8 @@ class ParticipantRegistration(Registration):
|
||||
raise NotImplementedError
|
||||
|
||||
def registration_informations(self):
|
||||
from survey.models import Survey
|
||||
|
||||
informations = []
|
||||
if not self.team:
|
||||
text = _("You are not in a team. You can <a href=\"{create_url}\">create one</a> "
|
||||
@ -300,6 +302,20 @@ class ParticipantRegistration(Registration):
|
||||
'content': content,
|
||||
})
|
||||
|
||||
if self.team.participation.valid:
|
||||
for survey in Survey.objects.filter(Q(tournament__isnull=True) | Q(tournament=self.team.participation.tournament),
|
||||
Q(invite_team=False), Q(invite_coaches=True) | Q(invite_coaches=self.is_coach),
|
||||
~Q(completed_registrations=self)):
|
||||
text = _("Please answer to the survey \"{name}\". You can go to the survey on <a href=\"{survey_link}\">that link</a>, "
|
||||
"using the token code you received by mail.")
|
||||
content = format_lazy(text, name=survey.name, survey_link=f"{settings.LIMESURVEY_URL}/index.php/{survey.survey_id}")
|
||||
informations.append({
|
||||
'title': _("Required answer to survey"),
|
||||
'type': "warning",
|
||||
'priority': 12,
|
||||
'content': content
|
||||
})
|
||||
|
||||
informations.extend(self.team.important_informations())
|
||||
|
||||
return informations
|
||||
@ -308,7 +324,7 @@ class ParticipantRegistration(Registration):
|
||||
"""
|
||||
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"))
|
||||
site = Site.objects.first()
|
||||
from participation.models import Tournament
|
||||
@ -802,7 +818,7 @@ class Payment(models.Model):
|
||||
return checkout_intent
|
||||
|
||||
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"))
|
||||
site = Site.objects.first()
|
||||
for registration in self.registrations.all():
|
||||
@ -813,7 +829,7 @@ class Payment(models.Model):
|
||||
registration.user.email_user(subject, message, html_message=html)
|
||||
|
||||
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"))
|
||||
site = Site.objects.first()
|
||||
for registration in self.registrations.all():
|
||||
|
@ -2,6 +2,7 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.template.defaultfilters import slugify
|
||||
from tfjm.lists import get_sympa_client
|
||||
|
||||
from .models import Registration, VolunteerRegistration
|
||||
@ -29,8 +30,8 @@ def send_email_link(instance, **_):
|
||||
registration.send_email_validation_link()
|
||||
|
||||
if registration.participates and registration.team:
|
||||
get_sympa_client().unsubscribe(old_instance.email, f"equipe-{registration.team.trigram.lower()}", False)
|
||||
get_sympa_client().subscribe(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-{slugify(registration.team.trigram)}", False,
|
||||
f"{instance.first_name} {instance.last_name}")
|
||||
|
||||
|
||||
|
@ -10,13 +10,13 @@
|
||||
|
||||
{% block content %}
|
||||
{% 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">
|
||||
{% trans "Thank you for your great interest, but registrations are not opened yet!" %}
|
||||
{% trans "They will open on:" %} {{ TFJM.REGISTRATION_DATES.open|date:'DATETIME_FORMAT' }}.
|
||||
{% trans "Please come back at this time to register!" %}
|
||||
</div>
|
||||
{% elif now > TFJM.REGISTRATION_DATES.close.isoformat %}
|
||||
{% elif now > TFJM.REGISTRATION_DATES.close.isoformat and not user.registration.is_admin %}
|
||||
<div class="alert alert-danger">
|
||||
{% trans "Registrations are closed for this year. We hope to see you next year!" %}
|
||||
{% trans "If needed, you can contact us by mail." %}
|
||||
|
@ -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
|
||||
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}
|
||||
|
||||
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
|
||||
|
@ -1,5 +1,3 @@
|
||||
{{ object.user.last_name }}
|
||||
{{ object.user.first_name }}
|
||||
{{ object.user.email }}
|
||||
{{ object.type }}
|
||||
{{ object.role }}
|
||||
|
@ -1,11 +1,4 @@
|
||||
{{ object.user.first_name }}
|
||||
{{ object.user.last_name }}
|
||||
{{ object.user.email }}
|
||||
{{ object.type }}
|
||||
{{ object.professional_activity }}
|
||||
{{ object.address }}
|
||||
{{ object.zip_code }}
|
||||
{{ object.city }}
|
||||
{{ object.phone_number }}
|
||||
{{ object.team.name }}
|
||||
{{ object.team.trigram }}
|
||||
|
@ -1,16 +1,7 @@
|
||||
{{ object.user.first_name }}
|
||||
{{ object.user.last_name }}
|
||||
{{ object.user.email }}
|
||||
{{ object.type }}
|
||||
{{ object.get_student_class_display }}
|
||||
{{ object.school }}
|
||||
{{ object.birth_date }}
|
||||
{{ object.address }}
|
||||
{{ object.zip_code }}
|
||||
{{ object.city }}
|
||||
{{ object.phone_number }}
|
||||
{{ object.responsible_name }}
|
||||
{{ object.reponsible_phone }}
|
||||
{{ object.reponsible_email }}
|
||||
{{ object.team.name }}
|
||||
{{ object.team.trigram }}
|
||||
|
@ -1,5 +1,3 @@
|
||||
{{ object.user.last_name }}
|
||||
{{ object.user.first_name }}
|
||||
{{ object.user.email }}
|
||||
{{ object.type }}
|
||||
{{ object.professional_activity }}
|
||||
|
@ -18,7 +18,7 @@ from django.http import FileResponse, Http404
|
||||
from django.shortcuts import redirect, resolve_url
|
||||
from django.template.loader import render_to_string
|
||||
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.http import urlsafe_base64_decode
|
||||
from django.utils.text import format_lazy
|
||||
@ -60,6 +60,22 @@ class SignupView(CreateView):
|
||||
|
||||
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
|
||||
def form_valid(self, form):
|
||||
role = form.cleaned_data["role"]
|
||||
@ -121,6 +137,7 @@ class AddOrganizerView(VolunteerMixin, CreateView):
|
||||
form.instance.set_password(password)
|
||||
form.instance.save()
|
||||
|
||||
with translation.override(settings.PREFERRED_LANGUAGE_CODE):
|
||||
subject = f"[{settings.APP_NAME}] " + str(_("New organizer account"))
|
||||
site = Site.objects.first()
|
||||
message = render_to_string('registration/mails/add_organizer.txt', dict(user=registration.user,
|
||||
@ -445,9 +462,8 @@ class AuthorizationTemplateView(TemplateView):
|
||||
return context
|
||||
|
||||
def render_to_response(self, context, **response_kwargs):
|
||||
translation.activate(settings.PREFERRED_LANGUAGE_CODE)
|
||||
|
||||
template_name = self.get_template_names()[0]
|
||||
with translation.override(settings.PREFERRED_LANGUAGE_CODE):
|
||||
tex = render_to_string(template_name, context=context, request=self.request)
|
||||
temp_dir = mkdtemp()
|
||||
with open(os.path.join(temp_dir, "texput.tex"), "w") as f:
|
||||
|
@ -1,29 +1,28 @@
|
||||
channels[daphne]~=4.1.0
|
||||
channels-redis~=4.2.0
|
||||
crispy-bootstrap5~=2024.10
|
||||
Django>=5.1.2,<6.0
|
||||
django-crispy-forms~=2.3
|
||||
django-extensions~=3.2.3
|
||||
django-filter~=24.3
|
||||
channels[daphne]~=4.2.2
|
||||
channels-redis~=4.2.1
|
||||
citric~=1.4.0
|
||||
crispy-bootstrap5~=2025.4
|
||||
Django>=5.2,<6.0
|
||||
django-crispy-forms~=2.4
|
||||
django-filter~=25.1
|
||||
django-haystack~=3.3.0
|
||||
django-mailer~=2.3.2
|
||||
django-phonenumber-field~=8.0.0
|
||||
django-pipeline~=3.1.0
|
||||
django-phonenumber-field~=8.1.0
|
||||
django-pipeline~=4.0.0
|
||||
django-polymorphic~=3.1.0
|
||||
django-tables2~=2.7.0
|
||||
djangorestframework~=3.15.2
|
||||
django-tables2~=2.7.5
|
||||
djangorestframework~=3.16.0
|
||||
django-rest-polymorphic~=0.1.10
|
||||
elasticsearch~=7.17.9
|
||||
gspread~=6.1.4
|
||||
gspread~=6.2.0
|
||||
gunicorn~=23.0.0
|
||||
odfpy~=1.4.1
|
||||
pandas~=2.2.3
|
||||
phonenumbers~=8.13.47
|
||||
psycopg~=3.2.3
|
||||
pypdf~=5.0.1
|
||||
ipython~=8.28.0
|
||||
phonenumbers~=9.0.3
|
||||
psycopg~=3.2.6
|
||||
pypdf~=5.4.0
|
||||
python-magic~=0.4.27
|
||||
requests~=2.32.3
|
||||
sympasoap~=1.1
|
||||
uvicorn~=0.32.0
|
||||
websockets~=13.1
|
||||
uvicorn~=0.34.2
|
||||
websockets~=15.0.1
|
0
survey/__init__.py
Normal file
0
survey/__init__.py
Normal file
13
survey/admin.py
Normal file
13
survey/admin.py
Normal 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
11
survey/apps.py
Normal 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
28
survey/forms.py
Normal 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',
|
||||
}),
|
||||
}
|
13
survey/management/commands/fetch_survey_completion_data.py
Normal file
13
survey/management/commands/fetch_survey_completion_data.py
Normal 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()
|
83
survey/migrations/0001_initial.py
Normal file
83
survey/migrations/0001_initial.py
Normal 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",
|
||||
},
|
||||
),
|
||||
]
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
0
survey/migrations/__init__.py
Normal file
0
survey/migrations/__init__.py
Normal file
137
survey/models.py
Normal file
137
survey/models.py
Normal 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
31
survey/tables.py
Normal 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',)
|
84
survey/templates/survey/survey_detail.html
Normal file
84
survey/templates/survey/survey_detail.html
Normal 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 %}
|
17
survey/templates/survey/survey_form.html
Normal file
17
survey/templates/survey/survey_form.html
Normal 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 %}
|
14
survey/templates/survey/survey_list.html
Normal file
14
survey/templates/survey/survey_list.html
Normal 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
3
survey/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
18
survey/urls.py
Normal file
18
survey/urls.py
Normal 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
56
survey/views.py
Normal 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
|
13
tfjm.cron
13
tfjm.cron
@ -11,15 +11,16 @@
|
||||
7 3 * * * cd /code && python manage.py fix_sympa_lists &> /dev/null
|
||||
|
||||
# Check payments from Hello Asso
|
||||
*/6 * * * * cd /code && python manage.py check_hello_asso &> /dev/null
|
||||
# Send reminders for payments
|
||||
30 6 * * 1 cd /code && python manage.py remind_payments &> /dev/null
|
||||
*/30 * * 03-05 * cd /code && python manage.py check_hello_asso -v 0
|
||||
|
||||
# Check notation sheets every 15 minutes from 08:00 to 23:00 on fridays to mondays in april and may
|
||||
# */15 8-23 * 4-5 5,6,7,1 cd /code && python manage.py parse_notation_sheets -v 0
|
||||
# Send reminders for payments
|
||||
30 6 * 03-05 1 cd /code && python manage.py remind_payments -v 0
|
||||
|
||||
# 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
|
||||
30 * * * * rm -rf /tmp/*
|
||||
|
@ -13,6 +13,7 @@ def tfjm_context(request):
|
||||
'HAS_OBSERVER': settings.HAS_OBSERVER,
|
||||
'HAS_FINAL': settings.HAS_FINAL,
|
||||
'HOME_PAGE_LINK': settings.HOME_PAGE_LINK,
|
||||
'LIMESURVEY_URL': settings.LIMESURVEY_URL,
|
||||
'LOGO_PATH': "tfjm/img/" + settings.LOGO_FILE,
|
||||
'NB_ROUNDS': settings.NB_ROUNDS,
|
||||
'ML_MANAGEMENT': settings.ML_MANAGEMENT,
|
||||
|
@ -1,7 +1,7 @@
|
||||
# Copyright (C) 2020 by Animath
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import os
|
||||
from django.conf import settings
|
||||
|
||||
_client = None
|
||||
|
||||
@ -9,10 +9,10 @@ _client = None
|
||||
def get_sympa_client():
|
||||
global _client
|
||||
if _client is None:
|
||||
if os.getenv("SYMPA_PASSWORD", None): # pragma: no cover
|
||||
if settings.SYMPA_PASSWORD is not None: # pragma: no cover
|
||||
from sympasoap import Client
|
||||
_client = Client("https://" + os.getenv("SYMPA_URL"))
|
||||
_client.login(os.getenv("SYMPA_EMAIL"), os.getenv("SYMPA_PASSWORD"))
|
||||
_client = Client("https://" + settings.SYMPA_URL)
|
||||
_client.login(settings.SYMPA_EMAIL, settings.SYMPA_PASSWORD)
|
||||
else:
|
||||
_client = FakeSympaSoapClient()
|
||||
return _client
|
||||
|
@ -74,11 +74,11 @@ INSTALLED_APPS = [
|
||||
'draw',
|
||||
'registration',
|
||||
'participation',
|
||||
'survey',
|
||||
]
|
||||
|
||||
if "test" not in sys.argv: # pragma: no cover
|
||||
INSTALLED_APPS += [
|
||||
'django_extensions',
|
||||
'mailer',
|
||||
]
|
||||
|
||||
@ -300,6 +300,12 @@ CHANNEL_LAYERS = {
|
||||
PHONENUMBER_DB_FORMAT = 'NATIONAL'
|
||||
PHONENUMBER_DEFAULT_REGION = 'FR'
|
||||
|
||||
# Sympa configuration
|
||||
SYMPA_HOST = os.getenv("SYMPA_HOST", "localhost")
|
||||
SYMPA_URL = os.getenv("SYMPA_URL", "localhost")
|
||||
SYMPA_EMAIL = os.getenv("SYMPA_EMAIL", "contact@localhost")
|
||||
SYMPA_PASSWORD = os.getenv("SYMPA_PASSWORD", None)
|
||||
|
||||
# Hello Asso API creds
|
||||
HELLOASSO_CLIENT_ID = os.getenv('HELLOASSO_CLIENT_ID', 'CHANGE_ME_IN_ENV_SETTINGS')
|
||||
HELLOASSO_CLIENT_SECRET = os.getenv('HELLOASSO_CLIENT_SECRET', 'CHANGE_ME_IN_ENV_SETTINGS')
|
||||
@ -322,6 +328,10 @@ GOOGLE_SERVICE_CLIENT = {
|
||||
# The ID of the Google Drive folder where to store the notation sheets
|
||||
NOTES_DRIVE_FOLDER_ID = os.getenv("NOTES_DRIVE_FOLDER_ID", "CHANGE_ME_IN_ENV_SETTINGS")
|
||||
|
||||
LIMESURVEY_URL = os.getenv("LIMESURVEY_URL", "https://survey.example.com")
|
||||
LIMESURVEY_USER = os.getenv("LIMESURVEY_USER", "CHANGE_ME_IN_ENV_SETTINGS")
|
||||
LIMESURVEY_PASSWORD = os.getenv("LIMESURVEY_PASSWORD", "CHANGE_ME_IN_ENV_SETTINGS")
|
||||
|
||||
# Custom parameters
|
||||
FORBIDDEN_TRIGRAMS = [
|
||||
"BIT",
|
||||
|
6
tfjm/static/bootstrap/css/bootstrap.min.css
vendored
6
tfjm/static/bootstrap/css/bootstrap.min.css
vendored
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
@ -30,15 +30,6 @@
|
||||
</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">
|
||||
<h5 class="display-4">{% trans "How does it work?" %}</h5>
|
||||
<p>
|
||||
|
@ -74,6 +74,9 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if user.registration.is_admin %}
|
||||
<li class="nav-item active">
|
||||
<a class="nav-link" href="{% url "survey:survey_list" %}"><i class="fas fa-square-poll-horizontal"></i> {% trans "surveys"|capfirst %}</a>
|
||||
</li>
|
||||
<li class="nav-item active">
|
||||
<a class="nav-link" href="{% url "admin:index" %}"><i class="fas fa-cog"></i> {% trans "Administration" %}</a>
|
||||
</li>
|
||||
|
@ -44,6 +44,7 @@ urlpatterns = [
|
||||
path('draw/', include('draw.urls')),
|
||||
path('participation/', include('participation.urls')),
|
||||
path('registration/', include('registration.urls')),
|
||||
path('survey/', include('survey.urls')),
|
||||
|
||||
path('media/authorization/photo/<str:filename>/', PhotoAuthorizationView.as_view(),
|
||||
name='photo_authorization'),
|
||||
|
Reference in New Issue
Block a user