1716 lines
71 KiB
Python
1716 lines
71 KiB
Python
# Copyright (C) 2020 by Animath
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
from datetime import date, timedelta
|
|
import os
|
|
|
|
from django.conf import settings
|
|
from django.core.exceptions import ValidationError
|
|
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
|
|
from django.db import models
|
|
from django.db.models import Index
|
|
from django.urls import reverse_lazy
|
|
from django.utils import timezone
|
|
from django.utils.crypto import get_random_string
|
|
from django.utils.text import format_lazy
|
|
from django.utils.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 TFJM².
|
|
This only includes the registration detail.
|
|
"""
|
|
name = models.CharField(
|
|
max_length=255,
|
|
verbose_name=_("name"),
|
|
unique=True,
|
|
)
|
|
|
|
trigram = models.CharField(
|
|
max_length=3,
|
|
verbose_name=_("trigram"),
|
|
help_text=_("The trigram must be composed of three uppercase letters."),
|
|
unique=True,
|
|
validators=[
|
|
RegexValidator(r"^[A-Z]{3}$"),
|
|
RegexValidator(fr"^(?!{'|'.join(f'{t}$' for t in settings.FORBIDDEN_TRIGRAMS)})",
|
|
message=_("This trigram is forbidden.")),
|
|
],
|
|
)
|
|
|
|
access_code = models.CharField(
|
|
max_length=6,
|
|
verbose_name=_("access code"),
|
|
help_text=_("The access code let other people to join the team."),
|
|
)
|
|
|
|
motivation_letter = models.FileField(
|
|
verbose_name=_("motivation letter"),
|
|
upload_to=get_motivation_letter_filename,
|
|
blank=True,
|
|
default="",
|
|
)
|
|
|
|
@property
|
|
def students(self):
|
|
return self.participants.filter(studentregistration__isnull=False)
|
|
|
|
@property
|
|
def coaches(self):
|
|
return self.participants.filter(coachregistration__isnull=False)
|
|
|
|
def can_validate(self):
|
|
if any(not r.email_confirmed for r in self.participants.all()):
|
|
return False
|
|
if self.students.count() < 4:
|
|
return False
|
|
if not self.coaches.exists():
|
|
return False
|
|
if not self.participation.tournament:
|
|
return False
|
|
if any(not r.photo_authorization for r in self.participants.all()):
|
|
return False
|
|
if not self.motivation_letter:
|
|
return False
|
|
if not self.participation.tournament.remote:
|
|
if any(r.under_18 and not r.health_sheet for r in self.students.all()):
|
|
return False
|
|
if any(r.under_18 and not r.vaccine_sheet for r in self.students.all()):
|
|
return False
|
|
if any(r.under_18 and not r.parental_authorization for r in self.students.all()):
|
|
return False
|
|
return True
|
|
|
|
def important_informations(self):
|
|
informations = []
|
|
|
|
if self.participation.valid is None:
|
|
if not self.participation.tournament:
|
|
text = _("The team {trigram} is not registered to any tournament. "
|
|
"You can register the team to a tournament using <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 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 save(self, *args, **kwargs):
|
|
if not self.access_code:
|
|
# if the team got created, generate the access code, create the contact mailing list
|
|
self.access_code = get_random_string(6)
|
|
self.create_mailing_list()
|
|
|
|
return super().save(*args, **kwargs)
|
|
|
|
def get_absolute_url(self):
|
|
return reverse_lazy("participation:team_detail", args=(self.pk,))
|
|
|
|
def __str__(self):
|
|
return _("Team {name} ({trigram})").format(name=self.name, trigram=self.trigram)
|
|
|
|
class Meta:
|
|
verbose_name = _("team")
|
|
verbose_name_plural = _("teams")
|
|
ordering = ('trigram',)
|
|
indexes = [
|
|
Index(fields=("trigram", )),
|
|
]
|
|
|
|
|
|
class Tournament(models.Model):
|
|
name = models.CharField(
|
|
max_length=255,
|
|
verbose_name=_("name"),
|
|
unique=True,
|
|
)
|
|
|
|
date_start = models.DateField(
|
|
verbose_name=_("start"),
|
|
default=date.today,
|
|
)
|
|
|
|
date_end = models.DateField(
|
|
verbose_name=_("end"),
|
|
default=date.today,
|
|
)
|
|
|
|
place = models.CharField(
|
|
max_length=255,
|
|
verbose_name=_("place"),
|
|
)
|
|
|
|
max_teams = models.PositiveSmallIntegerField(
|
|
verbose_name=_("max team count"),
|
|
default=9,
|
|
)
|
|
|
|
price = models.PositiveSmallIntegerField(
|
|
verbose_name=_("price"),
|
|
default=21,
|
|
)
|
|
|
|
remote = models.BooleanField(
|
|
verbose_name=_("remote"),
|
|
default=False,
|
|
)
|
|
|
|
inscription_limit = models.DateTimeField(
|
|
verbose_name=_("limit date for registrations"),
|
|
default=timezone.now,
|
|
)
|
|
|
|
solution_limit = models.DateTimeField(
|
|
verbose_name=_("limit date to upload solutions"),
|
|
default=timezone.now,
|
|
)
|
|
|
|
solutions_draw = models.DateTimeField(
|
|
verbose_name=_("random draw for solutions"),
|
|
default=timezone.now,
|
|
)
|
|
|
|
syntheses_first_phase_limit = models.DateTimeField(
|
|
verbose_name=_("limit date to upload the syntheses for the first phase"),
|
|
default=timezone.now,
|
|
)
|
|
|
|
solutions_available_second_phase = models.DateTimeField(
|
|
verbose_name=_("date when the solutions for the second round become available"),
|
|
default=timezone.now,
|
|
)
|
|
|
|
syntheses_second_phase_limit = models.DateTimeField(
|
|
verbose_name=_("limit date to upload the syntheses for the second phase"),
|
|
default=timezone.now,
|
|
)
|
|
|
|
description = models.TextField(
|
|
verbose_name=_("description"),
|
|
blank=True,
|
|
)
|
|
|
|
organizers = models.ManyToManyField(
|
|
VolunteerRegistration,
|
|
verbose_name=_("organizers"),
|
|
related_name="organized_tournaments",
|
|
)
|
|
|
|
final = models.BooleanField(
|
|
verbose_name=_("final"),
|
|
default=False,
|
|
)
|
|
|
|
notes_sheet_id = models.CharField(
|
|
max_length=64,
|
|
blank=True,
|
|
default="",
|
|
verbose_name=_("Google Sheet ID"),
|
|
)
|
|
|
|
@property
|
|
def teams_email(self):
|
|
"""
|
|
:return: The mailing list to contact the team members.
|
|
"""
|
|
return f"equipes-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}"
|
|
|
|
@property
|
|
def organizers_email(self):
|
|
"""
|
|
:return: The mailing list to contact the team members.
|
|
"""
|
|
return f"organisateurs-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}"
|
|
|
|
@property
|
|
def jurys_email(self):
|
|
"""
|
|
:return: The mailing list to contact the team members.
|
|
"""
|
|
return f"jurys-{self.name.lower().replace(' ', '-')}@{os.getenv('SYMPA_HOST', 'localhost')}"
|
|
|
|
def create_mailing_lists(self):
|
|
"""
|
|
Create a new Sympa mailing list to contact the team.
|
|
"""
|
|
get_sympa_client().create_list(
|
|
f"equipes-{self.name.lower().replace(' ', '-')}",
|
|
f"Equipes du tournoi de {self.name}",
|
|
"hotline", # TODO Use a custom sympa template
|
|
f"Liste de diffusion pour contacter les equipes du tournoi {self.name} du TFJM²",
|
|
"education",
|
|
raise_error=False,
|
|
)
|
|
get_sympa_client().create_list(
|
|
f"organisateurs-{self.name.lower().replace(' ', '-')}",
|
|
f"Organisateurs du tournoi de {self.name}",
|
|
"hotline", # TODO Use a custom sympa template
|
|
f"Liste de diffusion pour contacter les equipes du tournoi {self.name} du TFJM²",
|
|
"education",
|
|
raise_error=False,
|
|
)
|
|
|
|
@staticmethod
|
|
def final_tournament():
|
|
qs = Tournament.objects.filter(final=True)
|
|
if qs.exists():
|
|
return qs.get()
|
|
|
|
@property
|
|
def participations(self):
|
|
if self.final:
|
|
return Participation.objects.filter(final=True)
|
|
return self.participation_set
|
|
|
|
@property
|
|
def solutions(self):
|
|
if self.final:
|
|
return Solution.objects.filter(final_solution=True)
|
|
return Solution.objects.filter(participation__tournament=self)
|
|
|
|
@property
|
|
def syntheses(self):
|
|
if self.final:
|
|
return Synthesis.objects.filter(final_solution=True)
|
|
return Synthesis.objects.filter(participation__tournament=self)
|
|
|
|
@property
|
|
def best_format(self):
|
|
n = len(self.participations.filter(valid=True).all())
|
|
fmt = [n] if n <= 5 else [3] * (n // 3 - 1) + [3 + n % 3]
|
|
return '+'.join(map(str, sorted(fmt)))
|
|
|
|
def create_spreadsheet(self):
|
|
if self.notes_sheet_id:
|
|
return self.notes_sheet_id
|
|
|
|
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
|
|
spreadsheet = gc.create(f"Feuille de notes - {self.name}", folder_id=settings.NOTES_DRIVE_FOLDER_ID)
|
|
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):
|
|
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
|
|
spreadsheet = gc.open_by_key(self.notes_sheet_id)
|
|
worksheets = spreadsheet.worksheets()
|
|
if "Classement final" not in [ws.title for ws in worksheets]:
|
|
worksheet = spreadsheet.add_worksheet("Classement final", 100, 26)
|
|
else:
|
|
worksheet = spreadsheet.worksheet("Classement final")
|
|
|
|
if worksheet.index != self.pools.count():
|
|
worksheet.update_index(self.pools.count())
|
|
|
|
header = [["Équipe", "Score jour 1", "Harmonisation 1", "Score jour 2", "Harmonisation 2", "Total", "Rang"]]
|
|
lines = []
|
|
participations = self.participations.filter(pools__round=1, pools__tournament=self).all()
|
|
for i, participation in enumerate(participations):
|
|
line = [f"{participation.team.name} ({participation.team.trigram})"]
|
|
lines.append(line)
|
|
|
|
pool1 = self.pools.get(round=1, participations=participation)
|
|
passage1 = pool1.passages.get(defender=participation)
|
|
tweak1_qs = Tweak.objects.filter(pool=pool1, participation=participation)
|
|
tweak1 = tweak1_qs.get() if tweak1_qs.exists() else None
|
|
|
|
line.append(f"=SIERREUR('Poule {pool1.short_name}'!$D{pool1.juries.count() + 10 + passage1.position}; 0)")
|
|
line.append(tweak1.diff if tweak1 else 0)
|
|
|
|
if self.pools.filter(round=2, participations=participation).exists():
|
|
pool2 = self.pools.get(round=2, participations=participation)
|
|
passage2 = pool2.passages.get(defender=participation)
|
|
tweak2_qs = Tweak.objects.filter(pool=pool2, participation=participation)
|
|
tweak2 = tweak2_qs.get() if tweak2_qs.exists() else None
|
|
|
|
line.append(
|
|
f"=SIERREUR('Poule {pool2.short_name}'!$D{pool2.juries.count() + 10 + passage2.position}; 0)")
|
|
line.append(tweak2.diff if tweak2 else 0)
|
|
else:
|
|
# User has no second pool yet
|
|
line.append(0)
|
|
line.append(0)
|
|
|
|
line.append(f"=$B{i + 2} + $C{i + 2} + $D{i + 2} + E{i + 2}")
|
|
line.append(f"=RANG($F{i + 2}; $F$2:$F${participations.count() + 1})")
|
|
|
|
final_ranking = [["", "", ""], ["", "", ""], ["Équipe", "Score", "Rang"],
|
|
[f"=SORT($A$2:$A${participations.count() + 1}; "
|
|
f"$F$2:$F${participations.count() + 1}; FALSE)",
|
|
f"=SORT($F$2:$F${participations.count() + 1}; "
|
|
f"$F$2:$F${participations.count() + 1}; FALSE)",
|
|
f"=SORT($G$2:$G${participations.count() + 1}; "
|
|
f"$F$2:$F${participations.count() + 1}; FALSE)", ]]
|
|
|
|
data = header + lines + final_ranking
|
|
worksheet.update(data, f"A1:G{participations.count() + 5}", raw=False)
|
|
|
|
format_requests = []
|
|
|
|
# Set the width of the columns
|
|
column_widths = [("A", 300), ("B", 120), ("C", 120), ("D", 120), ("E", 120), ("F", 120), ("G", 120)]
|
|
for column, width in column_widths:
|
|
grid_range = a1_range_to_grid_range(column, worksheet.id)
|
|
format_requests.append({
|
|
"updateDimensionProperties": {
|
|
"range": {
|
|
"sheetId": worksheet.id,
|
|
"dimension": "COLUMNS",
|
|
"startIndex": grid_range['startColumnIndex'],
|
|
"endIndex": grid_range['endColumnIndex'],
|
|
},
|
|
"properties": {
|
|
"pixelSize": width,
|
|
},
|
|
"fields": "pixelSize",
|
|
}
|
|
})
|
|
|
|
# Set borders
|
|
border_ranges = [("A1:AF", "0000"),
|
|
(f"A1:G{participations.count() + 1}", "1111"),
|
|
(f"A{participations.count() + 4}:C{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:AF", False), ("A1:G1", True),
|
|
(f"A{participations.count() + 4}:C{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:AF", (1, 1, 1)),
|
|
("A1:G1", (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"F2:G{participations.count() + 1}", (0.9, 0.9, 0.9)),
|
|
(f"A{participations.count() + 4}:C{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)),]
|
|
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"G2:G{participations.count() + 1}", "0"),
|
|
(f"B{participations.count() + 5}:B{2 * participations.count() + 5}", "0.0"),
|
|
(f"C{participations.count() + 5}:C{2 * participations.count() + 5}", "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:G1", f"A2:B{participations.count() + 1}",
|
|
f"D2:D{participations.count() + 1}", f"F2:G{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": "Structure du tableur à ne pas modifier "
|
|
"pour une meilleure prise en charge automatisée",
|
|
"warningOnly": True,
|
|
},
|
|
}
|
|
})
|
|
|
|
body = {"requests": format_requests}
|
|
worksheet.client.batch_update(spreadsheet.id, body)
|
|
|
|
def parse_tweaks_spreadskeets(self):
|
|
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
|
|
spreadsheet = gc.open_by_key(self.notes_sheet_id)
|
|
worksheet = spreadsheet.worksheet("Classement final")
|
|
|
|
score_cell = worksheet.find("Score")
|
|
max_row = score_cell.row - 3
|
|
data = worksheet.get_values(f"A2:E{max_row}")
|
|
for line in data:
|
|
trigram = line[0][-4:-1]
|
|
participation = self.participations.get(team__trigram=trigram)
|
|
pool1 = self.pools.get(round=1, participations=participation)
|
|
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)
|
|
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})
|
|
|
|
def get_absolute_url(self):
|
|
return reverse_lazy("participation:tournament_detail", args=(self.pk,))
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
class Meta:
|
|
verbose_name = _("tournament")
|
|
verbose_name_plural = _("tournaments")
|
|
indexes = [
|
|
Index(fields=("name", "date_start", "date_end", )),
|
|
]
|
|
|
|
|
|
class Participation(models.Model):
|
|
"""
|
|
The Participation model contains all data that are related to the participation:
|
|
chosen problem, validity status, solutions,...
|
|
"""
|
|
team = models.OneToOneField(
|
|
Team,
|
|
on_delete=models.CASCADE,
|
|
verbose_name=_("team"),
|
|
)
|
|
|
|
tournament = models.ForeignKey(
|
|
Tournament,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
default=None,
|
|
verbose_name=_("tournament"),
|
|
)
|
|
|
|
valid = models.BooleanField(
|
|
null=True,
|
|
default=None,
|
|
verbose_name=_("valid team"),
|
|
help_text=_("The participation got the validation of the organizers."),
|
|
)
|
|
|
|
final = models.BooleanField(
|
|
default=False,
|
|
verbose_name=_("selected for final"),
|
|
help_text=_("The team is selected for the final tournament."),
|
|
)
|
|
|
|
def get_absolute_url(self):
|
|
return reverse_lazy("participation:participation_detail", args=(self.pk,))
|
|
|
|
def __str__(self):
|
|
return _("Participation of the team {name} ({trigram})").format(name=self.team.name, trigram=self.team.trigram)
|
|
|
|
def important_informations(self):
|
|
informations = []
|
|
|
|
missing_payments = Payment.objects.filter(registrations__in=self.team.participants.all(), valid=False)
|
|
if missing_payments.exists():
|
|
text = _("<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 timezone.now() <= self.tournament.solution_limit + timedelta(hours=4):
|
|
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=self.tournament.name, date=localtime(self.tournament.solution_limit),
|
|
nb_solutions=self.solutions.count(), min_solutions=len(settings.PROBLEMS) - 3,
|
|
url=url)
|
|
informations.append({
|
|
'title': _("Solutions due"),
|
|
'type': "info",
|
|
'priority': 1,
|
|
'content': content,
|
|
})
|
|
elif timezone.now() <= self.tournament.solutions_draw + timedelta(hours=4):
|
|
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=self.tournament.name,
|
|
date=localtime(self.tournament.solutions_draw), url=url)
|
|
informations.append({
|
|
'title': _("Draw of solutions"),
|
|
'type': "info",
|
|
'priority': 1,
|
|
'content': content,
|
|
})
|
|
elif timezone.now() <= self.tournament.syntheses_first_phase_limit + timedelta(hours=4):
|
|
pool = self.pools.get(round=1, tournament=self.tournament)
|
|
defender_passage = pool.passages.get(defender=self)
|
|
opponent_passage = pool.passages.get(opponent=self)
|
|
reporter_passage = pool.passages.get(reporter=self)
|
|
|
|
defender_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 defend "
|
|
"<a href='{solution_url}'>your solution of the problem {problem}</a>.</p>")
|
|
draw_url = reverse_lazy("draw:index")
|
|
solution_url = reverse_lazy("participation:solution_detail", args=(defender_passage.defended_solution.pk,))
|
|
defender_content = format_lazy(defender_text, draw_url=draw_url,
|
|
solution_url=solution_url, problem=defender_passage.problem)
|
|
|
|
opponent_text = _("<p>You will oppose the solution of the team {opponent} on the problem {problem}. "
|
|
"You can upload your synthesis sheet on <a href='{passage_url}'>this page</a>.</p>")
|
|
passage_url = reverse_lazy("participation:passage_detail", args=(opponent_passage.pk,))
|
|
opponent_content = format_lazy(opponent_text, opponent=opponent_passage.defender.team.trigram,
|
|
problem=opponent_passage.problem, passage_url=passage_url)
|
|
|
|
reporter_text = _("<p>You will report the solution of the team {reporter} on the problem {problem}. "
|
|
"You can upload your synthesis sheet on <a href='{passage_url}'>this page</a>.</p>")
|
|
passage_url = reverse_lazy("participation:passage_detail", args=(reporter_passage.pk,))
|
|
reporter_content = format_lazy(reporter_text, reporter=reporter_passage.defender.team.trigram,
|
|
problem=reporter_passage.problem, passage_url=passage_url)
|
|
|
|
content = defender_content + opponent_content + reporter_content
|
|
informations.append({
|
|
'title': _("First round"),
|
|
'type': "info",
|
|
'priority': 1,
|
|
'content': content,
|
|
})
|
|
elif timezone.now() <= self.tournament.syntheses_second_phase_limit + timedelta(hours=4):
|
|
pool = self.pools.get(round=2, tournament=self.tournament)
|
|
defender_passage = pool.passages.get(defender=self)
|
|
opponent_passage = pool.passages.get(opponent=self)
|
|
reporter_passage = pool.passages.get(reporter=self)
|
|
|
|
defender_text = _("<p>For the second round, you will defend "
|
|
"<a href='{solution_url}'>your solution of the problem {problem}</a>.</p>")
|
|
draw_url = reverse_lazy("draw:index")
|
|
solution_url = reverse_lazy("participation:solution_detail", args=(defender_passage.defended_solution.pk,))
|
|
defender_content = format_lazy(defender_text, draw_url=draw_url,
|
|
solution_url=solution_url, problem=defender_passage.problem)
|
|
|
|
opponent_text = _("<p>You will oppose the solution of the team {opponent} on the problem {problem}. "
|
|
"You can upload your synthesis sheet on <a href='{passage_url}'>this page</a>.</p>")
|
|
passage_url = reverse_lazy("participation:passage_detail", args=(opponent_passage.pk,))
|
|
opponent_content = format_lazy(opponent_text, opponent=opponent_passage.defender.team.trigram,
|
|
problem=opponent_passage.problem, passage_url=passage_url)
|
|
|
|
reporter_text = _("<p>You will report the solution of the team {reporter} on the problem {problem}. "
|
|
"You can upload your synthesis sheet on <a href='{passage_url}'>this page</a>.</p>")
|
|
passage_url = reverse_lazy("participation:passage_detail", args=(reporter_passage.pk,))
|
|
reporter_content = format_lazy(reporter_text, reporter=reporter_passage.defender.team.trigram,
|
|
problem=reporter_passage.problem, passage_url=passage_url)
|
|
|
|
content = defender_content + opponent_content + reporter_content
|
|
informations.append({
|
|
'title': _("Second round"),
|
|
'type': "info",
|
|
'priority': 1,
|
|
'content': content,
|
|
})
|
|
elif not self.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=(self.tournament.pk,))
|
|
content = format_lazy(text, tournament=self.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)),
|
|
]
|
|
)
|
|
|
|
letter = models.PositiveSmallIntegerField(
|
|
choices=[
|
|
(1, 'A'),
|
|
(2, 'B'),
|
|
(3, 'C'),
|
|
(4, 'D'),
|
|
],
|
|
verbose_name=_('letter'),
|
|
)
|
|
|
|
participations = models.ManyToManyField(
|
|
Participation,
|
|
related_name="pools",
|
|
verbose_name=_("participations"),
|
|
)
|
|
|
|
juries = models.ManyToManyField(
|
|
VolunteerRegistration,
|
|
related_name="jury_in",
|
|
verbose_name=_("juries"),
|
|
)
|
|
|
|
jury_president = models.ForeignKey(
|
|
VolunteerRegistration,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
default=None,
|
|
related_name="pools_presided",
|
|
verbose_name=_("president of the jury"),
|
|
)
|
|
|
|
bbb_url = models.CharField(
|
|
max_length=255,
|
|
blank=True,
|
|
default="",
|
|
verbose_name=_("BigBlueButton URL"),
|
|
help_text=_("The link of the BBB visio for this pool."),
|
|
)
|
|
|
|
results_available = models.BooleanField(
|
|
default=False,
|
|
verbose_name=_("results available"),
|
|
help_text=_("Check this case when results become accessible to teams. "
|
|
"They stay accessible to you. Only averages are given."),
|
|
)
|
|
|
|
@property
|
|
def short_name(self):
|
|
return f"{self.get_letter_display()}{self.round}"
|
|
|
|
@property
|
|
def solutions(self):
|
|
return [passage.defended_solution for passage in self.passages.all()]
|
|
|
|
def average(self, participation):
|
|
return sum(passage.average(participation) for passage in self.passages.all()) \
|
|
+ sum(tweak.diff for tweak in participation.tweaks.filter(pool=self).all())
|
|
|
|
async def aaverage(self, participation):
|
|
return sum([passage.average(participation) async for passage in self.passages.all()]) \
|
|
+ sum([tweak.diff async for tweak in participation.tweaks.filter(pool=self).all()])
|
|
|
|
def get_absolute_url(self):
|
|
return reverse_lazy("participation:pool_detail", args=(self.pk,))
|
|
|
|
def validate_constraints(self, exclude=None):
|
|
if self.jury_president not in self.juries.all():
|
|
raise ValidationError({'jury_president': _("The president of the jury must be part of the jury.")})
|
|
return super().validate_constraints()
|
|
|
|
def update_spreadsheet(self): # noqa: C901
|
|
# 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"Poule {self.short_name}" not in [ws.title for ws in worksheets]:
|
|
worksheet = spreadsheet.add_worksheet(f"Poule {self.short_name}", 100, 32)
|
|
else:
|
|
worksheet = spreadsheet.worksheet(f"Poule {self.short_name}")
|
|
if any(ws.title == "Sheet1" for ws in worksheets):
|
|
spreadsheet.del_worksheet(spreadsheet.worksheet("Sheet1"))
|
|
|
|
pool_size = self.participations.count()
|
|
passage_width = 7 if pool_size == 4 else 6
|
|
passages = self.passages.all()
|
|
|
|
header = [
|
|
sum(([f"Problème {passage.solution_number}"] + (passage_width - 1) * [""]
|
|
for passage in passages), start=["Problème", ""]),
|
|
sum((["Défenseur⋅se", "", "Opposant⋅e", "", "Rapporteur⋅rice", ""]
|
|
+ (["Observateur⋅rice"] if pool_size == 4 else [])
|
|
for _passage in passages), start=["Rôle", ""]),
|
|
sum((["Écrit (/20)", "Oral (/20)", "Écrit (/10)", "Oral (/10)", "Écrit (/10)", "Oral (/10)"]
|
|
+ (["Oral (± 4)"] if pool_size == 4 else [])
|
|
for _passage in passages), start=["Juré⋅e", ""]),
|
|
]
|
|
|
|
notes = [[]] # 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.defender_writing, note.defender_oral, note.opponent_writing, note.opponent_oral,
|
|
note.reporter_writing, note.reporter_oral])
|
|
if pool_size == 4:
|
|
line.append(note.observer_oral)
|
|
notes.append(line)
|
|
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 = ["Moyenne", ""]
|
|
coeffs = sum(([1, 1.6 - 0.4 * passage.defender_penalties, 0.9, 2, 0.9, 1]
|
|
+ ([1] if pool_size == 4 else []) for passage in passages), start=["Coefficient", ""])
|
|
subtotal = ["Sous-total", ""]
|
|
footer = [average, coeffs, subtotal, 32 * [""]]
|
|
|
|
min_row = 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 pool_size == 4:
|
|
obs_col = getcol(min_column + passage_width * i + 6)
|
|
subtotal.append(f"={obs_col}{max_row + 1} * {obs_col}{max_row + 2}")
|
|
|
|
ranking = [
|
|
["Équipe", "", "Problème", "Total", "Rang"],
|
|
]
|
|
passage_matrix = []
|
|
match pool_size:
|
|
case 3:
|
|
passage_matrix = [
|
|
[0, 2, 1],
|
|
[1, 0, 2],
|
|
[2, 1, 0],
|
|
]
|
|
case 4:
|
|
passage_matrix = [
|
|
[0, 3, 2, 1],
|
|
[1, 0, 3, 2],
|
|
[2, 1, 0, 3],
|
|
[3, 2, 1, 0],
|
|
]
|
|
case 5:
|
|
passage_matrix = [
|
|
[0, 2, 3],
|
|
[1, 4, 2],
|
|
[2, 0, 4],
|
|
[3, 1, 0],
|
|
[4, 3, 1],
|
|
]
|
|
for passage in passages:
|
|
participation = passage.defender
|
|
passage_line = passage_matrix[passage.position - 1]
|
|
formula = "="
|
|
formula += getcol(min_column + passage_line[0] * passage_width) + str(max_row + 3) # Defender
|
|
formula += " + " + getcol(min_column + passage_line[1] * passage_width + 2) + str(max_row + 3) # Opponent
|
|
formula += " + " + getcol(min_column + passage_line[2] * passage_width + 4) + str(max_row + 3) # Reporter
|
|
if pool_size == 4:
|
|
# Observer
|
|
formula += " + " + getcol(min_column + passage_line[3] * passage_width + 6) + str(max_row + 3)
|
|
ranking.append([f"{participation.team.name} ({participation.team.trigram})", "",
|
|
f"=${getcol(3 + (passage.position - 1) * passage_width)}$1", formula,
|
|
f"=RANG(D{max_row + 5 + passage.position}; "
|
|
f"D${max_row + 6}:D${max_row + 5 + pool_size})"])
|
|
|
|
all_values = header + notes + footer + ranking
|
|
|
|
worksheet.batch_clear([f"A1:AF{max_row + 5 + pool_size}"])
|
|
worksheet.update("A1:AF", all_values, raw=False)
|
|
|
|
format_requests = []
|
|
|
|
# Merge cells
|
|
merge_cells = ["A1:B1", "A2:B2", "A3:B3"]
|
|
for i, passage in enumerate(passages):
|
|
merge_cells.append(f"{getcol(3 + i * passage_width)}1:{getcol(2 + passage_width + i * passage_width)}1")
|
|
|
|
merge_cells.append(f"{getcol(3 + i * passage_width)}2:{getcol(4 + i * passage_width)}2")
|
|
merge_cells.append(f"{getcol(5 + i * passage_width)}2:{getcol(6 + i * passage_width)}2")
|
|
merge_cells.append(f"{getcol(7 + i * passage_width)}2:{getcol(8 + i * passage_width)}2")
|
|
|
|
merge_cells.append(f"{getcol(3 + i * passage_width)}{max_row + 3}"
|
|
f":{getcol(4 + i * passage_width)}{max_row + 3}")
|
|
merge_cells.append(f"{getcol(5 + i * passage_width)}{max_row + 3}"
|
|
f":{getcol(6 + i * passage_width)}{max_row + 3}")
|
|
merge_cells.append(f"{getcol(7 + i * passage_width)}{max_row + 3}"
|
|
f":{getcol(8 + i * passage_width)}{max_row + 3}")
|
|
merge_cells.append(f"A{max_row + 1}:B{max_row + 1}")
|
|
merge_cells.append(f"A{max_row + 2}:B{max_row + 2}")
|
|
merge_cells.append(f"A{max_row + 3}:B{max_row + 3}")
|
|
|
|
for i in range(pool_size + 1):
|
|
merge_cells.append(f"A{max_row + 5 + i}:B{max_row + 5 + i}")
|
|
|
|
format_requests.append({"unmergeCells": {"range": a1_range_to_grid_range("A1:AF", worksheet.id)}})
|
|
for name in merge_cells:
|
|
grid_range = a1_range_to_grid_range(name, worksheet.id)
|
|
format_requests.append({"mergeCells": {"mergeType": MergeType.merge_all, "range": grid_range}})
|
|
|
|
# Make titles bold
|
|
bold_ranges = [("A1:AF", False), ("A1:AF3", 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 = [("A1:AF", (1, 1, 1)),
|
|
(f"A1:{getcol(2 + pool_size * 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 + pool_size * passage_width)}{max_row + 3}", (0.9, 0.9, 0.9)),
|
|
(f"A{max_row + 5}:E{max_row + 5}", (0.8, 0.8, 0.8)),
|
|
(f"A{max_row + 6}:E{max_row + 5 + pool_size}", (0.9, 0.9, 0.9)),]
|
|
for bg_range, bg_color in bg_colors:
|
|
r, g, b = bg_color
|
|
format_requests.append({
|
|
"repeatCell": {
|
|
"range": a1_range_to_grid_range(bg_range, worksheet.id),
|
|
"cell": {"userEnteredFormat": {"backgroundColor": {"red": r, "green": g, "blue": b}}},
|
|
"fields": "userEnteredFormat(backgroundColor)",
|
|
}
|
|
})
|
|
|
|
# Freeze 2 first columns
|
|
format_requests.append({
|
|
"updateSheetProperties": {
|
|
"properties": {
|
|
"sheetId": worksheet.id,
|
|
"gridProperties": {
|
|
"frozenRowCount": 0,
|
|
"frozenColumnCount": 2,
|
|
},
|
|
},
|
|
"fields": "gridProperties/frozenRowCount,gridProperties/frozenColumnCount",
|
|
}
|
|
})
|
|
|
|
# Set the width of the columns
|
|
column_widths = [("A", 300), ("B", 30)]
|
|
for passage in passages:
|
|
column_widths.append((f"{getcol(3 + passage_width * (passage.position - 1))}"
|
|
f":{getcol(8 + passage_width * (passage.position - 1))}", 75))
|
|
if pool_size == 4:
|
|
column_widths.append((getcol(9 + passage_width * (passage.position - 1)), 120))
|
|
for column, width in column_widths:
|
|
grid_range = a1_range_to_grid_range(column, worksheet.id)
|
|
format_requests.append({
|
|
"updateDimensionProperties": {
|
|
"range": {
|
|
"sheetId": worksheet.id,
|
|
"dimension": "COLUMNS",
|
|
"startIndex": grid_range['startColumnIndex'],
|
|
"endIndex": grid_range['endColumnIndex'],
|
|
},
|
|
"properties": {
|
|
"pixelSize": width,
|
|
},
|
|
"fields": "pixelSize",
|
|
}
|
|
})
|
|
|
|
# Hide second column (Jury ID) 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 = [("A1:AF", "0000"),
|
|
(f"A1:{getcol(2 + pool_size * passage_width)}{max_row + 3}", "1111"),
|
|
(f"A{max_row + 5}:E{max_row + pool_size + 5}", "1111"),
|
|
(f"A1:B{max_row + 3}", "1113"),
|
|
(f"C1:{getcol(2 + (pool_size - 1) * passage_width)}1", "1113")]
|
|
for i in range(pool_size - 1):
|
|
border_ranges.append((f"{getcol(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(pool_size):
|
|
for j in range(passage_width):
|
|
column = getcol(min_column + i * passage_width + j)
|
|
min_note = 0 if j < 6 else -4
|
|
max_note = 20 if j < 2 else 10 if j < 6 else 4
|
|
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": f"La saisie doit être un entier valide "
|
|
f"compris entre {min_note} et {max_note}.",
|
|
"strict": True,
|
|
},
|
|
}
|
|
})
|
|
|
|
# Set number format, display only one decimal
|
|
number_format_ranges = [f"C{max_row + 1}:{getcol(2 + passage_width * pool_size)}{max_row + 1}",
|
|
f"C{max_row + 3}:{getcol(2 + passage_width * pool_size)}{max_row + 3}",
|
|
f"D{max_row + 6}:D{max_row + 5 + pool_size}",]
|
|
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 = ["A1:AF4",
|
|
f"A{min_row}:B{max_row}",
|
|
f"A{max_row}:AF{max_row + 5 + pool_size}"]
|
|
for protected_range in protected_ranges:
|
|
format_requests.append({
|
|
"addProtectedRange": {
|
|
"protectedRange": {
|
|
"range": a1_range_to_grid_range(protected_range, worksheet.id),
|
|
"description": "Structure du tableur à ne pas modifier "
|
|
"pour une meilleure prise en charge automatisée",
|
|
"warningOnly": True,
|
|
},
|
|
}
|
|
})
|
|
|
|
body = {"requests": format_requests}
|
|
worksheet.client.batch_update(spreadsheet.id, body)
|
|
|
|
def update_juries_lines_spreadsheet(self):
|
|
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
|
|
spreadsheet = gc.open_by_key(self.tournament.notes_sheet_id)
|
|
worksheet = spreadsheet.worksheet(f"Poule {self.short_name}")
|
|
|
|
average_cell = worksheet.find("Moyenne")
|
|
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):
|
|
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"Poule {self.short_name}")
|
|
|
|
average_cell = worksheet.find("Moyenne")
|
|
min_row = 5
|
|
max_row = average_cell.row - 2
|
|
data = worksheet.get_values(f"A{min_row}:AF{max_row}")
|
|
if not data or not data[0]:
|
|
return
|
|
|
|
passage_width = 7 if self.participations.count() == 4 else 6
|
|
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]
|
|
note.set_all(*note_line)
|
|
note.save()
|
|
|
|
def __str__(self):
|
|
return _("Pool of day {round} for tournament {tournament} with teams {teams}")\
|
|
.format(round=self.round,
|
|
tournament=str(self.tournament),
|
|
teams=", ".join(participation.team.trigram for participation in self.participations.all()))
|
|
|
|
class Meta:
|
|
verbose_name = _("pool")
|
|
verbose_name_plural = _("pools")
|
|
ordering = ('round', 'letter',)
|
|
|
|
|
|
class Passage(models.Model):
|
|
pool = models.ForeignKey(
|
|
Pool,
|
|
on_delete=models.CASCADE,
|
|
verbose_name=_("pool"),
|
|
related_name="passages",
|
|
)
|
|
|
|
position = models.PositiveSmallIntegerField(
|
|
verbose_name=_("position"),
|
|
choices=zip(range(1, 6), range(1, 6)),
|
|
default=1,
|
|
validators=[MinValueValidator(1), MaxValueValidator(5)],
|
|
)
|
|
|
|
solution_number = models.PositiveSmallIntegerField(
|
|
verbose_name=_("defended solution"),
|
|
choices=[
|
|
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, len(settings.PROBLEMS) + 1)
|
|
],
|
|
)
|
|
|
|
defender = models.ForeignKey(
|
|
Participation,
|
|
on_delete=models.PROTECT,
|
|
verbose_name=_("defender"),
|
|
related_name="+",
|
|
)
|
|
|
|
opponent = models.ForeignKey(
|
|
Participation,
|
|
on_delete=models.PROTECT,
|
|
verbose_name=_("opponent"),
|
|
related_name="+",
|
|
)
|
|
|
|
reporter = models.ForeignKey(
|
|
Participation,
|
|
on_delete=models.PROTECT,
|
|
verbose_name=_("reporter"),
|
|
related_name="+",
|
|
)
|
|
|
|
observer = models.ForeignKey(
|
|
Participation,
|
|
on_delete=models.PROTECT,
|
|
null=True,
|
|
blank=True,
|
|
default=None,
|
|
verbose_name=_("observer"),
|
|
related_name="+",
|
|
)
|
|
|
|
defender_penalties = models.PositiveSmallIntegerField(
|
|
verbose_name=_("penalties"),
|
|
default=0,
|
|
help_text=_("Number of penalties for the defender. "
|
|
"The defender will loose a 0.5 coefficient per penalty."),
|
|
)
|
|
|
|
@property
|
|
def defended_solution(self) -> "Solution":
|
|
return Solution.objects.get(
|
|
participation=self.defender,
|
|
problem=self.solution_number,
|
|
final_solution=self.pool.tournament.final)
|
|
|
|
def avg(self, iterator) -> float:
|
|
items = [i for i in iterator if i]
|
|
return sum(items) / len(items) if items else 0
|
|
|
|
@property
|
|
def average_defender_writing(self) -> float:
|
|
return self.avg(note.defender_writing for note in self.notes.all())
|
|
|
|
@property
|
|
def average_defender_oral(self) -> float:
|
|
return self.avg(note.defender_oral for note in self.notes.all())
|
|
|
|
@property
|
|
def average_defender(self) -> float:
|
|
return self.average_defender_writing + (1.6 - 0.4 * self.defender_penalties) * self.average_defender_oral
|
|
|
|
@property
|
|
def average_opponent_writing(self) -> float:
|
|
return self.avg(note.opponent_writing for note in self.notes.all())
|
|
|
|
@property
|
|
def average_opponent_oral(self) -> float:
|
|
return self.avg(note.opponent_oral for note in self.notes.all())
|
|
|
|
@property
|
|
def average_opponent(self) -> float:
|
|
return 0.9 * self.average_opponent_writing + 2 * self.average_opponent_oral
|
|
|
|
@property
|
|
def average_reporter_writing(self) -> float:
|
|
return self.avg(note.reporter_writing for note in self.notes.all())
|
|
|
|
@property
|
|
def average_reporter_oral(self) -> float:
|
|
return self.avg(note.reporter_oral for note in self.notes.all())
|
|
|
|
@property
|
|
def average_reporter(self) -> float:
|
|
return 0.9 * self.average_reporter_writing + self.average_reporter_oral
|
|
|
|
@property
|
|
def average_observer(self) -> float:
|
|
return self.avg(note.observer_oral for note in self.notes.all())
|
|
|
|
@property
|
|
def averages(self):
|
|
yield self.average_defender_writing
|
|
yield self.average_defender_oral
|
|
yield self.average_opponent_writing
|
|
yield self.average_opponent_oral
|
|
yield self.average_reporter_writing
|
|
yield self.average_reporter_oral
|
|
if self.observer:
|
|
yield self.average_observer
|
|
|
|
def average(self, participation):
|
|
return self.average_defender if participation == self.defender else self.average_opponent \
|
|
if participation == self.opponent else self.average_reporter if participation == self.reporter \
|
|
else self.average_observer if participation == self.observer else 0
|
|
|
|
def get_absolute_url(self):
|
|
return reverse_lazy("participation:passage_detail", args=(self.pk,))
|
|
|
|
def clean(self):
|
|
if self.defender not in self.pool.participations.all():
|
|
raise ValidationError(_("Team {trigram} is not registered in the pool.")
|
|
.format(trigram=self.defender.team.trigram))
|
|
if self.opponent not in self.pool.participations.all():
|
|
raise ValidationError(_("Team {trigram} is not registered in the pool.")
|
|
.format(trigram=self.opponent.team.trigram))
|
|
if self.reporter not in self.pool.participations.all():
|
|
raise ValidationError(_("Team {trigram} is not registered in the pool.")
|
|
.format(trigram=self.reporter.team.trigram))
|
|
if self.observer and self.observer not in self.pool.participations.all():
|
|
raise ValidationError(_("Team {trigram} is not registered in the pool.")
|
|
.format(trigram=self.observer.team.trigram))
|
|
return super().clean()
|
|
|
|
def __str__(self):
|
|
return _("Passage of {defender} for problem {problem}")\
|
|
.format(defender=self.defender.team, problem=self.solution_number)
|
|
|
|
class Meta:
|
|
verbose_name = _("passage")
|
|
verbose_name_plural = _("passages")
|
|
ordering = ('pool', 'position',)
|
|
|
|
|
|
class Tweak(models.Model):
|
|
pool = models.ForeignKey(
|
|
Pool,
|
|
on_delete=models.CASCADE,
|
|
verbose_name=_("passage"),
|
|
)
|
|
|
|
participation = models.ForeignKey(
|
|
Participation,
|
|
on_delete=models.CASCADE,
|
|
verbose_name=_("participation"),
|
|
related_name='tweaks',
|
|
)
|
|
|
|
diff = models.IntegerField(
|
|
verbose_name=_("difference"),
|
|
help_text=_("Score to add/remove on the final score"),
|
|
)
|
|
|
|
def __str__(self):
|
|
return f"Tweak for {self.participation.team} of {self.diff} points"
|
|
|
|
class Meta:
|
|
verbose_name = _("tweak")
|
|
verbose_name_plural = _("tweaks")
|
|
|
|
|
|
def get_solution_filename(instance, filename):
|
|
return f"solutions/{instance.participation.team.trigram}_{instance.problem}" \
|
|
+ ("final" if instance.final_solution else "")
|
|
|
|
|
|
def get_synthesis_filename(instance, filename):
|
|
return f"syntheses/{instance.participation.team.trigram}_{instance.type}_{instance.passage.pk}"
|
|
|
|
|
|
class Solution(models.Model):
|
|
participation = models.ForeignKey(
|
|
Participation,
|
|
on_delete=models.CASCADE,
|
|
verbose_name=_("participation"),
|
|
related_name="solutions",
|
|
)
|
|
|
|
problem = models.PositiveSmallIntegerField(
|
|
verbose_name=_("problem"),
|
|
choices=[
|
|
(i, format_lazy(_("Problem #{problem}"), problem=i)) for i in range(1, len(settings.PROBLEMS) + 1)
|
|
],
|
|
)
|
|
|
|
final_solution = models.BooleanField(
|
|
verbose_name=_("solution for the final tournament"),
|
|
default=False,
|
|
)
|
|
|
|
file = models.FileField(
|
|
verbose_name=_("file"),
|
|
upload_to=get_solution_filename,
|
|
unique=True,
|
|
)
|
|
|
|
@property
|
|
def tournament(self):
|
|
return Tournament.final_tournament() if self.final_solution else self.participation.tournament
|
|
|
|
def __str__(self):
|
|
return _("Solution of team {team} for problem {problem}")\
|
|
.format(team=self.participation.team.name, problem=self.problem)\
|
|
+ (" " + str(_("for final")) if self.final_solution else "")
|
|
|
|
class Meta:
|
|
verbose_name = _("solution")
|
|
verbose_name_plural = _("solutions")
|
|
unique_together = (('participation', 'problem', 'final_solution', ), )
|
|
ordering = ('participation__team__trigram', 'final_solution', 'problem',)
|
|
|
|
|
|
class Synthesis(models.Model):
|
|
participation = models.ForeignKey(
|
|
Participation,
|
|
on_delete=models.CASCADE,
|
|
verbose_name=_("participation"),
|
|
)
|
|
|
|
passage = models.ForeignKey(
|
|
Passage,
|
|
on_delete=models.CASCADE,
|
|
related_name="syntheses",
|
|
verbose_name=_("passage"),
|
|
)
|
|
|
|
type = models.PositiveSmallIntegerField(
|
|
choices=[
|
|
(1, _("opponent"), ),
|
|
(2, _("reporter"), ),
|
|
]
|
|
)
|
|
|
|
file = models.FileField(
|
|
verbose_name=_("file"),
|
|
upload_to=get_synthesis_filename,
|
|
unique=True,
|
|
)
|
|
|
|
def __str__(self):
|
|
return _("Synthesis of {team} as {type} for problem {problem} of {defender}").format(
|
|
team=self.participation.team.trigram,
|
|
type=self.get_type_display(),
|
|
problem=self.passage.solution_number,
|
|
defender=self.passage.defender.team.trigram,
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("synthesis")
|
|
verbose_name_plural = _("syntheses")
|
|
unique_together = (('participation', 'passage', 'type', ), )
|
|
ordering = ('passage__pool__round', 'type',)
|
|
|
|
|
|
class Note(models.Model):
|
|
jury = models.ForeignKey(
|
|
VolunteerRegistration,
|
|
on_delete=models.CASCADE,
|
|
verbose_name=_("jury"),
|
|
related_name="notes",
|
|
)
|
|
|
|
passage = models.ForeignKey(
|
|
Passage,
|
|
on_delete=models.CASCADE,
|
|
verbose_name=_("passage"),
|
|
related_name="notes",
|
|
)
|
|
|
|
defender_writing = models.PositiveSmallIntegerField(
|
|
verbose_name=_("defender writing note"),
|
|
choices=[(i, i) for i in range(0, 21)],
|
|
default=0,
|
|
)
|
|
|
|
defender_oral = models.PositiveSmallIntegerField(
|
|
verbose_name=_("defender oral note"),
|
|
choices=[(i, i) for i in range(0, 21)],
|
|
default=0,
|
|
)
|
|
|
|
opponent_writing = models.PositiveSmallIntegerField(
|
|
verbose_name=_("opponent writing note"),
|
|
choices=[(i, i) for i in range(0, 11)],
|
|
default=0,
|
|
)
|
|
|
|
opponent_oral = models.PositiveSmallIntegerField(
|
|
verbose_name=_("opponent oral note"),
|
|
choices=[(i, i) for i in range(0, 11)],
|
|
default=0,
|
|
)
|
|
|
|
reporter_writing = models.PositiveSmallIntegerField(
|
|
verbose_name=_("reporter writing note"),
|
|
choices=[(i, i) for i in range(0, 11)],
|
|
default=0,
|
|
)
|
|
|
|
reporter_oral = models.PositiveSmallIntegerField(
|
|
verbose_name=_("reporter oral note"),
|
|
choices=[(i, i) for i in range(0, 11)],
|
|
default=0,
|
|
)
|
|
|
|
observer_oral = models.SmallIntegerField(
|
|
verbose_name=_("observer note"),
|
|
choices=zip(range(-4, 5), range(-4, 5)),
|
|
default=0,
|
|
)
|
|
|
|
def get_all(self):
|
|
yield self.defender_writing
|
|
yield self.defender_oral
|
|
yield self.opponent_writing
|
|
yield self.opponent_oral
|
|
yield self.reporter_writing
|
|
yield self.reporter_oral
|
|
if self.passage.observer:
|
|
yield self.observer_oral
|
|
|
|
def set_all(self, defender_writing: int, defender_oral: int, opponent_writing: int, opponent_oral: int,
|
|
reporter_writing: int, reporter_oral: int, observer_oral: int = 0):
|
|
self.defender_writing = defender_writing
|
|
self.defender_oral = defender_oral
|
|
self.opponent_writing = opponent_writing
|
|
self.opponent_oral = opponent_oral
|
|
self.reporter_writing = reporter_writing
|
|
self.reporter_oral = reporter_oral
|
|
self.observer_oral = observer_oral
|
|
|
|
def update_spreadsheet(self):
|
|
if not self.has_any_note():
|
|
return
|
|
|
|
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
|
|
passage = Passage.objects.prefetch_related('pool__tournament', 'pool__participations').get(pk=self.passage.pk)
|
|
spreadsheet_id = passage.pool.tournament.notes_sheet_id
|
|
spreadsheet = gc.open_by_key(spreadsheet_id)
|
|
worksheet = spreadsheet.worksheet(f"Poule {passage.pool.short_name}")
|
|
jury_id_cell = worksheet.find(str(self.jury_id), in_column=2)
|
|
if not jury_id_cell:
|
|
raise ValueError("The jury ID cell was not found in the spreadsheet.")
|
|
jury_row = jury_id_cell.row
|
|
passage_width = 7 if passage.pool.participations.count() == 4 else 6
|
|
|
|
def getcol(number: int) -> str:
|
|
if number == 0:
|
|
return ''
|
|
return getcol((number - 1) // 26) + chr(65 + (number - 1) % 26)
|
|
|
|
min_col = getcol(3 + (self.passage.position - 1) * passage_width)
|
|
max_col = getcol(3 + self.passage.position * passage_width - 1)
|
|
|
|
worksheet.update([list(self.get_all())], f"{min_col}{jury_row}:{max_col}{jury_row}")
|
|
|
|
def get_absolute_url(self):
|
|
return reverse_lazy("participation:passage_detail", args=(self.passage.pk,))
|
|
|
|
@property
|
|
def modal_name(self):
|
|
return f"updateNotes{self.pk}"
|
|
|
|
def has_any_note(self):
|
|
return any(self.get_all())
|
|
|
|
def __str__(self):
|
|
return _("Notes of {jury} for {passage}").format(jury=self.jury, passage=self.passage)
|
|
|
|
class Meta:
|
|
verbose_name = _("note")
|
|
verbose_name_plural = _("notes")
|