Compare commits
10
Commits
b403311ce1
...
60d730a683
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60d730a683
|
||
|
|
1daf2757b8
|
||
|
|
a110575e0a
|
||
|
|
bfaef2e402
|
||
|
|
3d040dd377
|
||
|
|
a6ea3719b9
|
||
|
|
13c09adff3
|
||
|
|
de5ee8ae7a
|
||
|
|
8daa91d6c5
|
||
|
|
acd08e216a
|
@@ -5,7 +5,7 @@ from django_object_actions import (
|
||||
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 .tasks import refresh_steam_games_task
|
||||
@@ -51,8 +51,8 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
list_display = ["game", "event", "enabled", "has_apibot_token"]
|
||||
list_filter = ["event", "enabled"]
|
||||
search_fields = ["game__name", "event__name"]
|
||||
actions = ["sync_scopes"]
|
||||
change_actions = ["sync_scopes"]
|
||||
actions = ["sync_scopes", "stop_scope_sync"]
|
||||
change_actions = ["sync_scopes", "stop_scope_sync"]
|
||||
|
||||
@admin.display(description="AAndT token", boolean=True)
|
||||
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="")
|
||||
skipped = queryset.count() - tokened.count()
|
||||
|
||||
for event_game in tokened:
|
||||
sync_event_game_scopes_task.enqueue(event_game.id)
|
||||
started = sum(schedule_scope_sync(event_game.id) for event_game in tokened)
|
||||
|
||||
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:
|
||||
self.message_user(
|
||||
@@ -79,3 +80,13 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
f"Skipped {skipped} EventGame(s) with no AAndT token configured.",
|
||||
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).")
|
||||
|
||||
@@ -3,4 +3,5 @@ import { defineConfig } from '@hey-api/openapi-ts';
|
||||
export default defineConfig({
|
||||
input: `http://localhost:7777/api/openapi.json`,
|
||||
output: 'src/api/',
|
||||
plugins: ['@hey-api/typescript', '@hey-api/sdk', '@tanstack/vue-query'],
|
||||
});
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"dayjs": "^1.11.20",
|
||||
|
||||
Generated
+74
@@ -11,6 +11,15 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
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))
|
||||
'@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':
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(vue@3.5.22(typescript@5.9.3))
|
||||
@@ -515,10 +524,32 @@ packages:
|
||||
peerDependencies:
|
||||
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':
|
||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||
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':
|
||||
resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -961,6 +992,9 @@ packages:
|
||||
regenerator-runtime@0.13.11:
|
||||
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:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
@@ -1072,6 +1106,17 @@ packages:
|
||||
vscode-uri@3.1.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-yvArvvo3ErLKFNlQrZUGmRkxL/ctM5xFYtE9FxTKiE/PalWP1bnTopBgbSRGLluYEyYSNl1Pt8l2J7zVavbXww==}
|
||||
engines: {node: '>= 22'}
|
||||
@@ -1445,8 +1490,31 @@ snapshots:
|
||||
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)
|
||||
|
||||
'@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/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))':
|
||||
dependencies:
|
||||
'@tanstack/table-core': 8.21.3
|
||||
@@ -1863,6 +1931,8 @@ snapshots:
|
||||
|
||||
regenerator-runtime@0.13.11: {}
|
||||
|
||||
remove-accents@0.5.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
@@ -1962,6 +2032,10 @@ snapshots:
|
||||
|
||||
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)):
|
||||
dependencies:
|
||||
'@intlify/core-base': 11.4.12
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue";
|
||||
import { computed } from "vue";
|
||||
import { RouterView, useRoute } from "vue-router";
|
||||
import AppHeader from "@/layout/AppHeader.vue";
|
||||
import AppFooter from "@/layout/AppFooter.vue";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
|
||||
const userStore = useUserStore();
|
||||
const route = useRoute();
|
||||
// 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
|
||||
// stream overlay or OBS browser source gives it.
|
||||
const isEmbed = computed(() => route.meta.embed === true);
|
||||
|
||||
onMounted(() => {
|
||||
userStore.loadUserData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useTitle } from "@vueuse/core";
|
||||
import { useClockSocket } from "@/composables/useClockSocket";
|
||||
import { useLiveBadge } from "@/composables/useLiveBadge";
|
||||
|
||||
interface Props {
|
||||
eventPath: string;
|
||||
@@ -25,6 +26,7 @@ const {
|
||||
cooldownDurationSeconds,
|
||||
press,
|
||||
} = useClockSocket(props.eventPath);
|
||||
useLiveBadge("clock", connected);
|
||||
|
||||
const remainingDisplay = computed(() => {
|
||||
const totalSeconds = Math.floor(remainingMs.value / 1000);
|
||||
@@ -38,10 +40,6 @@ const onCooldown = computed(() => cooldownRemainingMs.value > 0);
|
||||
|
||||
<template>
|
||||
<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">
|
||||
{{ remainingDisplay }}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { ref, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
@@ -16,8 +17,7 @@ const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
useTitle(computed(() => t("home.title")));
|
||||
|
||||
const games = ref<GamesApiListEventGamesResponse | undefined>();
|
||||
const loading = ref(true);
|
||||
const { data: games, isPending: loading } = useQuery(eventGamesQuery(props.eventPath));
|
||||
const hasSteamGames = computed(() => games.value?.some((game) => game.steam_app_id) ?? false);
|
||||
const imageErrors = ref<Set<number>>(new Set());
|
||||
|
||||
@@ -40,15 +40,6 @@ const goToGame = (game: GamesApiListEventGamesResponse[number]) => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -9,10 +9,11 @@ import Results from "@/components/Results.vue";
|
||||
import Winners from "@/components/Winners.vue";
|
||||
import PuzzleResults from "@/components/PuzzleResults.vue";
|
||||
import TopUsersLeaderboard from "@/components/TopUsersLeaderboard.vue";
|
||||
import { polylanSubmitterApiGetUserInfo, opusMagnumApiGetCollection } from "@/api";
|
||||
import { opusMagnumApiGetCollection } from "@/api";
|
||||
import { apiService, errorHelpers } from "@/services/apiService";
|
||||
import { usePuzzlesStore } from "@/stores/puzzles";
|
||||
import { useSubmissionsStore } from "@/stores/submissions";
|
||||
import { queryClient, userQuery } from "@/queries";
|
||||
import { provideEventPath } from "@/composables/useEventPath";
|
||||
import type { PuzzleResponse, UserInfo, SteamCollection } from "@/types";
|
||||
import { useCountdown } from "@vueuse/core";
|
||||
@@ -74,13 +75,7 @@ async function initialize() {
|
||||
|
||||
// Load user info
|
||||
console.log("Loading user info...");
|
||||
const userResponse = await polylanSubmitterApiGetUserInfo();
|
||||
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);
|
||||
}
|
||||
userInfo.value = (await queryClient.fetchQuery(userQuery()).catch(() => null)) as UserInfo | null;
|
||||
|
||||
// Load collection data
|
||||
console.log("Loading collection...");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useTitle } from "@vueuse/core";
|
||||
import { usePlaceSocket } from "@/composables/usePlaceSocket";
|
||||
import { useLiveBadge } from "@/composables/useLiveBadge";
|
||||
import { PALETTE } from "@/palette";
|
||||
|
||||
interface Props {
|
||||
@@ -47,6 +48,7 @@ const {
|
||||
gridHeight,
|
||||
place,
|
||||
} = usePlaceSocket(props.eventPath, drawPixel);
|
||||
useLiveBadge("place", connected);
|
||||
|
||||
const overlayWidth = computed(() => gridWidth.value * PX_PER_CELL);
|
||||
const overlayHeight = computed(() => gridHeight.value * PX_PER_CELL);
|
||||
@@ -131,10 +133,6 @@ function handleClick(event: MouseEvent) {
|
||||
|
||||
<template>
|
||||
<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}` }">
|
||||
<canvas ref="pixelCanvasRef" :width="gridWidth" :height="gridHeight"
|
||||
class="absolute inset-0 w-full h-full border border-base-300 rounded-box shadow-lg" :style="{
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<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 { storeToRefs } from "pinia";
|
||||
import { useI18n } from "vue-i18n";
|
||||
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 { 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 { provideEventPath } from "@/composables/useEventPath";
|
||||
|
||||
@@ -31,8 +33,8 @@ const gameSteamId = ref("");
|
||||
|
||||
useTitle(computed(() => t("submissions.pageTitle", { game: gameName.value, scope: scopeName.value })));
|
||||
|
||||
const userStore = useUserStore();
|
||||
const { userInfo } = storeToRefs(userStore);
|
||||
const queryClient = useQueryClient();
|
||||
const userInfo = useUser();
|
||||
|
||||
const uploadedFiles = ref<File[]>([]);
|
||||
const isDragover = ref(false);
|
||||
@@ -41,11 +43,12 @@ const notes = ref("");
|
||||
const selectedFile = ref<File | null>(null);
|
||||
const filePreviewUrl = ref<string>("");
|
||||
const isModalOpen = ref(false);
|
||||
const userSubmissions = ref<any[]>([]);
|
||||
const userSubmissions = ref<SubmissionOut[]>([]);
|
||||
const isLoadingSubmissions = ref(false);
|
||||
const selectedSubmissionFile = ref<any>(null);
|
||||
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 cropStartX = ref(0);
|
||||
const cropStartY = ref(0);
|
||||
@@ -139,23 +142,23 @@ const closeSubmissionFileModal = () => {
|
||||
selectedSubmissionFile.value = null;
|
||||
};
|
||||
|
||||
const copyOCRText = async () => {
|
||||
if (selectedSubmissionFile.value?.ocr_raw_data) {
|
||||
const copyOCRText = async (text: string | undefined, key: string) => {
|
||||
if (!text) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(selectedSubmissionFile.value.ocr_raw_data);
|
||||
copyFeedback.value = t("common.copied");
|
||||
setTimeout(() => {
|
||||
copyFeedback.value = "";
|
||||
}, 2000);
|
||||
await navigator.clipboard.writeText(text);
|
||||
copyFeedback.value = { key, message: t("common.copied") };
|
||||
} catch (err) {
|
||||
copyFeedback.value = t("submissions.copyFailed");
|
||||
copyFeedback.value = { key, message: t("submissions.copyFailed") };
|
||||
}
|
||||
setTimeout(() => {
|
||||
copyFeedback.value = "";
|
||||
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 rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
@@ -244,6 +247,12 @@ const redrawCropCanvas = () => {
|
||||
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 () => {
|
||||
if (!currentCrop.value || !selectedSubmissionFile.value) return;
|
||||
|
||||
@@ -260,11 +269,13 @@ 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) {
|
||||
selectedSubmissionFile.value.crops = selectedSubmissionFile.value.crops || [];
|
||||
if (!selectedSubmissionFile.value.crops.some((c: any) => c.id === newCrop.id)) {
|
||||
selectedSubmissionFile.value.crops.push(newCrop);
|
||||
}
|
||||
}
|
||||
|
||||
currentCrop.value = null;
|
||||
cropLabel.value = "";
|
||||
@@ -404,6 +415,26 @@ const removeFile = (index: number) => {
|
||||
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 () => {
|
||||
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 handlePasteOnUploadArea = (event: ClipboardEvent) => {
|
||||
@@ -503,17 +544,16 @@ const handlePasteOnUploadArea = (event: ClipboardEvent) => {
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
userStore.loadUserData();
|
||||
loadUserSubmissions();
|
||||
|
||||
const [gamesResponse, scopesResponse] = await Promise.all([
|
||||
gamesApiListEventGames({ path: { event_path: props.eventPath } }),
|
||||
const [games, scopesResponse] = await Promise.all([
|
||||
queryClient.ensureQueryData(eventGamesQuery(props.eventPath)),
|
||||
submissionsApiListAvailableScopes({
|
||||
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) {
|
||||
gameName.value = game.name;
|
||||
gameImage.value = game.image ?? "";
|
||||
@@ -541,6 +581,7 @@ onMounted(async () => {
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('paste', handlePasteOnUploadArea);
|
||||
revokeThumbnails();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -548,14 +589,6 @@ onUnmounted(() => {
|
||||
<div class="flex-1 bg-base-200">
|
||||
<!-- Main Content -->
|
||||
<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">
|
||||
<!-- Right Column: Upload -->
|
||||
<div class="lg:col-span-1 flex flex-col gap-8 order-1 lg:order-2">
|
||||
@@ -617,17 +650,24 @@ onUnmounted(() => {
|
||||
<!-- Uploaded Files List -->
|
||||
<div v-if="uploadedFiles.length > 0" class="mb-6">
|
||||
<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"
|
||||
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)">
|
||||
<div class="aspect-video bg-base-300 flex items-center justify-center">
|
||||
<img v-if="thumbnails[index]" :src="thumbnails[index]" :alt="file.name"
|
||||
class="w-full h-full object-contain" />
|
||||
<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="font-medium truncate group-hover:text-primary">{{ file.name }}</p>
|
||||
<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>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
@@ -727,7 +767,7 @@ onUnmounted(() => {
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-6">
|
||||
<i class="mdi mdi-history text-primary mr-2"></i>
|
||||
{{ t("submissions.yourSubmissions") }}
|
||||
{{ userInfo.isStaff ? t("submissions.allSubmissions") : t("submissions.yourSubmissions") }}
|
||||
</h2>
|
||||
|
||||
<div v-if="isLoadingSubmissions" class="flex justify-center py-8">
|
||||
@@ -744,6 +784,7 @@ onUnmounted(() => {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("common.date") }}</th>
|
||||
<th v-if="userInfo.isStaff">{{ t("common.user") }}</th>
|
||||
<th>{{ t("common.files") }}</th>
|
||||
<th>{{ t("submissions.fileStatus") }}</th>
|
||||
<th>{{ t("submissions.ocrStatus") }}</th>
|
||||
@@ -753,6 +794,7 @@ onUnmounted(() => {
|
||||
<tbody>
|
||||
<tr v-for="submission in userSubmissions" :key="submission.id" class="hover">
|
||||
<td class="text-sm">{{ formatDate(submission.created_at) }}</td>
|
||||
<td v-if="userInfo.isStaff" class="text-sm">{{ submission.username ?? "-" }}</td>
|
||||
<td class="text-sm">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div v-for="file in submission.files" :key="file.id" class="flex items-center gap-2">
|
||||
@@ -905,9 +947,10 @@ onUnmounted(() => {
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<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>
|
||||
{{ copyFeedback || t("common.copy") }}
|
||||
{{ copyLabel("file") }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
@@ -944,8 +987,15 @@ onUnmounted(() => {
|
||||
<div v-for="crop in selectedSubmissionFile.crops" :key="crop.id" class="space-y-2">
|
||||
<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>
|
||||
<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 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">
|
||||
{{ crop.ocr_raw_data }}
|
||||
|
||||
@@ -3,7 +3,12 @@ import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
@@ -14,13 +19,18 @@ interface Props {
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useI18n();
|
||||
const userInfo = useUser();
|
||||
useTitle(computed(() => t("submissionsSelect.title")));
|
||||
|
||||
const gameName = ref("");
|
||||
const scopes = ref<ScopeOut[]>([]);
|
||||
const loading = ref(true);
|
||||
const notAssigned = ref(false);
|
||||
const notFound = ref(false);
|
||||
|
||||
const isSyncModalOpen = ref(false);
|
||||
|
||||
const scopeRoute = (scope: ScopeOut) => ({
|
||||
name: "submissions",
|
||||
@@ -31,40 +41,67 @@ const goToScope = (scope: ScopeOut) => {
|
||||
router.push(scopeRoute(scope));
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
const [gamesResponse, scopesResponse] = await Promise.all([
|
||||
gamesApiListEventGames({ path: { event_path: props.eventPath } }),
|
||||
const loadScopes = async () => {
|
||||
const [games, scopesResponse, user] = await Promise.all([
|
||||
queryClient.ensureQueryData(eventGamesQuery(props.eventPath)),
|
||||
submissionsApiListAvailableScopes({
|
||||
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 ?? [];
|
||||
|
||||
// Mirrors the server-side behavior this replaced: skip the picker
|
||||
// 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]));
|
||||
return;
|
||||
}
|
||||
if (availableScopes.length === 0) {
|
||||
notAssigned.value = true;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
scopes.value = availableScopes;
|
||||
notAssigned.value = availableScopes.length === 0;
|
||||
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>
|
||||
|
||||
<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">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</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">
|
||||
<i class="mdi mdi-account-alert-outline text-6xl text-warning"></i>
|
||||
<h2 class="text-2xl font-semibold mt-4">{{ t("submissionsSelect.notAssignedTitle") }}</h2>
|
||||
@@ -86,5 +123,8 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScopeSyncModal v-if="userInfo.isSuperuser && !notFound" v-model:open="isSyncModalOpen" :event-path="eventPath"
|
||||
:game-path="gamePath" :game-name="gameName" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
||||
|
||||
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
|
||||
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> & {
|
||||
/**
|
||||
@@ -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 });
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
@@ -423,6 +423,10 @@ export type SubmissionOut = {
|
||||
* Scope Id
|
||||
*/
|
||||
scope_id: string;
|
||||
/**
|
||||
* Username
|
||||
*/
|
||||
username?: string | null;
|
||||
/**
|
||||
* Notes
|
||||
*/
|
||||
@@ -1000,6 +1004,74 @@ export type ScopeOut = {
|
||||
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
|
||||
*/
|
||||
@@ -1008,6 +1080,10 @@ export type ImageCropOut = {
|
||||
* Id
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Submission File Id
|
||||
*/
|
||||
submission_file_id: string;
|
||||
/**
|
||||
* X1
|
||||
*/
|
||||
@@ -1873,6 +1949,128 @@ export type 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 = {
|
||||
/**
|
||||
* MultiPartBodyParams
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
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";
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -142,8 +143,7 @@ const clearCache = async () => {
|
||||
|
||||
const loadUserData = async () => {
|
||||
try {
|
||||
const response = await polylanSubmitterApiGetUserInfo();
|
||||
const user = response.data;
|
||||
const user = await queryClient.fetchQuery(userQuery());
|
||||
if (user?.is_authenticated) {
|
||||
userInfo.value.username = user.username ?? "";
|
||||
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 { placeApiCanvas } from '@/api'
|
||||
|
||||
@@ -108,6 +108,15 @@ export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?:
|
||||
|
||||
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() {
|
||||
const response = await placeApiCanvas({ path: { event_path: eventPath } })
|
||||
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 { useLiveBadge } from '@/composables/useLiveBadge'
|
||||
|
||||
interface SubmissionUpdate {
|
||||
id: string
|
||||
scope_id?: string
|
||||
username?: string | null
|
||||
notes?: string
|
||||
files?: SubmissionFileUpdate[]
|
||||
created_at: string
|
||||
@@ -23,6 +25,7 @@ interface SubmissionFileUpdate {
|
||||
|
||||
interface ImageCropUpdate {
|
||||
id: string
|
||||
submission_file_id: string
|
||||
x1: number
|
||||
y1: number
|
||||
x2: number
|
||||
@@ -33,13 +36,36 @@ interface ImageCropUpdate {
|
||||
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 reconnectAttempts = ref(0)
|
||||
const maxReconnectAttempts = 10
|
||||
const reconnectDelay = ref(3000)
|
||||
|
||||
const wsUrl = computed(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
@@ -53,14 +79,11 @@ export function useSubmissionsWebSocket(userSubmissions: any) {
|
||||
} = useWebSocket(wsUrl.value, {
|
||||
onConnected: () => {
|
||||
isConnected.value = true
|
||||
reconnectAttempts.value = 0
|
||||
reconnectDelay.value = 3000
|
||||
console.log('✓ Connected to submissions WebSocket')
|
||||
},
|
||||
onDisconnected: () => {
|
||||
isConnected.value = false
|
||||
console.log('✗ Disconnected from submissions WebSocket, attempting to reconnect...')
|
||||
attemptReconnect()
|
||||
console.log('✗ Disconnected from submissions WebSocket')
|
||||
},
|
||||
onError: (event) => {
|
||||
console.error('WebSocket error:', event)
|
||||
@@ -70,7 +93,13 @@ export function useSubmissionsWebSocket(userSubmissions: any) {
|
||||
const messageData = event.data as string
|
||||
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)
|
||||
} else if ('original_filename' in data) {
|
||||
handleFileUpdate(data as SubmissionFileUpdate)
|
||||
@@ -82,25 +111,21 @@ export function useSubmissionsWebSocket(userSubmissions: any) {
|
||||
}
|
||||
},
|
||||
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 = () => {
|
||||
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)
|
||||
}
|
||||
useLiveBadge('submissions', isConnected)
|
||||
|
||||
const handleSubmissionUpdate = (submission: SubmissionUpdate) => {
|
||||
if (scopeId && submission.scope_id !== scopeId) return
|
||||
|
||||
const existingIndex = userSubmissions.value.findIndex((s: any) => s.id === submission.id)
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
@@ -127,6 +152,8 @@ export function useSubmissionsWebSocket(userSubmissions: any) {
|
||||
}
|
||||
|
||||
const handleCropUpdate = (crop: ImageCropUpdate) => {
|
||||
let found = false
|
||||
|
||||
userSubmissions.value.forEach((submission: any) => {
|
||||
if (!submission.files) return
|
||||
|
||||
@@ -137,15 +164,38 @@ export function useSubmissionsWebSocket(userSubmissions: any) {
|
||||
if (cropIndex >= 0) {
|
||||
// Merge into existing object to preserve references
|
||||
Object.assign(file.crops[cropIndex], crop)
|
||||
found = true
|
||||
console.log('Updated crop:', crop.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (!found) handleNewCrop(crop)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
close()
|
||||
const handleCropDelete = (crop: DeletedCrop) => {
|
||||
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 {
|
||||
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,
|
||||
}));
|
||||
};
|
||||
@@ -3,16 +3,27 @@ import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { useUser } from "@/composables/useUser";
|
||||
import { usePageHeaderStore } from "@/stores/pageHeader";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const userStore = useUserStore();
|
||||
const { userInfo } = storeToRefs(userStore);
|
||||
const { t, tm, rt } = useI18n();
|
||||
const userInfo = useUser();
|
||||
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(() => {
|
||||
if (dynamicTitle.value) return dynamicTitle.value;
|
||||
@@ -30,13 +41,39 @@ const goBack = () => {
|
||||
|
||||
<template>
|
||||
<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 -->
|
||||
<div class="container min-w-3/4 mx-auto w-full grid grid-cols-[1fr_auto_1fr] items-center gap-4">
|
||||
<div class="flex items-center gap-4 min-w-0">
|
||||
<button v-if="route.meta.getBackRoute" @click="goBack" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-arrow-left"></i>
|
||||
{{ t("common.back") }}
|
||||
</button>
|
||||
<h1 class="text-xl font-bold">{{ title }}</h1>
|
||||
<div class="flex-1"></div>
|
||||
<h1 class="text-xl font-bold truncate">{{ title }}</h1>
|
||||
</div>
|
||||
<div>
|
||||
<!-- 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 class="flex items-center justify-end gap-4">
|
||||
<div v-if="userInfo.isAuthenticated" class="flex items-center gap-2">
|
||||
<div class="text-sm">
|
||||
<span class="font-medium">{{ userInfo.username }}</span>
|
||||
@@ -48,4 +85,5 @@ const goBack = () => {
|
||||
<a v-if="userInfo.isStaff" href="/admin" class="btn btn-xs btn-warning">{{ t("common.adminPanel") }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"puzzle": "Puzzle",
|
||||
"files": "Files",
|
||||
"date": "Date",
|
||||
"user": "User",
|
||||
"notes": "Notes",
|
||||
"actions": "Actions",
|
||||
"noRankYet": "No rank yet",
|
||||
@@ -80,8 +81,6 @@
|
||||
"clock": {
|
||||
"pageTitle": "Beat the Clock",
|
||||
"header": "Beat the Clock",
|
||||
"connected": "connected",
|
||||
"reconnecting": "reconnecting…",
|
||||
"press": "PRESS",
|
||||
"nextPlayer": "▲ next player",
|
||||
"chasingYou": "▼ chasing you",
|
||||
@@ -90,8 +89,6 @@
|
||||
"place": {
|
||||
"pageTitle": "Place",
|
||||
"header": "Place",
|
||||
"connected": "connected",
|
||||
"reconnecting": "reconnecting…",
|
||||
"showGrid": "Show grid",
|
||||
"hideGrid": "Hide grid"
|
||||
},
|
||||
@@ -172,6 +169,7 @@
|
||||
}
|
||||
},
|
||||
"yourSubmissions": "Your Submissions",
|
||||
"allSubmissions": "All Submissions",
|
||||
"noSubmissionsHint": "Upload your first submission above to get started",
|
||||
"fileStatus": "File Status",
|
||||
"ocrStatus": "OCR Status",
|
||||
@@ -209,7 +207,118 @@
|
||||
"title": "Select your match",
|
||||
"description": "You're assigned to multiple matches for {game}. Pick which one you want to submit for.",
|
||||
"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": {
|
||||
"notFound": {
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { createApp } from "vue";
|
||||
import { VueQueryPlugin } from "@tanstack/vue-query";
|
||||
import App from "@/App.vue";
|
||||
import { router } from "@/router";
|
||||
import { pinia } from "@/stores";
|
||||
import { i18n } from "@/i18n";
|
||||
import { onQueryCacheRestored, queryClient, restoreQueryCache } from "@/queries";
|
||||
import "@/style.css";
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(pinia);
|
||||
app.use(VueQueryPlugin, {
|
||||
queryClient,
|
||||
clientPersister: restoreQueryCache,
|
||||
clientPersisterOnSuccess: onQueryCacheRestored,
|
||||
});
|
||||
app.use(router);
|
||||
app.use(i18n);
|
||||
app.mount("#app");
|
||||
|
||||
@@ -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() });
|
||||
@@ -1,7 +1,8 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { polylanSubmitterApiGetUserInfo, marketApiListMarkets, marketApiListUserBets, marketApiGetUserPoints } from '@/api'
|
||||
import { marketApiListMarkets, marketApiListUserBets, marketApiGetUserPoints } from '@/api'
|
||||
import type { Market } from '@/types'
|
||||
import { queryClient, userQuery } from '@/queries'
|
||||
import type { UserInfoOut, UserBetSchema } from '@/api/types.gen'
|
||||
|
||||
export const useMarketStore = defineStore('market', () => {
|
||||
@@ -15,15 +16,7 @@ export const useMarketStore = defineStore('market', () => {
|
||||
|
||||
// Actions
|
||||
const loadUserInfo = async () => {
|
||||
try {
|
||||
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)
|
||||
}
|
||||
userInfo.value = await queryClient.fetchQuery(userQuery()).catch(() => undefined)
|
||||
}
|
||||
|
||||
const loadMarkets = async (eventPath: string) => {
|
||||
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
noitaApiGetResults,
|
||||
noitaApiGetLeaderboard,
|
||||
noitaApiSubmitLogFile,
|
||||
polylanSubmitterApiGetUserInfo,
|
||||
polylanSubmitterApiClearCache,
|
||||
} from '@/api'
|
||||
import type { ObjectivResultOut, LeaderboardEntryOut } from '@/api/types.gen'
|
||||
import { queryClient, userQuery } from '@/queries'
|
||||
|
||||
export type Objective = ObjectivResultOut
|
||||
|
||||
@@ -80,14 +80,10 @@ export const useNoitaStore = defineStore('noita', () => {
|
||||
}
|
||||
|
||||
const loadUserData = async (eventPath: string) => {
|
||||
try {
|
||||
const response = await polylanSubmitterApiGetUserInfo()
|
||||
if (response.data?.is_authenticated) {
|
||||
userInfo.value.username = response.data.username ?? ''
|
||||
userInfo.value.isStaff = response.data.is_staff || false
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching user info:', err)
|
||||
const user = await queryClient.fetchQuery(userQuery()).catch(() => undefined)
|
||||
if (user?.is_authenticated) {
|
||||
userInfo.value.username = user.username ?? ''
|
||||
userInfo.value.isStaff = user.is_staff || false
|
||||
}
|
||||
|
||||
await Promise.all([fetchUserResults(eventPath), fetchLeaderboard(eventPath)])
|
||||
|
||||
@@ -2,6 +2,9 @@ import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
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
|
||||
// has fetched (e.g. "Noita Submissions — Semifinal", assembled from an API
|
||||
// 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
|
||||
// fetched data (e.g. skip the scope picker when it would auto-redirect).
|
||||
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) => {
|
||||
title.value = value;
|
||||
@@ -20,10 +27,17 @@ export const usePageHeaderStore = defineStore("pageHeader", () => {
|
||||
backRoute.value = value;
|
||||
};
|
||||
|
||||
const setLive = (value: boolean, kind: LiveKind) => {
|
||||
live.value = value;
|
||||
liveKind.value = kind;
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
title.value = "";
|
||||
backRoute.value = null;
|
||||
live.value = null;
|
||||
liveKind.value = null;
|
||||
};
|
||||
|
||||
return { title, backRoute, setTitle, setBackRoute, clear };
|
||||
return { title, backRoute, live, liveKind, setTitle, setBackRoute, setLive, clear };
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import reverse
|
||||
from .models import Submission, SubmissionFile, ImageCrop, Scope
|
||||
from .signals import broadcast_scope_updates
|
||||
|
||||
|
||||
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)
|
||||
class SubmissionAdmin(admin.ModelAdmin):
|
||||
|
||||
@@ -5,10 +5,25 @@ from ninja import Router, File, Form
|
||||
from ninja.files import UploadedFile
|
||||
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 .schemas import SubmissionOut, ImageCropIn, ImageCropOut, ScopeOut
|
||||
from .tasks import process_submission_file, process_crop
|
||||
from .schemas import (
|
||||
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()
|
||||
|
||||
@@ -66,6 +81,81 @@ def list_available_scopes(request: HttpRequest, event_path: str, game_path: str)
|
||||
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})
|
||||
def create_submission(
|
||||
request: HttpRequest,
|
||||
@@ -112,10 +202,12 @@ def list_user_submissions(request: HttpRequest, event_path: str, scope_id: UUID)
|
||||
if not request.user.is_authenticated:
|
||||
return 403, {"detail": "Authentication required"}
|
||||
|
||||
submissions = Submission.for_event.filter(
|
||||
scope_id=scope_id, user=request.user
|
||||
).order_by("-created_at")
|
||||
return submissions
|
||||
submissions = Submission.for_event.filter(scope_id=scope_id)
|
||||
# Admins see every player's submissions for the scope
|
||||
if not request.user.is_staff:
|
||||
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])
|
||||
|
||||
@@ -2,6 +2,8 @@ import json
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
from submissions.signals import STAFF_GROUP_NAME
|
||||
|
||||
|
||||
class SubmissionsConsumer(AsyncWebsocketConsumer):
|
||||
async def connect(self):
|
||||
@@ -11,12 +13,18 @@ class SubmissionsConsumer(AsyncWebsocketConsumer):
|
||||
await self.close()
|
||||
return
|
||||
|
||||
self.user_group_name = f"submissions_user_{self.user.id}"
|
||||
await self.channel_layer.group_add(self.user_group_name, self.channel_name)
|
||||
self.group_names = [f"submissions_user_{self.user.id}"]
|
||||
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()
|
||||
|
||||
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):
|
||||
submission_data = event["submission"]
|
||||
@@ -29,3 +37,9 @@ class SubmissionsConsumer(AsyncWebsocketConsumer):
|
||||
async def submission_crop_updated(self, event):
|
||||
crop_data = event["crop"]
|
||||
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 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):
|
||||
@@ -25,9 +25,15 @@ class Command(BaseCommand):
|
||||
if not EventGame.objects.filter(id=event_game_id).exists():
|
||||
raise CommandError(f"No EventGame with id={event_game_id}")
|
||||
|
||||
sync_event_game_scopes_task.enqueue(event_game_id)
|
||||
if schedule_scope_sync(event_game_id):
|
||||
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
|
||||
|
||||
|
||||
@@ -10,6 +10,33 @@ class ScopeOut(Schema):
|
||||
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):
|
||||
x1: int
|
||||
y1: int
|
||||
@@ -20,6 +47,7 @@ class ImageCropIn(Schema):
|
||||
|
||||
class ImageCropOut(Schema):
|
||||
id: UUID
|
||||
submission_file_id: UUID
|
||||
x1: int
|
||||
y1: int
|
||||
x2: int
|
||||
@@ -44,7 +72,12 @@ class SubmissionFileOut(Schema):
|
||||
class SubmissionOut(Schema):
|
||||
id: UUID
|
||||
scope_id: UUID
|
||||
username: str | None = None
|
||||
notes: str | None = None
|
||||
files: list[SubmissionFileOut] = []
|
||||
created_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 ..models import Scope
|
||||
from ..signals import broadcast_scope_updates
|
||||
from .aandt_client import AuthenticatedClient
|
||||
from .aandt_client.api.default import (
|
||||
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
|
||||
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:
|
||||
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)
|
||||
now = timezone.now()
|
||||
synced_match_ids: set[int] = set()
|
||||
changes: dict[Scope, set[int]] = {}
|
||||
|
||||
for match in matches:
|
||||
match_id = _unwrap(match.id)
|
||||
created, warnings = _sync_match(
|
||||
created, warnings, change = _sync_match(
|
||||
event_game, client, match, userids_by_member_id, now
|
||||
)
|
||||
if created is None:
|
||||
@@ -185,14 +190,22 @@ def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
|
||||
result.created += created
|
||||
result.updated += not created
|
||||
result.warnings.extend(warnings)
|
||||
if change is not None:
|
||||
changes[change[0]] = change[1]
|
||||
|
||||
(
|
||||
gone = list(
|
||||
Scope.objects.filter(
|
||||
event_game=event_game, is_active=True, external_id__isnull=False
|
||||
)
|
||||
.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
|
||||
|
||||
@@ -203,10 +216,17 @@ def _sync_match(
|
||||
match: Match,
|
||||
userids_by_member_id: dict[int, set[int]],
|
||||
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)
|
||||
if match_id is None:
|
||||
return None, []
|
||||
return None, [], None
|
||||
|
||||
warnings: list[str] = []
|
||||
|
||||
@@ -218,6 +238,12 @@ def _sync_match(
|
||||
|
||||
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(
|
||||
event_game=event_game,
|
||||
external_id=match_id,
|
||||
@@ -241,7 +267,12 @@ def _sync_match(
|
||||
if scope.is_active:
|
||||
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(
|
||||
|
||||
@@ -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 asgiref.sync import async_to_sync
|
||||
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
|
||||
|
||||
|
||||
# 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)
|
||||
def broadcast_submission_update(sender, instance, created, **kwargs):
|
||||
if not instance.user:
|
||||
return
|
||||
|
||||
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,
|
||||
_broadcast_submission_event(
|
||||
instance,
|
||||
{
|
||||
"type": "submission.updated",
|
||||
"submission": submission_data,
|
||||
"submission": SubmissionOut.from_orm(instance).dict(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=SubmissionFile)
|
||||
def broadcast_submission_file_update(sender, instance, created, **kwargs):
|
||||
submission = instance.submission
|
||||
if not submission.user:
|
||||
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,
|
||||
_broadcast_submission_event(
|
||||
instance.submission,
|
||||
{
|
||||
"type": "submission.file.updated",
|
||||
"file": file_data,
|
||||
"file": SubmissionFileOut.from_orm(instance).dict(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=ImageCrop)
|
||||
def broadcast_image_crop_update(sender, instance, created, **kwargs):
|
||||
submission = instance.submission_file.submission
|
||||
if not submission.user:
|
||||
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,
|
||||
_broadcast_submission_event(
|
||||
instance.submission_file.submission,
|
||||
{
|
||||
"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)
|
||||
|
||||
@@ -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.utils import timezone
|
||||
from games.models import EventGame
|
||||
@@ -11,9 +16,117 @@ from submissions.services.aandt_sync import (
|
||||
|
||||
from PIL import Image
|
||||
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_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:
|
||||
@@ -90,26 +203,73 @@ def sync_all_event_games_task():
|
||||
)
|
||||
|
||||
for event_game in event_games:
|
||||
sync_event_game_scopes_task.enqueue(event_game.id)
|
||||
schedule_scope_sync(event_game.id)
|
||||
|
||||
|
||||
@task
|
||||
def sync_event_game_scopes_task(event_game_id: int):
|
||||
"""Sync an EventGame's Scopes, repolling every minute while matches are in progress.
|
||||
def sync_event_game_scopes_task(event_game_id: int, loop_id: str | None = None):
|
||||
"""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
|
||||
still "in progress" (`7inprog`), the task re-enqueues itself with a delay
|
||||
instead of relying on an external scheduler; once the status moves on, it
|
||||
simply doesn't reschedule and the loop stops.
|
||||
Started once (see schedule_scope_sync), it keeps going on its own without
|
||||
an external scheduler: every SCOPE_SYNC_REPOLL_INTERVAL while the AAndT is
|
||||
"in progress" (`7inprog`), every SCOPE_SYNC_IDLE_INTERVAL before that or
|
||||
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()
|
||||
|
||||
if not event_game:
|
||||
guard_key = _scope_sync_guard_key(event_game_id)
|
||||
if loop_id is not None and cache.get(guard_key) != loop_id:
|
||||
# Another loop owns the guard (or it expired/was stopped), stop this one
|
||||
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:
|
||||
sync_event_game_scopes_task.using(
|
||||
run_after=timezone.now() + SCOPE_SYNC_REPOLL_INTERVAL
|
||||
).enqueue(event_game_id)
|
||||
if not event_game or not event_game.apibot_token:
|
||||
if loop_id is not None:
|
||||
cache.delete(guard_key)
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,44 +1,17 @@
|
||||
from uuid import UUID
|
||||
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 events.models import Event
|
||||
from games.models import Game
|
||||
from .models import Scope
|
||||
|
||||
|
||||
@login_required
|
||||
def submission_home(request: HttpRequest, event_path: str, game_path: str):
|
||||
event = get_object_or_404(Event, path=event_path)
|
||||
game = get_object_or_404(Game, path=game_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
|
||||
|
||||
# Unknown game, no scope assigned, auto-entering the only scope: all
|
||||
# handled by the frontend (SubmissionsSelect.vue)
|
||||
get_object_or_404(Event, path=event_path)
|
||||
return render(request, "app.html")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user