1
0
mirror of https://gitlab.com/animath/si/plateforme.git synced 2025-02-26 17:46:27 +00:00

Compare commits

..

No commits in common. "2f4755ffc7e849456fb752352b7bd4e4f5ad55d4" and "ff414ea0468ec9c84f23a7a7586fcec95b2b323f" have entirely different histories.

171 changed files with 859 additions and 7790 deletions

View File

@ -2,6 +2,14 @@ stages:
- test - test
- quality-assurance - quality-assurance
py39:
stage: test
image: python:3.9-alpine
before_script:
- apk add --no-cache libmagic
- pip install tox --no-cache-dir
script: tox -e py39
py310: py310:
stage: test stage: test
image: python:3.10-alpine image: python:3.10-alpine

View File

@ -3,7 +3,7 @@ FROM python:3.11-alpine
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
ENV DJANGO_ALLOW_ASYNC_UNSAFE 1 ENV DJANGO_ALLOW_ASYNC_UNSAFE 1
RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libxml2-dev libxslt-dev postgresql-dev libmagic texlive texmf-dist-latexextra RUN apk add --no-cache gettext nginx gcc git libc-dev libffi-dev libxml2-dev libxslt-dev postgresql-dev libmagic texlive
RUN apk add --no-cache bash RUN apk add --no-cache bash
@ -13,8 +13,6 @@ COPY requirements.txt /code/requirements.txt
COPY docs/requirements.txt /code/docs/requirements.txt COPY docs/requirements.txt /code/docs/requirements.txt
RUN pip install -r requirements.txt --no-cache-dir RUN pip install -r requirements.txt --no-cache-dir
RUN pip install -r docs/requirements.txt --no-cache-dir RUN pip install -r docs/requirements.txt --no-cache-dir
# FIXME Remove this line when all dependencies will be ready
RUN pip install "Django>=4.2,<5.0"
COPY . /code/ COPY . /code/

View File

@ -2,7 +2,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.conf import settings from django.conf import settings
from django.urls import include, path from django.conf.urls import include, url
from rest_framework import routers from rest_framework import routers
from .viewsets import UserViewSet from .viewsets import UserViewSet
@ -29,6 +29,6 @@ app_name = 'api'
# Wire up our API using automatic URL routing. # Wire up our API using automatic URL routing.
# Additionally, we include login URLs for the browsable API. # Additionally, we include login URLs for the browsable API.
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), url('^', include(router.urls)),
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), url('^api-auth/', include('rest_framework.urls', namespace='rest_framework')),
] ]

View File

@ -34,13 +34,13 @@ def pre_save_object(sender, instance, **kwargs):
instance._previous = None instance._previous = None
def save_object(sender, instance, raw, **kwargs): def save_object(sender, instance, **kwargs):
""" """
Each time a model is saved, an entry in the table `Changelog` is added in the database Each time a model is saved, an entry in the table `Changelog` is added in the database
in order to store each modification made in order to store each modification made
""" """
# noinspection PyProtectedMember # noinspection PyProtectedMember
if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal") or raw: if instance._meta.label_lower in EXCLUDED or hasattr(instance, "_no_signal"):
return return
# noinspection PyProtectedMember # noinspection PyProtectedMember

View File

@ -0,0 +1,64 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament, Tweak
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ('name', 'trigram', 'valid',)
search_fields = ('name', 'trigram',)
list_filter = ('participation__valid',)
def valid(self, team):
return team.participation.valid
valid.short_description = _('valid')
@admin.register(Participation)
class ParticipationAdmin(admin.ModelAdmin):
list_display = ('team', 'valid',)
search_fields = ('team__name', 'team__trigram',)
list_filter = ('valid',)
@admin.register(Pool)
class PoolAdmin(admin.ModelAdmin):
search_fields = ('participations__team__name', 'participations__team__trigram',)
@admin.register(Passage)
class PassageAdmin(admin.ModelAdmin):
search_fields = ('pool__participations__team__name', 'pool__participations__team__trigram',)
@admin.register(Note)
class NoteAdmin(admin.ModelAdmin):
search_fields = ('jury',)
@admin.register(Solution)
class SolutionAdmin(admin.ModelAdmin):
list_display = ('participation',)
search_fields = ('participation__team__name', 'participation__team__trigram',)
@admin.register(Synthesis)
class SynthesisAdmin(admin.ModelAdmin):
list_display = ('participation',)
search_fields = ('participation__team__name', 'participation__team__trigram',)
@admin.register(Tournament)
class TournamentAdmin(admin.ModelAdmin):
list_display = ('name',)
search_fields = ('name',)
@admin.register(Tweak)
class TweakAdmin(admin.ModelAdmin):
list_display = ('participation', 'pool', 'diff',)

View File

@ -6,17 +6,12 @@ from io import StringIO
import re import re
from typing import Iterable from typing import Iterable
from crispy_forms.bootstrap import InlineField
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Div, Fieldset, Submit
from django import forms from django import forms
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator from django.core.validators import FileExtensionValidator
from django.db.models import CharField, Value
from django.db.models.functions import Concat
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pypdf import PdfReader from pypdf import PdfFileReader
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament
@ -28,7 +23,7 @@ class TeamForm(forms.ModelForm):
def clean_name(self): def clean_name(self):
if "name" in self.cleaned_data: if "name" in self.cleaned_data:
name = self.cleaned_data["name"] name = self.cleaned_data["name"]
if Team.objects.filter(name=name).exclude(pk=self.instance.pk).exists(): if not self.instance.pk and Team.objects.filter(name=name).exists():
raise ValidationError(_("This name is already used.")) raise ValidationError(_("This name is already used."))
return name return name
@ -38,7 +33,7 @@ class TeamForm(forms.ModelForm):
if not re.match("[A-Z]{3}", trigram): if not re.match("[A-Z]{3}", trigram):
raise ValidationError(_("The trigram must be composed of three uppercase letters.")) raise ValidationError(_("The trigram must be composed of three uppercase letters."))
if Team.objects.filter(trigram=trigram).exclude(pk=self.instance.pk).exists(): if not self.instance.pk and Team.objects.filter(trigram=trigram).exists():
raise ValidationError(_("This trigram is already used.")) raise ValidationError(_("This trigram is already used."))
return trigram return trigram
@ -156,7 +151,7 @@ class SolutionForm(forms.ModelForm):
raise ValidationError(_("The uploaded file size must be under 5 Mo.")) raise ValidationError(_("The uploaded file size must be under 5 Mo."))
if file.content_type != "application/pdf": if file.content_type != "application/pdf":
raise ValidationError(_("The uploaded file must be a PDF file.")) raise ValidationError(_("The uploaded file must be a PDF file."))
pdf_reader = PdfReader(file) pdf_reader = PdfFileReader(file)
pages = len(pdf_reader.pages) pages = len(pdf_reader.pages)
if pages > 30: if pages > 30:
raise ValidationError(_("The PDF file must not have more than 30 pages.")) raise ValidationError(_("The PDF file must not have more than 30 pages."))
@ -175,7 +170,7 @@ class SolutionForm(forms.ModelForm):
class PoolForm(forms.ModelForm): class PoolForm(forms.ModelForm):
class Meta: class Meta:
model = Pool model = Pool
fields = ('tournament', 'round', 'letter', 'bbb_url', 'results_available', 'juries',) fields = ('tournament', 'round', 'bbb_url', 'results_available', 'juries',)
widgets = { widgets = {
"juries": forms.SelectMultiple(attrs={ "juries": forms.SelectMultiple(attrs={
'class': 'selectpicker', 'class': 'selectpicker',
@ -203,48 +198,6 @@ class PoolTeamsForm(forms.ModelForm):
} }
class AddJuryForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_class = 'form-inline'
self.helper.layout = Fieldset(
_("Add new jury"),
Div(
Div(
InlineField('first_name', autofocus="autofocus"),
css_class='col-xl-3',
),
Div(
InlineField('last_name'),
css_class='col-xl-3',
),
Div(
InlineField('email'),
css_class='col-xl-5',
),
Div(
Submit('submit', _("Add")),
css_class='col-xl-1',
),
css_class='row',
)
)
def clean_email(self):
"""
Ensure that the email address is unique.
"""
email = self.data["email"]
if User.objects.filter(email=email).exists():
self.add_error("email", _("This email address is already used."))
return email
class Meta:
model = User
fields = ('first_name', 'last_name', 'email',)
class UploadNotesForm(forms.Form): class UploadNotesForm(forms.Form):
file = forms.FileField( file = forms.FileField(
label=_("CSV file:"), label=_("CSV file:"),
@ -262,57 +215,40 @@ class UploadNotesForm(forms.Form):
file = cleaned_data['file'] file = cleaned_data['file']
with file: with file:
try: try:
data: bytes = file.read() csvfile = csv.reader(StringIO(file.read().decode()))
try:
content = data.decode()
except UnicodeDecodeError: except UnicodeDecodeError:
# This is not UTF-8, grrrr self.add_error('file', _("This file contains non-UTF-8 content. "
content = data.decode('latin1')
csvfile = csv.reader(StringIO(content))
self.process(csvfile, cleaned_data)
except UnicodeDecodeError:
self.add_error('file', _("This file contains non-UTF-8 and non-ISO-8859-1 content. "
"Please send your sheet as a CSV file.")) "Please send your sheet as a CSV file."))
self.process(csvfile, cleaned_data)
return cleaned_data return cleaned_data
def process(self, csvfile: Iterable[str], cleaned_data: dict): def process(self, csvfile: Iterable[str], cleaned_data: dict):
parsed_notes = {} parsed_notes = {}
valid_lengths = [1 + 6 * 3, 1 + 7 * 4, 1 + 6 * 5] # Per pool sizes
pool_size = 0
line_length = 0
for line in csvfile: for line in csvfile:
line = [s.strip() for s in line if s] line = [s for s in line if s]
if line and line[0] == 'Problème': if len(line) < 19:
pool_size = len(line) - 1
if pool_size < 3 or pool_size > 5:
self.add_error('file', _("Can't determine the pool size. Are you sure your file is correct?"))
return
line_length = valid_lengths[pool_size - 3]
continue continue
if pool_size == 0 or len(line) < line_length:
continue
name = line[0] name = line[0]
if name.lower() in ["rôle", "juré", "moyenne", "coefficient", "sous-total", "équipe", "equipe"]: notes = line[1:19]
continue if not all(s.isnumeric() for s in notes):
notes = line[1:line_length]
if not all(s.isnumeric() or s[0] == '-' and s[1:].isnumeric() for s in notes):
continue continue
notes = list(map(int, notes)) notes = list(map(int, notes))
if max(notes) < 3 or min(notes) < 0:
continue
max_notes = pool_size * ([20, 16, 9, 10, 9, 10] + ([4] if pool_size == 4 else [])) max_notes = 3 * [20, 16, 9, 10, 9, 10]
for n, max_n in zip(notes, max_notes): for n, max_n in zip(notes, max_notes):
if n > max_n: if n > max_n:
self.add_error('file', self.add_error('file',
_("The following note is higher of the maximum expected value:") _("The following note is higher of the maximum expected value:")
+ str(n) + " > " + str(max_n)) + str(n) + " > " + str(max_n))
# Search by "{first_name} {last_name}" first_name, last_name = tuple(name.split(' ', 1))
jury = User.objects.annotate(full_name=Concat('first_name', Value(' '), 'last_name',
output_field=CharField())) \ jury = User.objects.filter(first_name=first_name, last_name=last_name,
.filter(full_name=name.replace('', '\''), registration__volunteerregistration__isnull=False) registration__volunteerregistration__isnull=False)
if jury.count() != 1: if jury.count() != 1:
self.add_error('file', _("The following user was not found:") + " " + name) self.add_error('file', _("The following user was not found:") + " " + name)
continue continue
@ -340,7 +276,7 @@ class PassageForm(forms.ModelForm):
class Meta: class Meta:
model = Passage model = Passage
fields = ('position', 'solution_number', 'defender', 'opponent', 'reporter', 'observer', 'defender_penalties',) fields = ('solution_number', 'defender', 'opponent', 'reporter', 'defender_penalties',)
class SynthesisForm(forms.ModelForm): class SynthesisForm(forms.ModelForm):
@ -351,10 +287,6 @@ class SynthesisForm(forms.ModelForm):
raise ValidationError(_("The uploaded file size must be under 2 Mo.")) raise ValidationError(_("The uploaded file size must be under 2 Mo."))
if file.content_type != "application/pdf": if file.content_type != "application/pdf":
raise ValidationError(_("The uploaded file must be a PDF file.")) raise ValidationError(_("The uploaded file must be a PDF file."))
pdf_reader = PdfReader(file)
pages = len(pdf_reader.pages)
if pages > 2:
raise ValidationError(_("The PDF file must not have more than 2 pages."))
return self.cleaned_data["file"] return self.cleaned_data["file"]
def save(self, commit=True): def save(self, commit=True):
@ -371,4 +303,4 @@ class NoteForm(forms.ModelForm):
class Meta: class Meta:
model = Note model = Note
fields = ('defender_writing', 'defender_oral', 'opponent_writing', fields = ('defender_writing', 'defender_oral', 'opponent_writing',
'opponent_oral', 'reporter_writing', 'reporter_oral', 'observer_oral', ) 'opponent_oral', 'reporter_writing', 'reporter_oral', )

View File

@ -23,7 +23,7 @@ class Command(BaseCommand):
token = response['access_token'] token = response['access_token']
organization = "animath" organization = "animath"
form_slug = "tfjm-2023-tournois-regionaux" form_slug = "tfjm-2022-tournois-regionaux"
from_date = "2000-01-01" from_date = "2000-01-01"
url = f"https://api.helloasso.com/v5/organizations/{organization}/forms/Event/{form_slug}/payments" \ url = f"https://api.helloasso.com/v5/organizations/{organization}/forms/Event/{form_slug}/payments" \
f"?from={from_date}&pageIndex=1&pageSize=100&retrieveOfflineDonations=false" f"?from={from_date}&pageIndex=1&pageSize=100&retrieveOfflineDonations=false"

View File

@ -4,7 +4,8 @@
from django.core.management import BaseCommand from django.core.management import BaseCommand
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.translation import activate from django.utils.translation import activate
from participation.models import Tournament
from .models import Tournament
class Command(BaseCommand): class Command(BaseCommand):

View File

@ -3,17 +3,29 @@
from pathlib import Path from pathlib import Path
from django.conf import settings
from django.core.management import BaseCommand from django.core.management import BaseCommand
from django.utils.translation import activate from django.utils.translation import activate
from participation.models import Solution, Tournament
from .models import Solution, Tournament
PROBLEMS = [
"Pliage de polygones",
"Mélodie des hirondelles",
"Professeur confiné",
"Nain sans mémoire",
"Bricolage microscopique",
"Villes jumelées",
"Promenade de chiens",
"Persée et la Gorgone",
]
class Command(BaseCommand): class Command(BaseCommand):
def handle(self, *args, **kwargs): def handle(self, *args, **kwargs):
activate('fr') activate('fr')
base_dir = Path(__file__).parent.parent.parent.parent base_dir = Path(__file__).parent.parent.parent.parent.parent
base_dir /= "output" base_dir /= "output"
if not base_dir.is_dir(): if not base_dir.is_dir():
base_dir.mkdir() base_dir.mkdir()
@ -29,7 +41,7 @@ class Command(BaseCommand):
if not base_dir.is_dir(): if not base_dir.is_dir():
base_dir.mkdir() base_dir.mkdir()
for problem_id, problem_name in enumerate(settings.PROBLEMS): for problem_id, problem_name in enumerate(PROBLEMS):
dir_name = f"Problème n°{problem_id + 1} : {problem_name}" dir_name = f"Problème n°{problem_id + 1} : {problem_name}"
problem_dir = base_dir / dir_name problem_dir = base_dir / dir_name
if not problem_dir.is_dir(): if not problem_dir.is_dir():

View File

@ -30,7 +30,7 @@ class Command(BaseCommand):
else: else:
stat_file = os.stat("tfjm/static/logo.png") stat_file = os.stat("tfjm/static/logo.png")
with open("tfjm/static/logo.png", "rb") as f: with open("tfjm/static/logo.png", "rb") as f:
resp = (await Matrix.upload(f, filename="../../../tfjm/static/logo.png", content_type="image/png", resp = (await Matrix.upload(f, filename="logo.png", content_type="image/png",
filesize=stat_file.st_size))[0][0] filesize=stat_file.st_size))[0][0]
avatar_uri = resp.content_uri avatar_uri = resp.content_uri
with open(".matrix_avatar", "w") as f: with open(".matrix_avatar", "w") as f:
@ -66,7 +66,7 @@ class Command(BaseCommand):
visibility=RoomVisibility.public, visibility=RoomVisibility.public,
alias="bienvenue", alias="bienvenue",
name="Bienvenue", name="Bienvenue",
topic="Bienvenue au TFJM² 2023 !", topic="Bienvenue au TFJM² 2022 !",
federate=False, federate=False,
preset=RoomPreset.public_chat, preset=RoomPreset.public_chat,
) )

View File

@ -3,6 +3,7 @@
import datetime import datetime
import django.core.validators import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone import django.utils.timezone
import participation.models import participation.models

View File

@ -6,7 +6,7 @@ import os
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator from django.core.validators import RegexValidator
from django.db import models from django.db import models
from django.db.models import Index from django.db.models import Index
from django.urls import reverse_lazy from django.urls import reverse_lazy
@ -124,7 +124,6 @@ class Team(models.Model):
class Meta: class Meta:
verbose_name = _("team") verbose_name = _("team")
verbose_name_plural = _("teams") verbose_name_plural = _("teams")
ordering = ('trigram',)
indexes = [ indexes = [
Index(fields=("trigram", )), Index(fields=("trigram", )),
] ]
@ -279,12 +278,6 @@ class Tournament(models.Model):
return Synthesis.objects.filter(final_solution=True) return Synthesis.objects.filter(final_solution=True)
return Synthesis.objects.filter(participation__tournament=self) return Synthesis.objects.filter(participation__tournament=self)
@property
def best_format(self):
n = len(self.participations.filter(valid=True).all())
fmt = [n] if n <= 5 else [3] * (n // 3 - 1) + [3 + n % 3]
return '+'.join(map(str, sorted(fmt, reverse=True)))
def get_absolute_url(self): def get_absolute_url(self):
return reverse_lazy("participation:tournament_detail", args=(self.pk,)) return reverse_lazy("participation:tournament_detail", args=(self.pk,))
@ -322,7 +315,7 @@ class Participation(models.Model):
valid = models.BooleanField( valid = models.BooleanField(
null=True, null=True,
default=None, default=None,
verbose_name=_("valid team"), verbose_name=_("valid"),
help_text=_("The participation got the validation of the organizers."), help_text=_("The participation got the validation of the organizers."),
) )
@ -341,7 +334,6 @@ class Participation(models.Model):
class Meta: class Meta:
verbose_name = _("participation") verbose_name = _("participation")
verbose_name_plural = _("participations") verbose_name_plural = _("participations")
ordering = ('valid', 'team__trigram',)
class Pool(models.Model): class Pool(models.Model):
@ -360,16 +352,6 @@ class Pool(models.Model):
] ]
) )
letter = models.PositiveSmallIntegerField(
choices=[
(1, 'A'),
(2, 'B'),
(3, 'C'),
(4, 'D'),
],
verbose_name=_('letter'),
)
participations = models.ManyToManyField( participations = models.ManyToManyField(
Participation, Participation,
related_name="pools", related_name="pools",
@ -399,16 +381,12 @@ class Pool(models.Model):
@property @property
def solutions(self): def solutions(self):
return [passage.defended_solution for passage in self.passages.all()] return Solution.objects.filter(participation__in=self.participations, final_solution=self.tournament.final)
def average(self, participation): def average(self, participation):
return sum(passage.average(participation) for passage in self.passages.all()) \ return sum(passage.average(participation) for passage in self.passages.all()) \
+ sum(tweak.diff for tweak in participation.tweaks.filter(pool=self).all()) + sum(tweak.diff for tweak in participation.tweaks.filter(pool=self).all())
async def aaverage(self, participation):
return sum([passage.average(participation) async for passage in self.passages.all()]) \
+ sum([tweak.diff async for tweak in participation.tweaks.filter(pool=self).all()])
def get_absolute_url(self): def get_absolute_url(self):
return reverse_lazy("participation:pool_detail", args=(self.pk,)) return reverse_lazy("participation:pool_detail", args=(self.pk,))
@ -421,7 +399,6 @@ class Pool(models.Model):
class Meta: class Meta:
verbose_name = _("pool") verbose_name = _("pool")
verbose_name_plural = _("pools") verbose_name_plural = _("pools")
ordering = ('round', 'letter',)
class Passage(models.Model): class Passage(models.Model):
@ -432,17 +409,10 @@ class Passage(models.Model):
related_name="passages", related_name="passages",
) )
position = models.PositiveSmallIntegerField(
verbose_name=_("position"),
choices=zip(range(1, 6), range(1, 6)),
default=1,
validators=[MinValueValidator(1), MaxValueValidator(5)],
)
solution_number = models.PositiveSmallIntegerField( solution_number = models.PositiveSmallIntegerField(
verbose_name=_("defended solution"), verbose_name=_("defended solution"),
choices=[ choices=[
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, len(settings.PROBLEMS) + 1) (i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, settings.PROBLEM_COUNT + 1)
], ],
) )
@ -467,16 +437,6 @@ class Passage(models.Model):
related_name="+", related_name="+",
) )
observer = models.ForeignKey(
Participation,
on_delete=models.PROTECT,
null=True,
blank=True,
default=None,
verbose_name=_("observer"),
related_name="+",
)
defender_penalties = models.PositiveSmallIntegerField( defender_penalties = models.PositiveSmallIntegerField(
verbose_name=_("penalties"), verbose_name=_("penalties"),
default=0, default=0,
@ -531,25 +491,9 @@ class Passage(models.Model):
def average_reporter(self) -> float: def average_reporter(self) -> float:
return self.average_reporter_writing + self.average_reporter_oral return self.average_reporter_writing + self.average_reporter_oral
@property
def average_observer(self) -> float:
return self.avg(note.observer_oral for note in self.notes.all())
@property
def averages(self):
yield self.average_defender_writing
yield self.average_defender_oral
yield self.average_opponent_writing
yield self.average_opponent_oral
yield self.average_reporter_writing
yield self.average_reporter_oral
if self.observer:
yield self.average_observer
def average(self, participation): def average(self, participation):
return self.average_defender if participation == self.defender else self.average_opponent \ return self.average_defender if participation == self.defender else self.average_opponent \
if participation == self.opponent else self.average_reporter if participation == self.reporter \ if participation == self.opponent else self.average_reporter if participation == self.reporter else 0
else self.average_observer if participation == self.observer else 0
def get_absolute_url(self): def get_absolute_url(self):
return reverse_lazy("participation:passage_detail", args=(self.pk,)) return reverse_lazy("participation:passage_detail", args=(self.pk,))
@ -564,9 +508,6 @@ class Passage(models.Model):
if self.reporter not in self.pool.participations.all(): if self.reporter not in self.pool.participations.all():
raise ValidationError(_("Team {trigram} is not registered in the pool.") raise ValidationError(_("Team {trigram} is not registered in the pool.")
.format(trigram=self.reporter.team.trigram)) .format(trigram=self.reporter.team.trigram))
if self.observer and self.observer not in self.pool.participations.all():
raise ValidationError(_("Team {trigram} is not registered in the pool.")
.format(trigram=self.observer.team.trigram))
return super().clean() return super().clean()
def __str__(self): def __str__(self):
@ -576,7 +517,6 @@ class Passage(models.Model):
class Meta: class Meta:
verbose_name = _("passage") verbose_name = _("passage")
verbose_name_plural = _("passages") verbose_name_plural = _("passages")
ordering = ('pool', 'position',)
class Tweak(models.Model): class Tweak(models.Model):
@ -626,7 +566,7 @@ class Solution(models.Model):
problem = models.PositiveSmallIntegerField( problem = models.PositiveSmallIntegerField(
verbose_name=_("problem"), verbose_name=_("problem"),
choices=[ choices=[
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, len(settings.PROBLEMS) + 1) (i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, settings.PROBLEM_COUNT + 1)
], ],
) )
@ -746,31 +686,14 @@ class Note(models.Model):
default=0, default=0,
) )
observer_oral = models.SmallIntegerField(
verbose_name=_("observer note"),
choices=zip(range(-4, 5), range(-4, 5)),
default=0,
)
def get_all(self):
yield self.defender_writing
yield self.defender_oral
yield self.opponent_writing
yield self.opponent_oral
yield self.reporter_writing
yield self.reporter_oral
if self.passage.observer:
yield self.observer_oral
def set_all(self, defender_writing: int, defender_oral: int, opponent_writing: int, opponent_oral: int, def set_all(self, defender_writing: int, defender_oral: int, opponent_writing: int, opponent_oral: int,
reporter_writing: int, reporter_oral: int, observer_oral: int = 0): reporter_writing: int, reporter_oral: int):
self.defender_writing = defender_writing self.defender_writing = defender_writing
self.defender_oral = defender_oral self.defender_oral = defender_oral
self.opponent_writing = opponent_writing self.opponent_writing = opponent_writing
self.opponent_oral = opponent_oral self.opponent_oral = opponent_oral
self.reporter_writing = reporter_writing self.reporter_writing = reporter_writing
self.reporter_oral = reporter_oral self.reporter_oral = reporter_oral
self.observer_oral = observer_oral
def get_absolute_url(self): def get_absolute_url(self):
return reverse_lazy("participation:passage_detail", args=(self.passage.pk,)) return reverse_lazy("participation:passage_detail", args=(self.passage.pk,))
@ -780,7 +703,7 @@ class Note(models.Model):
def __bool__(self): def __bool__(self):
return any((self.defender_writing, self.defender_oral, self.opponent_writing, self.opponent_oral, return any((self.defender_writing, self.defender_oral, self.opponent_writing, self.opponent_oral,
self.reporter_writing, self.reporter_oral, self.observer_oral)) self.reporter_writing, self.reporter_oral))
class Meta: class Meta:
verbose_name = _("note") verbose_name = _("note")

View File

@ -6,22 +6,21 @@ from participation.models import Note, Participation, Passage, Pool, Team
from tfjm.lists import get_sympa_client from tfjm.lists import get_sympa_client
def create_team_participation(instance, created, raw, **_): def create_team_participation(instance, created, **_):
""" """
When a team got created, create an associated participation. When a team got created, create an associated participation.
""" """
if not raw:
participation = Participation.objects.get_or_create(team=instance)[0] participation = Participation.objects.get_or_create(team=instance)[0]
participation.save() participation.save()
if not created: if not created:
participation.team.create_mailing_list() participation.team.create_mailing_list()
def update_mailing_list(instance: Team, raw, **_): def update_mailing_list(instance: Team, **_):
""" """
When a team name or trigram got updated, update mailing lists and Matrix rooms When a team name or trigram got updated, update mailing lists and Matrix rooms
""" """
if instance.pk and not raw: if instance.pk:
old_team = Team.objects.get(pk=instance.pk) old_team = Team.objects.get(pk=instance.pk)
if old_team.trigram != instance.trigram: if old_team.trigram != instance.trigram:
# TODO Rename Matrix room # TODO Rename Matrix room
@ -37,11 +36,10 @@ def update_mailing_list(instance: Team, raw, **_):
f"{coach.user.first_name} {coach.user.last_name}") f"{coach.user.first_name} {coach.user.last_name}")
def create_notes(instance: Union[Passage, Pool], raw, **_): def create_notes(instance: Union[Passage, Pool], **_):
if not raw:
if isinstance(instance, Pool): if isinstance(instance, Pool):
for passage in instance.passages.all(): for passage in instance.passages.all():
create_notes(passage, raw) create_notes(passage)
return return
for jury in instance.pool.juries.all(): for jury in instance.pool.juries.all():

View File

@ -54,7 +54,6 @@ class ParticipationTable(tables.Table):
} }
model = Team model = Team
fields = ('name', 'trigram', 'valid',) fields = ('name', 'trigram', 'valid',)
order = ('-valid',)
class TournamentTable(tables.Table): class TournamentTable(tables.Table):
@ -77,21 +76,13 @@ class TournamentTable(tables.Table):
class PoolTable(tables.Table): class PoolTable(tables.Table):
letter = tables.LinkColumn( teams = tables.LinkColumn(
'participation:pool_detail', 'participation:pool_detail',
args=[tables.A('id')], args=[tables.A('id')],
verbose_name=_("pool").capitalize,
)
teams = tables.Column(
verbose_name=_("teams").capitalize, verbose_name=_("teams").capitalize,
empty_values=(), empty_values=(),
orderable=False,
) )
def render_letter(self, record):
return format_lazy(_("Pool {letter}{round}"), letter=record.get_letter_display(), round=record.round)
def render_teams(self, record): def render_teams(self, record):
return ", ".join(participation.team.trigram for participation in record.participations.all()) \ return ", ".join(participation.team.trigram for participation in record.participations.all()) \
or _("No defined team") or _("No defined team")
@ -101,7 +92,7 @@ class PoolTable(tables.Table):
'class': 'table table-condensed table-striped', 'class': 'table table-condensed table-striped',
} }
model = Pool model = Pool
fields = ('letter', 'teams', 'round', 'tournament',) fields = ('teams', 'round', 'tournament',)
class PassageTable(tables.Table): class PassageTable(tables.Table):
@ -143,4 +134,4 @@ class NoteTable(tables.Table):
} }
model = Note model = Note
fields = ('jury', 'defender_writing', 'defender_oral', 'opponent_writing', 'opponent_oral', fields = ('jury', 'defender_writing', 'defender_oral', 'opponent_writing', 'opponent_oral',
'reporter_writing', 'reporter_oral', 'observer_oral',) 'reporter_writing', 'reporter_oral',)

View File

@ -13,9 +13,6 @@
<dt class="col-sm-3">{% trans "Pool:" %}</dt> <dt class="col-sm-3">{% trans "Pool:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.pool.get_absolute_url }}">{{ passage.pool }}</a></dd> <dd class="col-sm-9"><a href="{{ passage.pool.get_absolute_url }}">{{ passage.pool }}</a></dd>
<dt class="col-sm-3">{% trans "Position:" %}</dt>
<dd class="col-sm-9">{{ passage.position }}</dd>
<dt class="col-sm-3">{% trans "Defender:" %}</dt> <dt class="col-sm-3">{% trans "Defender:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.defender.get_absolute_url }}">{{ passage.defender.team }}</a></dd> <dd class="col-sm-9"><a href="{{ passage.defender.get_absolute_url }}">{{ passage.defender.team }}</a></dd>
@ -25,11 +22,6 @@
<dt class="col-sm-3">{% trans "Reporter:" %}</dt> <dt class="col-sm-3">{% trans "Reporter:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.reporter.get_absolute_url }}">{{ passage.reporter.team }}</a></dd> <dd class="col-sm-9"><a href="{{ passage.reporter.get_absolute_url }}">{{ passage.reporter.team }}</a></dd>
{% if passage.observer %}
<dt class="col-sm-3">{% trans "Observer:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.observer.get_absolute_url }}">{{ passage.observer.team }}</a></dd>
{% endif %}
<dt class="col-sm-3">{% trans "Defended solution:" %}</dt> <dt class="col-sm-3">{% trans "Defended solution:" %}</dt>
<dd class="col-sm-9"><a href="{{ passage.defended_solution.file.url }}">{{ passage.defended_solution }}</a></dd> <dd class="col-sm-9"><a href="{{ passage.defended_solution.file.url }}">{{ passage.defended_solution }}</a></dd>
@ -87,11 +79,6 @@
<dt class="col-sm-8">{% trans "Average points for the reporter oral:" %}</dt> <dt class="col-sm-8">{% trans "Average points for the reporter oral:" %}</dt>
<dd class="col-sm-4">{{ passage.average_reporter_oral|floatformat }}/10</dd> <dd class="col-sm-4">{{ passage.average_reporter_oral|floatformat }}/10</dd>
{% if passage.observer %}
<dt class="col-sm-8">{% trans "Average points for the observer oral:" %}</dt>
<dd class="col-sm-4">{{ passage.average_observer|floatformat }}/4</dd>
{% endif %}
</dl> </dl>
<hr> <hr>
@ -105,11 +92,6 @@
<dt class="col-sm-8">{% trans "Reporter points:" %}</dt> <dt class="col-sm-8">{% trans "Reporter points:" %}</dt>
<dd class="col-sm-4">{{ passage.average_reporter|floatformat }}/19</dd> <dd class="col-sm-4">{{ passage.average_reporter|floatformat }}/19</dd>
{% if passage.observer %}
<dt class="col-sm-8">{% trans "Observer points:" %}</dt>
<dd class="col-sm-4">{{ passage.average_observer|floatformat }}/4</dd>
{% endif %}
</dl> </dl>
</div> </div>
</div> </div>
@ -142,7 +124,7 @@
initModal("updatePassage", "{% url "participation:passage_update" pk=passage.pk %}") initModal("updatePassage", "{% url "participation:passage_update" pk=passage.pk %}")
{% if my_note is not None %} {% if my_note is not None %}
initModal("updateNotes", "{% url "participation:update_notes" pk=my_note.pk %}") initModal("updateNotesModal", "{% url "participation:update_notes" pk=my_note.pk %}")
{% endif %} {% endif %}
{% elif user.registration.participates %} {% elif user.registration.participates %}
initModal("uploadSynthesis", "{% url "participation:upload_synthesis" pk=passage.pk %}") initModal("uploadSynthesis", "{% url "participation:upload_synthesis" pk=passage.pk %}")

View File

@ -15,9 +15,6 @@
<dt class="col-sm-3">{% trans "Round:" %}</dt> <dt class="col-sm-3">{% trans "Round:" %}</dt>
<dd class="col-sm-9">{{ pool.get_round_display }}</dd> <dd class="col-sm-9">{{ pool.get_round_display }}</dd>
<dt class="col-sm-3">{% trans "Letter:" %}</dt>
<dd class="col-sm-9">{{ pool.get_letter_display }}</dd>
<dt class="col-sm-3">{% trans "Teams:" %}</dt> <dt class="col-sm-3">{% trans "Teams:" %}</dt>
<dd class="col-sm-9"> <dd class="col-sm-9">
{% for participation in pool.participations.all %} {% for participation in pool.participations.all %}
@ -26,40 +23,13 @@
</dd> </dd>
<dt class="col-sm-3">{% trans "Juries:" %}</dt> <dt class="col-sm-3">{% trans "Juries:" %}</dt>
<dd class="col-sm-9"> <dd class="col-sm-9">{{ pool.juries.all|join:", " }}</dd>
{{ pool.juries.all|join:", " }}
<a class="badge rounded-pill text-bg-info" href="{% url 'participation:pool_add_jurys' pk=pool.pk %}">
<i class="fas fa-plus"></i> {% trans "Add jurys" %}
</a>
</dd>
<dt class="col-sm-3">{% trans "Defended solutions:" %}</dt> <dt class="col-sm-3">{% trans "Defended solutions:" %}</dt>
<dd class="col-sm-9"> <dd class="col-sm-9">
{% for passage in pool.passages.all %} {% for passage in pool.passages.all %}
<a href="{{ passage.defended_solution.file.url }}">{{ passage.defender.team.trigram }} — {{ passage.get_solution_number_display }}</a>{% if not forloop.last %}, {% endif %} <a href="{{ passage.defended_solution.file.url }}">{{ passage.defended_solution }}{% if not forloop.last %}, {% endif %}</a>
{% endfor %} {% endfor %}
<a href="{% url 'participation:pool_download_solutions' pk=pool.pk %}" class="badge rounded-pill text-bg-secondary">
<i class="fas fa-download"></i> {% trans "Download all" %}
</a>
</dd>
<dt class="col-sm-3">{% trans "Syntheses:" %}</dt>
<dd class="col-sm-9">
<ul class="list-group list-group-flush">
{% for passage in pool.passages.all %}
<li class="list-group-item">
{{ passage.defender.team.trigram }} — {{ passage.get_solution_number_display }} :
{% for synthesis in passage.syntheses.all %}
<a href="{{ synthesis.file.url }}">{{ synthesis.participation.team.trigram }} ({{ synthesis.get_type_display }})</a>{% if not forloop.last %}, {% endif %}
{% empty %}
{% trans "No synthesis was uploaded yet." %}
{% endfor %}
</li>
{% endfor %}
</ul>
<a href="{% url 'participation:pool_download_syntheses' pk=pool.pk %}" class="badge rounded-pill text-bg-secondary">
<i class="fas fa-download"></i> {% trans "Download all" %}
</a>
</dd> </dd>
<dt class="col-sm-3">{% trans "BigBlueButton link:" %}</dt> <dt class="col-sm-3">{% trans "BigBlueButton link:" %}</dt>
@ -77,31 +47,6 @@
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
{% if user.registration.is_volunteer %}
<div class="card-footer text-center">
<div class="btn-group">
<a class="btn btn-info" href="{% url 'participation:pool_scale_note_sheet' pk=pool.pk %}">
{% trans "Download the scale sheet" %}{% if pool.passages.count == 5 %} — {% trans "Room" %} 1{% endif %}
</a>
{% if pool.passages.count == 5 %}
<a class="btn btn-info" href="{% url 'participation:pool_scale_note_sheet' pk=pool.pk %}?page=2">
{% trans "Room" %} 2
</a>
{% endif %}
</div>
<div class="btn-group">
<a class="btn btn-info" href="{% url 'participation:pool_final_note_sheet' pk=pool.pk %}">
{% trans "Download the final notation sheet" %}{% if pool.passages.count == 5 %} — {% trans "Room" %} 1{% endif %}
</a>
{% if pool.passages.count == 5 %}
<a class="btn btn-info" href="{% url 'participation:pool_final_note_sheet' pk=pool.pk %}?page=2">
{% trans "Room" %} 2
</a>
{% endif %}
</div>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadNotesModal">{% trans "Upload notes from a CSV file" %}</button>
</div>
{% endif %}
</div> </div>
</div> </div>
{% if user.registration.is_volunteer %} {% if user.registration.is_volunteer %}
@ -109,6 +54,7 @@
<button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#addPassageModal">{% trans "Add passage" %}</button> <button class="btn btn-success" data-bs-toggle="modal" data-bs-target="#addPassageModal">{% trans "Add passage" %}</button>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updatePoolModal">{% trans "Update" %}</button> <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updatePoolModal">{% trans "Update" %}</button>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updateTeamsModal">{% trans "Update teams" %}</button> <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updateTeamsModal">{% trans "Update teams" %}</button>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadNotesModal">{% trans "Upload notes from a CSV file" %}</button>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@ -115,14 +115,12 @@
</dl> </dl>
{% if user.registration.is_volunteer %} {% if user.registration.is_volunteer %}
{% if user.registration in self.team.participation.tournament.organizers or user.registration.is_admin %}
<div class="text-center"> <div class="text-center">
<a class="btn btn-info" href="{% url "participation:team_authorizations" pk=team.pk %}"> <a class="btn btn-info" href="{% url "participation:team_authorizations" pk=team.pk %}">
<i class="fas fa-file-archive"></i> {% trans "Download all submitted authorizations" %} <i class="fas fa-file-archive"></i> {% trans "Download all submitted authorizations" %}
</a> </a>
</div> </div>
{% endif %} {% endif %}
{% endif %}
</div> </div>
<div class="card-footer text-center"> <div class="card-footer text-center">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updateTeamModal">{% trans "Update" %}</button> <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#updateTeamModal">{% trans "Update" %}</button>

View File

@ -6,11 +6,6 @@
{% block content %} {% block content %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<div id="form-content"> <div id="form-content">
<div class="alert alert-info">
<a class="alert-link" href="{% url "participation:pool_notes_template" pk=pool.pk %}">
{% trans "Download empty notation sheet" %}
</a>
</div>
{% csrf_token %} {% csrf_token %}
{{ form|crispy }} {{ form|crispy }}
</div> </div>

View File

@ -7,9 +7,8 @@
<div id="form-content"> <div id="form-content">
<div class="alert alert-info"> <div class="alert alert-info">
{% trans "Templates:" %} {% trans "Templates:" %}
<a class="alert-link" href="{% static "Fiche_synthèse.pdf" %}"> PDF</a> <a class="alert-link" href="{% static "Fiche_synthèse.pdf" %}"> PDF</a> -
<a class="alert-link" href="{% static "Fiche_synthèse.tex" %}"> TEX</a> <a class="alert-link" href="{% static "Fiche_synthèse.tex" %}"> TEX</a>
<a class="alert-link" href="{% static "Fiche_synthèse.docx" %}" title="{% trans "Warning: non-free format" %}"> DOCX</a>
</div> </div>
{% csrf_token %} {% csrf_token %}
{{ form|crispy }} {{ form|crispy }}

View File

@ -4,11 +4,10 @@
from django.urls import path from django.urls import path
from django.views.generic import TemplateView from django.views.generic import TemplateView
from .views import CreateTeamView, FinalNotationSheetTemplateView, JoinTeamView, MyParticipationDetailView, \ from .views import CreateTeamView, JoinTeamView, MyParticipationDetailView, MyTeamDetailView, NoteUpdateView, \
MyTeamDetailView, NoteUpdateView, ParticipationDetailView, PassageCreateView, PassageDetailView, \ ParticipationDetailView, PassageCreateView, PassageDetailView, PassageUpdateView, PoolCreateView, PoolDetailView, \
PassageUpdateView, PoolAddJurysView, PoolCreateView, PoolDetailView, PoolDownloadView, PoolNotesTemplateView, \ PoolUpdateTeamsView, PoolUpdateView, PoolUploadNotesView, SolutionUploadView, SynthesisUploadView,\
PoolUpdateTeamsView, PoolUpdateView, PoolUploadNotesView, ScaleNotationSheetTemplateView, SolutionUploadView, \ TeamAuthorizationsView, TeamDetailView, TeamLeaveView, TeamListView, TeamUpdateView, \
SynthesisUploadView, TeamAuthorizationsView, TeamDetailView, TeamLeaveView, TeamListView, TeamUpdateView, \
TeamUploadMotivationLetterView, TournamentCreateView, TournamentDetailView, TournamentExportCSVView, \ TeamUploadMotivationLetterView, TournamentCreateView, TournamentDetailView, TournamentExportCSVView, \
TournamentListView, TournamentUpdateView TournamentListView, TournamentUpdateView
@ -37,14 +36,8 @@ urlpatterns = [
path("pools/create/", PoolCreateView.as_view(), name="pool_create"), path("pools/create/", PoolCreateView.as_view(), name="pool_create"),
path("pools/<int:pk>/", PoolDetailView.as_view(), name="pool_detail"), path("pools/<int:pk>/", PoolDetailView.as_view(), name="pool_detail"),
path("pools/<int:pk>/update/", PoolUpdateView.as_view(), name="pool_update"), path("pools/<int:pk>/update/", PoolUpdateView.as_view(), name="pool_update"),
path("pools/<int:pk>/solutions/", PoolDownloadView.as_view(), name="pool_download_solutions"),
path("pools/<int:pk>/syntheses/", PoolDownloadView.as_view(), name="pool_download_syntheses"),
path("pools/<int:pk>/notation/scale/", ScaleNotationSheetTemplateView.as_view(), name="pool_scale_note_sheet"),
path("pools/<int:pk>/notation/final/", FinalNotationSheetTemplateView.as_view(), name="pool_final_note_sheet"),
path("pools/<int:pk>/update-teams/", PoolUpdateTeamsView.as_view(), name="pool_update_teams"), path("pools/<int:pk>/update-teams/", PoolUpdateTeamsView.as_view(), name="pool_update_teams"),
path("pools/<int:pk>/add-jurys/", PoolAddJurysView.as_view(), name="pool_add_jurys"),
path("pools/<int:pk>/upload-notes/", PoolUploadNotesView.as_view(), name="pool_upload_notes"), path("pools/<int:pk>/upload-notes/", PoolUploadNotesView.as_view(), name="pool_upload_notes"),
path("pools/<int:pk>/upload-notes/template/", PoolNotesTemplateView.as_view(), name="pool_notes_template"),
path("pools/passages/add/<int:pk>/", PassageCreateView.as_view(), name="passage_create"), path("pools/passages/add/<int:pk>/", PassageCreateView.as_view(), name="passage_create"),
path("pools/passages/<int:pk>/", PassageDetailView.as_view(), name="passage_detail"), path("pools/passages/<int:pk>/", PassageDetailView.as_view(), name="passage_detail"),
path("pools/passages/<int:pk>/update/", PassageUpdateView.as_view(), name="passage_update"), path("pools/passages/<int:pk>/update/", PassageUpdateView.as_view(), name="passage_update"),

View File

@ -1,11 +1,8 @@
# Copyright (C) 2020 by Animath # Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import csv import csv
from io import BytesIO from io import BytesIO
import os import os
import subprocess
from tempfile import mkdtemp
from zipfile import ZipFile from zipfile import ZipFile
from django.conf import settings from django.conf import settings
@ -20,23 +17,18 @@ from django.shortcuts import redirect
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, FormView, RedirectView, TemplateView, UpdateView, View from django.views.generic import CreateView, DetailView, FormView, RedirectView, TemplateView, UpdateView, View
from django.views.generic.edit import FormMixin, ProcessFormView from django.views.generic.edit import FormMixin, ProcessFormView
from django_tables2 import MultiTableMixin, SingleTableView from django_tables2 import SingleTableView
from magic import Magic from magic import Magic
from odf.opendocument import OpenDocumentSpreadsheet from registration.models import StudentRegistration
from odf.style import Style, TableCellProperties, TableColumnProperties, TextProperties
from odf.table import CoveredTableCell, Table, TableCell, TableColumn, TableRow
from odf.text import P
from registration.models import StudentRegistration, VolunteerRegistration
from tfjm.lists import get_sympa_client from tfjm.lists import get_sympa_client
from tfjm.matrix import Matrix from tfjm.matrix import Matrix
from tfjm.views import AdminMixin, VolunteerMixin from tfjm.views import AdminMixin, VolunteerMixin
from .forms import AddJuryForm, JoinTeamForm, MotivationLetterForm, NoteForm, ParticipationForm, PassageForm, \ from .forms import JoinTeamForm, MotivationLetterForm, NoteForm, ParticipationForm, PassageForm, PoolForm, \
PoolForm, PoolTeamsForm, RequestValidationForm, SolutionForm, SynthesisForm, TeamForm, TournamentForm, \ PoolTeamsForm, RequestValidationForm, SolutionForm, SynthesisForm, TeamForm, TournamentForm, \
UploadNotesForm, ValidateParticipationForm UploadNotesForm, ValidateParticipationForm
from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament from .models import Note, Participation, Passage, Pool, Solution, Synthesis, Team, Tournament
from .tables import NoteTable, ParticipationTable, PassageTable, PoolTable, TeamTable, TournamentTable from .tables import NoteTable, ParticipationTable, PassageTable, PoolTable, TeamTable, TournamentTable
@ -392,9 +384,9 @@ class TeamAuthorizationsView(LoginRequiredMixin, DetailView):
if not user.is_authenticated: if not user.is_authenticated:
return super().handle_no_permission() return super().handle_no_permission()
if user.registration.is_admin or user.registration.is_volunteer \ if user.registration.is_admin or user.registration.is_volunteer \
and (user.registration in self.get_object().participation.tournament.organizers and (self.get_object().participation.tournament in user.registration.interesting_tournaments
or self.get_object().participation.final or self.get_object().participation.final
and user.registration in Tournament.final_tournament().organizers): and Tournament.final_tournament() in user.registration.interesting_tournaments):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
raise PermissionDenied raise PermissionDenied
@ -555,24 +547,16 @@ class TournamentUpdateView(VolunteerMixin, UpdateView):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
class TournamentDetailView(MultiTableMixin, DetailView): class TournamentDetailView(DetailView):
""" """
Display tournament detail. Display tournament detail.
""" """
model = Tournament model = Tournament
def dispatch(self, request, *args, **kwargs):
self.tables = [
ParticipationTable(self.get_object().participations.all()),
PoolTable(self.get_object().pools.order_by('id').all()),
]
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
tables = context['tables'] context["teams"] = ParticipationTable(self.object.participations.all())
context["teams"] = tables[0] context["pools"] = PoolTable(self.object.pools.order_by('id').all())
context["pools"] = tables[1]
notes = dict() notes = dict()
for participation in self.object.participations.all(): for participation in self.object.participations.all():
@ -600,8 +584,7 @@ class TournamentExportCSVView(VolunteerMixin, DetailView):
content_type='text/csv', content_type='text/csv',
headers={'Content-Disposition': f'attachment; filename="Tournoi de {tournament.name}.csv"'}, headers={'Content-Disposition': f'attachment; filename="Tournoi de {tournament.name}.csv"'},
) )
writer = csv.DictWriter(resp, ('Tournoi', 'Équipe', 'Trigramme', 'Nom', 'Prénom', 'Email', writer = csv.DictWriter(resp, ('Tournoi', 'Équipe', 'Trigramme', 'Nom', 'Prénom', 'Genre', 'Date de naissance'))
'Genre', 'Date de naissance'))
writer.writeheader() writer.writeheader()
for participation in tournament.participations.filter(valid=True).order_by('team__trigram').all(): for participation in tournament.participations.filter(valid=True).order_by('team__trigram').all():
@ -613,7 +596,6 @@ class TournamentExportCSVView(VolunteerMixin, DetailView):
'Trigramme': participation.team.trigram, 'Trigramme': participation.team.trigram,
'Nom': registration.user.last_name, 'Nom': registration.user.last_name,
'Prénom': registration.user.first_name, 'Prénom': registration.user.first_name,
'Email': registration.user.email,
'Genre': registration.get_gender_display() if isinstance(registration, StudentRegistration) 'Genre': registration.get_gender_display() if isinstance(registration, StudentRegistration)
else 'Encandrant⋅e', else 'Encandrant⋅e',
'Date de naissance': registration.birth_date if isinstance(registration, StudentRegistration) 'Date de naissance': registration.birth_date if isinstance(registration, StudentRegistration)
@ -733,106 +715,6 @@ class PoolUpdateTeamsView(VolunteerMixin, UpdateView):
return self.handle_no_permission() return self.handle_no_permission()
class PoolDownloadView(VolunteerMixin, DetailView):
"""
Download all solutions or syntheses as a ZIP archive.
"""
model = Pool
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return self.handle_no_permission()
if request.user.registration.is_admin or request.user.registration.is_volunteer \
and (self.get_object().tournament in request.user.registration.organized_tournaments.all()
or request.user.registration in self.get_object().juries.all()):
return super().dispatch(request, *args, **kwargs)
return self.handle_no_permission()
def get(self, request, *args, **kwargs):
pool = self.get_object()
is_solution = 'solutions' in request.path
output = BytesIO()
zf = ZipFile(output, "w")
for s in (pool.solutions if is_solution else Synthesis.objects.filter(passage__pool=pool).all()):
zf.write("media/" + s.file.name, f"{s}.pdf")
zf.close()
response = HttpResponse(content_type="application/zip")
filename = _("Solutions for pool {pool} of tournament {tournament}.zip") \
if is_solution else _("Syntheses for pool {pool} of tournament {tournament}.zip")
filename = filename.format(pool=pool.get_letter_display() + str(pool.round), tournament=pool.tournament.name)
response["Content-Disposition"] = "attachment; filename=\"{filename}\"" \
.format(filename=filename)
response.write(output.getvalue())
return response
class PoolAddJurysView(VolunteerMixin, FormView, DetailView):
"""
This view lets organizers set jurys for a pool, without multiplying clicks.
"""
model = Pool
form_class = AddJuryForm
template_name = 'participation/pool_add_jurys.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = _("Jurys of {pool}").format(pool=self.object)
return context
@transaction.atomic
def form_valid(self, form):
self.object = self.get_object()
# Save the user object first
form.save()
user = form.instance
# Create associated registration object to the new user
reg = VolunteerRegistration.objects.create(
user=user,
professional_activity="Juré⋅e du tournoi " + self.object.tournament.name,
)
# Add the user in the jury
self.object.juries.add(reg)
self.object.save()
reg.send_email_validation_link()
# Generate new password for the user
password = get_random_string(16)
user.set_password(password)
user.save()
# Send welcome mail
subject = "[TFJM²] " + str(_("New TFJM² jury account"))
site = Site.objects.first()
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,
inviter=self.request.user,
password=password,
domain=site.domain))
user.email_user(subject, message, html_message=html)
# Add notification
messages.success(self.request, _("The jury {name} has been successfully added!")
.format(name=f"{user.first_name} {user.last_name}"))
return super().form_valid(form)
def form_invalid(self, form):
# This is useful since we have a FormView + a DetailView
self.object = self.get_object()
return super().form_invalid(form)
def get_success_url(self):
return reverse_lazy('participation:pool_add_jurys', args=(self.kwargs['pk'],))
class PoolUploadNotesView(VolunteerMixin, FormView, DetailView): class PoolUploadNotesView(VolunteerMixin, FormView, DetailView):
model = Pool model = Pool
form_class = UploadNotesForm form_class = UploadNotesForm
@ -841,10 +723,9 @@ class PoolUploadNotesView(VolunteerMixin, FormView, DetailView):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
if request.user.is_authenticated and \ if request.user.registration.is_admin or request.user.registration.is_volunteer \
(request.user.registration.is_admin or request.user.registration.is_volunteer
and (self.object.tournament in request.user.registration.organized_tournaments.all() and (self.object.tournament in request.user.registration.organized_tournaments.all()
or request.user.registration in self.object.juries.all())): or request.user.registration in self.object.juries.all()):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
return self.handle_no_permission() return self.handle_no_permission()
@ -854,19 +735,13 @@ class PoolUploadNotesView(VolunteerMixin, FormView, DetailView):
pool = self.get_object() pool = self.get_object()
parsed_notes = form.cleaned_data['parsed_notes'] parsed_notes = form.cleaned_data['parsed_notes']
for vr in parsed_notes.keys(): for vr, notes in parsed_notes.items():
if vr not in pool.juries.all(): if vr not in pool.juries.all():
form.add_error('file', _("The following user is not registered as a jury:") + " " + str(vr)) form.add_error('file', _("The following user is not registered as a jury:") + " " + str(vr))
if form.errors:
return self.form_invalid(form)
for vr, notes in parsed_notes.items():
# There is an observer note for 4-teams pools
notes_count = 7 if pool.passages.count() == 4 else 6
for i, passage in enumerate(pool.passages.all()): for i, passage in enumerate(pool.passages.all()):
note = Note.objects.get_or_create(jury=vr, passage=passage)[0] note = Note.objects.get_or_create(jury=vr, passage=passage)[0]
passage_notes = notes[notes_count * i:notes_count * (i + 1)] passage_notes = notes[6 * i:6 * (i + 1)]
note.set_all(*passage_notes) note.set_all(*passage_notes)
note.save() note.save()
@ -877,555 +752,6 @@ class PoolUploadNotesView(VolunteerMixin, FormView, DetailView):
return reverse_lazy('participation:pool_detail', args=(self.kwargs['pk'],)) return reverse_lazy('participation:pool_detail', args=(self.kwargs['pk'],))
class PoolNotesTemplateView(VolunteerMixin, DetailView):
"""
Generate an ODS sheet to fill the notes of the pool.
"""
model = Pool
def render_to_response(self, context, **response_kwargs): # noqa: C901
pool_size = self.object.passages.count()
passage_width = 7 if pool_size == 4 else 6
line_length = pool_size * passage_width
def getcol(number: int) -> str:
"""
Translates the given number to the nth column name
"""
if number == 0:
return ''
return getcol((number - 1) // 26) + chr(65 + (number - 1) % 26)
doc = OpenDocumentSpreadsheet()
# Define styles
style = Style(name="Contenu", family="table-cell")
style.addElement(TableCellProperties(border="0.75pt solid #000000"))
doc.styles.addElement(style)
style_left = Style(name="Contenu gauche", family="table-cell")
style_left.addElement(TableCellProperties(border="0.75pt solid #000000", borderleft="2pt solid #000000"))
doc.styles.addElement(style_left)
style_right = Style(name="Contenu droite", family="table-cell")
style_right.addElement(TableCellProperties(border="0.75pt solid #000000", borderright="2pt solid #000000"))
doc.styles.addElement(style_right)
style_top = Style(name="Contenu haut", family="table-cell")
style_top.addElement(TableCellProperties(border="0.75pt solid #000000", bordertop="2pt solid #000000"))
doc.styles.addElement(style_top)
style_topright = Style(name="Contenu haut droite", family="table-cell")
style_topright.addElement(TableCellProperties(border="0.75pt solid #000000",
borderright="2pt solid #000000",
bordertop="2pt solid #000000"))
doc.styles.addElement(style_topright)
style_topleftright = Style(name="Contenu haut gauche droite", family="table-cell")
style_topleftright.addElement(TableCellProperties(border="0.75pt solid #000000",
borderleft="2pt solid #000000",
borderright="2pt solid #000000",
bordertop="2pt solid #000000"))
doc.styles.addElement(style_topleftright)
style_leftright = Style(name="Contenu haut gauche droite", family="table-cell")
style_leftright.addElement(TableCellProperties(border="0.75pt solid #000000",
borderleft="2pt solid #000000",
borderright="2pt solid #000000"))
doc.styles.addElement(style_leftright)
style_botleft = Style(name="Contenu bas gauche", family="table-cell")
style_botleft.addElement(TableCellProperties(border="0.75pt solid #000000",
borderbottom="2pt solid #000000",
borderleft="2pt solid #000000"))
doc.styles.addElement(style_botleft)
style_bot = Style(name="Contenu bas", family="table-cell")
style_bot.addElement(TableCellProperties(border="0.75pt solid #000000", borderbottom="2pt solid #000000"))
doc.styles.addElement(style_bot)
style_botright = Style(name="Contenu bas droite", family="table-cell")
style_botright.addElement(TableCellProperties(border="0.75pt solid #000000",
borderbottom="2pt solid #000000",
borderright="2pt solid #000000"))
doc.styles.addElement(style_botright)
title_style = Style(name="Titre", family="table-cell")
title_style.addElement(TextProperties(fontweight="bold"))
title_style.addElement(TableCellProperties(border="0.75pt solid #000000"))
doc.styles.addElement(title_style)
title_style_left = Style(name="Titre gauche", family="table-cell")
title_style_left.addElement(TextProperties(fontweight="bold"))
title_style_left.addElement(TableCellProperties(border="0.75pt solid #000000",
borderleft="2pt solid #000000"))
doc.styles.addElement(title_style_left)
title_style_right = Style(name="Titre droite", family="table-cell")
title_style_right.addElement(TextProperties(fontweight="bold"))
title_style_right.addElement(TableCellProperties(border="0.75pt solid #000000",
borderright="2pt solid #000000"))
doc.styles.addElement(title_style_right)
title_style_leftright = Style(name="Titre gauche droite", family="table-cell")
title_style_leftright.addElement(TextProperties(fontweight="bold"))
title_style_leftright.addElement(TableCellProperties(border="0.75pt solid #000000",
borderleft="2pt solid #000000",
borderright="2pt solid #000000"))
doc.styles.addElement(title_style_leftright)
title_style_top = Style(name="Titre haut", family="table-cell")
title_style_top.addElement(TextProperties(fontweight="bold"))
title_style_top.addElement(TableCellProperties(border="0.75pt solid #000000",
bordertop="2pt solid #000000"))
doc.styles.addElement(title_style_top)
title_style_topbot = Style(name="Titre haut bas", family="table-cell")
title_style_topbot.addElement(TextProperties(fontweight="bold"))
title_style_topbot.addElement(TableCellProperties(border="0.75pt solid #000000",
bordertop="2pt solid #000000",
borderbottom="2pt solid #000000"))
doc.styles.addElement(title_style_topbot)
title_style_topleft = Style(name="Titre haut gauche", family="table-cell")
title_style_topleft.addElement(TextProperties(fontweight="bold"))
title_style_topleft.addElement(TableCellProperties(border="0.75pt solid #000000",
bordertop="2pt solid #000000",
borderleft="2pt solid #000000"))
doc.styles.addElement(title_style_topleft)
title_style_topbotleft = Style(name="Titre haut bas gauche", family="table-cell")
title_style_topbotleft.addElement(TextProperties(fontweight="bold"))
title_style_topbotleft.addElement(TableCellProperties(border="0.75pt solid #000000",
bordertop="2pt solid #000000",
borderbottom="2pt solid #000000",
borderleft="2pt solid #000000"))
doc.styles.addElement(title_style_topbotleft)
title_style_topright = Style(name="Titre haut droite", family="table-cell")
title_style_topright.addElement(TextProperties(fontweight="bold"))
title_style_topright.addElement(TableCellProperties(border="0.75pt solid #000000",
bordertop="2pt solid #000000",
borderright="2pt solid #000000"))
doc.styles.addElement(title_style_topright)
title_style_topbotright = Style(name="Titre haut bas droite", family="table-cell")
title_style_topbotright.addElement(TextProperties(fontweight="bold"))
title_style_topbotright.addElement(TableCellProperties(border="0.75pt solid #000000",
bordertop="2pt solid #000000",
borderbottom="2pt solid #000000",
borderright="2pt solid #000000"))
doc.styles.addElement(title_style_topbotright)
title_style_topleftright = Style(name="Titre haut gauche droite", family="table-cell")
title_style_topleftright.addElement(TextProperties(fontweight="bold"))
title_style_topleftright.addElement(TableCellProperties(border="0.75pt solid #000000",
bordertop="2pt solid #000000",
borderleft="2pt solid #000000",
borderright="2pt solid #000000"))
doc.styles.addElement(title_style_topleftright)
title_style_bot = Style(name="Titre bas", family="table-cell")
title_style_bot.addElement(TextProperties(fontweight="bold"))
title_style_bot.addElement(TableCellProperties(border="0.75pt solid #000000",
borderbottom="2pt solid #000000"))
doc.styles.addElement(title_style_bot)
title_style_botleft = Style(name="Titre bas gauche", family="table-cell")
title_style_botleft.addElement(TextProperties(fontweight="bold"))
title_style_botleft.addElement(TableCellProperties(border="0.75pt solid #000000",
borderbottom="2pt solid #000000",
borderleft="2pt solid #000000"))
doc.styles.addElement(title_style_botleft)
title_style_botright = Style(name="Titre bas droite", family="table-cell")
title_style_botright.addElement(TextProperties(fontweight="bold"))
title_style_botright.addElement(TableCellProperties(border="0.75pt solid #000000",
borderbottom="2pt solid #000000",
borderright="2pt solid #000000"))
doc.styles.addElement(title_style_botright)
first_col_style = Style(name="co1", family="table-column")
first_col_style.addElement(TableColumnProperties(columnwidth="9cm", breakbefore="auto"))
doc.automaticstyles.addElement(first_col_style)
col_style = Style(name="co2", family="table-column")
col_style.addElement(TableColumnProperties(columnwidth="2.6cm", breakbefore="auto"))
doc.automaticstyles.addElement(col_style)
obs_col_style = Style(name="co3", family="table-column")
obs_col_style.addElement(TableColumnProperties(columnwidth="5.2cm", breakbefore="auto"))
doc.automaticstyles.addElement(obs_col_style)
table = Table(name=f"Poule {self.object.get_letter_display()}{self.object.round}")
doc.spreadsheet.addElement(table)
table.addElement(TableColumn(stylename=first_col_style))
for i in range(line_length):
table.addElement(TableColumn(stylename=obs_col_style if pool_size == 4
and i % passage_width == passage_width - 1 else col_style))
# Add line for the problems for different passages
header_pb = TableRow()
table.addElement(header_pb)
problems_tc = TableCell(valuetype="string", stylename=title_style_topleft)
problems_tc.addElement(P(text="Problème"))
header_pb.addElement(problems_tc)
for passage in self.object.passages.all():
tc = TableCell(valuetype="string", stylename=title_style_topleftright)
tc.addElement(P(text=f"Problème {passage.solution_number}"))
tc.setAttribute('numbercolumnsspanned', "7" if pool_size == 4 else "6")
tc.setAttribute("formula", f"of:=[.B{8 + self.object.juries.count() + passage.position}]")
header_pb.addElement(tc)
header_pb.addElement(CoveredTableCell(numbercolumnsrepeated=6 if pool_size == 4 else 5))
# Add roles on the second line of the table
header_role = TableRow()
table.addElement(header_role)
role_tc = TableCell(valuetype="string", stylename=title_style_left)
role_tc.addElement(P(text="Rôle"))
header_role.addElement(role_tc)
for i in range(pool_size):
defender_tc = TableCell(valuetype="string", stylename=title_style_left)
defender_tc.addElement(P(text="Défenseur⋅se"))
defender_tc.setAttribute('numbercolumnsspanned', "2")
header_role.addElement(defender_tc)
header_role.addElement(CoveredTableCell())
opponent_tc = TableCell(valuetype="string", stylename=title_style)
opponent_tc.addElement(P(text="Opposant⋅e"))
opponent_tc.setAttribute('numbercolumnsspanned', "2")
header_role.addElement(opponent_tc)
header_role.addElement(CoveredTableCell())
reporter_tc = TableCell(valuetype="string",
stylename=title_style_right if pool_size != 4 else title_style)
reporter_tc.addElement(P(text="Rapporteur⋅e"))
reporter_tc.setAttribute('numbercolumnsspanned', "2")
header_role.addElement(reporter_tc)
header_role.addElement(CoveredTableCell())
if pool_size == 4:
observer_tc = TableCell(valuetype="string", stylename=title_style_right)
observer_tc.addElement(P(text="Intervention exceptionnelle"))
header_role.addElement(observer_tc)
# Add maximum notes on the third line
header_notes = TableRow()
table.addElement(header_notes)
jury_tc = TableCell(valuetype="string", value="Juré⋅e", stylename=title_style_botleft)
jury_tc.addElement(P(text="Juré⋅e"))
header_notes.addElement(jury_tc)
for i in range(pool_size):
defender_w_tc = TableCell(valuetype="string", stylename=title_style_botleft)
defender_w_tc.addElement(P(text="Écrit (/20)"))
header_notes.addElement(defender_w_tc)
defender_o_tc = TableCell(valuetype="string", stylename=title_style_bot)
defender_o_tc.addElement(P(text="Oral (/16)"))
header_notes.addElement(defender_o_tc)
opponent_w_tc = TableCell(valuetype="string", stylename=title_style_bot)
opponent_w_tc.addElement(P(text="Écrit (/9)"))
header_notes.addElement(opponent_w_tc)
opponent_o_tc = TableCell(valuetype="string", stylename=title_style_bot)
opponent_o_tc.addElement(P(text="Oral (/10)"))
header_notes.addElement(opponent_o_tc)
reporter_w_tc = TableCell(valuetype="string", stylename=title_style_bot)
reporter_w_tc.addElement(P(text="Écrit (/9)"))
header_notes.addElement(reporter_w_tc)
reporter_o_tc = TableCell(valuetype="string",
stylename=title_style_botright if pool_size != 4 else title_style_bot)
reporter_o_tc.addElement(P(text="Oral (/10)"))
header_notes.addElement(reporter_o_tc)
if pool_size == 4:
observer_tc = TableCell(valuetype="string",
stylename=title_style_botright)
observer_tc.addElement(P(text="Oral (± 4)"))
header_notes.addElement(observer_tc)
# Add a notation line for each jury
for jury in self.object.juries.all():
jury_row = TableRow()
table.addElement(jury_row)
name_tc = TableCell(valuetype="string", stylename=style_leftright)
name_tc.addElement(P(text=f"{jury.user.first_name} {jury.user.last_name}"))
jury_row.addElement(name_tc)
for passage in self.object.passages.all():
notes = Note.objects.get(jury=jury, passage=passage)
for j, note in enumerate(notes.get_all()):
note_tc = TableCell(valuetype="float", value=note,
stylename=style_right if j == passage_width - 1 else style)
note_tc.addElement(P(text=str(note)))
jury_row.addElement(note_tc)
jury_size = self.object.juries.count()
min_row = 4
max_row = 4 + jury_size - 1
min_column = 2
# Add line for averages
average_row = TableRow()
table.addElement(average_row)
average_tc = TableCell(valuetype="string", stylename=title_style_topleftright)
average_tc.addElement(P(text="Moyenne"))
average_row.addElement(average_tc)
for i, passage in enumerate(self.object.passages.all()):
for j, note in enumerate(passage.averages):
tc = TableCell(valuetype="float", value=note,
stylename=style_topright if j == passage_width - 1 else style_top)
tc.addElement(P(text=str(note)))
column = getcol(min_column + i * passage_width + j)
tc.setAttribute("formula", f"of:=AVERAGEIF([.${getcol(min_column + i * passage_width)}${min_row}"
f":${getcol(min_column + i * passage_width)}{max_row}]; \">0\"; "
f"[.{column}${min_row}:{column}{max_row}])")
average_row.addElement(tc)
# Add coefficients for each note on the next line
coeff_row = TableRow()
table.addElement(coeff_row)
coeff_tc = TableCell(valuetype="string", stylename=title_style_leftright)
coeff_tc.addElement(P(text="Coefficient"))
coeff_row.addElement(coeff_tc)
for passage in self.object.passages.all():
defender_w_tc = TableCell(valuetype="float", value=1, stylename=style_left)
defender_w_tc.addElement(P(text="1"))
coeff_row.addElement(defender_w_tc)
defender_o_tc = TableCell(valuetype="float", value=2 - 0.5 * passage.defender_penalties, stylename=style)
defender_o_tc.addElement(P(text=str(2 - 0.5 * passage.defender_penalties)))
coeff_row.addElement(defender_o_tc)
opponent_w_tc = TableCell(valuetype="float", value=1, stylename=style)
opponent_w_tc.addElement(P(text="1"))
coeff_row.addElement(opponent_w_tc)
opponent_o_tc = TableCell(valuetype="float", value=2, stylename=style)
opponent_o_tc.addElement(P(text="2"))
coeff_row.addElement(opponent_o_tc)
reporter_w_tc = TableCell(valuetype="float", value=1, stylename=style)
reporter_w_tc.addElement(P(text="1"))
coeff_row.addElement(reporter_w_tc)
reporter_o_tc = TableCell(valuetype="float", value=1,
stylename=style_right if pool_size != 4 else style)
reporter_o_tc.addElement(P(text="1"))
coeff_row.addElement(reporter_o_tc)
if pool_size == 4:
observer_tc = TableCell(valuetype="float", value=1, stylename=style_right)
observer_tc.addElement(P(text="1"))
coeff_row.addElement(observer_tc)
# Add the subtotal on the next line
subtotal_row = TableRow()
table.addElement(subtotal_row)
subtotal_tc = TableCell(valuetype="string", stylename=title_style_botleft)
subtotal_tc.addElement(P(text="Sous-total"))
subtotal_row.addElement(subtotal_tc)
for i, passage in enumerate(self.object.passages.all()):
def_w_col = getcol(min_column + passage_width * i)
def_o_col = getcol(min_column + passage_width * i + 1)
defender_tc = TableCell(valuetype="float", value=passage.average_defender, stylename=style_botleft)
defender_tc.addElement(P(text=str(passage.average_defender)))
defender_tc.setAttribute('numbercolumnsspanned', "2")
defender_tc.setAttribute("formula", f"of:=[.{def_w_col}{max_row + 1}] * [.{def_w_col}{max_row + 2}]"
f" + [.{def_o_col}{max_row + 1}] * [.{def_o_col}{max_row + 2}]")
subtotal_row.addElement(defender_tc)
subtotal_row.addElement(CoveredTableCell())
opp_w_col = getcol(min_column + passage_width * i + 2)
opp_o_col = getcol(min_column + passage_width * i + 3)
opponent_tc = TableCell(valuetype="float", value=passage.average_opponent, stylename=style_bot)
opponent_tc.addElement(P(text=str(passage.average_opponent)))
opponent_tc.setAttribute('numbercolumnsspanned', "2")
opponent_tc.setAttribute("formula", f"of:=[.{opp_w_col}{max_row + 1}] * [.{opp_w_col}{max_row + 2}]"
f" + [.{opp_o_col}{max_row + 1}] * [.{opp_o_col}{max_row + 2}]")
subtotal_row.addElement(opponent_tc)
subtotal_row.addElement(CoveredTableCell())
rep_w_col = getcol(min_column + passage_width * i + 4)
rep_o_col = getcol(min_column + passage_width * i + 5)
reporter_tc = TableCell(valuetype="float", value=passage.average_reporter,
stylename=style_botright if pool_size != 4 else style_bot)
reporter_tc.addElement(P(text=str(passage.average_reporter)))
reporter_tc.setAttribute('numbercolumnsspanned', "2")
reporter_tc.setAttribute("formula", f"of:=[.{rep_w_col}{max_row + 1}] * [.{rep_w_col}{max_row + 2}]"
f" + [.{rep_o_col}{max_row + 1}] * [.{rep_o_col}{max_row + 2}]")
subtotal_row.addElement(reporter_tc)
subtotal_row.addElement(CoveredTableCell())
if pool_size == 4:
obs_col = getcol(min_column + passage_width * i + 6)
observer_tc = TableCell(valuetype="float", value=passage.average_observer,
stylename=style_botright)
observer_tc.addElement(P(text=str(passage.average_observer)))
observer_tc.setAttribute("formula", f"of:=[.{obs_col}{max_row + 1}] * [.{obs_col}{max_row + 2}]")
subtotal_row.addElement(observer_tc)
table.addElement(TableRow())
# Compute the total scores in a new table
scores_header = TableRow()
table.addElement(scores_header)
team_tc = TableCell(valuetype="string", stylename=title_style_topbotleft)
team_tc.addElement(P(text="Équipe"))
scores_header.addElement(team_tc)
problem_tc = TableCell(valuetype="string", stylename=title_style_topbot)
problem_tc.addElement(P(text="Problème"))
scores_header.addElement(problem_tc)
total_tc = TableCell(valuetype="string", stylename=title_style_topbot)
total_tc.addElement(P(text="Total"))
scores_header.addElement(total_tc)
rank_tc = TableCell(valuetype="string", stylename=title_style_topbotright)
rank_tc.addElement(P(text="Rang"))
scores_header.addElement(rank_tc)
# For each line of the matrix P, the ith team is defender on the passage number Pi0,
# opponent on the passage number Pi1, reporter on the passage number Pi2
# and eventually observer on the passage number Pi3.
passage_matrix = []
match pool_size:
case 3:
passage_matrix = [
[0, 2, 1],
[1, 0, 2],
[2, 1, 0],
]
case 4:
passage_matrix = [
[0, 3, 2, 1],
[1, 0, 3, 2],
[2, 1, 0, 3],
[3, 2, 1, 0],
]
case 5:
passage_matrix = [
[0, 2, 3],
[1, 4, 2],
[2, 0, 4],
[3, 1, 0],
[4, 3, 1],
]
sorted_participations = sorted(self.object.participations.all(), key=lambda p: -self.object.average(p))
for passage in self.object.passages.all():
team_row = TableRow()
table.addElement(team_row)
team_tc = TableCell(valuetype="string",
stylename=style_botleft if passage.position == pool_size else style_left)
team_tc.addElement(P(text=f"{passage.defender.team.name} ({passage.defender.team.trigram})"))
team_row.addElement(team_tc)
problem_tc = TableCell(valuetype="string",
stylename=style_bot if passage.position == pool_size else style)
problem_tc.addElement(P(text=f"Problème {passage.solution_number}"))
team_row.addElement(problem_tc)
passage_line = passage_matrix[passage.position - 1]
score_tc = TableCell(valuetype="float", value=self.object.average(passage.defender),
stylename=style_bot if passage.position == pool_size else style)
score_tc.addElement(P(text=self.object.average(passage.defender)))
formula = "of:="
formula += getcol(min_column + passage_line[0] * passage_width) + str(max_row + 3) # Defender
formula += " + " + getcol(min_column + passage_line[1] * passage_width + 2) + str(max_row + 3) # Opponent
formula += " + " + getcol(min_column + passage_line[2] * passage_width + 4) + str(max_row + 3) # Reporter
if pool_size == 4:
# Observer
formula += " + " + getcol(min_column + passage_line[3] * passage_width + 6) + str(max_row + 3)
score_tc.setAttribute("formula", formula)
team_row.addElement(score_tc)
score_col = 'C'
rank_tc = TableCell(valuetype="float", value=sorted_participations.index(passage.defender) + 1,
stylename=style_botright if passage.position == pool_size else style_right)
rank_tc.addElement(P(text=str(sorted_participations.index(passage.defender) + 1)))
rank_tc.setAttribute("formula", f"of:=RANK([.{score_col}{max_row + 5 + passage.position}]; "
f"[.{score_col}${max_row + 6}]:[.{score_col}${max_row + 5 + pool_size}])")
team_row.addElement(rank_tc)
table.addElement(TableRow())
# Add small instructions
instructions_tr = TableRow()
table.addElement(instructions_tr)
instructions_tc = TableCell()
instructions_tc.addElement(P(text="Merci de ne pas toucher aux noms des juré⋅es.\n"
"Si nécessaire, faites les modifications sur le site\n"
"et récupérez le nouveau template.\n"
"N'entrez que des notes entières.\n"
"Ne retirez pas de 0 : toute ligne incomplète sera ignorée.\n"
"Dans le cadre de poules à 5, laissez des 0 en face des\n"
"juré⋅es qui ne sont pas dans le passage souhaité,\n"
"et remplissez uniquement les notes nécessaires dans le tableau.\n"
"Les moyennes calculées ignorent les 0, donc pas d'inquiétude."))
instructions_tr.addElement(instructions_tc)
# Save the sheet in a temporary file and send it in the response
doc.save('/tmp/notes.ods')
return FileResponse(streaming_content=open("/tmp/notes.ods", "rb"),
content_type="application/vnd.oasis.opendocument.spreadsheet",
filename=f"Feuille de notes - {self.object.tournament.name} "
f"- Poule {self.object.get_letter_display()}{self.object.round}.ods")
class NotationSheetTemplateView(VolunteerMixin, DetailView):
"""
Generate a PDF from a LaTeX template for the notation papers.
"""
model = Pool
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
passages = self.object.passages.all()
if passages.count() == 5:
page = self.request.GET.get('page', '1')
if not page.isnumeric() or page not in ['1', '2']:
page = '1'
passages = passages.filter(id__in=[passages[0].id, passages[2].id, passages[4].id]
if page == '1' else [passages[1].id, passages[3].id])
context['page'] = page
context['passages'] = passages
context['esp'] = passages.count() * '&'
context['is_jury'] = self.request.user.registration in self.object.juries.all() \
and 'blank' not in self.request.GET
context['tfjm_number'] = timezone.now().year - 2010
return context
def render_to_response(self, context, **response_kwargs):
tex = render_to_string(self.template_name, context=context, request=self.request)
temp_dir = mkdtemp()
with open(os.path.join(temp_dir, "texput.tex"), "w") as f:
f.write(tex)
process = subprocess.Popen(["pdflatex", "-interaction=nonstopmode", f"-output-directory={temp_dir}",
os.path.join(temp_dir, "texput.tex"), ])
process.wait()
return FileResponse(streaming_content=open(os.path.join(temp_dir, "texput.pdf"), "rb"),
content_type="application/pdf",
filename=self.template_name.split("/")[-1][:-3] + "pdf")
class ScaleNotationSheetTemplateView(NotationSheetTemplateView):
template_name = 'participation/tex/bareme.tex'
class FinalNotationSheetTemplateView(NotationSheetTemplateView):
template_name = 'participation/tex/finale.tex'
class PassageCreateView(VolunteerMixin, CreateView): class PassageCreateView(VolunteerMixin, CreateView):
model = Passage model = Passage
form_class = PassageForm form_class = PassageForm
@ -1478,9 +804,6 @@ class PassageDetailView(LoginRequiredMixin, DetailView):
context["notes"] = NoteTable([note for note in self.object.notes.all() if note]) context["notes"] = NoteTable([note for note in self.object.notes.all() if note])
elif self.request.user.registration.is_admin: elif self.request.user.registration.is_admin:
context["notes"] = NoteTable([note for note in self.object.notes.all() if note]) context["notes"] = NoteTable([note for note in self.object.notes.all() if note])
if 'notes' in context and not self.object.observer:
# Only display the observer column for 4-teams pools
context['notes']._sequence.pop()
return context return context
@ -1564,10 +887,3 @@ class NoteUpdateView(VolunteerMixin, UpdateView):
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
return self.handle_no_permission() return self.handle_no_permission()
def get_form(self, form_class=None):
form = super().get_form(form_class)
if not self.object.passage.observer:
# Set the note of the observer only for 4-teams pools
del form.fields['observer_oral']
return form

View File

@ -0,0 +1,37 @@
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
from django.contrib.admin import ModelAdmin
from polymorphic.admin import PolymorphicChildModelAdmin, PolymorphicParentModelAdmin
from .models import CoachRegistration, Payment, Registration, StudentRegistration, VolunteerRegistration
@admin.register(Registration)
class RegistrationAdmin(PolymorphicParentModelAdmin):
child_models = (StudentRegistration, CoachRegistration, VolunteerRegistration,)
list_display = ("user", "type", "email_confirmed",)
polymorphic_list = True
@admin.register(StudentRegistration)
class StudentRegistrationAdmin(PolymorphicChildModelAdmin):
pass
@admin.register(CoachRegistration)
class CoachRegistrationAdmin(PolymorphicChildModelAdmin):
pass
@admin.register(VolunteerRegistration)
class VolunteerRegistrationAdmin(PolymorphicChildModelAdmin):
pass
@admin.register(Payment)
class PaymentAdmin(ModelAdmin):
list_display = ('registration', 'type', 'valid', )
search_fields = ('registration__user__last_name', 'registration__user__first_name', 'registration__user__email',)
list_filter = ('type', 'valid',)

View File

@ -82,15 +82,6 @@ class UserForm(forms.ModelForm):
self.fields["last_name"].required = True self.fields["last_name"].required = True
self.fields["email"].required = True self.fields["email"].required = True
def clean_email(self):
"""
Ensure that the email address is unique.
"""
email = self.data["email"]
if User.objects.filter(email=email).exclude(pk=self.instance.pk).exists():
self.add_error("email", _("This email address is already used."))
return email
class Meta: class Meta:
model = User model = User
fields = ('first_name', 'last_name', 'email',) fields = ('first_name', 'last_name', 'email',)

View File

@ -191,10 +191,6 @@ class ParticipantRegistration(Registration):
def form_class(self): # pragma: no cover def form_class(self): # pragma: no cover
raise NotImplementedError raise NotImplementedError
class Meta:
verbose_name = _("participant registration")
verbose_name_plural = _("participant registrations")
class StudentRegistration(ParticipantRegistration): class StudentRegistration(ParticipantRegistration):
""" """
@ -321,10 +317,6 @@ class VolunteerRegistration(Registration):
from registration.forms import VolunteerRegistrationForm from registration.forms import VolunteerRegistrationForm
return VolunteerRegistrationForm return VolunteerRegistrationForm
class Meta:
verbose_name = _("volunteer registration")
verbose_name_plural = _("volunteer registrations")
def get_scholarship_filename(instance, filename): def get_scholarship_filename(instance, filename):
return f"authorization/scholarship/scholarship_{instance.registration.pk}" return f"authorization/scholarship/scholarship_{instance.registration.pk}"
@ -369,7 +361,7 @@ class Payment(models.Model):
) )
valid = models.BooleanField( valid = models.BooleanField(
verbose_name=_("payment valid"), verbose_name=_("valid"),
null=True, null=True,
default=False, default=False,
) )

View File

@ -43,12 +43,12 @@ def create_admin_registration(instance, **_):
VolunteerRegistration.objects.get_or_create(user=instance, admin=True) VolunteerRegistration.objects.get_or_create(user=instance, admin=True)
def create_payment(instance: Registration, raw, **_): def create_payment(instance: Registration, **_):
""" """
When a user is saved, create the associated payment. When a user is saved, create the associated payment.
For a free tournament, the payment is valid. For a free tournament, the payment is valid.
""" """
if instance.participates and not raw: if instance.participates:
payment = Payment.objects.get_or_create(registration=instance)[0] payment = Payment.objects.get_or_create(registration=instance)[0]
if instance.team and instance.team.participation.valid and instance.team.participation.tournament.price == 0: if instance.team and instance.team.participation.valid and instance.team.participation.tournament.price == 0:
payment.valid = True payment.valid = True

View File

@ -19,7 +19,8 @@ class RegistrationTable(tables.Table):
) )
def order_type(self, queryset, desc): def order_type(self, queryset, desc):
return queryset.order_by(('-' if desc else '') + 'polymorphic_ctype'), True types = ["volunteerregistration", "-volunteerregistration__admin", "participantregistration"]
return queryset.order_by(*(("-" if desc else "") + t for t in types)), True
class Meta: class Meta:
attrs = { attrs = {

View File

@ -14,7 +14,7 @@
<p> <p>
Vous avez été invités par {{ inviter.registration }} à rejoindre la plateforme du TFJM², accessible à l'adresse Vous avez été invités par {{ inviter.registration }} à rejoindre la plateforme du TFJM², accessible à l'adresse
<a href="https://{{ domain }}/">https://{{ domain }}/</a>. Vous disposez d'un compte de bénévole. <a href="https://{{ domain }}/">https://{{ domain }}/</a>. Vous disposez d'un compte d'organisateur.
</p> </p>
<p> <p>

View File

@ -3,7 +3,7 @@
Bonjour {{ user.registration }}, Bonjour {{ user.registration }},
Vous avez été invités par {{ inviter.registration }} à rejoindre la plateforme du TFJM², accessible à l'adresse Vous avez été invités par {{ inviter.registration }} à rejoindre la plateforme du TFJM², accessible à l'adresse
https://{{ domain }}/. Vous disposez d'un compte de bénévole. https://{{ domain }}/. Vous disposez d'un compte d'organisateur.
Un mot de passe aléatoire a été défini : {{ password }}. Un mot de passe aléatoire a été défini : {{ password }}.
Par sécurité, merci de le changer dès votre connexion. Par sécurité, merci de le changer dès votre connexion.

Some files were not shown because too many files have changed in this diff Show More