# Copyright (C) 2020 by Animath # SPDX-License-Identifier: GPL-3.0-or-later from datetime import date, timedelta import math 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, Q from django.template.defaultfilters import slugify from django.urls import reverse_lazy from django.utils import timezone, translation from django.utils.crypto import get_random_string from django.utils.text import format_lazy from django.utils.timezone import localtime from django.utils.translation import gettext_lazy as _ import gspread 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 tournament. This only includes the registration detail. """ name = models.CharField( max_length=255, verbose_name=_("name"), unique=True, ) trigram = models.CharField( max_length=4, verbose_name=_("code"), help_text=format_lazy(_("The code must be composed of {nb_letters} uppercase letters."), nb_letters=settings.TEAM_CODE_LENGTH), unique=True, validators=[ RegexValidator("^[A-Z]{3}[A-Z]*$"), RegexValidator(fr"^(?!{'|'.join(f'{t}$' for t in settings.FORBIDDEN_TRIGRAMS)})", message=_("This team code 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 settings.MOTIVATION_LETTER_REQUIRED and not self.motivation_letter: return False if not self.participation.tournament.remote: if settings.HEALTH_SHEET_REQUIRED and any(r.under_18 and not r.health_sheet for r in self.students.all()): return False if settings.VACCINE_SHEET_REQUIRED and 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=localtime(self.participation.tournament.inscription_limit)) informations.append({ 'title': _("Registrations closure"), 'type': "info", 'priority': 1, 'content': content, }) if settings.MOTIVATION_LETTER_REQUIRED and 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-{slugify(self.trigram)}@{settings.SYMPA_HOST}" def create_mailing_list(self): """ Create a new Sympa mailing list to contact the team. """ get_sympa_client().create_list( f"equipe-{slugify(self.trigram)}", 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-{slugify(self.participation.tournament.name)}", False) else: get_sympa_client().unsubscribe(self.email, "equipes-non-valides", False) get_sympa_client().delete_list(f"equipe-{self.trigram}") def clean(self): if self.trigram and len(self.trigram) != settings.TEAM_CODE_LENGTH: raise ValidationError({'trigram': _("The team code must be composed of {nb_letters} uppercase letters.")}, params={'nb_letters': settings.TEAM_CODE_LENGTH}) return super().clean() 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) if settings.ML_MANAGEMENT: 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, ) unified_registration = models.BooleanField( verbose_name=_("unified registration"), default=False, ) 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, ) date_first_phase = models.DateField( verbose_name=_("first phase date"), default=date.today, ) reviews_first_phase_limit = models.DateTimeField( verbose_name=_("limit date to upload the written reviews for the first phase"), default=timezone.now, ) date_second_phase = models.DateField( verbose_name=_("first second date"), default=date.today, ) solutions_available_second_phase = models.BooleanField( verbose_name=_("check this case when solutions for the second round become available"), default=False, ) reviews_second_phase_limit = models.DateTimeField( verbose_name=_("limit date to upload the written reviews for the second phase"), default=timezone.now, ) date_third_phase = models.DateField( verbose_name=_("third phase date"), default=date.today, ) solutions_available_third_phase = models.BooleanField( verbose_name=_("check this case when solutions for the third round become available"), default=False, ) reviews_third_phase_limit = models.DateTimeField( verbose_name=_("limit date to upload the written reviews for the third 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-{slugify(self.name)}@{settings.SYMPA_HOST}" @property def organizers_email(self): """ :return: The mailing list to contact the team members. """ return f"organisateurs-{slugify(self.name)}@{settings.SYMPA_HOST}" @property def jurys_email(self): """ :return: The mailing list to contact the team members. """ return f"jurys-{slugify(self.name)}@{settings.SYMPA_HOST}" def create_mailing_lists(self): """ Create a new Sympa mailing list to contact the team. """ get_sympa_client().create_list( f"equipes-{slugify(self.name)}", 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-{slugify(self.name)}", 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 written_reviews(self): if self.final: return WrittenReview.objects.filter(final_solution=True) return WrittenReview.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(_('Notation sheet') + f" - {self.name}", folder_id=settings.NOTES_DRIVE_FOLDER_ID) spreadsheet.update_locale("fr_FR") spreadsheet.share(None, "anyone", "writer", with_link=True) self.notes_sheet_id = spreadsheet.id self.save() def update_ranking_spreadsheet(self): # noqa: C901 translation.activate(settings.PREFERRED_LANGUAGE_CODE) gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT) spreadsheet = gc.open_by_key(self.notes_sheet_id) worksheets = spreadsheet.worksheets() if str(_("Final ranking")) not in [ws.title for ws in worksheets]: worksheet = spreadsheet.add_worksheet(str(_("Final ranking")), 30, 10) else: worksheet = spreadsheet.worksheet(str(_("Final ranking"))) if worksheet.index != self.pools.count(): worksheet.update_index(self.pools.count()) header = [[str(_("Team")), str(_("Scores day 1")), str(_("Tweaks day 1")), str(_("Scores day 2")), str(_("Tweaks day 2"))] + ([str(_("Total D1 + D2")), str(_("Scores day 3")), str(_("Tweaks day 3"))] if settings.NB_ROUNDS >= 3 else []) + [str(_("Total")), str(_("Rank"))]] lines = [] participations = self.participations.filter(pools__round=1, pools__tournament=self).distinct().all() total_col, rank_col = ("F", "G") if settings.NB_ROUNDS == 2 else ("I", "J") for i, participation in enumerate(participations): line = [f"{participation.team.name} ({participation.team.trigram})"] lines.append(line) passage1 = Passage.objects.get(pool__tournament=self, pool__round=1, reporter=participation) pool1 = passage1.pool if pool1.participations.count() != 5: position1 = passage1.position else: position1 = (passage1.position - 1) * 2 + pool1.room tweak1_qs = Tweak.objects.filter(pool=pool1, participation=participation) tweak1 = tweak1_qs.get() if tweak1_qs.exists() else None line.append(f"=SIERREUR('{_('Pool')} {pool1.short_name}'!$D{pool1.juries.count() + 10 + position1}; 0)") line.append(tweak1.diff if tweak1 else 0) if Passage.objects.filter(pool__tournament=self, pool__round=2, reporter=participation).exists(): passage2 = Passage.objects.get(pool__tournament=self, pool__round=2, reporter=participation) pool2 = passage2.pool if pool2.participations.count() != 5: position2 = passage2.position else: position2 = (passage2.position - 1) * 2 + pool2.room tweak2_qs = Tweak.objects.filter(pool=pool2, participation=participation) tweak2 = tweak2_qs.get() if tweak2_qs.exists() else None line.append( f"=SIERREUR('{_('Pool')} {pool2.short_name}'!$D{pool2.juries.count() + 10 + position2}; 0)") line.append(tweak2.diff if tweak2 else 0) if settings.NB_ROUNDS >= 3: line.append(f"=$B{i + 2} + $C{i + 2} + $D{i + 2} + E{i + 2}") if Passage.objects.filter(pool__tournament=self, pool__round=3, reporter=participation).exists(): passage3 = Passage.objects.get(pool__tournament=self, pool__round=3, reporter=participation) pool3 = passage3.pool if pool3.participations.count() != 5: position3 = passage3.position else: position3 = (passage3.position - 1) * 2 + pool3.room tweak3_qs = Tweak.objects.filter(pool=pool3, participation=participation) tweak3 = tweak3_qs.get() if tweak3_qs.exists() else None line.append( f"=SIERREUR('{_('Pool')} {pool3.short_name}'!$D{pool3.juries.count() + 10 + position3}; 0)") line.append(tweak3.diff if tweak3 else 0) else: line.append(0) line.append(0) else: # There is no second pool yet line.append(0) line.append(0) if settings.NB_ROUNDS >= 3: line.append(f"=$B{i + 2} + $C{i + 2} + $D{i + 2} + E{i + 2}") line.append(0) line.append(0) line.append(f"=$B{i + 2} + $C{i + 2} + $D{i + 2} + E{i + 2}" + (f" + (PI() - 2) * $G{i + 2} + $H{i + 2}" if settings.NB_ROUNDS >= 3 else "")) line.append(f"=RANG(${total_col}{i + 2}; ${total_col}$2:${total_col}${participations.count() + 1})") final_ranking = [["", "", "", ""], ["", "", "", ""], [str(_("Team")), str(_("Score")), str(_("Rank")), str(_("Mention"))], [f"=SORT($A$2:$A${participations.count() + 1}; " f"${total_col}$2:${total_col}${participations.count() + 1}; FALSE)", f"=SORT(${total_col}$2:${total_col}${participations.count() + 1}; " f"${total_col}$2:${total_col}${participations.count() + 1}; FALSE)", f"=SORT(${rank_col}$2:${rank_col}${participations.count() + 1}; " f"${total_col}$2:${total_col}${participations.count() + 1}; FALSE)", ]] final_ranking += [["", "", ""] for _i in range(participations.count() - 1)] notes = dict() for participation in self.participations.filter(valid=True).all(): note = sum(pool.average(participation) for pool in self.pools.filter(participations=participation).all()) if note: notes[participation] = note sorted_notes = sorted(notes.items(), key=lambda x: x[1], reverse=True) for i, (participation, _note) in enumerate(sorted_notes): final_ranking[i + 3].append(participation.mention if not self.final else participation.mention_final) data = header + lines + final_ranking worksheet.update(data, f"A1:{rank_col}{2 * participations.count() + 4}", raw=False) format_requests = [] # Set the width of the columns column_widths = [("A", 350), ("B", 150), ("C", 150), ("D", 150), ("E", 150), ("F", 150), ("G", 150), ("H", 150), ("I", 150), ("J", 150)] 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", } }) # Set borders border_ranges = [("A1:Z", "0000"), (f"A1:{rank_col}{participations.count() + 1}", "1111"), (f"A{participations.count() + 4}:D{2 * participations.count() + 4}", "1111")] 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)", } }) # Make titles bold bold_ranges = [("A1:Z", False), (f"A1:{rank_col}1", True), (f"A{participations.count() + 4}:D{participations.count() + 4}", True)] for bold_range, bold in bold_ranges: format_requests.append({ "repeatCell": { "range": a1_range_to_grid_range(bold_range, worksheet.id), "cell": {"userEnteredFormat": {"textFormat": {"bold": bold}}}, "fields": "userEnteredFormat(textFormat)", } }) # Set background color for headers and footers bg_colors = [("A1:Z", (1, 1, 1)), (f"A1:{rank_col}1", (0.8, 0.8, 0.8)), (f"A2:B{participations.count() + 1}", (0.9, 0.9, 0.9)), (f"C2:C{participations.count() + 1}", (1, 1, 1)), (f"D2:D{participations.count() + 1}", (0.9, 0.9, 0.9)), (f"E2:E{participations.count() + 1}", (1, 1, 1)), (f"A{participations.count() + 4}:D{participations.count() + 4}", (0.8, 0.8, 0.8)), (f"A{participations.count() + 5}:C{2 * participations.count() + 4}", (0.9, 0.9, 0.9)),] if settings.NB_ROUNDS >= 3: bg_colors.append((f"F2:G{participations.count() + 1}", (0.9, 0.9, 0.9))) bg_colors.append((f"I2:J{participations.count() + 1}", (0.9, 0.9, 0.9))) else: bg_colors.append((f"F2:G{participations.count() + 1}", (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)", } }) # Set number format, display only one decimal number_format_ranges = [(f"B2:B{participations.count() + 1}", "0.0"), (f"C2:C{participations.count() + 1}", "0"), (f"D2:D{participations.count() + 1}", "0.0"), (f"E2:E{participations.count() + 1}", "0"), (f"F2:F{participations.count() + 1}", "0.0"), (f"B{participations.count() + 5}:B{2 * participations.count() + 5}", "0.0"), (f"C{participations.count() + 5}:C{2 * participations.count() + 5}", "0"), ] if settings.NB_ROUNDS >= 3: number_format_ranges += [(f"G2:G{participations.count() + 1}", "0.0"), (f"H2:H{participations.count() + 1}", "0"), (f"I2:I{participations.count() + 1}", "0.0"), (f"J2:J{participations.count() + 1}", "0"), ] else: number_format_ranges.append((f"G2:G{participations.count() + 1}", "0")) for number_format_range, pattern in number_format_ranges: format_requests.append({ "repeatCell": { "range": a1_range_to_grid_range(number_format_range, worksheet.id), "cell": {"userEnteredFormat": {"numberFormat": {"type": "NUMBER", "pattern": pattern}}}, "fields": "userEnteredFormat.numberFormat", } }) # 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:J1", f"A2:B{participations.count() + 1}", f"D2:D{participations.count() + 1}", f"F2:G{participations.count() + 1}", f"I2:J{participations.count() + 1}", f"A{participations.count() + 4}:C{2 * participations.count() + 4}", ] for protected_range in protected_ranges: format_requests.append({ "addProtectedRange": { "protectedRange": { "range": a1_range_to_grid_range(protected_range, worksheet.id), "description": str(_("Don't update the table structure for a better automated integration.")), "warningOnly": True, }, } }) body = {"requests": format_requests} worksheet.client.batch_update(spreadsheet.id, body) def parse_tweaks_spreadsheets(self): if not self.pools.exists(): # Draw has not been done yet return translation.activate(settings.PREFERRED_LANGUAGE_CODE) gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT) spreadsheet = gc.open_by_key(self.notes_sheet_id) worksheet = spreadsheet.worksheet(str(_("Final ranking"))) score_cell = worksheet.find(str(_("Score"))) max_row = score_cell.row - 3 if max_row == 1: # There is no team return data = worksheet.get_values(f"A2:H{max_row}") for line in data: trigram = line[0][-settings.TEAM_CODE_LENGTH - 1:-1] participation = self.participations.get(team__trigram=trigram) pool1 = self.pools.get(round=1, participations=participation, room=1) tweak1_qs = Tweak.objects.filter(pool=pool1, participation=participation) tweak1_nb = int(line[2]) if not tweak1_nb: tweak1_qs.delete() else: tweak1_qs.update_or_create(defaults={'diff': tweak1_nb}, create_defaults={'diff': tweak1_nb, 'pool': pool1, 'participation': participation}) if self.pools.filter(round=2, participations=participation).exists(): pool2 = self.pools.get(round=2, participations=participation, room=1) tweak2_qs = Tweak.objects.filter(pool=pool2, participation=participation) tweak2_nb = int(line[4]) if not tweak2_nb: tweak2_qs.delete() else: tweak2_qs.update_or_create(defaults={'diff': tweak2_nb}, create_defaults={'diff': tweak2_nb, 'pool': pool2, 'participation': participation}) if self.pools.filter(round=3, participations=participation).exists(): pool3 = self.pools.get(round=3, participations=participation, room=1) tweak3_qs = Tweak.objects.filter(pool=pool3, participation=participation) tweak3_nb = int(line[7]) if not tweak3_nb: tweak3_qs.delete() else: tweak3_qs.update_or_create(defaults={'diff': tweak3_nb}, create_defaults={'diff': tweak3_nb, 'pool': pool3, 'participation': participation}) nb_participations = self.participations.filter(valid=True).count() mentions = worksheet.get_values(f"A{score_cell.row + 1}:D{score_cell.row + nb_participations}") notes = dict() for participation in self.participations.filter(valid=True).all(): note = sum(pool.average(participation) for pool in self.pools.filter(participations=participation).all()) if note: notes[participation] = note sorted_notes = sorted(notes.items(), key=lambda x: x[1], reverse=True) for i, (participation, _note) in enumerate(sorted_notes): mention = mentions[i][3] if len(mentions[i]) >= 4 else "" if not self.final: participation.mention = mention else: participation.mention_final = mention participation.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."), ) mention = models.CharField( verbose_name=_("mention"), max_length=255, blank=True, default="", ) mention_final = models.CharField( verbose_name=_("mention (final)"), max_length=255, blank=True, default="", ) 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): from survey.models import Survey 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 self.valid: for survey in Survey.objects.filter(Q(tournament__isnull=True) | Q(tournament=self.tournament), Q(invite_team=True), ~Q(completed_teams=self.team)).all(): text = _("Please answer to the survey \"{name}\". You can go to the survey on that link, " "using the token code you received by mail.") content = format_lazy(text, name=survey.name, survey_link=f"{settings.LIMESURVEY_URL}/index.php/{survey.survey_id}") informations.append({ 'title': _("Required answer to survey"), 'type': "warning", 'priority': 12, 'content': content }) if self.tournament: informations.extend(self.informations_for_tournament(self.tournament)) if self.final: informations.extend(self.informations_for_tournament(Tournament.final_tournament())) return informations def informations_for_tournament(self, tournament) -> list[dict]: informations = [] if timezone.now() <= tournament.solution_limit + timedelta(hours=2): if not tournament.final: 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=tournament.name, date=localtime(tournament.solution_limit), nb_solutions=self.solutions.filter(final_solution=False).count(), min_solutions=len(settings.PROBLEMS) - 3, url=url) informations.append({ 'title': _("Solutions due"), 'type': "info", 'priority': 1, 'content': content, }) else: text = _("

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

" "

Remember that you can only fix minor changes to your solutions " "without adding new parts.

" "

You can upload your solutions on your participation page.

") url = reverse_lazy("participation:participation_detail", args=(self.pk,)) content = format_lazy(text, tournament=tournament.name, date=localtime(tournament.solution_limit), url=url) informations.append({ 'title': _("Solutions due"), 'type': "info", 'priority': 1, 'content': content, }) elif timezone.now() <= tournament.solutions_draw + timedelta(hours=2): text = _("

The draw of the solutions for the tournament {tournament} is planned on the " "{date:%Y-%m-%d %H:%M}. You can join it on this link.

") url = reverse_lazy("draw:index") content = format_lazy(text, tournament=tournament.name, date=localtime(tournament.solutions_draw), url=url) informations.append({ 'title': _("Draw of solutions"), 'type': "info", 'priority': 1, 'content': content, }) elif timezone.now() <= tournament.reviews_first_phase_limit + timedelta(hours=2): reporter_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=1, reporter=self) opponent_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=1, opponent=self) reviewer_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=1, reviewer=self) observer_passage = Passage.objects.filter(pool__tournament=self.tournament, pool__round=1, observer=self) observer_passage = observer_passage.get() if observer_passage.exists() else None reporter_text = _("

The solutions draw is ended. You can check the result on " "this page.

" "

For the first round, you will present " "your solution of the problem {problem}.

") draw_url = reverse_lazy("draw:index") solution_url = reporter_passage.reported_solution.file.url reporter_content = format_lazy(reporter_text, draw_url=draw_url, solution_url=solution_url, problem=reporter_passage.solution_number) opponent_text = _("

You will oppose the solution of the team {opponent} on the " "problem {problem}. " "You can upload your written review on this page.

") solution_url = opponent_passage.reported_solution.file.url passage_url = reverse_lazy("participation:passage_detail", args=(opponent_passage.pk,)) opponent_content = format_lazy(opponent_text, opponent=opponent_passage.reporter.team.trigram, solution_url=solution_url, problem=opponent_passage.solution_number, passage_url=passage_url) reviewer_text = _("

You will report the solution of the team {reviewer} on the " "problem {problem}. " "You can upload your written review on this page.

") solution_url = reviewer_passage.reported_solution.file.url passage_url = reverse_lazy("participation:passage_detail", args=(reviewer_passage.pk,)) reviewer_content = format_lazy(reviewer_text, reviewer=reviewer_passage.reporter.team.trigram, solution_url=solution_url, problem=reviewer_passage.solution_number, passage_url=passage_url) if observer_passage: observer_text = _("

You will observe the solution of the team {observer} on the " "problem {problem}. " "You can upload your written review on this page.

") solution_url = observer_passage.reported_solution.file.url passage_url = reverse_lazy("participation:passage_detail", args=(observer_passage.pk,)) observer_content = format_lazy(observer_text, observer=observer_passage.reporter.team.trigram, solution_url=solution_url, problem=observer_passage.solution_number, passage_url=passage_url) else: observer_content = "" if settings.TFJM_APP == "TFJM": reviews_template_begin = f"{settings.STATIC_URL}tfjm/Fiche_synthèse." reviews_templates = " — ".join(f"{ext.upper()}" for ext in ["pdf", "tex", "odt", "docx"]) else: reviews_template_begin = f"{settings.STATIC_URL}eteam/Written_review." reviews_templates = " — ".join(f"{ext.upper()}" for ext in ["pdf", "tex"]) reviews_templates_content = "

" + _('Templates:') + f" {reviews_templates}

" content = reporter_content + opponent_content + reviewer_content + observer_content \ + reviews_templates_content informations.append({ 'title': _("First round"), 'type': "info", 'priority': 1, 'content': content, }) elif timezone.now() <= tournament.reviews_second_phase_limit + timedelta(hours=2): reporter_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=2, reporter=self) opponent_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=2, opponent=self) reviewer_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=2, reviewer=self) observer_passage = Passage.objects.filter(pool__tournament=self.tournament, pool__round=2, observer=self) observer_passage = observer_passage.get() if observer_passage.exists() else None reporter_text = _("

For the second round, you will present " "your solution of the problem {problem}.

") draw_url = reverse_lazy("draw:index") solution_url = reporter_passage.reported_solution.file.url reporter_content = format_lazy(reporter_text, draw_url=draw_url, solution_url=solution_url, problem=reporter_passage.solution_number) opponent_text = _("

You will oppose the solution of the team {opponent} on the " "problem {problem}. " "You can upload your written review on this page.

") solution_url = opponent_passage.reported_solution.file.url passage_url = reverse_lazy("participation:passage_detail", args=(opponent_passage.pk,)) opponent_content = format_lazy(opponent_text, opponent=opponent_passage.reporter.team.trigram, solution_url=solution_url, problem=opponent_passage.solution_number, passage_url=passage_url) reviewer_text = _("

You will report the solution of the team {reviewer} on the " "problem {problem}. " "You can upload your written review on this page.

") solution_url = reviewer_passage.reported_solution.file.url passage_url = reverse_lazy("participation:passage_detail", args=(reviewer_passage.pk,)) reviewer_content = format_lazy(reviewer_text, reviewer=reviewer_passage.reporter.team.trigram, solution_url=solution_url, problem=reviewer_passage.solution_number, passage_url=passage_url) if observer_passage: observer_text = _("

You will observe the solution of the team {observer} on the " "problem {problem}. " "You can upload your written review on this page.

") solution_url = observer_passage.reported_solution.file.url passage_url = reverse_lazy("participation:passage_detail", args=(observer_passage.pk,)) observer_content = format_lazy(observer_text, observer=observer_passage.reporter.team.trigram, solution_url=solution_url, problem=observer_passage.solution_number, passage_url=passage_url) else: observer_content = "" if settings.TFJM_APP == "TFJM": reviews_template_begin = f"{settings.STATIC_URL}tfjm/Fiche_synthèse." reviews_templates = " — ".join(f"{ext.upper()}" for ext in ["pdf", "tex", "odt", "docx"]) else: reviews_template_begin = f"{settings.STATIC_URL}eteam/Written_review." reviews_templates = " — ".join(f"{ext.upper()}" for ext in ["pdf", "tex"]) reviews_templates_content = "

" + _('Templates:') + f" {reviews_templates}

" content = reporter_content + opponent_content + reviewer_content + observer_content \ + reviews_templates_content informations.append({ 'title': _("Second round"), 'type': "info", 'priority': 1, 'content': content, }) elif settings.NB_ROUNDS >= 3 \ and timezone.now() <= tournament.reviews_third_phase_limit + timedelta(hours=2): reporter_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=3, reporter=self) opponent_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=3, opponent=self) reviewer_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=3, reviewer=self) observer_passage = Passage.objects.filter(pool__tournament=self.tournament, pool__round=3, observer=self) observer_passage = observer_passage.get() if observer_passage.exists() else None reporter_text = _("

For the third round, you will present " "your solution of the problem {problem}.

") draw_url = reverse_lazy("draw:index") solution_url = reporter_passage.reported_solution.file.url reporter_content = format_lazy(reporter_text, draw_url=draw_url, solution_url=solution_url, problem=reporter_passage.solution_number) opponent_text = _("

You will oppose the solution of the team {opponent} on the " "problem {problem}. " "You can upload your written review on this page.

") solution_url = opponent_passage.reported_solution.file.url passage_url = reverse_lazy("participation:passage_detail", args=(opponent_passage.pk,)) opponent_content = format_lazy(opponent_text, opponent=opponent_passage.reporter.team.trigram, solution_url=solution_url, problem=opponent_passage.solution_number, passage_url=passage_url) reviewer_text = _("

You will report the solution of the team {reviewer} on the " "problem {problem}. " "You can upload your written review on this page.

") solution_url = reviewer_passage.reported_solution.file.url passage_url = reverse_lazy("participation:passage_detail", args=(reviewer_passage.pk,)) reviewer_content = format_lazy(reviewer_text, reviewer=reviewer_passage.reporter.team.trigram, solution_url=solution_url, problem=reviewer_passage.solution_number, passage_url=passage_url) if observer_passage: observer_text = _("

You will observe the solution of the team {observer} on the " "problem {problem}. " "You can upload your written review on this page.

") solution_url = observer_passage.reported_solution.file.url passage_url = reverse_lazy("participation:passage_detail", args=(observer_passage.pk,)) observer_content = format_lazy(observer_text, observer=observer_passage.reporter.team.trigram, solution_url=solution_url, problem=observer_passage.solution_number, passage_url=passage_url) else: observer_content = "" if settings.TFJM_APP == "TFJM": reviews_template_begin = f"{settings.STATIC_URL}tfjm/Fiche_synthèse." reviews_templates = " — ".join(f"{ext.upper()}" for ext in ["pdf", "tex", "odt", "docx"]) else: reviews_template_begin = f"{settings.STATIC_URL}eteam/Written_review." reviews_templates = " — ".join(f"{ext.upper()}" for ext in ["pdf", "tex"]) reviews_templates_content = "

" + _('Templates:') + f" {reviews_templates}

" content = reporter_content + opponent_content + reviewer_content + observer_content \ + reviews_templates_content informations.append({ 'title': _("Second round"), 'type': "info", 'priority': 1, 'content': content, }) elif not self.final or tournament.final: text = _("

The tournament {tournament} is ended. You can check the results on the " "tournament page.

") url = reverse_lazy("participation:tournament_detail", args=(tournament.pk,)) content = format_lazy(text, tournament=tournament.name, url=url) informations.append({ 'title': _("Tournament ended"), '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)), ] + ([] if settings.NB_ROUNDS == 2 else [(3, format_lazy(_("Round {round}"), round=3))]), ) letter = models.PositiveSmallIntegerField( verbose_name=_('letter'), choices=[ (1, 'A'), (2, 'B'), (3, 'C'), (4, 'D'), ], ) room = models.PositiveSmallIntegerField( verbose_name=_("room"), choices=[ (1, _("Room 1")), (2, _("Room 2")), ], default=1, help_text=_("For 5-teams pools only"), ) 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): short_name = f"{self.get_letter_display()}{self.round}" if self.participations.count() == 5: short_name += f" — {self.get_room_display()}" return short_name @property def solutions(self): return [passage.reported_solution for passage in self.passages.all()] @property def coeff(self): return 1 if self.round <= 2 else math.pi - 2 def average(self, participation): return self.coeff * 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 self.coeff * 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 translation.activate(settings.PREFERRED_LANGUAGE_CODE) pool_size = self.participations.count() has_observer = settings.HAS_OBSERVER and pool_size >= 4 passage_width = 6 + (2 if has_observer else 0) passages = self.passages.all() # Create tournament sheet if it does not exist self.tournament.create_spreadsheet() 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"{_('Pool')} {self.short_name}" not in [ws.title for ws in worksheets]: worksheet = spreadsheet.add_worksheet(f"{_('Pool')} {self.short_name}", 30, 2 + passages.count() * passage_width) else: worksheet = spreadsheet.worksheet(f"{_('Pool')} {self.short_name}") if any(ws.title == "Sheet1" for ws in worksheets): spreadsheet.del_worksheet(spreadsheet.worksheet("Sheet1")) header = [ sum(([str(_("Problem #{problem}").format(problem=passage.solution_number))] + (passage_width - 1) * [""] for passage in passages), start=[str(_("Problem")), ""]), sum(([_('Reporter') + f" ({passage.reporter.team.trigram})", "", _('Opponent') + f" ({passage.opponent.team.trigram})", "", _('Reviewer') + f" ({passage.reviewer.team.trigram})", ""] + ([_('Observer') + f" ({passage.observer.team.trigram})", ""] if has_observer else []) for passage in passages), start=[str(_("Role")), ""]), sum(([_('Writing') + f" (/{20 if settings.TFJM_APP == 'TFJM' else 10})", _('Oral') + f" (/{20 if settings.TFJM_APP == 'TFJM' else 10})", _('Writing') + " (/10)", _('Oral') + " (/10)", _('Writing') + " (/10)", _('Oral') + " (/10)"] + ([_('Writing') + " (/10)", _('Oral') + " (/10)"] if has_observer else []) for _passage in passages), start=[str(_("Juree")), ""]), ] notes = [[]] # Begin with empty hidden line to ensure pretty design 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.reporter_writing, note.reporter_oral, note.opponent_writing, note.opponent_oral, note.reviewer_writing, note.reviewer_oral]) if has_observer: line.extend([note.observer_writing, note.observer_oral]) notes.append(line) notes.append([]) # Add empty line to ensure pretty design 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 = [str(_("Average")), ""] coeffs = sum(([passage.coeff_reporter_writing, passage.coeff_reporter_oral, passage.coeff_opponent_writing, passage.coeff_opponent_oral, passage.coeff_reviewer_writing, passage.coeff_reviewer_oral] + ([passage.coeff_observer_writing, passage.coeff_observer_oral] if has_observer else []) for passage in passages), start=[str(_("Coefficient")), ""]) subtotal = [str(_("Subtotal")), ""] footer = [average, coeffs, subtotal, (2 + pool_size * passage_width) * [""]] min_row = 5 max_row = min_row + self.juries.count() 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"=SIERREUR(MOYENNE.SI(${getcol(min_column + i * passage_width)}${min_row - 1}" f":${getcol(min_column + i * passage_width)}{max_row}; \">0\"; " f"{column}${min_row - 1}:{column}{max_row});0)") 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 has_observer: obs_w_col = getcol(min_column + passage_width * i + 6) obs_o_col = getcol(min_column + passage_width * i + 7) subtotal.extend([f"={obs_w_col}{max_row + 1} * {obs_w_col}{max_row + 2}" f" + {obs_o_col}{max_row + 1} * {obs_o_col}{max_row + 2}", ""]) ranking = [ [str(_("Team")), "", str(_("Problem")), str(_("Total")), str(_("Rank"))], ] all_passages = Passage.objects.filter(pool__tournament=self.tournament, pool__round=self.round, pool__letter=self.letter).order_by('position', 'pool__room') for i, passage in enumerate(all_passages): participation = passage.reporter reporter_passage = Passage.objects.get(reporter=participation, pool__tournament=self.tournament, pool__round=self.round) reporter_row = 5 + reporter_passage.pool.juries.count() reporter_col = reporter_passage.position - 1 opponent_passage = Passage.objects.get(opponent=participation, pool__tournament=self.tournament, pool__round=self.round) opponent_row = 5 + opponent_passage.pool.juries.count() opponent_col = opponent_passage.position - 1 reviewer_passage = Passage.objects.get(reviewer=participation, pool__tournament=self.tournament, pool__round=self.round) reviewer_row = 5 + reviewer_passage.pool.juries.count() reviewer_col = reviewer_passage.position - 1 formula = "=" formula += (f"'{_('Pool')} {reporter_passage.pool.short_name}'" f"!{getcol(min_column + reporter_col * passage_width)}{reporter_row + 3}") # Reporter formula += (f" + '{_('Pool')} {opponent_passage.pool.short_name}'" f"!{getcol(min_column + opponent_col * passage_width + 2)}{opponent_row + 3}") # Opponent formula += (f" + '{_('Pool')} {reviewer_passage.pool.short_name}'" f"!{getcol(min_column + reviewer_col * passage_width + 4)}{reviewer_row + 3}") # reviewer if has_observer: observer_passage = Passage.objects.get(observer=participation, pool__tournament=self.tournament, pool__round=self.round) observer_row = 5 + observer_passage.pool.juries.count() observer_col = observer_passage.position - 1 formula += (f" + '{_('Pool')} {observer_passage.pool.short_name}'" f"!{getcol(min_column + observer_col * passage_width + 6)}{observer_row + 3}") ranking.append([f"{participation.team.name} ({participation.team.trigram})", "", f"='{_('Pool')} {reporter_passage.pool.short_name}'" f"!${getcol(3 + reporter_col * passage_width)}$1", formula, f"=RANG(D{max_row + 6 + i}; " f"D${max_row + 6}:D${max_row + 5 + pool_size})"]) all_values = header + notes + footer + ranking max_col = getcol(2 + pool_size * passage_width) worksheet.batch_clear([f"A1:{max_col}{max_row + 5 + pool_size}"]) worksheet.update(all_values, f"A1:{max_col}", 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}") if has_observer: merge_cells.append(f"{getcol(9 + i * passage_width)}2:{getcol(10 + i * passage_width)}2") merge_cells.append(f"{getcol(9 + i * passage_width)}{max_row + 3}" f":{getcol(10 + 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(f"A1:{max_col}", 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 = [(f"A1:{max_col}", False), (f"A1:{max_col}3", True), (f"A{max_row + 1}:B{max_row + 3}", True), (f"A{max_row + 5}:E{max_row + 5}", True)] for bold_range, bold in bold_ranges: format_requests.append({ "repeatCell": { "range": a1_range_to_grid_range(bold_range, worksheet.id), "cell": {"userEnteredFormat": {"textFormat": {"bold": bold}}}, "fields": "userEnteredFormat(textFormat)", } }) # Set background color for headers and footers bg_colors = [(f"A1:{max_col}", (1, 1, 1)), (f"A1:{getcol(2 + passages.count() * passage_width)}3", (0.8, 0.8, 0.8)), (f"A{min_row - 1}: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 + passages.count() * 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)),] # Display penalties in red bg_colors += [(f"{getcol(2 + (passage.position - 1) * passage_width + 2)}{max_row + 2}", (1.0, 0.7, 0.7)) for passage in self.passages.filter(reporter_penalties__gte=1).all()] 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", 350), ("B", 30)] for passage in passages: column_widths.append((f"{getcol(3 + passage_width * (passage.position - 1))}" f":{getcol(2 + passage_width * passage.position)}", 80)) 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) and first and last jury rows hidden_dimensions = [(1, "COLUMNS"), (3, "ROWS"), (max_row - 1, "ROWS")] format_requests.append({ "updateDimensionProperties": { "range": { "sheetId": worksheet.id, "dimension": "ROWS", "startIndex": 0, "endIndex": 1000, }, "properties": { "hiddenByUser": False, }, "fields": "hiddenByUser", } }) for dimension_id, dimension_type in hidden_dimensions: format_requests.append({ "updateDimensionProperties": { "range": { "sheetId": worksheet.id, "dimension": dimension_type, "startIndex": dimension_id, "endIndex": dimension_id + 1, }, "properties": { "hiddenByUser": True, }, "fields": "hiddenByUser", } }) # Define borders border_ranges = [(f"A1:{max_col}", "0000"), (f"A1:{getcol(2 + passages.count() * 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 + (passages.count() - 1) * passage_width)}1", "1113")] for i in range(passages.count() - 1): border_ranges.append((f"{getcol(1 + (i + 1) * passage_width)}2" f":{getcol(2 + (i + 1) * passage_width)}2", "1113")) border_ranges.append((f"{getcol(2 + (i + 1) * passage_width)}3" f":{getcol(2 + (i + 1) * passage_width)}{max_row + 2}", "1113")) border_ranges.append((f"{getcol(1 + (i + 1) * passage_width)}{max_row + 3}" 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)", } }) # Add range conditions for i in range(passages.count()): for j in range(passage_width): column = getcol(min_column + i * passage_width + j) min_note = 0 if j < 7 else -10 max_note = 20 if j < 2 and settings.TFJM_APP == "TFJM" else 10 format_requests.append({ "setDataValidation": { "range": a1_range_to_grid_range(f"{column}{min_row - 1}:{column}{max_row}", worksheet.id), "rule": { "condition": { "type": "CUSTOM_FORMULA", "values": [{"userEnteredValue": f'=ET(REGEXMATCH(TO_TEXT({column}4); "^-?[0-9]+$"); ' f'{column}4>={min_note}; {column}4<={max_note})'},], }, "inputMessage": str(_("Input must be a valid integer between {min_note} and {max_note}.") .format(min_note=min_note, max_note=max_note)), "strict": True, }, } }) # Set number format, display only one decimal number_format_ranges = [f"C{max_row + 1}:{getcol(2 + passage_width * passages.count())}{max_row + 1}", f"C{max_row + 3}:{getcol(2 + passage_width * passages.count())}{max_row + 3}", f"D{max_row + 6}:D{max_row + 5 + passages.count()}",] for number_format_range in number_format_ranges: format_requests.append({ "repeatCell": { "range": a1_range_to_grid_range(number_format_range, worksheet.id), "cell": {"userEnteredFormat": {"numberFormat": {"type": "NUMBER", "pattern": "0.0"}}}, "fields": "userEnteredFormat.numberFormat", } }) # 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 = [f"A1:{max_col}4", f"A{min_row}:B{max_row}", f"A{max_row}:{max_col}{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": str(_("Don't update the table structure for a better automated integration.")), "warningOnly": True, }, } }) body = {"requests": format_requests} worksheet.client.batch_update(spreadsheet.id, body) def update_juries_lines_spreadsheet(self): translation.activate(settings.PREFERRED_LANGUAGE_CODE) gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT) spreadsheet = gc.open_by_key(self.tournament.notes_sheet_id) worksheet = spreadsheet.worksheet(f"{_('Pool')} {self.short_name}") average_cell = worksheet.find(str(_("Average"))) min_row = 5 max_row = average_cell.row - 1 juries_visible = worksheet.get(f"A{min_row}:B{max_row}") juries_visible = [t for t in juries_visible if t and len(t) == 2] for i, (jury_name, jury_id) in enumerate(juries_visible): if not jury_id.isnumeric() or int(jury_id) not in self.juries.values_list("id", flat=True): print(f"Warning: {jury_name} ({jury_id}) appears on the sheet but is not part of the jury.") for jury in self.juries.all(): if str(jury.id) not in list(map(lambda x: x[1], juries_visible)): worksheet.insert_row([str(jury), jury.id], max_row) max_row += 1 def parse_spreadsheet(self): translation.activate(settings.PREFERRED_LANGUAGE_CODE) gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT) self.tournament.create_spreadsheet() spreadsheet = gc.open_by_key(self.tournament.notes_sheet_id) worksheet = spreadsheet.worksheet(f"{_('Pool')} {self.short_name}") average_cell = worksheet.find(str(_("Average"))) min_row = 5 max_row = average_cell.row - 2 data = worksheet.get_values(f"A{min_row}:AH{max_row}") if not data or not data[0]: return has_observer = settings.HAS_OBSERVER and self.participations.count() >= 4 passage_width = 6 + (2 if has_observer else 0) for line in data: jury_name = line[0] jury_id = line[1] if not jury_id.isnumeric() or int(jury_id) not in self.juries.values_list("id", flat=True): print(format_lazy(_("The jury {jury} is not part of the jury for this pool."), jury=jury_name)) continue jury = self.juries.get(id=jury_id) for i, passage in enumerate(self.passages.all()): note = passage.notes.get(jury=jury) note_line = line[2 + i * passage_width:2 + (i + 1) * passage_width] if not note_line: # There is no note continue note.set_all(*note_line) note.save() def __str__(self): return _("Pool {code} for tournament {tournament} with teams {teams}")\ .format(code=self.short_name, 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', 'room',) 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=_("reported solution"), choices=[ (i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, len(settings.PROBLEMS) + 1) ], ) reporter = models.ForeignKey( Participation, on_delete=models.PROTECT, verbose_name=_("reporter"), related_name="+", ) opponent = models.ForeignKey( Participation, on_delete=models.PROTECT, verbose_name=_("opponent"), related_name="+", ) reviewer = models.ForeignKey( Participation, on_delete=models.PROTECT, verbose_name=_("reviewer"), related_name="+", ) observer = models.ForeignKey( Participation, on_delete=models.SET_NULL, verbose_name=_("observer"), related_name="+", null=True, blank=True, default=None, ) reporter_penalties = models.PositiveSmallIntegerField( verbose_name=_("penalties"), default=0, help_text=_("Number of penalties for the reporter. " "The reporter will loose a 0.5 coefficient per penalty."), ) @property def reported_solution(self) -> "Solution": return Solution.objects.get( participation=self.reporter, 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_reporter_writing(self) -> float: return self.avg(note.reporter_writing for note in self.notes.all()) @property def coeff_reporter_writing(self) -> float: return 1 if settings.TFJM_APP == "TFJM" else 2 @property def average_reporter_oral(self) -> float: return self.avg(note.reporter_oral for note in self.notes.all()) @property def coeff_reporter_oral(self) -> float: coeff = 1.6 if settings.TFJM_APP == "TFJM" else 3 coeff *= 1 - 0.25 * self.reporter_penalties return coeff @property def average_reporter(self) -> float: return (self.coeff_reporter_writing * self.average_reporter_writing + self.coeff_reporter_oral * self.average_reporter_oral) @property def average_opponent_writing(self) -> float: return self.avg(note.opponent_writing for note in self.notes.all()) @property def coeff_opponent_writing(self) -> float: return 0.9 if not self.observer else 0.6 @property def average_opponent_oral(self) -> float: return self.avg(note.opponent_oral for note in self.notes.all()) @property def coeff_opponent_oral(self) -> float: return 2 @property def average_opponent(self) -> float: return (self.coeff_opponent_writing * self.average_opponent_writing + self.coeff_opponent_oral * self.average_opponent_oral) @property def average_reviewer_writing(self) -> float: return self.avg(note.reviewer_writing for note in self.notes.all()) @property def coeff_reviewer_writing(self): return 0.9 if not self.observer else 0.6 @property def average_reviewer_oral(self) -> float: return self.avg(note.reviewer_oral for note in self.notes.all()) @property def coeff_reviewer_oral(self): return 1 if settings.TFJM_APP == "TFJM" else 1.2 @property def average_reviewer(self) -> float: return (self.coeff_reviewer_writing * self.average_reviewer_writing + self.coeff_reviewer_oral * self.average_reviewer_oral) @property def average_observer_writing(self) -> float: return self.avg(note.observer_writing for note in self.notes.all()) @property def coeff_observer_writing(self): return 0.6 @property def average_observer_oral(self) -> float: return self.avg(note.observer_oral for note in self.notes.all()) @property def coeff_observer_oral(self): return 0.5 @property def average_observer(self) -> float: return (self.coeff_observer_writing * self.average_observer_writing + self.coeff_observer_oral * self.average_observer_oral) @property def averages(self): yield self.average_reporter_writing yield self.average_reporter_oral yield self.average_opponent_writing yield self.average_opponent_oral yield self.average_reviewer_writing yield self.average_reviewer_oral if self.observer: yield self.average_observer_writing yield self.average_observer_oral def average(self, participation): avg = self.average_reporter if participation == self.reporter else self.average_opponent \ if participation == self.opponent else self.average_reviewer if participation == self.reviewer \ else self.average_observer if participation == self.observer else 0 avg *= self.pool.coeff return avg def get_absolute_url(self): return reverse_lazy("participation:passage_detail", args=(self.pk,)) def clean(self): 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.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.reviewer not in self.pool.participations.all(): raise ValidationError(_("Team {trigram} is not registered in the pool.") .format(trigram=self.reviewer.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 {reporter} for problem {problem}")\ .format(reporter=self.reporter.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_review_filename(instance, filename): return f"reviews/{instance.participation.team.trigram}_{instance.type}_{instance.passage.pk}" def get_synthesis_filename(instance, filename): return get_review_filename(instance, filename) 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 WrittenReview(models.Model): participation = models.ForeignKey( Participation, on_delete=models.CASCADE, verbose_name=_("participation"), ) passage = models.ForeignKey( Passage, on_delete=models.CASCADE, related_name="written_reviews", verbose_name=_("passage"), ) type = models.PositiveSmallIntegerField( choices=[ (1, _("opponent"), ), (2, _("reviewer"), ), (3, _("observer"), ), ] ) file = models.FileField( verbose_name=_("file"), upload_to=get_synthesis_filename, unique=True, ) def __str__(self): return _("Written review of {team} as {type} for problem {problem} of {reporter}").format( team=self.participation.team.trigram, type=self.get_type_display(), problem=self.passage.solution_number, reporter=self.passage.reporter.team.trigram, ) class Meta: verbose_name = _("written review") verbose_name_plural = _("written reviews") 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", ) reporter_writing = models.PositiveSmallIntegerField( verbose_name=_("reporter writing note"), choices=[(i, i) for i in range(0, 21)], default=0, ) reporter_oral = models.PositiveSmallIntegerField( verbose_name=_("reporter 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, ) reviewer_writing = models.PositiveSmallIntegerField( verbose_name=_("reviewer writing note"), choices=[(i, i) for i in range(0, 11)], default=0, ) reviewer_oral = models.PositiveSmallIntegerField( verbose_name=_("reviewer oral note"), choices=[(i, i) for i in range(0, 11)], default=0, ) observer_writing = models.PositiveSmallIntegerField( verbose_name=_("observer writing note"), choices=[(i, i) for i in range(0, 11)], default=0, ) observer_oral = models.SmallIntegerField( verbose_name=_("observer oral note"), choices=[(i, i) for i in range(-10, 11)], default=0, ) def get_all(self): yield self.reporter_writing yield self.reporter_oral yield self.opponent_writing yield self.opponent_oral yield self.reviewer_writing yield self.reviewer_oral if self.passage.observer: yield self.observer_writing yield self.observer_oral def set_all(self, reporter_writing: int, reporter_oral: int, opponent_writing: int, opponent_oral: int, reviewer_writing: int, reviewer_oral: int, observer_writing: int = 0, observer_oral: int = 0): self.reporter_writing = reporter_writing self.reporter_oral = reporter_oral self.opponent_writing = opponent_writing self.opponent_oral = opponent_oral self.reviewer_writing = reviewer_writing self.reviewer_oral = reviewer_oral self.observer_writing = observer_writing self.observer_oral = observer_oral def update_spreadsheet(self): if not self.has_any_note(): return translation.activate(settings.PREFERRED_LANGUAGE_CODE) 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"{_('Pool')} {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 = 6 + (2 if passage.observer else 0) 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 __str__(self): return _("Notes of {jury} for {passage}").format(jury=self.jury, passage=self.passage) class Meta: verbose_name = _("note") verbose_name_plural = _("notes")