from typing import List, Optional from uuid import UUID from django.http import HttpRequest from ninja import Router, File, Form from ninja.files import UploadedFile from django.shortcuts import get_object_or_404 from games.models import EventGame, Game from .models import Submission, SubmissionFile, ImageCrop, OCRStatus, Scope from .schemas import ( SubmissionOut, ImageCropIn, ImageCropOut, ScopeOut, SyncScopesIn, SyncScopesOut, SyncScopesStatusOut, SyncScopesStopOut, ) from .tasks import ( get_scope_sync_state, process_submission_file, process_crop, schedule_scope_sync, stop_scope_sync, ) router = Router() PREFIX = "/events/{event_path}/submissions" @router.get(f"{PREFIX}/scopes/mine", response={200: list[ScopeOut], 403: dict}) def list_my_scopes(request: HttpRequest, event_path: str, game_path: str): if not request.user.is_authenticated: return 403, {"detail": "Authentication required"} game = get_object_or_404( Game.objects.enabled_for_event(request.event), path=game_path ) return Scope.objects.filter( event_game__event=request.event, event_game__game=game, assigned_users=request.user, ) @router.get( f"{PREFIX}/{{game_path}}/available-scopes", response={200: list[ScopeOut], 403: dict}, ) def list_available_scopes(request: HttpRequest, event_path: str, game_path: str): """Scopes the current user may submit to for this game - superusers see every active scope, everyone else only the ones assigned to them. Used by the submissions-select and submissions-detail pages, both to list scopes and (for the detail page) to confirm the requested scope_id is one the current user is actually allowed to see.""" if not request.user.is_authenticated: return 403, {"detail": "Authentication required"} game = get_object_or_404( Game.objects.enabled_for_event(request.event), path=game_path ) scopes = ( Scope.objects.filter( event_game__event=request.event, event_game__game=game, is_active=True, ) if request.user.is_superuser else Scope.objects.filter( event_game__event=request.event, event_game__game=game, assigned_users=request.user, is_active=True, ) ) return list(scopes.order_by("name")) def _syncable_event_game(request: HttpRequest, game_path: str) -> EventGame: return get_object_or_404( EventGame, event=request.event, game__path=game_path, game__custom_path=False, enabled=True, ) @router.get( f"{PREFIX}/{{game_path}}/sync-scopes", response={200: SyncScopesStatusOut, 403: dict}, ) def sync_scopes_status(request: HttpRequest, event_path: str, game_path: str): """State of the AAndT scope sync loop of a (non custom path) game, superusers only.""" if not request.user.is_superuser: return 403, {"detail": "Superuser required"} event_game = _syncable_event_game(request, game_path) scopes = Scope.objects.filter(event_game=event_game) return { **vars(get_scope_sync_state(event_game.id)), "has_token": bool(event_game.apibot_token), "scope_count": scopes.count(), "active_scope_count": scopes.filter(is_active=True).count(), } @router.post( f"{PREFIX}/{{game_path}}/sync-scopes", response={200: SyncScopesOut, 400: dict, 403: dict}, ) def sync_scopes( request: HttpRequest, event_path: str, game_path: str, payload: SyncScopesIn ): """Start the AAndT scope sync loop for a (non custom path) game, superusers only. Stores `apibot_token` first when given; without one and none stored yet, answers 400 with `token_required` so the frontend asks for it.""" if not request.user.is_superuser: return 403, {"detail": "Superuser required"} event_game = _syncable_event_game(request, game_path) token = (payload.apibot_token or "").strip() if token: event_game.apibot_token = token event_game.save(update_fields=["apibot_token"]) if not event_game.apibot_token: return 400, { "detail": "No AAndT apibot token configured", "token_required": True, } return {"started": schedule_scope_sync(event_game.id)} @router.delete( f"{PREFIX}/{{game_path}}/sync-scopes", response={200: SyncScopesStopOut, 403: dict}, ) def stop_sync_scopes(request: HttpRequest, event_path: str, game_path: str): """Stop the AAndT scope sync loop of a (non custom path) game, superusers only. Cancels its scheduled job, scopes are left as they are.""" if not request.user.is_superuser: return 403, {"detail": "Superuser required"} event_game = _syncable_event_game(request, game_path) return {"stopped": stop_scope_sync(event_game.id)} @router.post(f"{PREFIX}/submission/create", response={200: SubmissionOut, 403: dict}) def create_submission( request: HttpRequest, event_path: str, scope_id: UUID, files: List[UploadedFile] = File(...), notes: Optional[str] = Form(None), ): if not request.user.is_authenticated: return 403, {"detail": "Authentication required"} scope = get_object_or_404( Scope, id=scope_id, event_game__event=request.event, is_active=True ) if ( not request.user.is_staff and not scope.assigned_users.filter(pk=request.user.pk).exists() ): return 403, {"detail": "You are not assigned to this scope"} submission = Submission.objects.create( event=request.event, scope=scope, user=request.user, notes=notes or None, ) for file in files: SubmissionFile.objects.create( submission=submission, file=file, original_filename=file.name, content_type=file.content_type or "application/octet-stream", ) return submission @router.get( f"{PREFIX}/submission/by/user", response={200: list[SubmissionOut], 403: dict} ) def list_user_submissions(request: HttpRequest, event_path: str, scope_id: UUID): if not request.user.is_authenticated: return 403, {"detail": "Authentication required"} submissions = Submission.for_event.filter(scope_id=scope_id) # Admins see every player's submissions for the scope if not request.user.is_staff: submissions = submissions.filter(user=request.user) return submissions.select_related("user").order_by("-created_at") @router.get(f"{PREFIX}/submission/by/game", response=list[SubmissionOut]) def list_submissions_by_game(request: HttpRequest, event_path: str, game_path: str): game = get_object_or_404(Game, path=game_path) if not request.user.is_staff: return 403, {"detail": "Admin access required"} return Submission.for_event.filter(scope__event_game__game=game) @router.get(f"{PREFIX}/submission/{{submission_id}}", response=SubmissionOut) def get_submission(request: HttpRequest, event_path: str, submission_id: UUID): submission = get_object_or_404(Submission.for_event, id=submission_id) if submission.user != request.user and not request.user.is_staff: return 403, {"detail": "Not authorized"} return submission @router.post(f"{PREFIX}/crops/s/{{submission_file_id}}", response=ImageCropOut) def create_image_crop( request: HttpRequest, event_path: str, submission_file_id: UUID, data: ImageCropIn, ): if not request.user.is_staff: return 403, {"detail": "Admin access required"} submission_file = get_object_or_404( SubmissionFile, id=submission_file_id, submission__event=request.event ) crop = ImageCrop.objects.create( submission_file=submission_file, x1=data.x1, y1=data.y1, x2=data.x2, y2=data.y2, label=data.label or "", ocr_status=OCRStatus.TO_PROCESS, ) return crop @router.delete(f"{PREFIX}/crops/{{crop_id}}", response=dict) def delete_image_crop(request: HttpRequest, event_path: str, crop_id: UUID): if not request.user.is_staff: return 403, {"detail": "Admin access required"} crop = get_object_or_404( ImageCrop, id=crop_id, submission_file__submission__event=request.event ) crop.delete() return {"status": "deleted"} @router.post(f"{PREFIX}/files/{{file_id}}/process-ocr", response=dict) def process_file_ocr(request: HttpRequest, event_path: str, file_id: UUID): if not request.user.is_staff: return 403, {"detail": "Admin access required"} submission_file = get_object_or_404( SubmissionFile, id=file_id, submission__event=request.event ) submission_file.ocr_status = OCRStatus.TO_PROCESS submission_file.save() # Enqueue the OCR processing task process_submission_file.enqueue(str(file_id)) return {"status": "queued"} @router.post(f"{PREFIX}/crops/{{crop_id}}/process-ocr", response=dict) def process_crop_ocr(request: HttpRequest, event_path: str, crop_id: UUID): if not request.user.is_staff: return 403, {"detail": "Admin access required"} crop = get_object_or_404( ImageCrop, id=crop_id, submission_file__submission__event=request.event ) crop.ocr_status = OCRStatus.TO_PROCESS crop.save() # Enqueue the OCR processing task process_crop.enqueue(str(crop_id)) return {"status": "queued"}