plateforme-tfjm2/participation/models.py

2129 lines
94 KiB
Python

# Copyright (C) 2020 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import date, timedelta
import math
import os
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models
from django.db.models import Index
from django.urls import reverse_lazy
from django.utils import timezone, 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-{self.trigram.lower()}@{os.getenv('SYMPA_HOST', 'localhost')}"
def create_mailing_list(self):
"""
Create a new Sympa mailing list to contact the team.
"""
get_sympa_client().create_list(
f"equipe-{self.trigram.lower()}",
f"Equipe {self.name} ({self.trigram})",
"hotline", # TODO Use a custom sympa template
f"Liste de diffusion pour contacter l'equipe {self.name} du TFJM2",
"education",
raise_error=False,
)
def delete_mailing_list(self):
"""
Drop the Sympa mailing list, if the team is empty or if the trigram changed.
"""
if self.participation.valid: # pragma: no cover
get_sympa_client().unsubscribe(
self.email, f"equipes-{self.participation.tournament.name.lower().replace(' ', '-')}", False)
else:
get_sympa_client().unsubscribe(self.email, "equipes-non-valides", False)
get_sympa_client().delete_list(f"equipe-{self.trigram}")
def 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,
)
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-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}"
@property
def organizers_email(self):
"""
:return: The mailing list to contact the team members.
"""
return f"organisateurs-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}"
@property
def jurys_email(self):
"""
:return: The mailing list to contact the team members.
"""
return f"jurys-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}"
def create_mailing_lists(self):
"""
Create a new Sympa mailing list to contact the team.
"""
get_sympa_client().create_list(
f"equipes-{self.name.lower().replace(' ', '-')}",
f"Equipes du tournoi de {self.name}",
"hotline", # TODO Use a custom sympa template
f"Liste de diffusion pour contacter les equipes du tournoi {self.name} du TFJM²",
"education",
raise_error=False,
)
get_sympa_client().create_list(
f"organisateurs-{self.name.lower().replace(' ', '-')}",
f"Organisateurs du tournoi de {self.name}",
"hotline", # TODO Use a custom sympa template
f"Liste de diffusion pour contacter les equipes du tournoi {self.name} du TFJM²",
"education",
raise_error=False,
)
@staticmethod
def final_tournament():
qs = Tournament.objects.filter(final=True)
if qs.exists():
return qs.get()
@property
def participations(self):
if self.final:
return Participation.objects.filter(final=True)
return self.participation_set
@property
def solutions(self):
if self.final:
return Solution.objects.filter(final_solution=True)
return Solution.objects.filter(participation__tournament=self)
@property
def 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):
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.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=self.tournament, pool__round=1, reporter=self)
opponent_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=1, opponent=self)
reviewer_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=1, reviewer=self)
observer_passage = Passage.objects.filter(pool__tournament=self.tournament, pool__round=1, observer=self)
observer_passage = observer_passage.get() if observer_passage.exists() else None
reporter_text = _("<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=self.tournament, pool__round=2, reporter=self)
opponent_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=2, opponent=self)
reviewer_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=2, reviewer=self)
observer_passage = Passage.objects.filter(pool__tournament=self.tournament, pool__round=2, observer=self)
observer_passage = observer_passage.get() if observer_passage.exists() else None
reporter_text = _("<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=self.tournament, pool__round=3, reporter=self)
opponent_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=3, opponent=self)
reviewer_passage = Passage.objects.get(pool__tournament=self.tournament, pool__round=3, reviewer=self)
observer_passage = Passage.objects.filter(pool__tournament=self.tournament, pool__round=3, observer=self)
observer_passage = observer_passage.get() if observer_passage.exists() else None
reporter_text = _("<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()
# Create tournament sheet if it does not exist
self.tournament.create_spreadsheet()
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
spreadsheet = gc.open_by_key(self.tournament.notes_sheet_id)
worksheets = spreadsheet.worksheets()
if f"{_('Pool')} {self.short_name}" not in [ws.title for ws in worksheets]:
worksheet = spreadsheet.add_worksheet(f"{_('Pool')} {self.short_name}",
30, 2 + passages.count() * passage_width)
else:
worksheet = spreadsheet.worksheet(f"{_('Pool')} {self.short_name}")
if any(ws.title == "Sheet1" for ws in worksheets):
spreadsheet.del_worksheet(spreadsheet.worksheet("Sheet1"))
header = [
sum(([str(_("Problem #{problem}").format(problem=passage.solution_number))] + (passage_width - 1) * [""]
for passage in passages), start=[str(_("Problem")), ""]),
sum(([_('Reporter') + f" ({passage.reporter.team.trigram})", "",
_('Opponent') + f" ({passage.opponent.team.trigram})", "",
_('Reviewer') + f" ({passage.reviewer.team.trigram})", ""]
+ ([_('Observer') + f" ({passage.observer.team.trigram})", ""] if has_observer else [])
for passage in passages), start=[str(_("Role")), ""]),
sum(([_('Writing') + f" (/{20 if settings.TFJM_APP == 'TFJM' else 10})",
_('Oral') + f" (/{20 if settings.TFJM_APP == 'TFJM' else 10})",
_('Writing') + " (/10)", _('Oral') + " (/10)", _('Writing') + " (/10)", _('Oral') + " (/10)"]
+ ([_('Writing') + " (/10)", _('Oral') + " (/10)"] if has_observer else [])
for _passage in passages), start=[str(_("Juree")), ""]),
]
notes = [[]] # Begin with empty hidden line to ensure pretty design
for jury in self.juries.all():
line = [str(jury), jury.id]
for passage in passages:
note = passage.notes.filter(jury=jury).first()
line.extend([note.reporter_writing, note.reporter_oral, note.opponent_writing, note.opponent_oral,
note.reviewer_writing, note.reviewer_oral])
if has_observer:
line.extend([note.observer_writing, note.observer_oral])
notes.append(line)
notes.append([]) # Add empty line to ensure pretty design
def getcol(number: int) -> str:
"""
Translates the given number to the nth column name
"""
if number == 0:
return ''
return getcol((number - 1) // 26) + chr(65 + (number - 1) % 26)
average = [str(_("Average")), ""]
coeffs = sum(([passage.coeff_reporter_writing, passage.coeff_reporter_oral,
passage.coeff_opponent_writing, passage.coeff_opponent_oral,
passage.coeff_reviewer_writing, passage.coeff_reviewer_oral]
+ ([passage.coeff_observer_writing, passage.coeff_observer_oral] if has_observer else [])
for passage in passages),
start=[str(_("Coefficient")), ""])
subtotal = [str(_("Subtotal")), ""]
footer = [average, coeffs, subtotal, (2 + pool_size * passage_width) * [""]]
min_row = 5
max_row = min_row + self.juries.count()
min_column = 3
for i, passage in enumerate(passages):
for j, note in enumerate(passage.averages):
column = getcol(min_column + i * passage_width + j)
average.append(f"=SIERREUR(MOYENNE.SI(${getcol(min_column + i * passage_width)}${min_row - 1}"
f":${getcol(min_column + i * passage_width)}{max_row}; \">0\"; "
f"{column}${min_row - 1}:{column}{max_row});0)")
def_w_col = getcol(min_column + passage_width * i)
def_o_col = getcol(min_column + passage_width * i + 1)
subtotal.extend([f"={def_w_col}{max_row + 1} * {def_w_col}{max_row + 2}"
f" + {def_o_col}{max_row + 1} * {def_o_col}{max_row + 2}", ""])
opp_w_col = getcol(min_column + passage_width * i + 2)
opp_o_col = getcol(min_column + passage_width * i + 3)
subtotal.extend([f"={opp_w_col}{max_row + 1} * {opp_w_col}{max_row + 2}"
f" + {opp_o_col}{max_row + 1} * {opp_o_col}{max_row + 2}", ""])
rep_w_col = getcol(min_column + passage_width * i + 4)
rep_o_col = getcol(min_column + passage_width * i + 5)
subtotal.extend([f"={rep_w_col}{max_row + 1} * {rep_w_col}{max_row + 2}"
f" + {rep_o_col}{max_row + 1} * {rep_o_col}{max_row + 2}", ""])
if has_observer:
obs_w_col = getcol(min_column + passage_width * i + 6)
obs_o_col = getcol(min_column + passage_width * i + 7)
subtotal.extend([f"={obs_w_col}{max_row + 1} * {obs_w_col}{max_row + 2}"
f" + {obs_o_col}{max_row + 1} * {obs_o_col}{max_row + 2}", ""])
ranking = [
[str(_("Team")), "", str(_("Problem")), str(_("Total")), str(_("Rank"))],
]
all_passages = Passage.objects.filter(pool__tournament=self.tournament,
pool__round=self.round,
pool__letter=self.letter).order_by('position', 'pool__room')
for i, passage in enumerate(all_passages):
participation = passage.reporter
reporter_passage = Passage.objects.get(reporter=participation,
pool__tournament=self.tournament, pool__round=self.round)
reporter_row = 5 + reporter_passage.pool.juries.count()
reporter_col = reporter_passage.position - 1
opponent_passage = Passage.objects.get(opponent=participation,
pool__tournament=self.tournament, pool__round=self.round)
opponent_row = 5 + opponent_passage.pool.juries.count()
opponent_col = opponent_passage.position - 1
reviewer_passage = Passage.objects.get(reviewer=participation,
pool__tournament=self.tournament, pool__round=self.round)
reviewer_row = 5 + reviewer_passage.pool.juries.count()
reviewer_col = reviewer_passage.position - 1
formula = "="
formula += (f"'{_('Pool')} {reporter_passage.pool.short_name}'"
f"!{getcol(min_column + reporter_col * passage_width)}{reporter_row + 3}") # Reporter
formula += (f" + '{_('Pool')} {opponent_passage.pool.short_name}'"
f"!{getcol(min_column + opponent_col * passage_width + 2)}{opponent_row + 3}") # Opponent
formula += (f" + '{_('Pool')} {reviewer_passage.pool.short_name}'"
f"!{getcol(min_column + reviewer_col * passage_width + 4)}{reviewer_row + 3}") # reviewer
if has_observer:
observer_passage = Passage.objects.get(observer=participation,
pool__tournament=self.tournament, pool__round=self.round)
observer_row = 5 + observer_passage.pool.juries.count()
observer_col = observer_passage.position - 1
formula += (f" + '{_('Pool')} {observer_passage.pool.short_name}'"
f"!{getcol(min_column + observer_col * passage_width + 6)}{observer_row + 3}")
ranking.append([f"{participation.team.name} ({participation.team.trigram})", "",
f"='{_('Pool')} {reporter_passage.pool.short_name}'"
f"!${getcol(3 + reporter_col * passage_width)}$1",
formula,
f"=RANG(D{max_row + 6 + i}; "
f"D${max_row + 6}:D${max_row + 5 + pool_size})"])
all_values = header + notes + footer + ranking
max_col = getcol(2 + pool_size * passage_width)
worksheet.batch_clear([f"A1:{max_col}{max_row + 5 + pool_size}"])
worksheet.update(all_values, f"A1:{max_col}", raw=False)
format_requests = []
# Merge cells
merge_cells = ["A1:B1", "A2:B2", "A3:B3"]
for i, passage in enumerate(passages):
merge_cells.append(f"{getcol(3 + i * passage_width)}1:{getcol(2 + passage_width + i * passage_width)}1")
merge_cells.append(f"{getcol(3 + i * passage_width)}2:{getcol(4 + i * passage_width)}2")
merge_cells.append(f"{getcol(5 + i * passage_width)}2:{getcol(6 + i * passage_width)}2")
merge_cells.append(f"{getcol(7 + i * passage_width)}2:{getcol(8 + i * passage_width)}2")
merge_cells.append(f"{getcol(3 + i * passage_width)}{max_row + 3}"
f":{getcol(4 + i * passage_width)}{max_row + 3}")
merge_cells.append(f"{getcol(5 + i * passage_width)}{max_row + 3}"
f":{getcol(6 + i * passage_width)}{max_row + 3}")
merge_cells.append(f"{getcol(7 + i * passage_width)}{max_row + 3}"
f":{getcol(8 + i * passage_width)}{max_row + 3}")
if has_observer:
merge_cells.append(f"{getcol(9 + i * passage_width)}2:{getcol(10 + i * passage_width)}2")
merge_cells.append(f"{getcol(9 + i * passage_width)}{max_row + 3}"
f":{getcol(10 + i * passage_width)}{max_row + 3}")
merge_cells.append(f"A{max_row + 1}:B{max_row + 1}")
merge_cells.append(f"A{max_row + 2}:B{max_row + 2}")
merge_cells.append(f"A{max_row + 3}:B{max_row + 3}")
for i in range(pool_size + 1):
merge_cells.append(f"A{max_row + 5 + i}:B{max_row + 5 + i}")
format_requests.append({"unmergeCells": {"range": a1_range_to_grid_range(f"A1:{max_col}", worksheet.id)}})
for name in merge_cells:
grid_range = a1_range_to_grid_range(name, worksheet.id)
format_requests.append({"mergeCells": {"mergeType": MergeType.merge_all, "range": grid_range}})
# Make titles bold
bold_ranges = [(f"A1:{max_col}", False), (f"A1:{max_col}3", True),
(f"A{max_row + 1}:B{max_row + 3}", True), (f"A{max_row + 5}:E{max_row + 5}", True)]
for bold_range, bold in bold_ranges:
format_requests.append({
"repeatCell": {
"range": a1_range_to_grid_range(bold_range, worksheet.id),
"cell": {"userEnteredFormat": {"textFormat": {"bold": bold}}},
"fields": "userEnteredFormat(textFormat)",
}
})
# Set background color for headers and footers
bg_colors = [(f"A1:{max_col}", (1, 1, 1)),
(f"A1:{getcol(2 + passages.count() * passage_width)}3", (0.8, 0.8, 0.8)),
(f"A{min_row - 1}:B{max_row}", (0.95, 0.95, 0.95)),
(f"A{max_row + 1}:B{max_row + 3}", (0.8, 0.8, 0.8)),
(f"C{max_row + 1}:{getcol(2 + passages.count() * passage_width)}{max_row + 3}", (0.9, 0.9, 0.9)),
(f"A{max_row + 5}:E{max_row + 5}", (0.8, 0.8, 0.8)),
(f"A{max_row + 6}:E{max_row + 5 + pool_size}", (0.9, 0.9, 0.9)),]
# Display penalties in red
bg_colors += [(f"{getcol(2 + (passage.position - 1) * passage_width + 2)}{max_row + 2}", (1.0, 0.7, 0.7))
for passage in self.passages.filter(reporter_penalties__gte=1).all()]
for bg_range, bg_color in bg_colors:
r, g, b = bg_color
format_requests.append({
"repeatCell": {
"range": a1_range_to_grid_range(bg_range, worksheet.id),
"cell": {"userEnteredFormat": {"backgroundColor": {"red": r, "green": g, "blue": b}}},
"fields": "userEnteredFormat(backgroundColor)",
}
})
# Freeze 2 first columns
format_requests.append({
"updateSheetProperties": {
"properties": {
"sheetId": worksheet.id,
"gridProperties": {
"frozenRowCount": 0,
"frozenColumnCount": 2,
},
},
"fields": "gridProperties/frozenRowCount,gridProperties/frozenColumnCount",
}
})
# Set the width of the columns
column_widths = [("A", 350), ("B", 30)]
for passage in passages:
column_widths.append((f"{getcol(3 + passage_width * (passage.position - 1))}"
f":{getcol(2 + passage_width * passage.position)}", 80))
for column, width in column_widths:
grid_range = a1_range_to_grid_range(column, worksheet.id)
format_requests.append({
"updateDimensionProperties": {
"range": {
"sheetId": worksheet.id,
"dimension": "COLUMNS",
"startIndex": grid_range['startColumnIndex'],
"endIndex": grid_range['endColumnIndex'],
},
"properties": {
"pixelSize": width,
},
"fields": "pixelSize",
}
})
# Hide second column (Jury ID) and first and last jury rows
hidden_dimensions = [(1, "COLUMNS"), (3, "ROWS"), (max_row - 1, "ROWS")]
format_requests.append({
"updateDimensionProperties": {
"range": {
"sheetId": worksheet.id,
"dimension": "ROWS",
"startIndex": 0,
"endIndex": 1000,
},
"properties": {
"hiddenByUser": False,
},
"fields": "hiddenByUser",
}
})
for dimension_id, dimension_type in hidden_dimensions:
format_requests.append({
"updateDimensionProperties": {
"range": {
"sheetId": worksheet.id,
"dimension": dimension_type,
"startIndex": dimension_id,
"endIndex": dimension_id + 1,
},
"properties": {
"hiddenByUser": True,
},
"fields": "hiddenByUser",
}
})
# Define borders
border_ranges = [(f"A1:{max_col}", "0000"),
(f"A1:{getcol(2 + passages.count() * passage_width)}{max_row + 3}", "1111"),
(f"A{max_row + 5}:E{max_row + pool_size + 5}", "1111"),
(f"A1:B{max_row + 3}", "1113"),
(f"C1:{getcol(2 + (passages.count() - 1) * passage_width)}1", "1113")]
for i in range(passages.count() - 1):
border_ranges.append((f"{getcol(1 + (i + 1) * passage_width)}2"
f":{getcol(2 + (i + 1) * passage_width)}2", "1113"))
border_ranges.append((f"{getcol(2 + (i + 1) * passage_width)}3"
f":{getcol(2 + (i + 1) * passage_width)}{max_row + 2}", "1113"))
border_ranges.append((f"{getcol(1 + (i + 1) * passage_width)}{max_row + 3}"
f":{getcol(2 + (i + 1) * passage_width)}{max_row + 3}", "1113"))
sides_names = ['top', 'bottom', 'left', 'right']
styles = ["NONE", "SOLID", "SOLID_MEDIUM", "SOLID_THICK", "DOUBLE"]
for border_range, sides in border_ranges:
borders = {}
for side_name, side in zip(sides_names, sides):
borders[side_name] = {"style": styles[int(side)]}
format_requests.append({
"repeatCell": {
"range": a1_range_to_grid_range(border_range, worksheet.id),
"cell": {
"userEnteredFormat": {
"borders": borders,
"horizontalAlignment": "CENTER",
},
},
"fields": "userEnteredFormat(borders,horizontalAlignment)",
}
})
# Add range conditions
for i in range(passages.count()):
for j in range(passage_width):
column = getcol(min_column + i * passage_width + j)
min_note = 0 if j < 7 else -10
max_note = 20 if j < 2 and settings.TFJM_APP == "TFJM" else 10
format_requests.append({
"setDataValidation": {
"range": a1_range_to_grid_range(f"{column}{min_row - 1}:{column}{max_row}", worksheet.id),
"rule": {
"condition": {
"type": "CUSTOM_FORMULA",
"values": [{"userEnteredValue": f'=ET(REGEXMATCH(TO_TEXT({column}4); "^-?[0-9]+$"); '
f'{column}4>={min_note}; {column}4<={max_note})'},],
},
"inputMessage": str(_("Input must be a valid integer between {min_note} and {max_note}.")
.format(min_note=min_note, max_note=max_note)),
"strict": True,
},
}
})
# Set number format, display only one decimal
number_format_ranges = [f"C{max_row + 1}:{getcol(2 + passage_width * passages.count())}{max_row + 1}",
f"C{max_row + 3}:{getcol(2 + passage_width * passages.count())}{max_row + 3}",
f"D{max_row + 6}:D{max_row + 5 + passages.count()}",]
for number_format_range in number_format_ranges:
format_requests.append({
"repeatCell": {
"range": a1_range_to_grid_range(number_format_range, worksheet.id),
"cell": {"userEnteredFormat": {"numberFormat": {"type": "NUMBER", "pattern": "0.0"}}},
"fields": "userEnteredFormat.numberFormat",
}
})
# Remove old protected ranges
for protected_range in spreadsheet.list_protected_ranges(worksheet.id):
format_requests.append({
"deleteProtectedRange": {
"protectedRangeId": protected_range["protectedRangeId"],
}
})
# Protect the header, the juries list, the footer and the ranking
protected_ranges = [f"A1:{max_col}4",
f"A{min_row}:B{max_row}",
f"A{max_row}:{max_col}{max_row + 5 + pool_size}"]
for protected_range in protected_ranges:
format_requests.append({
"addProtectedRange": {
"protectedRange": {
"range": a1_range_to_grid_range(protected_range, worksheet.id),
"description": str(_("Don't update the table structure for a better automated integration.")),
"warningOnly": True,
},
}
})
body = {"requests": format_requests}
worksheet.client.batch_update(spreadsheet.id, body)
def update_juries_lines_spreadsheet(self):
translation.activate(settings.PREFERRED_LANGUAGE_CODE)
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
spreadsheet = gc.open_by_key(self.tournament.notes_sheet_id)
worksheet = spreadsheet.worksheet(f"{_('Pool')} {self.short_name}")
average_cell = worksheet.find(str(_("Average")))
min_row = 5
max_row = average_cell.row - 1
juries_visible = worksheet.get(f"A{min_row}:B{max_row}")
juries_visible = [t for t in juries_visible if t and len(t) == 2]
for i, (jury_name, jury_id) in enumerate(juries_visible):
if not jury_id.isnumeric() or int(jury_id) not in self.juries.values_list("id", flat=True):
print(f"Warning: {jury_name} ({jury_id}) appears on the sheet but is not part of the jury.")
for jury in self.juries.all():
if str(jury.id) not in list(map(lambda x: x[1], juries_visible)):
worksheet.insert_row([str(jury), jury.id], max_row)
max_row += 1
def parse_spreadsheet(self):
translation.activate(settings.PREFERRED_LANGUAGE_CODE)
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
self.tournament.create_spreadsheet()
spreadsheet = gc.open_by_key(self.tournament.notes_sheet_id)
worksheet = spreadsheet.worksheet(f"{_('Pool')} {self.short_name}")
average_cell = worksheet.find(str(_("Average")))
min_row = 5
max_row = average_cell.row - 2
data = worksheet.get_values(f"A{min_row}:AH{max_row}")
if not data or not data[0]:
return
has_observer = settings.HAS_OBSERVER and self.participations.count() >= 4
passage_width = 6 + (2 if has_observer else 0)
for line in data:
jury_name = line[0]
jury_id = line[1]
if not jury_id.isnumeric() or int(jury_id) not in self.juries.values_list("id", flat=True):
print(format_lazy(_("The jury {jury} is not part of the jury for this pool."), jury=jury_name))
continue
jury = self.juries.get(id=jury_id)
for i, passage in enumerate(self.passages.all()):
note = passage.notes.get(jury=jury)
note_line = line[2 + i * passage_width:2 + (i + 1) * passage_width]
if not note_line: # There is no note
continue
note.set_all(*note_line)
note.save()
def __str__(self):
return _("Pool {code} for tournament {tournament} with teams {teams}")\
.format(code=self.short_name,
tournament=str(self.tournament),
teams=", ".join(participation.team.trigram for participation in self.participations.all()))
class Meta:
verbose_name = _("pool")
verbose_name_plural = _("pools")
ordering = ('round', 'letter', 'room',)
class Passage(models.Model):
pool = models.ForeignKey(
Pool,
on_delete=models.CASCADE,
verbose_name=_("pool"),
related_name="passages",
)
position = models.PositiveSmallIntegerField(
verbose_name=_("position"),
choices=zip(range(1, 6), range(1, 6)),
default=1,
validators=[MinValueValidator(1), MaxValueValidator(5)],
)
solution_number = models.PositiveSmallIntegerField(
verbose_name=_("reported solution"),
choices=[
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, len(settings.PROBLEMS) + 1)
],
)
reporter = models.ForeignKey(
Participation,
on_delete=models.PROTECT,
verbose_name=_("reporter"),
related_name="+",
)
opponent = models.ForeignKey(
Participation,
on_delete=models.PROTECT,
verbose_name=_("opponent"),
related_name="+",
)
reviewer = models.ForeignKey(
Participation,
on_delete=models.PROTECT,
verbose_name=_("reviewer"),
related_name="+",
)
observer = models.ForeignKey(
Participation,
on_delete=models.SET_NULL,
verbose_name=_("observer"),
related_name="+",
null=True,
blank=True,
default=None,
)
reporter_penalties = models.PositiveSmallIntegerField(
verbose_name=_("penalties"),
default=0,
help_text=_("Number of penalties for the reporter. "
"The reporter will loose a 0.5 coefficient per penalty."),
)
@property
def reported_solution(self) -> "Solution":
return Solution.objects.get(
participation=self.reporter,
problem=self.solution_number,
final_solution=self.pool.tournament.final)
def avg(self, iterator) -> float:
items = [i for i in iterator if i]
return sum(items) / len(items) if items else 0
@property
def average_reporter_writing(self) -> float:
return self.avg(note.reporter_writing for note in self.notes.all())
@property
def coeff_reporter_writing(self) -> float:
return 1 if settings.TFJM_APP == "TFJM" else 2
@property
def average_reporter_oral(self) -> float:
return self.avg(note.reporter_oral for note in self.notes.all())
@property
def coeff_reporter_oral(self) -> float:
coeff = 1.6 if settings.TFJM_APP == "TFJM" else 3
coeff *= 1 - 0.25 * self.reporter_penalties
return coeff
@property
def average_reporter(self) -> float:
return (self.coeff_reporter_writing * self.average_reporter_writing
+ self.coeff_reporter_oral * self.average_reporter_oral)
@property
def average_opponent_writing(self) -> float:
return self.avg(note.opponent_writing for note in self.notes.all())
@property
def coeff_opponent_writing(self) -> float:
return 0.9 if not self.observer else 0.6
@property
def average_opponent_oral(self) -> float:
return self.avg(note.opponent_oral for note in self.notes.all())
@property
def coeff_opponent_oral(self) -> float:
return 2
@property
def average_opponent(self) -> float:
return (self.coeff_opponent_writing * self.average_opponent_writing
+ self.coeff_opponent_oral * self.average_opponent_oral)
@property
def average_reviewer_writing(self) -> float:
return self.avg(note.reviewer_writing for note in self.notes.all())
@property
def coeff_reviewer_writing(self):
return 0.9 if not self.observer else 0.6
@property
def average_reviewer_oral(self) -> float:
return self.avg(note.reviewer_oral for note in self.notes.all())
@property
def coeff_reviewer_oral(self):
return 1 if settings.TFJM_APP == "TFJM" else 1.2
@property
def average_reviewer(self) -> float:
return (self.coeff_reviewer_writing * self.average_reviewer_writing
+ self.coeff_reviewer_oral * self.average_reviewer_oral)
@property
def average_observer_writing(self) -> float:
return self.avg(note.observer_writing for note in self.notes.all())
@property
def coeff_observer_writing(self):
return 0.6
@property
def average_observer_oral(self) -> float:
return self.avg(note.observer_oral for note in self.notes.all())
@property
def coeff_observer_oral(self):
return 0.5
@property
def average_observer(self) -> float:
return (self.coeff_observer_writing * self.average_observer_writing
+ self.coeff_observer_oral * self.average_observer_oral)
@property
def averages(self):
yield self.average_reporter_writing
yield self.average_reporter_oral
yield self.average_opponent_writing
yield self.average_opponent_oral
yield self.average_reviewer_writing
yield self.average_reviewer_oral
if self.observer:
yield self.average_observer_writing
yield self.average_observer_oral
def average(self, participation):
avg = self.average_reporter if participation == self.reporter else self.average_opponent \
if participation == self.opponent else self.average_reviewer if participation == self.reviewer \
else self.average_observer if participation == self.observer else 0
avg *= self.pool.coeff
return avg
def get_absolute_url(self):
return reverse_lazy("participation:passage_detail", args=(self.pk,))
def clean(self):
if self.reporter not in self.pool.participations.all():
raise ValidationError(_("Team {trigram} is not registered in the pool.")
.format(trigram=self.reporter.team.trigram))
if self.opponent not in self.pool.participations.all():
raise ValidationError(_("Team {trigram} is not registered in the pool.")
.format(trigram=self.opponent.team.trigram))
if self.reviewer not in self.pool.participations.all():
raise ValidationError(_("Team {trigram} is not registered in the pool.")
.format(trigram=self.reviewer.team.trigram))
if self.observer and self.observer not in self.pool.participations.all():
raise ValidationError(_("Team {trigram} is not registered in the pool.")
.format(trigram=self.observer.team.trigram))
return super().clean()
def __str__(self):
return _("Passage of {reporter} for problem {problem}")\
.format(reporter=self.reporter.team, problem=self.solution_number)
class Meta:
verbose_name = _("passage")
verbose_name_plural = _("passages")
ordering = ('pool', 'position',)
class Tweak(models.Model):
pool = models.ForeignKey(
Pool,
on_delete=models.CASCADE,
verbose_name=_("passage"),
)
participation = models.ForeignKey(
Participation,
on_delete=models.CASCADE,
verbose_name=_("participation"),
related_name='tweaks',
)
diff = models.IntegerField(
verbose_name=_("difference"),
help_text=_("Score to add/remove on the final score"),
)
def __str__(self):
return f"Tweak for {self.participation.team} of {self.diff} points"
class Meta:
verbose_name = _("tweak")
verbose_name_plural = _("tweaks")
def get_solution_filename(instance, filename):
return f"solutions/{instance.participation.team.trigram}_{instance.problem}" \
+ ("_final" if instance.final_solution else "")
def get_review_filename(instance, filename):
return f"reviews/{instance.participation.team.trigram}_{instance.type}_{instance.passage.pk}"
def get_synthesis_filename(instance, filename):
return get_review_filename(instance, filename)
class Solution(models.Model):
participation = models.ForeignKey(
Participation,
on_delete=models.CASCADE,
verbose_name=_("participation"),
related_name="solutions",
)
problem = models.PositiveSmallIntegerField(
verbose_name=_("problem"),
choices=[
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, len(settings.PROBLEMS) + 1)
],
)
final_solution = models.BooleanField(
verbose_name=_("solution for the final tournament"),
default=False,
)
file = models.FileField(
verbose_name=_("file"),
upload_to=get_solution_filename,
unique=True,
)
@property
def tournament(self):
return Tournament.final_tournament() if self.final_solution else self.participation.tournament
def __str__(self):
return _("Solution of team {team} for problem {problem}")\
.format(team=self.participation.team.name, problem=self.problem)\
+ (" " + str(_("for final")) if self.final_solution else "")
class Meta:
verbose_name = _("solution")
verbose_name_plural = _("solutions")
unique_together = (('participation', 'problem', 'final_solution', ), )
ordering = ('participation__team__trigram', 'final_solution', 'problem',)
class WrittenReview(models.Model):
participation = models.ForeignKey(
Participation,
on_delete=models.CASCADE,
verbose_name=_("participation"),
)
passage = models.ForeignKey(
Passage,
on_delete=models.CASCADE,
related_name="written_reviews",
verbose_name=_("passage"),
)
type = models.PositiveSmallIntegerField(
choices=[
(1, _("opponent"), ),
(2, _("reviewer"), ),
(3, _("observer"), ),
]
)
file = models.FileField(
verbose_name=_("file"),
upload_to=get_synthesis_filename,
unique=True,
)
def __str__(self):
return _("Written review of {team} as {type} for problem {problem} of {reporter}").format(
team=self.participation.team.trigram,
type=self.get_type_display(),
problem=self.passage.solution_number,
reporter=self.passage.reporter.team.trigram,
)
class Meta:
verbose_name = _("written review")
verbose_name_plural = _("written reviews")
unique_together = (('participation', 'passage', 'type', ), )
ordering = ('passage__pool__round', 'type',)
class Note(models.Model):
jury = models.ForeignKey(
VolunteerRegistration,
on_delete=models.CASCADE,
verbose_name=_("jury"),
related_name="notes",
)
passage = models.ForeignKey(
Passage,
on_delete=models.CASCADE,
verbose_name=_("passage"),
related_name="notes",
)
reporter_writing = models.PositiveSmallIntegerField(
verbose_name=_("reporter writing note"),
choices=[(i, i) for i in range(0, 21)],
default=0,
)
reporter_oral = models.PositiveSmallIntegerField(
verbose_name=_("reporter oral note"),
choices=[(i, i) for i in range(0, 21)],
default=0,
)
opponent_writing = models.PositiveSmallIntegerField(
verbose_name=_("opponent writing note"),
choices=[(i, i) for i in range(0, 11)],
default=0,
)
opponent_oral = models.PositiveSmallIntegerField(
verbose_name=_("opponent oral note"),
choices=[(i, i) for i in range(0, 11)],
default=0,
)
reviewer_writing = models.PositiveSmallIntegerField(
verbose_name=_("reviewer writing note"),
choices=[(i, i) for i in range(0, 11)],
default=0,
)
reviewer_oral = models.PositiveSmallIntegerField(
verbose_name=_("reviewer oral note"),
choices=[(i, i) for i in range(0, 11)],
default=0,
)
observer_writing = models.PositiveSmallIntegerField(
verbose_name=_("observer writing note"),
choices=[(i, i) for i in range(0, 11)],
default=0,
)
observer_oral = models.SmallIntegerField(
verbose_name=_("observer oral note"),
choices=[(i, i) for i in range(-10, 11)],
default=0,
)
def get_all(self):
yield self.reporter_writing
yield self.reporter_oral
yield self.opponent_writing
yield self.opponent_oral
yield self.reviewer_writing
yield self.reviewer_oral
if self.passage.observer:
yield self.observer_writing
yield self.observer_oral
def set_all(self, reporter_writing: int, reporter_oral: int, opponent_writing: int, opponent_oral: int,
reviewer_writing: int, reviewer_oral: int, observer_writing: int = 0, observer_oral: int = 0):
self.reporter_writing = reporter_writing
self.reporter_oral = reporter_oral
self.opponent_writing = opponent_writing
self.opponent_oral = opponent_oral
self.reviewer_writing = reviewer_writing
self.reviewer_oral = reviewer_oral
self.observer_writing = observer_writing
self.observer_oral = observer_oral
def update_spreadsheet(self):
if not self.has_any_note():
return
translation.activate(settings.PREFERRED_LANGUAGE_CODE)
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
passage = Passage.objects.prefetch_related('pool__tournament', 'pool__participations').get(pk=self.passage.pk)
spreadsheet_id = passage.pool.tournament.notes_sheet_id
spreadsheet = gc.open_by_key(spreadsheet_id)
worksheet = spreadsheet.worksheet(f"{_('Pool')} {passage.pool.short_name}")
jury_id_cell = worksheet.find(str(self.jury_id), in_column=2)
if not jury_id_cell:
raise ValueError("The jury ID cell was not found in the spreadsheet.")
jury_row = jury_id_cell.row
passage_width = 6 + (2 if passage.observer else 0)
def getcol(number: int) -> str:
if number == 0:
return ''
return getcol((number - 1) // 26) + chr(65 + (number - 1) % 26)
min_col = getcol(3 + (self.passage.position - 1) * passage_width)
max_col = getcol(3 + self.passage.position * passage_width - 1)
worksheet.update([list(self.get_all())], f"{min_col}{jury_row}:{max_col}{jury_row}")
def get_absolute_url(self):
return reverse_lazy("participation:passage_detail", args=(self.passage.pk,))
@property
def modal_name(self):
return f"updateNotes{self.pk}"
def has_any_note(self):
return any(self.get_all())
def __str__(self):
return _("Notes of {jury} for {passage}").format(jury=self.jury, passage=self.passage)
class Meta:
verbose_name = _("note")
verbose_name_plural = _("notes")