Files
polylan-submitter/polylan_submitter/opus_magnum/api.py
T
2026-10-25 14:20:49 +01:00

440 lines
16 KiB
Python

import csv
from ninja import Router, File
from ninja.files import UploadedFile
from ninja.pagination import paginate
from ninja.errors import HttpError
from django.db import transaction
from django.core.files.base import ContentFile
from django.core.cache import cache
from django.http import HttpRequest, HttpResponse
from ninja.security import django_auth_is_staff
from django.utils import timezone
from django.shortcuts import get_object_or_404
from typing import List
from games.decorators import require_game_enabled
from opus_magnum.utils import verify_and_validate_ocr_date_for_submission
from ninja.decorators import decorate_view
from .models import (
Submission,
PuzzleResponse,
SubmissionFile,
SteamCollectionItem,
SteamCollection,
)
from .schemas import (
SubmissionIn,
SubmissionOut,
PuzzleResponseOut,
ValidationIn,
SubmissionFileOut,
SteamCollectionItemOut,
SteamCollectionOut,
)
router = Router()
OPUS_MAGNUM_APP_ID = 558990
PREFIX = "/events/{event_path}/opus-magnum"
@router.get(f"{PREFIX}/puzzles", response=List[SteamCollectionItemOut])
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def list_puzzles(request, event_path: str):
"""Get list of available puzzles"""
return SteamCollectionItem.objects.select_related("collection").filter(
collection__is_active=True, collection__event=request.event
)
@router.get(f"{PREFIX}/collection", response=SteamCollectionOut)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def get_collection(request, event_path: str):
"""Get the active collection details"""
return get_object_or_404(SteamCollection.for_event, is_active=True)
@router.get(f"{PREFIX}/submissions", response=List[SubmissionOut])
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
@paginate
def list_submissions(request, event_path: str):
"""Get paginated list of submissions"""
return Submission.for_event.prefetch_related(
"responses__files", "responses__puzzle"
).filter(user=request.user)
@router.get(f"{PREFIX}/submissions/{{submission_id}}", response=SubmissionOut)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def get_submission(request, event_path: str, submission_id: str):
"""Get detailed submission by ID"""
return get_object_or_404(
Submission.for_event.prefetch_related(
"responses__files", "responses__puzzle"
).filter(user=request.user),
id=submission_id,
)
@router.post(
f"{PREFIX}/submissions", response={200: SubmissionOut, 400: dict, 500: dict}
)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def create_submission(
request, event_path: str, data: SubmissionIn, files: List[UploadedFile] = File(...)
):
"""Create a new submission with multiple puzzle responses"""
# Validate that we have files
if not files:
return 400, {"detail": "At least one file is required"}
# Group files by puzzle (based on filename or order)
# For now, we'll assume files are provided in the same order as responses
if len(files) < len(data.responses):
return 400, {"detail": "Not enough files for all responses"}
# Check if collection is accepting submissions
if data.responses:
for puzzle in data.responses:
if not get_object_or_404(
SteamCollectionItem,
id=puzzle.puzzle_id,
collection__event=request.event,
).collection.accepting_submissions:
raise HttpError(
403, "This tournament is no longer accepting submissions"
)
try:
with transaction.atomic():
# Check if any confidence score is below 80% to auto-request validation
auto_request_validation = any(
(
response_data.ocr_confidence_cost is not None
and response_data.ocr_confidence_cost < 0.8
)
or (
response_data.ocr_confidence_cycles is not None
and response_data.ocr_confidence_cycles < 0.8
)
or (
response_data.ocr_confidence_area is not None
and response_data.ocr_confidence_area < 0.8
)
for response_data in data.responses
)
# Non-admin submissions always go through manual validation
needs_validation = (
data.manual_validation_requested or not request.user.is_staff
)
# Create the submission
submission = Submission.objects.create(
event=request.event,
user=request.user if request.user.is_authenticated else None,
notes=data.notes,
manual_validation_requested=needs_validation or auto_request_validation,
)
file_index = 0
for response_data in data.responses:
# Get the puzzle
puzzle = get_object_or_404(
SteamCollectionItem,
id=response_data.puzzle_id,
collection__event=request.event,
)
# Create the puzzle response
response = PuzzleResponse.objects.create(
submission=submission,
puzzle=puzzle,
puzzle_name=response_data.puzzle_name,
cost=response_data.cost,
cycles=response_data.cycles,
area=response_data.area,
needs_manual_validation=needs_validation,
ocr_confidence_cost=response_data.ocr_confidence_cost,
ocr_confidence_cycles=response_data.ocr_confidence_cycles,
ocr_confidence_area=response_data.ocr_confidence_area,
**{
# Put validated if not manual validation is needed
"validated_cost": response_data.cost,
"validated_cycles": response_data.cycles,
"validated_area": response_data.area,
}
if not needs_validation
else {},
)
# Process files for this response
# For simplicity, we'll take one file per response
# In a real implementation, you'd need better file-to-response mapping
if file_index < len(files):
uploaded_file = files[file_index]
# Validate file type
if not uploaded_file.content_type.startswith(("image/", "video/")):
return 400, {
"detail": f"Invalid file type: {uploaded_file.content_type}"
}
# Validate file size (256MB limit)
if uploaded_file.size > 256 * 1024 * 1024:
return 400, {"detail": "File too large (max 256MB)"}
# Create submission file
submission_file = SubmissionFile.objects.create(
response=response,
original_filename=uploaded_file.name,
file_size=uploaded_file.size,
content_type=uploaded_file.content_type,
)
# Save the file
submission_file.file.save(
uploaded_file.name, ContentFile(uploaded_file.read()), save=True
)
file_index += 1
# Check if OCR validation is needed
if not all(
[response_data.cost, response_data.cycles, response_data.area]
):
response.mark_for_validation("Incomplete OCR data")
# Reload with relations for response
submission = Submission.for_event.prefetch_related(
"responses__files", "responses__puzzle"
).get(id=submission.id)
# Invalidate results cache on successful submission
cache.delete(f"api:{event_path}:results:results")
return submission
except Exception as e:
return 500, {"detail": f"Error creating submission: {str(e)}"}
@router.put(
f"{PREFIX}/responses/{{response_id}}/validate",
response={200: PuzzleResponseOut, 403: dict},
)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def validate_response(request, event_path: str, response_id: int, data: ValidationIn):
"""Manually validate a puzzle response"""
if not request.user.is_authenticated or not request.user.is_staff:
return 403, {"detail": "Admin access required"}
response = get_object_or_404(
PuzzleResponse, id=response_id, submission__event=request.event
)
if data.puzzle is not None:
puzzle = get_object_or_404(
SteamCollectionItem, id=data.puzzle, collection__event=request.event
)
response.puzzle = puzzle
# Update validated values
if data.validated_cost is not None:
response.validated_cost = data.validated_cost
if data.validated_cycles is not None:
response.validated_cycles = data.validated_cycles
if data.validated_area is not None:
response.validated_area = data.validated_area
# Mark as no longer needing validation if we have all values
if all([response.final_cost, response.final_cycles, response.final_area]):
response.needs_manual_validation = False
response.save()
# Invalidate results cache when a response is validated
cache.delete(f"api:{event_path}:results:results")
return response
@router.put(
f"{PREFIX}/responses/{{response_id}}/validate/auto",
response={200: PuzzleResponseOut, 403: dict},
)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def validate_auto(request, event_path: str, response_id: int):
"""Try to auto validate a puzzle response"""
if not request.user.is_authenticated or not request.user.is_staff:
return 403, {"detail": "Admin access required"}
response = get_object_or_404(
PuzzleResponse, id=response_id, submission__event=request.event
)
for file in response.files.all():
verify_and_validate_ocr_date_for_submission(file)
return response
@router.get(
f"{PREFIX}/responses/needs-validation",
response={200: List[PuzzleResponseOut], 403: dict},
)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def list_responses_needing_validation(request, event_path: str):
"""Get all responses that need manual validation"""
if not request.user.is_authenticated or not request.user.is_staff:
return 403, {"detail": "Admin access required"}
return (
PuzzleResponse.objects.filter(needs_manual_validation=True)
.filter(
puzzle__collection__is_active=True, puzzle__collection__event=request.event
)
.select_related("puzzle", "submission")
.prefetch_related("files")
)
@router.get(
f"{PREFIX}/responses/{{response_id}}/files",
response={200: List[SubmissionFileOut], 403: dict},
)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def list_response_files(request, event_path: str, response_id: int):
"""Get the solution files of any player's response (admin only)"""
if not request.user.is_authenticated or not request.user.is_staff:
return 403, {"detail": "Admin access required"}
response = get_object_or_404(
PuzzleResponse, id=response_id, submission__event=request.event
)
return response.files.all()
@router.post(
f"{PREFIX}/submissions/{{submission_id}}/validate",
response={200: SubmissionOut, 403: dict},
)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def validate_submission(request, event_path: str, submission_id: str):
"""Mark entire submission as validated"""
if not request.user.is_authenticated or not request.user.is_staff:
return 403, {"detail": "Admin access required"}
submission = get_object_or_404(Submission.for_event, id=submission_id)
submission.is_validated = True
submission.validated_by = request.user
submission.validated_at = timezone.now()
submission.save()
# Also mark all responses as not needing validation
submission.responses.update(needs_manual_validation=False)
# Reload with relations
submission = Submission.for_event.prefetch_related(
"responses__files", "responses__puzzle"
).get(id=submission.id)
# Invalidate results cache when submission is validated
cache.delete(f"api:{event_path}:results:results")
return submission
@router.delete(
f"{PREFIX}/submissions/{{submission_id}}", response={200: dict, 403: dict}
)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def delete_submission(request, event_path: str, submission_id: str):
"""Delete a submission (admin only)"""
if not request.user.is_authenticated or not request.user.is_staff:
return 403, {"detail": "Admin access required"}
submission = get_object_or_404(Submission.for_event, id=submission_id)
submission.delete()
# Invalidate results cache when submission is deleted
cache.delete(f"api:{event_path}:results:results")
return {"detail": "Submission deleted successfully"}
@router.get(f"{PREFIX}/stats")
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def get_stats(request, event_path: str):
"""Get submission statistics"""
total_submissions = Submission.for_event.count()
total_responses = PuzzleResponse.objects.filter(
submission__event=request.event
).count()
needs_validation = PuzzleResponse.objects.filter(
needs_manual_validation=True, submission__event=request.event
).count()
validated_submissions = Submission.for_event.filter(is_validated=True).count()
return {
"total_submissions": total_submissions,
"total_responses": total_responses,
"needs_validation": needs_validation,
"validated_submissions": validated_submissions,
"validation_rate": (total_responses - needs_validation) / total_responses
if total_responses
else 0,
}
@router.get(f"{PREFIX}/points.csv", auth=django_auth_is_staff)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def points_csv(request: HttpRequest, event_path: str):
"""Admin-only: points per player for each puzzle of the active collection,
one column per puzzle (1 puzzle = 1 round), `;` separated."""
puzzles = list(
SteamCollectionItem.objects.filter(
collection__is_active=True, collection__event=request.event
).order_by("order_index")
)
points = {}
for response in (
PuzzleResponse.objects.filter(
needs_manual_validation=False,
submission__event=request.event,
submission__user__isnull=False,
puzzle__in=puzzles,
)
.filter_user_best_response()
.select_related("submission__user")
):
user_points = points.setdefault(response.submission.user.username, {})
user_points[response.puzzle_id] = response.rank_points or 0
# text/plain so browsers render it inline - most force-download text/csv
response = HttpResponse(content_type="text/plain; charset=utf-8")
writer = csv.writer(response, delimiter=";")
writer.writerow(["username", *(puzzle.title for puzzle in puzzles)])
for username, user_points in sorted(
points.items(), key=lambda item: (-sum(item[1].values()), item[0])
):
writer.writerow(
[username, *(user_points.get(puzzle.id, 0) for puzzle in puzzles)]
)
return response