1
0
mirror of https://gitlab.com/animath/si/plateforme.git synced 2025-06-21 17:58:24 +02:00

Compare commits

..

32 Commits

Author SHA1 Message Date
8aec72d712 Correction mot Coefficient 2025-05-31 17:38:45 +02:00
6a521b6121 Noms des fichiers en français 2025-05-31 12:18:12 +02:00
62abfa94d6 Correction liens bandeau Informations pour la finale 2025-05-29 21:49:59 +02:00
952315ea4d Correction publication des notes pour le dernier tour 2025-05-05 10:28:22 +02:00
2e613799c9 Remplacement de yuglify par uglify, plus récent 2025-04-28 23:32:49 +02:00
08805a6360 Correction non-affichage des colonnes d'observation sans observateur 2025-04-28 22:44:08 +02:00
6841659e41 Plus de AdminRegistration à indexer 2025-04-28 22:14:40 +02:00
a84ffcf0a3 Bouton pour rendre les solutions accessibles pour le second tour en 1 clic 2025-04-28 22:01:26 +02:00
203fc3cd54 On n'affiche pas les paiements pour la finale sur la liste des paiements d'un tournoi régional 2025-04-28 20:43:36 +02:00
60f5236dee Affichage du tournoi dans la liste des réponses à un questionnaire 2025-04-28 20:35:23 +02:00
ab459ecc17 On n'affiche pas les données de l'équipe observatrice quand on a pas 2025-04-28 20:34:06 +02:00
7ad7659d78 Use solution number instead of passage index in scale sheets 2025-04-28 20:06:53 +02:00
84eb08ec46 Correction formulaire saisie notes s'il n'y a pas d'observateur⋅rice 2025-04-26 19:20:54 +02:00
3750828883 Send mails using the runmailer_pg command 2025-04-25 00:00:25 +02:00
ba36ad4071 Update coefficients 2025-04-24 21:57:52 +02:00
626433c464 Prevent some errors 2025-04-24 21:29:08 +02:00
032b67ac51 Don't generate spreadhseet if there is no team in a pool 2025-04-23 20:40:14 +02:00
f3bd479fdc Fix final sheet layout for 4-teams pools 2025-04-22 23:17:20 +02:00
bc06cf4903 Fix draw issues with translated strings 2025-04-22 22:58:12 +02:00
6d43c4b97e annulé != terminé 2025-04-22 20:59:53 +02:00
0499885fc8 Fix problem names for 2025 2025-04-22 20:20:22 +02:00
63c96ff2d2 Refetch search query when the input is updated 2025-04-22 19:52:07 +02:00
efeb2628ad Fix notation sheet when there is no observer 2025-04-22 19:44:21 +02:00
56aad288f4 Simplify elasticsearch index to make it work better 2025-04-22 19:19:22 +02:00
b33a69410a Bump dependencies for Django 5.2 2025-04-21 18:57:23 +02:00
0a80e03b58 Add Docker build in CI 2025-04-21 18:57:16 +02:00
73b94d5578 Remove default gender value
Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
2025-03-27 20:19:11 +01:00
97eea3b11a Add survey notification in the menu 2025-03-19 23:56:53 +01:00
702c8d8c9e Add survey feature 2025-03-19 23:18:45 +01:00
ca0601fb24 Autorisation parentale particulière pour Lyon 2025-03-16 19:39:38 +01:00
d315c8371a Update Bootstrap to v5.3.3 and fix light mode hamburger button in chat 2025-03-09 13:08:58 +01:00
7488d3eae1 Ensure that all mails are translated 2025-03-09 12:35:04 +01:00
60 changed files with 1640 additions and 703 deletions

View File

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

View File

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

View File

@ -178,7 +178,7 @@ Seuls les refus distincts comptent : refuser une deuxième fois un problème
déjà refusé ne compte pas. Au-delà de ces refus gratuits, l'équipe se verra
dotée d'une pénalité de 25 % sur le coefficient de l'oral de défense, par
refus. Par exemple, si une équipe refuse 4 problèmes avec un coefficient
sur l'oral de défense normalement à ``1.6``, son coefficient passera à ``1.2``.
sur l'oral de défense normalement à ``1.5``, son coefficient passera à ``1.125``.
Une fois que toutes les équipes de la poule ont tiré leur problème, on passe
à la poule suivante. Une fois que toutes les poules ont vu leurs problèmes

View File

@ -224,7 +224,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Update user interface
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.start', 'fmt': fmt, 'draw': draw})
{'tid': self.tournament_id, 'type': 'draw.start', 'fmt': fmt})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.set_info',
'info': await self.tournament.draw.ainformation()})
@ -235,7 +235,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.notify',
'title': 'Tirage au sort du TFJM²',
'body': _("The draw of tournament {tournament} started!")
'body': str(_("The draw of tournament {tournament} started!"))
.format(tournament=self.tournament.name)})
async def draw_start(self, content) -> None:
@ -405,15 +405,15 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
await self.channel_layer.group_send(
f"team-{dup.participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify', 'title': 'Tirage au sort du TFJM²',
'body': _("Your dice score is identical to the one of one or multiple teams. "
"Please relaunch it.")}
'body': str(_("Your dice score is identical to the one of one or multiple teams. "
"Please relaunch it."))}
)
# Alert the tournament
await self.channel_layer.group_send(
f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.alert',
'message': _('Dices from teams {teams} are identical. Please relaunch your dices.').format(
teams=', '.join(td.participation.team.trigram for td in dups)),
'message': str(_('Dices from teams {teams} are identical. Please relaunch your dices.').format(
teams=', '.join(td.participation.team.trigram for td in dups))),
'alert_type': 'warning'})
error = True
@ -537,7 +537,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
async for next_round in self.tournament.draw.round_set.filter(number__gte=2).all():
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.send_poules',
'round': r.number,
'round': next_round.number,
'poules': [
{
'letter': pool.get_letter_display(),
@ -612,8 +612,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Notify the team that it can draw a problem
await self.channel_layer.group_send(f"team-{tds[0].participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify',
'title': _("Your turn!"),
'body': _("It's your turn to draw a problem!")})
'title': str(_("Your turn!")),
'body': str(_("It's your turn to draw a problem!"))})
async def select_problem(self, **kwargs):
"""
@ -752,8 +752,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Notify the team that it can draw a problem
await self.channel_layer.group_send(f"team-{new_trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify',
'title': _("Your turn!"),
'body': _("It's your turn to draw a problem!")})
'title': str(_("Your turn!")),
'body': str(_("It's your turn to draw a problem!"))})
else:
# Pool is ended
await self.end_pool(pool)
@ -829,8 +829,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Notify the team that it can draw a dice
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify',
'title': _("Your turn!"),
'body': _("It's your turn to launch the dice!")})
'title': str(_("Your turn!")),
'body': str(_("It's your turn to launch the dice!"))})
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.dice_visibility',
@ -863,8 +863,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Notify the team that it can draw a dice
await self.channel_layer.group_send(f"team-{participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify',
'title': _("Your turn!"),
'body': _("It's your turn to launch the dice!")})
'title': str(_("Your turn!")),
'body': str(_("It's your turn to launch the dice!"))})
# Reorder dices
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
@ -988,8 +988,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Notify the team that it can draw a problem
await self.channel_layer.group_send(f"team-{new_trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify',
'title': _("Your turn!"),
'body': _("It's your turn to draw a problem!")})
'title': str(_("Your turn!")),
'body': str(_("It's your turn to draw a problem!"))})
@ensure_orga
async def export(self, **kwargs):
@ -1039,7 +1039,7 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Send notification to everyone
await self.channel_layer.group_send(f"tournament-{self.tournament.id}",
{'tid': self.tournament_id, 'type': 'draw.notify',
'title': _("Draw") + " " + settings.APP_NAME,
'title': str(_("Draw")) + " " + settings.APP_NAME,
'body': str(_("The draw of the second round is starting!"))})
if settings.TFJM_APP == "TFJM":
@ -1092,8 +1092,8 @@ class DrawConsumer(AsyncJsonWebsocketConsumer):
# Notify the team that it can draw a problem
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",
{'tid': self.tournament_id, 'type': 'draw.notify',
'title': _("Your turn!"),
'body': _("It's your turn to draw a problem!")})
'title': str(_("Your turn!")),
'body': str(_("It's your turn to draw a problem!"))})
else:
async for td in r2.team_draws.prefetch_related('participation__team'):
await self.channel_layer.group_send(f"team-{td.participation.team.trigram}",

View File

@ -221,9 +221,10 @@ document.addEventListener('DOMContentLoaded', () => {
elem.innerText = `${trigram} 🎲 ${result}`
}
let nextTeam = document.querySelector(` div[id="dices-${tid}"] > div > div[class*="text-bg-warning"]`).getAttribute("data-team")
if (nextTeam) {
let nextTeamDiv = document.querySelector(` div[id="dices-${tid}"] > div > div[class*="text-bg-warning"]`)
if (nextTeamDiv) {
// If there is one team that does not have launched its dice, then we update the debug section
let nextTeam = nextTeamDiv.getAttribute("data-team")
let debugSpan = document.getElementById(`debug-dice-${tid}-team`)
if (debugSpan)
debugSpan.innerText = nextTeam

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,9 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.contrib import admin
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from .models import Note, Participation, Passage, Pool, Solution, Team, Tournament, Tweak, WrittenReview
@ -51,9 +53,14 @@ class PassageInline(admin.TabularInline):
model = Passage
extra = 0
ordering = ('position',)
autocomplete_fields = ('reporter', 'opponent', 'reviewer', 'observer',)
show_change_link = True
def get_autocomplete_fields(self, request: HttpRequest) -> tuple[str]:
fields = ('reporter', 'opponent', 'reviewer',)
if settings.HAS_OBSERVER:
fields += ('observer',)
return fields
class NoteInline(admin.TabularInline):
model = Note
@ -113,12 +120,9 @@ class PoolAdmin(admin.ModelAdmin):
@admin.register(Passage)
class PassageAdmin(admin.ModelAdmin):
list_display = ('__str__', 'reporter_trigram', 'solution_number', 'opponent_trigram', 'reviewer_trigram',
'observer_trigram', 'pool_abbr', 'position', 'tournament')
list_filter = ('pool__tournament', 'pool__round', 'pool__letter', 'solution_number',)
search_fields = ('pool__participations__team__name', 'pool__participations__team__trigram',)
ordering = ('pool__tournament', 'pool__round', 'pool__letter', 'position',)
autocomplete_fields = ('pool', 'reporter', 'opponent', 'reviewer', 'observer',)
inlines = (NoteInline,)
@admin.display(description=_("reporter"), ordering='reporter__team__trigram')
@ -135,7 +139,7 @@ class PassageAdmin(admin.ModelAdmin):
@admin.display(description=_("observer"), ordering='observer__team__trigram')
def observer_trigram(self, record: Passage):
return record.observer.team.trigram
return record.observer.team.trigram if record.observer else None
@admin.display(description=_("pool"), ordering='pool__letter')
def pool_abbr(self, record):
@ -145,15 +149,23 @@ class PassageAdmin(admin.ModelAdmin):
def tournament(self, record: Passage):
return record.pool.tournament
def get_list_display(self, request: HttpRequest) -> tuple[str]:
if settings.HAS_OBSERVER:
return ('__str__', 'reporter_trigram', 'solution_number', 'opponent_trigram',
'reviewer_trigram', 'observer_trigram', 'pool_abbr', 'position', 'tournament')
else:
return ('__str__', 'reporter_trigram', 'solution_number', 'opponent_trigram',
'reviewer_trigram', 'pool_abbr', 'position', 'tournament')
def get_autocomplete_fields(self, request: HttpRequest) -> tuple[str]:
fields = ('pool', 'reporter', 'opponent', 'reviewer',)
if settings.HAS_OBSERVER:
fields += ('observer',)
return fields
@admin.register(Note)
class NoteAdmin(admin.ModelAdmin):
list_display = ('passage', 'pool', 'jury', 'reporter_writing', 'reporter_oral',
'opponent_writing', 'opponent_oral', 'reviewer_writing', 'reviewer_oral',
'observer_writing', 'observer_oral',)
list_filter = ('passage__pool__letter', 'passage__solution_number', 'jury',
'reporter_writing', 'reporter_oral', 'opponent_writing', 'opponent_oral',
'reviewer_writing', 'reviewer_oral', 'observer_writing', 'observer_oral')
search_fields = ('jury__user__last_name', 'jury__user__first_name', 'passage__reporter__team__trigram',)
autocomplete_fields = ('jury', 'passage',)
@ -161,6 +173,21 @@ class NoteAdmin(admin.ModelAdmin):
def pool(self, record):
return record.passage.pool.short_name
def get_list_display(self, request: HttpRequest) -> tuple[str]:
fields = ('passage', 'pool', 'jury', 'reporter_writing', 'reporter_oral',
'opponent_writing', 'opponent_oral', 'reviewer_writing', 'reviewer_oral',)
if settings.HAS_OBSERVER:
fields += ('observer_writing', 'observer_oral',)
return fields
def get_list_filter(self, request: HttpRequest) -> tuple[str]:
fields = ('passage__pool__letter', 'passage__solution_number', 'jury',
'reporter_writing', 'reporter_oral', 'opponent_writing', 'opponent_oral',
'reviewer_writing', 'reviewer_oral',)
if settings.HAS_OBSERVER:
fields += ('observer_writing', 'observer_oral',)
return fields
@admin.register(Solution)
class SolutionAdmin(admin.ModelAdmin):

View File

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

View File

@ -7,6 +7,7 @@ import re
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Div, Field, HTML, Layout, Submit
from django import forms
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator
@ -14,7 +15,6 @@ from django.utils.translation import gettext_lazy as _
import pandas
from pypdf import PdfReader
from registration.models import VolunteerRegistration
from tfjm import settings
from .models import Note, Participation, Passage, Pool, Solution, Team, Tournament, WrittenReview
@ -405,6 +405,12 @@ class WrittenReviewForm(forms.ModelForm):
class NoteForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not settings.HAS_OBSERVER:
del self.fields['observer_writing']
del self.fields['observer_oral']
class Meta:
model = Note
fields = ('reporter_writing', 'reporter_oral', 'opponent_writing',

View File

@ -5,11 +5,13 @@ from pathlib import Path
from django.conf import settings
from django.core.management import BaseCommand
from django.utils.translation import activate
from participation.models import Solution, Tournament
class Command(BaseCommand):
def handle(self, *args, **kwargs):
activate(settings.PREFERRED_LANGUAGE_CODE)
base_dir = Path(__file__).parent.parent.parent.parent
base_dir /= "output"
if not base_dir.is_dir():

View File

@ -3,13 +3,12 @@
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
@ -211,7 +210,7 @@ class Team(models.Model):
"""
:return: The mailing list to contact the team members.
"""
return f"equipe-{slugify(self.trigram)}@{os.getenv('SYMPA_HOST', 'localhost')}"
return f"equipe-{slugify(self.trigram)}@{settings.SYMPA_HOST}"
def create_mailing_list(self):
"""
@ -392,21 +391,21 @@ class Tournament(models.Model):
"""
:return: The mailing list to contact the team members.
"""
return f"equipes-{slugify(self.name)}@{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-{slugify(self.name)}@{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-{slugify(self.name)}@{os.getenv('SYMPA_HOST', 'localhost')}"
return f"jurys-{slugify(self.name)}@{settings.SYMPA_HOST}"
def create_mailing_lists(self):
"""
@ -441,6 +440,10 @@ class Tournament(models.Model):
return Participation.objects.filter(final=True)
return self.participation_set
@property
def organizers_and_presidents(self):
return VolunteerRegistration.objects.filter(Q(admin=True) | Q(organized_tournaments=self) | Q(pools_presided__tournament=self))
@property
def solutions(self):
if self.final:
@ -847,6 +850,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)
@ -865,6 +870,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:
@ -918,10 +936,10 @@ class Participation(models.Model):
'content': content,
})
elif timezone.now() <= tournament.reviews_first_phase_limit + timedelta(hours=2):
reporter_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=1, reporter=self)
opponent_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=1, opponent=self)
reviewer_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=1, reviewer=self)
observer_passage = Passage.objects.filter(pool__tournament=self.tournament, pool__round=1, observer=self)
reporter_passage = Passage.objects.get(pool__tournament=tournament, pool__round=1, reporter=self)
opponent_passage = Passage.objects.get(pool__tournament=tournament, pool__round=1, opponent=self)
reviewer_passage = Passage.objects.get(pool__tournament=tournament, pool__round=1, reviewer=self)
observer_passage = Passage.objects.filter(pool__tournament=tournament, pool__round=1, observer=self)
observer_passage = observer_passage.get() if observer_passage.exists() else None
reporter_text = _("<p>The solutions draw is ended. You can check the result on "
@ -983,10 +1001,10 @@ class Participation(models.Model):
'content': content,
})
elif timezone.now() <= tournament.reviews_second_phase_limit + timedelta(hours=2):
reporter_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=2, reporter=self)
opponent_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=2, opponent=self)
reviewer_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=2, reviewer=self)
observer_passage = Passage.objects.filter(pool__tournament=self.tournament, pool__round=2, observer=self)
reporter_passage = Passage.objects.get(pool__tournament=tournament, pool__round=2, reporter=self)
opponent_passage = Passage.objects.get(pool__tournament=tournament, pool__round=2, opponent=self)
reviewer_passage = Passage.objects.get(pool__tournament=tournament, pool__round=2, reviewer=self)
observer_passage = Passage.objects.filter(pool__tournament=tournament, pool__round=2, observer=self)
observer_passage = observer_passage.get() if observer_passage.exists() else None
reporter_text = _("<p>For the second round, you will present "
@ -1047,10 +1065,10 @@ class Participation(models.Model):
})
elif settings.NB_ROUNDS >= 3 \
and timezone.now() <= tournament.reviews_third_phase_limit + timedelta(hours=2):
reporter_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=3, reporter=self)
opponent_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=3, opponent=self)
reviewer_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=3, reviewer=self)
observer_passage = Passage.objects.filter(pool__tournament=self.tournament, pool__round=3, observer=self)
reporter_passage = Passage.objects.get(pool__tournament=tournament, pool__round=3, reporter=self)
opponent_passage = Passage.objects.get(pool__tournament=tournament, pool__round=3, opponent=self)
reviewer_passage = Passage.objects.get(pool__tournament=tournament, pool__round=3, reviewer=self)
observer_passage = Passage.objects.filter(pool__tournament=tournament, pool__round=3, observer=self)
observer_passage = observer_passage.get() if observer_passage.exists() else None
reporter_text = _("<p>For the third round, you will present "
@ -1240,6 +1258,10 @@ class Pool(models.Model):
passage_width = 6 + (2 if has_observer else 0)
passages = self.passages.all()
if not pool_size or not passages.count():
# Not initialized yet
return
# Create tournament sheet if it does not exist
self.tournament.create_spreadsheet()
@ -1609,6 +1631,10 @@ class Pool(models.Model):
worksheet.client.batch_update(spreadsheet.id, body)
def update_juries_lines_spreadsheet(self):
if not self.participations.count() or not self.passages.count():
# Not initialized yet
return
translation.activate(settings.PREFERRED_LANGUAGE_CODE)
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
@ -1759,7 +1785,7 @@ class Passage(models.Model):
@property
def coeff_reporter_oral(self) -> float:
coeff = 1.6 if settings.TFJM_APP == "TFJM" else 3
coeff = 1.5 if settings.TFJM_APP == "TFJM" else 3
coeff *= 1 - 0.25 * self.reporter_penalties
return coeff
@ -1803,7 +1829,7 @@ class Passage(models.Model):
@property
def coeff_reviewer_oral(self):
return 1 if settings.TFJM_APP == "TFJM" else 1.2
return 1.2
@property
def average_reviewer(self) -> float:

View File

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

View File

@ -44,7 +44,7 @@
\Large {\bf \tfjmedition$^{st}$ European Tournament of Enthusiastic Apprentice Mathematicians}\\
{% endif %}
\vspace{3mm}
{% trans "Round" %} {{ pool.round }} \;-- {% trans "Pool" %} {{ pool.get_letter_display }}{% if pool.participations.count == 5 %} \;-- {{ pool.get_room_display }}{% endif %} \;-- {% if pool.round == 1 %}{{ pool.tournament.date_first_phase }}{% elif pool.round == 2 %}{{ pool.tournament.date_second_phase }}{% else %}{{ pool.tournament.date_third_phase }}{% endif %}
{% trans "round"|capfirst %} {{ pool.round }} \;-- {% trans "pool"|capfirst %} {{ pool.get_letter_display }}{% if pool.participations.count == 5 %} \;-- {{ pool.get_room_display }}{% endif %} \;-- {% if pool.round == 1 %}{{ pool.tournament.date_first_phase }}{% elif pool.round == 2 %}{{ pool.tournament.date_second_phase }}{% else %}{{ pool.tournament.date_third_phase }}{% endif %}
\vspace{15mm}
@ -52,7 +52,7 @@
\begin{tabular}{|p{40mm}{% for passage in passages.all %}{% if passages.count <= 3 %}|p{3cm}|p{3cm}{% else %}|p{2.8cm}|p{2.5cm}{% endif %}{% endfor %}|}\hline
\multirow{2}{40mm}{\LARGE {% trans "Role" %}} {% for passage in passages.all %}& \multicolumn{2}{c|}{ \Large {% trans "Problem" %} {{ passage.solution_number }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
{% for passage in passages.all %}& \hspace{4mm} {\Large {% trans "Writing"|upper %}} & \hspace{4mm} {\Large {% trans "Oral"|upper %}}{% endfor %} \\ \hline
{% for passage in passages.all %}& \multicolumn{1}{c|}{\Large {% trans "Writing"|upper %}} & \multicolumn{1}{c|}{\Large {% trans "Oral"|upper %}}{% endfor %} \\ \hline
\multirow{2}{35mm}{\LARGE {% trans "Reporter" %}} {% for passage in passages.all %}& \multicolumn{2}{c|}{\Large {{ passage.reporter.team.trigram }}}{% endfor %} \\ \cline{2-{{ passages.count|add:passages.count|add:1 }}}
{% for passage in passages.all %}
& \phantom{asd asd} \phantom{asd asd} \centering \normalsize$0\leq x\leq {% if TFJM.APP == "TFJM" %}20{% else %}10{% endif %}$

View File

@ -58,7 +58,7 @@
%%%%%%%%%%%%%%%%%%%%%DEFENSEUR
\begin{tabular}{|c|p{25mm}|p{11cm}|c|{% for passage in passages.all %}p{2cm}|{% endfor %}}\hline
\multicolumn{4}{|l|}{The {\bf {% trans "Reporter" %}} \normalsize presents their ideas and major results for the solution of the problem.} {% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.reporter.team.trigram }} {% endfor %}\\ \hline \hline
\multicolumn{4}{|l|}{The {\bf {% trans "Reporter" %}} \normalsize presents their ideas and major results for the solution of the problem.} {% for passage in passages.all %}& Pb. {{ passage.solution_number }} - {{ passage.reporter.team.trigram }} {% endfor %}\\ \hline \hline
%ECRIT
\multirow{7}{3mm}{\bf \begin{turn}{90}WRITING\end{turn}} & \multirow{3}{20mm}{ {% trans "Scientific part" %}} & {% trans "Depth and difficulty of the elements presented" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
@ -86,7 +86,7 @@
%%%%%%%%%%%%%%%%%OPPOSANT⋅E
\begin{tabular}{|c|p{25mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
\multicolumn{4}{|l|}{The {\bf {% trans "Opponent" %}} \normalsize provides a critical analysis of the solution and presentation.}
{% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.opponent.team.trigram }} {% endfor %} \\ \hline \hline
{% for passage in passages.all %}& Pb. {{ passage.solution_number }} - {{ passage.opponent.team.trigram }} {% endfor %} \\ \hline \hline
%ECRIT
\multirow{6}{3mm}{\bf \begin{turn}{90}WRITING\end{turn}} &\multirow{4}{25mm}{ {% trans "Scientific part" %}} & {% trans "Critical thinking and perspective on the proposed solution" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
@ -108,7 +108,7 @@
%%%%%%%%%%%%%%%%%%%%%%RAPPORTEUR⋅RICE
\begin{tabular}{|c|p{25mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
\multicolumn{4}{|l|}{The {\bf {% trans "Reviewer" %}} \normalsize evaluates the debate between the Reporter and the Opponent.} {% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.reviewer.team.trigram }} {% endfor %}\\ \hline \hline
\multicolumn{4}{|l|}{The {\bf {% trans "Reviewer" %}} \normalsize evaluates the debate between the Reporter and the Opponent.} {% for passage in passages.all %}& Pb. {{ passage.solution_number }} - {{ passage.reviewer.team.trigram }} {% endfor %}\\ \hline \hline
%ECRIT
\multirow{6}{3mm}{\bf \begin{turn}{90}WRITING\end{turn}} &\multirow{4}{25mm}{ {% trans "Scientific part" %}} & {% trans "Critical thinking and perspective on the proposed solution" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
@ -131,7 +131,7 @@
{% if TFJM.APP == "ETEAM" and pool.participations.count >= 4 %}
%%%%%%%%%%%%%%%%%%%%%%OBSERVATEUR⋅RICE
\begin{tabular}{|c|p{25mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
\multicolumn{4}{|l|}{The {\bf {% trans "Observer" %}} \normalsize makes useful remarks on crucial points missed by the other participants.} {% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.observer.team.trigram }} {% endfor %}\\ \hline \hline
\multicolumn{4}{|l|}{The {\bf {% trans "Observer" %}} \normalsize makes useful remarks on crucial points missed by the other participants.} {% for passage in passages.all %}& Pb. {{ passage.solution_number }} - {{ passage.observer.team.trigram }} {% endfor %}\\ \hline \hline
%ECRIT
\multirow{6}{3mm}{\bf \begin{turn}{90}WRITING\end{turn}} &\multirow{4}{25mm}{ {% trans "Scientific part" %}} & {% trans "Critical thinking and perspective on the proposed solution" %} & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}

View File

@ -52,7 +52,7 @@
%%%%%%%%%%%%%%%%%%%%%DEFENSEUR
\begin{tabular}{|c|p{24mm}|p{11cm}|c|{% for passage in passages.all %}p{2cm}|{% endfor %}}\hline
\multicolumn{4}{|l|}{{\bf D\'efenseur⋅se} \normalsize pr\'esente les id\'ees et r\'esultats principaux pour la solution du probl\`eme.} {% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.reporter.team.trigram }} {% endfor %}\\ \hline \hline
\multicolumn{4}{|l|}{{\bf D\'efenseur⋅se} \normalsize pr\'esente les id\'ees et r\'esultats principaux pour la solution du probl\`eme.} {% for passage in passages.all %}& Pb. {{ passage.solution_number }} - {{ passage.reporter.team.trigram }} {% endfor %}\\ \hline \hline
%ECRIT
\multirow{7}{3mm}{\bf \begin{turn}{90}ÉCRIT\end{turn}} & \multirow{3}{24mm}{Partie scientifique} & Profondeur et difficulté des éléments présentés & [0,6] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
@ -80,7 +80,7 @@
%%%%%%%%%%%%%%%%%OPPOSANT
\begin{tabular}{|c|p{24mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
\multicolumn{4}{|l|}{L' {\bf Opposant⋅e} \normalsize fournit une analyse critique de la solution et de la pr\'esentation.}
{% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.opponent.team.trigram }} {% endfor %} \\ \hline \hline
{% for passage in passages.all %}& Pb. {{ passage.solution_number }} - {{ passage.opponent.team.trigram }} {% endfor %} \\ \hline \hline
%ECRIT
\multirow{6}{3mm}{\bf \begin{turn}{90}ÉCRIT\end{turn}} &\multirow{4}{24mm}{Partie scientifique} & Recul et esprit critique par rapport à la solution proposée & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
@ -102,7 +102,7 @@
%%%%%%%%%%%%%%%%%%%%%%RAPPORTEUR.RICE
\begin{tabular}{|c|p{24mm}|p{11cm}|c{% for passage in passages.all %}|p{2cm}{% endfor %}|}\hline
\multicolumn{4}{|l|}{{\bf Rapporteur⋅rice} \normalsize \'evalue le d\'ebat entre læ D\'efenseur⋅se et l'Opposant⋅e.} {% for passage in passages.all %}& P.{{ forloop.counter }} - {{ passage.reviewer.team.trigram }} {% endfor %}\\ \hline \hline
\multicolumn{4}{|l|}{{\bf Rapporteur⋅rice} \normalsize \'evalue le d\'ebat entre læ D\'efenseur⋅se et l'Opposant⋅e.} {% for passage in passages.all %}& Pb. {{ passage.solution_number }} - {{ passage.reviewer.team.trigram }} {% endfor %}\\ \hline \hline
%ECRIT
\multirow{6}{3mm}{\bf \begin{turn}{90}ÉCRIT\end{turn}} &\multirow{4}{24mm}{Partie scientifique} & Recul et esprit critique par rapport à la solution proposée & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
@ -115,7 +115,7 @@
\multirow{9}{3mm}{\bf \begin{turn}{90}ORAL\end{turn}} & \multirow{5}{24mm}{Questions et discours de læ rapporteur⋅rice} & \footnotesize Faire prendre de la hauteur au débat (par les sujets abordés, la pertinence des questions posées, les points soulevés, gestion du temps) & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& \footnotesize Créer un échange constructif entre les participants (formulation des questions, réaction aux réponses, articulation entre les questions, circulation de la parole) & [0,3] {{ esp|safe }}\\ \cline{3-{{ passages.count|add:4 }}}
&& Capacité à évaluer la qualité des échanges (Défenseur⋅se-Opposant⋅e et à trois) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&& Réponses aux questions de læ Rapporteur⋅rice et du jury (fond et capacité à faire avancer le débat) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&& Réponses aux questions du jury (fond et capacité à faire avancer le débat) & [0,2] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
& Malus & Attitude irrespectueuse ? & [-3,0] {{ esp|safe }}\\ \cline{2-{{ passages.count|add:4 }}}
&\multicolumn{3}{|l|}{\bf TOTAL ORAL (/10)} {{ esp|safe }}\\ \hline
\end{tabular}

View File

@ -23,45 +23,81 @@
<dd class="col-sm-6">{% if tournament.price %}{{ tournament.price }} €{% else %}{% trans "Free" %}{% endif %}</dd>
{% endif %}
<dt class="col-sm-6 text-sm-end">{% trans 'remote'|capfirst %}</dt>
<dt class="col-sm-6 text-sm-end">{% trans "remote"|capfirst %}</dt>
<dd class="col-sm-6">{{ tournament.remote|yesno }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans 'dates'|capfirst %}</dt>
<dt class="col-sm-6 text-sm-end">{% trans "dates"|capfirst %}</dt>
<dd class="col-sm-6">{% trans "From" %} {{ tournament.date_start }} {% trans "to" %} {{ tournament.date_end }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans 'date of registration closing'|capfirst %}</dt>
<dt class="col-sm-6 text-sm-end">{% trans "date of registration closing"|capfirst %}</dt>
<dd class="col-sm-6">{{ tournament.inscription_limit }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans 'date of maximal solution submission'|capfirst %}</dt>
<dt class="col-sm-6 text-sm-end">{% trans "date of maximal solution submission"|capfirst %}</dt>
<dd class="col-sm-6">{{ tournament.solution_limit }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans 'date of the random draw'|capfirst %}</dt>
<dt class="col-sm-6 text-sm-end">{% trans "date of the random draw"|capfirst %}</dt>
<dd class="col-sm-6">{{ tournament.solutions_draw }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans 'date of maximal written reviews submission for the first round'|capfirst %}</dt>
<dt class="col-sm-6 text-sm-end">{% trans "date of maximal written reviews submission for the first round"|capfirst %}</dt>
<dd class="col-sm-6">{{ tournament.reviews_first_phase_limit }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans 'date of maximal written reviews submission for the second round'|capfirst %}</dt>
<dt class="col-sm-6 text-sm-end">{% trans "Solutions available for the second round" %}</dt>
<dd class="col-sm-6">
{{ tournament.solutions_available_second_phase|yesno }}
{% if user.is_authenticated and user.registration in tournament.organizers_and_presidents.all %}
{% now 'Y-m-d' as today %}
{% if not tournament.solutions_available_second_phase %}
{% if today >= tournament.date_first_phase|date:"Y-m-d" %}
<a href="{% url 'participation:tournament_publish_solutions' pk=tournament.pk round=2 %}" class="btn btn-sm btn-info"><i class="fas fa-eye"></i> {% trans "Publish" %}</a>
{% endif %}
{% else %}
{% if today <= tournament.date_second_phase|date:"Y-m-d" %}
<a href="{% url 'participation:tournament_publish_solutions' pk=tournament.pk round=2 %}?hide" class="btn btn-sm bg-danger"><i class="fas fa-eye-slash"></i> {% trans "Unpublish" %}</a>
{% endif %}
{% endif %}
{% endif %}
</dd>
<dt class="col-sm-6 text-sm-end">{% trans "date of maximal written reviews submission for the second round"|capfirst %}</dt>
<dd class="col-sm-6">{{ tournament.reviews_second_phase_limit }}</dd>
{% if TFJM.APP == "ETEAM" %}
<dt class="col-sm-6 text-sm-end">{% trans 'date of maximal written reviews submission for the third round'|capfirst %}</dt>
{% if TFJM.NB_ROUNDS == 3 %}
<dt class="col-sm-6 text-sm-end">{% trans "Solutions available for the third round" %}</dt>
<dd class="col-sm-6">
{{ tournament.solutions_available_third_phase|yesno }}
{% if tournament.solutions_available_second_phase and user.is_authenticated and user.registration in tournament.organizers_and_presidents.all %}
{% now 'Y-m-d' as today %}
{% if not tournament.solutions_available_third_phase %}
{% if today >= tournament.date_second_phase|date:"Y-m-d" %}
<a href="{% url 'participation:tournament_publish_solutions' pk=tournament.pk round=3 %}" class="btn btn-sm btn-info"><i class="fas fa-eye"></i> {% trans "Publish" %}</a>
{% endif %}
{% else %}
{% if today <= tournament.date_third_phase|date:"Y-m-d" %}
<a href="{% url 'participation:tournament_publish_solutions' pk=tournament.pk round=3 %}?hide" class="btn btn-sm bg-danger"><i class="fas fa-eye-slash"></i> {% trans "Unpublish" %}</a>
{% endif %}
{% endif %}
{% endif %}
</dd>
<dt class="col-sm-6 text-sm-end">{% trans "date of maximal written reviews submission for the third round"|capfirst %}</dt>
<dd class="col-sm-6">{{ tournament.reviews_third_phase_limit }}</dd>
{% endif %}
<dt class="col-sm-6 text-sm-end">{% trans 'description'|capfirst %}</dt>
<dt class="col-sm-6 text-sm-end">{% trans "description"|capfirst %}</dt>
<dd class="col-sm-6">{{ tournament.description }}</dd>
{% if TFJM.ML_MANAGEMENT %}
<dt class="col-sm-6 text-sm-end">{% trans 'To contact organizers' %}</dt>
<dt class="col-sm-6 text-sm-end">{% trans "To contact organizers" %}</dt>
<dd class="col-sm-6"><a href="mailto:{{ tournament.organizers_email }}">{{ tournament.organizers_email }}</a></dd>
<dt class="col-sm-6 text-sm-end">{% trans 'To contact juries' %}</dt>
{% if user.is_authenticated and user.registration.is_volunteer %}
<dt class="col-sm-6 text-sm-end">{% trans "To contact juries" %}</dt>
<dd class="col-sm-6"><a href="mailto:{{ tournament.jurys_email }}">{{ tournament.jurys_email }}</a></dd>
<dt class="col-sm-6 text-sm-end">{% trans 'To contact valid teams' %}</dt>
<dt class="col-sm-6 text-sm-end">{% trans "To contact valid teams" %}</dt>
<dd class="col-sm-6"><a href="mailto:{{ tournament.teams_email }}">{{ tournament.teams_email }}</a></dd>
{% endif %}
{% endif %}
</dl>
</div>

View File

@ -1,3 +1,2 @@
{{ object.name }}
{{ object.place }}
{{ object.description }}

View File

@ -1,5 +0,0 @@
{{ object.link }}
{{ object.participation.team.name }}
{{ object.participation.team.trigram }}
{{ object.participation.problem }}
{{ object.participation.get_problem_display }}

View File

@ -12,7 +12,7 @@ from .views import CreateTeamView, FinalNotationSheetTemplateView, GSheetNotific
TeamAuthorizationsView, TeamDetailView, TeamLeaveView, TeamListView, TeamUpdateView, \
TeamUploadMotivationLetterView, TournamentCreateView, TournamentDetailView, TournamentExportCSVView, \
TournamentHarmonizeNoteView, TournamentHarmonizeView, TournamentListView, TournamentPaymentsView, \
TournamentPublishNotesView, TournamentUpdateView, WrittenReviewUploadView
TournamentPublishNotesView, TournamentPublishSolutionsView, TournamentUpdateView, WrittenReviewUploadView
app_name = "participation"
@ -48,6 +48,8 @@ urlpatterns = [
name="tournament_notation_sheets"),
path("tournament/<int:pk>/notation/notifications/", GSheetNotificationsView.as_view(),
name="tournament_gsheet_notifications"),
path("tournament/<int:pk>/publish-solutions/<int:round>/", TournamentPublishSolutionsView.as_view(),
name="tournament_publish_solutions"),
path("tournament/<int:pk>/publish-notes/<int:round>/", TournamentPublishNotesView.as_view(),
name="tournament_publish_notes"),
path("tournament/<int:pk>/harmonize/<int:round>/", TournamentHarmonizeView.as_view(),

View File

@ -230,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,
@ -265,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,
@ -274,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,
@ -554,7 +557,7 @@ class ParticipationDetailView(LoginRequiredMixin, DetailView):
if not self.get_object().valid:
raise PermissionDenied(_("The team is not validated yet."))
if user.registration.is_admin or user.registration.participates \
and user.registration.team.participation \
and user.registration.team \
and user.registration.team.participation.pk == kwargs["pk"] \
or user.registration.is_volunteer \
and (self.get_object().tournament in user.registration.interesting_tournaments
@ -669,7 +672,7 @@ class TournamentPaymentsView(VolunteerMixin, SingleTableMixin, DetailView):
if self.object.final:
payments = Payment.objects.filter(final=True)
else:
payments = Payment.objects.filter(registrations__team__participation__tournament=self.get_object())
payments = Payment.objects.filter(registrations__team__participation__tournament=self.get_object(), final=False)
return payments.annotate(team_id=F('registrations__team')).order_by('-valid', 'registrations__team__trigram') \
.distinct().all()
@ -744,12 +747,12 @@ class TournamentPublishNotesView(VolunteerMixin, SingleObjectMixin, RedirectView
return self.handle_no_permission()
tournament = self.get_object()
reg = request.user.registration
if not reg.is_admin and (not reg.is_volunteer or tournament not in reg.organized_tournaments.all()):
if not reg.is_volunteer or reg not in tournament.organizers_and_presidents.all():
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
if int(kwargs["round"]) not in (1, 2):
if int(kwargs["round"]) not in range(1, settings.NB_ROUNDS + 1):
raise Http404
tournament = Tournament.objects.get(pk=kwargs["pk"])
@ -764,6 +767,45 @@ class TournamentPublishNotesView(VolunteerMixin, SingleObjectMixin, RedirectView
return reverse_lazy("participation:tournament_detail", args=(kwargs['pk'],))
class TournamentPublishSolutionsView(VolunteerMixin, SingleObjectMixin, RedirectView):
"""
On rend les solutions du tour suivant accessibles aux équipes.
"""
model = Tournament
def dispatch(self, request, *args, **kwargs):
"""
Les admins, orgas et PJ peuvent rendre les solutions accessibles.
"""
if not request.user.is_authenticated:
return self.handle_no_permission()
tournament = self.get_object()
reg = request.user.registration
if not reg.is_volunteer or reg not in tournament.organizers_and_presidents.all():
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
if int(kwargs["round"]) not in range(2, settings.NB_ROUNDS + 1):
raise Http404
tournament = Tournament.objects.get(pk=kwargs["pk"])
publish_solutions = 'hide' not in request.GET
if int(kwargs['round']) == 2:
tournament.solutions_available_second_phase = publish_solutions
elif int(kwargs['round']) == 3:
tournament.solutions_available_third_phase = publish_solutions
tournament.save()
if 'hide' not in request.GET:
messages.success(request, _("Solutions are now available to teams!"))
else:
messages.warning(request, _("Solutions are not available to teams anymore."))
return super().get(request, *args, **kwargs)
def get_redirect_url(self, *args, **kwargs):
return reverse_lazy("participation:tournament_detail", args=(kwargs['pk'],))
class TournamentHarmonizeView(VolunteerMixin, DetailView):
"""
Harmonize the notes of a tournament.
@ -776,7 +818,7 @@ class TournamentHarmonizeView(VolunteerMixin, DetailView):
return self.handle_no_permission()
tournament = self.get_object()
reg = request.user.registration
if not reg.is_admin and (not reg.is_volunteer or tournament not in reg.organized_tournaments.all()):
if not reg.is_volunteer or reg not in tournament.organizers_and_presidents.all():
return self.handle_no_permission()
if self.kwargs['round'] not in range(1, settings.NB_ROUNDS + 1):
raise Http404
@ -809,7 +851,7 @@ class TournamentHarmonizeNoteView(VolunteerMixin, DetailView):
return self.handle_no_permission()
tournament = self.get_object()
reg = request.user.registration
if not reg.is_admin and (not reg.is_volunteer or tournament not in reg.organized_tournaments.all()):
if not reg.is_volunteer or reg not in tournament.organizers_and_presidents.all():
return self.handle_no_permission()
if self.kwargs['round'] not in range(1, settings.NB_ROUNDS + 1) \
or self.kwargs['action'] not in ('add', 'remove') \
@ -849,7 +891,7 @@ class SelectTeamFinalView(VolunteerMixin, DetailView):
return self.handle_no_permission()
tournament = self.get_object()
reg = request.user.registration
if not reg.is_admin and (not reg.is_volunteer or tournament not in reg.organized_tournaments.all()):
if not reg.is_volunteer or reg not in tournament.organizers_and_presidents.all():
return self.handle_no_permission()
participation_qs = tournament.participations.filter(pk=self.kwargs["participation_id"])
if not participation_qs.exists():
@ -1000,17 +1042,14 @@ class SolutionsDownloadView(VolunteerMixin, View):
return super().dispatch(request, *args, **kwargs)
elif 'tournament_id' in kwargs:
tournament = Tournament.objects.get(pk=kwargs["tournament_id"])
if reg.is_volunteer \
and (tournament in reg.organized_tournaments.all()
or reg.pools_presided.filter(tournament=tournament).exists()):
if reg.is_volunteer and reg in tournament.organizers_and_presidents.all():
return super().dispatch(request, *args, **kwargs)
else:
pool = Pool.objects.get(pk=kwargs["pool_id"])
tournament = pool.tournament
if reg.is_volunteer \
and (reg in tournament.organizers.all()
or reg in pool.juries.all()
or reg.pools_presided.filter(tournament=tournament).exists()):
and (reg in tournament.organizers_and_presidents.all()
or reg in pool.juries.all()):
return super().dispatch(request, *args, **kwargs)
return self.handle_no_permission()
@ -1147,11 +1186,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))
@ -1845,9 +1887,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:
@ -1996,7 +2037,7 @@ class PassageDetailView(LoginRequiredMixin, DetailView):
reg = request.user.registration
passage = self.get_object()
if reg.is_admin or reg.is_volunteer \
and (self.get_object().pool.tournament in reg.organized_tournaments.all()
and (reg in self.get_object().pool.tournament.organizers_and_presidents.all()
or reg in passage.pool.juries.all()
or reg.pools_presided.filter(tournament=passage.pool.tournament).exists()) \
or reg.participates and reg.team \
@ -2123,6 +2164,7 @@ class NoteUpdateView(VolunteerMixin, UpdateView):
form.fields['opponent_oral'].label += f" ({self.object.passage.opponent.team.trigram})"
form.fields['reviewer_writing'].label += f" ({self.object.passage.reviewer.team.trigram})"
form.fields['reviewer_oral'].label += f" ({self.object.passage.reviewer.team.trigram})"
if settings.HAS_OBSERVER:
form.fields['observer_writing'].label += f" ({self.object.passage.observer.team.trigram})"
form.fields['observer_oral'].label += f" ({self.object.passage.observer.team.trigram})"
return form

View File

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

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.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():

View File

@ -66,7 +66,11 @@ 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}

View File

@ -1,5 +0,0 @@
{{ object.user.last_name }}
{{ object.user.first_name }}
{{ object.user.email }}
{{ object.type }}
{{ object.role }}

View File

@ -1,11 +1,4 @@
{{ object.user.first_name }}
{{ object.user.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 }}

View File

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

View File

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

View File

@ -137,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,
@ -461,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:
@ -726,10 +726,11 @@ class PhotoAuthorizationView(LoginRequiredMixin, View):
def get(self, request, *args, **kwargs):
filename = kwargs["filename"]
path = f"media/authorization/photo/{filename}"
if not os.path.exists(path):
raise Http404
student = ParticipantRegistration.objects.get(Q(photo_authorization__endswith=filename)
student_qs = ParticipantRegistration.objects.filter(Q(photo_authorization__endswith=filename)
| Q(photo_authorization_final__endswith=filename))
if not os.path.exists(path) or not student_qs.exists():
raise Http404
student = student_qs.get()
user = request.user
if not (student.user == user or user.registration.is_admin or user.registration.is_volunteer and student.team
and student.team.participation.tournament in user.registration.organized_tournaments.all()):

View File

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

13
survey/admin.py Normal file
View File

@ -0,0 +1,13 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
from .models import Survey
@admin.register(Survey)
class SurveyAdmin(admin.ModelAdmin):
list_display = ('survey_id', 'name', 'invite_team', 'invite_coaches', 'tournament',)
list_filter = ('invite_team', 'invite_coaches', 'tournament',)
search_fields = ('name',)

11
survey/apps.py Normal file
View File

@ -0,0 +1,11 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class SurveyConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "survey"
verbose_name = _("surveys")

28
survey/forms.py Normal file
View File

@ -0,0 +1,28 @@
from django import forms
from .models import Survey
class SurveyForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'survey_id' in self.initial:
self.fields['survey_id'].disabled = True
class Meta:
model = Survey
exclude = ('completed_registrations', 'completed_teams',)
widgets = {
'completed_registrations': forms.SelectMultiple(attrs={
'class': 'selectpicker',
'data-live-search': 'true',
'data-live-search-normalize': 'true',
'data-width': 'fit',
}),
'completed_teams': forms.SelectMultiple(attrs={
'class': 'selectpicker',
'data-live-search': 'true',
'data-live-search-normalize': 'true',
'data-width': 'fit',
}),
}

View File

@ -0,0 +1,13 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings
from django.core.management import BaseCommand
from ...models import Survey
class Command(BaseCommand):
def handle(self, *args, **kwargs):
for survey in Survey.objects.all():
survey.fetch_completion_data()

View File

@ -0,0 +1,83 @@
# Generated by Django 5.1.5 on 2025-03-19 21:12
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
(
"participation",
"0023_tournament_unified_registration",
),
("registration", "0014_participantregistration_country"),
]
operations = [
migrations.CreateModel(
name="Survey",
fields=[
(
"survey_id",
models.IntegerField(
help_text="The numeric identifier of the Limesurvey.",
primary_key=True,
serialize=False,
verbose_name="survey identifier",
),
),
("name", models.CharField(max_length=255, verbose_name="display name")),
(
"invite_team",
models.BooleanField(
default=False,
help_text="When this field is checked, teams will get only one survey invitation instead of one per person.",
verbose_name="invite whole team",
),
),
(
"invite_coaches",
models.BooleanField(
default=True,
help_text="When this field is checked, coaches will also be invited in the survey. No effect when the whole team is invited.",
verbose_name="invite coaches",
),
),
(
"completed_registrations",
models.ManyToManyField(
related_name="completed_surveys",
to="registration.participantregistration",
verbose_name="participants that completed the survey",
),
),
(
"completed_teams",
models.ManyToManyField(
related_name="completed_surveys",
to="participation.team",
verbose_name="teams that completed the survey",
),
),
(
"tournament",
models.ForeignKey(
blank=True,
default=None,
help_text="When this field is filled, the survey participants will be restricted to this tournament members.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="participation.tournament",
verbose_name="tournament restriction",
),
),
],
options={
"verbose_name": "survey",
"verbose_name_plural": "surveys",
},
),
]

View File

@ -0,0 +1,53 @@
# Generated by Django 5.1.5 on 2025-03-19 22:51
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"participation",
"0023_tournament_unified_registration",
),
("registration", "0014_participantregistration_country"),
("survey", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="survey",
name="completed_registrations",
field=models.ManyToManyField(
blank=True,
related_name="completed_surveys",
to="registration.participantregistration",
verbose_name="participants that completed the survey",
),
),
migrations.AlterField(
model_name="survey",
name="completed_teams",
field=models.ManyToManyField(
blank=True,
related_name="completed_surveys",
to="participation.team",
verbose_name="teams that completed the survey",
),
),
migrations.AlterField(
model_name="survey",
name="tournament",
field=models.ForeignKey(
blank=True,
default=None,
help_text="When this field is filled, the survey participants will be restricted to this tournament members.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="surveys",
to="participation.tournament",
verbose_name="tournament restriction",
),
),
]

View File

137
survey/models.py Normal file
View File

@ -0,0 +1,137 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from citric import Client
from django.conf import settings
from django.db import models
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from participation.models import Team, Tournament
from registration.models import ParticipantRegistration, StudentRegistration
class Survey(models.Model):
"""
Ce modèle représente un sondage LimeSurvey afin de faciliter l'import des
participant⋅es au sondage et d'effectuer le suivi.
"""
survey_id = models.IntegerField(
primary_key=True,
verbose_name=_("survey identifier"),
help_text=_("The numeric identifier of the Limesurvey."),
)
name = models.CharField(
max_length=255,
verbose_name=_("display name"),
)
invite_team = models.BooleanField(
default=False,
verbose_name=_("invite whole team"),
help_text=_("When this field is checked, teams will get only one survey invitation instead of one per person."),
)
invite_coaches = models.BooleanField(
default=True,
verbose_name=_("invite coaches"),
help_text=_("When this field is checked, coaches will also be invited in the survey. No effect when the whole team is invited."),
)
tournament = models.ForeignKey(
Tournament,
null=True,
blank=True,
default=None,
on_delete=models.SET_NULL,
related_name="surveys",
verbose_name=_("tournament restriction"),
help_text=_("When this field is filled, the survey participants will be restricted to this tournament members."),
)
completed_registrations = models.ManyToManyField(
ParticipantRegistration,
blank=True,
related_name="completed_surveys",
verbose_name=_("participants that completed the survey"),
)
completed_teams = models.ManyToManyField(
Team,
blank=True,
related_name="completed_surveys",
verbose_name=_("teams that completed the survey"),
)
@property
def participants(self):
if self.invite_team:
teams = Team.objects.filter(participation__valid=True)
if self.tournament:
teams = teams.filter(participation__tournament=self.tournament)
return teams.order_by('participation__tournament__name', 'trigram').all()
else:
if self.invite_coaches:
registrations = ParticipantRegistration.objects.filter(team__participation__valid=True)
else:
registrations = StudentRegistration.objects.filter(team__participation__valid=True)
if self.tournament:
registrations = registrations.filter(team__participation__tournament=self.tournament)
return registrations.order_by('team__participation__tournament__name', 'team__trigram').all()
@property
def completed(self):
if self.invite_team:
return self.completed_teams
else:
return self.completed_registrations
def get_absolute_url(self):
return reverse_lazy("survey:survey_detail", args=(self.survey_id,))
def generate_participants_data(self):
participants_data = []
if self.invite_team:
for team in self.participants:
participant_data = {"firstname": team.name, "lastname": f"(équipe {team.trigram})", "email": team.email}
participants_data.append(participant_data)
else:
for reg in self.participants:
participant_data = {"firstname": reg.user.first_name, "lastname": reg.user.last_name, "email": reg.user.email}
participants_data.append(participant_data)
return participants_data
def invite_all(self):
participants_data = self.generate_participants_data()
with Client(f"{settings.LIMESURVEY_URL}/index.php/admin/remotecontrol", settings.LIMESURVEY_USER, settings.LIMESURVEY_PASSWORD) as client:
try:
current_participants = client.list_participants(self.survey_id, limit=10000)
except:
current_participants = []
current_participants_email = set(participant['participant_info']['email'] for participant in current_participants)
participants_data = [participant_data for participant_data in participants_data if participant_data['email'] not in current_participants_email]
try:
client.activate_tokens(self.survey_id)
except:
pass
new_participants = client.add_participants(self.survey_id, participant_data=participants_data)
if new_participants:
client.invite_participants(self.survey_id, token_ids=[participant['tid'] for participant in new_participants])
return new_participants
def fetch_completion_data(self):
with Client(f"{settings.LIMESURVEY_URL}/index.php/admin/remotecontrol", settings.LIMESURVEY_USER, settings.LIMESURVEY_PASSWORD) as client:
participants = client.list_participants(self.survey_id, limit=10000, attributes=['completed'])
if self.invite_team:
team_names = [participant['participant_info']['firstname'] for participant in participants if participant['completed'] != 'N']
self.completed_teams.set(list(Team.objects.filter(name__in=team_names).values_list('id', flat=True)))
else:
mails = [participant['participant_info']['email'] for participant in participants if participant['completed'] != 'N']
self.completed_registrations.set(list(ParticipantRegistration.objects.filter(user__email__in=mails).values_list('id', flat=True)))
self.save()
class Meta:
verbose_name = _("survey")
verbose_name_plural = _("surveys")

31
survey/tables.py Normal file
View File

@ -0,0 +1,31 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
from .models import Survey
class SurveyTable(tables.Table):
survey_id = tables.LinkColumn(
'survey:survey_detail',
args=[tables.A('survey_id')],
verbose_name=lambda: _("survey identifier").capitalize(),
)
nb_completed = tables.Column(
verbose_name=_("completed").capitalize,
accessor='survey_id'
)
def render_nb_completed(self, record):
return f"{record.completed.count()}/{record.participants.count()}"
class Meta:
attrs = {
'class': 'table table-condensed table-striped',
}
model = Survey
fields = ('survey_id', 'name', 'invite_team', 'invite_coaches', 'tournament', 'nb_completed',)
order_by = ('survey_id',)

View File

@ -0,0 +1,87 @@
{% extends "base.html" %}
{% load i18n %}
{% load crispy_forms_filters %}
{% block content %}
<div class="card bg-body shadow">
<div class="card-header text-center">
<h4>
{% trans "survey"|capfirst %} {{ survey.survey_id }}
<a href="{{ TFJM.LIMESURVEY_URL }}/index.php/{{ survey.survey_id }}" target="_blank"><i class="fas fa-arrow-up-right-from-square"></i></a>
</h4>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-6 text-sm-end">{% trans "Name:" %}</dt>
<dd class="col-sm-6">{{ survey.name }}</dd>
<dt class="col-sm-6 text-sm-end">{% trans "One answer per team:" %}</dt>
<dd class="col-sm-6">{{ survey.invite_team|yesno }}</dd>
{% if not survey.invite_team %}
<dt class="col-sm-6 text-sm-end">{% trans "Coaches can answer the survey:" %}</dt>
<dd class="col-sm-6">{{ survey.invite_coaches|yesno }}</dd>
{% endif %}
{% if survey.tournament %}
<dt class="col-sm-6 text-sm-end">{% trans "Tournament restriction:" %}</dt>
<dd class="col-sm-6">{{ survey.tournament }}</dd>
{% endif %}
<dt class="col-sm-6 text-sm-end">{% trans "Completion rate:" %}</dt>
<dd class="col-sm-6">
{{ survey.completed.count }}/{{ survey.participants.count }}
<a href="{% url "survey:survey_refresh_completed" pk=survey.pk %}"><i class="fas fa-arrow-rotate-right" alt="refresh"></i></a>
</dd>
</dl>
</div>
<div class="card-footer text-center">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updateSurveyModal">{% trans "Update" %}</button>
<a class="btn btn-secondary" href="{% url "survey:survey_invite" pk=survey.pk %}">{% trans "Send invites" %}</a>
</div>
</div>
<hr>
<table class="table table-condensed table-striped">
<thead>
<tr>
<th>{% trans "participant"|capfirst %}</th>
<th>{% trans "tournament"|capfirst %}</th>
<th>{% trans "completed"|capfirst %}</th>
</tr>
</thead>
<tbody>
{% for participant in survey.participants %}
<tr class="{% if participant in survey.completed.all %}table-success{% else %}table-danger{% endif %}">
{% if survey.invite_team %}
<td>{% trans "Team" %} {{ participant.name }} ({{ participant.trigram }})</td>
<td>{{ participant.participation.tournament.name }}</td>
{% else %}
<td>{{ participant.user.first_name }} {{ participant.user.last_name }} ({% trans "team" %} {{ participant.team.trigram }})</td>
<td>{{ participant.team.participation.tournament.name }}</td>
{% endif %}
{% if participant in survey.completed.all %}
<td>{% trans "Yes" %}</td>
{% else %}
<td>{% trans "No" %}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% trans "Update survey" as modal_title %}
{% trans "Update" as modal_button %}
{% url "survey:survey_update" pk=survey.pk as modal_action %}
{% include "base_modal.html" with modal_id="updateSurvey" %}
{% endblock %}
{% block extrajavascript %}
<script>
document.addEventListener('DOMContentLoaded', () => {
initModal("updateSurvey", "{% url "survey:survey_update" pk=survey.pk %}")
})
</script>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends request.content_only|yesno:"empty.html,base.html" %}
{% load crispy_forms_filters i18n %}
{% block content %}
<form method="post">
<div id="form-content">
{% csrf_token %}
{{ form|crispy }}
</div>
{% if object.pk %}
<button class="btn btn-primary" type="submit">{% trans "Update" %}</button>
{% else %}
<button class="btn btn-success" type="submit">{% trans "Create" %}</button>
{% endif %}
</form>
{% endblock content %}

View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% load django_tables2 i18n %}
{% block content %}
<div class="d-grid">
<a href="{% url "survey:survey_create" %}" class="btn gap-0 btn-success">
<i class="fas fa-square-poll-horizontal"></i> {% trans "Add survey" %}
</a>
</div>
<hr>
{% render_table table %}
{% endblock %}

3
survey/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

18
survey/urls.py Normal file
View File

@ -0,0 +1,18 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.urls import path
from .views import SurveyCreateView, SurveyDetailView, SurveyInviteView, \
SurveyListView, SurveyRefreshCompletedView, SurveyUpdateView
app_name = "survey"
urlpatterns = [
path("", SurveyListView.as_view(), name="survey_list"),
path("create/", SurveyCreateView.as_view(), name="survey_create"),
path("<int:pk>/", SurveyDetailView.as_view(), name="survey_detail"),
path("<int:pk>/invite/", SurveyInviteView.as_view(), name="survey_invite"),
path("<int:pk>/refresh/", SurveyRefreshCompletedView.as_view(), name="survey_refresh_completed"),
path("<int:pk>/update/", SurveyUpdateView.as_view(), name="survey_update"),
]

56
survey/views.py Normal file
View File

@ -0,0 +1,56 @@
# Copyright (C) 2025 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import messages
from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, UpdateView
from django_tables2 import SingleTableView
from tfjm.views import AdminMixin
from .forms import SurveyForm
from .models import Survey
from .tables import SurveyTable
class SurveyListView(AdminMixin, SingleTableView):
model = Survey
table_class = SurveyTable
template_name = "survey/survey_list.html"
class SurveyCreateView(AdminMixin, CreateView):
model = Survey
form_class = SurveyForm
class SurveyDetailView(AdminMixin, DetailView):
model = Survey
class SurveyInviteView(AdminMixin, DetailView):
model = Survey
def get(self, request, *args, **kwargs):
survey = self.get_object()
new_participants = survey.invite_all()
if new_participants:
messages.success(request, _("Invites sent!"))
else:
messages.warning(request, _("All invites were already sent."))
return redirect("survey:survey_detail", survey.pk)
class SurveyRefreshCompletedView(AdminMixin, DetailView):
model = Survey
def get(self, request, *args, **kwargs):
survey = self.get_object()
survey.fetch_completion_data()
messages.success(request, _("Completion data refreshed!"))
return redirect("survey:survey_detail", survey.pk)
class SurveyUpdateView(AdminMixin, UpdateView):
model = Survey
form_class = SurveyForm

View File

@ -1,9 +1,4 @@
# min hour day month weekday command
# Send pending mails
* * * * * cd /code && python manage.py send_mail -c 1
* * * * * cd /code && python manage.py retry_deferred -c 1
0 0 * * * cd /code && python manage.py purge_mail_log 7 -c 1
# Update search index
*/2 * * * * cd /code && python manage.py update_index &> /dev/null
@ -19,5 +14,8 @@
# Update Google Drive notifications daily
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/*

View File

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

View File

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

View File

@ -74,11 +74,11 @@ INSTALLED_APPS = [
'draw',
'registration',
'participation',
'survey',
]
if "test" not in sys.argv: # pragma: no cover
INSTALLED_APPS += [
'django_extensions',
'mailer',
]
@ -213,6 +213,7 @@ STATICFILES_FINDERS = (
PIPELINE = {
'DISABLE_WRAPPER': True,
'JS_COMPRESSOR': 'pipeline.compressors.uglifyjs.UglifyJSCompressor',
'JAVASCRIPT': {
'main': {
'source_filenames': (
@ -300,6 +301,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 +329,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",
@ -379,14 +390,14 @@ if TFJM_APP == "TFJM":
)
PROBLEMS = [
"Triominos",
"Rassemblements mathématiques",
"Tournoi de ping-pong",
"Dépollution de la Seine",
"Électron libre",
"Pièces truquées",
"Drôles de cookies",
"Création d'un jeu",
"Une bonne humeur contagieuse",
"Drôles de toboggans",
"Plats à tarte gradués",
"Transformation de papillons",
"Gerrymandering",
"Le cauchemar de la ligne 20-25",
"Taxes routières",
"Points colorés sur un cercle",
]
elif TFJM_APP == "ETEAM":
PREFERRED_LANGUAGE_CODE = 'en'

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,9 +1,9 @@
function initModal(target, url, content_id = 'form-content') {
function initModal(target, url, content_id = 'form-content', always_refetch = false) {
document.querySelectorAll('[data-bs-target="#' + target + 'Modal"]')
.forEach(elem => elem.addEventListener('click', () => {
let modalBody = document.querySelector("#" + target + "Modal div.modal-body")
if (!modalBody.innerHTML.trim()) {
if (!modalBody.innerHTML.trim() || always_refetch) {
if (url instanceof Function) url = url()
fetch(url, {headers: {'CONTENT-ONLY': '1'}})

View File

@ -106,7 +106,7 @@
{% if user.is_authenticated and user.registration.is_admin %}
initModal("search",
() => "{% url "haystack_search" %}?q=" + encodeURI(document.getElementById("search-term").value),
"search-results")
"search-results", true)
{% endif %}
{% if not user.is_authenticated %}

View File

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

View File

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

View File

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