# Copyright (C) 2020 by Animath # SPDX-License-Identifier: GPL-3.0-or-later import asyncio 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 _ import gspread from django.views.debug import ExceptionReporter from gspread.utils import a1_range_to_grid_range, MergeType 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, ) notes_sheet_id = models.CharField( max_length=64, blank=True, default="", verbose_name=_("Google Sheet ID"), ) @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 create_spreadsheet(self): if self.notes_sheet_id: return self.notes_sheet_id gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT) spreadsheet = gc.create(f"Feuille de notes - {self.name}", folder_id=settings.NOTES_DRIVE_FOLDER_ID) self.notes_sheet_id = spreadsheet.id self.save() 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 short_name(self): return f"{self.get_letter_display()}{self.round}" @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 update_spreadsheet(self): # noqa: C901 gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT) spreadsheet = gc.open_by_key(self.tournament.notes_sheet_id) worksheets = spreadsheet.worksheets() if f"Poule {self.short_name}" not in [ws.title for ws in worksheets]: worksheet = spreadsheet.add_worksheet(f"Poule {self.short_name}", 100, 32) else: worksheet = spreadsheet.worksheet(f"Poule {self.short_name}") if any(ws.title == "Sheet1" for ws in worksheets): spreadsheet.del_worksheet(spreadsheet.worksheet("Sheet1")) pool_size = self.participations.count() passage_width = 7 if pool_size == 4 else 6 passages = self.passages.all() header = [ sum(([f"Problème {passage.solution_number}"] + (passage_width - 1) * [""] for passage in passages), start=["Problème", ""]), sum((["Défenseur⋅se", "", "Opposant⋅e", "", "Rapporteur⋅e", ""] + (["Observateur⋅rice"] if pool_size == 4 else []) for _passage in passages), start=["Rôle", ""]), sum((["Écrit (/20)", "Oral (/20)", "Écrit (/10)", "Oral (/10)", "Écrit (/10)", "Oral (/10)"] + (["Oral (± 4)"] if pool_size == 4 else []) for _passage in passages), start=["Juré⋅e", ""]), ] notes = [] for jury in self.juries.all(): line = [str(jury), jury.id] for passage in passages: note = passage.notes.filter(jury=jury).first() line.extend([note.defender_writing, note.defender_oral, note.opponent_writing, note.opponent_oral, note.reporter_writing, note.reporter_oral]) if pool_size == 4: line.append(note.observer_oral) notes.append(line) 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) average = ["Moyenne", ""] coeffs = sum(([1, 1.6 - 0.4 * passage.defender_penalties, 0.9, 2, 0.9, 1] + ([1] if pool_size == 4 else []) for passage in passages), start=["Coefficient", ""]) subtotal = ["Sous-total", ""] footer = [average, coeffs, subtotal, 32 * [""]] min_row = 4 max_row = min_row + self.juries.count() - 1 min_column = 3 for i, passage in enumerate(passages): for j, note in enumerate(passage.averages): column = getcol(min_column + i * passage_width + j) average.append(f"=MOYENNE.SI(${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})") def_w_col = getcol(min_column + passage_width * i) def_o_col = getcol(min_column + passage_width * i + 1) subtotal.extend([f"={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}", ""]) opp_w_col = getcol(min_column + passage_width * i + 2) opp_o_col = getcol(min_column + passage_width * i + 3) subtotal.extend([f"={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}", ""]) rep_w_col = getcol(min_column + passage_width * i + 4) rep_o_col = getcol(min_column + passage_width * i + 5) subtotal.extend([f"={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}", ""]) if pool_size == 4: obs_col = getcol(min_column + passage_width * i + 6) subtotal.append(f"={obs_col}{max_row + 1} * {obs_col}{max_row + 2}") ranking = [ ["Équipe", "", "Problème", "Total", "Rang"], ] 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], ] for passage in passages: participation = passage.defender passage_line = passage_matrix[passage.position - 1] formula = "=" 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) ranking.append([f"{participation.team.name} ({participation.team.trigram})", "", f"=${getcol(3 + (passage.position - 1) * passage_width)}$1", formula, f"=RANG(D{max_row + 5 + passage.position}; " f"D${max_row + 6}:D${max_row + 5 + pool_size})"]) all_values = header + notes + footer + ranking worksheet.update("A1:AF", all_values, raw=False) format_requests = [] # Merge cells merge_cells = ["A1:B1", "A2:B2", "A3:B3"] for i, passage in enumerate(passages): merge_cells.append(f"{getcol(3 + i * passage_width)}1:{getcol(2 + passage_width + i * passage_width)}1") merge_cells.append(f"{getcol(3 + i * passage_width)}2:{getcol(4 + i * passage_width)}2") merge_cells.append(f"{getcol(5 + i * passage_width)}2:{getcol(6 + i * passage_width)}2") merge_cells.append(f"{getcol(7 + i * passage_width)}2:{getcol(8 + i * passage_width)}2") merge_cells.append(f"{getcol(3 + i * passage_width)}{max_row + 3}" f":{getcol(4 + i * passage_width)}{max_row + 3}") merge_cells.append(f"{getcol(5 + i * passage_width)}{max_row + 3}" f":{getcol(6 + i * passage_width)}{max_row + 3}") merge_cells.append(f"{getcol(7 + i * passage_width)}{max_row + 3}" f":{getcol(8 + i * passage_width)}{max_row + 3}") merge_cells.append(f"A{max_row + 1}:B{max_row + 1}") merge_cells.append(f"A{max_row + 2}:B{max_row + 2}") merge_cells.append(f"A{max_row + 3}:B{max_row + 3}") for i in range(pool_size + 1): merge_cells.append(f"A{max_row + 5 + i}:B{max_row + 5 + i}") format_requests.append({"unmergeCells": {"range": a1_range_to_grid_range("A1:AF", worksheet.id)}}) for name in merge_cells: grid_range = a1_range_to_grid_range(name, worksheet.id) format_requests.append({"mergeCells": {"mergeType": MergeType.merge_all, "range": grid_range}}) # Make titles bold bold_ranges = ["A1:AF3", f"A{max_row + 1}:B{max_row + 3}", f"A{max_row + 5}:E{max_row + 5}"] for bold_range in bold_ranges: format_requests.append({ "repeatCell": { "range": a1_range_to_grid_range(bold_range, worksheet.id), "cell": {"userEnteredFormat": {"textFormat": {"bold": True}}}, "fields": "userEnteredFormat(textFormat)", } }) # Set background color for headers and footers bg_colors = [(f"A1:{getcol(2 + pool_size * passage_width)}3", (0.8, 0.8, 0.8)), (f"A{min_row}:B{max_row}", (0.95, 0.95, 0.95)), (f"A{max_row + 1}:B{max_row + 3}", (0.8, 0.8, 0.8)), (f"C{max_row + 1}:{getcol(2 + pool_size * passage_width)}{max_row + 3}", (0.9, 0.9, 0.9)), (f"A{max_row + 5}:E{max_row + 5}", (0.8, 0.8, 0.8)), (f"A{max_row + 6}:E{max_row + 5 + pool_size}", (0.9, 0.9, 0.9)),] for bg_range, bg_color in bg_colors: r, g, b = bg_color format_requests.append({ "repeatCell": { "range": a1_range_to_grid_range(bg_range, worksheet.id), "cell": {"userEnteredFormat": {"backgroundColor": {"red": r, "green": g, "blue": b}}}, "fields": "userEnteredFormat(backgroundColor)", } }) # Freeze 2 first columns format_requests.append({ "updateSheetProperties": { "properties": { "sheetId": worksheet.id, "gridProperties": { "frozenRowCount": 0, "frozenColumnCount": 2, }, }, "fields": "gridProperties/frozenRowCount,gridProperties/frozenColumnCount", } }) # Set the width of the columns column_widths = [("A", 250), ("B", 30)] for passage in passages: column_widths.append((f"{getcol(3 + passage_width * (passage.position - 1))}" f":{getcol(8 + passage_width * (passage.position - 1))}", 75)) if pool_size == 4: column_widths.append((getcol(9 + passage_width * (passage.position - 1)), 120)) for column, width in column_widths: grid_range = a1_range_to_grid_range(column, worksheet.id) format_requests.append({ "updateDimensionProperties": { "range": { "sheetId": worksheet.id, "dimension": "COLUMNS", "startIndex": grid_range['startColumnIndex'], "endIndex": grid_range['endColumnIndex'], }, "properties": { "pixelSize": width, }, "fields": "pixelSize", } }) # Hide second column (Jury ID) format_requests.append({ "updateDimensionProperties": { "range": { "sheetId": worksheet.id, "dimension": "COLUMNS", "startIndex": 1, "endIndex": 2, }, "properties": { "hiddenByUser": True, }, "fields": "hiddenByUser", } }) # Define borders border_ranges = [(f"A1:{getcol(2 + pool_size * passage_width)}{max_row + 3}", "1111"), (f"A{max_row + 5}:E{max_row + pool_size + 5}", "1111"), (f"A1:B{max_row + 3}", "1113"), (f"C1:{getcol(2 + (pool_size - 1) * passage_width)}1", "1113")] for i in range(pool_size - 1): border_ranges.append((f"{getcol(2 + (i + 1) * passage_width)}2" f":{getcol(2 + (i + 1) * passage_width)}{max_row + 3}", "1113")) sides_names = ['top', 'bottom', 'left', 'right'] styles = ["NONE", "SOLID", "SOLID_MEDIUM", "SOLID_THICK", "DOUBLE"] for border_range, sides in border_ranges: borders = {} for side_name, side in zip(sides_names, sides): borders[side_name] = {"style": styles[int(side)]} format_requests.append({ "repeatCell": { "range": a1_range_to_grid_range(border_range, worksheet.id), "cell": { "userEnteredFormat": { "borders": borders, "horizontalAlignment": "CENTER", }, }, "fields": "userEnteredFormat(borders,horizontalAlignment)", } }) # Remove old protected ranges for protected_range in spreadsheet.list_protected_ranges(worksheet.id): format_requests.append({ "deleteProtectedRange": { "protectedRangeId": protected_range["protectedRangeId"], } }) # Protect the header, the juries list, the footer and the ranking protected_ranges = ["A1:AF3", f"A{min_row}:B{max_row}", f"A{max_row + 1}:AF{max_row + 5 + pool_size}"] for protected_range in protected_ranges: format_requests.append({ "addProtectedRange": { "protectedRange": { "range": a1_range_to_grid_range(protected_range, worksheet.id), "description": "Structure du tableur à ne pas modifier " "pour une meilleure prise en charge automatisée", "warningOnly": True, }, } }) body = {"requests": format_requests} worksheet.client.batch_update(spreadsheet.id, body) 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 update_spreadsheet(self): gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT) passage = Passage.objects.prefetch_related('pool__tournament', 'pool__participations').get(pk=self.passage.pk) spreadsheet_id = passage.pool.tournament.notes_sheet_id spreadsheet = gc.open_by_key(spreadsheet_id) worksheet = spreadsheet.worksheet(f"Poule {passage.pool.short_name}") jury_id_cell = worksheet.find(str(self.jury_id), in_column=2) if not jury_id_cell: raise ValueError("The jury ID cell was not found in the spreadsheet.") jury_row = jury_id_cell.row passage_width = 7 if passage.pool.participations.count() == 4 else 6 def getcol(number: int) -> str: if number == 0: return '' return getcol((number - 1) // 26) + chr(65 + (number - 1) % 26) min_col = getcol(3 + (self.passage.position - 1) * passage_width) max_col = getcol(3 + self.passage.position * passage_width - 1) worksheet.update([list(self.get_all())], f"{min_col}{jury_row}:{max_col}{jury_row}") 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 save(self, *args, **kwargs): super().save(*args, **kwargs) self.update_spreadsheet() 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")