diff --git a/participation/management/commands/parse_notation_sheets.py b/participation/management/commands/parse_notation_sheets.py index 9dcb209..8f4f10a 100644 --- a/participation/management/commands/parse_notation_sheets.py +++ b/participation/management/commands/parse_notation_sheets.py @@ -43,4 +43,4 @@ class Command(BaseCommand): pool.parse_spreadsheet() sleep(3) # Three calls = 3s sleep - tournament.parse_tweaks_spreadskeets() + tournament.parse_tweaks_spreadsheets() diff --git a/participation/management/commands/renew_gdrive_notifications.py b/participation/management/commands/renew_gdrive_notifications.py new file mode 100644 index 0000000..d3fee86 --- /dev/null +++ b/participation/management/commands/renew_gdrive_notifications.py @@ -0,0 +1,58 @@ +# Copyright (C) 2024 by Animath +# SPDX-License-Identifier: GPL-3.0-or-later + +from hashlib import sha1 + +import gspread +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.management import BaseCommand +from django.urls import reverse +from django.utils import timezone +from django.utils.timezone import localtime + +from ...models import Tournament + + +class Command(BaseCommand): + def add_arguments(self, parser): + parser.add_argument( + '--tournament', '-t', help="Tournament name to update (if not set, all tournaments will be updated)", + ) + + def handle(self, *args, **options): + tournaments = Tournament.objects.all() if not options['tournament'] \ + else Tournament.objects.filter(name=options['tournament']).all() + + gc = gspread.service_account_from_dict(settings.GOOGLE_SERVICE_CLIENT) + http_client = gc.http_client + http_client.login() + + site = Site.objects.get(pk=settings.SITE_ID) + + now = localtime(timezone.now()) + tomorrow = now + timezone.timedelta(days=1) + tomorrow -= timezone.timedelta(hours=now.hour, minutes=now.minute, seconds=now.second, + microseconds=now.microsecond) + + for tournament in tournaments: + if options['verbosity'] >= 1: + self.stdout.write(f"Renewing Google Drive notifications for {tournament}") + + if not tournament.notes_sheet_id: + if options['verbosity'] >= 1: + self.stdout.write( + self.style.WARNING(f"No spreadsheet found for {tournament}. Please create it first")) + continue + + channel_id = sha1(f"{tournament.name}-{timezone.now().date()}-{site.domain}".encode()).hexdigest() + url = f"https://www.googleapis.com/drive/v3/files/{tournament.notes_sheet_id}/watch" + notif_path = reverse('participation:tournament_gsheet_notifications', args=[tournament.pk]) + notif_url = f"https://{site.domain}{notif_path}" + body = { + "id": channel_id, + "type": "web_hook", + "address": notif_url, + "expiration": str(int(1000 * tomorrow.timestamp())), + } + http_client.request(method="POST", endpoint=url, json=body).raise_for_status() diff --git a/participation/models.py b/participation/models.py index f32a697..43766ef 100644 --- a/participation/models.py +++ b/participation/models.py @@ -606,7 +606,7 @@ class Tournament(models.Model): body = {"requests": format_requests} worksheet.client.batch_update(spreadsheet.id, body) - def parse_tweaks_spreadskeets(self): + def parse_tweaks_spreadsheets(self): if not self.pools.exists(): # Draw has not been done yet return diff --git a/participation/urls.py b/participation/urls.py index 45f4dca..292ab1c 100644 --- a/participation/urls.py +++ b/participation/urls.py @@ -4,8 +4,8 @@ from django.urls import path from django.views.generic import TemplateView -from .views import CreateTeamView, FinalNotationSheetTemplateView, JoinTeamView, MyParticipationDetailView, \ - MyTeamDetailView, NotationSheetsArchiveView, NoteUpdateView, ParticipationDetailView, \ +from .views import CreateTeamView, FinalNotationSheetTemplateView, GSheetNotificationsView, JoinTeamView, \ + MyParticipationDetailView, MyTeamDetailView, NotationSheetsArchiveView, NoteUpdateView, ParticipationDetailView, \ PassageDetailView, PassageUpdateView, PoolCreateView, PoolDetailView, PoolJuryView, PoolNotesTemplateView, \ PoolPresideJuryView, PoolRemoveJuryView, PoolUpdateView, PoolUploadNotesView, \ ScaleNotationSheetTemplateView, SolutionsDownloadView, SolutionUploadView, SynthesisUploadView, \ @@ -46,6 +46,8 @@ urlpatterns = [ name="tournament_syntheses"), path("tournament//notation/sheets/", NotationSheetsArchiveView.as_view(), name="tournament_notation_sheets"), + path("tournament//notation/notifications/", GSheetNotificationsView.as_view(), + name="tournament_gsheet_notifications"), path("tournament//publish-notes//", TournamentPublishNotesView.as_view(), name="tournament_publish_notes"), path("tournament//harmonize//", TournamentHarmonizeView.as_view(), diff --git a/participation/views.py b/participation/views.py index 75afbd8..c084096 100644 --- a/participation/views.py +++ b/participation/views.py @@ -1,7 +1,9 @@ # Copyright (C) 2020 by Animath # SPDX-License-Identifier: GPL-3.0-or-later - +import asyncio import csv +from concurrent.futures import ThreadPoolExecutor +from hashlib import sha1 from io import BytesIO import os import subprocess @@ -9,6 +11,7 @@ from tempfile import mkdtemp from typing import Any, Dict from zipfile import ZipFile +from asgiref.sync import sync_to_async from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin @@ -23,7 +26,9 @@ from django.template.loader import render_to_string from django.urls import reverse_lazy from django.utils import timezone from django.utils.crypto import get_random_string +from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ +from django.views.decorators.csrf import csrf_exempt from django.views.generic import CreateView, DetailView, FormView, RedirectView, TemplateView, UpdateView, View from django.views.generic.detail import SingleObjectMixin from django.views.generic.edit import FormMixin, ProcessFormView @@ -1874,6 +1879,29 @@ class NotationSheetsArchiveView(VolunteerMixin, DetailView): return response +@method_decorator(csrf_exempt, name='dispatch') +class GSheetNotificationsView(View): + async def post(self, request, *args, **kwargs): + if not await Tournament.objects.filter(pk=kwargs['pk']).aexists(): + return HttpResponse(status=404) + + tournament = await Tournament.objects.prefetch_related('participations', 'pools').aget(pk=kwargs['pk']) + request.site.domain = "test.ynerant.fr" + expected_channel_id = sha1(f"{tournament.name}{timezone.now().date()}{request.site.domain}".encode()) \ + .hexdigest() + + if request.headers['X-Goog-Channel-ID'] != expected_channel_id: + raise ValueError(f"Invalid channel ID: {request.headers['X-Goog-Channel-ID']}") + + # Run the parsing in dedicated executors since it takes time + executor = ThreadPoolExecutor() + async for pool in tournament.pools.prefetch_related('participations', 'passages__notes', 'juries').all(): + asyncio.get_event_loop().run_in_executor(executor, pool.parse_spreadsheet) + asyncio.get_event_loop().run_in_executor(executor, tournament.parse_tweaks_spreadsheets) + + return HttpResponse(status=204) + + class PassageDetailView(LoginRequiredMixin, DetailView): model = Passage diff --git a/tfjm.cron b/tfjm.cron index 64391f0..e6eac1a 100644 --- a/tfjm.cron +++ b/tfjm.cron @@ -16,7 +16,10 @@ 30 6 * * 1 cd /code && python manage.py remind_payments &> /dev/null # Check notation sheets every 15 minutes from 08:00 to 23:00 on fridays to mondays in april and may -*/15 8-23 * 4-5 5,6,7,1 cd /code && python manage.py parse_notation_sheets -v 0 +# */15 8-23 * 4-5 5,6,7,1 cd /code && python manage.py parse_notation_sheets -v 0 + +# Update Google Drive notifications daily +0 0 * * * cd /code && python manage.py renew_gdrive_notifications &> /dev/null # Clean temporary files 30 * * * * rm -rf /tmp/*