187 lines
5.5 KiB
Python
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
|
|
)
|