feat(submissions): auto-replace player in scopes, cancel sync + info about the sync
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 schedule_scope_sync
|
from submissions.tasks import schedule_scope_sync, stop_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
|
||||||
@@ -51,8 +51,8 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
|
|||||||
list_display = ["game", "event", "enabled", "has_apibot_token"]
|
list_display = ["game", "event", "enabled", "has_apibot_token"]
|
||||||
list_filter = ["event", "enabled"]
|
list_filter = ["event", "enabled"]
|
||||||
search_fields = ["game__name", "event__name"]
|
search_fields = ["game__name", "event__name"]
|
||||||
actions = ["sync_scopes"]
|
actions = ["sync_scopes", "stop_scope_sync"]
|
||||||
change_actions = ["sync_scopes"]
|
change_actions = ["sync_scopes", "stop_scope_sync"]
|
||||||
|
|
||||||
@admin.display(description="AAndT token", boolean=True)
|
@admin.display(description="AAndT token", boolean=True)
|
||||||
def has_apibot_token(self, obj):
|
def has_apibot_token(self, obj):
|
||||||
@@ -80,3 +80,13 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
|
|||||||
f"Skipped {skipped} EventGame(s) with no AAndT token configured.",
|
f"Skipped {skipped} EventGame(s) with no AAndT token configured.",
|
||||||
level=messages.WARNING,
|
level=messages.WARNING,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@action(
|
||||||
|
label="Stop scope sync",
|
||||||
|
description="Stop the scope sync loop of selected EventGames",
|
||||||
|
)
|
||||||
|
@takes_instance_or_queryset
|
||||||
|
def stop_scope_sync(self, request, queryset):
|
||||||
|
stopped = sum(stop_scope_sync(event_game.id) for event_game in queryset)
|
||||||
|
|
||||||
|
self.message_user(request, f"Stopped scope sync for {stopped} EventGame(s).")
|
||||||
|
|||||||
@@ -475,7 +475,16 @@ const loadUserSubmissions = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { isConnected: wsConnected } = useSubmissionsWebSocket(userSubmissions);
|
// Match over (or user moved off it): back to the picker, which auto-enters
|
||||||
|
// the next match when there's only one, so players follow along without a refresh.
|
||||||
|
const { isConnected: wsConnected } = useSubmissionsWebSocket({
|
||||||
|
userSubmissions,
|
||||||
|
onScopeUpdate: (scope) => {
|
||||||
|
if (scope.id !== props.scopeId || (scope.is_active && scope.assigned)) return;
|
||||||
|
|
||||||
|
router.replace({ name: "submissions-select", params: { eventPath: props.eventPath, gamePath: props.gamePath } });
|
||||||
|
},
|
||||||
|
});
|
||||||
const isUploadAreaHovered = ref(false);
|
const isUploadAreaHovered = ref(false);
|
||||||
|
|
||||||
const handlePasteOnUploadArea = (event: ClipboardEvent) => {
|
const handlePasteOnUploadArea = (event: ClipboardEvent) => {
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ 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, submissionsApiSyncScopes } from "@/api";
|
import { submissionsApiListAvailableScopes } from "@/api";
|
||||||
import { useQueryClient } from "@tanstack/vue-query";
|
import { useQueryClient } from "@tanstack/vue-query";
|
||||||
import { eventGamesQuery, userQuery } from "@/queries";
|
import { eventGamesQuery, userQuery } from "@/queries";
|
||||||
import { useUser } from "@/composables/useUser";
|
import { useUser } from "@/composables/useUser";
|
||||||
|
import { useSubmissionsWebSocket } from "@/composables/useSubmissionsWebSocket";
|
||||||
|
import ScopeSyncModal from "@/components/ScopeSyncModal.vue";
|
||||||
import type { ScopeOut } from "@/api/types.gen";
|
import type { ScopeOut } from "@/api/types.gen";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -27,10 +29,7 @@ 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 isSyncModalOpen = 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",
|
||||||
@@ -41,40 +40,7 @@ const goToScope = (scope: ScopeOut) => {
|
|||||||
router.push(scopeRoute(scope));
|
router.push(scopeRoute(scope));
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncScopes = async (token?: string) => {
|
const loadScopes = async () => {
|
||||||
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, user] = await Promise.all([
|
const [games, scopesResponse, user] = await Promise.all([
|
||||||
queryClient.ensureQueryData(eventGamesQuery(props.eventPath)),
|
queryClient.ensureQueryData(eventGamesQuery(props.eventPath)),
|
||||||
submissionsApiListAvailableScopes({
|
submissionsApiListAvailableScopes({
|
||||||
@@ -94,28 +60,25 @@ onMounted(async () => {
|
|||||||
router.replace(scopeRoute(availableScopes[0]));
|
router.replace(scopeRoute(availableScopes[0]));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (availableScopes.length === 0) {
|
|
||||||
notAssigned.value = true;
|
|
||||||
loading.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
scopes.value = availableScopes;
|
scopes.value = availableScopes;
|
||||||
|
notAssigned.value = availableScopes.length === 0;
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// A match opened/closed or players got (re)assigned: reload, so waiting
|
||||||
|
// players get auto-entered into their next match without a refresh.
|
||||||
|
useSubmissionsWebSocket({ onScopeUpdate: () => loadScopes() });
|
||||||
|
|
||||||
|
onMounted(loadScopes);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex-1 bg-base-200 flex flex-col items-center justify-center gap-4 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">
|
<div v-if="userInfo.isSuperuser && !loading" class="w-full max-w-md flex justify-end">
|
||||||
<button @click="syncScopes()" :disabled="syncing" class="btn btn-primary btn-sm self-end">
|
<button @click="isSyncModalOpen = true" class="btn btn-primary btn-sm">
|
||||||
<span v-if="syncing" class="loading loading-spinner loading-xs"></span>
|
<i class="mdi mdi-sync"></i>
|
||||||
<i v-else class="mdi mdi-sync"></i>
|
|
||||||
{{ t("submissionsSelect.syncScopes") }}
|
{{ t("submissionsSelect.syncScopes") }}
|
||||||
</button>
|
</button>
|
||||||
<div v-if="syncMessage" :class="['alert', `alert-${syncMessage.type}`]">
|
|
||||||
{{ syncMessage.text }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="flex justify-center py-20">
|
<div v-if="loading" class="flex justify-center py-20">
|
||||||
@@ -143,22 +106,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Apibot token modal, only shown when the game has none configured yet -->
|
<ScopeSyncModal v-if="userInfo.isSuperuser" v-model:open="isSyncModalOpen" :event-path="eventPath"
|
||||||
<div :class="['modal', { 'modal-open': isTokenModalOpen }]">
|
:game-path="gamePath" :game-name="gameName" />
|
||||||
<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, submissionsApiSyncScopes } 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, submissionsApiStopSyncScopes, submissionsApiSyncScopes, submissionsApiSyncScopesStatus } 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';
|
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, SubmissionsApiStopSyncScopesData, SubmissionsApiStopSyncScopesError, SubmissionsApiStopSyncScopesResponse, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesError, SubmissionsApiSyncScopesResponse, SubmissionsApiSyncScopesStatusData, SubmissionsApiSyncScopesStatusError, SubmissionsApiSyncScopesStatusResponse } 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,47 @@ export const submissionsApiListAvailableScopesOptions = (options: Options<Submis
|
|||||||
queryKey: submissionsApiListAvailableScopesQueryKey(options)
|
queryKey: submissionsApiListAvailableScopesQueryKey(options)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop Sync Scopes
|
||||||
|
*
|
||||||
|
* Stop the AAndT scope sync loop of a (non custom path) game, superusers
|
||||||
|
* only. Cancels its scheduled job, scopes are left as they are.
|
||||||
|
*/
|
||||||
|
export const submissionsApiStopSyncScopesMutation = (options?: Partial<Options<SubmissionsApiStopSyncScopesData>>): UseMutationOptions<SubmissionsApiStopSyncScopesResponse, SubmissionsApiStopSyncScopesError, Options<SubmissionsApiStopSyncScopesData>> => {
|
||||||
|
const mutationOptions: UseMutationOptions<SubmissionsApiStopSyncScopesResponse, SubmissionsApiStopSyncScopesError, Options<SubmissionsApiStopSyncScopesData>> = {
|
||||||
|
mutationFn: async (fnOptions) => {
|
||||||
|
const { data } = await submissionsApiStopSyncScopes({
|
||||||
|
...options,
|
||||||
|
...fnOptions,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submissionsApiSyncScopesStatusQueryKey = (options: Options<SubmissionsApiSyncScopesStatusData>) => createQueryKey('submissionsApiSyncScopesStatus', options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync Scopes Status
|
||||||
|
*
|
||||||
|
* State of the AAndT scope sync loop of a (non custom path) game,
|
||||||
|
* superusers only.
|
||||||
|
*/
|
||||||
|
export const submissionsApiSyncScopesStatusOptions = (options: Options<SubmissionsApiSyncScopesStatusData>) => queryOptions<SubmissionsApiSyncScopesStatusResponse, SubmissionsApiSyncScopesStatusError, SubmissionsApiSyncScopesStatusResponse, ReturnType<typeof submissionsApiSyncScopesStatusQueryKey>>({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await submissionsApiSyncScopesStatus({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: submissionsApiSyncScopesStatusQueryKey(options)
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync Scopes
|
* Sync Scopes
|
||||||
*
|
*
|
||||||
|
|||||||
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, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesErrors, SubmissionsApiSyncScopesResponses } from './types.gen';
|
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses, SubmissionsApiStopSyncScopesData, SubmissionsApiStopSyncScopesErrors, SubmissionsApiStopSyncScopesResponses, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesErrors, SubmissionsApiSyncScopesResponses, SubmissionsApiSyncScopesStatusData, SubmissionsApiSyncScopesStatusErrors, SubmissionsApiSyncScopesStatusResponses } from './types.gen';
|
||||||
|
|
||||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
|
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 });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop Sync Scopes
|
||||||
|
*
|
||||||
|
* Stop the AAndT scope sync loop of a (non custom path) game, superusers
|
||||||
|
* only. Cancels its scheduled job, scopes are left as they are.
|
||||||
|
*/
|
||||||
|
export const submissionsApiStopSyncScopes = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiStopSyncScopesData, ThrowOnError>) => (options.client ?? client).delete<SubmissionsApiStopSyncScopesResponses, SubmissionsApiStopSyncScopesErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/{game_path}/sync-scopes', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync Scopes Status
|
||||||
|
*
|
||||||
|
* State of the AAndT scope sync loop of a (non custom path) game,
|
||||||
|
* superusers only.
|
||||||
|
*/
|
||||||
|
export const submissionsApiSyncScopesStatus = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiSyncScopesStatusData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiSyncScopesStatusResponses, SubmissionsApiSyncScopesStatusErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/{game_path}/sync-scopes', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync Scopes
|
* Sync Scopes
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1000,6 +1000,44 @@ export type ScopeOut = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SyncScopesStatusOut
|
||||||
|
*/
|
||||||
|
export type SyncScopesStatusOut = {
|
||||||
|
/**
|
||||||
|
* Running
|
||||||
|
*/
|
||||||
|
running: boolean;
|
||||||
|
/**
|
||||||
|
* Has Token
|
||||||
|
*/
|
||||||
|
has_token: boolean;
|
||||||
|
/**
|
||||||
|
* Aandt Status
|
||||||
|
*/
|
||||||
|
aandt_status?: string | null;
|
||||||
|
/**
|
||||||
|
* Last Run At
|
||||||
|
*/
|
||||||
|
last_run_at?: string | null;
|
||||||
|
/**
|
||||||
|
* Next Run At
|
||||||
|
*/
|
||||||
|
next_run_at?: string | null;
|
||||||
|
/**
|
||||||
|
* Error
|
||||||
|
*/
|
||||||
|
error?: string | null;
|
||||||
|
/**
|
||||||
|
* Scope Count
|
||||||
|
*/
|
||||||
|
scope_count: number;
|
||||||
|
/**
|
||||||
|
* Active Scope Count
|
||||||
|
*/
|
||||||
|
active_scope_count: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SyncScopesOut
|
* SyncScopesOut
|
||||||
*/
|
*/
|
||||||
@@ -1020,6 +1058,16 @@ export type SyncScopesIn = {
|
|||||||
apibot_token?: string | null;
|
apibot_token?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SyncScopesStopOut
|
||||||
|
*/
|
||||||
|
export type SyncScopesStopOut = {
|
||||||
|
/**
|
||||||
|
* Stopped
|
||||||
|
*/
|
||||||
|
stopped: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ImageCropOut
|
* ImageCropOut
|
||||||
*/
|
*/
|
||||||
@@ -1893,6 +1941,82 @@ export type SubmissionsApiListAvailableScopesResponses = {
|
|||||||
|
|
||||||
export type SubmissionsApiListAvailableScopesResponse = SubmissionsApiListAvailableScopesResponses[keyof SubmissionsApiListAvailableScopesResponses];
|
export type SubmissionsApiListAvailableScopesResponse = SubmissionsApiListAvailableScopesResponses[keyof SubmissionsApiListAvailableScopesResponses];
|
||||||
|
|
||||||
|
export type SubmissionsApiStopSyncScopesData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
/**
|
||||||
|
* Event Path
|
||||||
|
*/
|
||||||
|
event_path: string;
|
||||||
|
/**
|
||||||
|
* Game Path
|
||||||
|
*/
|
||||||
|
game_path: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/events/{event_path}/submissions/{game_path}/sync-scopes';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubmissionsApiStopSyncScopesErrors = {
|
||||||
|
/**
|
||||||
|
* Response
|
||||||
|
*
|
||||||
|
* Forbidden
|
||||||
|
*/
|
||||||
|
403: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubmissionsApiStopSyncScopesError = SubmissionsApiStopSyncScopesErrors[keyof SubmissionsApiStopSyncScopesErrors];
|
||||||
|
|
||||||
|
export type SubmissionsApiStopSyncScopesResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: SyncScopesStopOut;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubmissionsApiStopSyncScopesResponse = SubmissionsApiStopSyncScopesResponses[keyof SubmissionsApiStopSyncScopesResponses];
|
||||||
|
|
||||||
|
export type SubmissionsApiSyncScopesStatusData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
/**
|
||||||
|
* Event Path
|
||||||
|
*/
|
||||||
|
event_path: string;
|
||||||
|
/**
|
||||||
|
* Game Path
|
||||||
|
*/
|
||||||
|
game_path: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/events/{event_path}/submissions/{game_path}/sync-scopes';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubmissionsApiSyncScopesStatusErrors = {
|
||||||
|
/**
|
||||||
|
* Response
|
||||||
|
*
|
||||||
|
* Forbidden
|
||||||
|
*/
|
||||||
|
403: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubmissionsApiSyncScopesStatusError = SubmissionsApiSyncScopesStatusErrors[keyof SubmissionsApiSyncScopesStatusErrors];
|
||||||
|
|
||||||
|
export type SubmissionsApiSyncScopesStatusResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: SyncScopesStatusOut;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SubmissionsApiSyncScopesStatusResponse = SubmissionsApiSyncScopesStatusResponses[keyof SubmissionsApiSyncScopesStatusResponses];
|
||||||
|
|
||||||
export type SubmissionsApiSyncScopesData = {
|
export type SubmissionsApiSyncScopesData = {
|
||||||
body: SyncScopesIn;
|
body: SyncScopesIn;
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import {
|
||||||
|
submissionsApiStopSyncScopesMutation,
|
||||||
|
submissionsApiSyncScopesMutation,
|
||||||
|
submissionsApiSyncScopesStatusOptions,
|
||||||
|
submissionsApiSyncScopesStatusQueryKey,
|
||||||
|
} from "@/api/@tanstack/vue-query.gen";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
eventPath: string;
|
||||||
|
gamePath: string;
|
||||||
|
gameName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const open = defineModel<boolean>("open", { required: true });
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const path = computed(() => ({ event_path: props.eventPath, game_path: props.gamePath }));
|
||||||
|
const statusKey = computed(() => submissionsApiSyncScopesStatusQueryKey({ path: path.value }));
|
||||||
|
|
||||||
|
// Polled while open, so admins see each run land (last/next run, errors).
|
||||||
|
const { data: status, isLoading } = useQuery(
|
||||||
|
computed(() => ({
|
||||||
|
...submissionsApiSyncScopesStatusOptions({ path: path.value }),
|
||||||
|
enabled: open.value,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const apibotToken = ref("");
|
||||||
|
const message = ref<{ type: "success" | "info" | "error"; text: string } | null>(null);
|
||||||
|
|
||||||
|
watch(open, (isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
apibotToken.value = "";
|
||||||
|
message.value = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshStatus = () => queryClient.invalidateQueries({ queryKey: statusKey.value });
|
||||||
|
|
||||||
|
const start = useMutation({
|
||||||
|
...submissionsApiSyncScopesMutation(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
apibotToken.value = "";
|
||||||
|
message.value = data.started
|
||||||
|
? { type: "success", text: t("scopeSync.started") }
|
||||||
|
: { type: "info", text: t("scopeSync.alreadyRunning") };
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.value = { type: "error", text: t("scopeSync.startFailed") };
|
||||||
|
},
|
||||||
|
onSettled: refreshStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stop = useMutation({
|
||||||
|
...submissionsApiStopSyncScopesMutation(),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
message.value = data.stopped
|
||||||
|
? { type: "success", text: t("scopeSync.stopped") }
|
||||||
|
: { type: "info", text: t("scopeSync.notRunning") };
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.value = { type: "error", text: t("scopeSync.stopFailed") };
|
||||||
|
},
|
||||||
|
onSettled: refreshStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
const busy = computed(() => start.isPending.value || stop.isPending.value);
|
||||||
|
const hasToken = computed(() => status.value?.has_token ?? false);
|
||||||
|
const tokenTyped = computed(() => apibotToken.value.trim() !== "");
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
start.mutate({
|
||||||
|
path: path.value,
|
||||||
|
body: { apibot_token: tokenTyped.value ? apibotToken.value.trim() : null },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const AANDT_STATUSES: Record<string, string> = {
|
||||||
|
"0prep": "prep",
|
||||||
|
"1publ": "published",
|
||||||
|
"3insc": "registrations",
|
||||||
|
"5inscdone": "registrationsDone",
|
||||||
|
"7inprog": "inProgress",
|
||||||
|
"9done": "done",
|
||||||
|
"11reviewed": "reviewed",
|
||||||
|
};
|
||||||
|
|
||||||
|
const aandtStatusLabel = computed(() => {
|
||||||
|
const value = status.value?.aandt_status;
|
||||||
|
if (!value) return t("scopeSync.unknown");
|
||||||
|
return AANDT_STATUSES[value] ? t(`scopeSync.aandtStatus.${AANDT_STATUSES[value]}`) : value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDate = (value?: string | null) =>
|
||||||
|
value ? dayjs(value).format("DD/MM HH:mm:ss") : t("scopeSync.never");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="['modal', { 'modal-open': open }]">
|
||||||
|
<div class="modal-box w-11/12 max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<h3 class="font-bold text-xl flex items-center gap-2">
|
||||||
|
<i class="mdi mdi-sync"></i>
|
||||||
|
{{ t("scopeSync.title", { game: gameName }) }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- What this actually does -->
|
||||||
|
<div class="mt-4 space-y-3 text-base-content/80">
|
||||||
|
<p>{{ t("scopeSync.intro") }}</p>
|
||||||
|
<ul class="list-disc pl-6 space-y-1">
|
||||||
|
<li>{{ t("scopeSync.whatScopes") }}</li>
|
||||||
|
<li>{{ t("scopeSync.whatPlayers") }}</li>
|
||||||
|
<li>{{ t("scopeSync.whatClose") }}</li>
|
||||||
|
<li>{{ t("scopeSync.whatLink") }}</li>
|
||||||
|
<li>{{ t("scopeSync.whatLive") }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info mt-4 items-start">
|
||||||
|
<i class="mdi mdi-autorenew text-2xl"></i>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold">{{ t("scopeSync.autoTitle") }}</h4>
|
||||||
|
<p class="text-sm">{{ t("scopeSync.autoInterval") }}</p>
|
||||||
|
<p class="text-sm">{{ t("scopeSync.autoStop") }}</p>
|
||||||
|
<p class="text-sm">{{ t("scopeSync.autoErrors") }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current state -->
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="font-semibold mb-2">{{ t("scopeSync.statusTitle") }}</h4>
|
||||||
|
<div v-if="isLoading" class="flex justify-center py-6">
|
||||||
|
<span class="loading loading-spinner"></span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="status" class="stats stats-vertical md:stats-horizontal shadow w-full">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">{{ t("scopeSync.loop") }}</div>
|
||||||
|
<div class="stat-value text-lg flex items-center gap-2">
|
||||||
|
<span :class="['badge', status.running ? 'badge-success' : 'badge-ghost']">
|
||||||
|
{{ status.running ? t("scopeSync.running") : t("scopeSync.notRunningShort") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-desc">
|
||||||
|
{{ t("scopeSync.nextRun") }}: {{ status.running ? formatDate(status.next_run_at) : "—" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">{{ t("scopeSync.aandt") }}</div>
|
||||||
|
<div class="stat-value text-lg">{{ aandtStatusLabel }}</div>
|
||||||
|
<div class="stat-desc">{{ t("scopeSync.lastRun") }}: {{ formatDate(status.last_run_at) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-title">{{ t("scopeSync.scopes") }}</div>
|
||||||
|
<div class="stat-value text-lg">{{ status.active_scope_count }} / {{ status.scope_count }}</div>
|
||||||
|
<div class="stat-desc">{{ t("scopeSync.activeOfTotal") }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="status?.error" class="alert alert-error mt-3">
|
||||||
|
<i class="mdi mdi-alert-circle-outline"></i>
|
||||||
|
<span>{{ t("scopeSync.lastError") }}: {{ status.error }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token -->
|
||||||
|
<form class="mt-6" @submit.prevent="submit">
|
||||||
|
<label class="font-semibold block mb-1" for="apibot-token">{{ t("scopeSync.tokenTitle") }}</label>
|
||||||
|
<p class="text-sm text-base-content/70 mb-2">
|
||||||
|
{{ hasToken ? t("scopeSync.tokenReplace") : t("scopeSync.tokenMissing") }}
|
||||||
|
</p>
|
||||||
|
<input id="apibot-token" v-model="apibotToken" type="password" autocomplete="off"
|
||||||
|
:placeholder="hasToken ? t('scopeSync.tokenConfigured') : t('scopeSync.tokenPlaceholder')"
|
||||||
|
class="input input-bordered w-full" />
|
||||||
|
|
||||||
|
<div v-if="message" :class="['alert mt-4', `alert-${message.type}`]">{{ message.text }}</div>
|
||||||
|
|
||||||
|
<div class="modal-action flex-wrap">
|
||||||
|
<button type="button" @click="open = false" class="btn">{{ t("common.close") }}</button>
|
||||||
|
<button v-if="status?.running" type="button" :disabled="busy" class="btn btn-error"
|
||||||
|
@click="stop.mutate({ path })">
|
||||||
|
<span v-if="stop.isPending.value" class="loading loading-spinner loading-xs"></span>
|
||||||
|
<i v-else class="mdi mdi-stop"></i>
|
||||||
|
{{ t("scopeSync.stop") }}
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary"
|
||||||
|
:disabled="busy || (!hasToken && !tokenTyped) || (status?.running && !tokenTyped)">
|
||||||
|
<span v-if="start.isPending.value" class="loading loading-spinner loading-xs"></span>
|
||||||
|
<i v-else class="mdi mdi-play"></i>
|
||||||
|
{{ status?.running ? t("scopeSync.saveToken") : t("scopeSync.start") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop" @click="open = false"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -33,9 +33,26 @@ interface ImageCropUpdate {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebSocketData = SubmissionUpdate | SubmissionFileUpdate | ImageCropUpdate
|
// A scope (match) the user was or is assigned to opened/closed, or the user
|
||||||
|
// got (un)assigned from it - see broadcast_scope_updates in signals.py.
|
||||||
|
export interface ScopeUpdate {
|
||||||
|
id: string
|
||||||
|
is_active: boolean
|
||||||
|
assigned: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export function useSubmissionsWebSocket(userSubmissions: any) {
|
type WebSocketData =
|
||||||
|
| SubmissionUpdate
|
||||||
|
| SubmissionFileUpdate
|
||||||
|
| ImageCropUpdate
|
||||||
|
| { scope: ScopeUpdate }
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
userSubmissions?: any
|
||||||
|
onScopeUpdate?: (scope: ScopeUpdate) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSubmissionsWebSocket({ userSubmissions, onScopeUpdate }: Options) {
|
||||||
const isConnected = ref(false)
|
const isConnected = ref(false)
|
||||||
|
|
||||||
const wsUrl = computed(() => {
|
const wsUrl = computed(() => {
|
||||||
@@ -64,7 +81,11 @@ export function useSubmissionsWebSocket(userSubmissions: any) {
|
|||||||
const messageData = event.data as string
|
const messageData = event.data as string
|
||||||
const data = JSON.parse(messageData) as WebSocketData
|
const data = JSON.parse(messageData) as WebSocketData
|
||||||
|
|
||||||
if ('files' in data) {
|
if ('scope' in data) {
|
||||||
|
onScopeUpdate?.(data.scope)
|
||||||
|
} else if (!userSubmissions) {
|
||||||
|
return
|
||||||
|
} else if ('files' in data) {
|
||||||
handleSubmissionUpdate(data as SubmissionUpdate)
|
handleSubmissionUpdate(data as SubmissionUpdate)
|
||||||
} else if ('original_filename' in data) {
|
} else if ('original_filename' in data) {
|
||||||
handleFileUpdate(data as SubmissionFileUpdate)
|
handleFileUpdate(data as SubmissionFileUpdate)
|
||||||
|
|||||||
@@ -210,14 +210,55 @@
|
|||||||
"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",
|
"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.",
|
"scopeSync": {
|
||||||
"syncFailed": "Failed to start the scope sync.",
|
"title": "AAndT scope sync — {game}",
|
||||||
|
"intro": "Keeps this game's matches (scopes) in line with its AAndT on the PolyLAN site, using the AAndT's apibot API.",
|
||||||
|
"whatScopes": "Every AAndT match becomes a scope here, created or updated (name, open/closed) on each sync.",
|
||||||
|
"whatPlayers": "Players of each match are assigned to its scope, matched through their PolyLAN account. Players without an account here yet are skipped until they log in once.",
|
||||||
|
"whatClose": "A scope is open only while its match is playing. Finished matches, and matches removed from the AAndT, get closed.",
|
||||||
|
"whatLink": "Each playing match gets a \"Submit your proof here\" link on the PolyLAN site, pointing to its submission page here.",
|
||||||
|
"whatLive": "Players are moved live: when their match closes, they're sent back to the match list, and straight into their next match if there's only one. No refresh needed.",
|
||||||
|
"autoTitle": "Starts once, then keeps going on its own",
|
||||||
|
"autoInterval": "After starting, the sync re-triggers itself: every 15 seconds while the AAndT is in progress, every minute before that (registrations, preparation, ...).",
|
||||||
|
"autoStop": "It stops by itself once the AAndT is done or reviewed, or when you stop it here (or with the \"Stop scope sync\" admin action). Scopes are kept as they are when it stops.",
|
||||||
|
"autoErrors": "If a sync fails (AAndT site down, wrong token, ...), it's retried a minute later. The last error is shown below.",
|
||||||
|
"statusTitle": "Current state",
|
||||||
|
"loop": "Sync",
|
||||||
|
"running": "Running",
|
||||||
|
"notRunningShort": "Stopped",
|
||||||
|
"nextRun": "Next run",
|
||||||
|
"lastRun": "Last run",
|
||||||
|
"aandt": "AAndT status",
|
||||||
|
"scopes": "Scopes",
|
||||||
|
"activeOfTotal": "open / total",
|
||||||
|
"lastError": "Last run failed",
|
||||||
|
"never": "never",
|
||||||
|
"unknown": "Unknown",
|
||||||
|
"aandtStatus": {
|
||||||
|
"prep": "Preparation",
|
||||||
|
"published": "Published",
|
||||||
|
"registrations": "Registrations",
|
||||||
|
"registrationsDone": "Registrations closed",
|
||||||
|
"inProgress": "In progress",
|
||||||
|
"done": "Done",
|
||||||
|
"reviewed": "Reviewed"
|
||||||
|
},
|
||||||
"tokenTitle": "AAndT apibot token",
|
"tokenTitle": "AAndT apibot token",
|
||||||
"tokenDescription": "No apibot token is configured for this game yet. Paste the one from the AAndT's 'apibot' admin page.",
|
"tokenMissing": "No token is configured for this game yet. Paste the one from the AAndT's \"apibot\" admin page on the PolyLAN site.",
|
||||||
|
"tokenReplace": "A token is already configured. Paste a new one only to replace it (e.g. for a new AAndT); a running sync picks it up on its next run.",
|
||||||
"tokenPlaceholder": "Apibot token",
|
"tokenPlaceholder": "Apibot token",
|
||||||
"tokenSubmit": "Save & sync"
|
"tokenConfigured": "Token configured (leave empty to keep it)",
|
||||||
|
"start": "Start sync",
|
||||||
|
"saveToken": "Save token",
|
||||||
|
"stop": "Stop sync",
|
||||||
|
"started": "Sync started. It keeps running on its own until the AAndT is done.",
|
||||||
|
"alreadyRunning": "Sync is already running.",
|
||||||
|
"startFailed": "Failed to start the sync.",
|
||||||
|
"stopped": "Sync stopped.",
|
||||||
|
"notRunning": "Sync wasn't running.",
|
||||||
|
"stopFailed": "Failed to stop the sync."
|
||||||
},
|
},
|
||||||
"errorPage": {
|
"errorPage": {
|
||||||
"notFound": {
|
"notFound": {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from .models import Submission, SubmissionFile, ImageCrop, Scope
|
from .models import Submission, SubmissionFile, ImageCrop, Scope
|
||||||
|
from .signals import broadcast_scope_updates
|
||||||
|
|
||||||
|
|
||||||
class ImageCropInline(admin.TabularInline):
|
class ImageCropInline(admin.TabularInline):
|
||||||
@@ -77,6 +78,45 @@ class ScopeAdmin(admin.ModelAdmin):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Players on a scope edited here get moved like with the AAndT sync (see
|
||||||
|
# broadcast_scope_updates): state before the save is kept on the instance,
|
||||||
|
# compared once the assigned users (m2m) are saved too in save_related.
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
previous = Scope.objects.filter(pk=obj.pk).first() if change else None
|
||||||
|
obj._state_before_save = (
|
||||||
|
(
|
||||||
|
previous.is_active,
|
||||||
|
set(previous.assigned_users.values_list("id", flat=True)),
|
||||||
|
)
|
||||||
|
if previous
|
||||||
|
else (False, set())
|
||||||
|
)
|
||||||
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
|
def save_related(self, request, form, formsets, change):
|
||||||
|
super().save_related(request, form, formsets, change)
|
||||||
|
|
||||||
|
scope = form.instance
|
||||||
|
was_active, previous_user_ids = scope._state_before_save
|
||||||
|
user_ids = set(scope.assigned_users.values_list("id", flat=True))
|
||||||
|
|
||||||
|
if scope.is_active != was_active or user_ids != previous_user_ids:
|
||||||
|
broadcast_scope_updates({scope: user_ids | previous_user_ids})
|
||||||
|
|
||||||
|
def delete_model(self, request, obj):
|
||||||
|
self.delete_queryset(request, Scope.objects.filter(pk=obj.pk))
|
||||||
|
|
||||||
|
def delete_queryset(self, request, queryset):
|
||||||
|
changes = {
|
||||||
|
scope: {user.id for user in scope.assigned_users.all()}
|
||||||
|
for scope in queryset.prefetch_related("assigned_users")
|
||||||
|
}
|
||||||
|
for scope in changes:
|
||||||
|
scope.is_active = False
|
||||||
|
|
||||||
|
super().delete_queryset(request, queryset)
|
||||||
|
broadcast_scope_updates(changes)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Submission)
|
@admin.register(Submission)
|
||||||
class SubmissionAdmin(admin.ModelAdmin):
|
class SubmissionAdmin(admin.ModelAdmin):
|
||||||
|
|||||||
@@ -14,8 +14,16 @@ from .schemas import (
|
|||||||
ScopeOut,
|
ScopeOut,
|
||||||
SyncScopesIn,
|
SyncScopesIn,
|
||||||
SyncScopesOut,
|
SyncScopesOut,
|
||||||
|
SyncScopesStatusOut,
|
||||||
|
SyncScopesStopOut,
|
||||||
|
)
|
||||||
|
from .tasks import (
|
||||||
|
get_scope_sync_state,
|
||||||
|
process_submission_file,
|
||||||
|
process_crop,
|
||||||
|
schedule_scope_sync,
|
||||||
|
stop_scope_sync,
|
||||||
)
|
)
|
||||||
from .tasks import process_submission_file, process_crop, schedule_scope_sync
|
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
@@ -73,6 +81,37 @@ def list_available_scopes(request: HttpRequest, event_path: str, game_path: str)
|
|||||||
return list(scopes.order_by("name"))
|
return list(scopes.order_by("name"))
|
||||||
|
|
||||||
|
|
||||||
|
def _syncable_event_game(request: HttpRequest, game_path: str) -> EventGame:
|
||||||
|
return get_object_or_404(
|
||||||
|
EventGame,
|
||||||
|
event=request.event,
|
||||||
|
game__path=game_path,
|
||||||
|
game__custom_path=False,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
f"{PREFIX}/{{game_path}}/sync-scopes",
|
||||||
|
response={200: SyncScopesStatusOut, 403: dict},
|
||||||
|
)
|
||||||
|
def sync_scopes_status(request: HttpRequest, event_path: str, game_path: str):
|
||||||
|
"""State of the AAndT scope sync loop of a (non custom path) game,
|
||||||
|
superusers only."""
|
||||||
|
if not request.user.is_superuser:
|
||||||
|
return 403, {"detail": "Superuser required"}
|
||||||
|
|
||||||
|
event_game = _syncable_event_game(request, game_path)
|
||||||
|
scopes = Scope.objects.filter(event_game=event_game)
|
||||||
|
|
||||||
|
return {
|
||||||
|
**vars(get_scope_sync_state(event_game.id)),
|
||||||
|
"has_token": bool(event_game.apibot_token),
|
||||||
|
"scope_count": scopes.count(),
|
||||||
|
"active_scope_count": scopes.filter(is_active=True).count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
f"{PREFIX}/{{game_path}}/sync-scopes",
|
f"{PREFIX}/{{game_path}}/sync-scopes",
|
||||||
response={200: SyncScopesOut, 400: dict, 403: dict},
|
response={200: SyncScopesOut, 400: dict, 403: dict},
|
||||||
@@ -86,13 +125,7 @@ def sync_scopes(
|
|||||||
if not request.user.is_superuser:
|
if not request.user.is_superuser:
|
||||||
return 403, {"detail": "Superuser required"}
|
return 403, {"detail": "Superuser required"}
|
||||||
|
|
||||||
event_game = get_object_or_404(
|
event_game = _syncable_event_game(request, game_path)
|
||||||
EventGame,
|
|
||||||
event=request.event,
|
|
||||||
game__path=game_path,
|
|
||||||
game__custom_path=False,
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
token = (payload.apibot_token or "").strip()
|
token = (payload.apibot_token or "").strip()
|
||||||
if token:
|
if token:
|
||||||
@@ -108,6 +141,21 @@ def sync_scopes(
|
|||||||
return {"started": schedule_scope_sync(event_game.id)}
|
return {"started": schedule_scope_sync(event_game.id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
f"{PREFIX}/{{game_path}}/sync-scopes",
|
||||||
|
response={200: SyncScopesStopOut, 403: dict},
|
||||||
|
)
|
||||||
|
def stop_sync_scopes(request: HttpRequest, event_path: str, game_path: str):
|
||||||
|
"""Stop the AAndT scope sync loop of a (non custom path) game, superusers
|
||||||
|
only. Cancels its scheduled job, scopes are left as they are."""
|
||||||
|
if not request.user.is_superuser:
|
||||||
|
return 403, {"detail": "Superuser required"}
|
||||||
|
|
||||||
|
event_game = _syncable_event_game(request, game_path)
|
||||||
|
|
||||||
|
return {"stopped": stop_scope_sync(event_game.id)}
|
||||||
|
|
||||||
|
|
||||||
@router.post(f"{PREFIX}/submission/create", response={200: SubmissionOut, 403: dict})
|
@router.post(f"{PREFIX}/submission/create", response={200: SubmissionOut, 403: dict})
|
||||||
def create_submission(
|
def create_submission(
|
||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
|
|||||||
@@ -29,3 +29,6 @@ class SubmissionsConsumer(AsyncWebsocketConsumer):
|
|||||||
async def submission_crop_updated(self, event):
|
async def submission_crop_updated(self, event):
|
||||||
crop_data = event["crop"]
|
crop_data = event["crop"]
|
||||||
await self.send(text_data=json.dumps(crop_data))
|
await self.send(text_data=json.dumps(crop_data))
|
||||||
|
|
||||||
|
async def scope_updated(self, event):
|
||||||
|
await self.send(text_data=json.dumps({"scope": event["scope"]}))
|
||||||
|
|||||||
@@ -19,6 +19,24 @@ class SyncScopesOut(Schema):
|
|||||||
started: bool
|
started: bool
|
||||||
|
|
||||||
|
|
||||||
|
class SyncScopesStopOut(Schema):
|
||||||
|
# False when no sync loop was running
|
||||||
|
stopped: bool
|
||||||
|
|
||||||
|
|
||||||
|
class SyncScopesStatusOut(Schema):
|
||||||
|
running: bool
|
||||||
|
has_token: bool
|
||||||
|
# Last AAndT status seen (e.g. "7inprog"), see games' AAndT status values
|
||||||
|
aandt_status: str | None = None
|
||||||
|
last_run_at: datetime | None = None
|
||||||
|
next_run_at: datetime | None = None
|
||||||
|
# Last run's error, if it failed
|
||||||
|
error: str | None = None
|
||||||
|
scope_count: int
|
||||||
|
active_scope_count: int
|
||||||
|
|
||||||
|
|
||||||
class ImageCropIn(Schema):
|
class ImageCropIn(Schema):
|
||||||
x1: int
|
x1: int
|
||||||
y1: int
|
y1: int
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from accounts.models import CustomUser
|
|||||||
from games.models import EventGame
|
from games.models import EventGame
|
||||||
|
|
||||||
from ..models import Scope
|
from ..models import Scope
|
||||||
|
from ..signals import broadcast_scope_updates
|
||||||
from .aandt_client import AuthenticatedClient
|
from .aandt_client import AuthenticatedClient
|
||||||
from .aandt_client.api.default import (
|
from .aandt_client.api.default import (
|
||||||
aandt_api_infos,
|
aandt_api_infos,
|
||||||
@@ -120,6 +121,9 @@ def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
|
|||||||
Scopes whose match is no longer returned by the API (deleted, or from a
|
Scopes whose match is no longer returned by the API (deleted, or from a
|
||||||
previous AAndT run reusing the same token) are deactivated.
|
previous AAndT run reusing the same token) are deactivated.
|
||||||
|
|
||||||
|
Users of scopes that opened/closed or got their players changed are
|
||||||
|
notified over the submissions websocket (see `broadcast_scope_updates`).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event_game: The EventGame to sync; must have `apibot_token` set.
|
event_game: The EventGame to sync; must have `apibot_token` set.
|
||||||
|
|
||||||
@@ -173,10 +177,11 @@ def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
|
|||||||
result = ScopeSyncResult(status=status.value if status is not None else None)
|
result = ScopeSyncResult(status=status.value if status is not None else None)
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
synced_match_ids: set[int] = set()
|
synced_match_ids: set[int] = set()
|
||||||
|
changes: dict[Scope, set[int]] = {}
|
||||||
|
|
||||||
for match in matches:
|
for match in matches:
|
||||||
match_id = _unwrap(match.id)
|
match_id = _unwrap(match.id)
|
||||||
created, warnings = _sync_match(
|
created, warnings, change = _sync_match(
|
||||||
event_game, client, match, userids_by_member_id, now
|
event_game, client, match, userids_by_member_id, now
|
||||||
)
|
)
|
||||||
if created is None:
|
if created is None:
|
||||||
@@ -185,14 +190,22 @@ def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
|
|||||||
result.created += created
|
result.created += created
|
||||||
result.updated += not created
|
result.updated += not created
|
||||||
result.warnings.extend(warnings)
|
result.warnings.extend(warnings)
|
||||||
|
if change is not None:
|
||||||
|
changes[change[0]] = change[1]
|
||||||
|
|
||||||
(
|
gone = list(
|
||||||
Scope.objects.filter(
|
Scope.objects.filter(
|
||||||
event_game=event_game, is_active=True, external_id__isnull=False
|
event_game=event_game, is_active=True, external_id__isnull=False
|
||||||
)
|
)
|
||||||
.exclude(external_id__in=synced_match_ids)
|
.exclude(external_id__in=synced_match_ids)
|
||||||
.update(is_active=False)
|
.prefetch_related("assigned_users")
|
||||||
)
|
)
|
||||||
|
Scope.objects.filter(id__in=[scope.id for scope in gone]).update(is_active=False)
|
||||||
|
for scope in gone:
|
||||||
|
scope.is_active = False
|
||||||
|
changes[scope] = {user.id for user in scope.assigned_users.all()}
|
||||||
|
|
||||||
|
broadcast_scope_updates(changes)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -203,10 +216,17 @@ def _sync_match(
|
|||||||
match: Match,
|
match: Match,
|
||||||
userids_by_member_id: dict[int, set[int]],
|
userids_by_member_id: dict[int, set[int]],
|
||||||
now: datetime,
|
now: datetime,
|
||||||
) -> tuple[bool | None, list[str]]:
|
) -> tuple[bool | None, list[str], tuple[Scope, set[int]] | None]:
|
||||||
|
"""Upsert the Scope of one match.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Whether the Scope was created (None when the match has no id), the
|
||||||
|
warnings, and when it opened/closed or its players changed, the Scope
|
||||||
|
with every user id that was or now is assigned to it.
|
||||||
|
"""
|
||||||
match_id = _unwrap(match.id)
|
match_id = _unwrap(match.id)
|
||||||
if match_id is None:
|
if match_id is None:
|
||||||
return None, []
|
return None, [], None
|
||||||
|
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
|
|
||||||
@@ -218,6 +238,12 @@ def _sync_match(
|
|||||||
|
|
||||||
users = list(CustomUser.objects.filter(cas_user_id__in=userids))
|
users = list(CustomUser.objects.filter(cas_user_id__in=userids))
|
||||||
|
|
||||||
|
previous = Scope.objects.filter(event_game=event_game, external_id=match_id).first()
|
||||||
|
was_active = previous.is_active if previous else False
|
||||||
|
previous_user_ids = (
|
||||||
|
set(previous.assigned_users.values_list("id", flat=True)) if previous else set()
|
||||||
|
)
|
||||||
|
|
||||||
scope, created = Scope.objects.update_or_create(
|
scope, created = Scope.objects.update_or_create(
|
||||||
event_game=event_game,
|
event_game=event_game,
|
||||||
external_id=match_id,
|
external_id=match_id,
|
||||||
@@ -241,7 +267,12 @@ def _sync_match(
|
|||||||
if scope.is_active:
|
if scope.is_active:
|
||||||
warnings.extend(_push_submission_link(client, match_id, scope))
|
warnings.extend(_push_submission_link(client, match_id, scope))
|
||||||
|
|
||||||
return created, warnings
|
user_ids = {user.id for user in users}
|
||||||
|
change = None
|
||||||
|
if scope.is_active != was_active or user_ids != previous_user_ids:
|
||||||
|
change = (scope, user_ids | previous_user_ids)
|
||||||
|
|
||||||
|
return created, warnings, change
|
||||||
|
|
||||||
|
|
||||||
def _push_submission_link(
|
def _push_submission_link(
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
|
from django.db import transaction
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
|
|
||||||
from submissions.models import Submission, SubmissionFile, ImageCrop
|
from submissions.models import Submission, SubmissionFile, ImageCrop, Scope
|
||||||
from submissions.schemas import SubmissionOut, SubmissionFileOut, ImageCropOut
|
from submissions.schemas import SubmissionOut, SubmissionFileOut, ImageCropOut
|
||||||
|
|
||||||
|
|
||||||
channel_layer = get_channel_layer()
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Submission)
|
@receiver(post_save, sender=Submission)
|
||||||
def broadcast_submission_update(sender, instance, created, **kwargs):
|
def broadcast_submission_update(sender, instance, created, **kwargs):
|
||||||
if not instance.user:
|
if not instance.user:
|
||||||
@@ -18,7 +16,7 @@ def broadcast_submission_update(sender, instance, created, **kwargs):
|
|||||||
user_group_name = f"submissions_user_{instance.user.id}"
|
user_group_name = f"submissions_user_{instance.user.id}"
|
||||||
submission_data = SubmissionOut.from_orm(instance).dict()
|
submission_data = SubmissionOut.from_orm(instance).dict()
|
||||||
|
|
||||||
async_to_sync(channel_layer.group_send)(
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
user_group_name,
|
user_group_name,
|
||||||
{
|
{
|
||||||
"type": "submission.updated",
|
"type": "submission.updated",
|
||||||
@@ -36,7 +34,7 @@ def broadcast_submission_file_update(sender, instance, created, **kwargs):
|
|||||||
user_group_name = f"submissions_user_{submission.user.id}"
|
user_group_name = f"submissions_user_{submission.user.id}"
|
||||||
file_data = SubmissionFileOut.from_orm(instance).dict()
|
file_data = SubmissionFileOut.from_orm(instance).dict()
|
||||||
|
|
||||||
async_to_sync(channel_layer.group_send)(
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
user_group_name,
|
user_group_name,
|
||||||
{
|
{
|
||||||
"type": "submission.file.updated",
|
"type": "submission.file.updated",
|
||||||
@@ -54,10 +52,43 @@ def broadcast_image_crop_update(sender, instance, created, **kwargs):
|
|||||||
user_group_name = f"submissions_user_{submission.user.id}"
|
user_group_name = f"submissions_user_{submission.user.id}"
|
||||||
crop_data = ImageCropOut.from_orm(instance).dict()
|
crop_data = ImageCropOut.from_orm(instance).dict()
|
||||||
|
|
||||||
async_to_sync(channel_layer.group_send)(
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
user_group_name,
|
user_group_name,
|
||||||
{
|
{
|
||||||
"type": "submission.crop.updated",
|
"type": "submission.crop.updated",
|
||||||
"crop": crop_data,
|
"crop": crop_data,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def broadcast_scope_updates(changes: dict[Scope, set[int]], /):
|
||||||
|
"""Tell users their scope changed (closed/opened, (un)assigned), once the
|
||||||
|
current transaction commits, so the frontend can move them to the right
|
||||||
|
match without a refresh.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
changes: Changed scopes, each with every user id that was or now is
|
||||||
|
assigned to it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def send():
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
for scope, user_ids in changes.items():
|
||||||
|
assigned_ids = set(scope.assigned_users.values_list("id", flat=True))
|
||||||
|
for user_id in user_ids:
|
||||||
|
async_to_sync(channel_layer.group_send)(
|
||||||
|
f"submissions_user_{user_id}",
|
||||||
|
{
|
||||||
|
"type": "scope.updated",
|
||||||
|
"scope": {
|
||||||
|
"id": str(scope.id),
|
||||||
|
"is_active": scope.is_active,
|
||||||
|
"assigned": user_id in assigned_ids,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if changes:
|
||||||
|
# robust: a websocket hiccup only gets logged, it mustn't fail the
|
||||||
|
# (already committed) sync or admin save
|
||||||
|
transaction.on_commit(send, robust=True)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import timedelta
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import django_rq
|
||||||
from django.core.cache import cache
|
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
|
||||||
@@ -9,17 +11,18 @@ 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
|
||||||
|
from rq.exceptions import InvalidJobOperation, NoSuchJobError
|
||||||
|
from rq.job import Job, JobStatus
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# How often to repoll an EventGame's AAndT while it's "in progress", and while
|
# 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.
|
# it isn't yet (registrations, preparation, ...) or the run failed.
|
||||||
SCOPE_SYNC_REPOLL_INTERVAL = timedelta(seconds=15)
|
SCOPE_SYNC_REPOLL_INTERVAL = timedelta(seconds=15)
|
||||||
SCOPE_SYNC_IDLE_INTERVAL = timedelta(minutes=1)
|
SCOPE_SYNC_IDLE_INTERVAL = timedelta(minutes=1)
|
||||||
# AAndT statuses after which there's nothing left to sync, the loop stops.
|
# AAndT statuses after which there's nothing left to sync, the loop stops.
|
||||||
@@ -30,10 +33,59 @@ def _scope_sync_guard_key(event_game_id: int) -> str:
|
|||||||
return f"submissions:scope_sync:{event_game_id}"
|
return f"submissions:scope_sync:{event_game_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_sync_job_key(event_game_id: int) -> str:
|
||||||
|
return f"submissions:scope_sync:{event_game_id}:job"
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_sync_state_key(event_game_id: int) -> str:
|
||||||
|
return f"submissions:scope_sync:{event_game_id}:state"
|
||||||
|
|
||||||
|
|
||||||
def _scope_sync_guard_timeout() -> int:
|
def _scope_sync_guard_timeout() -> int:
|
||||||
return int(SCOPE_SYNC_IDLE_INTERVAL.total_seconds() * 3)
|
return int(SCOPE_SYNC_IDLE_INTERVAL.total_seconds() * 3)
|
||||||
|
|
||||||
|
|
||||||
|
# Last run's outcome, shown to admins in the sync modal. Kept around a while
|
||||||
|
# after the loop stops, so they still see why it stopped.
|
||||||
|
SCOPE_SYNC_STATE_TIMEOUT = int(timedelta(days=1).total_seconds())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScopeSyncState:
|
||||||
|
running: bool
|
||||||
|
aandt_status: str | None = None
|
||||||
|
last_run_at: datetime | None = None
|
||||||
|
next_run_at: datetime | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_scope_sync_state(event_game_id: int, /) -> ScopeSyncState:
|
||||||
|
state = cache.get(_scope_sync_state_key(event_game_id)) or {}
|
||||||
|
running = cache.get(_scope_sync_guard_key(event_game_id)) is not None
|
||||||
|
return ScopeSyncState(
|
||||||
|
running=running,
|
||||||
|
aandt_status=state.get("aandt_status"),
|
||||||
|
last_run_at=state.get("last_run_at"),
|
||||||
|
next_run_at=state.get("next_run_at") if running else None,
|
||||||
|
error=state.get("error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _enqueue_scope_sync(event_game_id: int, loop_id: str, run_after: datetime | None):
|
||||||
|
task = (
|
||||||
|
sync_event_game_scopes_task.using(run_after=run_after)
|
||||||
|
if run_after
|
||||||
|
else sync_event_game_scopes_task
|
||||||
|
)
|
||||||
|
result = task.enqueue(event_game_id, loop_id=loop_id)
|
||||||
|
# Same id as the rq job (see django_tasks_rq), so stop_scope_sync can cancel it
|
||||||
|
cache.set(
|
||||||
|
_scope_sync_job_key(event_game_id),
|
||||||
|
result.id,
|
||||||
|
timeout=_scope_sync_guard_timeout(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def schedule_scope_sync(event_game_id: int, /) -> bool:
|
def schedule_scope_sync(event_game_id: int, /) -> bool:
|
||||||
"""Start the sync loop of an EventGame, unless one is already running.
|
"""Start the sync loop of an EventGame, unless one is already running.
|
||||||
Safe to call any number of times (admin button spam, multiple workers...).
|
Safe to call any number of times (admin button spam, multiple workers...).
|
||||||
@@ -49,13 +101,32 @@ def schedule_scope_sync(event_game_id: int, /) -> bool:
|
|||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
sync_event_game_scopes_task.enqueue(event_game_id, loop_id=loop_id)
|
_enqueue_scope_sync(event_game_id, loop_id, run_after=None)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def stop_scope_sync(event_game_id: int, /):
|
def stop_scope_sync(event_game_id: int, /) -> bool:
|
||||||
"""Release the guard, the running loop (if any) stops at its next run."""
|
"""Stop the sync loop of an EventGame: cancels its scheduled rq job and
|
||||||
cache.delete(_scope_sync_guard_key(event_game_id))
|
releases the guard (a run already in progress won't reschedule itself).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Whether a loop was running.
|
||||||
|
"""
|
||||||
|
running = cache.get(_scope_sync_guard_key(event_game_id)) is not None
|
||||||
|
job_id = cache.get(_scope_sync_job_key(event_game_id))
|
||||||
|
cache.delete_many(
|
||||||
|
[_scope_sync_guard_key(event_game_id), _scope_sync_job_key(event_game_id)]
|
||||||
|
)
|
||||||
|
|
||||||
|
if job_id:
|
||||||
|
try:
|
||||||
|
job = Job.fetch(job_id, connection=django_rq.get_connection())
|
||||||
|
if job.get_status() in (JobStatus.SCHEDULED, JobStatus.QUEUED):
|
||||||
|
job.cancel()
|
||||||
|
except (NoSuchJobError, InvalidJobOperation):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return running
|
||||||
|
|
||||||
|
|
||||||
def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool:
|
def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool:
|
||||||
@@ -142,14 +213,14 @@ def sync_event_game_scopes_task(event_game_id: int, loop_id: str | None = None):
|
|||||||
Started once (see schedule_scope_sync), it keeps going on its own without
|
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
|
an external scheduler: every SCOPE_SYNC_REPOLL_INTERVAL while the AAndT is
|
||||||
"in progress" (`7inprog`), every SCOPE_SYNC_IDLE_INTERVAL before that or
|
"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
|
when the run failed. Stops once the AAndT is done/reviewed, when the
|
||||||
EventGame (or its token) is gone, or when another loop took the guard.
|
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.
|
Without loop_id it's a one-off run that leaves the loop alone.
|
||||||
"""
|
"""
|
||||||
guard_key = _scope_sync_guard_key(event_game_id)
|
guard_key = _scope_sync_guard_key(event_game_id)
|
||||||
if loop_id is not None and cache.get(guard_key) != loop_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
|
# Another loop owns the guard (or it expired/was stopped), stop this one
|
||||||
return
|
return
|
||||||
|
|
||||||
event_game = EventGame.objects.filter(id=event_game_id).first()
|
event_game = EventGame.objects.filter(id=event_game_id).first()
|
||||||
@@ -160,27 +231,45 @@ def sync_event_game_scopes_task(event_game_id: int, loop_id: str | None = None):
|
|||||||
return
|
return
|
||||||
|
|
||||||
status = None
|
status = None
|
||||||
|
error = None
|
||||||
try:
|
try:
|
||||||
status = sync_event_game_scopes(event_game).status
|
status = sync_event_game_scopes(event_game).status
|
||||||
except ScopeSyncError:
|
except Exception as exc:
|
||||||
if loop_id is None:
|
if loop_id is None:
|
||||||
raise
|
raise
|
||||||
# Probably transient (AAndT site down, ...), retry later
|
# Retry later rather than letting the loop die (AAndT site down, bug, ...),
|
||||||
|
# it'd otherwise only restart once the guard expires and someone restarts it
|
||||||
logger.exception("Scope sync failed for EventGame %s", event_game_id)
|
logger.exception("Scope sync failed for EventGame %s", event_game_id)
|
||||||
|
error = str(exc) or exc.__class__.__name__
|
||||||
|
|
||||||
if loop_id is None:
|
if loop_id is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if status in AANDT_FINISHED_STATUSES:
|
now = timezone.now()
|
||||||
cache.delete(guard_key)
|
next_run_at = None
|
||||||
|
# Re-checked: the loop may have been stopped while this run was syncing
|
||||||
|
if status not in AANDT_FINISHED_STATUSES and cache.get(guard_key) == loop_id:
|
||||||
|
next_run_at = now + (
|
||||||
|
SCOPE_SYNC_REPOLL_INTERVAL
|
||||||
|
if status == AANDT_STATUS_IN_PROGRESS
|
||||||
|
else SCOPE_SYNC_IDLE_INTERVAL
|
||||||
|
)
|
||||||
|
|
||||||
|
cache.set(
|
||||||
|
_scope_sync_state_key(event_game_id),
|
||||||
|
{
|
||||||
|
"aandt_status": status,
|
||||||
|
"last_run_at": now,
|
||||||
|
"next_run_at": next_run_at,
|
||||||
|
"error": error,
|
||||||
|
},
|
||||||
|
timeout=SCOPE_SYNC_STATE_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if next_run_at is None:
|
||||||
|
if cache.get(guard_key) == loop_id:
|
||||||
|
cache.delete(guard_key)
|
||||||
return
|
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())
|
cache.set(guard_key, loop_id, timeout=_scope_sync_guard_timeout())
|
||||||
sync_event_game_scopes_task.using(run_after=timezone.now() + interval).enqueue(
|
_enqueue_scope_sync(event_game_id, loop_id, run_after=next_run_at)
|
||||||
event_game_id, loop_id=loop_id
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
|
from contextlib import ExitStack
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from rq.job import JobStatus
|
||||||
|
|
||||||
from accounts.models import CustomUser
|
from accounts.models import CustomUser
|
||||||
from events.models import Event
|
from events.models import Event
|
||||||
from games.models import EventGame, Game
|
from games.models import EventGame, Game
|
||||||
|
|
||||||
from . import tasks
|
from . import tasks
|
||||||
from .services.aandt_sync import ScopeSyncError, ScopeSyncResult
|
from .models import Scope
|
||||||
|
from .services.aandt_client.models import (
|
||||||
|
AandtApiInfosResponse200,
|
||||||
|
AandtApiMatchesResponse200,
|
||||||
|
AandtApiMembersResponse200,
|
||||||
|
)
|
||||||
|
from .services.aandt_sync import ScopeSyncError, ScopeSyncResult, sync_event_game_scopes
|
||||||
|
|
||||||
|
|
||||||
class SyncScopesApiTests(TestCase):
|
class SyncScopesApiTests(TestCase):
|
||||||
@@ -59,6 +68,42 @@ class SyncScopesApiTests(TestCase):
|
|||||||
self.assertEqual(self.event_game.apibot_token, "abc")
|
self.assertEqual(self.event_game.apibot_token, "abc")
|
||||||
self.schedule.assert_called_once_with(self.event_game.id)
|
self.schedule.assert_called_once_with(self.event_game.id)
|
||||||
|
|
||||||
|
def test_status(self):
|
||||||
|
Scope.objects.create(event_game=self.event_game, name="M1")
|
||||||
|
Scope.objects.create(event_game=self.event_game, name="M2", is_active=False)
|
||||||
|
self.client.force_login(self.superuser)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"submissions.api.get_scope_sync_state",
|
||||||
|
return_value=tasks.ScopeSyncState(running=True, aandt_status="7inprog"),
|
||||||
|
):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertTrue(data["running"])
|
||||||
|
self.assertFalse(data["has_token"])
|
||||||
|
self.assertEqual(data["aandt_status"], "7inprog")
|
||||||
|
self.assertEqual((data["active_scope_count"], data["scope_count"]), (1, 2))
|
||||||
|
|
||||||
|
def test_stop(self):
|
||||||
|
self.client.force_login(self.superuser)
|
||||||
|
|
||||||
|
with mock.patch("submissions.api.stop_scope_sync", return_value=True) as stop:
|
||||||
|
response = self.client.delete(self.url)
|
||||||
|
|
||||||
|
self.assertEqual(response.json(), {"stopped": True})
|
||||||
|
stop.assert_called_once_with(self.event_game.id)
|
||||||
|
|
||||||
|
def test_status_and_stop_forbidden_for_staff(self):
|
||||||
|
staff = CustomUser.objects.create_user(
|
||||||
|
username="staff", password="x", is_staff=True
|
||||||
|
)
|
||||||
|
self.client.force_login(staff)
|
||||||
|
|
||||||
|
self.assertEqual(self.client.get(self.url).status_code, 403)
|
||||||
|
self.assertEqual(self.client.delete(self.url).status_code, 403)
|
||||||
|
|
||||||
def test_custom_path_game_is_404(self):
|
def test_custom_path_game_is_404(self):
|
||||||
self.game.custom_path = True
|
self.game.custom_path = True
|
||||||
self.game.save()
|
self.game.save()
|
||||||
@@ -76,21 +121,38 @@ class SyncEventGameScopesTaskTests(TestCase):
|
|||||||
)
|
)
|
||||||
self.guard_key = tasks._scope_sync_guard_key(self.event_game.id)
|
self.guard_key = tasks._scope_sync_guard_key(self.event_game.id)
|
||||||
self.loop_id = "loop"
|
self.loop_id = "loop"
|
||||||
|
self.job_key = tasks._scope_sync_job_key(self.event_game.id)
|
||||||
|
self.state_key = tasks._scope_sync_state_key(self.event_game.id)
|
||||||
cache.set(self.guard_key, self.loop_id)
|
cache.set(self.guard_key, self.loop_id)
|
||||||
self.addCleanup(cache.delete, self.guard_key)
|
self.addCleanup(
|
||||||
|
cache.delete_many, [self.guard_key, self.job_key, self.state_key]
|
||||||
|
)
|
||||||
|
|
||||||
def _run(self, status=None, exc=None):
|
@staticmethod
|
||||||
|
def _task_mock(task):
|
||||||
|
# enqueue() returns a TaskResult, whose id ends up in the cache
|
||||||
|
task.enqueue.return_value.id = "job-1"
|
||||||
|
task.using.return_value.enqueue.return_value.id = "job-2"
|
||||||
|
return task
|
||||||
|
|
||||||
|
def _run(self, status=None, exc=None, during_sync=None):
|
||||||
run = tasks.sync_event_game_scopes_task.func
|
run = tasks.sync_event_game_scopes_task.func
|
||||||
|
|
||||||
|
def sync_side_effect(_event_game):
|
||||||
|
if during_sync:
|
||||||
|
during_sync()
|
||||||
|
if exc:
|
||||||
|
raise exc
|
||||||
|
return ScopeSyncResult(status=status)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
tasks,
|
tasks, "sync_event_game_scopes", side_effect=sync_side_effect
|
||||||
"sync_event_game_scopes",
|
|
||||||
side_effect=exc,
|
|
||||||
return_value=ScopeSyncResult(status=status),
|
|
||||||
) as sync,
|
) as sync,
|
||||||
mock.patch.object(tasks, "sync_event_game_scopes_task") as task,
|
mock.patch.object(tasks, "sync_event_game_scopes_task") as task,
|
||||||
mock.patch.object(tasks, "logger"),
|
mock.patch.object(tasks, "logger"),
|
||||||
):
|
):
|
||||||
|
self._task_mock(task)
|
||||||
run(self.event_game.id, loop_id=self.loop_id)
|
run(self.event_game.id, loop_id=self.loop_id)
|
||||||
return sync, task
|
return sync, task
|
||||||
|
|
||||||
@@ -98,6 +160,7 @@ class SyncEventGameScopesTaskTests(TestCase):
|
|||||||
cache.delete(self.guard_key)
|
cache.delete(self.guard_key)
|
||||||
|
|
||||||
with mock.patch.object(tasks, "sync_event_game_scopes_task") as task:
|
with mock.patch.object(tasks, "sync_event_game_scopes_task") as task:
|
||||||
|
self._task_mock(task)
|
||||||
self.assertTrue(tasks.schedule_scope_sync(self.event_game.id))
|
self.assertTrue(tasks.schedule_scope_sync(self.event_game.id))
|
||||||
self.assertFalse(tasks.schedule_scope_sync(self.event_game.id))
|
self.assertFalse(tasks.schedule_scope_sync(self.event_game.id))
|
||||||
|
|
||||||
@@ -114,6 +177,45 @@ class SyncEventGameScopesTaskTests(TestCase):
|
|||||||
|
|
||||||
task.using.assert_called_once()
|
task.using.assert_called_once()
|
||||||
|
|
||||||
|
def test_reschedules_after_unexpected_error(self):
|
||||||
|
_, task = self._run(exc=RuntimeError("boom"))
|
||||||
|
|
||||||
|
task.using.assert_called_once()
|
||||||
|
state = tasks.get_scope_sync_state(self.event_game.id)
|
||||||
|
self.assertEqual(state.error, "boom")
|
||||||
|
self.assertTrue(state.running)
|
||||||
|
|
||||||
|
def test_records_job_and_state(self):
|
||||||
|
self._run(status="7inprog")
|
||||||
|
|
||||||
|
self.assertEqual(cache.get(self.job_key), "job-2")
|
||||||
|
state = tasks.get_scope_sync_state(self.event_game.id)
|
||||||
|
self.assertEqual(state.aandt_status, "7inprog")
|
||||||
|
self.assertIsNotNone(state.next_run_at)
|
||||||
|
|
||||||
|
def test_stop_cancels_scheduled_job(self):
|
||||||
|
cache.set(self.job_key, "job-1")
|
||||||
|
job = mock.Mock(get_status=mock.Mock(return_value=JobStatus.SCHEDULED))
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(tasks.Job, "fetch", return_value=job) as fetch,
|
||||||
|
mock.patch.object(tasks.django_rq, "get_connection"),
|
||||||
|
):
|
||||||
|
self.assertTrue(tasks.stop_scope_sync(self.event_game.id))
|
||||||
|
|
||||||
|
fetch.assert_called_once_with("job-1", connection=mock.ANY)
|
||||||
|
job.cancel.assert_called_once()
|
||||||
|
self.assertIsNone(cache.get(self.guard_key))
|
||||||
|
self.assertFalse(tasks.stop_scope_sync(self.event_game.id))
|
||||||
|
|
||||||
|
def test_stop_during_run_does_not_reschedule(self):
|
||||||
|
_, task = self._run(
|
||||||
|
status="7inprog", during_sync=lambda: cache.delete(self.guard_key)
|
||||||
|
)
|
||||||
|
|
||||||
|
task.using.assert_not_called()
|
||||||
|
self.assertIsNone(cache.get(self.guard_key))
|
||||||
|
|
||||||
def test_stops_once_aandt_is_done(self):
|
def test_stops_once_aandt_is_done(self):
|
||||||
_, task = self._run(status="9done")
|
_, task = self._run(status="9done")
|
||||||
|
|
||||||
@@ -127,3 +229,142 @@ class SyncEventGameScopesTaskTests(TestCase):
|
|||||||
|
|
||||||
sync.assert_not_called()
|
sync.assert_not_called()
|
||||||
task.using.assert_not_called()
|
task.using.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class SyncScopeBroadcastTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
event = Event.objects.create(name="Event", path="event")
|
||||||
|
game = Game.objects.create(name="Game", path="game")
|
||||||
|
self.event_game = EventGame.objects.create(
|
||||||
|
event=event, game=game, apibot_token="abc"
|
||||||
|
)
|
||||||
|
self.player = CustomUser.objects.create_user(
|
||||||
|
username="player", password="x", cas_user_id="42"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sync(self, matches):
|
||||||
|
api = "submissions.services.aandt_sync"
|
||||||
|
responses = {
|
||||||
|
"aandt_api_infos": AandtApiInfosResponse200.from_dict(
|
||||||
|
{"aandt": {"status": "7inprog"}}
|
||||||
|
),
|
||||||
|
"aandt_api_members": AandtApiMembersResponse200.from_dict(
|
||||||
|
{"members": [{"id": 1, "user": {"id": 42}}]}
|
||||||
|
),
|
||||||
|
"aandt_api_matches": AandtApiMatchesResponse200.from_dict(
|
||||||
|
{"matches": matches}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
with ExitStack() as stack:
|
||||||
|
for module, parsed in responses.items():
|
||||||
|
stack.enter_context(
|
||||||
|
mock.patch(
|
||||||
|
f"{api}.{module}.sync_detailed",
|
||||||
|
return_value=mock.Mock(parsed=parsed),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stack.enter_context(
|
||||||
|
mock.patch(f"{api}._push_submission_link", return_value=[])
|
||||||
|
)
|
||||||
|
stack.enter_context(mock.patch("submissions.signals.get_channel_layer"))
|
||||||
|
send = stack.enter_context(mock.patch("submissions.signals.async_to_sync"))
|
||||||
|
with self.captureOnCommitCallbacks(execute=True):
|
||||||
|
sync_event_game_scopes(self.event_game)
|
||||||
|
|
||||||
|
return [c.args[1]["scope"] for c in send.return_value.call_args_list]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _match(match_id, status):
|
||||||
|
return {
|
||||||
|
"id": match_id,
|
||||||
|
"title": f"M{match_id}",
|
||||||
|
"status": status,
|
||||||
|
"players": {"1": 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_match_done_and_next_one_playing_notifies_player(self):
|
||||||
|
self._sync([self._match(1, "playing"), self._match(2, "waitPlayer")])
|
||||||
|
|
||||||
|
sent = self._sync([self._match(1, "done"), self._match(2, "playing")])
|
||||||
|
|
||||||
|
by_external_id = {
|
||||||
|
Scope.objects.get(id=scope["id"]).external_id: scope for scope in sent
|
||||||
|
}
|
||||||
|
self.assertFalse(by_external_id[1]["is_active"])
|
||||||
|
self.assertTrue(by_external_id[2]["is_active"])
|
||||||
|
self.assertTrue(by_external_id[2]["assigned"])
|
||||||
|
|
||||||
|
def test_unchanged_scopes_send_nothing(self):
|
||||||
|
self._sync([self._match(1, "playing")])
|
||||||
|
|
||||||
|
self.assertEqual(self._sync([self._match(1, "playing")]), [])
|
||||||
|
|
||||||
|
def test_removed_match_is_closed_and_notified(self):
|
||||||
|
self._sync([self._match(1, "playing")])
|
||||||
|
|
||||||
|
sent = self._sync([])
|
||||||
|
|
||||||
|
self.assertEqual(len(sent), 1)
|
||||||
|
self.assertFalse(sent[0]["is_active"])
|
||||||
|
|
||||||
|
|
||||||
|
class ScopeAdminBroadcastTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
event = Event.objects.create(name="Event", path="event")
|
||||||
|
game = Game.objects.create(name="Game", path="game")
|
||||||
|
event_game = EventGame.objects.create(event=event, game=game)
|
||||||
|
self.player = CustomUser.objects.create_user(username="player", password="x")
|
||||||
|
self.scope = Scope.objects.create(event_game=event_game, name="Match 1")
|
||||||
|
self.scope.assigned_users.set([self.player])
|
||||||
|
self.client.force_login(
|
||||||
|
CustomUser.objects.create_user(
|
||||||
|
username="admin", password="x", is_staff=True, is_superuser=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.form = {
|
||||||
|
"event_game": event_game.id,
|
||||||
|
"name": "Match 1",
|
||||||
|
"assigned_users": [self.player.id],
|
||||||
|
"is_active": "on",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _sent(self, request):
|
||||||
|
with (
|
||||||
|
mock.patch("submissions.signals.get_channel_layer"),
|
||||||
|
mock.patch("submissions.signals.async_to_sync") as send,
|
||||||
|
self.captureOnCommitCallbacks(execute=True),
|
||||||
|
):
|
||||||
|
response = request()
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
return [c.args[1]["scope"] for c in send.return_value.call_args_list]
|
||||||
|
|
||||||
|
def _change(self, **changes):
|
||||||
|
url = f"/admin/submissions/scope/{self.scope.id}/change/"
|
||||||
|
data = {k: v for k, v in {**self.form, **changes}.items() if v is not None}
|
||||||
|
return self._sent(lambda: self.client.post(url, data))
|
||||||
|
|
||||||
|
def test_closing_scope_notifies_player(self):
|
||||||
|
sent = self._change(is_active=None)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
sent, [{"id": str(self.scope.id), "is_active": False, "assigned": True}]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unassigning_player_notifies_them(self):
|
||||||
|
sent = self._change(assigned_users=[])
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
sent, [{"id": str(self.scope.id), "is_active": True, "assigned": False}]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unchanged_save_sends_nothing(self):
|
||||||
|
self.assertEqual(self._change(name="Renamed"), [])
|
||||||
|
|
||||||
|
def test_delete_notifies_player(self):
|
||||||
|
url = f"/admin/submissions/scope/{self.scope.id}/delete/"
|
||||||
|
|
||||||
|
sent = self._sent(lambda: self.client.post(url, {"post": "yes"}))
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
sent, [{"id": str(self.scope.id), "is_active": False, "assigned": False}]
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user