feat(submissions): scoped submissions per user/matchs, sync from polylan
This commit is contained in:
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 — {{ 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,7 +570,8 @@ 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"
|
||||||
|
@mouseenter="isUploadAreaHovered = true" @mouseleave="isUploadAreaHovered = false" :class="[
|
||||||
'border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer bg-base-200/50 mb-6',
|
'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'
|
isDragover ? 'border-primary bg-primary/10' : 'border-base-300 hover:border-primary'
|
||||||
]">
|
]">
|
||||||
@@ -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>
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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 %}
|
||||||
@@ -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'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user