# 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 F, Index, Q 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 Payment, VolunteerRegistration from tfjm.lists import get_sympa_client 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) def can_validate(self): if any(not r.email_confirmed for r in self.participants.all()): return False if self.students.count() < 4: return False if not self.coaches.exists(): return False if not self.participation.tournament: return False if any(not r.photo_authorization for r in self.participants.all()): return False if not self.motivation_letter: return False if not self.participation.tournament.remote: if any(r.under_18 and not r.health_sheet for r in self.students.all()): return False if any(r.under_18 and not r.vaccine_sheet for r in self.students.all()): return False if any(r.under_18 and not r.parental_authorization for r in self.students.all()): return False return True def important_informations(self): informations = [] if self.participation.valid is None: if not self.participation.tournament: text = _("The team {trigram} is not registered to any tournament. " "You can register the team to a tournament using this link.") url = reverse_lazy("participation:update_team", args=(self.pk,)) content = format_lazy(text, trigram=self.trigram, url=url) informations.append({ 'title': _("No tournament"), 'type': "danger", 'priority': 4, 'content': content, }) else: text = _("Registrations for the tournament of {tournament} are ending on the {date:%Y-%m-%d %H:%M}.") content = format_lazy(text, tournament=self.participation.tournament.name, date=self.participation.tournament.inscription_limit) informations.append({ 'title': _("Registrations closure"), 'type': "info", 'priority': 1, 'content': content, }) if not self.motivation_letter: text = _("The team {trigram} has not uploaded a motivation letter. " "You can upload your motivation letter using this link.") url = reverse_lazy("participation:upload_team_motivation_letter", args=(self.pk,)) content = format_lazy(text, trigram=self.trigram, url=url) informations.append({ 'title': _("No motivation letter"), 'type': "danger", 'priority': 10, 'content': content, }) nb_students = self.students.count() nb_coaches = self.coaches.count() if nb_students < 4: text = _("The team {trigram} has less than 4 students ({nb_students}). " "You can invite more students to join the team using " "the invite code {code}.") content = format_lazy(text, trigram=self.trigram, nb_students=nb_students, code=self.access_code) informations.append({ 'title': _("Not enough students"), 'type': "warning", 'priority': 7, 'content': content, }) if not nb_coaches: text = _("The team {trigram} has no coach. " "You can invite a coach to join the team using the invite code {code}.") content = format_lazy(text, trigram=self.trigram, nb_students=nb_students, code=self.access_code) informations.append({ 'title': _("No coach"), 'type': "warning", 'priority': 8, 'content': content, }) if nb_students > 6 or nb_coaches > 2: text = _("The team {trigram} has more than 6 students ({nb_students}) " "or more than 2 coaches ({nb_coaches})." "You have to restrict the number of students and coaches to 6 and 2, respectively.") content = format_lazy(text, trigram=self.trigram, nb_students=nb_students, nb_coaches=nb_coaches) informations.append({ 'title': _("Too many members"), 'type': "warning", 'priority': 7, 'content': content, }) elif nb_students >= 4 and nb_coaches >= 1: if self.can_validate(): text = _("The team {trigram} is ready to be validated. " "You can request validation on the page of your team.") url = reverse_lazy("participation:team_detail", args=(self.pk,)) content = format_lazy(text, trigram=self.trigram, url=url) informations.append({ 'title': _("Validate team"), 'type': "success", 'priority': 2, 'content': content, }) else: text = _("The team {trigram} has enough participants, but is not ready to be validated. " "Please make sure that all the participants have uploaded the required documents. " "To invite more participants, use the invite code {code}.") content = format_lazy(text, trigram=self.trigram, code=self.access_code) informations.append({ 'title': _("Validate team"), 'type': "warning", 'priority': 10, 'content': content, }) elif self.participation.valid is False: text = _("The team {trigram} has not been validated by the organizers yet. Please be patient.") content = format_lazy(text, trigram=self.trigram) informations.append({ 'title': _("Pending validation"), 'type': "warning", 'priority': 2, 'content': content, }) else: informations.extend(self.participation.important_informations()) return informations @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 self.access_code = get_random_string(6) self.create_mailing_list() 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))) 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) def important_informations(self): informations = [] missing_payments = Payment.objects.filter(registrations__in=self.team.participants.all(), valid=False) if missing_payments.exists(): text = _("

The team {trigram} has {nb_missing_payments} missing payments. Each member of the team " "must have a valid payment (or send a scholarship notification) " "to participate to the tournament.

" "

Participants that have not paid yet are: {participants}.

") content = format_lazy(text, trigram=self.team.trigram, nb_missing_payments=missing_payments.count(), participants=", ".join(", ".join(str(r) for r in p.registrations.all()) for p in missing_payments.all())) informations.append({ 'title': _("Missing payments"), 'type': "danger", 'priority': 10, 'content': content, }) if timezone.now() <= self.tournament.solution_limit: text = _("

The solutions for the tournament of {tournament} are due on the {date:%Y-%m-%d %H:%M}.

" "

You have currently sent {nb_solutions} solutions. " "We suggest to send at least {min_solutions} different solutions.

" "

You can upload your solutions on your participation page.

") url = reverse_lazy("participation:participation_detail", args=(self.pk,)) content = format_lazy(text, tournament=self.tournament.name, date=self.tournament.solution_limit, nb_solutions=self.solutions.count(), min_solutions=len(settings.PROBLEMS) - 3, url=url) informations.append({ 'title': _("Solutions due"), 'type': "info", 'priority': 1, 'content': content, }) return informations 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"), ) jury_president = models.ForeignKey( VolunteerRegistration, on_delete=models.SET_NULL, null=True, default=None, related_name="pools_presided", verbose_name=_("president of the jury"), ) 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 validate_constraints(self, exclude=None): if self.jury_president not in self.juries.all(): raise ValidationError({'jury_president': _("The president of the jury must be part of the jury.")}) return super().validate_constraints() 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 + (1.6 - 0.4 * 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 0.9 * 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 0.9 * 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, ) @property def tournament(self): return Tournament.final_tournament() if self.final_solution else self.participation.tournament 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, 21)], default=0, ) opponent_writing = models.PositiveSmallIntegerField( verbose_name=_("opponent writing note"), choices=[(i, i) for i in range(0, 11)], 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, 11)], 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,)) @property def modal_name(self): return f"updateNotes{self.pk}" def has_any_note(self): return any(self.get_all()) def __str__(self): return _("Notes of {jury} for {passage}").format(jury=self.jury, passage=self.passage) class Meta: verbose_name = _("note") verbose_name_plural = _("notes")