From 91ca4b067742d93a779db2636afbe1cf725a32d5 Mon Sep 17 00:00:00 2001 From: Jerry Mao Date: Mon, 30 Jan 2023 16:52:18 -0500 Subject: [PATCH] Support mass resume download --- backend/siarnaq/api/episodes/admin.py | 46 +++++++++++++++++++++++++++ backend/siarnaq/gcloud/titan.py | 22 ++++++++++--- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/backend/siarnaq/api/episodes/admin.py b/backend/siarnaq/api/episodes/admin.py index 2246928ad..ea5a285b4 100644 --- a/backend/siarnaq/api/episodes/admin.py +++ b/backend/siarnaq/api/episodes/admin.py @@ -1,5 +1,11 @@ +import tempfile +from zipfile import ZipFile + import structlog +from django.conf import settings from django.contrib import admin, messages +from django.db.models import Max, Q +from django.http import HttpResponse from django.utils.html import format_html from siarnaq.api.compete.models import Match @@ -10,10 +16,49 @@ Tournament, TournamentRound, ) +from siarnaq.api.user.models import User +from siarnaq.gcloud import titan logger = structlog.get_logger(__name__) +@admin.action(description="Export all submitted resumes") +def export_resumes(modeladmin, request, queryset): + users = list( + User.objects.annotate( + rating=Max( + "teams__profile__rating__value", filter=Q(teams__episode__in=queryset) + ) + ) + .filter(profile__has_resume=True, rating__isnull=False) + .order_by("-rating") + ) + rank_len = len(str(len({user.rating for user in users}))) + with tempfile.SpooledTemporaryFile() as f: + with ZipFile(f, "w") as archive: + rank, last_rating = 0, None + for user in users: + if user.rating != last_rating: + rank, last_rating = rank + 1, user.rating + rank_str = "rank-" + str(rank).zfill(rank_len) + user_str = user.first_name + "-" + user.last_name + if not user_str.isascii(): + user_str = "nonascii-user" + fname = f"{rank_str}-{user_str}.pdf" + resume = titan.get_object( + bucket=settings.GCLOUD_BUCKET_SECURE, + name=user.profile.get_resume_path(), + check_safety=False, # TODO: actually check safety, see #628 + get_raw=True, + ) + if resume["ready"]: + archive.writestr(fname, resume["data"]) + f.seek(0) + response = HttpResponse(f.read(), content_type="application/x-zip-compressed") + response["Content-Disposition"] = "attachment; filename=resumes.zip" + return response + + class MapInline(admin.TabularInline): model = Map extra = 0 @@ -23,6 +68,7 @@ class MapInline(admin.TabularInline): @admin.register(Episode) class EpisodeAdmin(admin.ModelAdmin): + actions = [export_resumes] fieldsets = ( ( "General", diff --git a/backend/siarnaq/gcloud/titan.py b/backend/siarnaq/gcloud/titan.py index 5af3158ac..290619d79 100644 --- a/backend/siarnaq/gcloud/titan.py +++ b/backend/siarnaq/gcloud/titan.py @@ -19,7 +19,9 @@ def request_scan(blob: storage.Blob) -> None: blob.patch() -def get_object(bucket: str, name: str, check_safety: bool) -> dict[str, str | bool]: +def get_object( + bucket: str, name: str, check_safety: bool, get_raw: bool = False +) -> dict[str, str | bytes | bool]: """ Retrieve a file from storage, performing safety checks if required. @@ -31,14 +33,21 @@ def get_object(bucket: str, name: str, check_safety: bool) -> dict[str, str | bo The name (full path) of the object in the bucket. check_safety : bool Whether the object should only be returned if verified by Titan. + get_raw : bool + Whether to return the raw file contents instead of a URL. Returns ------- dict[str, str] A dictionary consisting of a boolean field "ready" indicating whether the file - has passed any requested safety checks. If this is true, then an additional - field "url" is supplied with a signed download link. Otherwise, a field "reason" - is available explaining why the file cannot be downloaded. + has passed any requested safety checks. + + If this is true, then an additional field will be available for retrieving the + file: either a field "url" with a signed download link, or "data" with the raw + data. + + Otherwise, a field "reason" is available explaining why the file cannot be + downloaded. """ log = logger.bind(bucket=bucket, name=name) if not settings.GCLOUD_ENABLE_ACTIONS: @@ -49,6 +58,11 @@ def get_object(bucket: str, name: str, check_safety: bool) -> dict[str, str | bo blob = client.bucket(bucket).get_blob(name) match (check_safety, blob.metadata): case (False, _) | (True, {"Titan-Status": "Verified"}): + if get_raw: + return { + "ready": True, + "data": blob.download_as_bytes(), + } # Signing is complicated due to an issue with the Google Auth library. # See: https://github.com/googleapis/google-auth-library-python/issues/50 signing_credentials = impersonated_credentials.Credentials(