From c522387482fb9ded8d9af1894b7af3af45277a83 Mon Sep 17 00:00:00 2001 From: Emmy D'Anello Date: Sat, 30 Mar 2024 13:41:46 +0100 Subject: [PATCH] Export notation sheets on Google Sheets Signed-off-by: Emmy D'Anello --- .../commands/update_notation_sheets.py | 17 + ...et_id_alter_note_defender_oral_and_more.py | 93 ++++++ participation/models.py | 307 ++++++++++++++++++ participation/views.py | 13 +- requirements.txt | 6 +- tfjm/settings.py | 17 + 6 files changed, 446 insertions(+), 7 deletions(-) create mode 100644 participation/management/commands/update_notation_sheets.py create mode 100644 participation/migrations/0010_tournament_notes_sheet_id_alter_note_defender_oral_and_more.py diff --git a/participation/management/commands/update_notation_sheets.py b/participation/management/commands/update_notation_sheets.py new file mode 100644 index 0000000..d265654 --- /dev/null +++ b/participation/management/commands/update_notation_sheets.py @@ -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() diff --git a/participation/migrations/0010_tournament_notes_sheet_id_alter_note_defender_oral_and_more.py b/participation/migrations/0010_tournament_notes_sheet_id_alter_note_defender_oral_and_more.py new file mode 100644 index 0000000..79c0a52 --- /dev/null +++ b/participation/migrations/0010_tournament_notes_sheet_id_alter_note_defender_oral_and_more.py @@ -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", + ), + ), + ] diff --git a/participation/models.py b/participation/models.py index 208b0e8..fb15b35 100644 --- a/participation/models.py +++ b/participation/models.py @@ -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, diff --git a/participation/views.py b/participation/views.py index fd49503..b171e83 100644 --- a/participation/views.py +++ b/participation/views.py @@ -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() * '&' diff --git a/requirements.txt b/requirements.txt index 637d52d..d7cceee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tfjm/settings.py b/tfjm/settings.py index b297970..c1b98bb 100644 --- a/tfjm/settings.py +++ b/tfjm/settings.py @@ -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",