feat(submissions): scoped submissions per user/matchs, sync from polylan
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user