Files
polylan-submitter/polylan_submitter/submissions/tasks.py
T

187 lines
5.5 KiB
Python

import logging
from datetime import timedelta
from uuid import uuid4
from django.core.cache import cache
from django.tasks import task
from django.utils import timezone
from games.models import EventGame
from submissions.models import ImageCrop, SubmissionFile, OCRStatus
from submissions.services.aandt_sync import (
AANDT_STATUS_IN_PROGRESS,
ScopeSyncError,
sync_event_game_scopes,
)
from PIL import Image
import pytesseract
logger = logging.getLogger(__name__)
# How often to repoll an EventGame's AAndT while it's "in progress", and while
# it isn't yet (registrations, preparation, ...) or the API call failed.
SCOPE_SYNC_REPOLL_INTERVAL = timedelta(seconds=15)
SCOPE_SYNC_IDLE_INTERVAL = timedelta(minutes=1)
# AAndT statuses after which there's nothing left to sync, the loop stops.
AANDT_FINISHED_STATUSES = {"9done", "11reviewed"}
def _scope_sync_guard_key(event_game_id: int) -> str:
return f"submissions:scope_sync:{event_game_id}"
def _scope_sync_guard_timeout() -> int:
return int(SCOPE_SYNC_IDLE_INTERVAL.total_seconds() * 3)
def schedule_scope_sync(event_game_id: int, /) -> bool:
"""Start the sync loop of an EventGame, unless one is already running.
Safe to call any number of times (admin button spam, multiple workers...).
The loop holds a per-EventGame guard with its id (same idea as
games.tasks.schedule_steam_refresh), so if the guard expires while an old
loop is just late, the old one stops at its next run."""
loop_id = uuid4().hex
if not cache.add(
_scope_sync_guard_key(event_game_id),
loop_id,
timeout=_scope_sync_guard_timeout(),
):
return False
sync_event_game_scopes_task.enqueue(event_game_id, loop_id=loop_id)
return True
def stop_scope_sync(event_game_id: int, /):
"""Release the guard, the running loop (if any) stops at its next run."""
cache.delete(_scope_sync_guard_key(event_game_id))
def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool:
if obj.ocr_status != OCRStatus.TO_PROCESS:
return False
obj.ocr_status = OCRStatus.PROCESSING
obj.save(update_fields=["ocr_status"])
try:
image = Image.open(file)
if crop:
image = image.crop((crop.x1, crop.y1, crop.x2, crop.y2))
obj.ocr_raw_data = pytesseract.image_to_string(image)
obj.ocr_status = OCRStatus.PROCESSED
except Exception as e:
obj.ocr_status = OCRStatus.ERROR
raise e
finally:
obj.save(update_fields=["ocr_status", "ocr_raw_data"])
return True
@task
def process_submission_files():
"""Process uploaded submission files (OCR, validation, etc.)"""
for file in SubmissionFile.objects.filter(ocr_status=OCRStatus.TO_PROCESS):
process_submission_file.enqueue(str(file.id))
@task
def process_submission_file(file_id: str):
file = SubmissionFile.objects.filter(id=file_id).first()
if not file:
return
_process_ocr_file(file, file.file)
process_crops.enqueue(file_id)
@task
def process_crops(file_id: str):
file = SubmissionFile.objects.filter(id=file_id).first()
if not file:
return
for crop in file.crops.all():
process_crop.enqueue(str(crop.id))
@task
def process_crop(crop_id: str):
crop = ImageCrop.objects.filter(id=crop_id).first()
if not crop:
return
_process_ocr_file(crop, crop.submission_file.file, crop=crop)
@task
def sync_all_event_games_task():
"""Queue a Scope sync for every EventGame with an AAndT token configured."""
event_games = EventGame.objects.exclude(apibot_token__isnull=True).exclude(
apibot_token=""
)
for event_game in event_games:
schedule_scope_sync(event_game.id)
@task
def sync_event_game_scopes_task(event_game_id: int, loop_id: str | None = None):
"""Sync an EventGame's Scopes, then re-enqueue itself until the AAndT is done.
Started once (see schedule_scope_sync), it keeps going on its own without
an external scheduler: every SCOPE_SYNC_REPOLL_INTERVAL while the AAndT is
"in progress" (`7inprog`), every SCOPE_SYNC_IDLE_INTERVAL before that or
when the API call failed. Stops once the AAndT is done/reviewed, when the
EventGame (or its token) is gone, or when another loop took the guard.
Without loop_id it's a one-off run that leaves the loop alone.
"""
guard_key = _scope_sync_guard_key(event_game_id)
if loop_id is not None and cache.get(guard_key) != loop_id:
# Another loop owns the guard (or it expired), stop this one
return
event_game = EventGame.objects.filter(id=event_game_id).first()
if not event_game or not event_game.apibot_token:
if loop_id is not None:
cache.delete(guard_key)
return
status = None
try:
status = sync_event_game_scopes(event_game).status
except ScopeSyncError:
if loop_id is None:
raise
# Probably transient (AAndT site down, ...), retry later
logger.exception("Scope sync failed for EventGame %s", event_game_id)
if loop_id is None:
return
if status in AANDT_FINISHED_STATUSES:
cache.delete(guard_key)
return
interval = (
SCOPE_SYNC_REPOLL_INTERVAL
if status == AANDT_STATUS_IN_PROGRESS
else SCOPE_SYNC_IDLE_INTERVAL
)
cache.set(guard_key, loop_id, timeout=_scope_sync_guard_timeout())
sync_event_game_scopes_task.using(run_after=timezone.now() + interval).enqueue(
event_game_id, loop_id=loop_id
)