plateforme-tfjm2/participation/models.py

730 lines
22 KiB
Python
Raw Normal View History

2020-12-27 10:49:54 +00:00
# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
2021-01-18 21:28:43 +00:00
from datetime import date
2020-12-27 10:49:54 +00:00
import os
from django.conf import settings
2021-01-14 13:22:45 +00:00
from django.core.exceptions import ValidationError
2020-12-27 10:49:54 +00:00
from django.core.validators import 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
2020-12-27 10:49:54 +00:00
from django.utils.translation import gettext_lazy as _
2020-12-28 18:19:01 +00:00
from registration.models import VolunteerRegistration
from tfjm.lists import get_sympa_client
from tfjm.matrix import Matrix, RoomPreset, RoomVisibility
2020-12-27 10:49:54 +00:00
def get_motivation_letter_filename(instance, filename):
return f"authorization/motivation_letters/motivation_letter_{instance.trigram}"
2020-12-27 10:49:54 +00:00
class Team(models.Model):
"""
2021-01-18 14:52:09 +00:00
The Team model represents a real team that participates to the TFJM².
2020-12-27 10:49:54 +00:00
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,
2023-02-19 18:49:03 +00:00
validators=[
RegexValidator(r"^[A-Z]{3}$"),
RegexValidator(fr"^(?!{'|'.join(f'{t}$' for t in settings.FORBIDDEN_TRIGRAMS)})",
message=_("This trigram is forbidden.")),
],
2020-12-27 10:49:54 +00:00
)
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="",
)
2020-12-28 17:52:50 +00:00
@property
def students(self):
return self.participants.filter(studentregistration__isnull=False)
@property
def coaches(self):
2020-12-28 17:52:50 +00:00
return self.participants.filter(coachregistration__isnull=False)
2020-12-27 10:49:54 +00:00
@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()}",
2021-01-16 21:29:10 +00:00
f"Equipe {self.name} ({self.trigram})",
2020-12-27 10:49:54 +00:00
"hotline", # TODO Use a custom sympa template
2021-01-16 21:29:10 +00:00
f"Liste de diffusion pour contacter l'equipe {self.name} du TFJM2",
2020-12-27 10:49:54 +00:00
"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
2021-01-16 21:29:10 +00:00
get_sympa_client().unsubscribe(
self.email, f"equipes-{self.participation.tournament.name.lower().replace(' ', '-')}", False)
2020-12-27 10:49:54 +00:00
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',)
2020-12-27 10:49:54 +00:00
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"),
2021-01-18 21:28:43 +00:00
default=date.today,
)
date_end = models.DateField(
2020-12-30 11:13:05 +00:00
verbose_name=_("end"),
2021-01-18 21:28:43 +00:00
default=date.today,
)
place = models.CharField(
max_length=255,
2020-12-31 11:13:42 +00:00
verbose_name=_("place"),
)
max_teams = models.PositiveSmallIntegerField(
verbose_name=_("max team count"),
default=9,
)
2021-01-01 11:11:09 +00:00
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,
)
2021-01-01 11:11:09 +00:00
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,
)
2021-01-01 11:11:09 +00:00
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,
)
2021-01-16 21:29:10 +00:00
@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.
"""
2021-01-18 01:07:31 +00:00
return f"jurys-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}"
2021-01-16 21:29:10 +00:00
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()
2020-12-28 16:49:59 +00:00
@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)))
2020-12-31 11:23:09 +00:00
def get_absolute_url(self):
return reverse_lazy("participation:tournament_detail", args=(self.pk,))
2020-12-28 18:19:01 +00:00
def __str__(self):
2021-01-01 23:06:58 +00:00
return self.name
2020-12-28 18:19:01 +00:00
class Meta:
verbose_name = _("tournament")
verbose_name_plural = _("tournaments")
indexes = [
Index(fields=("name", "date_start", "date_end", )),
]
2020-12-27 10:49:54 +00:00
class Participation(models.Model):
"""
The Participation model contains all data that are related to the participation:
2021-01-12 14:42:32 +00:00
chosen problem, validity status, solutions,...
2020-12-27 10:49:54 +00:00
"""
team = models.OneToOneField(
Team,
on_delete=models.CASCADE,
verbose_name=_("team"),
)
tournament = models.ForeignKey(
Tournament,
on_delete=models.SET_NULL,
2020-12-27 10:49:54 +00:00
null=True,
blank=True,
2020-12-27 10:49:54 +00:00
default=None,
verbose_name=_("tournament"),
2020-12-27 10:49:54 +00:00
)
valid = models.BooleanField(
null=True,
default=None,
verbose_name=_("valid"),
2021-01-01 16:07:28 +00:00
help_text=_("The participation got the validation of the organizers."),
2020-12-27 10:49:54 +00:00
)
2021-01-12 16:24:46 +00:00
final = models.BooleanField(
default=False,
verbose_name=_("selected for final"),
help_text=_("The team is selected for the final tournament."),
)
2020-12-27 10:49:54 +00:00
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")
2020-12-28 16:49:59 +00:00
class Pool(models.Model):
tournament = models.ForeignKey(
Tournament,
on_delete=models.CASCADE,
related_name="pools",
verbose_name=_("tournament"),
)
round = models.PositiveSmallIntegerField(
verbose_name=_("round"),
2021-01-13 16:00:50 +00:00
choices=[
(1, format_lazy(_("Round {round}"), round=1)),
(2, format_lazy(_("Round {round}"), round=2)),
]
2020-12-28 16:49:59 +00:00
)
letter = models.PositiveSmallIntegerField(
choices=[
(1, 'A'),
(2, 'B'),
(3, 'C'),
(4, 'D'),
],
verbose_name=_('letter'),
)
2020-12-28 16:49:59 +00:00
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,
2021-01-21 16:55:20 +00:00
blank=True,
default="",
verbose_name=_("BigBlueButton URL"),
help_text=_("The link of the BBB visio for this pool."),
2021-01-21 16:55:20 +00:00
)
2021-04-10 07:59:04 +00:00
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."),
)
2020-12-28 16:49:59 +00:00
@property
def solutions(self):
return Solution.objects.filter(participation__in=self.participations, final_solution=self.tournament.final)
2021-01-14 16:54:47 +00:00
def average(self, participation):
2022-05-15 14:43:07 +00:00
return sum(passage.average(participation) for passage in self.passages.all()) \
+ sum(tweak.diff for tweak in participation.tweaks.filter(pool=self).all())
2021-01-14 16:54:47 +00:00
2021-01-13 16:00:50 +00:00
def get_absolute_url(self):
return reverse_lazy("participation:pool_detail", args=(self.pk,))
2020-12-28 18:19:01 +00:00
def __str__(self):
2021-04-29 13:47:46 +00:00
return _("Pool of day {round} for tournament {tournament} with teams {teams}")\
2021-01-13 15:22:26 +00:00
.format(round=self.round,
tournament=str(self.tournament),
teams=", ".join(participation.team.trigram for participation in self.participations.all()))
2020-12-28 18:19:01 +00:00
2020-12-28 16:49:59 +00:00
class Meta:
verbose_name = _("pool")
verbose_name_plural = _("pools")
ordering = ('round', 'letter',)
2020-12-28 16:49:59 +00:00
2021-01-14 13:22:45 +00:00
class Passage(models.Model):
pool = models.ForeignKey(
Pool,
on_delete=models.CASCADE,
verbose_name=_("pool"),
related_name="passages",
)
2021-01-14 14:59:11 +00:00
solution_number = models.PositiveSmallIntegerField(
verbose_name=_("defended solution"),
choices=[
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, settings.PROBLEM_COUNT + 1)
],
)
2021-01-14 13:22:45 +00:00
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="+",
)
2021-04-03 19:59:06 +00:00
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."),
)
2021-01-14 14:59:11 +00:00
@property
def defended_solution(self) -> "Solution":
return Solution.objects.get(
participation=self.defender,
problem=self.solution_number,
final_solution=self.pool.tournament.final)
2021-04-03 19:59:06 +00:00
def avg(self, iterator) -> float:
2021-01-14 17:21:22 +00:00
items = [i for i in iterator if i]
return sum(items) / len(items) if items else 0
2021-01-14 16:54:47 +00:00
@property
2021-04-03 19:59:06 +00:00
def average_defender_writing(self) -> float:
2021-01-14 16:54:47 +00:00
return self.avg(note.defender_writing for note in self.notes.all())
@property
2021-04-03 19:59:06 +00:00
def average_defender_oral(self) -> float:
2021-01-14 16:54:47 +00:00
return self.avg(note.defender_oral for note in self.notes.all())
@property
2021-04-03 19:59:06 +00:00
def average_defender(self) -> float:
return self.average_defender_writing + (2 - 0.5 * self.defender_penalties) * self.average_defender_oral
2021-01-14 16:54:47 +00:00
@property
2021-04-03 19:59:06 +00:00
def average_opponent_writing(self) -> float:
2021-01-14 16:54:47 +00:00
return self.avg(note.opponent_writing for note in self.notes.all())
@property
2021-04-03 19:59:06 +00:00
def average_opponent_oral(self) -> float:
2021-01-14 16:54:47 +00:00
return self.avg(note.opponent_oral for note in self.notes.all())
@property
2021-04-03 19:59:06 +00:00
def average_opponent(self) -> float:
2021-01-14 17:43:53 +00:00
return self.average_opponent_writing + 2 * self.average_opponent_oral
2021-01-14 16:54:47 +00:00
@property
2021-04-03 19:59:06 +00:00
def average_reporter_writing(self) -> float:
2021-01-14 16:54:47 +00:00
return self.avg(note.reporter_writing for note in self.notes.all())
@property
2021-04-03 19:59:06 +00:00
def average_reporter_oral(self) -> float:
2021-01-14 16:54:47 +00:00
return self.avg(note.reporter_oral for note in self.notes.all())
@property
2021-04-03 19:59:06 +00:00
def average_reporter(self) -> float:
2021-01-14 16:54:47 +00:00
return self.average_reporter_writing + self.average_reporter_oral
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 0
2021-01-14 14:59:11 +00:00
def get_absolute_url(self):
return reverse_lazy("participation:passage_detail", args=(self.pk,))
2021-01-14 13:22:45 +00:00
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))
return super().clean()
2021-01-14 14:59:11 +00:00
def __str__(self):
return _("Passage of {defender} for problem {problem}")\
.format(defender=self.defender.team, problem=self.solution_number)
2021-01-14 13:22:45 +00:00
class Meta:
verbose_name = _("passage")
verbose_name_plural = _("passages")
2022-05-15 14:43:07 +00:00
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"
2022-05-15 14:43:07 +00:00
class Meta:
verbose_name = _("tweak")
verbose_name_plural = _("tweaks")
2021-01-12 16:51:55 +00:00
def get_solution_filename(instance, filename):
return f"solutions/{instance.participation.team.trigram}_{instance.problem}" \
+ ("final" if instance.final_solution else "")
2021-01-14 16:26:08 +00:00
def get_synthesis_filename(instance, filename):
return f"syntheses/{instance.participation.team.trigram}_{instance.type}_{instance.passage.pk}"
2021-01-12 16:51:55 +00:00
class Solution(models.Model):
participation = models.ForeignKey(
Participation,
on_delete=models.CASCADE,
verbose_name=_("participation"),
related_name="solutions",
2020-12-27 10:49:54 +00:00
)
problem = models.PositiveSmallIntegerField(
verbose_name=_("problem"),
choices=[
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, settings.PROBLEM_COUNT + 1)
],
2020-12-27 10:49:54 +00:00
)
final_solution = models.BooleanField(
verbose_name=_("solution for the final tournament"),
default=False,
)
2020-12-27 10:49:54 +00:00
file = models.FileField(
verbose_name=_("file"),
2021-01-12 16:51:55 +00:00
upload_to=get_solution_filename,
unique=True,
)
2020-12-27 10:49:54 +00:00
2020-12-28 18:19:01 +00:00
def __str__(self):
2021-01-12 16:51:55 +00:00
return _("Solution of team {team} for problem {problem}")\
2021-04-29 13:46:38 +00:00
.format(team=self.participation.team.name, problem=self.problem)\
2021-05-11 14:56:44 +00:00
+ (" " + str(_("for final")) if self.final_solution else "")
2020-12-28 18:19:01 +00:00
2020-12-27 10:49:54 +00:00
class Meta:
verbose_name = _("solution")
verbose_name_plural = _("solutions")
2020-12-28 17:52:50 +00:00
unique_together = (('participation', 'problem', 'final_solution', ), )
2021-04-29 13:46:38 +00:00
ordering = ('participation__team__trigram', 'final_solution', 'problem',)
2020-12-27 10:49:54 +00:00
class Synthesis(models.Model):
2020-12-27 10:49:54 +00:00
participation = models.ForeignKey(
Participation,
on_delete=models.CASCADE,
verbose_name=_("participation"),
)
2021-01-14 13:22:45 +00:00
passage = models.ForeignKey(
Passage,
on_delete=models.CASCADE,
2020-12-28 16:49:59 +00:00
related_name="syntheses",
2021-01-14 13:22:45 +00:00
verbose_name=_("passage"),
2020-12-27 10:49:54 +00:00
)
type = models.PositiveSmallIntegerField(
choices=[
(1, _("opponent"), ),
(2, _("reporter"), ),
]
2020-12-27 10:49:54 +00:00
)
file = models.FileField(
verbose_name=_("file"),
2021-01-14 16:26:08 +00:00
upload_to=get_synthesis_filename,
unique=True,
2020-12-27 10:49:54 +00:00
)
2021-01-14 16:26:08 +00:00
def __str__(self):
2021-04-10 08:02:49 +00:00
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,
)
2021-01-14 16:26:08 +00:00
2020-12-27 10:49:54 +00:00
class Meta:
verbose_name = _("synthesis")
verbose_name_plural = _("syntheses")
2021-01-14 13:22:45 +00:00
unique_together = (('participation', 'passage', 'type', ), )
ordering = ('passage__pool__round', 'type',)
2021-01-14 16:54:47 +00:00
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,
)
2022-05-15 10:23:17 +00:00
def set_all(self, defender_writing: int, defender_oral: int, opponent_writing: int, opponent_oral: int,
reporter_writing: int, reporter_oral: int):
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
2021-01-14 17:21:22 +00:00
def get_absolute_url(self):
return reverse_lazy("participation:passage_detail", args=(self.passage.pk,))
2021-01-14 16:54:47 +00:00
def __str__(self):
return _("Notes of {jury} for {passage}").format(jury=self.jury, passage=self.passage)
2021-01-14 17:43:53 +00:00
def __bool__(self):
return any((self.defender_writing, self.defender_oral, self.opponent_writing, self.opponent_oral,
self.reporter_writing, self.reporter_oral))
2021-01-14 16:54:47 +00:00
class Meta:
verbose_name = _("note")
verbose_name_plural = _("notes")