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
+27 -2
View File
@@ -1,4 +1,6 @@
from django.contrib import admin from django.contrib import admin, messages
from submissions.tasks import sync_event_game_scopes_task
from .models import EventGame, Game from .models import EventGame, Game
@@ -18,6 +20,29 @@ class GameAdmin(admin.ModelAdmin):
@admin.register(EventGame) @admin.register(EventGame)
class EventGameAdmin(admin.ModelAdmin): class EventGameAdmin(admin.ModelAdmin):
list_display = ["game", "event", "enabled"] list_display = ["game", "event", "enabled", "has_apibot_token"]
list_filter = ["event", "enabled"] list_filter = ["event", "enabled"]
search_fields = ["game__name", "event__name"] search_fields = ["game__name", "event__name"]
actions = ["sync_scopes"]
@admin.display(description="AAndT token", boolean=True)
def has_apibot_token(self, obj):
return bool(obj.apibot_token)
@admin.action(description="Sync scopes from AAndT for selected EventGames")
def sync_scopes(self, request, queryset):
tokened = queryset.exclude(apibot_token__isnull=True).exclude(apibot_token="")
skipped = queryset.count() - tokened.count()
for event_game in tokened:
sync_event_game_scopes_task.enqueue(event_game.id)
self.message_user(
request, f"Queued scope sync for {tokened.count()} EventGame(s)."
)
if skipped:
self.message_user(
request,
f"Skipped {skipped} EventGame(s) with no AAndT token configured.",
level=messages.WARNING,
)
@@ -0,0 +1,23 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0006_remove_game_enabled"),
]
operations = [
migrations.AddField(
model_name="eventgame",
name="apibot_token",
field=models.CharField(
blank=True,
help_text=(
"Bearer token for the legacy PolyLAN AAndT API (from the AAndT's "
"'apibot' admin page), used to auto-sync Scopes and their assigned users."
),
max_length=512,
null=True,
),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 6.0.5 on 2026-09-16 00:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0007_eventgame_apibot_token"),
]
operations = [
migrations.AddField(
model_name="game",
name="custom_path",
field=models.BooleanField(default=False),
),
]
+11
View File
@@ -12,6 +12,7 @@ class Game(models.Model):
steam_app_id = models.PositiveIntegerField(null=True, blank=True) steam_app_id = models.PositiveIntegerField(null=True, blank=True)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
path = models.CharField(max_length=100) path = models.CharField(max_length=100)
custom_path = models.BooleanField(default=False)
image = models.URLField(null=True, blank=True) image = models.URLField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@@ -41,6 +42,16 @@ class EventGame(models.Model):
) )
enabled = models.BooleanField(default=True) enabled = models.BooleanField(default=True)
apibot_token = models.CharField(
max_length=512,
blank=True,
null=True,
help_text=(
"Bearer token for the legacy PolyLAN AAndT API (from the AAndT's "
"'apibot' admin page), used to auto-sync Scopes and their assigned users."
),
)
class Meta: class Meta:
constraints = [ constraints = [
models.UniqueConstraint( models.UniqueConstraint(
+1
View File
@@ -8,4 +8,5 @@ class GameOut(Schema):
steam_app_id: Optional[int] = None steam_app_id: Optional[int] = None
name: str name: str
path: str path: str
custom_path: bool
image: Optional[str] = None image: Optional[str] = None
@@ -149,6 +149,14 @@ CAS_SERVER_URL = "https://polylan.ch/cas/"
# Steam API Configuration # Steam API Configuration
STEAM_API_KEY = os.environ.get("STEAM_API_KEY", None) # Set via environment variable STEAM_API_KEY = os.environ.get("STEAM_API_KEY", None) # Set via environment variable
# Base URL of the legacy PolyLAN site, used to sync AAndT matches/members
# (see submissions.services.aandt_sync)
AANDT_API_BASE_URL = os.environ.get("AANDT_API_BASE_URL", "https://polylan.ch")
# Base URL of this site, used to build submission links pushed back to the
# legacy AAndT match messages (see submissions.services.aandt_sync)
SUBMITTER_BASE_URL = os.environ.get("SUBMITTER_BASE_URL", "http://127.0.0.1:7777")
# File Upload Settings # File Upload Settings
MEDIA_URL = "/media/" MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media" MEDIA_ROOT = BASE_DIR / "media"
+7 -2
View File
@@ -26,7 +26,7 @@ from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
from events.models import Event from events.models import Event
from games.decorators import require_game_enabled from games.decorators import require_game_enabled
from market.views import market_home from market.views import market_home
from submissions.views import submission_home from submissions.views import submission_home, submission_view
from .api import api from .api import api
NOITA_APP_ID = 881100 NOITA_APP_ID = 881100
@@ -90,10 +90,15 @@ urlpatterns = [
path("<slug:event_path>/opus-magnum", opus_magnum_home, name="opus-magnum.home"), path("<slug:event_path>/opus-magnum", opus_magnum_home, name="opus-magnum.home"),
path("<slug:event_path>/noita", noita_home, name="noita.home"), path("<slug:event_path>/noita", noita_home, name="noita.home"),
path( path(
"<slug:event_path>/submissions/<str:game>", "<slug:event_path>/submissions/<slug:game_path>",
submission_home, submission_home,
name="submissions.home", name="submissions.home",
), ),
path(
"<slug:event_path>/submissions/<slug:game_path>/<uuid:scope_id>",
submission_view,
name="submissions.view",
),
path("<slug:event_path>/", event_home, name="event.home"), path("<slug:event_path>/", event_home, name="event.home"),
path("", home, name="home"), path("", home, name="home"),
] ]
+2 -4
View File
@@ -7,6 +7,7 @@ import requests
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.backends import BaseBackend from django.contrib.auth.backends import BaseBackend
import sentry_sdk
class SimpleCASBackend(BaseBackend): class SimpleCASBackend(BaseBackend):
@@ -23,9 +24,6 @@ class SimpleCASBackend(BaseBackend):
if not cas_user_id: if not cas_user_id:
return None return None
print(f"CAS User ID: {cas_user_id}")
print(f"CAS Attributes: {attributes}")
User = get_user_model() User = get_user_model()
# Try to find user by CAS user ID first, then by username # Try to find user by CAS user ID first, then by username
@@ -80,7 +78,7 @@ class SimpleCASBackend(BaseBackend):
return None, None return None, None
except Exception as e: except Exception as e:
print(f"CAS validation error: {e}") sentry_sdk.capture_exception(e)
return None, None return None, None
def get_user(self, user_id): def get_user(self, user_id):
+2 -1
View File
@@ -91,7 +91,8 @@ onMounted(async () => {
</div> </div>
<!-- Game Cards --> <!-- Game Cards -->
<div v-for="game in games" :key="game.id" @click="navigate(game.path)" <div v-for="game in games" :key="game.id"
@click="navigate(game.custom_path ? `/${game.path}` : `/submissions/${game.path}`)"
class="card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden"> class="card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden">
<figure class="relative h-60 bg-base-300 overflow-hidden"> <figure class="relative h-60 bg-base-300 overflow-hidden">
<img v-if="getHeaderImage(game) && !imageErrors.has(game.id)" :src="getHeaderImage(game)!" :alt="game.name" <img v-if="getHeaderImage(game) && !imageErrors.has(game.id)" :src="getHeaderImage(game)!" :alt="game.name"
+23 -29
View File
@@ -8,15 +8,18 @@ import { provideEventPath } from "@/composables/useEventPath";
interface Props { interface Props {
eventPath: string; eventPath: string;
gamePath?: string; scopeId: string;
scopeName?: string;
gameName?: string; gameName?: string;
gamePath?: string;
gameImage?: string; gameImage?: string;
gameSteamId?: string; gameSteamId?: string;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
gamePath: "", scopeName: "Match",
gameName: "Game", gameName: "Game",
gamePath: "",
gameImage: "", gameImage: "",
gameSteamId: "", gameSteamId: "",
}); });
@@ -403,7 +406,7 @@ const submitSubmission = async () => {
try { try {
await submissionsApiCreateSubmission({ await submissionsApiCreateSubmission({
path: { event_path: props.eventPath }, path: { event_path: props.eventPath },
query: { game_path: props.gamePath }, query: { scope_id: props.scopeId },
body: { body: {
files: uploadedFiles.value, files: uploadedFiles.value,
notes: notes.value || null, notes: notes.value || null,
@@ -423,7 +426,7 @@ const submitSubmission = async () => {
}; };
const goHome = () => { const goHome = () => {
window.location.href = `/${props.eventPath}/`; window.location.href = `/${props.eventPath}/submissions/${props.gamePath}`;
}; };
const formatFileSize = (bytes: number) => { const formatFileSize = (bytes: number) => {
@@ -454,13 +457,11 @@ const getOCRStatusColor = (status: string) => {
}; };
const loadUserSubmissions = async () => { const loadUserSubmissions = async () => {
if (!props.gamePath) return;
isLoadingSubmissions.value = true; isLoadingSubmissions.value = true;
try { try {
const { data } = await submissionsApiListUserSubmissions({ const { data } = await submissionsApiListUserSubmissions({
path: { event_path: props.eventPath }, path: { event_path: props.eventPath },
query: { game_path: props.gamePath }, query: { scope_id: props.scopeId },
}); });
if (data && Array.isArray(data)) { if (data && Array.isArray(data)) {
userSubmissions.value = data; userSubmissions.value = data;
@@ -522,7 +523,7 @@ onUnmounted(() => {
<i class="mdi mdi-arrow-left"></i> <i class="mdi mdi-arrow-left"></i>
Back Back
</button> </button>
<h1 class="text-xl font-bold">{{ gameName }} Submissions</h1> <h1 class="text-xl font-bold">{{ gameName }} Submissions &mdash; {{ scopeName }}</h1>
<div class="flex-1"></div> <div class="flex-1"></div>
<!-- WebSocket Status Indicator --> <!-- WebSocket Status Indicator -->
@@ -555,7 +556,8 @@ onUnmounted(() => {
</figure> </figure>
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-2xl">{{ gameName }}</h2> <h2 class="card-title text-2xl">{{ gameName }}</h2>
<p class="text-base-content/70">Submit your best results for this game</p> <p class="text-base-content/70">Submitting for match: <span class="font-semibold">{{ scopeName }}</span>
</p>
</div> </div>
</div> </div>
@@ -568,10 +570,11 @@ onUnmounted(() => {
</h2> </h2>
<!-- Upload Area --> <!-- Upload Area -->
<div @dragover="handleDragover" @dragleave="handleDragleave" @drop="handleDrop" @mouseenter="isUploadAreaHovered = true" @mouseleave="isUploadAreaHovered = false" :class="[ <div @dragover="handleDragover" @dragleave="handleDragleave" @drop="handleDrop"
'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer bg-base-200/50 mb-6', @mouseenter="isUploadAreaHovered = true" @mouseleave="isUploadAreaHovered = false" :class="[
isDragover ? 'border-primary bg-primary/10' : 'border-base-300 hover:border-primary' 'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer bg-base-200/50 mb-6',
]"> isDragover ? 'border-primary bg-primary/10' : 'border-base-300 hover:border-primary'
]">
<input type="file" multiple @change="handleFileUpload" class="hidden" id="file-upload" /> <input type="file" multiple @change="handleFileUpload" class="hidden" id="file-upload" />
<label for="file-upload" class="cursor-pointer flex flex-col items-center gap-3"> <label for="file-upload" class="cursor-pointer flex flex-col items-center gap-3">
<i :class="[ <i :class="[
@@ -625,7 +628,7 @@ onUnmounted(() => {
<span class="label-text-alt">0/500</span> <span class="label-text-alt">0/500</span>
</label> </label>
<textarea class="flex textarea textarea-bordered h-24 w-full resize-none" <textarea v-model="notes" class="flex textarea textarea-bordered h-24 w-full resize-none"
placeholder="Add any notes special for this upload" maxlength="500"></textarea> placeholder="Add any notes special for this upload" maxlength="500"></textarea>
</div> </div>
</div> </div>
@@ -699,15 +702,6 @@ onUnmounted(() => {
processed. processed.
</p> </p>
</div> </div>
<div class="divider"></div>
<div class="bg-base-200 p-4 rounded-lg">
<p class="text-base-content/70 text-sm">
<i class="mdi mdi-lightbulb-on mr-2 text-warning"></i>
Pro tip: Include clear screenshots or documentation with your submission for faster verification.
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -925,8 +919,7 @@ onUnmounted(() => {
<p class="text-base-content/70">OCR processing failed</p> <p class="text-base-content/70">OCR processing failed</p>
</div> </div>
</div> </div>
<div v-else-if="!selectedSubmissionFile?.ocr_raw_data" <div v-else-if="!selectedSubmissionFile?.ocr_raw_data" class="flex items-center justify-center py-8">
class="flex items-center justify-center py-8">
<div class="text-center"> <div class="text-center">
<i class="mdi mdi-clock-outline text-4xl text-base-content/30 mb-2"></i> <i class="mdi mdi-clock-outline text-4xl text-base-content/30 mb-2"></i>
<p class="text-base-content/70">Waiting for OCR processing...</p> <p class="text-base-content/70">Waiting for OCR processing...</p>
@@ -934,9 +927,9 @@ onUnmounted(() => {
</div> </div>
<!-- Crops OCR Data --> <!-- Crops OCR Data -->
<div v-if="selectedSubmissionFile?.crops?.length" <div v-if="selectedSubmissionFile?.crops?.length" class="space-y-3 pt-4 border-t border-base-300">
class="space-y-3 pt-4 border-t border-base-300"> <p class="text-xs font-semibold text-base-content/70">Crop OCR Results ({{
<p class="text-xs font-semibold text-base-content/70">Crop OCR Results ({{ selectedSubmissionFile.crops.length }}):</p> selectedSubmissionFile.crops.length }}):</p>
<div v-for="crop in selectedSubmissionFile.crops" :key="crop.id" class="space-y-2"> <div v-for="crop in selectedSubmissionFile.crops" :key="crop.id" class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p class="text-xs font-medium">{{ crop.label || `Crop (${crop.x1}, ${crop.y1})` }}</p> <p class="text-xs font-medium">{{ crop.label || `Crop (${crop.x1}, ${crop.y1})` }}</p>
@@ -946,7 +939,8 @@ onUnmounted(() => {
class="bg-base-100 p-2 rounded text-xs whitespace-pre-wrap break-words font-mono max-h-32 overflow-y-auto"> class="bg-base-100 p-2 rounded text-xs whitespace-pre-wrap break-words font-mono max-h-32 overflow-y-auto">
{{ crop.ocr_raw_data }} {{ crop.ocr_raw_data }}
</div> </div>
<div v-else-if="crop.ocr_status === 'processing'" class="text-xs text-base-content/70 text-center py-2"> <div v-else-if="crop.ocr_status === 'processing'"
class="text-xs text-base-content/70 text-center py-2">
<span class="loading loading-spinner loading-sm inline-block"></span> <span class="loading loading-spinner loading-sm inline-block"></span>
Processing... Processing...
</div> </div>
@@ -0,0 +1,54 @@
<script setup lang="ts">
interface ScopeOption {
id: string;
name: string;
}
interface Props {
eventPath: string;
gamePath: string;
gameName?: string;
scopes?: ScopeOption[];
}
const props = withDefaults(defineProps<Props>(), {
gameName: "Game",
scopes: () => [],
});
const goToScope = (scopeId: string) => {
window.location.href = `/${props.eventPath}/submissions/${props.gamePath}/${scopeId}`;
};
const goHome = () => {
window.location.href = `/${props.eventPath}`;
};
</script>
<template>
<div class="min-h-screen bg-base-200 flex items-center justify-center p-4">
<div class="card bg-base-100 shadow-lg w-full max-w-md">
<div class="card-body">
<h2 class="card-title">Select your match</h2>
<p class="text-base-content/70 mb-2">
You're assigned to multiple matches for {{ gameName }}. Pick which one you want to submit for.
</p>
<div class="flex flex-col gap-2 mt-2">
<button v-for="scope in scopes" :key="scope.id" @click="goToScope(scope.id)"
class="btn btn-outline justify-start">
<i class="mdi mdi-sword-cross mr-2"></i>
{{ scope.name }}
</button>
</div>
<div class="card-actions mt-4">
<button @click="goHome" class="btn btn-ghost btn-sm">
<i class="mdi mdi-arrow-left"></i>
Back
</button>
</div>
</div>
</div>
</div>
</template>
+2 -2
View File
@@ -1,4 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
export { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, gamesApiListEventGames, gamesApiListGames, marketApiCloseMarket, marketApiCreateBet, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck, submissionsApiCreateImageCrop, submissionsApiCreateSubmission, submissionsApiDeleteImageCrop, submissionsApiGetSubmission, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions, submissionsApiProcessCropOcr, submissionsApiProcessFileOcr } from './sdk.gen'; export { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, gamesApiListEventGames, gamesApiListGames, marketApiCloseMarket, marketApiCreateBet, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck, submissionsApiCreateImageCrop, submissionsApiCreateSubmission, submissionsApiDeleteImageCrop, submissionsApiGetSubmission, submissionsApiListMyScopes, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions, submissionsApiProcessCropOcr, submissionsApiProcessFileOcr } from './sdk.gen';
export type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, AnimationsApiTopSubmissionsResponses, ClientOptions, GameOut, GamesApiListEventGamesData, GamesApiListEventGamesResponse, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponse, GamesApiListGamesResponses, ImageCropIn, ImageCropOut, Input, LeaderboardEntryOut, LeaderboardOut, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponse, MarketApiResolveMarketResponses, MarketListSchema, MarketOptionSchema, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponse, NoitaApiSubmitLogFileResponses, NoitaSubmissionOut, ObjectivResultOut, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponse, OpusMagnumApiValidateSubmissionResponses, PagedSubmissionOut, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, PuzzlePointsFactorOut, PuzzleResponseIn, PuzzleResponseOut, PuzzleResponseRankingOut, PuzzleResultsOut, PuzzleSubmissionsOut, PuzzleSubmissionWithRankOut, RankingSchema, ResolveMarketSchema, ResultsOut, SteamCollectionItemOut, SteamCollectionOut, SubmissionFileOut, SubmissionIn, SubmissionOut, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionResponse, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponse, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponse, SubmissionsApiGetSubmissionResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponse, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponse, SubmissionsApiProcessFileOcrResponses, TournamentPuzzleResultsOut, TournamentSubmissionsOut, UserBetCreateSchema, UserBetSchema, UserDisplayOut, UserInfoOut, ValidationIn, WinnerFileOut, WinnerResponseOut } from './types.gen'; export type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, AnimationsApiTopSubmissionsResponses, ClientOptions, GameOut, GamesApiListEventGamesData, GamesApiListEventGamesResponse, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponse, GamesApiListGamesResponses, ImageCropIn, ImageCropOut, Input, LeaderboardEntryOut, LeaderboardOut, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponse, MarketApiResolveMarketResponses, MarketListSchema, MarketOptionSchema, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponse, NoitaApiSubmitLogFileResponses, NoitaSubmissionOut, ObjectivResultOut, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponse, OpusMagnumApiValidateSubmissionResponses, PagedSubmissionOut, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, PuzzlePointsFactorOut, PuzzleResponseIn, PuzzleResponseOut, PuzzleResponseRankingOut, PuzzleResultsOut, PuzzleSubmissionsOut, PuzzleSubmissionWithRankOut, RankingSchema, ResolveMarketSchema, ResultsOut, ScopeOut, SteamCollectionItemOut, SteamCollectionOut, SubmissionFileOut, SubmissionIn, SubmissionOut, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionError, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponse, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponse, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponse, SubmissionsApiGetSubmissionResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesError, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponse, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsError, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponse, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponse, SubmissionsApiProcessFileOcrResponses, TournamentPuzzleResultsOut, TournamentSubmissionsOut, UserBetCreateSchema, UserBetSchema, UserDisplayOut, UserInfoOut, ValidationIn, WinnerFileOut, WinnerResponseOut } from './types.gen';
+8 -3
View File
@@ -2,7 +2,7 @@
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client'; import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
import { client } from './client.gen'; import { client } from './client.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen'; import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/** /**
@@ -196,10 +196,15 @@ export const noitaApiSubmitLogFile = <ThrowOnError extends boolean = false>(opti
*/ */
export const gamesApiListEventGames = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventGamesData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventGamesResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/games', ...options }); export const gamesApiListEventGames = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventGamesData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventGamesResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/games', ...options });
/**
* List My Scopes
*/
export const submissionsApiListMyScopes = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListMyScopesData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListMyScopesResponses, SubmissionsApiListMyScopesErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/scopes/mine', ...options });
/** /**
* Create Submission * Create Submission
*/ */
export const submissionsApiCreateSubmission = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiCreateSubmissionData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiCreateSubmissionResponses, unknown, ThrowOnError>({ export const submissionsApiCreateSubmission = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiCreateSubmissionData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiCreateSubmissionResponses, SubmissionsApiCreateSubmissionErrors, ThrowOnError>({
...formDataBodySerializer, ...formDataBodySerializer,
url: '/api/events/{event_path}/submissions/submission/create', url: '/api/events/{event_path}/submissions/submission/create',
...options, ...options,
@@ -212,7 +217,7 @@ export const submissionsApiCreateSubmission = <ThrowOnError extends boolean = fa
/** /**
* List User Submissions * List User Submissions
*/ */
export const submissionsApiListUserSubmissions = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListUserSubmissionsData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListUserSubmissionsResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/submissions/submission/by/user', ...options }); export const submissionsApiListUserSubmissions = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListUserSubmissionsData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListUserSubmissionsResponses, SubmissionsApiListUserSubmissionsErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/submission/by/user', ...options });
/** /**
* List Submissions By Game * List Submissions By Game
+99 -6
View File
@@ -72,6 +72,10 @@ export type GameOut = {
* Path * Path
*/ */
path: string; path: string;
/**
* Custom Path
*/
custom_path: boolean;
/** /**
* Image * Image
*/ */
@@ -412,9 +416,9 @@ export type SubmissionOut = {
*/ */
id: string; id: string;
/** /**
* Game Id * Scope Id
*/ */
game_id?: number | null; scope_id: string;
/** /**
* Notes * Notes
*/ */
@@ -916,6 +920,28 @@ export type NoitaSubmissionOut = {
processed: boolean; processed: boolean;
}; };
/**
* ScopeOut
*/
export type ScopeOut = {
/**
* Id
*/
id: string;
/**
* Name
*/
name: string;
/**
* Is Active
*/
is_active: boolean;
/**
* Created At
*/
created_at: string;
};
/** /**
* ImageCropOut * ImageCropOut
*/ */
@@ -1601,6 +1627,47 @@ export type GamesApiListEventGamesResponses = {
export type GamesApiListEventGamesResponse = GamesApiListEventGamesResponses[keyof GamesApiListEventGamesResponses]; export type GamesApiListEventGamesResponse = GamesApiListEventGamesResponses[keyof GamesApiListEventGamesResponses];
export type SubmissionsApiListMyScopesData = {
body?: never;
path: {
/**
* Event Path
*/
event_path: string;
};
query: {
/**
* Game Path
*/
game_path: string;
};
url: '/api/events/{event_path}/submissions/scopes/mine';
};
export type SubmissionsApiListMyScopesErrors = {
/**
* Response
*
* Forbidden
*/
403: {
[key: string]: unknown;
};
};
export type SubmissionsApiListMyScopesError = SubmissionsApiListMyScopesErrors[keyof SubmissionsApiListMyScopesErrors];
export type SubmissionsApiListMyScopesResponses = {
/**
* Response
*
* OK
*/
200: Array<ScopeOut>;
};
export type SubmissionsApiListMyScopesResponse = SubmissionsApiListMyScopesResponses[keyof SubmissionsApiListMyScopesResponses];
export type SubmissionsApiCreateSubmissionData = { export type SubmissionsApiCreateSubmissionData = {
/** /**
* MultiPartBodyParams * MultiPartBodyParams
@@ -1623,13 +1690,26 @@ export type SubmissionsApiCreateSubmissionData = {
}; };
query: { query: {
/** /**
* Game Path * Scope Id
*/ */
game_path: string; scope_id: string;
}; };
url: '/api/events/{event_path}/submissions/submission/create'; url: '/api/events/{event_path}/submissions/submission/create';
}; };
export type SubmissionsApiCreateSubmissionErrors = {
/**
* Response
*
* Forbidden
*/
403: {
[key: string]: unknown;
};
};
export type SubmissionsApiCreateSubmissionError = SubmissionsApiCreateSubmissionErrors[keyof SubmissionsApiCreateSubmissionErrors];
export type SubmissionsApiCreateSubmissionResponses = { export type SubmissionsApiCreateSubmissionResponses = {
/** /**
* OK * OK
@@ -1649,13 +1729,26 @@ export type SubmissionsApiListUserSubmissionsData = {
}; };
query: { query: {
/** /**
* Game Path * Scope Id
*/ */
game_path: string; scope_id: string;
}; };
url: '/api/events/{event_path}/submissions/submission/by/user'; url: '/api/events/{event_path}/submissions/submission/by/user';
}; };
export type SubmissionsApiListUserSubmissionsErrors = {
/**
* Response
*
* Forbidden
*/
403: {
[key: string]: unknown;
};
};
export type SubmissionsApiListUserSubmissionsError = SubmissionsApiListUserSubmissionsErrors[keyof SubmissionsApiListUserSubmissionsErrors];
export type SubmissionsApiListUserSubmissionsResponses = { export type SubmissionsApiListUserSubmissionsResponses = {
/** /**
* Response * Response
@@ -3,7 +3,7 @@ import { useWebSocket } from '@vueuse/core'
interface SubmissionUpdate { interface SubmissionUpdate {
id: string id: string
game_id?: number scope_id?: string
notes?: string notes?: string
files?: SubmissionFileUpdate[] files?: SubmissionFileUpdate[]
created_at: string created_at: string
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import SubmissionsSelect from '@/SubmissionsSelect.vue'
import '@/style.css'
const selector = "#app"
const mountData = document.querySelector<HTMLElement>(selector)
const scopesEl = document.getElementById('scopes-data')
const scopes = scopesEl ? JSON.parse(scopesEl.textContent || '[]') : []
const app = createApp(SubmissionsSelect, { ...mountData?.dataset, scopes })
app.mount(selector)
+66 -5
View File
@@ -1,5 +1,6 @@
from django.contrib import admin 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): class ImageCropInline(admin.TabularInline):
@@ -37,16 +38,76 @@ class SubmissionFileInline(admin.TabularInline):
return super().get_queryset(request) 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) @admin.register(Submission)
class SubmissionAdmin(admin.ModelAdmin): class SubmissionAdmin(admin.ModelAdmin):
list_display = ["id_short", "game", "user", "notes_preview", "created_at"] list_display = ["id_short", "scope", "game", "user", "notes_preview", "created_at"]
list_filter = ["game", "created_at", "updated_at"] list_filter = [
search_fields = ["id", "user__email", "notes", "game__name"] "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"] readonly_fields = ["id", "created_at", "updated_at"]
autocomplete_fields = ["scope"]
inlines = [SubmissionFileInline] inlines = [SubmissionFileInline]
def get_queryset(self, request): 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") @admin.display(description="ID")
def id_short(self, obj): 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 django.shortcuts import get_object_or_404
from games.models import Game from games.models import Game
from .models import Submission, SubmissionFile, ImageCrop, OCRStatus from .models import Submission, SubmissionFile, ImageCrop, OCRStatus, Scope
from .schemas import SubmissionOut, ImageCropIn, ImageCropOut from .schemas import SubmissionOut, ImageCropIn, ImageCropOut, ScopeOut
from .tasks import process_submission_file, process_crop from .tasks import process_submission_file, process_crop
router = Router() router = Router()
@@ -15,22 +15,47 @@ router = Router()
PREFIX = "/events/{event_path}/submissions" PREFIX = "/events/{event_path}/submissions"
@router.post(f"{PREFIX}/submission/create", response=SubmissionOut) @router.get(f"{PREFIX}/scopes/mine", response={200: list[ScopeOut], 403: dict})
def create_submission( def list_my_scopes(request: HttpRequest, event_path: str, game_path: str):
request: HttpRequest, if not request.user.is_authenticated:
event_path: str, return 403, {"detail": "Authentication required"}
game_path: str,
files: List[UploadedFile] = File(...),
notes: Optional[str] = Form(None),
):
game = get_object_or_404( game = get_object_or_404(
Game.objects.enabled_for_event(request.event), path=game_path 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( submission = Submission.objects.create(
event=request.event, event=request.event,
game=game, scope=scope,
user=request.user if request.user.is_authenticated else None, user=request.user,
notes=notes or None, notes=notes or None,
) )
@@ -45,16 +70,16 @@ def create_submission(
return submission return submission
@router.get(f"{PREFIX}/submission/by/user", response=list[SubmissionOut]) @router.get(
def list_user_submissions(request: HttpRequest, event_path: str, game_path: str): f"{PREFIX}/submission/by/user", response={200: list[SubmissionOut], 403: dict}
game = get_object_or_404(Game, path=game_path) )
def list_user_submissions(request: HttpRequest, event_path: str, scope_id: UUID):
if not request.user.is_authenticated: if not request.user.is_authenticated:
return 403, {"detail": "Authentication required"} return 403, {"detail": "Authentication required"}
submissions = Submission.for_event.filter(game=game, user=request.user).order_by( submissions = Submission.for_event.filter(
"-created_at" scope_id=scope_id, user=request.user
) ).order_by("-created_at")
return submissions 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: if not request.user.is_staff:
return 403, {"detail": "Admin access required"} 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) @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 accounts.models import CustomUser
from events.models import Event from events.models import Event
from events.scoping import EventScopedManager from events.scoping import EventScopedManager
from games.models import Game from games.models import EventGame
class OCRStatus(models.TextChoices): class OCRStatus(models.TextChoices):
@@ -16,14 +16,20 @@ class OCRStatus(models.TextChoices):
ERROR = "error", _("Error processing") 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 "" ext = filename.split(".")[-1] if "." in filename else ""
new_filename = f"{instance.id}_{filename}" if ext else str(instance.id) 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 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): class BaseModel(models.Model):
@@ -37,6 +43,60 @@ class BaseModel(models.Model):
abstract = True 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): class Submission(BaseModel):
event = models.ForeignKey( event = models.ForeignKey(
Event, Event,
@@ -44,12 +104,11 @@ class Submission(BaseModel):
related_name="submissions", related_name="submissions",
) )
game = models.ForeignKey( scope = models.ForeignKey(
Game, Scope,
on_delete=models.CASCADE, on_delete=models.CASCADE,
null=True,
blank=True,
related_name="submissions", related_name="submissions",
help_text="The match this submission's score is for.",
) )
user = models.ForeignKey( user = models.ForeignKey(
+8 -1
View File
@@ -3,6 +3,13 @@ from uuid import UUID
from ninja import Schema from ninja import Schema
class ScopeOut(Schema):
id: UUID
name: str
is_active: bool
created_at: datetime
class ImageCropIn(Schema): class ImageCropIn(Schema):
x1: int x1: int
y1: int y1: int
@@ -36,7 +43,7 @@ class SubmissionFileOut(Schema):
class SubmissionOut(Schema): class SubmissionOut(Schema):
id: UUID id: UUID
game_id: int | None = None scope_id: UUID
notes: str | None = None notes: str | None = None
files: list[SubmissionFileOut] = [] files: list[SubmissionFileOut] = []
created_at: datetime 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 django.tasks import task
from games.models import EventGame
from submissions.models import ImageCrop, SubmissionFile, OCRStatus from submissions.models import ImageCrop, SubmissionFile, OCRStatus
from submissions.services.aandt_sync import sync_event_game_scopes
from PIL import Image from PIL import Image
import pytesseract import pytesseract
@@ -69,3 +71,24 @@ def process_crop(crop_id: str):
return return
_process_ocr_file(crop, crop.submission_file.file, crop=crop) _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 uuid import UUID
from django.shortcuts import render, get_object_or_404 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 django.contrib.auth.decorators import login_required
from events.models import Event from events.models import Event
from games.models import Game from games.models import Game
from .models import Scope
@login_required @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) event = get_object_or_404(Event, path=event_path)
game_obj = get_object_or_404( game = get_object_or_404(Game, path=game_path)
Game.objects.enabled_for_event(event), path=f"/submissions/{game}"
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( return render(
request, request,
"submissions.html", "submissions.html",
{ {
"event": event, "event": event,
"game": game_obj, "scope": scope,
"game": scope.event_game.game,
}, },
) )
+2 -2
View File
@@ -2,12 +2,12 @@
{% load django_vite %} {% load django_vite %}
{% block title %}{{ game.name }} Submissions{% endblock %} {% block title %}{{ scope.name }} Submissions{% endblock %}
{% block head %} {% block head %}
{% vite_asset 'src/submissions.ts' %} {% vite_asset 'src/submissions.ts' %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div id="app" data-event-path="{{ event.path }}" data-game-path="{{ game.path }}" data-game-name="{{ game.name }}" data-game-image="{{ game.image |default:'' }}" data-game-steam-id="{{ game.steam_app_id|default:'' }}"></div> <div id="app" data-event-path="{{ event.path }}" data-scope-id="{{ scope.id }}" data-scope-name="{{ scope.name }}" data-game-name="{{ game.name }}" data-game-image="{{ game.image |default:'' }}" data-game-steam-id="{{ game.steam_app_id|default:'' }}" data-game-path="{{ game.path }}"></div>
{% endblock %} {% endblock %}
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% load django_vite %}
{% block title %}Select a match{% endblock %}
{% block head %}
{% vite_asset 'src/submissions_select.ts' %}
{% endblock %}
{% block content %}
{{ scopes_data|json_script:"scopes-data" }}
<div id="app" data-event-path="{{ event.path }}" data-game-path="{{ game.path }}" data-game-name="{{ game.name }}"></div>
{% endblock %}
+1
View File
@@ -34,6 +34,7 @@ export default defineConfig({
noita: resolve('./src/noita.ts'), noita: resolve('./src/noita.ts'),
market: resolve('./src/market.ts'), market: resolve('./src/market.ts'),
submissions: resolve('./src/submissions.ts'), submissions: resolve('./src/submissions.ts'),
submissions_select: resolve('./src/submissions_select.ts'),
} }
} }
}, },