feat(submissions): scoped submissions per user/matchs, sync from polylan

This commit is contained in:
2026-09-16 13:12:22 +02:00
parent 85e67599af
commit fabf06e6d4
35 changed files with 1008 additions and 92 deletions
+66 -5
View File
@@ -1,5 +1,6 @@
from django.contrib import admin
from .models import Submission, SubmissionFile, ImageCrop
from django.urls import reverse
from .models import Submission, SubmissionFile, ImageCrop, Scope
class ImageCropInline(admin.TabularInline):
@@ -37,16 +38,76 @@ class SubmissionFileInline(admin.TabularInline):
return super().get_queryset(request)
@admin.register(Scope)
class ScopeAdmin(admin.ModelAdmin):
list_display = [
"name",
"event_game",
"external_id",
"is_active",
"assigned_user_count",
"synced_at",
"created_at",
]
list_filter = ["is_active", "event_game__event", "event_game__game"]
search_fields = ["name", "external_id", "event_game__game__name"]
readonly_fields = ["id", "created_at", "updated_at", "synced_at"]
filter_horizontal = ["assigned_users"]
autocomplete_fields = ["event_game"]
def get_queryset(self, request):
return (
super()
.get_queryset(request)
.select_related("event_game", "event_game__event", "event_game__game")
.prefetch_related("assigned_users")
)
@admin.display(description="Assigned users")
def assigned_user_count(self, obj):
return obj.assigned_users.count()
def view_on_site(self, obj):
return reverse(
"submissions.view",
kwargs={
"event_path": obj.event_game.event.path,
"game_path": obj.event_game.game.path,
"scope_id": obj.id,
},
)
@admin.register(Submission)
class SubmissionAdmin(admin.ModelAdmin):
list_display = ["id_short", "game", "user", "notes_preview", "created_at"]
list_filter = ["game", "created_at", "updated_at"]
search_fields = ["id", "user__email", "notes", "game__name"]
list_display = ["id_short", "scope", "game", "user", "notes_preview", "created_at"]
list_filter = [
"scope__event_game__game",
"scope__event_game__event",
"created_at",
"updated_at",
]
search_fields = [
"id",
"user__email",
"notes",
"scope__event_game__game__name",
"scope__name",
]
readonly_fields = ["id", "created_at", "updated_at"]
autocomplete_fields = ["scope"]
inlines = [SubmissionFileInline]
def get_queryset(self, request):
return super().get_queryset(request).select_related("user", "game")
return (
super()
.get_queryset(request)
.select_related("user", "scope", "scope__event_game__game")
)
@admin.display(description="Game", ordering="scope__event_game__game")
def game(self, obj):
return obj.scope.event_game.game
@admin.display(description="ID")
def id_short(self, obj):
+45 -20
View File
@@ -6,8 +6,8 @@ from ninja.files import UploadedFile
from django.shortcuts import get_object_or_404
from games.models import Game
from .models import Submission, SubmissionFile, ImageCrop, OCRStatus
from .schemas import SubmissionOut, ImageCropIn, ImageCropOut
from .models import Submission, SubmissionFile, ImageCrop, OCRStatus, Scope
from .schemas import SubmissionOut, ImageCropIn, ImageCropOut, ScopeOut
from .tasks import process_submission_file, process_crop
router = Router()
@@ -15,22 +15,47 @@ router = Router()
PREFIX = "/events/{event_path}/submissions"
@router.post(f"{PREFIX}/submission/create", response=SubmissionOut)
def create_submission(
request: HttpRequest,
event_path: str,
game_path: str,
files: List[UploadedFile] = File(...),
notes: Optional[str] = Form(None),
):
@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.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,
game=game,
user=request.user if request.user.is_authenticated else None,
scope=scope,
user=request.user,
notes=notes or None,
)
@@ -45,16 +70,16 @@ def create_submission(
return submission
@router.get(f"{PREFIX}/submission/by/user", response=list[SubmissionOut])
def list_user_submissions(request: HttpRequest, event_path: str, game_path: str):
game = get_object_or_404(Game, path=game_path)
@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(game=game, user=request.user).order_by(
"-created_at"
)
submissions = Submission.for_event.filter(
scope_id=scope_id, user=request.user
).order_by("-created_at")
return submissions
@@ -65,7 +90,7 @@ def list_submissions_by_game(request: HttpRequest, event_path: str, game_path: s
if not request.user.is_staff:
return 403, {"detail": "Admin access required"}
return Submission.for_event.filter(game=game)
return Submission.for_event.filter(scope__event_game__game=game)
@router.get(f"{PREFIX}/submission/{{submission_id}}", response=SubmissionOut)
@@ -0,0 +1,43 @@
from django.core.management.base import BaseCommand, CommandError
from accounts.models import CustomUser
from games.models import EventGame
from submissions.models import Scope
class Command(BaseCommand):
help = "Manually create a Scope (match) and assign users to it."
def add_arguments(self, parser):
parser.add_argument("event_game_id", type=int)
parser.add_argument("name", type=str)
parser.add_argument(
"--user",
dest="usernames",
action="append",
default=[],
help="Username to assign to this scope. Repeatable.",
)
def handle(self, *args, **options):
try:
event_game = EventGame.objects.get(id=options["event_game_id"])
except EventGame.DoesNotExist:
raise CommandError(f"No EventGame with id={options['event_game_id']}")
usernames = options["usernames"]
users = list(CustomUser.objects.filter(username__in=usernames))
missing = set(usernames) - {u.username for u in users}
if missing:
raise CommandError(f"Unknown usernames: {', '.join(sorted(missing))}")
scope = Scope.objects.create(event_game=event_game, name=options["name"])
scope.assigned_users.set(users)
self.stdout.write(
self.style.SUCCESS(
f"Created scope '{scope.name}' (id={scope.id}) "
f"with {len(users)} assigned user(s)."
)
)
@@ -0,0 +1,47 @@
from django.core.management.base import BaseCommand, CommandError
from games.models import EventGame
from submissions.tasks import sync_all_event_games_task, sync_event_game_scopes_task
class Command(BaseCommand):
help = "Queue a sync of Scopes (matches) and their assigned users from the legacy AAndT API."
def add_arguments(self, parser):
parser.add_argument(
"--event-game",
type=int,
default=None,
help=(
"Only queue a sync for the EventGame with this id. "
"Default: every EventGame with an AAndT token configured."
),
)
def handle(self, *args, **options):
event_game_id = options["event_game"]
if event_game_id is not None:
if not EventGame.objects.filter(id=event_game_id).exists():
raise CommandError(f"No EventGame with id={event_game_id}")
sync_event_game_scopes_task.enqueue(event_game_id)
self.stdout.write(
self.style.SUCCESS(f"Queued sync for EventGame {event_game_id}")
)
return
if (
not EventGame.objects.exclude(apibot_token__isnull=True)
.exclude(apibot_token="")
.exists()
):
self.stdout.write(
self.style.WARNING("No EventGame has an AAndT token configured.")
)
return
sync_all_event_games_task.enqueue()
self.stdout.write(
self.style.SUCCESS("Queued sync for every EventGame with an AAndT token.")
)
@@ -0,0 +1,84 @@
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0005_submission_event_required"),
("games", "0007_eventgame_apibot_token"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Scope",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"external_id",
models.PositiveIntegerField(
blank=True,
help_text="Legacy AAndT match id, set when synced automatically.",
null=True,
),
),
("name", models.CharField(max_length=255)),
(
"is_active",
models.BooleanField(
default=True,
help_text="Whether submissions are currently accepted for this scope.",
),
),
(
"synced_at",
models.DateTimeField(
blank=True,
help_text="Last time this scope was synced from the AAndT API.",
null=True,
),
),
(
"assigned_users",
models.ManyToManyField(
blank=True,
help_text="Users allowed to submit scores for this scope.",
related_name="scopes",
to=settings.AUTH_USER_MODEL,
),
),
(
"event_game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="scopes",
to="games.eventgame",
),
),
],
options={
"ordering": ["-created_at"],
},
),
migrations.AddConstraint(
model_name="scope",
constraint=models.UniqueConstraint(
condition=models.Q(("external_id__isnull", False)),
fields=("event_game", "external_id"),
name="unique_scope_external_id_per_event_game",
),
),
]
@@ -0,0 +1,37 @@
import uuid
import django.db.models.deletion
from django.db import migrations, models
def delete_existing_submissions(apps, schema_editor):
"""Submissions predate Scope and can't be backfilled with one; wipe them."""
Submission = apps.get_model("submissions", "Submission")
Submission.objects.all().delete()
class Migration(migrations.Migration):
# Deleting existing submissions (which cascades to their files/crops) fires
# deferred FK triggers on postgres; altering the same table right after in
# the same transaction hits "pending trigger events", so run non-atomically.
atomic = False
dependencies = [
("submissions", "0006_scope"),
]
operations = [
migrations.RunPython(delete_existing_submissions, migrations.RunPython.noop),
migrations.AddField(
model_name="submission",
name="scope",
field=models.ForeignKey(
default=uuid.uuid4,
help_text="The match this submission's score is for.",
on_delete=django.db.models.deletion.CASCADE,
related_name="submissions",
to="submissions.scope",
),
preserve_default=False,
),
]
@@ -0,0 +1,17 @@
# Generated by Django 6.0.5 on 2026-09-16 09:27
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("submissions", "0007_submission_scope"),
]
operations = [
migrations.RemoveField(
model_name="submission",
name="game",
),
]
+67 -8
View File
@@ -6,7 +6,7 @@ import uuid
from accounts.models import CustomUser
from events.models import Event
from events.scoping import EventScopedManager
from games.models import Game
from games.models import EventGame
class OCRStatus(models.TextChoices):
@@ -16,14 +16,20 @@ class OCRStatus(models.TextChoices):
ERROR = "error", _("Error processing")
def submission_file_upload_path(instance, filename):
def submission_file_upload_path(instance: "SubmissionFile", filename: str):
ext = filename.split(".")[-1] if "." in filename else ""
new_filename = f"{instance.id}_{filename}" if ext else str(instance.id)
date = instance.created_at.strftime("%Y/%m/%d")
user = instance.submission.user.username
game_path = instance.submission.scope.event_game.game.path
event_path = instance.submission.scope.event_game.event.path
mid = (
instance.submission.scope.external_id
if instance.submission.scope.external_id
else instance.submission.scope.name
)
return f"{instance.submission.game.path.lstrip('/')}/{date}/{user}/{instance.submission.id}/{new_filename}"
return f"{event_path.lstrip('/')}/{game_path.lstrip('/')}/{mid}/{user}/{instance.submission.id}/{new_filename}"
class BaseModel(models.Model):
@@ -37,6 +43,60 @@ class BaseModel(models.Model):
abstract = True
class Scope(BaseModel):
"""A "match" that submissions can be scored against.
Scopes are either created manually (admin/CLI) or auto-synced from the
legacy PolyLAN AAndT API (see submissions.services.aandt_sync), in which
case `external_id` holds the remote match id for idempotent upserts.
"""
event_game = models.ForeignKey(
EventGame,
on_delete=models.CASCADE,
related_name="scopes",
)
external_id = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Legacy AAndT match id, set when synced automatically.",
)
name = models.CharField(max_length=255)
assigned_users = models.ManyToManyField(
CustomUser,
blank=True,
related_name="scopes",
help_text="Users allowed to submit scores for this scope.",
)
is_active = models.BooleanField(
default=True,
help_text="Whether submissions are currently accepted for this scope.",
)
synced_at = models.DateTimeField(
null=True,
blank=True,
help_text="Last time this scope was synced from the AAndT API.",
)
class Meta:
ordering = ["-created_at"]
constraints = [
models.UniqueConstraint(
fields=["event_game", "external_id"],
condition=models.Q(external_id__isnull=False),
name="unique_scope_external_id_per_event_game",
),
]
def __str__(self) -> str:
return f"{self.name} ({self.event_game})"
class Submission(BaseModel):
event = models.ForeignKey(
Event,
@@ -44,12 +104,11 @@ class Submission(BaseModel):
related_name="submissions",
)
game = models.ForeignKey(
Game,
scope = models.ForeignKey(
Scope,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="submissions",
help_text="The match this submission's score is for.",
)
user = models.ForeignKey(
+8 -1
View File
@@ -3,6 +3,13 @@ from uuid import UUID
from ninja import Schema
class ScopeOut(Schema):
id: UUID
name: str
is_active: bool
created_at: datetime
class ImageCropIn(Schema):
x1: int
y1: int
@@ -36,7 +43,7 @@ class SubmissionFileOut(Schema):
class SubmissionOut(Schema):
id: UUID
game_id: int | None = None
scope_id: UUID
notes: str | None = None
files: list[SubmissionFileOut] = []
created_at: datetime
@@ -0,0 +1,65 @@
import requests
class AAndTAPIError(Exception):
"""Raised when the legacy PolyLAN AAndT API is unreachable or errors out."""
class AAndTClient:
"""Thin wrapper around the legacy PolyLAN site's per-AAndT JWT-bearer API."""
def __init__(self, base_url: str, token: str, /, *, timeout: int = 15) -> None:
self.timeout = timeout
self.base_url = base_url.rstrip("/")
self.session = requests.Session()
self.session.headers["Authorization"] = f"Bearer {token}"
def get_members(self) -> list[dict]:
return self._get("members")["members"]
def get_matches(self) -> list[dict]:
return self._get("matches")["matches"]
def set_match_message(
self,
match_id: int,
/,
*,
players: str | None = None,
admin: str | None = None,
msg_all: str | None = None,
) -> None:
body = {
key: value
for key, value in {
"players": players,
"admin": admin,
"all": msg_all,
}.items()
if value is not None
}
self._post(f"match/{match_id}/messages", body)
def _get(self, path: str) -> dict:
try:
response = self.session.get(
f"{self.base_url}/Plamingo/aandt/api/aandt/{path}", timeout=self.timeout
)
response.raise_for_status()
except requests.RequestException as exc:
raise AAndTAPIError(f"Failed to reach AAndT API ({path}): {exc}") from exc
return response.json()
def _post(self, path: str, data: dict) -> dict:
try:
response = self.session.post(
f"{self.base_url}/Plamingo/aandt/api/aandt/{path}",
json=data,
timeout=self.timeout,
)
response.raise_for_status()
except requests.RequestException as exc:
raise AAndTAPIError(f"Failed to reach AAndT API ({path}): {exc}") from exc
return response.json()
@@ -0,0 +1,131 @@
from dataclasses import dataclass, field
from django.conf import settings
from django.db import transaction
from django.urls import reverse
from django.utils import timezone
from accounts.models import CustomUser
from games.models import EventGame
from ..models import Scope
from .aandt_client import AAndTAPIError, AAndTClient
class ScopeSyncError(Exception):
"""Raised when an EventGame cannot be synced against the AAndT API."""
@dataclass
class ScopeSyncResult:
created: int = 0
updated: int = 0
warnings: list[str] = field(default_factory=list)
def _submission_url(scope: Scope, /) -> str:
path = reverse(
"submissions.view",
kwargs={
"event_path": scope.event_game.event.path,
"game_path": scope.event_game.game.path,
"scope_id": scope.id,
},
)
return f"{settings.SUBMITTER_BASE_URL.rstrip('/')}{path}"
def _userid_by_members(members: list[dict]) -> dict[int, set[int]]:
userids_by_members: dict[int, set[int]] = {}
for member in members:
if "team" in member:
userids = {u["id"] for u in member["team"]["members"]}
else:
userids = {member["user"]["id"]}
userids_by_members[member["id"]] = userids
return userids_by_members
@transaction.atomic
def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
"""Fetch matches and members from the legacy AAndT API and upsert local Scopes.
Each remote match becomes one Scope (matched on `external_id`), with
`assigned_users` set to every local account tied to either side of the
match, resolved by username. Active scopes get their submission page's
URL pushed back as the match's player-facing message, so participants
browsing the legacy site get a direct link here.
Args:
event_game: The EventGame to sync; must have `apibot_token` set.
Returns:
A summary of created/updated scopes and any unresolved participants.
Raises:
ScopeSyncError: When the EventGame has no token, or the API call fails.
"""
if not event_game.apibot_token:
raise ScopeSyncError(f"{event_game} has no AAndT token configured")
client = AAndTClient(settings.AANDT_API_BASE_URL, event_game.apibot_token)
try:
userids_by_members = _userid_by_members(client.get_members())
matches = client.get_matches()
except AAndTAPIError as exc:
raise ScopeSyncError(str(exc)) from exc
result = ScopeSyncResult()
now = timezone.now()
for match in matches:
userids: set[int] = set()
for member_id in match.get("players", {}):
userids.update(userids_by_members.get(int(member_id), set()))
users = list(CustomUser.objects.filter(cas_user_id__in=userids))
scope, created = Scope.objects.update_or_create(
event_game=event_game,
external_id=match["id"],
defaults={
"name": match.get("title") or f"Match {match['id']}",
"is_active": match.get("status") == "playing",
"synced_at": now,
},
)
scope.assigned_users.set(users)
result.created += created
result.updated += not created
matched_ids = {
int(u.cas_user_id)
for u in users
if u.cas_user_id and u.cas_user_id.isdigit()
}
missing = userids - matched_ids
if missing:
result.warnings.append(
f"Match {match['id']}: no local account for {', '.join(sorted(map(str, missing)))}"
)
if scope.is_active:
try:
client.set_match_message(
match["id"],
players=(
"<h3>Submit your result</h3>"
f'<a href="{_submission_url(scope)}" target="_blank" rel="noopener">'
"Submit your proof here</a>"
),
)
except AAndTAPIError as exc:
result.warnings.append(
f"Match {match['id']}: failed to push submission link ({exc})"
)
return result
+23
View File
@@ -1,5 +1,7 @@
from django.tasks import task
from games.models import EventGame
from submissions.models import ImageCrop, SubmissionFile, OCRStatus
from submissions.services.aandt_sync import sync_event_game_scopes
from PIL import Image
import pytesseract
@@ -69,3 +71,24 @@ def process_crop(crop_id: str):
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:
sync_event_game_scopes_task.enqueue(event_game.id)
@task
def sync_event_game_scopes_task(event_game_id: int):
event_game = EventGame.objects.filter(id=event_game_id).first()
if not event_game:
return
sync_event_game_scopes(event_game)
+63 -6
View File
@@ -1,22 +1,79 @@
from django.http import HttpRequest
from django.shortcuts import render, get_object_or_404
from uuid import UUID
from django.http import HttpRequest, Http404
from django.shortcuts import redirect, render, get_object_or_404
from django.contrib.auth.decorators import login_required
from events.models import Event
from games.models import Game
from .models import Scope
@login_required
def submission_home(request: HttpRequest, event_path: str, game: str):
def submission_home(request: HttpRequest, event_path: str, game_path: str):
event = get_object_or_404(Event, path=event_path)
game_obj = get_object_or_404(
Game.objects.enabled_for_event(event), path=f"/submissions/{game}"
game = get_object_or_404(Game, path=game_path)
scopes = (
Scope.objects.filter(event_game__event=event, event_game__game=game)
if request.user.is_superuser
else Scope.objects.filter(
event_game__event=event,
event_game__game=game,
assigned_users=request.user,
)
)
if scopes.count() == 1:
return redirect(
"submissions.view",
event_path=event.path,
game_path=game.path,
scope_id=scopes[0].id,
)
if not scopes:
raise Http404
return render(
request,
"submissions_select.html",
{
"event": event,
"game": game,
"scopes_data": [
{"id": str(scope.id), "name": scope.name}
for scope in scopes.order_by("name")
],
},
)
@login_required
def submission_view(
request: HttpRequest, event_path: str, game_path: str, scope_id: UUID
):
event = get_object_or_404(Event, path=event_path)
scope = get_object_or_404(
Scope.objects.select_related("event_game__game").prefetch_related(
"assigned_users"
),
id=scope_id,
event_game__game__path=game_path,
event_game__event=event,
)
if (
not request.user.is_superuser
and not scope.assigned_users.filter(pk=request.user.pk).exists()
):
raise Http404
return render(
request,
"submissions.html",
{
"event": event,
"game": game_obj,
"scope": scope,
"game": scope.event_game.game,
},
)