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,
)
from submissions.tasks import sync_event_game_scopes_task
from submissions.tasks import schedule_scope_sync
from .models import EventGame, Game
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="")
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(
+76 -5
View File
@@ -3,9 +3,10 @@ import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core";
import { submissionsApiListAvailableScopes } from "@/api";
import { submissionsApiListAvailableScopes, submissionsApiSyncScopes } from "@/api";
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";
interface Props {
@@ -18,6 +19,7 @@ const props = defineProps<Props>();
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useI18n();
const userInfo = useUser();
useTitle(computed(() => t("submissionsSelect.title")));
const gameName = ref("");
@@ -25,6 +27,11 @@ const scopes = ref<ScopeOut[]>([]);
const loading = ref(true);
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) => ({
name: "submissions",
params: { eventPath: props.eventPath, gamePath: props.gamePath, scopeId: scope.id },
@@ -34,12 +41,46 @@ const goToScope = (scope: ScopeOut) => {
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 () => {
const [games, scopesResponse] = await Promise.all([
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 = games.find((game) => game.path === props.gamePath)?.name ?? "";
@@ -48,7 +89,8 @@ onMounted(async () => {
// 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;
}
@@ -64,7 +106,18 @@ onMounted(async () => {
</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" 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">
<span class="loading loading-spinner loading-lg"></span>
</div>
@@ -89,5 +142,23 @@ onMounted(async () => {
</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>
</template>
@@ -3,8 +3,8 @@
import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/vue-query';
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 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 { 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, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesError, SubmissionsApiSyncScopesResponse } from '../types.gen';
export type QueryKey<TOptions extends Options> = [
Pick<TOptions, 'baseUrl' | 'body' | 'headers' | 'path' | 'query'> & {
@@ -593,6 +593,27 @@ export const submissionsApiListAvailableScopesOptions = (options: Options<Submis
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
*/
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 { 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> & {
/**
@@ -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 });
/**
* 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
*/
+66
View File
@@ -1000,6 +1000,26 @@ export type ScopeOut = {
created_at: string;
};
/**
* SyncScopesOut
*/
export type SyncScopesOut = {
/**
* Started
*/
started: boolean;
};
/**
* SyncScopesIn
*/
export type SyncScopesIn = {
/**
* Apibot Token
*/
apibot_token?: string | null;
};
/**
* ImageCropOut
*/
@@ -1873,6 +1893,52 @@ export type 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 = {
/**
* MultiPartBodyParams
@@ -9,5 +9,6 @@ export const useUser = () => {
username: data.value?.username ?? "",
isAuthenticated: data.value?.is_authenticated ?? 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",
"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",
"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": {
"notFound": {
+45 -3
View File
@@ -5,10 +5,17 @@ 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,
)
from .tasks import process_submission_file, process_crop, schedule_scope_sync
router = Router()
@@ -66,6 +73,41 @@ def list_available_scopes(request: HttpRequest, event_path: str, game_path: str)
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})
def create_submission(
request: HttpRequest,
@@ -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,10 +25,16 @@ 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)
self.stdout.write(
self.style.SUCCESS(f"Queued sync for EventGame {event_game_id}")
)
if schedule_scope_sync(event_game_id):
self.stdout.write(
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
if (
+9
View File
@@ -10,6 +10,15 @@ 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 ImageCropIn(Schema):
x1: int
y1: int
+87 -16
View File
@@ -1,19 +1,61 @@
import logging
from datetime import timedelta
from uuid import uuid4
from django.core.cache import cache
from django.tasks import task
from django.utils import timezone
from games.models import EventGame
from submissions.models import ImageCrop, SubmissionFile, OCRStatus
from submissions.services.aandt_sync import (
AANDT_STATUS_IN_PROGRESS,
ScopeSyncError,
sync_event_game_scopes,
)
from PIL import Image
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_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:
@@ -90,26 +132,55 @@ 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 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()
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), 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
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()