Compare commits

...
10 Commits
39 changed files with 3043 additions and 330 deletions
+17 -6
View File
@@ -5,7 +5,7 @@ from django_object_actions import (
takes_instance_or_queryset, takes_instance_or_queryset,
) )
from submissions.tasks import sync_event_game_scopes_task from submissions.tasks import schedule_scope_sync, stop_scope_sync
from .models import EventGame, Game from .models import EventGame, Game
from .tasks import refresh_steam_games_task from .tasks import refresh_steam_games_task
@@ -51,8 +51,8 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
list_display = ["game", "event", "enabled", "has_apibot_token"] 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"] actions = ["sync_scopes", "stop_scope_sync"]
change_actions = ["sync_scopes"] change_actions = ["sync_scopes", "stop_scope_sync"]
@admin.display(description="AAndT token", boolean=True) @admin.display(description="AAndT token", boolean=True)
def has_apibot_token(self, obj): def has_apibot_token(self, obj):
@@ -67,11 +67,12 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
tokened = queryset.exclude(apibot_token__isnull=True).exclude(apibot_token="") tokened = queryset.exclude(apibot_token__isnull=True).exclude(apibot_token="")
skipped = queryset.count() - tokened.count() skipped = queryset.count() - tokened.count()
for event_game in tokened: started = sum(schedule_scope_sync(event_game.id) for event_game in tokened)
sync_event_game_scopes_task.enqueue(event_game.id)
self.message_user( self.message_user(
request, f"Queued scope sync for {tokened.count()} EventGame(s)." request,
f"Started scope sync for {started} EventGame(s)"
f" ({tokened.count() - started} already running).",
) )
if skipped: if skipped:
self.message_user( self.message_user(
@@ -79,3 +80,13 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
f"Skipped {skipped} EventGame(s) with no AAndT token configured.", f"Skipped {skipped} EventGame(s) with no AAndT token configured.",
level=messages.WARNING, level=messages.WARNING,
) )
@action(
label="Stop scope sync",
description="Stop the scope sync loop of selected EventGames",
)
@takes_instance_or_queryset
def stop_scope_sync(self, request, queryset):
stopped = sum(stop_scope_sync(event_game.id) for event_game in queryset)
self.message_user(request, f"Stopped scope sync for {stopped} EventGame(s).")
+1
View File
@@ -3,4 +3,5 @@ import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({ export default defineConfig({
input: `http://localhost:7777/api/openapi.json`, input: `http://localhost:7777/api/openapi.json`,
output: 'src/api/', output: 'src/api/',
plugins: ['@hey-api/typescript', '@hey-api/sdk', '@tanstack/vue-query'],
}); });
+3
View File
@@ -12,6 +12,9 @@
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"@tanstack/query-persist-client-core": "^5.104.3",
"@tanstack/query-sync-storage-persister": "^5.104.3",
"@tanstack/vue-query": "^5.104.3",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.0.0", "@vueuse/core": "^14.0.0",
"dayjs": "^1.11.20", "dayjs": "^1.11.20",
+74
View File
@@ -11,6 +11,15 @@ importers:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.1.16 specifier: ^4.1.16
version: 4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4)) version: 4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4))
'@tanstack/query-persist-client-core':
specifier: ^5.104.3
version: 5.104.3
'@tanstack/query-sync-storage-persister':
specifier: ^5.104.3
version: 5.104.3
'@tanstack/vue-query':
specifier: ^5.104.3
version: 5.104.3(vue@3.5.22(typescript@5.9.3))
'@tanstack/vue-table': '@tanstack/vue-table':
specifier: ^8.21.3 specifier: ^8.21.3
version: 8.21.3(vue@3.5.22(typescript@5.9.3)) version: 8.21.3(vue@3.5.22(typescript@5.9.3))
@@ -515,10 +524,32 @@ packages:
peerDependencies: peerDependencies:
vite: ^5.2.0 || ^6 || ^7 vite: ^5.2.0 || ^6 || ^7
'@tanstack/match-sorter-utils@9.2.6':
resolution: {integrity: sha512-DIpUSOhPfQ7u/qfUi/Wgm0wBO56MyBYXGvnlI0NcauDRPqdBUlAZZjW2Dxm92m+k1UWVhKw8YJ4f47+75Aujyw==}
engines: {node: '>=20'}
'@tanstack/query-core@5.104.3':
resolution: {integrity: sha512-DZY9TuJAOgU3UyHUhXb4CcTYEHNZszlwkPg4VViIwTxOKzLprPUU+rbaPZQd3srqkGOtpaDdKpW972UV/LKlcg==}
'@tanstack/query-persist-client-core@5.104.3':
resolution: {integrity: sha512-6nhc/QbMpkKeuAM81SFSdcsJ9B8dm3fGHPODbau2cRSDyqcXiSl7sXQM7POVktch/YdgIGYPIQyZOQQaRarHMA==}
'@tanstack/query-sync-storage-persister@5.104.3':
resolution: {integrity: sha512-sNM9X7QZdTHBlzHuNxuHQjECn3/zncuMgw5pesOFXu8XQ01UXBGASdMKZSRaxfNOhigRjm5souHhfmByN97NEg==}
'@tanstack/table-core@8.21.3': '@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'} engines: {node: '>=12'}
'@tanstack/vue-query@5.104.3':
resolution: {integrity: sha512-bdVlhyEFPllLfHioHx8kwtCAQ1YJQrvYczlsNzyiYFsp6n5yz9isNXiy6MIu+QGmG+RyghVdnp1UmiNDqLS22g==}
peerDependencies:
'@vue/composition-api': ^1.1.2
vue: ^2.6.0 || ^3.3.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
'@tanstack/vue-table@8.21.3': '@tanstack/vue-table@8.21.3':
resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==} resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -961,6 +992,9 @@ packages:
regenerator-runtime@0.13.11: regenerator-runtime@0.13.11:
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
remove-accents@0.5.0:
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
resolve-pkg-maps@1.0.0: resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@@ -1072,6 +1106,17 @@ packages:
vscode-uri@3.1.0: vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-i18n@11.4.12: vue-i18n@11.4.12:
resolution: {integrity: sha512-yvArvvo3ErLKFNlQrZUGmRkxL/ctM5xFYtE9FxTKiE/PalWP1bnTopBgbSRGLluYEyYSNl1Pt8l2J7zVavbXww==} resolution: {integrity: sha512-yvArvvo3ErLKFNlQrZUGmRkxL/ctM5xFYtE9FxTKiE/PalWP1bnTopBgbSRGLluYEyYSNl1Pt8l2J7zVavbXww==}
engines: {node: '>= 22'} engines: {node: '>= 22'}
@@ -1445,8 +1490,31 @@ snapshots:
tailwindcss: 4.1.16 tailwindcss: 4.1.16
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4) vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4)
'@tanstack/match-sorter-utils@9.2.6':
dependencies:
remove-accents: 0.5.0
'@tanstack/query-core@5.104.3': {}
'@tanstack/query-persist-client-core@5.104.3':
dependencies:
'@tanstack/query-core': 5.104.3
'@tanstack/query-sync-storage-persister@5.104.3':
dependencies:
'@tanstack/query-core': 5.104.3
'@tanstack/query-persist-client-core': 5.104.3
'@tanstack/table-core@8.21.3': {} '@tanstack/table-core@8.21.3': {}
'@tanstack/vue-query@5.104.3(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@tanstack/match-sorter-utils': 9.2.6
'@tanstack/query-core': 5.104.3
'@vue/devtools-api': 6.6.4
vue: 3.5.22(typescript@5.9.3)
vue-demi: 0.14.10(vue@3.5.22(typescript@5.9.3))
'@tanstack/vue-table@8.21.3(vue@3.5.22(typescript@5.9.3))': '@tanstack/vue-table@8.21.3(vue@3.5.22(typescript@5.9.3))':
dependencies: dependencies:
'@tanstack/table-core': 8.21.3 '@tanstack/table-core': 8.21.3
@@ -1863,6 +1931,8 @@ snapshots:
regenerator-runtime@0.13.11: {} regenerator-runtime@0.13.11: {}
remove-accents@0.5.0: {}
resolve-pkg-maps@1.0.0: {} resolve-pkg-maps@1.0.0: {}
rfdc@1.4.1: {} rfdc@1.4.1: {}
@@ -1962,6 +2032,10 @@ snapshots:
vscode-uri@3.1.0: {} vscode-uri@3.1.0: {}
vue-demi@0.14.10(vue@3.5.22(typescript@5.9.3)):
dependencies:
vue: 3.5.22(typescript@5.9.3)
vue-i18n@11.4.12(vue@3.5.22(typescript@5.9.3)): vue-i18n@11.4.12(vue@3.5.22(typescript@5.9.3)):
dependencies: dependencies:
'@intlify/core-base': 11.4.12 '@intlify/core-base': 11.4.12
+1 -7
View File
@@ -1,20 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from "vue"; import { computed } from "vue";
import { RouterView, useRoute } from "vue-router"; import { RouterView, useRoute } from "vue-router";
import AppHeader from "@/layout/AppHeader.vue"; import AppHeader from "@/layout/AppHeader.vue";
import AppFooter from "@/layout/AppFooter.vue"; import AppFooter from "@/layout/AppFooter.vue";
import { useUserStore } from "@/stores/user";
const userStore = useUserStore();
const route = useRoute(); const route = useRoute();
// Embed routes (e.g. place/embed) are meant to be dropped into an <iframe> // Embed routes (e.g. place/embed) are meant to be dropped into an <iframe>
// as-is - the header/footer chrome would just eat into the fixed space a // as-is - the header/footer chrome would just eat into the fixed space a
// stream overlay or OBS browser source gives it. // stream overlay or OBS browser source gives it.
const isEmbed = computed(() => route.meta.embed === true); const isEmbed = computed(() => route.meta.embed === true);
onMounted(() => {
userStore.loadUserData();
});
</script> </script>
<template> <template>
+2 -4
View File
@@ -3,6 +3,7 @@ import { computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import { useClockSocket } from "@/composables/useClockSocket"; import { useClockSocket } from "@/composables/useClockSocket";
import { useLiveBadge } from "@/composables/useLiveBadge";
interface Props { interface Props {
eventPath: string; eventPath: string;
@@ -25,6 +26,7 @@ const {
cooldownDurationSeconds, cooldownDurationSeconds,
press, press,
} = useClockSocket(props.eventPath); } = useClockSocket(props.eventPath);
useLiveBadge("clock", connected);
const remainingDisplay = computed(() => { const remainingDisplay = computed(() => {
const totalSeconds = Math.floor(remainingMs.value / 1000); const totalSeconds = Math.floor(remainingMs.value / 1000);
@@ -38,10 +40,6 @@ const onCooldown = computed(() => cooldownRemainingMs.value > 0);
<template> <template>
<div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-8"> <div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-8">
<span class="badge" :class="connected ? 'badge-success' : 'badge-error'">
{{ connected ? t("clock.connected") : t("clock.reconnecting") }}
</span>
<div class="font-bold text-7xl tabular-nums"> <div class="font-bold text-7xl tabular-nums">
{{ remainingDisplay }} {{ remainingDisplay }}
</div> </div>
+4 -13
View File
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from "vue"; import { ref, computed } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import { gamesApiListEventGames } from "./api"; import { useQuery } from "@tanstack/vue-query";
import { eventGamesQuery } from "@/queries";
import type { GamesApiListEventGamesResponse } from "./api/types.gen"; import type { GamesApiListEventGamesResponse } from "./api/types.gen";
interface Props { interface Props {
@@ -16,8 +17,7 @@ const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
useTitle(computed(() => t("home.title"))); useTitle(computed(() => t("home.title")));
const games = ref<GamesApiListEventGamesResponse | undefined>(); const { data: games, isPending: loading } = useQuery(eventGamesQuery(props.eventPath));
const loading = ref(true);
const hasSteamGames = computed(() => games.value?.some((game) => game.steam_app_id) ?? false); const hasSteamGames = computed(() => games.value?.some((game) => game.steam_app_id) ?? false);
const imageErrors = ref<Set<number>>(new Set()); const imageErrors = ref<Set<number>>(new Set());
@@ -40,15 +40,6 @@ const goToGame = (game: GamesApiListEventGamesResponse[number]) => {
router.push(`/${props.eventPath}${path}`); router.push(`/${props.eventPath}${path}`);
}; };
onMounted(async () => {
const gamesResponse = await gamesApiListEventGames({
path: { event_path: props.eventPath },
});
if (gamesResponse.data) {
games.value = gamesResponse.data;
}
loading.value = false;
});
</script> </script>
<template> <template>
+3 -8
View File
@@ -9,10 +9,11 @@ import Results from "@/components/Results.vue";
import Winners from "@/components/Winners.vue"; import Winners from "@/components/Winners.vue";
import PuzzleResults from "@/components/PuzzleResults.vue"; import PuzzleResults from "@/components/PuzzleResults.vue";
import TopUsersLeaderboard from "@/components/TopUsersLeaderboard.vue"; import TopUsersLeaderboard from "@/components/TopUsersLeaderboard.vue";
import { polylanSubmitterApiGetUserInfo, opusMagnumApiGetCollection } from "@/api"; import { opusMagnumApiGetCollection } from "@/api";
import { apiService, errorHelpers } from "@/services/apiService"; import { apiService, errorHelpers } from "@/services/apiService";
import { usePuzzlesStore } from "@/stores/puzzles"; import { usePuzzlesStore } from "@/stores/puzzles";
import { useSubmissionsStore } from "@/stores/submissions"; import { useSubmissionsStore } from "@/stores/submissions";
import { queryClient, userQuery } from "@/queries";
import { provideEventPath } from "@/composables/useEventPath"; import { provideEventPath } from "@/composables/useEventPath";
import type { PuzzleResponse, UserInfo, SteamCollection } from "@/types"; import type { PuzzleResponse, UserInfo, SteamCollection } from "@/types";
import { useCountdown } from "@vueuse/core"; import { useCountdown } from "@vueuse/core";
@@ -74,13 +75,7 @@ async function initialize() {
// Load user info // Load user info
console.log("Loading user info..."); console.log("Loading user info...");
const userResponse = await polylanSubmitterApiGetUserInfo(); userInfo.value = (await queryClient.fetchQuery(userQuery()).catch(() => null)) as UserInfo | null;
if (userResponse.data) {
userInfo.value = userResponse.data as UserInfo;
console.log("User info loaded:", userResponse.data);
} else if (userResponse.error) {
console.warn("User info error:", userResponse.error);
}
// Load collection data // Load collection data
console.log("Loading collection..."); console.log("Loading collection...");
+2 -4
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import { usePlaceSocket } from "@/composables/usePlaceSocket"; import { usePlaceSocket } from "@/composables/usePlaceSocket";
import { useLiveBadge } from "@/composables/useLiveBadge";
import { PALETTE } from "@/palette"; import { PALETTE } from "@/palette";
interface Props { interface Props {
@@ -47,6 +48,7 @@ const {
gridHeight, gridHeight,
place, place,
} = usePlaceSocket(props.eventPath, drawPixel); } = usePlaceSocket(props.eventPath, drawPixel);
useLiveBadge("place", connected);
const overlayWidth = computed(() => gridWidth.value * PX_PER_CELL); const overlayWidth = computed(() => gridWidth.value * PX_PER_CELL);
const overlayHeight = computed(() => gridHeight.value * PX_PER_CELL); const overlayHeight = computed(() => gridHeight.value * PX_PER_CELL);
@@ -131,10 +133,6 @@ function handleClick(event: MouseEvent) {
<template> <template>
<div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-6"> <div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-6">
<span class="badge" :class="connected ? 'badge-success' : 'badge-error'">
{{ connected ? t("place.connected") : t("place.reconnecting") }}
</span>
<div class="relative w-full max-w-[512px]" :style="{ aspectRatio: `${gridWidth} / ${gridHeight}` }"> <div class="relative w-full max-w-[512px]" :style="{ aspectRatio: `${gridWidth} / ${gridHeight}` }">
<canvas ref="pixelCanvasRef" :width="gridWidth" :height="gridHeight" <canvas ref="pixelCanvasRef" :width="gridWidth" :height="gridHeight"
class="absolute inset-0 w-full h-full border border-base-300 rounded-box shadow-lg" :style="{ class="absolute inset-0 w-full h-full border border-base-300 rounded-box shadow-lg" :style="{
+98 -48
View File
@@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from "vue"; import { ref, onMounted, onUnmounted, computed, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import { useUserStore } from "@/stores/user"; import { useQueryClient } from "@tanstack/vue-query";
import { useUser } from "@/composables/useUser";
import { eventGamesQuery } from "@/queries";
import { usePageHeaderStore } from "@/stores/pageHeader"; import { usePageHeaderStore } from "@/stores/pageHeader";
import { gamesApiListEventGames, submissionsApiListAvailableScopes, submissionsApiCreateSubmission, submissionsApiListUserSubmissions, submissionsApiCreateImageCrop, submissionsApiDeleteImageCrop, submissionsApiProcessFileOcr, submissionsApiProcessCropOcr } from "@/api"; import { submissionsApiListAvailableScopes, submissionsApiCreateSubmission, submissionsApiListUserSubmissions, submissionsApiCreateImageCrop, submissionsApiDeleteImageCrop, submissionsApiProcessFileOcr, submissionsApiProcessCropOcr } from "@/api";
import type { SubmissionOut } from "@/api";
import { useSubmissionsWebSocket } from "@/composables/useSubmissionsWebSocket"; import { useSubmissionsWebSocket } from "@/composables/useSubmissionsWebSocket";
import { provideEventPath } from "@/composables/useEventPath"; import { provideEventPath } from "@/composables/useEventPath";
@@ -31,8 +33,8 @@ const gameSteamId = ref("");
useTitle(computed(() => t("submissions.pageTitle", { game: gameName.value, scope: scopeName.value }))); useTitle(computed(() => t("submissions.pageTitle", { game: gameName.value, scope: scopeName.value })));
const userStore = useUserStore(); const queryClient = useQueryClient();
const { userInfo } = storeToRefs(userStore); const userInfo = useUser();
const uploadedFiles = ref<File[]>([]); const uploadedFiles = ref<File[]>([]);
const isDragover = ref(false); const isDragover = ref(false);
@@ -41,11 +43,12 @@ const notes = ref("");
const selectedFile = ref<File | null>(null); const selectedFile = ref<File | null>(null);
const filePreviewUrl = ref<string>(""); const filePreviewUrl = ref<string>("");
const isModalOpen = ref(false); const isModalOpen = ref(false);
const userSubmissions = ref<any[]>([]); const userSubmissions = ref<SubmissionOut[]>([]);
const isLoadingSubmissions = ref(false); const isLoadingSubmissions = ref(false);
const selectedSubmissionFile = ref<any>(null); const selectedSubmissionFile = ref<any>(null);
const isSubmissionFileModalOpen = ref(false); const isSubmissionFileModalOpen = ref(false);
const copyFeedback = ref<string>(""); // Feedback shown on the copy button that was clicked, keyed by "file" or crop id
const copyFeedback = ref<{ key: string; message: string } | null>(null);
const isDrawingCrop = ref(false); const isDrawingCrop = ref(false);
const cropStartX = ref(0); const cropStartX = ref(0);
const cropStartY = ref(0); const cropStartY = ref(0);
@@ -139,23 +142,23 @@ const closeSubmissionFileModal = () => {
selectedSubmissionFile.value = null; selectedSubmissionFile.value = null;
}; };
const copyOCRText = async () => { const copyOCRText = async (text: string | undefined, key: string) => {
if (selectedSubmissionFile.value?.ocr_raw_data) { if (!text) return;
try {
await navigator.clipboard.writeText(selectedSubmissionFile.value.ocr_raw_data); try {
copyFeedback.value = t("common.copied"); await navigator.clipboard.writeText(text);
setTimeout(() => { copyFeedback.value = { key, message: t("common.copied") };
copyFeedback.value = ""; } catch (err) {
}, 2000); copyFeedback.value = { key, message: t("submissions.copyFailed") };
} catch (err) {
copyFeedback.value = t("submissions.copyFailed");
setTimeout(() => {
copyFeedback.value = "";
}, 2000);
}
} }
setTimeout(() => {
if (copyFeedback.value?.key === key) copyFeedback.value = null;
}, 2000);
}; };
const copyLabel = (key: string) =>
copyFeedback.value?.key === key ? copyFeedback.value.message : t("common.copy");
const getCanvasCoordinates = (canvas: HTMLCanvasElement, clientX: number, clientY: number) => { const getCanvasCoordinates = (canvas: HTMLCanvasElement, clientX: number, clientY: number) => {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width; const scaleX = canvas.width / rect.width;
@@ -244,6 +247,12 @@ const redrawCropCanvas = () => {
img.src = selectedSubmissionFile.value?.file_url; img.src = selectedSubmissionFile.value?.file_url;
}; };
// Crops can also arrive over the websocket (e.g. drawn by another admin)
watch(
() => selectedSubmissionFile.value?.crops?.length,
() => redrawCropCanvas(),
);
const submitCrop = async () => { const submitCrop = async () => {
if (!currentCrop.value || !selectedSubmissionFile.value) return; if (!currentCrop.value || !selectedSubmissionFile.value) return;
@@ -260,10 +269,12 @@ const submitCrop = async () => {
}, },
}); });
// Add the new crop to the list immediately // Add the new crop to the list immediately, unless the websocket beat us to it
if (newCrop) { if (newCrop) {
selectedSubmissionFile.value.crops = selectedSubmissionFile.value.crops || []; selectedSubmissionFile.value.crops = selectedSubmissionFile.value.crops || [];
selectedSubmissionFile.value.crops.push(newCrop); if (!selectedSubmissionFile.value.crops.some((c: any) => c.id === newCrop.id)) {
selectedSubmissionFile.value.crops.push(newCrop);
}
} }
currentCrop.value = null; currentCrop.value = null;
@@ -404,6 +415,26 @@ const removeFile = (index: number) => {
uploadedFiles.value.splice(index, 1); uploadedFiles.value.splice(index, 1);
}; };
// Thumbnails of the selected images, revoked once the file is removed/submitted
const thumbnailUrls = new Map<File, string>();
const thumbnails = computed(() =>
uploadedFiles.value.map((file) => {
if (!file.type.startsWith("image/")) return null;
if (!thumbnailUrls.has(file)) thumbnailUrls.set(file, URL.createObjectURL(file));
return thumbnailUrls.get(file)!;
}),
);
const revokeThumbnails = (keep: File[] = []) => {
thumbnailUrls.forEach((url, file) => {
if (keep.includes(file)) return;
URL.revokeObjectURL(url);
thumbnailUrls.delete(file);
});
};
watch(uploadedFiles, (files) => revokeThumbnails(files), { deep: true });
const submitSubmission = async () => { const submitSubmission = async () => {
if (uploadedFiles.value.length === 0) return; if (uploadedFiles.value.length === 0) return;
@@ -474,7 +505,17 @@ const loadUserSubmissions = async () => {
} }
}; };
const { isConnected: wsConnected } = useSubmissionsWebSocket(userSubmissions); // Match over (or user moved off it): back to the picker, which auto-enters
// the next match when there's only one, so players follow along without a refresh.
useSubmissionsWebSocket({
userSubmissions,
scopeId: props.scopeId,
onScopeUpdate: (scope) => {
if (scope.id !== props.scopeId || (scope.is_active && scope.assigned)) return;
router.replace({ name: "submissions-select", params: { eventPath: props.eventPath, gamePath: props.gamePath } });
},
});
const isUploadAreaHovered = ref(false); const isUploadAreaHovered = ref(false);
const handlePasteOnUploadArea = (event: ClipboardEvent) => { const handlePasteOnUploadArea = (event: ClipboardEvent) => {
@@ -503,17 +544,16 @@ const handlePasteOnUploadArea = (event: ClipboardEvent) => {
}; };
onMounted(async () => { onMounted(async () => {
userStore.loadUserData();
loadUserSubmissions(); loadUserSubmissions();
const [gamesResponse, scopesResponse] = await Promise.all([ const [games, scopesResponse] = await Promise.all([
gamesApiListEventGames({ path: { event_path: props.eventPath } }), queryClient.ensureQueryData(eventGamesQuery(props.eventPath)),
submissionsApiListAvailableScopes({ submissionsApiListAvailableScopes({
path: { event_path: props.eventPath, game_path: props.gamePath }, path: { event_path: props.eventPath, game_path: props.gamePath },
}), }),
]); ]);
const game = gamesResponse.data?.find((g) => g.path === props.gamePath); const game = games.find((g) => g.path === props.gamePath);
if (game) { if (game) {
gameName.value = game.name; gameName.value = game.name;
gameImage.value = game.image ?? ""; gameImage.value = game.image ?? "";
@@ -541,6 +581,7 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('paste', handlePasteOnUploadArea); window.removeEventListener('paste', handlePasteOnUploadArea);
revokeThumbnails();
}); });
</script> </script>
@@ -548,14 +589,6 @@ onUnmounted(() => {
<div class="flex-1 bg-base-200"> <div class="flex-1 bg-base-200">
<!-- Main Content --> <!-- Main Content -->
<div class="container min-w-3/4 mx-auto px-4 py-8"> <div class="container min-w-3/4 mx-auto px-4 py-8">
<!-- WebSocket Status Indicator -->
<div class="flex justify-end mb-4">
<div :class="['badge', wsConnected ? 'badge-success' : 'badge-warning']">
<i :class="['mdi mr-1', wsConnected ? 'mdi-wifi' : 'mdi-wifi-off']"></i>
{{ wsConnected ? t("submissions.live") : t("submissions.offline") }}
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Right Column: Upload --> <!-- Right Column: Upload -->
<div class="lg:col-span-1 flex flex-col gap-8 order-1 lg:order-2"> <div class="lg:col-span-1 flex flex-col gap-8 order-1 lg:order-2">
@@ -617,17 +650,24 @@ onUnmounted(() => {
<!-- Uploaded Files List --> <!-- Uploaded Files List -->
<div v-if="uploadedFiles.length > 0" class="mb-6"> <div v-if="uploadedFiles.length > 0" class="mb-6">
<p class="font-semibold mb-3">{{ t("common.selectedFiles") }}</p> <p class="font-semibold mb-3">{{ t("common.selectedFiles") }}</p>
<div class="space-y-2"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div v-for="(file, index) in uploadedFiles" :key="index" <div v-for="(file, index) in uploadedFiles" :key="index"
class="flex items-center gap-3 bg-base-200 p-3 rounded-lg cursor-pointer hover:bg-base-300 transition-colors group" class="relative bg-base-200 rounded-lg overflow-hidden cursor-pointer hover:bg-base-300 transition-colors group"
@click="openFilePreview(file)"> @click="openFilePreview(file)">
<i :class="['mdi text-primary', getFileIcon(file)]"></i> <div class="aspect-video bg-base-300 flex items-center justify-center">
<div class="flex-1 min-w-0"> <img v-if="thumbnails[index]" :src="thumbnails[index]" :alt="file.name"
<p class="font-medium truncate group-hover:text-primary">{{ file.name }}</p> class="w-full h-full object-contain" />
<p class="text-xs text-base-content/70">{{ formatFileSize(file.size) }}</p> <i v-else :class="['mdi text-5xl text-primary', getFileIcon(file)]"></i>
</div>
<div class="flex items-center gap-2 p-2">
<i :class="['mdi text-primary', getFileIcon(file)]"></i>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate group-hover:text-primary" :title="file.name">{{ file.name }}</p>
<p class="text-xs text-base-content/70">{{ formatFileSize(file.size) }}</p>
</div>
</div> </div>
<button @click.stop="removeFile(index)" <button @click.stop="removeFile(index)"
class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100"> class="btn btn-circle btn-xs absolute top-2 right-2 sm:opacity-0 sm:group-hover:opacity-100">
<i class="mdi mdi-close"></i> <i class="mdi mdi-close"></i>
</button> </button>
</div> </div>
@@ -727,7 +767,7 @@ onUnmounted(() => {
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-2xl mb-6"> <h2 class="card-title text-2xl mb-6">
<i class="mdi mdi-history text-primary mr-2"></i> <i class="mdi mdi-history text-primary mr-2"></i>
{{ t("submissions.yourSubmissions") }} {{ userInfo.isStaff ? t("submissions.allSubmissions") : t("submissions.yourSubmissions") }}
</h2> </h2>
<div v-if="isLoadingSubmissions" class="flex justify-center py-8"> <div v-if="isLoadingSubmissions" class="flex justify-center py-8">
@@ -744,6 +784,7 @@ onUnmounted(() => {
<thead> <thead>
<tr> <tr>
<th>{{ t("common.date") }}</th> <th>{{ t("common.date") }}</th>
<th v-if="userInfo.isStaff">{{ t("common.user") }}</th>
<th>{{ t("common.files") }}</th> <th>{{ t("common.files") }}</th>
<th>{{ t("submissions.fileStatus") }}</th> <th>{{ t("submissions.fileStatus") }}</th>
<th>{{ t("submissions.ocrStatus") }}</th> <th>{{ t("submissions.ocrStatus") }}</th>
@@ -753,6 +794,7 @@ onUnmounted(() => {
<tbody> <tbody>
<tr v-for="submission in userSubmissions" :key="submission.id" class="hover"> <tr v-for="submission in userSubmissions" :key="submission.id" class="hover">
<td class="text-sm">{{ formatDate(submission.created_at) }}</td> <td class="text-sm">{{ formatDate(submission.created_at) }}</td>
<td v-if="userInfo.isStaff" class="text-sm">{{ submission.username ?? "-" }}</td>
<td class="text-sm"> <td class="text-sm">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div v-for="file in submission.files" :key="file.id" class="flex items-center gap-2"> <div v-for="file in submission.files" :key="file.id" class="flex items-center gap-2">
@@ -905,9 +947,10 @@ onUnmounted(() => {
<div> <div>
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<p class="text-xs font-semibold text-base-content/70">{{ t("submissions.extractedText") }}</p> <p class="text-xs font-semibold text-base-content/70">{{ t("submissions.extractedText") }}</p>
<button @click="copyOCRText" class="btn btn-xs btn-ghost gap-1"> <button @click="copyOCRText(selectedSubmissionFile.ocr_raw_data, 'file')"
class="btn btn-xs btn-ghost gap-1">
<i class="mdi mdi-content-copy"></i> <i class="mdi mdi-content-copy"></i>
{{ copyFeedback || t("common.copy") }} {{ copyLabel("file") }}
</button> </button>
</div> </div>
<div <div
@@ -944,7 +987,14 @@ onUnmounted(() => {
<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 || t("submissions.cropLabelFallback", { x: crop.x1, y: crop.y1 }) }}</p> <p class="text-xs font-medium">{{ crop.label || t("submissions.cropLabelFallback", { x: crop.x1, y: crop.y1 }) }}</p>
<span :class="getOCRStatusColor(crop.ocr_status)">{{ crop.ocr_status }}</span> <div class="flex items-center gap-1">
<button v-if="crop.ocr_status === 'processed' && crop.ocr_raw_data"
@click="copyOCRText(crop.ocr_raw_data, crop.id)" class="btn btn-xs btn-ghost gap-1">
<i class="mdi mdi-content-copy"></i>
{{ copyLabel(crop.id) }}
</button>
<span :class="getOCRStatusColor(crop.ocr_status)">{{ crop.ocr_status }}</span>
</div>
</div> </div>
<div v-if="crop.ocr_status === 'processed' && crop.ocr_raw_data" <div v-if="crop.ocr_status === 'processed' && crop.ocr_raw_data"
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">
+54 -14
View File
@@ -3,7 +3,12 @@ import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import { gamesApiListEventGames, submissionsApiListAvailableScopes } from "@/api"; import { submissionsApiListAvailableScopes } from "@/api";
import { useQueryClient } from "@tanstack/vue-query";
import { eventGamesQuery, userQuery } from "@/queries";
import { useUser } from "@/composables/useUser";
import { useSubmissionsWebSocket } from "@/composables/useSubmissionsWebSocket";
import ScopeSyncModal from "@/components/ScopeSyncModal.vue";
import type { ScopeOut } from "@/api/types.gen"; import type { ScopeOut } from "@/api/types.gen";
interface Props { interface Props {
@@ -14,13 +19,18 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
const { t } = useI18n(); const { t } = useI18n();
const userInfo = useUser();
useTitle(computed(() => t("submissionsSelect.title"))); useTitle(computed(() => t("submissionsSelect.title")));
const gameName = ref(""); const gameName = ref("");
const scopes = ref<ScopeOut[]>([]); const scopes = ref<ScopeOut[]>([]);
const loading = ref(true); const loading = ref(true);
const notAssigned = ref(false); const notAssigned = ref(false);
const notFound = ref(false);
const isSyncModalOpen = ref(false);
const scopeRoute = (scope: ScopeOut) => ({ const scopeRoute = (scope: ScopeOut) => ({
name: "submissions", name: "submissions",
@@ -31,40 +41,67 @@ const goToScope = (scope: ScopeOut) => {
router.push(scopeRoute(scope)); router.push(scopeRoute(scope));
}; };
onMounted(async () => { const loadScopes = async () => {
const [gamesResponse, scopesResponse] = await Promise.all([ const [games, scopesResponse, user] = await Promise.all([
gamesApiListEventGames({ path: { event_path: props.eventPath } }), queryClient.ensureQueryData(eventGamesQuery(props.eventPath)),
submissionsApiListAvailableScopes({ submissionsApiListAvailableScopes({
path: { event_path: props.eventPath, game_path: props.gamePath }, path: { event_path: props.eventPath, game_path: props.gamePath },
}), }),
queryClient.ensureQueryData(userQuery()),
]); ]);
gameName.value = gamesResponse.data?.find((game) => game.path === props.gamePath)?.name ?? ""; // Not a game of this event (or one with its own page, not submissions):
// the backend just serves the app for any game path, this is our 404.
const game = games.find((game) => game.path === props.gamePath && !game.custom_path);
if (!game || scopesResponse.error) {
notFound.value = true;
loading.value = false;
return;
}
gameName.value = game.name;
const availableScopes = scopesResponse.data ?? []; const availableScopes = scopesResponse.data ?? [];
// Mirrors the server-side behavior this replaced: skip the picker // Mirrors the server-side behavior this replaced: skip the picker
// straight to the only option, or bounce out if there's nothing to pick. // straight to the only option, or bounce out if there's nothing to pick.
if (availableScopes.length === 1) { // Superusers always land here, it's where the scope sync button lives.
if (availableScopes.length === 1 && !user.is_superuser) {
router.replace(scopeRoute(availableScopes[0])); router.replace(scopeRoute(availableScopes[0]));
return; return;
} }
if (availableScopes.length === 0) {
notAssigned.value = true;
loading.value = false;
return;
}
scopes.value = availableScopes; scopes.value = availableScopes;
notAssigned.value = availableScopes.length === 0;
loading.value = false; loading.value = false;
}); };
// A match opened/closed or players got (re)assigned: reload, so waiting
// players get auto-entered into their next match without a refresh.
useSubmissionsWebSocket({ onScopeUpdate: () => loadScopes() });
onMounted(loadScopes);
</script> </script>
<template> <template>
<div class="flex-1 bg-base-200 flex items-center justify-center p-4"> <div class="flex-1 bg-base-200 flex flex-col items-center justify-center gap-4 p-4">
<div v-if="userInfo.isSuperuser && !loading && !notFound" class="w-full max-w-md flex justify-end">
<button @click="isSyncModalOpen = true" class="btn btn-primary btn-sm">
<i class="mdi mdi-sync"></i>
{{ t("submissionsSelect.syncScopes") }}
</button>
</div>
<div v-if="loading" class="flex justify-center py-20"> <div v-if="loading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg"></span>
</div> </div>
<div v-else-if="notFound" class="text-center max-w-md">
<i class="mdi mdi-alert-circle-outline text-6xl text-primary"></i>
<h2 class="text-2xl font-semibold mt-4">{{ t("errorPage.notFound.title") }}</h2>
<p class="text-base-content/70 mt-2">{{ t("errorPage.notFound.message") }}</p>
<RouterLink :to="{ name: 'home', params: { eventPath } }" class="btn btn-primary mt-6">
<i class="mdi mdi-home mr-2"></i>
{{ t("errorPage.goHome") }}
</RouterLink>
</div>
<div v-else-if="notAssigned" class="text-center max-w-md"> <div v-else-if="notAssigned" class="text-center max-w-md">
<i class="mdi mdi-account-alert-outline text-6xl text-warning"></i> <i class="mdi mdi-account-alert-outline text-6xl text-warning"></i>
<h2 class="text-2xl font-semibold mt-4">{{ t("submissionsSelect.notAssignedTitle") }}</h2> <h2 class="text-2xl font-semibold mt-4">{{ t("submissionsSelect.notAssignedTitle") }}</h2>
@@ -86,5 +123,8 @@ onMounted(async () => {
</div> </div>
</div> </div>
</div> </div>
<ScopeSyncModal v-if="userInfo.isSuperuser && !notFound" v-model:open="isSyncModalOpen" :event-path="eventPath"
:game-path="gamePath" :game-name="gameName" />
</div> </div>
</template> </template>
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+33 -1
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, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, 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, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen'; import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, 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, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses, SubmissionsApiStopSyncScopesData, SubmissionsApiStopSyncScopesErrors, SubmissionsApiStopSyncScopesResponses, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesErrors, SubmissionsApiSyncScopesResponses, SubmissionsApiSyncScopesStatusData, SubmissionsApiSyncScopesStatusErrors, SubmissionsApiSyncScopesStatusResponses } 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> & {
/** /**
@@ -219,6 +219,38 @@ export const submissionsApiListMyScopes = <ThrowOnError extends boolean = false>
*/ */
export const submissionsApiListAvailableScopes = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListAvailableScopesData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListAvailableScopesResponses, SubmissionsApiListAvailableScopesErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/{game_path}/available-scopes', ...options }); export const submissionsApiListAvailableScopes = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListAvailableScopesData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListAvailableScopesResponses, SubmissionsApiListAvailableScopesErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/{game_path}/available-scopes', ...options });
/**
* Stop Sync Scopes
*
* Stop the AAndT scope sync loop of a (non custom path) game, superusers
* only. Cancels its scheduled job, scopes are left as they are.
*/
export const submissionsApiStopSyncScopes = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiStopSyncScopesData, ThrowOnError>) => (options.client ?? client).delete<SubmissionsApiStopSyncScopesResponses, SubmissionsApiStopSyncScopesErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/{game_path}/sync-scopes', ...options });
/**
* Sync Scopes Status
*
* State of the AAndT scope sync loop of a (non custom path) game,
* superusers only.
*/
export const submissionsApiSyncScopesStatus = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiSyncScopesStatusData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiSyncScopesStatusResponses, SubmissionsApiSyncScopesStatusErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/{game_path}/sync-scopes', ...options });
/**
* Sync Scopes
*
* Start the AAndT scope sync loop for a (non custom path) game, superusers
* only. Stores `apibot_token` first when given; without one and none stored
* yet, answers 400 with `token_required` so the frontend asks for it.
*/
export const submissionsApiSyncScopes = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiSyncScopesData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiSyncScopesResponses, SubmissionsApiSyncScopesErrors, ThrowOnError>({
url: '/api/events/{event_path}/submissions/{game_path}/sync-scopes',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/** /**
* Create Submission * Create Submission
*/ */
+198
View File
@@ -423,6 +423,10 @@ export type SubmissionOut = {
* Scope Id * Scope Id
*/ */
scope_id: string; scope_id: string;
/**
* Username
*/
username?: string | null;
/** /**
* Notes * Notes
*/ */
@@ -1000,6 +1004,74 @@ export type ScopeOut = {
created_at: string; created_at: string;
}; };
/**
* SyncScopesStatusOut
*/
export type SyncScopesStatusOut = {
/**
* Running
*/
running: boolean;
/**
* Has Token
*/
has_token: boolean;
/**
* Aandt Status
*/
aandt_status?: string | null;
/**
* Last Run At
*/
last_run_at?: string | null;
/**
* Next Run At
*/
next_run_at?: string | null;
/**
* Error
*/
error?: string | null;
/**
* Scope Count
*/
scope_count: number;
/**
* Active Scope Count
*/
active_scope_count: number;
};
/**
* SyncScopesOut
*/
export type SyncScopesOut = {
/**
* Started
*/
started: boolean;
};
/**
* SyncScopesIn
*/
export type SyncScopesIn = {
/**
* Apibot Token
*/
apibot_token?: string | null;
};
/**
* SyncScopesStopOut
*/
export type SyncScopesStopOut = {
/**
* Stopped
*/
stopped: boolean;
};
/** /**
* ImageCropOut * ImageCropOut
*/ */
@@ -1008,6 +1080,10 @@ export type ImageCropOut = {
* Id * Id
*/ */
id: string; id: string;
/**
* Submission File Id
*/
submission_file_id: string;
/** /**
* X1 * X1
*/ */
@@ -1873,6 +1949,128 @@ export type SubmissionsApiListAvailableScopesResponses = {
export type SubmissionsApiListAvailableScopesResponse = SubmissionsApiListAvailableScopesResponses[keyof SubmissionsApiListAvailableScopesResponses]; export type SubmissionsApiListAvailableScopesResponse = SubmissionsApiListAvailableScopesResponses[keyof SubmissionsApiListAvailableScopesResponses];
export type SubmissionsApiStopSyncScopesData = {
body?: never;
path: {
/**
* Event Path
*/
event_path: string;
/**
* Game Path
*/
game_path: string;
};
query?: never;
url: '/api/events/{event_path}/submissions/{game_path}/sync-scopes';
};
export type SubmissionsApiStopSyncScopesErrors = {
/**
* Response
*
* Forbidden
*/
403: {
[key: string]: unknown;
};
};
export type SubmissionsApiStopSyncScopesError = SubmissionsApiStopSyncScopesErrors[keyof SubmissionsApiStopSyncScopesErrors];
export type SubmissionsApiStopSyncScopesResponses = {
/**
* OK
*/
200: SyncScopesStopOut;
};
export type SubmissionsApiStopSyncScopesResponse = SubmissionsApiStopSyncScopesResponses[keyof SubmissionsApiStopSyncScopesResponses];
export type SubmissionsApiSyncScopesStatusData = {
body?: never;
path: {
/**
* Event Path
*/
event_path: string;
/**
* Game Path
*/
game_path: string;
};
query?: never;
url: '/api/events/{event_path}/submissions/{game_path}/sync-scopes';
};
export type SubmissionsApiSyncScopesStatusErrors = {
/**
* Response
*
* Forbidden
*/
403: {
[key: string]: unknown;
};
};
export type SubmissionsApiSyncScopesStatusError = SubmissionsApiSyncScopesStatusErrors[keyof SubmissionsApiSyncScopesStatusErrors];
export type SubmissionsApiSyncScopesStatusResponses = {
/**
* OK
*/
200: SyncScopesStatusOut;
};
export type SubmissionsApiSyncScopesStatusResponse = SubmissionsApiSyncScopesStatusResponses[keyof SubmissionsApiSyncScopesStatusResponses];
export type SubmissionsApiSyncScopesData = {
body: SyncScopesIn;
path: {
/**
* Event Path
*/
event_path: string;
/**
* Game Path
*/
game_path: string;
};
query?: never;
url: '/api/events/{event_path}/submissions/{game_path}/sync-scopes';
};
export type SubmissionsApiSyncScopesErrors = {
/**
* Response
*
* Bad Request
*/
400: {
[key: string]: unknown;
};
/**
* Response
*
* Forbidden
*/
403: {
[key: string]: unknown;
};
};
export type SubmissionsApiSyncScopesError = SubmissionsApiSyncScopesErrors[keyof SubmissionsApiSyncScopesErrors];
export type SubmissionsApiSyncScopesResponses = {
/**
* OK
*/
200: SyncScopesOut;
};
export type SubmissionsApiSyncScopesResponse = SubmissionsApiSyncScopesResponses[keyof SubmissionsApiSyncScopesResponses];
export type SubmissionsApiCreateSubmissionData = { export type SubmissionsApiCreateSubmissionData = {
/** /**
* MultiPartBodyParams * MultiPartBodyParams
+3 -3
View File
@@ -2,7 +2,8 @@
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import RankBadge from "./RankBadge.vue"; import RankBadge from "./RankBadge.vue";
import { animationsApiResults, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo } from "@/api"; import { animationsApiResults, polylanSubmitterApiClearCache } from "@/api";
import { queryClient, userQuery } from "@/queries";
import { useEventPath } from "@/composables/useEventPath"; import { useEventPath } from "@/composables/useEventPath";
const { t } = useI18n(); const { t } = useI18n();
@@ -142,8 +143,7 @@ const clearCache = async () => {
const loadUserData = async () => { const loadUserData = async () => {
try { try {
const response = await polylanSubmitterApiGetUserInfo(); const user = await queryClient.fetchQuery(userQuery());
const user = response.data;
if (user?.is_authenticated) { if (user?.is_authenticated) {
userInfo.value.username = user.username ?? ""; userInfo.value.username = user.username ?? "";
userInfo.value.isStaff = user.is_staff || false; userInfo.value.isStaff = user.is_staff || false;
@@ -0,0 +1,203 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import dayjs from "dayjs";
import {
submissionsApiStopSyncScopesMutation,
submissionsApiSyncScopesMutation,
submissionsApiSyncScopesStatusOptions,
submissionsApiSyncScopesStatusQueryKey,
} from "@/api/@tanstack/vue-query.gen";
interface Props {
eventPath: string;
gamePath: string;
gameName: string;
}
const props = defineProps<Props>();
const open = defineModel<boolean>("open", { required: true });
const { t } = useI18n();
const queryClient = useQueryClient();
const path = computed(() => ({ event_path: props.eventPath, game_path: props.gamePath }));
const statusKey = computed(() => submissionsApiSyncScopesStatusQueryKey({ path: path.value }));
// Polled while open, so admins see each run land (last/next run, errors).
const { data: status, isLoading } = useQuery(
computed(() => ({
...submissionsApiSyncScopesStatusOptions({ path: path.value }),
enabled: open.value,
refetchInterval: 5000,
})),
);
const apibotToken = ref("");
const message = ref<{ type: "success" | "info" | "error"; text: string } | null>(null);
watch(open, (isOpen) => {
if (!isOpen) {
apibotToken.value = "";
message.value = null;
}
});
const refreshStatus = () => queryClient.invalidateQueries({ queryKey: statusKey.value });
const start = useMutation({
...submissionsApiSyncScopesMutation(),
onSuccess: (data) => {
apibotToken.value = "";
message.value = data.started
? { type: "success", text: t("scopeSync.started") }
: { type: "info", text: t("scopeSync.alreadyRunning") };
},
onError: () => {
message.value = { type: "error", text: t("scopeSync.startFailed") };
},
onSettled: refreshStatus,
});
const stop = useMutation({
...submissionsApiStopSyncScopesMutation(),
onSuccess: (data) => {
message.value = data.stopped
? { type: "success", text: t("scopeSync.stopped") }
: { type: "info", text: t("scopeSync.notRunning") };
},
onError: () => {
message.value = { type: "error", text: t("scopeSync.stopFailed") };
},
onSettled: refreshStatus,
});
const busy = computed(() => start.isPending.value || stop.isPending.value);
const hasToken = computed(() => status.value?.has_token ?? false);
const tokenTyped = computed(() => apibotToken.value.trim() !== "");
const submit = () => {
start.mutate({
path: path.value,
body: { apibot_token: tokenTyped.value ? apibotToken.value.trim() : null },
});
};
const AANDT_STATUSES: Record<string, string> = {
"0prep": "prep",
"1publ": "published",
"3insc": "registrations",
"5inscdone": "registrationsDone",
"7inprog": "inProgress",
"9done": "done",
"11reviewed": "reviewed",
};
const aandtStatusLabel = computed(() => {
const value = status.value?.aandt_status;
if (!value) return t("scopeSync.unknown");
return AANDT_STATUSES[value] ? t(`scopeSync.aandtStatus.${AANDT_STATUSES[value]}`) : value;
});
const formatDate = (value?: string | null) =>
value ? dayjs(value).format("DD/MM HH:mm:ss") : t("scopeSync.never");
</script>
<template>
<div :class="['modal', { 'modal-open': open }]">
<div class="modal-box w-11/12 max-w-4xl max-h-[90vh] overflow-y-auto">
<h3 class="font-bold text-xl flex items-center gap-2">
<i class="mdi mdi-sync"></i>
{{ t("scopeSync.title", { game: gameName }) }}
</h3>
<!-- What this actually does -->
<div class="mt-4 space-y-3 text-base-content/80">
<p>{{ t("scopeSync.intro") }}</p>
<ul class="list-disc pl-6 space-y-1">
<li>{{ t("scopeSync.whatScopes") }}</li>
<li>{{ t("scopeSync.whatPlayers") }}</li>
<li>{{ t("scopeSync.whatClose") }}</li>
<li>{{ t("scopeSync.whatLink") }}</li>
<li>{{ t("scopeSync.whatLive") }}</li>
</ul>
</div>
<div class="alert alert-info mt-4 items-start">
<i class="mdi mdi-autorenew text-2xl"></i>
<div>
<h4 class="font-semibold">{{ t("scopeSync.autoTitle") }}</h4>
<p class="text-sm">{{ t("scopeSync.autoInterval") }}</p>
<p class="text-sm">{{ t("scopeSync.autoStop") }}</p>
<p class="text-sm">{{ t("scopeSync.autoErrors") }}</p>
</div>
</div>
<!-- Current state -->
<div class="mt-6">
<h4 class="font-semibold mb-2">{{ t("scopeSync.statusTitle") }}</h4>
<div v-if="isLoading" class="flex justify-center py-6">
<span class="loading loading-spinner"></span>
</div>
<div v-else-if="status" class="stats stats-vertical md:stats-horizontal shadow w-full">
<div class="stat">
<div class="stat-title">{{ t("scopeSync.loop") }}</div>
<div class="stat-value text-lg flex items-center gap-2">
<span :class="['badge', status.running ? 'badge-success' : 'badge-ghost']">
{{ status.running ? t("scopeSync.running") : t("scopeSync.notRunningShort") }}
</span>
</div>
<div class="stat-desc">
{{ t("scopeSync.nextRun") }}: {{ status.running ? formatDate(status.next_run_at) : "—" }}
</div>
</div>
<div class="stat">
<div class="stat-title">{{ t("scopeSync.aandt") }}</div>
<div class="stat-value text-lg">{{ aandtStatusLabel }}</div>
<div class="stat-desc">{{ t("scopeSync.lastRun") }}: {{ formatDate(status.last_run_at) }}</div>
</div>
<div class="stat">
<div class="stat-title">{{ t("scopeSync.scopes") }}</div>
<div class="stat-value text-lg">{{ status.active_scope_count }} / {{ status.scope_count }}</div>
<div class="stat-desc">{{ t("scopeSync.activeOfTotal") }}</div>
</div>
</div>
<div v-if="status?.error" class="alert alert-error mt-3">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ t("scopeSync.lastError") }}: {{ status.error }}</span>
</div>
</div>
<!-- Token -->
<form class="mt-6" @submit.prevent="submit">
<label class="font-semibold block mb-1" for="apibot-token">{{ t("scopeSync.tokenTitle") }}</label>
<p class="text-sm text-base-content/70 mb-2">
{{ hasToken ? t("scopeSync.tokenReplace") : t("scopeSync.tokenMissing") }}
</p>
<input id="apibot-token" v-model="apibotToken" type="password" autocomplete="off"
:placeholder="hasToken ? t('scopeSync.tokenConfigured') : t('scopeSync.tokenPlaceholder')"
class="input input-bordered w-full" />
<div v-if="message" :class="['alert mt-4', `alert-${message.type}`]">{{ message.text }}</div>
<div class="modal-action flex-wrap">
<button type="button" @click="open = false" class="btn">{{ t("common.close") }}</button>
<button v-if="status?.running" type="button" :disabled="busy" class="btn btn-error"
@click="stop.mutate({ path })">
<span v-if="stop.isPending.value" class="loading loading-spinner loading-xs"></span>
<i v-else class="mdi mdi-stop"></i>
{{ t("scopeSync.stop") }}
</button>
<button type="submit" class="btn btn-primary"
:disabled="busy || (!hasToken && !tokenTyped) || (status?.running && !tokenTyped)">
<span v-if="start.isPending.value" class="loading loading-spinner loading-xs"></span>
<i v-else class="mdi mdi-play"></i>
{{ status?.running ? t("scopeSync.saveToken") : t("scopeSync.start") }}
</button>
</div>
</form>
</div>
<div class="modal-backdrop" @click="open = false"></div>
</div>
</template>
@@ -0,0 +1,10 @@
import { watch, type Ref } from "vue";
import { usePageHeaderStore, type LiveKind } from "@/stores/pageHeader";
// Shows the page's live (websocket) state as a badge mid-header, with a
// popover explaining what it means on this page (liveStatus.<kind> strings).
// Reset by the router on the next navigation.
export const useLiveBadge = (kind: LiveKind, connected: Ref<boolean>) => {
const pageHeaderStore = usePageHeaderStore();
watch(connected, (value) => pageHeaderStore.setLive(value, kind), { immediate: true });
};
@@ -1,4 +1,4 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useWebSocket } from '@vueuse/core' import { useWebSocket } from '@vueuse/core'
import { placeApiCanvas } from '@/api' import { placeApiCanvas } from '@/api'
@@ -108,6 +108,15 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
const connected = computed(() => status.value === 'OPEN') const connected = computed(() => status.value === 'OPEN')
// Pixels placed while disconnected never reach us: catch up by reloading
// the whole canvas on every reconnect (the first load is done on mount).
let hasConnected = false
watch(connected, (isConnected) => {
if (!isConnected) return
if (hasConnected) loadCanvas()
hasConnected = true
})
async function loadCanvas() { async function loadCanvas() {
const response = await placeApiCanvas({ path: { event_path: eventPath } }) const response = await placeApiCanvas({ path: { event_path: eventPath } })
if (!response.data) return if (!response.data) return
@@ -1,9 +1,11 @@
import { ref, computed, onBeforeUnmount } from 'vue' import { ref, computed } from 'vue'
import { useWebSocket } from '@vueuse/core' import { useWebSocket } from '@vueuse/core'
import { useLiveBadge } from '@/composables/useLiveBadge'
interface SubmissionUpdate { interface SubmissionUpdate {
id: string id: string
scope_id?: string scope_id?: string
username?: string | null
notes?: string notes?: string
files?: SubmissionFileUpdate[] files?: SubmissionFileUpdate[]
created_at: string created_at: string
@@ -23,6 +25,7 @@ interface SubmissionFileUpdate {
interface ImageCropUpdate { interface ImageCropUpdate {
id: string id: string
submission_file_id: string
x1: number x1: number
y1: number y1: number
x2: number x2: number
@@ -33,13 +36,36 @@ interface ImageCropUpdate {
created_at: string created_at: string
} }
type WebSocketData = SubmissionUpdate | SubmissionFileUpdate | ImageCropUpdate // A scope (match) the user was or is assigned to opened/closed, or the user
// got (un)assigned from it - see broadcast_scope_updates in signals.py.
export interface ScopeUpdate {
id: string
is_active: boolean
assigned: boolean
}
export function useSubmissionsWebSocket(userSubmissions: any) { // See broadcast_image_crop_delete in signals.py
interface DeletedCrop {
id: string
submission_file_id: string
}
type WebSocketData =
| { deleted_crop: DeletedCrop }
| SubmissionUpdate
| SubmissionFileUpdate
| ImageCropUpdate
| { scope: ScopeUpdate }
interface Options {
userSubmissions?: any
// Only submissions of this scope get added to the list (staff get them all)
scopeId?: string
onScopeUpdate?: (scope: ScopeUpdate) => void
}
export function useSubmissionsWebSocket({ userSubmissions, scopeId, onScopeUpdate }: Options) {
const isConnected = ref(false) const isConnected = ref(false)
const reconnectAttempts = ref(0)
const maxReconnectAttempts = 10
const reconnectDelay = ref(3000)
const wsUrl = computed(() => { const wsUrl = computed(() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -53,14 +79,11 @@ export function useSubmissionsWebSocket(userSubmissions: any) {
} = useWebSocket(wsUrl.value, { } = useWebSocket(wsUrl.value, {
onConnected: () => { onConnected: () => {
isConnected.value = true isConnected.value = true
reconnectAttempts.value = 0
reconnectDelay.value = 3000
console.log('✓ Connected to submissions WebSocket') console.log('✓ Connected to submissions WebSocket')
}, },
onDisconnected: () => { onDisconnected: () => {
isConnected.value = false isConnected.value = false
console.log('✗ Disconnected from submissions WebSocket, attempting to reconnect...') console.log('✗ Disconnected from submissions WebSocket')
attemptReconnect()
}, },
onError: (event) => { onError: (event) => {
console.error('WebSocket error:', event) console.error('WebSocket error:', event)
@@ -70,7 +93,13 @@ export function useSubmissionsWebSocket(userSubmissions: any) {
const messageData = event.data as string const messageData = event.data as string
const data = JSON.parse(messageData) as WebSocketData const data = JSON.parse(messageData) as WebSocketData
if ('files' in data) { if ('scope' in data) {
onScopeUpdate?.(data.scope)
} else if (!userSubmissions) {
return
} else if ('deleted_crop' in data) {
handleCropDelete(data.deleted_crop)
} else if ('files' in data) {
handleSubmissionUpdate(data as SubmissionUpdate) handleSubmissionUpdate(data as SubmissionUpdate)
} else if ('original_filename' in data) { } else if ('original_filename' in data) {
handleFileUpdate(data as SubmissionFileUpdate) handleFileUpdate(data as SubmissionFileUpdate)
@@ -82,25 +111,21 @@ export function useSubmissionsWebSocket(userSubmissions: any) {
} }
}, },
immediate: true, immediate: true,
// vueuse's own reconnect stops once close() is called (it also closes
// on unmount), unlike a hand-rolled setTimeout(open) that would reopen
// the socket after leaving the page.
autoReconnect: {
retries: 10,
delay: 3000,
onFailed: () => console.error('Max reconnection attempts reached'),
},
}) })
const attemptReconnect = () => { useLiveBadge('submissions', isConnected)
if (reconnectAttempts.value >= maxReconnectAttempts) {
console.error('Max reconnection attempts reached')
return
}
reconnectAttempts.value++
const delay = reconnectDelay.value * Math.pow(1.5, reconnectAttempts.value - 1)
console.log(`Reconnecting (attempt ${reconnectAttempts.value}/${maxReconnectAttempts}) in ${Math.round(delay / 1000)}s...`)
setTimeout(() => {
open()
}, delay)
}
const handleSubmissionUpdate = (submission: SubmissionUpdate) => { const handleSubmissionUpdate = (submission: SubmissionUpdate) => {
if (scopeId && submission.scope_id !== scopeId) return
const existingIndex = userSubmissions.value.findIndex((s: any) => s.id === submission.id) const existingIndex = userSubmissions.value.findIndex((s: any) => s.id === submission.id)
if (existingIndex >= 0) { if (existingIndex >= 0) {
@@ -127,6 +152,8 @@ export function useSubmissionsWebSocket(userSubmissions: any) {
} }
const handleCropUpdate = (crop: ImageCropUpdate) => { const handleCropUpdate = (crop: ImageCropUpdate) => {
let found = false
userSubmissions.value.forEach((submission: any) => { userSubmissions.value.forEach((submission: any) => {
if (!submission.files) return if (!submission.files) return
@@ -137,15 +164,38 @@ export function useSubmissionsWebSocket(userSubmissions: any) {
if (cropIndex >= 0) { if (cropIndex >= 0) {
// Merge into existing object to preserve references // Merge into existing object to preserve references
Object.assign(file.crops[cropIndex], crop) Object.assign(file.crops[cropIndex], crop)
found = true
console.log('Updated crop:', crop.id) console.log('Updated crop:', crop.id)
} }
}) })
}) })
if (!found) handleNewCrop(crop)
} }
onBeforeUnmount(() => { const handleCropDelete = (crop: DeletedCrop) => {
close() userSubmissions.value.forEach((submission: any) => {
}) submission.files?.forEach((file: any) => {
if (file.id !== crop.submission_file_id || !file.crops) return
file.crops = file.crops.filter((c: any) => c.id !== crop.id)
console.log('Deleted crop:', crop.id)
})
})
}
// New crop (e.g. drawn by another admin): add it to its file
const handleNewCrop = (crop: ImageCropUpdate) => {
userSubmissions.value.forEach((submission: any) => {
submission.files?.forEach((file: any) => {
if (file.id !== crop.submission_file_id) return
file.crops = file.crops || []
file.crops.push(crop)
console.log('Added crop:', crop.id)
})
})
}
return { return {
isConnected, isConnected,
@@ -0,0 +1,14 @@
import { computed } from "vue";
import { useQuery } from "@tanstack/vue-query";
import { userQuery } from "@/queries";
export const useUser = () => {
const { data } = useQuery(userQuery());
return computed(() => ({
username: data.value?.username ?? "",
isAuthenticated: data.value?.is_authenticated ?? false,
isStaff: data.value?.is_staff ?? false,
isSuperuser: data.value?.is_superuser ?? false,
}));
};
+57 -19
View File
@@ -3,16 +3,27 @@ import { computed } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { useUserStore } from "@/stores/user"; import { useUser } from "@/composables/useUser";
import { usePageHeaderStore } from "@/stores/pageHeader"; import { usePageHeaderStore } from "@/stores/pageHeader";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { t } = useI18n(); const { t, tm, rt } = useI18n();
const userStore = useUserStore(); const userInfo = useUser();
const { userInfo } = storeToRefs(userStore);
const pageHeaderStore = usePageHeaderStore(); const pageHeaderStore = usePageHeaderStore();
const { title: dynamicTitle, backRoute: dynamicBackRoute } = storeToRefs(pageHeaderStore); const { title: dynamicTitle, backRoute: dynamicBackRoute, live, liveKind } = storeToRefs(pageHeaderStore);
// Popover text of the live badge, per page (liveStatus.<kind>.live/offline)
const liveHelp = computed(() => {
if (!liveKind.value) return null;
const key = `liveStatus.${liveKind.value}.${live.value ? "live" : "offline"}`;
return {
title: t(`${key}.title`),
intro: t(`${key}.intro`),
points: (tm(`${key}.points`) as unknown[]).map((point) => rt(point as Parameters<typeof rt>[0])),
outro: t(`${key}.outro`),
};
});
const title = computed(() => { const title = computed(() => {
if (dynamicTitle.value) return dynamicTitle.value; if (dynamicTitle.value) return dynamicTitle.value;
@@ -30,22 +41,49 @@ const goBack = () => {
<template> <template>
<div class="navbar bg-base-100 shadow-lg"> <div class="navbar bg-base-100 shadow-lg">
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4"> <!-- 3 columns so the live badge stays centered whatever the sides' width -->
<button v-if="route.meta.getBackRoute" @click="goBack" class="btn btn-primary btn-sm"> <div class="container min-w-3/4 mx-auto w-full grid grid-cols-[1fr_auto_1fr] items-center gap-4">
<i class="mdi mdi-arrow-left"></i> <div class="flex items-center gap-4 min-w-0">
{{ t("common.back") }} <button v-if="route.meta.getBackRoute" @click="goBack" class="btn btn-primary btn-sm">
</button> <i class="mdi mdi-arrow-left"></i>
<h1 class="text-xl font-bold">{{ title }}</h1> {{ t("common.back") }}
<div class="flex-1"></div> </button>
<div v-if="userInfo.isAuthenticated" class="flex items-center gap-2"> <h1 class="text-xl font-bold truncate">{{ title }}</h1>
<div class="text-sm"> </div>
<span class="font-medium">{{ userInfo.username }}</span> <div>
<span v-if="userInfo.isStaff" class="badge badge-warning badge-xs ml-1">{{ t("common.admin") }}</span> <!-- Hover (or tap, on mobile) to explain what live means for the player -->
<div v-if="live !== null" class="dropdown dropdown-hover dropdown-center">
<div tabindex="0" role="button" :class="['badge cursor-help', live ? 'badge-success' : 'badge-warning']">
<i :class="['mdi mr-1', live ? 'mdi-wifi' : 'mdi-wifi-off']"></i>
{{ live ? t("submissions.live") : t("submissions.offline") }}
<i class="mdi mdi-information-outline ml-1 opacity-70"></i>
</div>
<div tabindex="0" class="dropdown-content card card-sm bg-base-200 shadow-xl z-50 w-80 mt-2">
<div v-if="liveHelp" class="card-body">
<h3 class="card-title text-base">
<i :class="['mdi', live ? 'mdi-wifi text-success' : 'mdi-wifi-off text-warning']"></i>
{{ liveHelp.title }}
</h3>
<p>{{ liveHelp.intro }}</p>
<ul v-if="liveHelp.points.length" class="list-disc pl-5 space-y-1">
<li v-for="point in liveHelp.points" :key="point">{{ point }}</li>
</ul>
<p class="text-base-content/70">{{ liveHelp.outro }}</p>
</div>
</div>
</div> </div>
</div> </div>
<div v-else class="text-sm text-base-content/70">{{ t("common.notLoggedIn") }}</div> <div class="flex items-center justify-end gap-4">
<a href="/api/docs" class="btn btn-xs">{{ t("common.apiDocs") }}</a> <div v-if="userInfo.isAuthenticated" class="flex items-center gap-2">
<a v-if="userInfo.isStaff" href="/admin" class="btn btn-xs btn-warning">{{ t("common.adminPanel") }}</a> <div class="text-sm">
<span class="font-medium">{{ userInfo.username }}</span>
<span v-if="userInfo.isStaff" class="badge badge-warning badge-xs ml-1">{{ t("common.admin") }}</span>
</div>
</div>
<div v-else class="text-sm text-base-content/70">{{ t("common.notLoggedIn") }}</div>
<a href="/api/docs" class="btn btn-xs">{{ t("common.apiDocs") }}</a>
<a v-if="userInfo.isStaff" href="/admin" class="btn btn-xs btn-warning">{{ t("common.adminPanel") }}</a>
</div>
</div> </div>
</div> </div>
</template> </template>
+114 -5
View File
@@ -24,6 +24,7 @@
"puzzle": "Puzzle", "puzzle": "Puzzle",
"files": "Files", "files": "Files",
"date": "Date", "date": "Date",
"user": "User",
"notes": "Notes", "notes": "Notes",
"actions": "Actions", "actions": "Actions",
"noRankYet": "No rank yet", "noRankYet": "No rank yet",
@@ -80,8 +81,6 @@
"clock": { "clock": {
"pageTitle": "Beat the Clock", "pageTitle": "Beat the Clock",
"header": "Beat the Clock", "header": "Beat the Clock",
"connected": "connected",
"reconnecting": "reconnecting…",
"press": "PRESS", "press": "PRESS",
"nextPlayer": "▲ next player", "nextPlayer": "▲ next player",
"chasingYou": "▼ chasing you", "chasingYou": "▼ chasing you",
@@ -90,8 +89,6 @@
"place": { "place": {
"pageTitle": "Place", "pageTitle": "Place",
"header": "Place", "header": "Place",
"connected": "connected",
"reconnecting": "reconnecting…",
"showGrid": "Show grid", "showGrid": "Show grid",
"hideGrid": "Hide grid" "hideGrid": "Hide grid"
}, },
@@ -172,6 +169,7 @@
} }
}, },
"yourSubmissions": "Your Submissions", "yourSubmissions": "Your Submissions",
"allSubmissions": "All Submissions",
"noSubmissionsHint": "Upload your first submission above to get started", "noSubmissionsHint": "Upload your first submission above to get started",
"fileStatus": "File Status", "fileStatus": "File Status",
"ocrStatus": "OCR Status", "ocrStatus": "OCR Status",
@@ -209,7 +207,118 @@
"title": "Select your match", "title": "Select your match",
"description": "You're assigned to multiple matches for {game}. Pick which one you want to submit for.", "description": "You're assigned to multiple matches for {game}. Pick which one you want to submit for.",
"notAssignedTitle": "No match assigned", "notAssignedTitle": "No match assigned",
"notAssignedMessage": "You are not assigned to a scope/matches, you cannot submit a score." "notAssignedMessage": "You are not assigned to a scope/matches, you cannot submit a score.",
"syncScopes": "Sync scopes"
},
"scopeSync": {
"title": "AAndT scope sync — {game}",
"intro": "Keeps this game's matches (scopes) in line with its AAndT on the PolyLAN site, using the AAndT's apibot API.",
"whatScopes": "Every AAndT match becomes a scope here, created or updated (name, open/closed) on each sync.",
"whatPlayers": "Players of each match are assigned to its scope, matched through their PolyLAN account. Players without an account here yet are skipped until they log in once.",
"whatClose": "A scope is open only while its match is playing. Finished matches, and matches removed from the AAndT, get closed.",
"whatLink": "Each playing match gets a \"Submit your proof here\" link on the PolyLAN site, pointing to its submission page here.",
"whatLive": "Players are moved live: when their match closes, they're sent back to the match list, and straight into their next match if there's only one. No refresh needed.",
"autoTitle": "Starts once, then keeps going on its own",
"autoInterval": "After starting, the sync re-triggers itself: every 15 seconds while the AAndT is in progress, every minute before that (registrations, preparation, ...).",
"autoStop": "It stops by itself once the AAndT is done or reviewed, or when you stop it here (or with the \"Stop scope sync\" admin action). Scopes are kept as they are when it stops.",
"autoErrors": "If a sync fails (AAndT site down, wrong token, ...), it's retried a minute later. The last error is shown below.",
"statusTitle": "Current state",
"loop": "Sync",
"running": "Running",
"notRunningShort": "Stopped",
"nextRun": "Next run",
"lastRun": "Last run",
"aandt": "AAndT status",
"scopes": "Scopes",
"activeOfTotal": "open / total",
"lastError": "Last run failed",
"never": "never",
"unknown": "Unknown",
"aandtStatus": {
"prep": "Preparation",
"published": "Published",
"registrations": "Registrations",
"registrationsDone": "Registrations closed",
"inProgress": "In progress",
"done": "Done",
"reviewed": "Reviewed"
},
"tokenTitle": "AAndT apibot token",
"tokenMissing": "No token is configured for this game yet. Paste the one from the AAndT's \"apibot\" admin page on the PolyLAN site.",
"tokenReplace": "A token is already configured. Paste a new one only to replace it (e.g. for a new AAndT); a running sync picks it up on its next run.",
"tokenPlaceholder": "Apibot token",
"tokenConfigured": "Token configured (leave empty to keep it)",
"start": "Start sync",
"saveToken": "Save token",
"stop": "Stop sync",
"started": "Sync started. It keeps running on its own until the AAndT is done.",
"alreadyRunning": "Sync is already running.",
"startFailed": "Failed to start the sync.",
"stopped": "Sync stopped.",
"notRunning": "Sync wasn't running.",
"stopFailed": "Failed to stop the sync."
},
"liveStatus": {
"submissions": {
"live": {
"title": "You're connected live",
"intro": "This page updates on its own, you don't need to refresh it:",
"points": [
"Your submissions update as they're processed (uploaded files, text read from your screenshots).",
"When your match ends, you're sent back to the match list, and straight into your next match if you have only one."
],
"outro": "Just keep this page open between matches."
},
"offline": {
"title": "Live connection lost",
"intro": "The page can't get updates right now:",
"points": [
"It won't move you to your next match on its own.",
"Processing results of your submissions won't show up by themselves."
],
"outro": "It reconnects automatically, usually within a few seconds. Still offline after a while? Refresh the page, anything you already submitted is saved."
}
},
"clock": {
"live": {
"title": "You're connected live",
"intro": "Everything here is real time, shared with every player:",
"points": [
"The countdown is the same for everyone, and moves as soon as someone presses.",
"Your points and rank update right after each of your presses."
],
"outro": "Presses go through this live connection, keep it green to play."
},
"offline": {
"title": "Live connection lost",
"intro": "You can't play right now:",
"points": [
"Your presses can't reach the server.",
"The countdown shown may be off, other players' presses aren't coming in."
],
"outro": "It reconnects automatically within a second or so, and your stats are refreshed when it does. Still offline? Refresh the page."
}
},
"place": {
"live": {
"title": "You're connected live",
"intro": "The canvas is shared with every player, in real time:",
"points": [
"Pixels placed by others show up as soon as they're placed.",
"Your pixels go through this live connection, then the cooldown starts."
],
"outro": "Just keep this page open, no refresh needed."
},
"offline": {
"title": "Live connection lost",
"intro": "You can't place pixels right now:",
"points": [
"Your clicks can't reach the server.",
"Pixels placed by others aren't coming in, the canvas may be out of date."
],
"outro": "It reconnects automatically within a second or so, and reloads the whole canvas to catch up. Still offline? Refresh the page."
}
}
}, },
"errorPage": { "errorPage": {
"notFound": { "notFound": {
+7
View File
@@ -1,12 +1,19 @@
import { createApp } from "vue"; import { createApp } from "vue";
import { VueQueryPlugin } from "@tanstack/vue-query";
import App from "@/App.vue"; import App from "@/App.vue";
import { router } from "@/router"; import { router } from "@/router";
import { pinia } from "@/stores"; import { pinia } from "@/stores";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
import { onQueryCacheRestored, queryClient, restoreQueryCache } from "@/queries";
import "@/style.css"; import "@/style.css";
const app = createApp(App); const app = createApp(App);
app.use(pinia); app.use(pinia);
app.use(VueQueryPlugin, {
queryClient,
clientPersister: restoreQueryCache,
clientPersisterOnSuccess: onQueryCacheRestored,
});
app.use(router); app.use(router);
app.use(i18n); app.use(i18n);
app.mount("#app"); app.mount("#app");
+45
View File
@@ -0,0 +1,45 @@
import { QueryClient } from "@tanstack/vue-query";
import { persistQueryClient } from "@tanstack/query-persist-client-core";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import {
gamesApiListEventGamesOptions,
polylanSubmitterApiGetUserInfoOptions,
polylanSubmitterApiGetUserInfoQueryKey,
} from "@/api/@tanstack/vue-query.gen";
// Exported (not just provided by VueQueryPlugin) so Pinia stores can
// fetchQuery() through the same cache.
export const queryClient = new QueryClient();
// Login/logout are full-page redirects, so the user can't change within a
// page load - only refetched once after a reload (see restoreQueryCache).
export const userQuery = () => ({
...polylanSubmitterApiGetUserInfoOptions(),
staleTime: Infinity,
});
// Games only change when staff enable/disable one, a few minutes of
// staleness is fine.
export const eventGamesQuery = (eventPath: string) => ({
...gamesApiListEventGamesOptions({ path: { event_path: eventPath } }),
staleTime: 5 * 60 * 1000,
});
const PERSISTED_QUERIES = new Set(["polylanSubmitterApiGetUserInfo", "gamesApiListEventGames"]);
// Restores user + games from localStorage before the app mounts, so pages
// render right away on reload instead of waiting on the API.
export const restoreQueryCache = (client: QueryClient) =>
persistQueryClient({
queryClient: client,
persister: createSyncStoragePersister({ storage: window.localStorage }),
dehydrateOptions: {
shouldDehydrateQuery: (query) =>
query.state.status === "success" &&
PERSISTED_QUERIES.has((query.queryKey[0] as { _id: string })._id),
},
});
// The restored user is only a placeholder until the real one comes back.
export const onQueryCacheRestored = (client: QueryClient) =>
client.invalidateQueries({ queryKey: polylanSubmitterApiGetUserInfoQueryKey() });
+3 -10
View File
@@ -1,7 +1,8 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { polylanSubmitterApiGetUserInfo, marketApiListMarkets, marketApiListUserBets, marketApiGetUserPoints } from '@/api' import { marketApiListMarkets, marketApiListUserBets, marketApiGetUserPoints } from '@/api'
import type { Market } from '@/types' import type { Market } from '@/types'
import { queryClient, userQuery } from '@/queries'
import type { UserInfoOut, UserBetSchema } from '@/api/types.gen' import type { UserInfoOut, UserBetSchema } from '@/api/types.gen'
export const useMarketStore = defineStore('market', () => { export const useMarketStore = defineStore('market', () => {
@@ -15,15 +16,7 @@ export const useMarketStore = defineStore('market', () => {
// Actions // Actions
const loadUserInfo = async () => { const loadUserInfo = async () => {
try { userInfo.value = await queryClient.fetchQuery(userQuery()).catch(() => undefined)
const response = await polylanSubmitterApiGetUserInfo()
if (response.data) {
userInfo.value = response.data
}
} catch (err) {
error.value = 'Failed to load user info'
console.error('Error loading user info:', err)
}
} }
const loadMarkets = async (eventPath: string) => { const loadMarkets = async (eventPath: string) => {
+5 -9
View File
@@ -4,10 +4,10 @@ import {
noitaApiGetResults, noitaApiGetResults,
noitaApiGetLeaderboard, noitaApiGetLeaderboard,
noitaApiSubmitLogFile, noitaApiSubmitLogFile,
polylanSubmitterApiGetUserInfo,
polylanSubmitterApiClearCache, polylanSubmitterApiClearCache,
} from '@/api' } from '@/api'
import type { ObjectivResultOut, LeaderboardEntryOut } from '@/api/types.gen' import type { ObjectivResultOut, LeaderboardEntryOut } from '@/api/types.gen'
import { queryClient, userQuery } from '@/queries'
export type Objective = ObjectivResultOut export type Objective = ObjectivResultOut
@@ -80,14 +80,10 @@ export const useNoitaStore = defineStore('noita', () => {
} }
const loadUserData = async (eventPath: string) => { const loadUserData = async (eventPath: string) => {
try { const user = await queryClient.fetchQuery(userQuery()).catch(() => undefined)
const response = await polylanSubmitterApiGetUserInfo() if (user?.is_authenticated) {
if (response.data?.is_authenticated) { userInfo.value.username = user.username ?? ''
userInfo.value.username = response.data.username ?? '' userInfo.value.isStaff = user.is_staff || false
userInfo.value.isStaff = response.data.is_staff || false
}
} catch (err) {
console.error('Error fetching user info:', err)
} }
await Promise.all([fetchUserResults(eventPath), fetchLeaderboard(eventPath)]) await Promise.all([fetchUserResults(eventPath), fetchLeaderboard(eventPath)])
+15 -1
View File
@@ -2,6 +2,9 @@ import { defineStore } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
import type { RouteLocationRaw } from "vue-router"; import type { RouteLocationRaw } from "vue-router";
// Which page's explanation the live badge popover shows (liveStatus.<kind>)
export type LiveKind = "submissions" | "clock" | "place";
// Lets a routed page hand AppHeader a title built from data only that page // Lets a routed page hand AppHeader a title built from data only that page
// has fetched (e.g. "Noita Submissions — Semifinal", assembled from an API // has fetched (e.g. "Noita Submissions — Semifinal", assembled from an API
// call keyed by the scopeId route param). Cleared on every navigation so a // call keyed by the scopeId route param). Cleared on every navigation so a
@@ -11,6 +14,10 @@ export const usePageHeaderStore = defineStore("pageHeader", () => {
// Overrides route.meta.getBackRoute when the right target depends on // Overrides route.meta.getBackRoute when the right target depends on
// fetched data (e.g. skip the scope picker when it would auto-redirect). // fetched data (e.g. skip the scope picker when it would auto-redirect).
const backRoute = ref<RouteLocationRaw | null>(null); const backRoute = ref<RouteLocationRaw | null>(null);
// Live (websocket) connection state of the page, shown mid-header; null
// when the page has no live connection (no badge at all).
const live = ref<boolean | null>(null);
const liveKind = ref<LiveKind | null>(null);
const setTitle = (value: string) => { const setTitle = (value: string) => {
title.value = value; title.value = value;
@@ -20,10 +27,17 @@ export const usePageHeaderStore = defineStore("pageHeader", () => {
backRoute.value = value; backRoute.value = value;
}; };
const setLive = (value: boolean, kind: LiveKind) => {
live.value = value;
liveKind.value = kind;
};
const clear = () => { const clear = () => {
title.value = ""; title.value = "";
backRoute.value = null; backRoute.value = null;
live.value = null;
liveKind.value = null;
}; };
return { title, backRoute, setTitle, setBackRoute, clear }; return { title, backRoute, live, liveKind, setTitle, setBackRoute, setLive, clear };
}); });
-44
View File
@@ -1,44 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { polylanSubmitterApiGetUserInfo } from '@/api'
interface UserInfo {
username: string
isAuthenticated: boolean
isStaff: boolean
}
export const useUserStore = defineStore('user', () => {
const userInfo = ref<UserInfo>({
username: '',
isAuthenticated: false,
isStaff: false,
})
const isLoading = ref(false)
const error = ref<string>('')
const loadUserData = async () => {
isLoading.value = true
try {
const response = await polylanSubmitterApiGetUserInfo()
if (response.data) {
const user = response.data
userInfo.value.isAuthenticated = user.is_authenticated || false
userInfo.value.username = user.username || ''
userInfo.value.isStaff = user.is_staff || false
}
} catch (err) {
error.value = 'Failed to fetch user info'
console.error('Error fetching user info:', err)
} finally {
isLoading.value = false
}
}
return {
userInfo,
isLoading,
error,
loadUserData,
}
})
+40
View File
@@ -1,6 +1,7 @@
from django.contrib import admin from django.contrib import admin
from django.urls import reverse from django.urls import reverse
from .models import Submission, SubmissionFile, ImageCrop, Scope from .models import Submission, SubmissionFile, ImageCrop, Scope
from .signals import broadcast_scope_updates
class ImageCropInline(admin.TabularInline): class ImageCropInline(admin.TabularInline):
@@ -77,6 +78,45 @@ class ScopeAdmin(admin.ModelAdmin):
}, },
) )
# Players on a scope edited here get moved like with the AAndT sync (see
# broadcast_scope_updates): state before the save is kept on the instance,
# compared once the assigned users (m2m) are saved too in save_related.
def save_model(self, request, obj, form, change):
previous = Scope.objects.filter(pk=obj.pk).first() if change else None
obj._state_before_save = (
(
previous.is_active,
set(previous.assigned_users.values_list("id", flat=True)),
)
if previous
else (False, set())
)
super().save_model(request, obj, form, change)
def save_related(self, request, form, formsets, change):
super().save_related(request, form, formsets, change)
scope = form.instance
was_active, previous_user_ids = scope._state_before_save
user_ids = set(scope.assigned_users.values_list("id", flat=True))
if scope.is_active != was_active or user_ids != previous_user_ids:
broadcast_scope_updates({scope: user_ids | previous_user_ids})
def delete_model(self, request, obj):
self.delete_queryset(request, Scope.objects.filter(pk=obj.pk))
def delete_queryset(self, request, queryset):
changes = {
scope: {user.id for user in scope.assigned_users.all()}
for scope in queryset.prefetch_related("assigned_users")
}
for scope in changes:
scope.is_active = False
super().delete_queryset(request, queryset)
broadcast_scope_updates(changes)
@admin.register(Submission) @admin.register(Submission)
class SubmissionAdmin(admin.ModelAdmin): class SubmissionAdmin(admin.ModelAdmin):
+99 -7
View File
@@ -5,10 +5,25 @@ from ninja import Router, File, Form
from ninja.files import UploadedFile 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 EventGame, Game
from .models import Submission, SubmissionFile, ImageCrop, OCRStatus, Scope from .models import Submission, SubmissionFile, ImageCrop, OCRStatus, Scope
from .schemas import SubmissionOut, ImageCropIn, ImageCropOut, ScopeOut from .schemas import (
from .tasks import process_submission_file, process_crop SubmissionOut,
ImageCropIn,
ImageCropOut,
ScopeOut,
SyncScopesIn,
SyncScopesOut,
SyncScopesStatusOut,
SyncScopesStopOut,
)
from .tasks import (
get_scope_sync_state,
process_submission_file,
process_crop,
schedule_scope_sync,
stop_scope_sync,
)
router = Router() router = Router()
@@ -66,6 +81,81 @@ def list_available_scopes(request: HttpRequest, event_path: str, game_path: str)
return list(scopes.order_by("name")) return list(scopes.order_by("name"))
def _syncable_event_game(request: HttpRequest, game_path: str) -> EventGame:
return get_object_or_404(
EventGame,
event=request.event,
game__path=game_path,
game__custom_path=False,
enabled=True,
)
@router.get(
f"{PREFIX}/{{game_path}}/sync-scopes",
response={200: SyncScopesStatusOut, 403: dict},
)
def sync_scopes_status(request: HttpRequest, event_path: str, game_path: str):
"""State of the AAndT scope sync loop of a (non custom path) game,
superusers only."""
if not request.user.is_superuser:
return 403, {"detail": "Superuser required"}
event_game = _syncable_event_game(request, game_path)
scopes = Scope.objects.filter(event_game=event_game)
return {
**vars(get_scope_sync_state(event_game.id)),
"has_token": bool(event_game.apibot_token),
"scope_count": scopes.count(),
"active_scope_count": scopes.filter(is_active=True).count(),
}
@router.post(
f"{PREFIX}/{{game_path}}/sync-scopes",
response={200: SyncScopesOut, 400: dict, 403: dict},
)
def sync_scopes(
request: HttpRequest, event_path: str, game_path: str, payload: SyncScopesIn
):
"""Start the AAndT scope sync loop for a (non custom path) game, superusers
only. Stores `apibot_token` first when given; without one and none stored
yet, answers 400 with `token_required` so the frontend asks for it."""
if not request.user.is_superuser:
return 403, {"detail": "Superuser required"}
event_game = _syncable_event_game(request, game_path)
token = (payload.apibot_token or "").strip()
if token:
event_game.apibot_token = token
event_game.save(update_fields=["apibot_token"])
if not event_game.apibot_token:
return 400, {
"detail": "No AAndT apibot token configured",
"token_required": True,
}
return {"started": schedule_scope_sync(event_game.id)}
@router.delete(
f"{PREFIX}/{{game_path}}/sync-scopes",
response={200: SyncScopesStopOut, 403: dict},
)
def stop_sync_scopes(request: HttpRequest, event_path: str, game_path: str):
"""Stop the AAndT scope sync loop of a (non custom path) game, superusers
only. Cancels its scheduled job, scopes are left as they are."""
if not request.user.is_superuser:
return 403, {"detail": "Superuser required"}
event_game = _syncable_event_game(request, game_path)
return {"stopped": stop_scope_sync(event_game.id)}
@router.post(f"{PREFIX}/submission/create", response={200: SubmissionOut, 403: dict}) @router.post(f"{PREFIX}/submission/create", response={200: SubmissionOut, 403: dict})
def create_submission( def create_submission(
request: HttpRequest, request: HttpRequest,
@@ -112,10 +202,12 @@ 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( submissions = Submission.for_event.filter(scope_id=scope_id)
scope_id=scope_id, user=request.user # Admins see every player's submissions for the scope
).order_by("-created_at") if not request.user.is_staff:
return submissions submissions = submissions.filter(user=request.user)
return submissions.select_related("user").order_by("-created_at")
@router.get(f"{PREFIX}/submission/by/game", response=list[SubmissionOut]) @router.get(f"{PREFIX}/submission/by/game", response=list[SubmissionOut])
+17 -3
View File
@@ -2,6 +2,8 @@ import json
from channels.generic.websocket import AsyncWebsocketConsumer from channels.generic.websocket import AsyncWebsocketConsumer
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from submissions.signals import STAFF_GROUP_NAME
class SubmissionsConsumer(AsyncWebsocketConsumer): class SubmissionsConsumer(AsyncWebsocketConsumer):
async def connect(self): async def connect(self):
@@ -11,12 +13,18 @@ class SubmissionsConsumer(AsyncWebsocketConsumer):
await self.close() await self.close()
return return
self.user_group_name = f"submissions_user_{self.user.id}" self.group_names = [f"submissions_user_{self.user.id}"]
await self.channel_layer.group_add(self.user_group_name, self.channel_name) if self.user.is_staff:
self.group_names.append(STAFF_GROUP_NAME)
for group_name in self.group_names:
await self.channel_layer.group_add(group_name, self.channel_name)
await self.accept() await self.accept()
async def disconnect(self, close_code): async def disconnect(self, close_code):
await self.channel_layer.group_discard(self.user_group_name, self.channel_name) # closed before joining (anonymous)
for group_name in getattr(self, "group_names", []):
await self.channel_layer.group_discard(group_name, self.channel_name)
async def submission_updated(self, event): async def submission_updated(self, event):
submission_data = event["submission"] submission_data = event["submission"]
@@ -29,3 +37,9 @@ class SubmissionsConsumer(AsyncWebsocketConsumer):
async def submission_crop_updated(self, event): async def submission_crop_updated(self, event):
crop_data = event["crop"] crop_data = event["crop"]
await self.send(text_data=json.dumps(crop_data)) await self.send(text_data=json.dumps(crop_data))
async def submission_crop_deleted(self, event):
await self.send(text_data=json.dumps({"deleted_crop": event["crop"]}))
async def scope_updated(self, event):
await self.send(text_data=json.dumps({"scope": event["scope"]}))
@@ -1,7 +1,7 @@
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from games.models import EventGame from games.models import EventGame
from submissions.tasks import sync_all_event_games_task, sync_event_game_scopes_task from submissions.tasks import schedule_scope_sync, sync_all_event_games_task
class Command(BaseCommand): class Command(BaseCommand):
@@ -25,10 +25,16 @@ class Command(BaseCommand):
if not EventGame.objects.filter(id=event_game_id).exists(): if not EventGame.objects.filter(id=event_game_id).exists():
raise CommandError(f"No EventGame with id={event_game_id}") raise CommandError(f"No EventGame with id={event_game_id}")
sync_event_game_scopes_task.enqueue(event_game_id) if schedule_scope_sync(event_game_id):
self.stdout.write( self.stdout.write(
self.style.SUCCESS(f"Queued sync for EventGame {event_game_id}") self.style.SUCCESS(f"Started sync for EventGame {event_game_id}")
) )
else:
self.stdout.write(
self.style.WARNING(
f"Sync already running for EventGame {event_game_id}"
)
)
return return
if ( if (
+33
View File
@@ -10,6 +10,33 @@ class ScopeOut(Schema):
created_at: datetime created_at: datetime
class SyncScopesIn(Schema):
apibot_token: str | None = None
class SyncScopesOut(Schema):
# False when a sync loop was already running for this game
started: bool
class SyncScopesStopOut(Schema):
# False when no sync loop was running
stopped: bool
class SyncScopesStatusOut(Schema):
running: bool
has_token: bool
# Last AAndT status seen (e.g. "7inprog"), see games' AAndT status values
aandt_status: str | None = None
last_run_at: datetime | None = None
next_run_at: datetime | None = None
# Last run's error, if it failed
error: str | None = None
scope_count: int
active_scope_count: int
class ImageCropIn(Schema): class ImageCropIn(Schema):
x1: int x1: int
y1: int y1: int
@@ -20,6 +47,7 @@ class ImageCropIn(Schema):
class ImageCropOut(Schema): class ImageCropOut(Schema):
id: UUID id: UUID
submission_file_id: UUID
x1: int x1: int
y1: int y1: int
x2: int x2: int
@@ -44,7 +72,12 @@ class SubmissionFileOut(Schema):
class SubmissionOut(Schema): class SubmissionOut(Schema):
id: UUID id: UUID
scope_id: UUID scope_id: UUID
username: str | None = None
notes: str | None = None notes: str | None = None
files: list[SubmissionFileOut] = [] files: list[SubmissionFileOut] = []
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@staticmethod
def resolve_username(obj) -> str | None:
return obj.user.username if obj.user else None
@@ -12,6 +12,7 @@ from accounts.models import CustomUser
from games.models import EventGame from games.models import EventGame
from ..models import Scope from ..models import Scope
from ..signals import broadcast_scope_updates
from .aandt_client import AuthenticatedClient from .aandt_client import AuthenticatedClient
from .aandt_client.api.default import ( from .aandt_client.api.default import (
aandt_api_infos, aandt_api_infos,
@@ -120,6 +121,9 @@ def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
Scopes whose match is no longer returned by the API (deleted, or from a Scopes whose match is no longer returned by the API (deleted, or from a
previous AAndT run reusing the same token) are deactivated. previous AAndT run reusing the same token) are deactivated.
Users of scopes that opened/closed or got their players changed are
notified over the submissions websocket (see `broadcast_scope_updates`).
Args: Args:
event_game: The EventGame to sync; must have `apibot_token` set. event_game: The EventGame to sync; must have `apibot_token` set.
@@ -173,10 +177,11 @@ def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
result = ScopeSyncResult(status=status.value if status is not None else None) result = ScopeSyncResult(status=status.value if status is not None else None)
now = timezone.now() now = timezone.now()
synced_match_ids: set[int] = set() synced_match_ids: set[int] = set()
changes: dict[Scope, set[int]] = {}
for match in matches: for match in matches:
match_id = _unwrap(match.id) match_id = _unwrap(match.id)
created, warnings = _sync_match( created, warnings, change = _sync_match(
event_game, client, match, userids_by_member_id, now event_game, client, match, userids_by_member_id, now
) )
if created is None: if created is None:
@@ -185,14 +190,22 @@ def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
result.created += created result.created += created
result.updated += not created result.updated += not created
result.warnings.extend(warnings) result.warnings.extend(warnings)
if change is not None:
changes[change[0]] = change[1]
( gone = list(
Scope.objects.filter( Scope.objects.filter(
event_game=event_game, is_active=True, external_id__isnull=False event_game=event_game, is_active=True, external_id__isnull=False
) )
.exclude(external_id__in=synced_match_ids) .exclude(external_id__in=synced_match_ids)
.update(is_active=False) .prefetch_related("assigned_users")
) )
Scope.objects.filter(id__in=[scope.id for scope in gone]).update(is_active=False)
for scope in gone:
scope.is_active = False
changes[scope] = {user.id for user in scope.assigned_users.all()}
broadcast_scope_updates(changes)
return result return result
@@ -203,10 +216,17 @@ def _sync_match(
match: Match, match: Match,
userids_by_member_id: dict[int, set[int]], userids_by_member_id: dict[int, set[int]],
now: datetime, now: datetime,
) -> tuple[bool | None, list[str]]: ) -> tuple[bool | None, list[str], tuple[Scope, set[int]] | None]:
"""Upsert the Scope of one match.
Returns:
Whether the Scope was created (None when the match has no id), the
warnings, and when it opened/closed or its players changed, the Scope
with every user id that was or now is assigned to it.
"""
match_id = _unwrap(match.id) match_id = _unwrap(match.id)
if match_id is None: if match_id is None:
return None, [] return None, [], None
warnings: list[str] = [] warnings: list[str] = []
@@ -218,6 +238,12 @@ def _sync_match(
users = list(CustomUser.objects.filter(cas_user_id__in=userids)) users = list(CustomUser.objects.filter(cas_user_id__in=userids))
previous = Scope.objects.filter(event_game=event_game, external_id=match_id).first()
was_active = previous.is_active if previous else False
previous_user_ids = (
set(previous.assigned_users.values_list("id", flat=True)) if previous else set()
)
scope, created = Scope.objects.update_or_create( scope, created = Scope.objects.update_or_create(
event_game=event_game, event_game=event_game,
external_id=match_id, external_id=match_id,
@@ -241,7 +267,12 @@ def _sync_match(
if scope.is_active: if scope.is_active:
warnings.extend(_push_submission_link(client, match_id, scope)) warnings.extend(_push_submission_link(client, match_id, scope))
return created, warnings user_ids = {user.id for user in users}
change = None
if scope.is_active != was_active or user_ids != previous_user_ids:
change = (scope, user_ids | previous_user_ids)
return created, warnings, change
def _push_submission_link( def _push_submission_link(
+71 -32
View File
@@ -1,63 +1,102 @@
from django.db.models.signals import post_save from django.db import transaction
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver from django.dispatch import receiver
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
from submissions.models import Submission, SubmissionFile, ImageCrop from submissions.models import Submission, SubmissionFile, ImageCrop, Scope
from submissions.schemas import SubmissionOut, SubmissionFileOut, ImageCropOut from submissions.schemas import SubmissionOut, SubmissionFileOut, ImageCropOut
channel_layer = get_channel_layer() # Staff see (and OCR/crop) every player's submissions, so they get every update
STAFF_GROUP_NAME = "submissions_staff"
def _broadcast_submission_event(submission: Submission, message: dict):
channel_layer = get_channel_layer()
groups = [STAFF_GROUP_NAME]
if submission.user_id:
groups.append(f"submissions_user_{submission.user_id}")
for group_name in groups:
async_to_sync(channel_layer.group_send)(group_name, message)
@receiver(post_save, sender=Submission) @receiver(post_save, sender=Submission)
def broadcast_submission_update(sender, instance, created, **kwargs): def broadcast_submission_update(sender, instance, created, **kwargs):
if not instance.user: _broadcast_submission_event(
return instance,
user_group_name = f"submissions_user_{instance.user.id}"
submission_data = SubmissionOut.from_orm(instance).dict()
async_to_sync(channel_layer.group_send)(
user_group_name,
{ {
"type": "submission.updated", "type": "submission.updated",
"submission": submission_data, "submission": SubmissionOut.from_orm(instance).dict(),
}, },
) )
@receiver(post_save, sender=SubmissionFile) @receiver(post_save, sender=SubmissionFile)
def broadcast_submission_file_update(sender, instance, created, **kwargs): def broadcast_submission_file_update(sender, instance, created, **kwargs):
submission = instance.submission _broadcast_submission_event(
if not submission.user: instance.submission,
return
user_group_name = f"submissions_user_{submission.user.id}"
file_data = SubmissionFileOut.from_orm(instance).dict()
async_to_sync(channel_layer.group_send)(
user_group_name,
{ {
"type": "submission.file.updated", "type": "submission.file.updated",
"file": file_data, "file": SubmissionFileOut.from_orm(instance).dict(),
}, },
) )
@receiver(post_save, sender=ImageCrop) @receiver(post_save, sender=ImageCrop)
def broadcast_image_crop_update(sender, instance, created, **kwargs): def broadcast_image_crop_update(sender, instance, created, **kwargs):
submission = instance.submission_file.submission _broadcast_submission_event(
if not submission.user: instance.submission_file.submission,
return
user_group_name = f"submissions_user_{submission.user.id}"
crop_data = ImageCropOut.from_orm(instance).dict()
async_to_sync(channel_layer.group_send)(
user_group_name,
{ {
"type": "submission.crop.updated", "type": "submission.crop.updated",
"crop": crop_data, "crop": ImageCropOut.from_orm(instance).dict(),
}, },
) )
@receiver(post_delete, sender=ImageCrop)
def broadcast_image_crop_delete(sender, instance, **kwargs):
_broadcast_submission_event(
instance.submission_file.submission,
{
"type": "submission.crop.deleted",
"crop": {
"id": str(instance.id),
"submission_file_id": str(instance.submission_file_id),
},
},
)
def broadcast_scope_updates(changes: dict[Scope, set[int]], /):
"""Tell users their scope changed (closed/opened, (un)assigned), once the
current transaction commits, so the frontend can move them to the right
match without a refresh.
Args:
changes: Changed scopes, each with every user id that was or now is
assigned to it.
"""
def send():
channel_layer = get_channel_layer()
for scope, user_ids in changes.items():
assigned_ids = set(scope.assigned_users.values_list("id", flat=True))
for user_id in user_ids:
async_to_sync(channel_layer.group_send)(
f"submissions_user_{user_id}",
{
"type": "scope.updated",
"scope": {
"id": str(scope.id),
"is_active": scope.is_active,
"assigned": user_id in assigned_ids,
},
},
)
if changes:
# robust: a websocket hiccup only gets logged, it mustn't fail the
# (already committed) sync or admin save
transaction.on_commit(send, robust=True)
+177 -17
View File
@@ -1,5 +1,10 @@
from datetime import timedelta import logging
from dataclasses import dataclass
from datetime import datetime, timedelta
from uuid import uuid4
import django_rq
from django.core.cache import cache
from django.tasks import task from django.tasks import task
from django.utils import timezone from django.utils import timezone
from games.models import EventGame from games.models import EventGame
@@ -11,9 +16,117 @@ from submissions.services.aandt_sync import (
from PIL import Image from PIL import Image
import pytesseract import pytesseract
from rq.exceptions import InvalidJobOperation, NoSuchJobError
from rq.job import Job, JobStatus
# How often to repoll an EventGame's AAndT while it's still "in progress". logger = logging.getLogger(__name__)
# How often to repoll an EventGame's AAndT while it's "in progress", and while
# it isn't yet (registrations, preparation, ...) or the run failed.
SCOPE_SYNC_REPOLL_INTERVAL = timedelta(seconds=15) SCOPE_SYNC_REPOLL_INTERVAL = timedelta(seconds=15)
SCOPE_SYNC_IDLE_INTERVAL = timedelta(minutes=1)
# AAndT statuses after which there's nothing left to sync, the loop stops.
AANDT_FINISHED_STATUSES = {"9done", "11reviewed"}
def _scope_sync_guard_key(event_game_id: int) -> str:
return f"submissions:scope_sync:{event_game_id}"
def _scope_sync_job_key(event_game_id: int) -> str:
return f"submissions:scope_sync:{event_game_id}:job"
def _scope_sync_state_key(event_game_id: int) -> str:
return f"submissions:scope_sync:{event_game_id}:state"
def _scope_sync_guard_timeout() -> int:
return int(SCOPE_SYNC_IDLE_INTERVAL.total_seconds() * 3)
# Last run's outcome, shown to admins in the sync modal. Kept around a while
# after the loop stops, so they still see why it stopped.
SCOPE_SYNC_STATE_TIMEOUT = int(timedelta(days=1).total_seconds())
@dataclass
class ScopeSyncState:
running: bool
aandt_status: str | None = None
last_run_at: datetime | None = None
next_run_at: datetime | None = None
error: str | None = None
def get_scope_sync_state(event_game_id: int, /) -> ScopeSyncState:
state = cache.get(_scope_sync_state_key(event_game_id)) or {}
running = cache.get(_scope_sync_guard_key(event_game_id)) is not None
return ScopeSyncState(
running=running,
aandt_status=state.get("aandt_status"),
last_run_at=state.get("last_run_at"),
next_run_at=state.get("next_run_at") if running else None,
error=state.get("error"),
)
def _enqueue_scope_sync(event_game_id: int, loop_id: str, run_after: datetime | None):
task = (
sync_event_game_scopes_task.using(run_after=run_after)
if run_after
else sync_event_game_scopes_task
)
result = task.enqueue(event_game_id, loop_id=loop_id)
# Same id as the rq job (see django_tasks_rq), so stop_scope_sync can cancel it
cache.set(
_scope_sync_job_key(event_game_id),
result.id,
timeout=_scope_sync_guard_timeout(),
)
def schedule_scope_sync(event_game_id: int, /) -> bool:
"""Start the sync loop of an EventGame, unless one is already running.
Safe to call any number of times (admin button spam, multiple workers...).
The loop holds a per-EventGame guard with its id (same idea as
games.tasks.schedule_steam_refresh), so if the guard expires while an old
loop is just late, the old one stops at its next run."""
loop_id = uuid4().hex
if not cache.add(
_scope_sync_guard_key(event_game_id),
loop_id,
timeout=_scope_sync_guard_timeout(),
):
return False
_enqueue_scope_sync(event_game_id, loop_id, run_after=None)
return True
def stop_scope_sync(event_game_id: int, /) -> bool:
"""Stop the sync loop of an EventGame: cancels its scheduled rq job and
releases the guard (a run already in progress won't reschedule itself).
Returns:
Whether a loop was running.
"""
running = cache.get(_scope_sync_guard_key(event_game_id)) is not None
job_id = cache.get(_scope_sync_job_key(event_game_id))
cache.delete_many(
[_scope_sync_guard_key(event_game_id), _scope_sync_job_key(event_game_id)]
)
if job_id:
try:
job = Job.fetch(job_id, connection=django_rq.get_connection())
if job.get_status() in (JobStatus.SCHEDULED, JobStatus.QUEUED):
job.cancel()
except (NoSuchJobError, InvalidJobOperation):
pass
return running
def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool: def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool:
@@ -90,26 +203,73 @@ def sync_all_event_games_task():
) )
for event_game in event_games: for event_game in event_games:
sync_event_game_scopes_task.enqueue(event_game.id) schedule_scope_sync(event_game.id)
@task @task
def sync_event_game_scopes_task(event_game_id: int): def sync_event_game_scopes_task(event_game_id: int, loop_id: str | None = None):
"""Sync an EventGame's Scopes, repolling every minute while matches are in progress. """Sync an EventGame's Scopes, then re-enqueue itself until the AAndT is done.
Each run re-checks the AAndT's status (from the `/` endpoint). While it's Started once (see schedule_scope_sync), it keeps going on its own without
still "in progress" (`7inprog`), the task re-enqueues itself with a delay an external scheduler: every SCOPE_SYNC_REPOLL_INTERVAL while the AAndT is
instead of relying on an external scheduler; once the status moves on, it "in progress" (`7inprog`), every SCOPE_SYNC_IDLE_INTERVAL before that or
simply doesn't reschedule and the loop stops. when the run failed. Stops once the AAndT is done/reviewed, when the
EventGame (or its token) is gone, or when another loop took the guard.
Without loop_id it's a one-off run that leaves the loop alone.
""" """
event_game = EventGame.objects.filter(id=event_game_id).first() guard_key = _scope_sync_guard_key(event_game_id)
if loop_id is not None and cache.get(guard_key) != loop_id:
if not event_game: # Another loop owns the guard (or it expired/was stopped), stop this one
return return
result = sync_event_game_scopes(event_game) event_game = EventGame.objects.filter(id=event_game_id).first()
if result.status == AANDT_STATUS_IN_PROGRESS: if not event_game or not event_game.apibot_token:
sync_event_game_scopes_task.using( if loop_id is not None:
run_after=timezone.now() + SCOPE_SYNC_REPOLL_INTERVAL cache.delete(guard_key)
).enqueue(event_game_id) return
status = None
error = None
try:
status = sync_event_game_scopes(event_game).status
except Exception as exc:
if loop_id is None:
raise
# Retry later rather than letting the loop die (AAndT site down, bug, ...),
# it'd otherwise only restart once the guard expires and someone restarts it
logger.exception("Scope sync failed for EventGame %s", event_game_id)
error = str(exc) or exc.__class__.__name__
if loop_id is None:
return
now = timezone.now()
next_run_at = None
# Re-checked: the loop may have been stopped while this run was syncing
if status not in AANDT_FINISHED_STATUSES and cache.get(guard_key) == loop_id:
next_run_at = now + (
SCOPE_SYNC_REPOLL_INTERVAL
if status == AANDT_STATUS_IN_PROGRESS
else SCOPE_SYNC_IDLE_INTERVAL
)
cache.set(
_scope_sync_state_key(event_game_id),
{
"aandt_status": status,
"last_run_at": now,
"next_run_at": next_run_at,
"error": error,
},
timeout=SCOPE_SYNC_STATE_TIMEOUT,
)
if next_run_at is None:
if cache.get(guard_key) == loop_id:
cache.delete(guard_key)
return
cache.set(guard_key, loop_id, timeout=_scope_sync_guard_timeout())
_enqueue_scope_sync(event_game_id, loop_id, run_after=next_run_at)
+488 -1
View File
@@ -1 +1,488 @@
# Create your tests here. from contextlib import ExitStack
from unittest import mock
from django.core.cache import cache
from django.test import TestCase
from rq.job import JobStatus
from accounts.models import CustomUser
from events.models import Event
from games.models import EventGame, Game
from . import tasks
from .models import ImageCrop, Scope, Submission, SubmissionFile
from .services.aandt_client.models import (
AandtApiInfosResponse200,
AandtApiMatchesResponse200,
AandtApiMembersResponse200,
)
from .services.aandt_sync import ScopeSyncError, ScopeSyncResult, sync_event_game_scopes
class SyncScopesApiTests(TestCase):
def setUp(self):
self.event = Event.objects.create(name="Event", path="event")
self.game = Game.objects.create(name="Game", path="game")
self.event_game = EventGame.objects.create(event=self.event, game=self.game)
self.url = (
f"/api/events/{self.event.path}/submissions/{self.game.path}/sync-scopes"
)
self.superuser = CustomUser.objects.create_user(
username="admin", password="x", is_staff=True, is_superuser=True
)
patcher = mock.patch("submissions.api.schedule_scope_sync", return_value=True)
self.schedule = patcher.start()
self.addCleanup(patcher.stop)
def _post(self, body=None):
return self.client.post(self.url, body or {}, content_type="application/json")
def test_staff_is_forbidden(self):
staff = CustomUser.objects.create_user(
username="staff", password="x", is_staff=True
)
self.client.force_login(staff)
self.assertEqual(self._post().status_code, 403)
self.schedule.assert_not_called()
def test_asks_for_token_when_missing(self):
self.client.force_login(self.superuser)
response = self._post()
self.assertEqual(response.status_code, 400)
self.assertTrue(response.json()["token_required"])
self.schedule.assert_not_called()
def test_stores_token_and_starts_sync(self):
self.client.force_login(self.superuser)
response = self._post({"apibot_token": " abc "})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"started": True})
self.event_game.refresh_from_db()
self.assertEqual(self.event_game.apibot_token, "abc")
self.schedule.assert_called_once_with(self.event_game.id)
def test_status(self):
Scope.objects.create(event_game=self.event_game, name="M1")
Scope.objects.create(event_game=self.event_game, name="M2", is_active=False)
self.client.force_login(self.superuser)
with mock.patch(
"submissions.api.get_scope_sync_state",
return_value=tasks.ScopeSyncState(running=True, aandt_status="7inprog"),
):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["running"])
self.assertFalse(data["has_token"])
self.assertEqual(data["aandt_status"], "7inprog")
self.assertEqual((data["active_scope_count"], data["scope_count"]), (1, 2))
def test_stop(self):
self.client.force_login(self.superuser)
with mock.patch("submissions.api.stop_scope_sync", return_value=True) as stop:
response = self.client.delete(self.url)
self.assertEqual(response.json(), {"stopped": True})
stop.assert_called_once_with(self.event_game.id)
def test_status_and_stop_forbidden_for_staff(self):
staff = CustomUser.objects.create_user(
username="staff", password="x", is_staff=True
)
self.client.force_login(staff)
self.assertEqual(self.client.get(self.url).status_code, 403)
self.assertEqual(self.client.delete(self.url).status_code, 403)
def test_custom_path_game_is_404(self):
self.game.custom_path = True
self.game.save()
self.client.force_login(self.superuser)
self.assertEqual(self._post({"apibot_token": "abc"}).status_code, 404)
class SyncEventGameScopesTaskTests(TestCase):
def setUp(self):
event = Event.objects.create(name="Event", path="event")
game = Game.objects.create(name="Game", path="game")
self.event_game = EventGame.objects.create(
event=event, game=game, apibot_token="abc"
)
self.guard_key = tasks._scope_sync_guard_key(self.event_game.id)
self.loop_id = "loop"
self.job_key = tasks._scope_sync_job_key(self.event_game.id)
self.state_key = tasks._scope_sync_state_key(self.event_game.id)
cache.set(self.guard_key, self.loop_id)
self.addCleanup(
cache.delete_many, [self.guard_key, self.job_key, self.state_key]
)
@staticmethod
def _task_mock(task):
# enqueue() returns a TaskResult, whose id ends up in the cache
task.enqueue.return_value.id = "job-1"
task.using.return_value.enqueue.return_value.id = "job-2"
return task
def _run(self, status=None, exc=None, during_sync=None):
run = tasks.sync_event_game_scopes_task.func
def sync_side_effect(_event_game):
if during_sync:
during_sync()
if exc:
raise exc
return ScopeSyncResult(status=status)
with (
mock.patch.object(
tasks, "sync_event_game_scopes", side_effect=sync_side_effect
) as sync,
mock.patch.object(tasks, "sync_event_game_scopes_task") as task,
mock.patch.object(tasks, "logger"),
):
self._task_mock(task)
run(self.event_game.id, loop_id=self.loop_id)
return sync, task
def test_schedule_starts_only_one_loop(self):
cache.delete(self.guard_key)
with mock.patch.object(tasks, "sync_event_game_scopes_task") as task:
self._task_mock(task)
self.assertTrue(tasks.schedule_scope_sync(self.event_game.id))
self.assertFalse(tasks.schedule_scope_sync(self.event_game.id))
task.enqueue.assert_called_once()
def test_reschedules_before_and_during_aandt(self):
for status in ["3insc", "7inprog"]:
with self.subTest(status=status):
_, task = self._run(status=status)
task.using.assert_called_once()
def test_reschedules_after_api_failure(self):
_, task = self._run(exc=ScopeSyncError("down"))
task.using.assert_called_once()
def test_reschedules_after_unexpected_error(self):
_, task = self._run(exc=RuntimeError("boom"))
task.using.assert_called_once()
state = tasks.get_scope_sync_state(self.event_game.id)
self.assertEqual(state.error, "boom")
self.assertTrue(state.running)
def test_records_job_and_state(self):
self._run(status="7inprog")
self.assertEqual(cache.get(self.job_key), "job-2")
state = tasks.get_scope_sync_state(self.event_game.id)
self.assertEqual(state.aandt_status, "7inprog")
self.assertIsNotNone(state.next_run_at)
def test_stop_cancels_scheduled_job(self):
cache.set(self.job_key, "job-1")
job = mock.Mock(get_status=mock.Mock(return_value=JobStatus.SCHEDULED))
with (
mock.patch.object(tasks.Job, "fetch", return_value=job) as fetch,
mock.patch.object(tasks.django_rq, "get_connection"),
):
self.assertTrue(tasks.stop_scope_sync(self.event_game.id))
fetch.assert_called_once_with("job-1", connection=mock.ANY)
job.cancel.assert_called_once()
self.assertIsNone(cache.get(self.guard_key))
self.assertFalse(tasks.stop_scope_sync(self.event_game.id))
def test_stop_during_run_does_not_reschedule(self):
_, task = self._run(
status="7inprog", during_sync=lambda: cache.delete(self.guard_key)
)
task.using.assert_not_called()
self.assertIsNone(cache.get(self.guard_key))
def test_stops_once_aandt_is_done(self):
_, task = self._run(status="9done")
task.using.assert_not_called()
self.assertIsNone(cache.get(self.guard_key))
def test_stale_loop_stops(self):
cache.set(self.guard_key, "another-loop")
sync, task = self._run(status="7inprog")
sync.assert_not_called()
task.using.assert_not_called()
class SyncScopeBroadcastTests(TestCase):
def setUp(self):
event = Event.objects.create(name="Event", path="event")
game = Game.objects.create(name="Game", path="game")
self.event_game = EventGame.objects.create(
event=event, game=game, apibot_token="abc"
)
self.player = CustomUser.objects.create_user(
username="player", password="x", cas_user_id="42"
)
def _sync(self, matches):
api = "submissions.services.aandt_sync"
responses = {
"aandt_api_infos": AandtApiInfosResponse200.from_dict(
{"aandt": {"status": "7inprog"}}
),
"aandt_api_members": AandtApiMembersResponse200.from_dict(
{"members": [{"id": 1, "user": {"id": 42}}]}
),
"aandt_api_matches": AandtApiMatchesResponse200.from_dict(
{"matches": matches}
),
}
with ExitStack() as stack:
for module, parsed in responses.items():
stack.enter_context(
mock.patch(
f"{api}.{module}.sync_detailed",
return_value=mock.Mock(parsed=parsed),
)
)
stack.enter_context(
mock.patch(f"{api}._push_submission_link", return_value=[])
)
stack.enter_context(mock.patch("submissions.signals.get_channel_layer"))
send = stack.enter_context(mock.patch("submissions.signals.async_to_sync"))
with self.captureOnCommitCallbacks(execute=True):
sync_event_game_scopes(self.event_game)
return [c.args[1]["scope"] for c in send.return_value.call_args_list]
@staticmethod
def _match(match_id, status):
return {
"id": match_id,
"title": f"M{match_id}",
"status": status,
"players": {"1": 0},
}
def test_match_done_and_next_one_playing_notifies_player(self):
self._sync([self._match(1, "playing"), self._match(2, "waitPlayer")])
sent = self._sync([self._match(1, "done"), self._match(2, "playing")])
by_external_id = {
Scope.objects.get(id=scope["id"]).external_id: scope for scope in sent
}
self.assertFalse(by_external_id[1]["is_active"])
self.assertTrue(by_external_id[2]["is_active"])
self.assertTrue(by_external_id[2]["assigned"])
def test_unchanged_scopes_send_nothing(self):
self._sync([self._match(1, "playing")])
self.assertEqual(self._sync([self._match(1, "playing")]), [])
def test_removed_match_is_closed_and_notified(self):
self._sync([self._match(1, "playing")])
sent = self._sync([])
self.assertEqual(len(sent), 1)
self.assertFalse(sent[0]["is_active"])
class ScopeAdminBroadcastTests(TestCase):
def setUp(self):
event = Event.objects.create(name="Event", path="event")
game = Game.objects.create(name="Game", path="game")
event_game = EventGame.objects.create(event=event, game=game)
self.player = CustomUser.objects.create_user(username="player", password="x")
self.scope = Scope.objects.create(event_game=event_game, name="Match 1")
self.scope.assigned_users.set([self.player])
self.client.force_login(
CustomUser.objects.create_user(
username="admin", password="x", is_staff=True, is_superuser=True
)
)
self.form = {
"event_game": event_game.id,
"name": "Match 1",
"assigned_users": [self.player.id],
"is_active": "on",
}
def _sent(self, request):
with (
mock.patch("submissions.signals.get_channel_layer"),
mock.patch("submissions.signals.async_to_sync") as send,
self.captureOnCommitCallbacks(execute=True),
):
response = request()
self.assertEqual(response.status_code, 302)
return [c.args[1]["scope"] for c in send.return_value.call_args_list]
def _change(self, **changes):
url = f"/admin/submissions/scope/{self.scope.id}/change/"
data = {k: v for k, v in {**self.form, **changes}.items() if v is not None}
return self._sent(lambda: self.client.post(url, data))
def test_closing_scope_notifies_player(self):
sent = self._change(is_active=None)
self.assertEqual(
sent, [{"id": str(self.scope.id), "is_active": False, "assigned": True}]
)
def test_unassigning_player_notifies_them(self):
sent = self._change(assigned_users=[])
self.assertEqual(
sent, [{"id": str(self.scope.id), "is_active": True, "assigned": False}]
)
def test_unchanged_save_sends_nothing(self):
self.assertEqual(self._change(name="Renamed"), [])
def test_delete_notifies_player(self):
url = f"/admin/submissions/scope/{self.scope.id}/delete/"
sent = self._sent(lambda: self.client.post(url, {"post": "yes"}))
self.assertEqual(
sent, [{"id": str(self.scope.id), "is_active": False, "assigned": False}]
)
class SubmissionHomeViewTests(TestCase):
def setUp(self):
self.event = Event.objects.create(name="Event", path="event")
self.client.force_login(
CustomUser.objects.create_user(username="player", password="x")
)
def test_any_game_path_serves_the_app(self):
# Unknown game / no scope: the frontend shows its own not-found state
response = self.client.get(f"/{self.event.path}/submissions/unknown-game")
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "app.html")
def test_unknown_event_is_404(self):
response = self.client.get("/nope/submissions/unknown-game")
self.assertEqual(response.status_code, 404)
class ListUserSubmissionsApiTests(TestCase):
def setUp(self):
self.event = Event.objects.create(name="Event", path="event")
game = Game.objects.create(name="Game", path="game")
event_game = EventGame.objects.create(event=self.event, game=game)
self.scope = Scope.objects.create(event_game=event_game, name="M1")
other_scope = Scope.objects.create(event_game=event_game, name="M2")
self.player = CustomUser.objects.create_user(username="player", password="x")
other = CustomUser.objects.create_user(username="other", password="x")
self.mine = Submission.objects.create(
event=self.event, scope=self.scope, user=self.player
)
self.theirs = Submission.objects.create(
event=self.event, scope=self.scope, user=other
)
Submission.objects.create(event=self.event, scope=other_scope, user=other)
self.url = f"/api/events/{self.event.path}/submissions/submission/by/user"
def _list(self):
response = self.client.get(self.url, {"scope_id": self.scope.id})
self.assertEqual(response.status_code, 200)
return response.json()
def test_player_sees_only_own_submissions(self):
self.client.force_login(self.player)
data = self._list()
self.assertEqual([s["id"] for s in data], [str(self.mine.id)])
self.assertEqual(data[0]["username"], "player")
def test_staff_sees_all_submissions_of_scope(self):
staff = CustomUser.objects.create_user(
username="staff", password="x", is_staff=True
)
self.client.force_login(staff)
data = self._list()
self.assertEqual(
{s["id"] for s in data}, {str(self.mine.id), str(self.theirs.id)}
)
class SubmissionBroadcastTests(TestCase):
def setUp(self):
event = Event.objects.create(name="Event", path="event")
game = Game.objects.create(name="Game", path="game")
event_game = EventGame.objects.create(event=event, game=game)
scope = Scope.objects.create(event_game=event_game, name="M1")
self.player = CustomUser.objects.create_user(username="player", password="x")
submission = Submission.objects.create(
event=event, scope=scope, user=self.player
)
# bulk_create: skips the file broadcast, which would sign an S3 url
[submission_file] = SubmissionFile.objects.bulk_create(
[
SubmissionFile(
submission=submission,
file="shot.png",
original_filename="shot.png",
file_size=1,
content_type="image/png",
)
]
)
self.crop = ImageCrop.objects.create(
submission_file=submission_file, x1=0, y1=0, x2=1, y2=1
)
def test_crop_update_reaches_owner_and_staff(self):
with mock.patch("submissions.signals.get_channel_layer") as get_layer:
get_layer.return_value.group_send = mock.AsyncMock()
self.crop.save()
groups = [c.args[0] for c in get_layer.return_value.group_send.call_args_list]
self.assertEqual(
groups, ["submissions_staff", f"submissions_user_{self.player.id}"]
)
def test_crop_delete_reaches_owner_and_staff(self):
crop_id = str(self.crop.id)
with mock.patch("submissions.signals.get_channel_layer") as get_layer:
get_layer.return_value.group_send = mock.AsyncMock()
self.crop.delete()
calls = get_layer.return_value.group_send.call_args_list
self.assertEqual(
[c.args[0] for c in calls],
["submissions_staff", f"submissions_user_{self.player.id}"],
)
self.assertEqual(calls[0].args[1]["type"], "submission.crop.deleted")
self.assertEqual(calls[0].args[1]["crop"]["id"], crop_id)
+4 -31
View File
@@ -1,44 +1,17 @@
from uuid import UUID from uuid import UUID
from django.http import HttpRequest, Http404 from django.http import HttpRequest, Http404
from django.shortcuts import redirect, render, get_object_or_404 from django.shortcuts import 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 .models import Scope from .models import Scope
@login_required @login_required
def submission_home(request: HttpRequest, event_path: str, game_path: str): def submission_home(request: HttpRequest, event_path: str, game_path: str):
event = get_object_or_404(Event, path=event_path) # Unknown game, no scope assigned, auto-entering the only scope: all
game = get_object_or_404(Game, path=game_path) # handled by the frontend (SubmissionsSelect.vue)
get_object_or_404(Event, path=event_path)
scopes = (
Scope.objects.filter(
event_game__event=event,
event_game__game=game,
is_active=True,
)
if request.user.is_superuser
else Scope.objects.filter(
event_game__event=event,
event_game__game=game,
assigned_users=request.user,
is_active=True,
)
)
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, "app.html") return render(request, "app.html")