feat(submissions): sync scopes admin from token
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user