Export notation sheets on Google Sheets

Signed-off-by: Emmy D'Anello <emmy.danello@animath.fr>
This commit is contained in:
Emmy D'Anello 2024-03-30 13:41:46 +01:00
parent 0006ecc90d
commit c522387482
Signed by: ynerant
GPG Key ID: 3A75C55819C8CF85
6 changed files with 446 additions and 7 deletions

View File

@ -0,0 +1,17 @@
# Copyright (C) 2024 by Animath
# SPDX-License-Identifier: GPL-3.0-or-later
from django.core.management import BaseCommand
from participation.models import Tournament
class Command(BaseCommand):
def handle(self, *args, **options):
for tournament in Tournament.objects.all():
if options['verbosity'] >= 1:
self.stdout.write(f"Updating notation sheet for {tournament}")
tournament.create_spreadsheet()
for pool in tournament.pools.all():
if options['verbosity'] >= 1:
self.stdout.write(f"Updating notation sheet for pool {pool.short_name} for {tournament}")
pool.update_spreadsheet()

View File

@ -0,0 +1,93 @@
# Generated by Django 5.0.3 on 2024-03-29 22:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("participation", "0009_pool_jury_president"),
]
operations = [
migrations.AddField(
model_name="tournament",
name="notes_sheet_id",
field=models.CharField(
blank=True, default="", max_length=64, verbose_name="Google Sheet ID"
),
),
migrations.AlterField(
model_name="note",
name="defender_oral",
field=models.PositiveSmallIntegerField(
choices=[
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 4),
(5, 5),
(6, 6),
(7, 7),
(8, 8),
(9, 9),
(10, 10),
(11, 11),
(12, 12),
(13, 13),
(14, 14),
(15, 15),
(16, 16),
(17, 17),
(18, 18),
(19, 19),
(20, 20),
],
default=0,
verbose_name="defender oral note",
),
),
migrations.AlterField(
model_name="note",
name="opponent_writing",
field=models.PositiveSmallIntegerField(
choices=[
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 4),
(5, 5),
(6, 6),
(7, 7),
(8, 8),
(9, 9),
(10, 10),
],
default=0,
verbose_name="opponent writing note",
),
),
migrations.AlterField(
model_name="note",
name="reporter_writing",
field=models.PositiveSmallIntegerField(
choices=[
(0, 0),
(1, 1),
(2, 2),
(3, 3),
(4, 4),
(5, 5),
(6, 6),
(7, 7),
(8, 8),
(9, 9),
(10, 10),
],
default=0,
verbose_name="reporter writing note",
),
),
]

View File

@ -14,6 +14,8 @@ from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _
import gspread
from gspread.utils import a1_range_to_grid_range, MergeType
from registration.models import Payment, VolunteerRegistration
from tfjm.lists import get_sympa_client
@ -337,6 +339,13 @@ class Tournament(models.Model):
default=False,
)
notes_sheet_id = models.CharField(
max_length=64,
blank=True,
default="",
verbose_name=_("Google Sheet ID"),
)
@property
def teams_email(self):
"""
@ -409,6 +418,15 @@ class Tournament(models.Model):
fmt = [n] if n <= 5 else [3] * (n // 3 - 1) + [3 + n % 3]
return '+'.join(map(str, sorted(fmt)))
def create_spreadsheet(self):
if self.notes_sheet_id:
return self.notes_sheet_id
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
spreadsheet = gc.create(f"Feuille de notes - {self.name}", folder_id=settings.NOTES_DRIVE_FOLDER_ID)
self.notes_sheet_id = spreadsheet.id
self.save()
def get_absolute_url(self):
return reverse_lazy("participation:tournament_detail", args=(self.pk,))
@ -591,6 +609,295 @@ class Pool(models.Model):
raise ValidationError({'jury_president': _("The president of the jury must be part of the jury.")})
return super().validate_constraints()
def update_spreadsheet(self): # noqa: C901
gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT)
spreadsheet = gc.open_by_key(self.tournament.notes_sheet_id)
worksheets = spreadsheet.worksheets()
if f"Poule {self.short_name}" not in [ws.title for ws in worksheets]:
worksheet = spreadsheet.add_worksheet(f"Poule {self.short_name}", 100, 32)
else:
worksheet = spreadsheet.worksheet(f"Poule {self.short_name}")
if any(ws.title == "Sheet1" for ws in worksheets):
spreadsheet.del_worksheet(spreadsheet.worksheet("Sheet1"))
pool_size = self.participations.count()
passage_width = 7 if pool_size == 4 else 6
passages = self.passages.all()
header = [
sum(([f"Problème {passage.solution_number}"] + (passage_width - 1) * [""]
for passage in passages), start=["Problème", ""]),
sum((["Défenseur⋅se", "", "Opposant⋅e", "", "Rapporteur⋅e", ""]
+ (["Observateur⋅rice"] if pool_size == 4 else [])
for _passage in passages), start=["Rôle", ""]),
sum((["Écrit (/20)", "Oral (/20)", "Écrit (/10)", "Oral (/10)", "Écrit (/10)", "Oral (/10)"]
+ (["Oral (± 4)"] if pool_size == 4 else [])
for _passage in passages), start=["Juré⋅e", ""]),
]
notes = []
for jury in self.juries.all():
line = [str(jury), jury.id]
for passage in passages:
note = passage.notes.filter(jury=jury).first()
line.extend([note.defender_writing, note.defender_oral, note.opponent_writing, note.opponent_oral,
note.reporter_writing, note.reporter_oral])
if pool_size == 4:
line.append(note.observer_oral)
notes.append(line)
def getcol(number: int) -> str:
"""
Translates the given number to the nth column name
"""
if number == 0:
return ''
return getcol((number - 1) // 26) + chr(65 + (number - 1) % 26)
average = ["Moyenne", ""]
coeffs = sum(([1, 1.6 - 0.4 * passage.defender_penalties, 0.9, 2, 0.9, 1]
+ ([1] if pool_size == 4 else []) for passage in passages), start=["Coefficient", ""])
subtotal = ["Sous-total", ""]
footer = [average, coeffs, subtotal, 32 * [""]]
min_row = 4
max_row = min_row + self.juries.count() - 1
min_column = 3
for i, passage in enumerate(passages):
for j, note in enumerate(passage.averages):
column = getcol(min_column + i * passage_width + j)
average.append(f"=MOYENNE.SI(${getcol(min_column + i * passage_width)}${min_row}"
f":${getcol(min_column + i * passage_width)}{max_row}; \">0\"; "
f"{column}${min_row}:{column}{max_row})")
def_w_col = getcol(min_column + passage_width * i)
def_o_col = getcol(min_column + passage_width * i + 1)
subtotal.extend([f"={def_w_col}{max_row + 1} * {def_w_col}{max_row + 2}"
f" + {def_o_col}{max_row + 1} * {def_o_col}{max_row + 2}", ""])
opp_w_col = getcol(min_column + passage_width * i + 2)
opp_o_col = getcol(min_column + passage_width * i + 3)
subtotal.extend([f"={opp_w_col}{max_row + 1} * {opp_w_col}{max_row + 2}"
f" + {opp_o_col}{max_row + 1} * {opp_o_col}{max_row + 2}", ""])
rep_w_col = getcol(min_column + passage_width * i + 4)
rep_o_col = getcol(min_column + passage_width * i + 5)
subtotal.extend([f"={rep_w_col}{max_row + 1} * {rep_w_col}{max_row + 2}"
f" + {rep_o_col}{max_row + 1} * {rep_o_col}{max_row + 2}", ""])
if pool_size == 4:
obs_col = getcol(min_column + passage_width * i + 6)
subtotal.append(f"={obs_col}{max_row + 1} * {obs_col}{max_row + 2}")
ranking = [
["Équipe", "", "Problème", "Total", "Rang"],
]
passage_matrix = []
match pool_size:
case 3:
passage_matrix = [
[0, 2, 1],
[1, 0, 2],
[2, 1, 0],
]
case 4:
passage_matrix = [
[0, 3, 2, 1],
[1, 0, 3, 2],
[2, 1, 0, 3],
[3, 2, 1, 0],
]
case 5:
passage_matrix = [
[0, 2, 3],
[1, 4, 2],
[2, 0, 4],
[3, 1, 0],
[4, 3, 1],
]
for passage in passages:
participation = passage.defender
passage_line = passage_matrix[passage.position - 1]
formula = "="
formula += getcol(min_column + passage_line[0] * passage_width) + str(max_row + 3) # Defender
formula += " + " + getcol(min_column + passage_line[1] * passage_width + 2) + str(max_row + 3) # Opponent
formula += " + " + getcol(min_column + passage_line[2] * passage_width + 4) + str(max_row + 3) # Reporter
if pool_size == 4:
# Observer
formula += " + " + getcol(min_column + passage_line[3] * passage_width + 6) + str(max_row + 3)
ranking.append([f"{participation.team.name} ({participation.team.trigram})", "",
f"=${getcol(3 + (passage.position - 1) * passage_width)}$1", formula,
f"=RANG(D{max_row + 5 + passage.position}; "
f"D${max_row + 6}:D${max_row + 5 + pool_size})"])
all_values = header + notes + footer + ranking
worksheet.update("A1:AF", all_values, raw=False)
format_requests = []
# Merge cells
merge_cells = ["A1:B1"]
for i, passage in enumerate(passages):
merge_cells.append(f"{getcol(3 + i * passage_width)}1:{getcol(2 + passage_width + i * passage_width)}1")
merge_cells.append(f"{getcol(3 + i * passage_width)}2:{getcol(4 + i * passage_width)}2")
merge_cells.append(f"{getcol(5 + i * passage_width)}2:{getcol(6 + i * passage_width)}2")
merge_cells.append(f"{getcol(7 + i * passage_width)}2:{getcol(8 + i * passage_width)}2")
merge_cells.append(f"{getcol(3 + i * passage_width)}{max_row + 3}"
f":{getcol(4 + i * passage_width)}{max_row + 3}")
merge_cells.append(f"{getcol(5 + i * passage_width)}{max_row + 3}"
f":{getcol(6 + i * passage_width)}{max_row + 3}")
merge_cells.append(f"{getcol(7 + i * passage_width)}{max_row + 3}"
f":{getcol(8 + i * passage_width)}{max_row + 3}")
merge_cells.append(f"A{max_row + 1}:B{max_row + 1}")
merge_cells.append(f"A{max_row + 2}:B{max_row + 2}")
merge_cells.append(f"A{max_row + 3}:B{max_row + 3}")
for i in range(pool_size + 1):
merge_cells.append(f"A{max_row + 5 + i}:B{max_row + 5 + i}")
format_requests.append({"unmergeCells": {"range": a1_range_to_grid_range("A1:AF", worksheet.id)}})
for name in merge_cells:
grid_range = a1_range_to_grid_range(name, worksheet.id)
format_requests.append({"mergeCells": {"mergeType": MergeType.merge_all, "range": grid_range}})
# Make titles bold
bold_ranges = ["A1:AF3", f"A{max_row + 1}:B{max_row + 3}", f"A{max_row + 5}:E{max_row + 5}"]
for bold_range in bold_ranges:
format_requests.append({
"repeatCell": {
"range": a1_range_to_grid_range(bold_range, worksheet.id),
"cell": {"userEnteredFormat": {"textFormat": {"bold": True}}},
"fields": "userEnteredFormat(textFormat)",
}
})
# Set background color for headers and footers
bg_colors = [(f"A1:{getcol(2 + pool_size * passage_width)}3", (0.8, 0.8, 0.8)),
(f"A{min_row}:B{max_row}", (0.95, 0.95, 0.95)),
(f"A{max_row + 1}:B{max_row + 3}", (0.8, 0.8, 0.8)),
(f"C{max_row + 1}:{getcol(2 + pool_size * passage_width)}{max_row + 3}", (0.9, 0.9, 0.9)),
(f"A{max_row + 5}:E{max_row + 5}", (0.8, 0.8, 0.8)),
(f"A{max_row + 6}:E{max_row + 5 + pool_size}", (0.9, 0.9, 0.9)),]
for bg_range, bg_color in bg_colors:
r, g, b = bg_color
format_requests.append({
"repeatCell": {
"range": a1_range_to_grid_range(bg_range, worksheet.id),
"cell": {"userEnteredFormat": {"backgroundColor": {"red": r, "green": g, "blue": b}}},
"fields": "userEnteredFormat(backgroundColor)",
}
})
# Freeze 2 first columns
format_requests.append({
"updateSheetProperties": {
"properties": {
"sheetId": worksheet.id,
"gridProperties": {
"frozenRowCount": 0,
"frozenColumnCount": 2,
},
},
"fields": "gridProperties/frozenRowCount,gridProperties/frozenColumnCount",
}
})
# Set the width of the columns
column_widths = [("A", 250), ("B", 30)]
for passage in passages:
column_widths.append((f"{getcol(3 + passage_width * (passage.position - 1))}"
f":{getcol(8 + passage_width * (passage.position - 1))}", 75))
if pool_size == 4:
column_widths.append((getcol(9 + passage_width * (passage.position - 1)), 120))
for column, width in column_widths:
grid_range = a1_range_to_grid_range(column, worksheet.id)
format_requests.append({
"updateDimensionProperties": {
"range": {
"sheetId": worksheet.id,
"dimension": "COLUMNS",
"startIndex": grid_range['startColumnIndex'],
"endIndex": grid_range['endColumnIndex'],
},
"properties": {
"pixelSize": width,
},
"fields": "pixelSize",
}
})
# Hide second column (Jury ID)
format_requests.append({
"updateDimensionProperties": {
"range": {
"sheetId": worksheet.id,
"dimension": "COLUMNS",
"startIndex": 1,
"endIndex": 2,
},
"properties": {
"hiddenByUser": True,
},
"fields": "hiddenByUser",
}
})
# Define borders
border_ranges = [(f"A1:{getcol(2 + pool_size * passage_width)}{max_row + 3}", "1111"),
(f"A{max_row + 5}:E{max_row + pool_size + 5}", "1111"),
(f"A1:B{max_row + 3}", "1113"),
(f"C1:{getcol(2 + (pool_size - 1) * passage_width)}1", "1113")]
for i in range(pool_size - 1):
border_ranges.append((f"{getcol(2 + (i + 1) * passage_width)}2"
f":{getcol(2 + (i + 1) * passage_width)}{max_row + 3}", "1113"))
sides_names = ['top', 'bottom', 'left', 'right']
styles = ["NONE", "SOLID", "SOLID_MEDIUM", "SOLID_THICK", "DOUBLE"]
for border_range, sides in border_ranges:
borders = {}
for side_name, side in zip(sides_names, sides):
borders[side_name] = {"style": styles[int(side)]}
format_requests.append({
"repeatCell": {
"range": a1_range_to_grid_range(border_range, worksheet.id),
"cell": {
"userEnteredFormat": {
"borders": borders,
"horizontalAlignment": "CENTER",
},
},
"fields": "userEnteredFormat(borders,horizontalAlignment)",
}
})
# Remove old protected ranges
for protected_range in spreadsheet.list_protected_ranges(worksheet.id):
format_requests.append({
"deleteProtectedRange": {
"protectedRangeId": protected_range["protectedRangeId"],
}
})
# Protect the header, the juries list, the footer and the ranking
protected_ranges = ["A1:AF3",
f"A{min_row}:B{max_row}",
f"A{max_row + 1}:AF{max_row + 5 + pool_size}"]
for protected_range in protected_ranges:
format_requests.append({
"addProtectedRange": {
"protectedRange": {
"range": a1_range_to_grid_range(protected_range, worksheet.id),
"description": "Structure du tableur à ne pas modifier "
"pour une meilleure prise en charge automatisée",
"warningOnly": True,
},
}
})
body = {"requests": format_requests}
worksheet.client.batch_update(spreadsheet.id, body)
def __str__(self):
return _("Pool of day {round} for tournament {tournament} with teams {teams}")\
.format(round=self.round,

View File

@ -1312,8 +1312,8 @@ class PoolNotesTemplateView(VolunteerMixin, DetailView):
table.addElement(TableColumn(stylename=jury_id_style))
for i in range(line_length):
table.addElement(TableColumn(stylename=obs_col_style if pool_size == 4
and i % passage_width == passage_width - 1 else col_style))
table.addElement(TableColumn(
stylename=obs_col_style if pool_size == 4 and i % passage_width == passage_width - 1 else col_style))
# Add line for the problems for different passages
header_pb = TableRow()
@ -1671,8 +1671,8 @@ class NotationSheetTemplateView(VolunteerMixin, DetailView):
page = self.request.GET.get('page', '1')
if not page.isnumeric() or page not in ['1', '2']:
page = '1'
passages = passages.filter(id__in=[passages[0].id, passages[2].id, passages[4].id]
if page == '1' else [passages[1].id, passages[3].id])
passages = passages.filter(id__in=([passages[0].id, passages[2].id, passages[4].id]
if page == '1' else [passages[1].id, passages[3].id]))
context['page'] = page
context['passages'] = passages
@ -1761,8 +1761,9 @@ class NotationSheetsArchiveView(VolunteerMixin, DetailView):
passages = pool.passages.all()
if passages.count() == 5:
passages = passages.filter(id__in=[passages[0].id, passages[2].id, passages[4].id]
if page == '1' else [passages[1].id, passages[3].id])
passages = passages.filter(
id__in=([passages[0].id, passages[2].id, passages[4].id]
if page == '1' else [passages[1].id, passages[3].id]))
context['passages'] = passages
context['esp'] = passages.count() * '&'

View File

@ -1,7 +1,7 @@
channels[daphne]~=4.0.0
channels-redis~=4.2.0
crispy-bootstrap5~=2023.10
Django>=5.0,<6.0
Django>=5.0.3,<6.0
django-crispy-forms~=2.1
django-extensions~=3.2.3
django-filter~=23.5
@ -13,6 +13,10 @@ django-polymorphic~=3.1.0
django-tables2~=2.7.0
djangorestframework~=3.14.0
django-rest-polymorphic~=0.1.10
google-api-python-client~=2.124.0
google-auth-httplib2~=0.2.0
google-auth-oauthlib~=1.2.0
gspread~=6.1.0
gunicorn~=21.2.0
odfpy~=1.4.1
phonenumbers~=8.13.27

View File

@ -246,6 +246,23 @@ HELLOASSO_CLIENT_ID = os.getenv('HELLOASSO_CLIENT_ID', 'CHANGE_ME_IN_ENV_SETTING
HELLOASSO_CLIENT_SECRET = os.getenv('HELLOASSO_CLIENT_SECRET', 'CHANGE_ME_IN_ENV_SETTINGS')
HELLOASSO_TEST_ENDPOINT = False # Enable custom test endpoint, for unit tests
GOOGLE_SERVICE_CLIENT = {
"type": "service_account",
"project_id": os.getenv("GOOGLE_PROJECT_ID", "plateforme-tfjm"),
"private_key_id": os.getenv("GOOGLE_PRIVATE_KEY_ID", "CHANGE_ME_IN_ENV_SETTINGS"),
"private_key": os.getenv("GOOGLE_PRIVATE_KEY", "CHANGE_ME_IN_ENV_SETTINGS").replace("\\n", "\n"),
"client_email": os.getenv("GOOGLE_CLIENT_EMAIL", "CHANGE_ME_IN_ENV_SETTINGS"),
"client_id": os.getenv("GOOGLE_CLIENT_ID", "CHANGE_ME_IN_ENV_SETTINGS"),
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": os.getenv("GOOGLE_CLIENT_X509_CERT_URL", "CHANGE_ME_IN_ENV_SETTINGS"),
"universe_domain": "googleapis.com"
}
# The ID of the Google Drive folder where to store the notation sheets
NOTES_DRIVE_FOLDER_ID = os.getenv("NOTES_DRIVE_FOLDER_ID", "CHANGE_ME_IN_ENV_SETTINGS")
# Custom parameters
PROBLEMS = [
"Triominos",