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 )