From de5ee8ae7af978afdd48fdb875b18f332b5e8140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Gremaud?= Date: Fri, 16 Oct 2026 01:34:56 +0200 Subject: [PATCH] feat(submissions): sync scopes admin from token --- polylan_submitter/games/admin.py | 9 +- polylan_submitter/src/SubmissionsSelect.vue | 81 ++++++++++- .../src/api/@tanstack/vue-query.gen.ts | 25 +++- polylan_submitter/src/api/index.ts | 4 +- polylan_submitter/src/api/sdk.gen.ts | 18 ++- polylan_submitter/src/api/types.gen.ts | 66 +++++++++ polylan_submitter/src/composables/useUser.ts | 1 + polylan_submitter/src/locales/en.json | 10 +- polylan_submitter/submissions/api.py | 48 ++++++- .../management/commands/sync_scopes.py | 16 ++- polylan_submitter/submissions/schemas.py | 9 ++ polylan_submitter/submissions/tasks.py | 103 +++++++++++--- polylan_submitter/submissions/tests.py | 130 +++++++++++++++++- 13 files changed, 480 insertions(+), 40 deletions(-) diff --git a/polylan_submitter/games/admin.py b/polylan_submitter/games/admin.py index be65b06..2250420 100644 --- a/polylan_submitter/games/admin.py +++ b/polylan_submitter/games/admin.py @@ -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( diff --git a/polylan_submitter/src/SubmissionsSelect.vue b/polylan_submitter/src/SubmissionsSelect.vue index 27a8c23..3ea132e 100644 --- a/polylan_submitter/src/SubmissionsSelect.vue +++ b/polylan_submitter/src/SubmissionsSelect.vue @@ -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(); 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([]); 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 () => { diff --git a/polylan_submitter/src/api/@tanstack/vue-query.gen.ts b/polylan_submitter/src/api/@tanstack/vue-query.gen.ts index 30ab0a1..6d9d0ed 100644 --- a/polylan_submitter/src/api/@tanstack/vue-query.gen.ts +++ b/polylan_submitter/src/api/@tanstack/vue-query.gen.ts @@ -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 = [ Pick & { @@ -593,6 +593,27 @@ export const submissionsApiListAvailableScopesOptions = (options: Options>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await submissionsApiSyncScopes({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + /** * Create Submission */ diff --git a/polylan_submitter/src/api/index.ts b/polylan_submitter/src/api/index.ts index 727c8cf..ca060cd 100644 --- a/polylan_submitter/src/api/index.ts +++ b/polylan_submitter/src/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { 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'; -export type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, AnimationsApiTopSubmissionsResponses, BetHistoryPointSchema, ClientOptions, ClockApiCurrentStateData, ClockApiCurrentStateResponse, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponse, ClockApiMyStatsResponses, ClockStateOut, GameOut, GamesApiListEventGamesData, GamesApiListEventGamesResponse, GamesApiListEventGamesResponses, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponse, GamesApiListEventSteamGamesResponses, GamesApiListGamesData, GamesApiListGamesResponse, GamesApiListGamesResponses, ImageCropIn, ImageCropOut, Input, LeaderboardEntryOut, LeaderboardOut, MarketApiBetHistoryData, MarketApiBetHistoryResponse, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponse, MarketApiGetUserPointsResponses, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponse, MarketApiResolveMarketResponses, MarketListSchema, MarketOptionSchema, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponse, NoitaApiSubmitLogFileResponses, NoitaSubmissionOut, ObjectivResultOut, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponse, OpusMagnumApiValidateSubmissionResponses, PagedSubmissionOut, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PlayerStatsOut, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, PuzzlePointsFactorOut, PuzzleResponseIn, PuzzleResponseOut, PuzzleResponseRankingOut, PuzzleResultsOut, PuzzleSubmissionsOut, PuzzleSubmissionWithRankOut, RankingSchema, ResolveMarketSchema, ResultsOut, ScopeOut, SteamCollectionItemOut, SteamCollectionOut, SteamGameOut, SubmissionFileOut, SubmissionIn, SubmissionOut, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionError, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponse, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponse, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponse, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesError, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponse, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesError, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponse, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsError, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponse, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponse, SubmissionsApiProcessFileOcrResponses, TournamentPuzzleResultsOut, TournamentSubmissionsOut, UserBetCreateSchema, UserBetSchema, UserDisplayOut, UserInfoOut, UserPointsSchema, ValidationIn, WinnerFileOut, WinnerResponseOut } from './types.gen'; +export { 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'; +export type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, AnimationsApiTopSubmissionsResponses, BetHistoryPointSchema, ClientOptions, ClockApiCurrentStateData, ClockApiCurrentStateResponse, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponse, ClockApiMyStatsResponses, ClockStateOut, GameOut, GamesApiListEventGamesData, GamesApiListEventGamesResponse, GamesApiListEventGamesResponses, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponse, GamesApiListEventSteamGamesResponses, GamesApiListGamesData, GamesApiListGamesResponse, GamesApiListGamesResponses, ImageCropIn, ImageCropOut, Input, LeaderboardEntryOut, LeaderboardOut, MarketApiBetHistoryData, MarketApiBetHistoryResponse, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponse, MarketApiGetUserPointsResponses, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponse, MarketApiResolveMarketResponses, MarketListSchema, MarketOptionSchema, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponse, NoitaApiSubmitLogFileResponses, NoitaSubmissionOut, ObjectivResultOut, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponse, OpusMagnumApiValidateSubmissionResponses, PagedSubmissionOut, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PlayerStatsOut, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, PuzzlePointsFactorOut, PuzzleResponseIn, PuzzleResponseOut, PuzzleResponseRankingOut, PuzzleResultsOut, PuzzleSubmissionsOut, PuzzleSubmissionWithRankOut, RankingSchema, ResolveMarketSchema, ResultsOut, ScopeOut, SteamCollectionItemOut, SteamCollectionOut, SteamGameOut, SubmissionFileOut, SubmissionIn, SubmissionOut, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionError, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponse, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponse, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponse, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesError, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponse, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesError, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponse, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsError, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponse, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponse, SubmissionsApiProcessFileOcrResponses, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesError, SubmissionsApiSyncScopesErrors, SubmissionsApiSyncScopesResponse, SubmissionsApiSyncScopesResponses, SyncScopesIn, SyncScopesOut, TournamentPuzzleResultsOut, TournamentSubmissionsOut, UserBetCreateSchema, UserBetSchema, UserDisplayOut, UserInfoOut, UserPointsSchema, ValidationIn, WinnerFileOut, WinnerResponseOut } from './types.gen'; diff --git a/polylan_submitter/src/api/sdk.gen.ts b/polylan_submitter/src/api/sdk.gen.ts index 2397946..a93f95c 100644 --- a/polylan_submitter/src/api/sdk.gen.ts +++ b/polylan_submitter/src/api/sdk.gen.ts @@ -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 = Options2 & { /** @@ -219,6 +219,22 @@ export const submissionsApiListMyScopes = */ export const submissionsApiListAvailableScopes = (options: Options) => (options.client ?? client).get({ 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 = (options: Options) => (options.client ?? client).post({ + url: '/api/events/{event_path}/submissions/{game_path}/sync-scopes', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * Create Submission */ diff --git a/polylan_submitter/src/api/types.gen.ts b/polylan_submitter/src/api/types.gen.ts index a59b657..7257a3d 100644 --- a/polylan_submitter/src/api/types.gen.ts +++ b/polylan_submitter/src/api/types.gen.ts @@ -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 diff --git a/polylan_submitter/src/composables/useUser.ts b/polylan_submitter/src/composables/useUser.ts index 8d9a6a3..3e8bea3 100644 --- a/polylan_submitter/src/composables/useUser.ts +++ b/polylan_submitter/src/composables/useUser.ts @@ -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, })); }; diff --git a/polylan_submitter/src/locales/en.json b/polylan_submitter/src/locales/en.json index e4c9ba0..8ecd3ba 100644 --- a/polylan_submitter/src/locales/en.json +++ b/polylan_submitter/src/locales/en.json @@ -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": { diff --git a/polylan_submitter/submissions/api.py b/polylan_submitter/submissions/api.py index 7d59555..52df9ae 100644 --- a/polylan_submitter/submissions/api.py +++ b/polylan_submitter/submissions/api.py @@ -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, diff --git a/polylan_submitter/submissions/management/commands/sync_scopes.py b/polylan_submitter/submissions/management/commands/sync_scopes.py index d0b712b..739e425 100644 --- a/polylan_submitter/submissions/management/commands/sync_scopes.py +++ b/polylan_submitter/submissions/management/commands/sync_scopes.py @@ -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 ( diff --git a/polylan_submitter/submissions/schemas.py b/polylan_submitter/submissions/schemas.py index 9fb3aff..2fbf2d5 100644 --- a/polylan_submitter/submissions/schemas.py +++ b/polylan_submitter/submissions/schemas.py @@ -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 diff --git a/polylan_submitter/submissions/tasks.py b/polylan_submitter/submissions/tasks.py index 73f896e..c7367c5 100644 --- a/polylan_submitter/submissions/tasks.py +++ b/polylan_submitter/submissions/tasks.py @@ -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 + ) diff --git a/polylan_submitter/submissions/tests.py b/polylan_submitter/submissions/tests.py index a39b155..06e8757 100644 --- a/polylan_submitter/submissions/tests.py +++ b/polylan_submitter/submissions/tests.py @@ -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()