305 lines
9.3 KiB
Python
305 lines
9.3 KiB
Python
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"}
|