feat(submissions): sync scopes admin from token

This commit is contained in:
2026-10-16 01:34:56 +02:00
parent 8daa91d6c5
commit de5ee8ae7a
13 changed files with 480 additions and 40 deletions
+5 -4
View File
@@ -5,7 +5,7 @@ from django_object_actions import (
takes_instance_or_queryset, takes_instance_or_queryset,
) )
from submissions.tasks import sync_event_game_scopes_task from submissions.tasks import schedule_scope_sync
from .models import EventGame, Game from .models import EventGame, Game
from .tasks import refresh_steam_games_task from .tasks import refresh_steam_games_task
@@ -67,11 +67,12 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
tokened = queryset.exclude(apibot_token__isnull=True).exclude(apibot_token="") tokened = queryset.exclude(apibot_token__isnull=True).exclude(apibot_token="")
skipped = queryset.count() - tokened.count() skipped = queryset.count() - tokened.count()
for event_game in tokened: started = sum(schedule_scope_sync(event_game.id) for event_game in tokened)
sync_event_game_scopes_task.enqueue(event_game.id)
self.message_user( self.message_user(
request, f"Queued scope sync for {tokened.count()} EventGame(s)." request,
f"Started scope sync for {started} EventGame(s)"
f" ({tokened.count() - started} already running).",
) )
if skipped: if skipped:
self.message_user( self.message_user(
+76 -5
View File
@@ -3,9 +3,10 @@ import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import { submissionsApiListAvailableScopes } from "@/api"; import { submissionsApiListAvailableScopes, submissionsApiSyncScopes } from "@/api";
import { useQueryClient } from "@tanstack/vue-query"; import { useQueryClient } from "@tanstack/vue-query";
import { eventGamesQuery } from "@/queries"; import { eventGamesQuery, userQuery } from "@/queries";
import { useUser } from "@/composables/useUser";
import type { ScopeOut } from "@/api/types.gen"; import type { ScopeOut } from "@/api/types.gen";
interface Props { interface Props {
@@ -18,6 +19,7 @@ const props = defineProps<Props>();
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useI18n(); const { t } = useI18n();
const userInfo = useUser();
useTitle(computed(() => t("submissionsSelect.title"))); useTitle(computed(() => t("submissionsSelect.title")));
const gameName = ref(""); const gameName = ref("");
@@ -25,6 +27,11 @@ const scopes = ref<ScopeOut[]>([]);
const loading = ref(true); const loading = ref(true);
const notAssigned = ref(false); const notAssigned = ref(false);
const syncing = ref(false);
const syncMessage = ref<{ type: "success" | "info" | "error"; text: string } | null>(null);
const isTokenModalOpen = ref(false);
const apibotToken = ref("");
const scopeRoute = (scope: ScopeOut) => ({ const scopeRoute = (scope: ScopeOut) => ({
name: "submissions", name: "submissions",
params: { eventPath: props.eventPath, gamePath: props.gamePath, scopeId: scope.id }, params: { eventPath: props.eventPath, gamePath: props.gamePath, scopeId: scope.id },
@@ -34,12 +41,46 @@ const goToScope = (scope: ScopeOut) => {
router.push(scopeRoute(scope)); router.push(scopeRoute(scope));
}; };
const syncScopes = async (token?: string) => {
syncing.value = true;
syncMessage.value = null;
const { data, error } = await submissionsApiSyncScopes({
path: { event_path: props.eventPath, game_path: props.gamePath },
body: { apibot_token: token ?? null },
});
syncing.value = false;
if (error?.token_required) {
isTokenModalOpen.value = true;
return;
}
isTokenModalOpen.value = false;
apibotToken.value = "";
if (!data) {
syncMessage.value = { type: "error", text: t("submissionsSelect.syncFailed") };
} else if (data.started) {
syncMessage.value = { type: "success", text: t("submissionsSelect.syncStarted") };
} else {
syncMessage.value = { type: "info", text: t("submissionsSelect.syncAlreadyRunning") };
}
};
const closeTokenModal = () => {
isTokenModalOpen.value = false;
apibotToken.value = "";
};
onMounted(async () => { onMounted(async () => {
const [games, scopesResponse] = await Promise.all([ const [games, scopesResponse, user] = await Promise.all([
queryClient.ensureQueryData(eventGamesQuery(props.eventPath)), queryClient.ensureQueryData(eventGamesQuery(props.eventPath)),
submissionsApiListAvailableScopes({ submissionsApiListAvailableScopes({
path: { event_path: props.eventPath, game_path: props.gamePath }, path: { event_path: props.eventPath, game_path: props.gamePath },
}), }),
queryClient.ensureQueryData(userQuery()),
]); ]);
gameName.value = games.find((game) => game.path === props.gamePath)?.name ?? ""; gameName.value = games.find((game) => game.path === props.gamePath)?.name ?? "";
@@ -48,7 +89,8 @@ onMounted(async () => {
// Mirrors the server-side behavior this replaced: skip the picker // Mirrors the server-side behavior this replaced: skip the picker
// straight to the only option, or bounce out if there's nothing to pick. // straight to the only option, or bounce out if there's nothing to pick.
if (availableScopes.length === 1) { // Superusers always land here, it's where the scope sync button lives.
if (availableScopes.length === 1 && !user.is_superuser) {
router.replace(scopeRoute(availableScopes[0])); router.replace(scopeRoute(availableScopes[0]));
return; return;
} }
@@ -64,7 +106,18 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div class="flex-1 bg-base-200 flex items-center justify-center p-4"> <div class="flex-1 bg-base-200 flex flex-col items-center justify-center gap-4 p-4">
<div v-if="userInfo.isSuperuser && !loading" class="w-full max-w-md flex flex-col gap-2">
<button @click="syncScopes()" :disabled="syncing" class="btn btn-primary btn-sm self-end">
<span v-if="syncing" class="loading loading-spinner loading-xs"></span>
<i v-else class="mdi mdi-sync"></i>
{{ t("submissionsSelect.syncScopes") }}
</button>
<div v-if="syncMessage" :class="['alert', `alert-${syncMessage.type}`]">
{{ syncMessage.text }}
</div>
</div>
<div v-if="loading" class="flex justify-center py-20"> <div v-if="loading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg"></span>
</div> </div>
@@ -89,5 +142,23 @@ onMounted(async () => {
</div> </div>
</div> </div>
</div> </div>
<!-- Apibot token modal, only shown when the game has none configured yet -->
<div :class="['modal', { 'modal-open': isTokenModalOpen }]">
<form class="modal-box" @submit.prevent="syncScopes(apibotToken)">
<h3 class="font-bold text-lg mb-2">{{ t("submissionsSelect.tokenTitle") }}</h3>
<p class="text-base-content/70 mb-4">{{ t("submissionsSelect.tokenDescription") }}</p>
<input v-model="apibotToken" type="password" autocomplete="off" required
:placeholder="t('submissionsSelect.tokenPlaceholder')" class="input input-bordered w-full" />
<div class="modal-action">
<button type="button" @click="closeTokenModal" class="btn">{{ t("common.cancel") }}</button>
<button type="submit" :disabled="syncing || !apibotToken.trim()" class="btn btn-primary">
<span v-if="syncing" class="loading loading-spinner loading-xs"></span>
{{ t("submissionsSelect.tokenSubmit") }}
</button>
</div>
</form>
<div class="modal-backdrop" @click="closeTokenModal"></div>
</div>
</div> </div>
</template> </template>
@@ -3,8 +3,8 @@
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/vue-query'; import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/vue-query';
import { client } from '../client.gen'; import { client } from '../client.gen';
import { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, clockApiCurrentState, clockApiMyStats, gamesApiListEventGames, gamesApiListEventSteamGames, gamesApiListGames, marketApiBetHistory, marketApiCloseMarket, marketApiCreateBet, marketApiGetUserPoints, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, placeApiCanvas, placeApiEvolutionGif, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck, submissionsApiCreateImageCrop, submissionsApiCreateSubmission, submissionsApiDeleteImageCrop, submissionsApiGetSubmission, submissionsApiListAvailableScopes, submissionsApiListMyScopes, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions, submissionsApiProcessCropOcr, submissionsApiProcessFileOcr } from '../sdk.gen'; import { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, clockApiCurrentState, clockApiMyStats, gamesApiListEventGames, gamesApiListEventSteamGames, gamesApiListGames, marketApiBetHistory, marketApiCloseMarket, marketApiCreateBet, marketApiGetUserPoints, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, placeApiCanvas, placeApiEvolutionGif, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck, submissionsApiCreateImageCrop, submissionsApiCreateSubmission, submissionsApiDeleteImageCrop, submissionsApiGetSubmission, submissionsApiListAvailableScopes, submissionsApiListMyScopes, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions, submissionsApiProcessCropOcr, submissionsApiProcessFileOcr, submissionsApiSyncScopes } from '../sdk.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, ClockApiCurrentStateData, ClockApiCurrentStateResponse, ClockApiMyStatsData, ClockApiMyStatsResponse, GamesApiListEventGamesData, GamesApiListEventGamesResponse, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponse, GamesApiListGamesData, GamesApiListGamesResponse, MarketApiBetHistoryData, MarketApiBetHistoryResponse, MarketApiCloseMarketData, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiGetUserPointsData, MarketApiGetUserPointsResponse, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiResolveMarketData, MarketApiResolveMarketResponse, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileResponse, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetStatsData, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponse, PlaceApiCanvasData, PlaceApiEvolutionGifData, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiHealthCheckData, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionError, SubmissionsApiCreateSubmissionResponse, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponse, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponse, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesError, SubmissionsApiListAvailableScopesResponse, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesError, SubmissionsApiListMyScopesResponse, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsError, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponse, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponse } from '../types.gen'; import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, ClockApiCurrentStateData, ClockApiCurrentStateResponse, ClockApiMyStatsData, ClockApiMyStatsResponse, GamesApiListEventGamesData, GamesApiListEventGamesResponse, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponse, GamesApiListGamesData, GamesApiListGamesResponse, MarketApiBetHistoryData, MarketApiBetHistoryResponse, MarketApiCloseMarketData, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiGetUserPointsData, MarketApiGetUserPointsResponse, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiResolveMarketData, MarketApiResolveMarketResponse, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileResponse, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetStatsData, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponse, PlaceApiCanvasData, PlaceApiEvolutionGifData, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiHealthCheckData, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionError, SubmissionsApiCreateSubmissionResponse, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponse, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponse, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesError, SubmissionsApiListAvailableScopesResponse, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesError, SubmissionsApiListMyScopesResponse, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsError, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponse, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponse, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesError, SubmissionsApiSyncScopesResponse } from '../types.gen';
export type QueryKey<TOptions extends Options> = [ export type QueryKey<TOptions extends Options> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & { Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
@@ -593,6 +593,27 @@ export const submissionsApiListAvailableScopesOptions = (options: Options<Submis
queryKey: submissionsApiListAvailableScopesQueryKey(options) queryKey: submissionsApiListAvailableScopesQueryKey(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 submissionsApiSyncScopesMutation = (options?: Partial<Options<SubmissionsApiSyncScopesData>>): UseMutationOptions<SubmissionsApiSyncScopesResponse, SubmissionsApiSyncScopesError, Options<SubmissionsApiSyncScopesData>> => {
const mutationOptions: UseMutationOptions<SubmissionsApiSyncScopesResponse, SubmissionsApiSyncScopesError, Options<SubmissionsApiSyncScopesData>> = {
mutationFn: async (fnOptions) => {
const { data } = await submissionsApiSyncScopes({
...options,
...fnOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
/** /**
* Create Submission * Create Submission
*/ */
File diff suppressed because one or more lines are too long
+17 -1
View File
@@ -2,7 +2,7 @@
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client'; import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
import { client } from './client.gen'; import { client } from './client.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen'; import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesErrors, SubmissionsApiSyncScopesResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/** /**
@@ -219,6 +219,22 @@ export const submissionsApiListMyScopes = <ThrowOnError extends boolean = false>
*/ */
export const submissionsApiListAvailableScopes = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListAvailableScopesData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListAvailableScopesResponses, SubmissionsApiListAvailableScopesErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/{game_path}/available-scopes', ...options }); export const submissionsApiListAvailableScopes = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListAvailableScopesData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListAvailableScopesResponses, SubmissionsApiListAvailableScopesErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/{game_path}/available-scopes', ...options });
/**
* Sync Scopes
*
* Start the AAndT scope sync loop for a (non custom path) game, superusers
* only. Stores `apibot_token` first when given; without one and none stored
* yet, answers 400 with `token_required` so the frontend asks for it.
*/
export const submissionsApiSyncScopes = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiSyncScopesData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiSyncScopesResponses, SubmissionsApiSyncScopesErrors, ThrowOnError>({
url: '/api/events/{event_path}/submissions/{game_path}/sync-scopes',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/** /**
* Create Submission * Create Submission
*/ */
+66
View File
@@ -1000,6 +1000,26 @@ export type ScopeOut = {
created_at: string; created_at: string;
}; };
/**
* SyncScopesOut
*/
export type SyncScopesOut = {
/**
* Started
*/
started: boolean;
};
/**
* SyncScopesIn
*/
export type SyncScopesIn = {
/**
* Apibot Token
*/
apibot_token?: string | null;
};
/** /**
* ImageCropOut * ImageCropOut
*/ */
@@ -1873,6 +1893,52 @@ export type SubmissionsApiListAvailableScopesResponses = {
export type SubmissionsApiListAvailableScopesResponse = SubmissionsApiListAvailableScopesResponses[keyof SubmissionsApiListAvailableScopesResponses]; export type SubmissionsApiListAvailableScopesResponse = SubmissionsApiListAvailableScopesResponses[keyof SubmissionsApiListAvailableScopesResponses];
export type SubmissionsApiSyncScopesData = {
body: SyncScopesIn;
path: {
/**
* Event Path
*/
event_path: string;
/**
* Game Path
*/
game_path: string;
};
query?: never;
url: '/api/events/{event_path}/submissions/{game_path}/sync-scopes';
};
export type SubmissionsApiSyncScopesErrors = {
/**
* Response
*
* Bad Request
*/
400: {
[key: string]: unknown;
};
/**
* Response
*
* Forbidden
*/
403: {
[key: string]: unknown;
};
};
export type SubmissionsApiSyncScopesError = SubmissionsApiSyncScopesErrors[keyof SubmissionsApiSyncScopesErrors];
export type SubmissionsApiSyncScopesResponses = {
/**
* OK
*/
200: SyncScopesOut;
};
export type SubmissionsApiSyncScopesResponse = SubmissionsApiSyncScopesResponses[keyof SubmissionsApiSyncScopesResponses];
export type SubmissionsApiCreateSubmissionData = { export type SubmissionsApiCreateSubmissionData = {
/** /**
* MultiPartBodyParams * MultiPartBodyParams
@@ -9,5 +9,6 @@ export const useUser = () => {
username: data.value?.username ?? "", username: data.value?.username ?? "",
isAuthenticated: data.value?.is_authenticated ?? false, isAuthenticated: data.value?.is_authenticated ?? false,
isStaff: data.value?.is_staff ?? false, isStaff: data.value?.is_staff ?? false,
isSuperuser: data.value?.is_superuser ?? false,
})); }));
}; };
+9 -1
View File
@@ -209,7 +209,15 @@
"title": "Select your match", "title": "Select your match",
"description": "You're assigned to multiple matches for {game}. Pick which one you want to submit for.", "description": "You're assigned to multiple matches for {game}. Pick which one you want to submit for.",
"notAssignedTitle": "No match assigned", "notAssignedTitle": "No match assigned",
"notAssignedMessage": "You are not assigned to a scope/matches, you cannot submit a score." "notAssignedMessage": "You are not assigned to a scope/matches, you cannot submit a score.",
"syncScopes": "Sync scopes",
"syncStarted": "Scope sync started, it keeps running until the AAndT is done. Refresh in a few seconds to see new matches.",
"syncAlreadyRunning": "Scope sync is already running.",
"syncFailed": "Failed to start the scope sync.",
"tokenTitle": "AAndT apibot token",
"tokenDescription": "No apibot token is configured for this game yet. Paste the one from the AAndT's 'apibot' admin page.",
"tokenPlaceholder": "Apibot token",
"tokenSubmit": "Save & sync"
}, },
"errorPage": { "errorPage": {
"notFound": { "notFound": {
+45 -3
View File
@@ -5,10 +5,17 @@ from ninja import Router, File, Form
from ninja.files import UploadedFile from ninja.files import UploadedFile
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from games.models import Game from games.models import EventGame, Game
from .models import Submission, SubmissionFile, ImageCrop, OCRStatus, Scope from .models import Submission, SubmissionFile, ImageCrop, OCRStatus, Scope
from .schemas import SubmissionOut, ImageCropIn, ImageCropOut, ScopeOut from .schemas import (
from .tasks import process_submission_file, process_crop SubmissionOut,
ImageCropIn,
ImageCropOut,
ScopeOut,
SyncScopesIn,
SyncScopesOut,
)
from .tasks import process_submission_file, process_crop, schedule_scope_sync
router = Router() router = Router()
@@ -66,6 +73,41 @@ def list_available_scopes(request: HttpRequest, event_path: str, game_path: str)
return list(scopes.order_by("name")) return list(scopes.order_by("name"))
@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 = get_object_or_404(
EventGame,
event=request.event,
game__path=game_path,
game__custom_path=False,
enabled=True,
)
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.post(f"{PREFIX}/submission/create", response={200: SubmissionOut, 403: dict}) @router.post(f"{PREFIX}/submission/create", response={200: SubmissionOut, 403: dict})
def create_submission( def create_submission(
request: HttpRequest, request: HttpRequest,
@@ -1,7 +1,7 @@
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from games.models import EventGame from games.models import EventGame
from submissions.tasks import sync_all_event_games_task, sync_event_game_scopes_task from submissions.tasks import schedule_scope_sync, sync_all_event_games_task
class Command(BaseCommand): class Command(BaseCommand):
@@ -25,10 +25,16 @@ class Command(BaseCommand):
if not EventGame.objects.filter(id=event_game_id).exists(): if not EventGame.objects.filter(id=event_game_id).exists():
raise CommandError(f"No EventGame with id={event_game_id}") raise CommandError(f"No EventGame with id={event_game_id}")
sync_event_game_scopes_task.enqueue(event_game_id) if schedule_scope_sync(event_game_id):
self.stdout.write( self.stdout.write(
self.style.SUCCESS(f"Queued sync for EventGame {event_game_id}") self.style.SUCCESS(f"Started sync for EventGame {event_game_id}")
) )
else:
self.stdout.write(
self.style.WARNING(
f"Sync already running for EventGame {event_game_id}"
)
)
return return
if ( if (
+9
View File
@@ -10,6 +10,15 @@ class ScopeOut(Schema):
created_at: datetime created_at: datetime
class SyncScopesIn(Schema):
apibot_token: str | None = None
class SyncScopesOut(Schema):
# False when a sync loop was already running for this game
started: bool
class ImageCropIn(Schema): class ImageCropIn(Schema):
x1: int x1: int
y1: int y1: int
+87 -16
View File
@@ -1,19 +1,61 @@
import logging
from datetime import timedelta from datetime import timedelta
from uuid import uuid4
from django.core.cache import cache
from django.tasks import task from django.tasks import task
from django.utils import timezone from django.utils import timezone
from games.models import EventGame from games.models import EventGame
from submissions.models import ImageCrop, SubmissionFile, OCRStatus from submissions.models import ImageCrop, SubmissionFile, OCRStatus
from submissions.services.aandt_sync import ( from submissions.services.aandt_sync import (
AANDT_STATUS_IN_PROGRESS, AANDT_STATUS_IN_PROGRESS,
ScopeSyncError,
sync_event_game_scopes, sync_event_game_scopes,
) )
from PIL import Image from PIL import Image
import pytesseract import pytesseract
# 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 API call failed.
SCOPE_SYNC_REPOLL_INTERVAL = timedelta(seconds=15) SCOPE_SYNC_REPOLL_INTERVAL = timedelta(seconds=15)
SCOPE_SYNC_IDLE_INTERVAL = timedelta(minutes=1)
# AAndT statuses after which there's nothing left to sync, the loop stops.
AANDT_FINISHED_STATUSES = {"9done", "11reviewed"}
def _scope_sync_guard_key(event_game_id: int) -> str:
return f"submissions:scope_sync:{event_game_id}"
def _scope_sync_guard_timeout() -> int:
return int(SCOPE_SYNC_IDLE_INTERVAL.total_seconds() * 3)
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
sync_event_game_scopes_task.enqueue(event_game_id, loop_id=loop_id)
return True
def stop_scope_sync(event_game_id: int, /):
"""Release the guard, the running loop (if any) stops at its next run."""
cache.delete(_scope_sync_guard_key(event_game_id))
def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool: def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool:
@@ -90,26 +132,55 @@ def sync_all_event_games_task():
) )
for event_game in event_games: for event_game in event_games:
sync_event_game_scopes_task.enqueue(event_game.id) schedule_scope_sync(event_game.id)
@task @task
def sync_event_game_scopes_task(event_game_id: int): def sync_event_game_scopes_task(event_game_id: int, loop_id: str | None = None):
"""Sync an EventGame's Scopes, repolling every minute while matches are in progress. """Sync an EventGame's Scopes, then re-enqueue itself until the AAndT is done.
Each run re-checks the AAndT's status (from the `/` endpoint). While it's Started once (see schedule_scope_sync), it keeps going on its own without
still "in progress" (`7inprog`), the task re-enqueues itself with a delay an external scheduler: every SCOPE_SYNC_REPOLL_INTERVAL while the AAndT is
instead of relying on an external scheduler; once the status moves on, it "in progress" (`7inprog`), every SCOPE_SYNC_IDLE_INTERVAL before that or
simply doesn't reschedule and the loop stops. when the API call failed. Stops once the AAndT is done/reviewed, when the
EventGame (or its token) is gone, or when another loop took the guard.
Without loop_id it's a one-off run that leaves the loop alone.
""" """
event_game = EventGame.objects.filter(id=event_game_id).first() guard_key = _scope_sync_guard_key(event_game_id)
if loop_id is not None and cache.get(guard_key) != loop_id:
if not event_game: # Another loop owns the guard (or it expired), stop this one
return return
result = sync_event_game_scopes(event_game) event_game = EventGame.objects.filter(id=event_game_id).first()
if result.status == AANDT_STATUS_IN_PROGRESS: if not event_game or not event_game.apibot_token:
sync_event_game_scopes_task.using( if loop_id is not None:
run_after=timezone.now() + SCOPE_SYNC_REPOLL_INTERVAL cache.delete(guard_key)
).enqueue(event_game_id) return
status = None
try:
status = sync_event_game_scopes(event_game).status
except ScopeSyncError:
if loop_id is None:
raise
# Probably transient (AAndT site down, ...), retry later
logger.exception("Scope sync failed for EventGame %s", event_game_id)
if loop_id is None:
return
if status in AANDT_FINISHED_STATUSES:
cache.delete(guard_key)
return
interval = (
SCOPE_SYNC_REPOLL_INTERVAL
if status == AANDT_STATUS_IN_PROGRESS
else SCOPE_SYNC_IDLE_INTERVAL
)
cache.set(guard_key, loop_id, timeout=_scope_sync_guard_timeout())
sync_event_game_scopes_task.using(run_after=timezone.now() + interval).enqueue(
event_game_id, loop_id=loop_id
)
+129 -1
View File
@@ -1 +1,129 @@
# Create your tests here. from unittest import mock
from django.core.cache import cache
from django.test import TestCase
from accounts.models import CustomUser
from events.models import Event
from games.models import EventGame, Game
from . import tasks
from .services.aandt_sync import ScopeSyncError, ScopeSyncResult
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_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"
cache.set(self.guard_key, self.loop_id)
self.addCleanup(cache.delete, self.guard_key)
def _run(self, status=None, exc=None):
run = tasks.sync_event_game_scopes_task.func
with (
mock.patch.object(
tasks,
"sync_event_game_scopes",
side_effect=exc,
return_value=ScopeSyncResult(status=status),
) as sync,
mock.patch.object(tasks, "sync_event_game_scopes_task") as task,
mock.patch.object(tasks, "logger"),
):
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.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_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()