mirror of
				https://gitlab.com/animath/si/plateforme.git
				synced 2025-10-31 15:40:01 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			2161 lines
		
	
	
		
			95 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			2161 lines
		
	
	
		
			95 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| # 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 <a href='{url}'>this link</a>.")
 | |
|                 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 <a href='{url}'>this link</a>.")
 | |
|                     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 <strong>{code}</strong>.")
 | |
|                     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 <strong>{code}</strong>.")
 | |
|                     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 <a href='{url}'>the page of your team</a>.")
 | |
|                         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 <strong>{code}</strong>.")
 | |
|                         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 organizers_and_presidents(self):
 | |
|         return VolunteerRegistration.objects.filter(Q(admin=True) | Q(organized_tournaments=self) | Q(pools_presided__tournament=self))
 | |
| 
 | |
|     @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 = _("<p>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.</p>"
 | |
|                      "<p>Participants that have not paid yet are: {participants}.</p>")
 | |
|             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 <a href=\"{survey_link}\">that link</a>, "
 | |
|                          "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 = _("<p>The solutions for the tournament of {tournament} are due on the {date:%Y-%m-%d %H:%M}.</p>"
 | |
|                          "<p>You have currently sent <strong>{nb_solutions}</strong> solutions. "
 | |
|                          "We suggest to send at least <strong>{min_solutions}</strong> different solutions.</p>"
 | |
|                          "<p>You can upload your solutions on <a href='{url}'>your participation page</a>.</p>")
 | |
|                 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 = _("<p>The solutions for the tournament of {tournament} are due on the {date:%Y-%m-%d %H:%M}.</p>"
 | |
|                          "<p>Remember that you can only fix minor changes to your solutions "
 | |
|                          "without adding new parts.</p>"
 | |
|                          "<p>You can upload your solutions on <a href='{url}'>your participation page</a>.</p>")
 | |
|                 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 = _("<p>The draw of the solutions for the tournament {tournament} is planned on the "
 | |
|                      "{date:%Y-%m-%d %H:%M}. You can join it on <a href='{url}'>this link</a>.</p>")
 | |
|             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=tournament, pool__round=1, reporter=self)
 | |
|             opponent_passage = Passage.objects.get(pool__tournament=tournament, pool__round=1, opponent=self)
 | |
|             reviewer_passage = Passage.objects.get(pool__tournament=tournament, pool__round=1, reviewer=self)
 | |
|             observer_passage = Passage.objects.filter(pool__tournament=tournament, pool__round=1, observer=self)
 | |
|             observer_passage = observer_passage.get() if observer_passage.exists() else None
 | |
| 
 | |
|             reporter_text = _("<p>The solutions draw is ended. You can check the result on "
 | |
|                               "<a href='{draw_url}'>this page</a>.</p>"
 | |
|                               "<p>For the first round, you will present "
 | |
|                               "<a href='{solution_url}'>your solution of the problem {problem}</a>.</p>")
 | |
|             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 = _("<p>You will oppose the solution of the team {opponent} on the "
 | |
|                               "<a href='{solution_url}'>problem {problem}</a>. "
 | |
|                               "You can upload your written review on <a href='{passage_url}'>this page</a>.</p>")
 | |
|             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 = _("<p>You will report the solution of the team {reviewer} on the "
 | |
|                               "<a href='{solution_url}'>problem {problem}</a>. "
 | |
|                               "You can upload your written review on <a href='{passage_url}'>this page</a>.</p>")
 | |
|             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 = _("<p>You will observe the solution of the team {observer} on the "
 | |
|                                   "<a href='{solution_url}'>problem {problem}</a>. "
 | |
|                                   "You can upload your written review on <a href='{passage_url}'>this page</a>.</p>")
 | |
|                 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"<a href='{reviews_template_begin}{ext}'>{ext.upper()}</a>"
 | |
|                                                for ext in ["pdf", "tex", "odt", "docx"])
 | |
|             else:
 | |
|                 reviews_template_begin = f"{settings.STATIC_URL}eteam/Written_review."
 | |
|                 reviews_templates = " — ".join(f"<a href='{reviews_template_begin}{ext}'>{ext.upper()}</a>"
 | |
|                                                for ext in ["pdf", "tex"])
 | |
|             reviews_templates_content = "<p>" + _('Templates:') + f" {reviews_templates}</p>"
 | |
| 
 | |
|             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=tournament, pool__round=2, reporter=self)
 | |
|             opponent_passage = Passage.objects.get(pool__tournament=tournament, pool__round=2, opponent=self)
 | |
|             reviewer_passage = Passage.objects.get(pool__tournament=tournament, pool__round=2, reviewer=self)
 | |
|             observer_passage = Passage.objects.filter(pool__tournament=tournament, pool__round=2, observer=self)
 | |
|             observer_passage = observer_passage.get() if observer_passage.exists() else None
 | |
| 
 | |
|             reporter_text = _("<p>For the second round, you will present "
 | |
|                               "<a href='{solution_url}'>your solution of the problem {problem}</a>.</p>")
 | |
|             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 = _("<p>You will oppose the solution of the team {opponent} on the "
 | |
|                               "<a href='{solution_url}'>problem {problem}</a>. "
 | |
|                               "You can upload your written review on <a href='{passage_url}'>this page</a>.</p>")
 | |
|             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 = _("<p>You will report the solution of the team {reviewer} on the "
 | |
|                               "<a href='{solution_url}'>problem {problem}</a>. "
 | |
|                               "You can upload your written review on <a href='{passage_url}'>this page</a>.</p>")
 | |
|             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 = _("<p>You will observe the solution of the team {observer} on the "
 | |
|                                   "<a href='{solution_url}'>problem {problem}</a>. "
 | |
|                                   "You can upload your written review on <a href='{passage_url}'>this page</a>.</p>")
 | |
|                 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"<a href='{reviews_template_begin}{ext}'>{ext.upper()}</a>"
 | |
|                                                for ext in ["pdf", "tex", "odt", "docx"])
 | |
|             else:
 | |
|                 reviews_template_begin = f"{settings.STATIC_URL}eteam/Written_review."
 | |
|                 reviews_templates = " — ".join(f"<a href='{reviews_template_begin}{ext}'>{ext.upper()}</a>"
 | |
|                                                for ext in ["pdf", "tex"])
 | |
|             reviews_templates_content = "<p>" + _('Templates:') + f" {reviews_templates}</p>"
 | |
| 
 | |
|             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=tournament, pool__round=3, reporter=self)
 | |
|             opponent_passage = Passage.objects.get(pool__tournament=tournament, pool__round=3, opponent=self)
 | |
|             reviewer_passage = Passage.objects.get(pool__tournament=tournament, pool__round=3, reviewer=self)
 | |
|             observer_passage = Passage.objects.filter(pool__tournament=tournament, pool__round=3, observer=self)
 | |
|             observer_passage = observer_passage.get() if observer_passage.exists() else None
 | |
| 
 | |
|             reporter_text = _("<p>For the third round, you will present "
 | |
|                               "<a href='{solution_url}'>your solution of the problem {problem}</a>.</p>")
 | |
|             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 = _("<p>You will oppose the solution of the team {opponent} on the "
 | |
|                               "<a href='{solution_url}'>problem {problem}</a>. "
 | |
|                               "You can upload your written review on <a href='{passage_url}'>this page</a>.</p>")
 | |
|             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 = _("<p>You will report the solution of the team {reviewer} on the "
 | |
|                               "<a href='{solution_url}'>problem {problem}</a>. "
 | |
|                               "You can upload your written review on <a href='{passage_url}'>this page</a>.</p>")
 | |
|             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 = _("<p>You will observe the solution of the team {observer} on the "
 | |
|                                   "<a href='{solution_url}'>problem {problem}</a>. "
 | |
|                                   "You can upload your written review on <a href='{passage_url}'>this page</a>.</p>")
 | |
|                 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"<a href='{reviews_template_begin}{ext}'>{ext.upper()}</a>"
 | |
|                                                for ext in ["pdf", "tex", "odt", "docx"])
 | |
|             else:
 | |
|                 reviews_template_begin = f"{settings.STATIC_URL}eteam/Written_review."
 | |
|                 reviews_templates = " — ".join(f"<a href='{reviews_template_begin}{ext}'>{ext.upper()}</a>"
 | |
|                                                for ext in ["pdf", "tex"])
 | |
|             reviews_templates_content = "<p>" + _('Templates:') + f" {reviews_templates}</p>"
 | |
| 
 | |
|             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 = _("<p>The tournament {tournament} is ended. You can check the results on the "
 | |
|                      "<a href='{url}'>tournament page</a>.</p>")
 | |
|             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()
 | |
| 
 | |
|         if not pool_size or not passages.count():
 | |
|             # Not initialized yet
 | |
|             return
 | |
| 
 | |
|         # 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):
 | |
|         if not self.participations.count() or not self.passages.count():
 | |
|             # Not initialized yet
 | |
|             return
 | |
| 
 | |
|         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.5 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.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")
 |