plateforme-tfjm2/participation/models.py

788 lines
24 KiB
Python

# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import date
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.urls import reverse_lazy
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _
from registration.models import VolunteerRegistration
from tfjm.lists import get_sympa_client
from tfjm.matrix import Matrix, RoomPreset, RoomVisibility
def get_motivation_letter_filename(instance, filename):
return f"authorization/motivation_letters/motivation_letter_{instance.trigram}"
class Team(models.Model):
"""
The Team model represents a real team that participates to the TFJM².
This only includes the registration detail.
"""
name = models.CharField(
max_length=255,
verbose_name=_("name"),
unique=True,
)
trigram = models.CharField(
max_length=3,
verbose_name=_("trigram"),
help_text=_("The trigram must be composed of three uppercase letters."),
unique=True,
validators=[
RegexValidator(r"^[A-Z]{3}$"),
RegexValidator(fr"^(?!{'|'.join(f'{t}$' for t in settings.FORBIDDEN_TRIGRAMS)})",
message=_("This trigram is forbidden.")),
],
)
access_code = models.CharField(
max_length=6,
verbose_name=_("access code"),
help_text=_("The access code let other people to join the team."),
)
motivation_letter = models.FileField(
verbose_name=_("motivation letter"),
upload_to=get_motivation_letter_filename,
blank=True,
default="",
)
@property
def students(self):
return self.participants.filter(studentregistration__isnull=False)
@property
def coaches(self):
return self.participants.filter(coachregistration__isnull=False)
@property
def email(self):
"""
:return: The mailing list to contact the team members.
"""
return f"equipe-{self.trigram.lower()}@{os.getenv('SYMPA_HOST', 'localhost')}"
def create_mailing_list(self):
"""
Create a new Sympa mailing list to contact the team.
"""
get_sympa_client().create_list(
f"equipe-{self.trigram.lower()}",
f"Equipe {self.name} ({self.trigram})",
"hotline", # TODO Use a custom sympa template
f"Liste de diffusion pour contacter l'equipe {self.name} du TFJM2",
"education",
raise_error=False,
)
def delete_mailing_list(self):
"""
Drop the Sympa mailing list, if the team is empty or if the trigram changed.
"""
if self.participation.valid: # pragma: no cover
get_sympa_client().unsubscribe(
self.email, f"equipes-{self.participation.tournament.name.lower().replace(' ', '-')}", False)
else:
get_sympa_client().unsubscribe(self.email, "equipes-non-valides", False)
get_sympa_client().delete_list(f"equipe-{self.trigram}")
def save(self, *args, **kwargs):
if not self.access_code:
# if the team got created, generate the access code, create the contact mailing list
# and create a dedicated Matrix room.
self.access_code = get_random_string(6)
self.create_mailing_list()
Matrix.create_room(
visibility=RoomVisibility.private,
name=f"#équipe-{self.trigram.lower()}",
alias=f"equipe-{self.trigram.lower()}",
topic=f"Discussion de l'équipe {self.name}",
preset=RoomPreset.private_chat,
)
return super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse_lazy("participation:team_detail", args=(self.pk,))
def __str__(self):
return _("Team {name} ({trigram})").format(name=self.name, trigram=self.trigram)
class Meta:
verbose_name = _("team")
verbose_name_plural = _("teams")
ordering = ('trigram',)
indexes = [
Index(fields=("trigram", )),
]
class Tournament(models.Model):
name = models.CharField(
max_length=255,
verbose_name=_("name"),
unique=True,
)
date_start = models.DateField(
verbose_name=_("start"),
default=date.today,
)
date_end = models.DateField(
verbose_name=_("end"),
default=date.today,
)
place = models.CharField(
max_length=255,
verbose_name=_("place"),
)
max_teams = models.PositiveSmallIntegerField(
verbose_name=_("max team count"),
default=9,
)
price = models.PositiveSmallIntegerField(
verbose_name=_("price"),
default=21,
)
remote = models.BooleanField(
verbose_name=_("remote"),
default=False,
)
inscription_limit = models.DateTimeField(
verbose_name=_("limit date for registrations"),
default=timezone.now,
)
solution_limit = models.DateTimeField(
verbose_name=_("limit date to upload solutions"),
default=timezone.now,
)
solutions_draw = models.DateTimeField(
verbose_name=_("random draw for solutions"),
default=timezone.now,
)
syntheses_first_phase_limit = models.DateTimeField(
verbose_name=_("limit date to upload the syntheses for the first phase"),
default=timezone.now,
)
solutions_available_second_phase = models.DateTimeField(
verbose_name=_("date when the solutions for the second round become available"),
default=timezone.now,
)
syntheses_second_phase_limit = models.DateTimeField(
verbose_name=_("limit date to upload the syntheses for the second phase"),
default=timezone.now,
)
description = models.TextField(
verbose_name=_("description"),
blank=True,
)
organizers = models.ManyToManyField(
VolunteerRegistration,
verbose_name=_("organizers"),
related_name="organized_tournaments",
)
final = models.BooleanField(
verbose_name=_("final"),
default=False,
)
@property
def teams_email(self):
"""
:return: The mailing list to contact the team members.
"""
return f"equipes-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}"
@property
def organizers_email(self):
"""
:return: The mailing list to contact the team members.
"""
return f"organisateurs-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}"
@property
def jurys_email(self):
"""
:return: The mailing list to contact the team members.
"""
return f"jurys-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}"
def create_mailing_lists(self):
"""
Create a new Sympa mailing list to contact the team.
"""
get_sympa_client().create_list(
f"equipes-{self.name.lower().replace(' ', '-')}",
f"Equipes du tournoi de {self.name}",
"hotline", # TODO Use a custom sympa template
f"Liste de diffusion pour contacter les equipes du tournoi {self.name} du TFJM²",
"education",
raise_error=False,
)
get_sympa_client().create_list(
f"organisateurs-{self.name.lower().replace(' ', '-')}",
f"Organisateurs du tournoi de {self.name}",
"hotline", # TODO Use a custom sympa template
f"Liste de diffusion pour contacter les equipes du tournoi {self.name} du TFJM²",
"education",
raise_error=False,
)
@staticmethod
def final_tournament():
qs = Tournament.objects.filter(final=True)
if qs.exists():
return qs.get()
@property
def participations(self):
if self.final:
return Participation.objects.filter(final=True)
return self.participation_set
@property
def solutions(self):
if self.final:
return Solution.objects.filter(final_solution=True)
return Solution.objects.filter(participation__tournament=self)
@property
def syntheses(self):
if self.final:
return Synthesis.objects.filter(final_solution=True)
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):
return reverse_lazy("participation:tournament_detail", args=(self.pk,))
def __str__(self):
return self.name
class Meta:
verbose_name = _("tournament")
verbose_name_plural = _("tournaments")
indexes = [
Index(fields=("name", "date_start", "date_end", )),
]
class Participation(models.Model):
"""
The Participation model contains all data that are related to the participation:
chosen problem, validity status, solutions,...
"""
team = models.OneToOneField(
Team,
on_delete=models.CASCADE,
verbose_name=_("team"),
)
tournament = models.ForeignKey(
Tournament,
on_delete=models.SET_NULL,
null=True,
blank=True,
default=None,
verbose_name=_("tournament"),
)
valid = models.BooleanField(
null=True,
default=None,
verbose_name=_("valid team"),
help_text=_("The participation got the validation of the organizers."),
)
final = models.BooleanField(
default=False,
verbose_name=_("selected for final"),
help_text=_("The team is selected for the final tournament."),
)
def get_absolute_url(self):
return reverse_lazy("participation:participation_detail", args=(self.pk,))
def __str__(self):
return _("Participation of the team {name} ({trigram})").format(name=self.team.name, trigram=self.team.trigram)
class Meta:
verbose_name = _("participation")
verbose_name_plural = _("participations")
ordering = ('valid', 'team__trigram',)
class Pool(models.Model):
tournament = models.ForeignKey(
Tournament,
on_delete=models.CASCADE,
related_name="pools",
verbose_name=_("tournament"),
)
round = models.PositiveSmallIntegerField(
verbose_name=_("round"),
choices=[
(1, format_lazy(_("Round {round}"), round=1)),
(2, format_lazy(_("Round {round}"), round=2)),
]
)
letter = models.PositiveSmallIntegerField(
choices=[
(1, 'A'),
(2, 'B'),
(3, 'C'),
(4, 'D'),
],
verbose_name=_('letter'),
)
participations = models.ManyToManyField(
Participation,
related_name="pools",
verbose_name=_("participations"),
)
juries = models.ManyToManyField(
VolunteerRegistration,
related_name="jury_in",
verbose_name=_("juries"),
)
bbb_url = models.CharField(
max_length=255,
blank=True,
default="",
verbose_name=_("BigBlueButton URL"),
help_text=_("The link of the BBB visio for this pool."),
)
results_available = models.BooleanField(
default=False,
verbose_name=_("results available"),
help_text=_("Check this case when results become accessible to teams. "
"They stay accessible to you. Only averages are given."),
)
@property
def solutions(self):
return [passage.defended_solution for passage in self.passages.all()]
def average(self, participation):
return sum(passage.average(participation) for passage in self.passages.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):
return reverse_lazy("participation:pool_detail", args=(self.pk,))
def __str__(self):
return _("Pool of day {round} for tournament {tournament} with teams {teams}")\
.format(round=self.round,
tournament=str(self.tournament),
teams=", ".join(participation.team.trigram for participation in self.participations.all()))
class Meta:
verbose_name = _("pool")
verbose_name_plural = _("pools")
ordering = ('round', 'letter',)
class Passage(models.Model):
pool = models.ForeignKey(
Pool,
on_delete=models.CASCADE,
verbose_name=_("pool"),
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(
verbose_name=_("defended solution"),
choices=[
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, len(settings.PROBLEMS) + 1)
],
)
defender = models.ForeignKey(
Participation,
on_delete=models.PROTECT,
verbose_name=_("defender"),
related_name="+",
)
opponent = models.ForeignKey(
Participation,
on_delete=models.PROTECT,
verbose_name=_("opponent"),
related_name="+",
)
reporter = models.ForeignKey(
Participation,
on_delete=models.PROTECT,
verbose_name=_("reporter"),
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(
verbose_name=_("penalties"),
default=0,
help_text=_("Number of penalties for the defender. "
"The defender will loose a 0.5 coefficient per penalty."),
)
@property
def defended_solution(self) -> "Solution":
return Solution.objects.get(
participation=self.defender,
problem=self.solution_number,
final_solution=self.pool.tournament.final)
def avg(self, iterator) -> float:
items = [i for i in iterator if i]
return sum(items) / len(items) if items else 0
@property
def average_defender_writing(self) -> float:
return self.avg(note.defender_writing for note in self.notes.all())
@property
def average_defender_oral(self) -> float:
return self.avg(note.defender_oral for note in self.notes.all())
@property
def average_defender(self) -> float:
return self.average_defender_writing + (2 - 0.5 * self.defender_penalties) * self.average_defender_oral
@property
def average_opponent_writing(self) -> float:
return self.avg(note.opponent_writing for note in self.notes.all())
@property
def average_opponent_oral(self) -> float:
return self.avg(note.opponent_oral for note in self.notes.all())
@property
def average_opponent(self) -> float:
return self.average_opponent_writing + 2 * self.average_opponent_oral
@property
def average_reporter_writing(self) -> float:
return self.avg(note.reporter_writing for note in self.notes.all())
@property
def average_reporter_oral(self) -> float:
return self.avg(note.reporter_oral for note in self.notes.all())
@property
def average_reporter(self) -> float:
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):
return self.average_defender if participation == self.defender else self.average_opponent \
if participation == self.opponent else self.average_reporter if participation == self.reporter \
else self.average_observer if participation == self.observer else 0
def get_absolute_url(self):
return reverse_lazy("participation:passage_detail", args=(self.pk,))
def clean(self):
if self.defender not in self.pool.participations.all():
raise ValidationError(_("Team {trigram} is not registered in the pool.")
.format(trigram=self.defender.team.trigram))
if self.opponent not in self.pool.participations.all():
raise ValidationError(_("Team {trigram} is not registered in the pool.")
.format(trigram=self.opponent.team.trigram))
if self.reporter not in self.pool.participations.all():
raise ValidationError(_("Team {trigram} is not registered in the pool.")
.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()
def __str__(self):
return _("Passage of {defender} for problem {problem}")\
.format(defender=self.defender.team, problem=self.solution_number)
class Meta:
verbose_name = _("passage")
verbose_name_plural = _("passages")
ordering = ('pool', 'position',)
class Tweak(models.Model):
pool = models.ForeignKey(
Pool,
on_delete=models.CASCADE,
verbose_name=_("passage"),
)
participation = models.ForeignKey(
Participation,
on_delete=models.CASCADE,
verbose_name=_("participation"),
related_name='tweaks',
)
diff = models.IntegerField(
verbose_name=_("difference"),
help_text=_("Score to add/remove on the final score"),
)
def __str__(self):
return f"Tweak for {self.participation.team} of {self.diff} points"
class Meta:
verbose_name = _("tweak")
verbose_name_plural = _("tweaks")
def get_solution_filename(instance, filename):
return f"solutions/{instance.participation.team.trigram}_{instance.problem}" \
+ ("final" if instance.final_solution else "")
def get_synthesis_filename(instance, filename):
return f"syntheses/{instance.participation.team.trigram}_{instance.type}_{instance.passage.pk}"
class Solution(models.Model):
participation = models.ForeignKey(
Participation,
on_delete=models.CASCADE,
verbose_name=_("participation"),
related_name="solutions",
)
problem = models.PositiveSmallIntegerField(
verbose_name=_("problem"),
choices=[
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, len(settings.PROBLEMS) + 1)
],
)
final_solution = models.BooleanField(
verbose_name=_("solution for the final tournament"),
default=False,
)
file = models.FileField(
verbose_name=_("file"),
upload_to=get_solution_filename,
unique=True,
)
def __str__(self):
return _("Solution of team {team} for problem {problem}")\
.format(team=self.participation.team.name, problem=self.problem)\
+ (" " + str(_("for final")) if self.final_solution else "")
class Meta:
verbose_name = _("solution")
verbose_name_plural = _("solutions")
unique_together = (('participation', 'problem', 'final_solution', ), )
ordering = ('participation__team__trigram', 'final_solution', 'problem',)
class Synthesis(models.Model):
participation = models.ForeignKey(
Participation,
on_delete=models.CASCADE,
verbose_name=_("participation"),
)
passage = models.ForeignKey(
Passage,
on_delete=models.CASCADE,
related_name="syntheses",
verbose_name=_("passage"),
)
type = models.PositiveSmallIntegerField(
choices=[
(1, _("opponent"), ),
(2, _("reporter"), ),
]
)
file = models.FileField(
verbose_name=_("file"),
upload_to=get_synthesis_filename,
unique=True,
)
def __str__(self):
return _("Synthesis of {team} as {type} for problem {problem} of {defender}").format(
team=self.participation.team.trigram,
type=self.get_type_display(),
problem=self.passage.solution_number,
defender=self.passage.defender.team.trigram,
)
class Meta:
verbose_name = _("synthesis")
verbose_name_plural = _("syntheses")
unique_together = (('participation', 'passage', 'type', ), )
ordering = ('passage__pool__round', 'type',)
class Note(models.Model):
jury = models.ForeignKey(
VolunteerRegistration,
on_delete=models.CASCADE,
verbose_name=_("jury"),
related_name="notes",
)
passage = models.ForeignKey(
Passage,
on_delete=models.CASCADE,
verbose_name=_("passage"),
related_name="notes",
)
defender_writing = models.PositiveSmallIntegerField(
verbose_name=_("defender writing note"),
choices=[(i, i) for i in range(0, 21)],
default=0,
)
defender_oral = models.PositiveSmallIntegerField(
verbose_name=_("defender oral note"),
choices=[(i, i) for i in range(0, 17)],
default=0,
)
opponent_writing = models.PositiveSmallIntegerField(
verbose_name=_("opponent writing note"),
choices=[(i, i) for i in range(0, 10)],
default=0,
)
opponent_oral = models.PositiveSmallIntegerField(
verbose_name=_("opponent oral note"),
choices=[(i, i) for i in range(0, 11)],
default=0,
)
reporter_writing = models.PositiveSmallIntegerField(
verbose_name=_("reporter writing note"),
choices=[(i, i) for i in range(0, 10)],
default=0,
)
reporter_oral = models.PositiveSmallIntegerField(
verbose_name=_("reporter oral note"),
choices=[(i, i) for i in range(0, 11)],
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,
reporter_writing: int, reporter_oral: int, observer_oral: int = 0):
self.defender_writing = defender_writing
self.defender_oral = defender_oral
self.opponent_writing = opponent_writing
self.opponent_oral = opponent_oral
self.reporter_writing = reporter_writing
self.reporter_oral = reporter_oral
self.observer_oral = observer_oral
def get_absolute_url(self):
return reverse_lazy("participation:passage_detail", args=(self.passage.pk,))
def __str__(self):
return _("Notes of {jury} for {passage}").format(jury=self.jury, passage=self.passage)
def __bool__(self):
return any((self.defender_writing, self.defender_oral, self.opponent_writing, self.opponent_oral,
self.reporter_writing, self.reporter_oral, self.observer_oral))
class Meta:
verbose_name = _("note")
verbose_name_plural = _("notes")