+
Processing...
diff --git a/polylan_submitter/src/SubmissionsSelect.vue b/polylan_submitter/src/SubmissionsSelect.vue
new file mode 100644
index 0000000..109a778
--- /dev/null
+++ b/polylan_submitter/src/SubmissionsSelect.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
Select your match
+
+ You're assigned to multiple matches for {{ gameName }}. Pick which one you want to submit for.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/polylan_submitter/src/api/index.ts b/polylan_submitter/src/api/index.ts
index 977b91c..fa0edc3 100644
--- a/polylan_submitter/src/api/index.ts
+++ b/polylan_submitter/src/api/index.ts
@@ -1,4 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts
-export { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, gamesApiListEventGames, gamesApiListGames, marketApiCloseMarket, marketApiCreateBet, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck, submissionsApiCreateImageCrop, submissionsApiCreateSubmission, submissionsApiDeleteImageCrop, submissionsApiGetSubmission, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions, submissionsApiProcessCropOcr, submissionsApiProcessFileOcr } from './sdk.gen';
-export type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, AnimationsApiTopSubmissionsResponses, ClientOptions, GameOut, GamesApiListEventGamesData, GamesApiListEventGamesResponse, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponse, GamesApiListGamesResponses, ImageCropIn, ImageCropOut, Input, LeaderboardEntryOut, LeaderboardOut, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponse, MarketApiResolveMarketResponses, MarketListSchema, MarketOptionSchema, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponse, NoitaApiSubmitLogFileResponses, NoitaSubmissionOut, ObjectivResultOut, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponse, OpusMagnumApiValidateSubmissionResponses, PagedSubmissionOut, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, PuzzlePointsFactorOut, PuzzleResponseIn, PuzzleResponseOut, PuzzleResponseRankingOut, PuzzleResultsOut, PuzzleSubmissionsOut, PuzzleSubmissionWithRankOut, RankingSchema, ResolveMarketSchema, ResultsOut, SteamCollectionItemOut, SteamCollectionOut, SubmissionFileOut, SubmissionIn, SubmissionOut, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionResponse, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponse, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponse, SubmissionsApiGetSubmissionResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponse, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponse, SubmissionsApiProcessFileOcrResponses, TournamentPuzzleResultsOut, TournamentSubmissionsOut, UserBetCreateSchema, UserBetSchema, UserDisplayOut, UserInfoOut, ValidationIn, WinnerFileOut, WinnerResponseOut } from './types.gen';
+export { animationsApiPuzzleResults, animationsApiResults, animationsApiTopSubmissions, gamesApiListEventGames, gamesApiListGames, marketApiCloseMarket, marketApiCreateBet, marketApiListMarkets, marketApiListUserBets, marketApiResolveMarket, noitaApiGetLeaderboard, noitaApiGetResults, noitaApiSubmitLogFile, type Options, opusMagnumApiCreateSubmission, opusMagnumApiDeleteSubmission, opusMagnumApiGetCollection, opusMagnumApiGetStats, opusMagnumApiGetSubmission, opusMagnumApiListPuzzles, opusMagnumApiListResponsesNeedingValidation, opusMagnumApiListSubmissions, opusMagnumApiValidateAuto, opusMagnumApiValidateResponse, opusMagnumApiValidateSubmission, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo, polylanSubmitterApiHealthCheck, submissionsApiCreateImageCrop, submissionsApiCreateSubmission, submissionsApiDeleteImageCrop, submissionsApiGetSubmission, submissionsApiListMyScopes, submissionsApiListSubmissionsByGame, submissionsApiListUserSubmissions, submissionsApiProcessCropOcr, submissionsApiProcessFileOcr } from './sdk.gen';
+export type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, AnimationsApiTopSubmissionsResponses, ClientOptions, GameOut, GamesApiListEventGamesData, GamesApiListEventGamesResponse, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponse, GamesApiListGamesResponses, ImageCropIn, ImageCropOut, Input, LeaderboardEntryOut, LeaderboardOut, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponse, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponse, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponse, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponse, MarketApiResolveMarketResponses, MarketListSchema, MarketOptionSchema, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponse, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponse, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileError, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponse, NoitaApiSubmitLogFileResponses, NoitaSubmissionOut, ObjectivResultOut, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponse, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponse, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponse, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponse, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponse, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponse, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponse, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponse, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponse, OpusMagnumApiValidateSubmissionResponses, PagedSubmissionOut, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponse, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, PuzzlePointsFactorOut, PuzzleResponseIn, PuzzleResponseOut, PuzzleResponseRankingOut, PuzzleResultsOut, PuzzleSubmissionsOut, PuzzleSubmissionWithRankOut, RankingSchema, ResolveMarketSchema, ResultsOut, ScopeOut, SteamCollectionItemOut, SteamCollectionOut, SubmissionFileOut, SubmissionIn, SubmissionOut, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponse, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionError, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponse, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponse, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponse, SubmissionsApiGetSubmissionResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesError, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponse, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponse, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsError, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponse, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponse, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponse, SubmissionsApiProcessFileOcrResponses, TournamentPuzzleResultsOut, TournamentSubmissionsOut, UserBetCreateSchema, UserBetSchema, UserDisplayOut, UserInfoOut, ValidationIn, WinnerFileOut, WinnerResponseOut } from './types.gen';
diff --git a/polylan_submitter/src/api/sdk.gen.ts b/polylan_submitter/src/api/sdk.gen.ts
index 2a1c4b4..a49fa31 100644
--- a/polylan_submitter/src/api/sdk.gen.ts
+++ b/polylan_submitter/src/api/sdk.gen.ts
@@ -2,7 +2,7 @@
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
import { client } from './client.gen';
-import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, 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, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen';
+import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, 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, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen';
export type Options
= Options2 & {
/**
@@ -196,10 +196,15 @@ export const noitaApiSubmitLogFile = (opti
*/
export const gamesApiListEventGames = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/games', ...options });
+/**
+ * List My Scopes
+ */
+export const submissionsApiListMyScopes = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/submissions/scopes/mine', ...options });
+
/**
* Create Submission
*/
-export const submissionsApiCreateSubmission = (options: Options) => (options.client ?? client).post({
+export const submissionsApiCreateSubmission = (options: Options) => (options.client ?? client).post({
...formDataBodySerializer,
url: '/api/events/{event_path}/submissions/submission/create',
...options,
@@ -212,7 +217,7 @@ export const submissionsApiCreateSubmission = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/submissions/submission/by/user', ...options });
+export const submissionsApiListUserSubmissions = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/submissions/submission/by/user', ...options });
/**
* List Submissions By Game
diff --git a/polylan_submitter/src/api/types.gen.ts b/polylan_submitter/src/api/types.gen.ts
index 1c3b42f..078d7cd 100644
--- a/polylan_submitter/src/api/types.gen.ts
+++ b/polylan_submitter/src/api/types.gen.ts
@@ -72,6 +72,10 @@ export type GameOut = {
* Path
*/
path: string;
+ /**
+ * Custom Path
+ */
+ custom_path: boolean;
/**
* Image
*/
@@ -412,9 +416,9 @@ export type SubmissionOut = {
*/
id: string;
/**
- * Game Id
+ * Scope Id
*/
- game_id?: number | null;
+ scope_id: string;
/**
* Notes
*/
@@ -916,6 +920,28 @@ export type NoitaSubmissionOut = {
processed: boolean;
};
+/**
+ * ScopeOut
+ */
+export type ScopeOut = {
+ /**
+ * Id
+ */
+ id: string;
+ /**
+ * Name
+ */
+ name: string;
+ /**
+ * Is Active
+ */
+ is_active: boolean;
+ /**
+ * Created At
+ */
+ created_at: string;
+};
+
/**
* ImageCropOut
*/
@@ -1601,6 +1627,47 @@ export type GamesApiListEventGamesResponses = {
export type GamesApiListEventGamesResponse = GamesApiListEventGamesResponses[keyof GamesApiListEventGamesResponses];
+export type SubmissionsApiListMyScopesData = {
+ body?: never;
+ path: {
+ /**
+ * Event Path
+ */
+ event_path: string;
+ };
+ query: {
+ /**
+ * Game Path
+ */
+ game_path: string;
+ };
+ url: '/api/events/{event_path}/submissions/scopes/mine';
+};
+
+export type SubmissionsApiListMyScopesErrors = {
+ /**
+ * Response
+ *
+ * Forbidden
+ */
+ 403: {
+ [key: string]: unknown;
+ };
+};
+
+export type SubmissionsApiListMyScopesError = SubmissionsApiListMyScopesErrors[keyof SubmissionsApiListMyScopesErrors];
+
+export type SubmissionsApiListMyScopesResponses = {
+ /**
+ * Response
+ *
+ * OK
+ */
+ 200: Array;
+};
+
+export type SubmissionsApiListMyScopesResponse = SubmissionsApiListMyScopesResponses[keyof SubmissionsApiListMyScopesResponses];
+
export type SubmissionsApiCreateSubmissionData = {
/**
* MultiPartBodyParams
@@ -1623,13 +1690,26 @@ export type SubmissionsApiCreateSubmissionData = {
};
query: {
/**
- * Game Path
+ * Scope Id
*/
- game_path: string;
+ scope_id: string;
};
url: '/api/events/{event_path}/submissions/submission/create';
};
+export type SubmissionsApiCreateSubmissionErrors = {
+ /**
+ * Response
+ *
+ * Forbidden
+ */
+ 403: {
+ [key: string]: unknown;
+ };
+};
+
+export type SubmissionsApiCreateSubmissionError = SubmissionsApiCreateSubmissionErrors[keyof SubmissionsApiCreateSubmissionErrors];
+
export type SubmissionsApiCreateSubmissionResponses = {
/**
* OK
@@ -1649,13 +1729,26 @@ export type SubmissionsApiListUserSubmissionsData = {
};
query: {
/**
- * Game Path
+ * Scope Id
*/
- game_path: string;
+ scope_id: string;
};
url: '/api/events/{event_path}/submissions/submission/by/user';
};
+export type SubmissionsApiListUserSubmissionsErrors = {
+ /**
+ * Response
+ *
+ * Forbidden
+ */
+ 403: {
+ [key: string]: unknown;
+ };
+};
+
+export type SubmissionsApiListUserSubmissionsError = SubmissionsApiListUserSubmissionsErrors[keyof SubmissionsApiListUserSubmissionsErrors];
+
export type SubmissionsApiListUserSubmissionsResponses = {
/**
* Response
diff --git a/polylan_submitter/src/composables/useSubmissionsWebSocket.ts b/polylan_submitter/src/composables/useSubmissionsWebSocket.ts
index cfd179b..2697d9e 100644
--- a/polylan_submitter/src/composables/useSubmissionsWebSocket.ts
+++ b/polylan_submitter/src/composables/useSubmissionsWebSocket.ts
@@ -3,7 +3,7 @@ import { useWebSocket } from '@vueuse/core'
interface SubmissionUpdate {
id: string
- game_id?: number
+ scope_id?: string
notes?: string
files?: SubmissionFileUpdate[]
created_at: string
diff --git a/polylan_submitter/src/submissions_select.ts b/polylan_submitter/src/submissions_select.ts
new file mode 100644
index 0000000..03f745e
--- /dev/null
+++ b/polylan_submitter/src/submissions_select.ts
@@ -0,0 +1,10 @@
+import { createApp } from 'vue'
+import SubmissionsSelect from '@/SubmissionsSelect.vue'
+import '@/style.css'
+
+const selector = "#app"
+const mountData = document.querySelector(selector)
+const scopesEl = document.getElementById('scopes-data')
+const scopes = scopesEl ? JSON.parse(scopesEl.textContent || '[]') : []
+const app = createApp(SubmissionsSelect, { ...mountData?.dataset, scopes })
+app.mount(selector)
diff --git a/polylan_submitter/submissions/admin.py b/polylan_submitter/submissions/admin.py
index e3bae95..81abe77 100644
--- a/polylan_submitter/submissions/admin.py
+++ b/polylan_submitter/submissions/admin.py
@@ -1,5 +1,6 @@
from django.contrib import admin
-from .models import Submission, SubmissionFile, ImageCrop
+from django.urls import reverse
+from .models import Submission, SubmissionFile, ImageCrop, Scope
class ImageCropInline(admin.TabularInline):
@@ -37,16 +38,76 @@ class SubmissionFileInline(admin.TabularInline):
return super().get_queryset(request)
+@admin.register(Scope)
+class ScopeAdmin(admin.ModelAdmin):
+ list_display = [
+ "name",
+ "event_game",
+ "external_id",
+ "is_active",
+ "assigned_user_count",
+ "synced_at",
+ "created_at",
+ ]
+ list_filter = ["is_active", "event_game__event", "event_game__game"]
+ search_fields = ["name", "external_id", "event_game__game__name"]
+ readonly_fields = ["id", "created_at", "updated_at", "synced_at"]
+ filter_horizontal = ["assigned_users"]
+ autocomplete_fields = ["event_game"]
+
+ def get_queryset(self, request):
+ return (
+ super()
+ .get_queryset(request)
+ .select_related("event_game", "event_game__event", "event_game__game")
+ .prefetch_related("assigned_users")
+ )
+
+ @admin.display(description="Assigned users")
+ def assigned_user_count(self, obj):
+ return obj.assigned_users.count()
+
+ def view_on_site(self, obj):
+ return reverse(
+ "submissions.view",
+ kwargs={
+ "event_path": obj.event_game.event.path,
+ "game_path": obj.event_game.game.path,
+ "scope_id": obj.id,
+ },
+ )
+
+
@admin.register(Submission)
class SubmissionAdmin(admin.ModelAdmin):
- list_display = ["id_short", "game", "user", "notes_preview", "created_at"]
- list_filter = ["game", "created_at", "updated_at"]
- search_fields = ["id", "user__email", "notes", "game__name"]
+ list_display = ["id_short", "scope", "game", "user", "notes_preview", "created_at"]
+ list_filter = [
+ "scope__event_game__game",
+ "scope__event_game__event",
+ "created_at",
+ "updated_at",
+ ]
+ search_fields = [
+ "id",
+ "user__email",
+ "notes",
+ "scope__event_game__game__name",
+ "scope__name",
+ ]
readonly_fields = ["id", "created_at", "updated_at"]
+ autocomplete_fields = ["scope"]
inlines = [SubmissionFileInline]
def get_queryset(self, request):
- return super().get_queryset(request).select_related("user", "game")
+ return (
+ super()
+ .get_queryset(request)
+ .select_related("user", "scope", "scope__event_game__game")
+ )
+
+ @admin.display(description="Game", ordering="scope__event_game__game")
+ def game(self, obj):
+ return obj.scope.event_game.game
@admin.display(description="ID")
def id_short(self, obj):
diff --git a/polylan_submitter/submissions/api.py b/polylan_submitter/submissions/api.py
index 91d8ad5..d15fbce 100644
--- a/polylan_submitter/submissions/api.py
+++ b/polylan_submitter/submissions/api.py
@@ -6,8 +6,8 @@ from ninja.files import UploadedFile
from django.shortcuts import get_object_or_404
from games.models import Game
-from .models import Submission, SubmissionFile, ImageCrop, OCRStatus
-from .schemas import SubmissionOut, ImageCropIn, ImageCropOut
+from .models import Submission, SubmissionFile, ImageCrop, OCRStatus, Scope
+from .schemas import SubmissionOut, ImageCropIn, ImageCropOut, ScopeOut
from .tasks import process_submission_file, process_crop
router = Router()
@@ -15,22 +15,47 @@ router = Router()
PREFIX = "/events/{event_path}/submissions"
-@router.post(f"{PREFIX}/submission/create", response=SubmissionOut)
-def create_submission(
- request: HttpRequest,
- event_path: str,
- game_path: str,
- files: List[UploadedFile] = File(...),
- notes: Optional[str] = Form(None),
-):
+@router.get(f"{PREFIX}/scopes/mine", response={200: list[ScopeOut], 403: dict})
+def list_my_scopes(request: HttpRequest, event_path: str, game_path: str):
+ if not request.user.is_authenticated:
+ return 403, {"detail": "Authentication required"}
+
game = get_object_or_404(
Game.objects.enabled_for_event(request.event), path=game_path
)
+ return Scope.objects.filter(
+ event_game__event=request.event,
+ event_game__game=game,
+ assigned_users=request.user,
+ )
+
+
+@router.post(f"{PREFIX}/submission/create", response={200: SubmissionOut, 403: dict})
+def create_submission(
+ request: HttpRequest,
+ event_path: str,
+ scope_id: UUID,
+ files: List[UploadedFile] = File(...),
+ notes: Optional[str] = Form(None),
+):
+ if not request.user.is_authenticated:
+ return 403, {"detail": "Authentication required"}
+
+ scope = get_object_or_404(
+ Scope, id=scope_id, event_game__event=request.event, is_active=True
+ )
+
+ if (
+ not request.user.is_staff
+ and not scope.assigned_users.filter(pk=request.user.pk).exists()
+ ):
+ return 403, {"detail": "You are not assigned to this scope"}
+
submission = Submission.objects.create(
event=request.event,
- game=game,
- user=request.user if request.user.is_authenticated else None,
+ scope=scope,
+ user=request.user,
notes=notes or None,
)
@@ -45,16 +70,16 @@ def create_submission(
return submission
-@router.get(f"{PREFIX}/submission/by/user", response=list[SubmissionOut])
-def list_user_submissions(request: HttpRequest, event_path: str, game_path: str):
- game = get_object_or_404(Game, path=game_path)
-
+@router.get(
+ f"{PREFIX}/submission/by/user", response={200: list[SubmissionOut], 403: dict}
+)
+def list_user_submissions(request: HttpRequest, event_path: str, scope_id: UUID):
if not request.user.is_authenticated:
return 403, {"detail": "Authentication required"}
- submissions = Submission.for_event.filter(game=game, user=request.user).order_by(
- "-created_at"
- )
+ submissions = Submission.for_event.filter(
+ scope_id=scope_id, user=request.user
+ ).order_by("-created_at")
return submissions
@@ -65,7 +90,7 @@ def list_submissions_by_game(request: HttpRequest, event_path: str, game_path: s
if not request.user.is_staff:
return 403, {"detail": "Admin access required"}
- return Submission.for_event.filter(game=game)
+ return Submission.for_event.filter(scope__event_game__game=game)
@router.get(f"{PREFIX}/submission/{{submission_id}}", response=SubmissionOut)
diff --git a/polylan_submitter/submissions/management/__init__.py b/polylan_submitter/submissions/management/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/polylan_submitter/submissions/management/commands/__init__.py b/polylan_submitter/submissions/management/commands/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/polylan_submitter/submissions/management/commands/create_scope.py b/polylan_submitter/submissions/management/commands/create_scope.py
new file mode 100644
index 0000000..c1f1146
--- /dev/null
+++ b/polylan_submitter/submissions/management/commands/create_scope.py
@@ -0,0 +1,43 @@
+from django.core.management.base import BaseCommand, CommandError
+
+from accounts.models import CustomUser
+from games.models import EventGame
+from submissions.models import Scope
+
+
+class Command(BaseCommand):
+ help = "Manually create a Scope (match) and assign users to it."
+
+ def add_arguments(self, parser):
+ parser.add_argument("event_game_id", type=int)
+ parser.add_argument("name", type=str)
+ parser.add_argument(
+ "--user",
+ dest="usernames",
+ action="append",
+ default=[],
+ help="Username to assign to this scope. Repeatable.",
+ )
+
+ def handle(self, *args, **options):
+ try:
+ event_game = EventGame.objects.get(id=options["event_game_id"])
+ except EventGame.DoesNotExist:
+ raise CommandError(f"No EventGame with id={options['event_game_id']}")
+
+ usernames = options["usernames"]
+ users = list(CustomUser.objects.filter(username__in=usernames))
+
+ missing = set(usernames) - {u.username for u in users}
+ if missing:
+ raise CommandError(f"Unknown usernames: {', '.join(sorted(missing))}")
+
+ scope = Scope.objects.create(event_game=event_game, name=options["name"])
+ scope.assigned_users.set(users)
+
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"Created scope '{scope.name}' (id={scope.id}) "
+ f"with {len(users)} assigned user(s)."
+ )
+ )
diff --git a/polylan_submitter/submissions/management/commands/sync_scopes.py b/polylan_submitter/submissions/management/commands/sync_scopes.py
new file mode 100644
index 0000000..d0b712b
--- /dev/null
+++ b/polylan_submitter/submissions/management/commands/sync_scopes.py
@@ -0,0 +1,47 @@
+from django.core.management.base import BaseCommand, CommandError
+
+from games.models import EventGame
+from submissions.tasks import sync_all_event_games_task, sync_event_game_scopes_task
+
+
+class Command(BaseCommand):
+ help = "Queue a sync of Scopes (matches) and their assigned users from the legacy AAndT API."
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--event-game",
+ type=int,
+ default=None,
+ help=(
+ "Only queue a sync for the EventGame with this id. "
+ "Default: every EventGame with an AAndT token configured."
+ ),
+ )
+
+ def handle(self, *args, **options):
+ event_game_id = options["event_game"]
+
+ if event_game_id is not None:
+ if not EventGame.objects.filter(id=event_game_id).exists():
+ raise CommandError(f"No EventGame with id={event_game_id}")
+
+ sync_event_game_scopes_task.enqueue(event_game_id)
+ self.stdout.write(
+ self.style.SUCCESS(f"Queued sync for EventGame {event_game_id}")
+ )
+ return
+
+ if (
+ not EventGame.objects.exclude(apibot_token__isnull=True)
+ .exclude(apibot_token="")
+ .exists()
+ ):
+ self.stdout.write(
+ self.style.WARNING("No EventGame has an AAndT token configured.")
+ )
+ return
+
+ sync_all_event_games_task.enqueue()
+ self.stdout.write(
+ self.style.SUCCESS("Queued sync for every EventGame with an AAndT token.")
+ )
diff --git a/polylan_submitter/submissions/migrations/0006_scope.py b/polylan_submitter/submissions/migrations/0006_scope.py
new file mode 100644
index 0000000..a4f6018
--- /dev/null
+++ b/polylan_submitter/submissions/migrations/0006_scope.py
@@ -0,0 +1,84 @@
+import uuid
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("submissions", "0005_submission_event_required"),
+ ("games", "0007_eventgame_apibot_token"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Scope",
+ fields=[
+ (
+ "id",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ (
+ "external_id",
+ models.PositiveIntegerField(
+ blank=True,
+ help_text="Legacy AAndT match id, set when synced automatically.",
+ null=True,
+ ),
+ ),
+ ("name", models.CharField(max_length=255)),
+ (
+ "is_active",
+ models.BooleanField(
+ default=True,
+ help_text="Whether submissions are currently accepted for this scope.",
+ ),
+ ),
+ (
+ "synced_at",
+ models.DateTimeField(
+ blank=True,
+ help_text="Last time this scope was synced from the AAndT API.",
+ null=True,
+ ),
+ ),
+ (
+ "assigned_users",
+ models.ManyToManyField(
+ blank=True,
+ help_text="Users allowed to submit scores for this scope.",
+ related_name="scopes",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "event_game",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="scopes",
+ to="games.eventgame",
+ ),
+ ),
+ ],
+ options={
+ "ordering": ["-created_at"],
+ },
+ ),
+ migrations.AddConstraint(
+ model_name="scope",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("external_id__isnull", False)),
+ fields=("event_game", "external_id"),
+ name="unique_scope_external_id_per_event_game",
+ ),
+ ),
+ ]
diff --git a/polylan_submitter/submissions/migrations/0007_submission_scope.py b/polylan_submitter/submissions/migrations/0007_submission_scope.py
new file mode 100644
index 0000000..84957ab
--- /dev/null
+++ b/polylan_submitter/submissions/migrations/0007_submission_scope.py
@@ -0,0 +1,37 @@
+import uuid
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+def delete_existing_submissions(apps, schema_editor):
+ """Submissions predate Scope and can't be backfilled with one; wipe them."""
+ Submission = apps.get_model("submissions", "Submission")
+ Submission.objects.all().delete()
+
+
+class Migration(migrations.Migration):
+ # Deleting existing submissions (which cascades to their files/crops) fires
+ # deferred FK triggers on postgres; altering the same table right after in
+ # the same transaction hits "pending trigger events", so run non-atomically.
+ atomic = False
+
+ dependencies = [
+ ("submissions", "0006_scope"),
+ ]
+
+ operations = [
+ migrations.RunPython(delete_existing_submissions, migrations.RunPython.noop),
+ migrations.AddField(
+ model_name="submission",
+ name="scope",
+ field=models.ForeignKey(
+ default=uuid.uuid4,
+ help_text="The match this submission's score is for.",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="submissions",
+ to="submissions.scope",
+ ),
+ preserve_default=False,
+ ),
+ ]
diff --git a/polylan_submitter/submissions/migrations/0008_remove_submission_game.py b/polylan_submitter/submissions/migrations/0008_remove_submission_game.py
new file mode 100644
index 0000000..c4d2a06
--- /dev/null
+++ b/polylan_submitter/submissions/migrations/0008_remove_submission_game.py
@@ -0,0 +1,17 @@
+# Generated by Django 6.0.5 on 2026-09-16 09:27
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("submissions", "0007_submission_scope"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="submission",
+ name="game",
+ ),
+ ]
diff --git a/polylan_submitter/submissions/models.py b/polylan_submitter/submissions/models.py
index d91c4ae..6aa072c 100644
--- a/polylan_submitter/submissions/models.py
+++ b/polylan_submitter/submissions/models.py
@@ -6,7 +6,7 @@ import uuid
from accounts.models import CustomUser
from events.models import Event
from events.scoping import EventScopedManager
-from games.models import Game
+from games.models import EventGame
class OCRStatus(models.TextChoices):
@@ -16,14 +16,20 @@ class OCRStatus(models.TextChoices):
ERROR = "error", _("Error processing")
-def submission_file_upload_path(instance, filename):
+def submission_file_upload_path(instance: "SubmissionFile", filename: str):
ext = filename.split(".")[-1] if "." in filename else ""
new_filename = f"{instance.id}_{filename}" if ext else str(instance.id)
- date = instance.created_at.strftime("%Y/%m/%d")
user = instance.submission.user.username
+ game_path = instance.submission.scope.event_game.game.path
+ event_path = instance.submission.scope.event_game.event.path
+ mid = (
+ instance.submission.scope.external_id
+ if instance.submission.scope.external_id
+ else instance.submission.scope.name
+ )
- return f"{instance.submission.game.path.lstrip('/')}/{date}/{user}/{instance.submission.id}/{new_filename}"
+ return f"{event_path.lstrip('/')}/{game_path.lstrip('/')}/{mid}/{user}/{instance.submission.id}/{new_filename}"
class BaseModel(models.Model):
@@ -37,6 +43,60 @@ class BaseModel(models.Model):
abstract = True
+class Scope(BaseModel):
+ """A "match" that submissions can be scored against.
+
+ Scopes are either created manually (admin/CLI) or auto-synced from the
+ legacy PolyLAN AAndT API (see submissions.services.aandt_sync), in which
+ case `external_id` holds the remote match id for idempotent upserts.
+ """
+
+ event_game = models.ForeignKey(
+ EventGame,
+ on_delete=models.CASCADE,
+ related_name="scopes",
+ )
+
+ external_id = models.PositiveIntegerField(
+ null=True,
+ blank=True,
+ help_text="Legacy AAndT match id, set when synced automatically.",
+ )
+
+ name = models.CharField(max_length=255)
+
+ assigned_users = models.ManyToManyField(
+ CustomUser,
+ blank=True,
+ related_name="scopes",
+ help_text="Users allowed to submit scores for this scope.",
+ )
+
+ is_active = models.BooleanField(
+ default=True,
+ help_text="Whether submissions are currently accepted for this scope.",
+ )
+
+ synced_at = models.DateTimeField(
+ null=True,
+ blank=True,
+ help_text="Last time this scope was synced from the AAndT API.",
+ )
+
+ class Meta:
+ ordering = ["-created_at"]
+ constraints = [
+ models.UniqueConstraint(
+ fields=["event_game", "external_id"],
+ condition=models.Q(external_id__isnull=False),
+ name="unique_scope_external_id_per_event_game",
+ ),
+ ]
+
+ def __str__(self) -> str:
+ return f"{self.name} ({self.event_game})"
+
+
class Submission(BaseModel):
event = models.ForeignKey(
Event,
@@ -44,12 +104,11 @@ class Submission(BaseModel):
related_name="submissions",
)
- game = models.ForeignKey(
- Game,
+ scope = models.ForeignKey(
+ Scope,
on_delete=models.CASCADE,
- null=True,
- blank=True,
related_name="submissions",
+ help_text="The match this submission's score is for.",
)
user = models.ForeignKey(
diff --git a/polylan_submitter/submissions/schemas.py b/polylan_submitter/submissions/schemas.py
index ba16185..9fb3aff 100644
--- a/polylan_submitter/submissions/schemas.py
+++ b/polylan_submitter/submissions/schemas.py
@@ -3,6 +3,13 @@ from uuid import UUID
from ninja import Schema
+class ScopeOut(Schema):
+ id: UUID
+ name: str
+ is_active: bool
+ created_at: datetime
+
+
class ImageCropIn(Schema):
x1: int
y1: int
@@ -36,7 +43,7 @@ class SubmissionFileOut(Schema):
class SubmissionOut(Schema):
id: UUID
- game_id: int | None = None
+ scope_id: UUID
notes: str | None = None
files: list[SubmissionFileOut] = []
created_at: datetime
diff --git a/polylan_submitter/submissions/services/__init__.py b/polylan_submitter/submissions/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/polylan_submitter/submissions/services/aandt_client.py b/polylan_submitter/submissions/services/aandt_client.py
new file mode 100644
index 0000000..b1bea42
--- /dev/null
+++ b/polylan_submitter/submissions/services/aandt_client.py
@@ -0,0 +1,65 @@
+import requests
+
+
+class AAndTAPIError(Exception):
+ """Raised when the legacy PolyLAN AAndT API is unreachable or errors out."""
+
+
+class AAndTClient:
+ """Thin wrapper around the legacy PolyLAN site's per-AAndT JWT-bearer API."""
+
+ def __init__(self, base_url: str, token: str, /, *, timeout: int = 15) -> None:
+ self.timeout = timeout
+ self.base_url = base_url.rstrip("/")
+ self.session = requests.Session()
+ self.session.headers["Authorization"] = f"Bearer {token}"
+
+ def get_members(self) -> list[dict]:
+ return self._get("members")["members"]
+
+ def get_matches(self) -> list[dict]:
+ return self._get("matches")["matches"]
+
+ def set_match_message(
+ self,
+ match_id: int,
+ /,
+ *,
+ players: str | None = None,
+ admin: str | None = None,
+ msg_all: str | None = None,
+ ) -> None:
+ body = {
+ key: value
+ for key, value in {
+ "players": players,
+ "admin": admin,
+ "all": msg_all,
+ }.items()
+ if value is not None
+ }
+ self._post(f"match/{match_id}/messages", body)
+
+ def _get(self, path: str) -> dict:
+ try:
+ response = self.session.get(
+ f"{self.base_url}/Plamingo/aandt/api/aandt/{path}", timeout=self.timeout
+ )
+ response.raise_for_status()
+ except requests.RequestException as exc:
+ raise AAndTAPIError(f"Failed to reach AAndT API ({path}): {exc}") from exc
+
+ return response.json()
+
+ def _post(self, path: str, data: dict) -> dict:
+ try:
+ response = self.session.post(
+ f"{self.base_url}/Plamingo/aandt/api/aandt/{path}",
+ json=data,
+ timeout=self.timeout,
+ )
+ response.raise_for_status()
+ except requests.RequestException as exc:
+ raise AAndTAPIError(f"Failed to reach AAndT API ({path}): {exc}") from exc
+
+ return response.json()
diff --git a/polylan_submitter/submissions/services/aandt_sync.py b/polylan_submitter/submissions/services/aandt_sync.py
new file mode 100644
index 0000000..17afc7f
--- /dev/null
+++ b/polylan_submitter/submissions/services/aandt_sync.py
@@ -0,0 +1,131 @@
+from dataclasses import dataclass, field
+
+from django.conf import settings
+from django.db import transaction
+from django.urls import reverse
+from django.utils import timezone
+
+from accounts.models import CustomUser
+from games.models import EventGame
+
+from ..models import Scope
+from .aandt_client import AAndTAPIError, AAndTClient
+
+
+class ScopeSyncError(Exception):
+ """Raised when an EventGame cannot be synced against the AAndT API."""
+
+
+@dataclass
+class ScopeSyncResult:
+ created: int = 0
+ updated: int = 0
+ warnings: list[str] = field(default_factory=list)
+
+
+def _submission_url(scope: Scope, /) -> str:
+ path = reverse(
+ "submissions.view",
+ kwargs={
+ "event_path": scope.event_game.event.path,
+ "game_path": scope.event_game.game.path,
+ "scope_id": scope.id,
+ },
+ )
+ return f"{settings.SUBMITTER_BASE_URL.rstrip('/')}{path}"
+
+
+def _userid_by_members(members: list[dict]) -> dict[int, set[int]]:
+ userids_by_members: dict[int, set[int]] = {}
+
+ for member in members:
+ if "team" in member:
+ userids = {u["id"] for u in member["team"]["members"]}
+ else:
+ userids = {member["user"]["id"]}
+ userids_by_members[member["id"]] = userids
+
+ return userids_by_members
+
+
+@transaction.atomic
+def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
+ """Fetch matches and members from the legacy AAndT API and upsert local Scopes.
+
+ Each remote match becomes one Scope (matched on `external_id`), with
+ `assigned_users` set to every local account tied to either side of the
+ match, resolved by username. Active scopes get their submission page's
+ URL pushed back as the match's player-facing message, so participants
+ browsing the legacy site get a direct link here.
+
+ Args:
+ event_game: The EventGame to sync; must have `apibot_token` set.
+
+ Returns:
+ A summary of created/updated scopes and any unresolved participants.
+
+ Raises:
+ ScopeSyncError: When the EventGame has no token, or the API call fails.
+ """
+ if not event_game.apibot_token:
+ raise ScopeSyncError(f"{event_game} has no AAndT token configured")
+
+ client = AAndTClient(settings.AANDT_API_BASE_URL, event_game.apibot_token)
+
+ try:
+ userids_by_members = _userid_by_members(client.get_members())
+ matches = client.get_matches()
+ except AAndTAPIError as exc:
+ raise ScopeSyncError(str(exc)) from exc
+
+ result = ScopeSyncResult()
+ now = timezone.now()
+
+ for match in matches:
+ userids: set[int] = set()
+ for member_id in match.get("players", {}):
+ userids.update(userids_by_members.get(int(member_id), set()))
+
+ users = list(CustomUser.objects.filter(cas_user_id__in=userids))
+
+ scope, created = Scope.objects.update_or_create(
+ event_game=event_game,
+ external_id=match["id"],
+ defaults={
+ "name": match.get("title") or f"Match {match['id']}",
+ "is_active": match.get("status") == "playing",
+ "synced_at": now,
+ },
+ )
+ scope.assigned_users.set(users)
+
+ result.created += created
+ result.updated += not created
+
+ matched_ids = {
+ int(u.cas_user_id)
+ for u in users
+ if u.cas_user_id and u.cas_user_id.isdigit()
+ }
+ missing = userids - matched_ids
+ if missing:
+ result.warnings.append(
+ f"Match {match['id']}: no local account for {', '.join(sorted(map(str, missing)))}"
+ )
+
+ if scope.is_active:
+ try:
+ client.set_match_message(
+ match["id"],
+ players=(
+ "Submit your result
"
+ f''
+ "Submit your proof here"
+ ),
+ )
+ except AAndTAPIError as exc:
+ result.warnings.append(
+ f"Match {match['id']}: failed to push submission link ({exc})"
+ )
+
+ return result
diff --git a/polylan_submitter/submissions/tasks.py b/polylan_submitter/submissions/tasks.py
index 6ac2de9..48f3ff0 100644
--- a/polylan_submitter/submissions/tasks.py
+++ b/polylan_submitter/submissions/tasks.py
@@ -1,5 +1,7 @@
from django.tasks import task
+from games.models import EventGame
from submissions.models import ImageCrop, SubmissionFile, OCRStatus
+from submissions.services.aandt_sync import sync_event_game_scopes
from PIL import Image
import pytesseract
@@ -69,3 +71,24 @@ def process_crop(crop_id: str):
return
_process_ocr_file(crop, crop.submission_file.file, crop=crop)
+
+
+@task
+def sync_all_event_games_task():
+ """Queue a Scope sync for every EventGame with an AAndT token configured."""
+ event_games = EventGame.objects.exclude(apibot_token__isnull=True).exclude(
+ apibot_token=""
+ )
+
+ for event_game in event_games:
+ sync_event_game_scopes_task.enqueue(event_game.id)
+
+
+@task
+def sync_event_game_scopes_task(event_game_id: int):
+ event_game = EventGame.objects.filter(id=event_game_id).first()
+
+ if not event_game:
+ return
+
+ sync_event_game_scopes(event_game)
diff --git a/polylan_submitter/submissions/views.py b/polylan_submitter/submissions/views.py
index ce5419e..a217750 100644
--- a/polylan_submitter/submissions/views.py
+++ b/polylan_submitter/submissions/views.py
@@ -1,22 +1,79 @@
-from django.http import HttpRequest
-from django.shortcuts import render, get_object_or_404
+from uuid import UUID
+from django.http import HttpRequest, Http404
+from django.shortcuts import redirect, render, get_object_or_404
from django.contrib.auth.decorators import login_required
from events.models import Event
from games.models import Game
+from .models import Scope
@login_required
-def submission_home(request: HttpRequest, event_path: str, game: str):
+def submission_home(request: HttpRequest, event_path: str, game_path: str):
event = get_object_or_404(Event, path=event_path)
- game_obj = get_object_or_404(
- Game.objects.enabled_for_event(event), path=f"/submissions/{game}"
+ game = get_object_or_404(Game, path=game_path)
+
+ scopes = (
+ Scope.objects.filter(event_game__event=event, event_game__game=game)
+ if request.user.is_superuser
+ else Scope.objects.filter(
+ event_game__event=event,
+ event_game__game=game,
+ assigned_users=request.user,
+ )
)
+
+ if scopes.count() == 1:
+ return redirect(
+ "submissions.view",
+ event_path=event.path,
+ game_path=game.path,
+ scope_id=scopes[0].id,
+ )
+
+ if not scopes:
+ raise Http404
+
+ return render(
+ request,
+ "submissions_select.html",
+ {
+ "event": event,
+ "game": game,
+ "scopes_data": [
+ {"id": str(scope.id), "name": scope.name}
+ for scope in scopes.order_by("name")
+ ],
+ },
+ )
+
+
+@login_required
+def submission_view(
+ request: HttpRequest, event_path: str, game_path: str, scope_id: UUID
+):
+ event = get_object_or_404(Event, path=event_path)
+ scope = get_object_or_404(
+ Scope.objects.select_related("event_game__game").prefetch_related(
+ "assigned_users"
+ ),
+ id=scope_id,
+ event_game__game__path=game_path,
+ event_game__event=event,
+ )
+
+ if (
+ not request.user.is_superuser
+ and not scope.assigned_users.filter(pk=request.user.pk).exists()
+ ):
+ raise Http404
+
return render(
request,
"submissions.html",
{
"event": event,
- "game": game_obj,
+ "scope": scope,
+ "game": scope.event_game.game,
},
)
diff --git a/polylan_submitter/templates/submissions.html b/polylan_submitter/templates/submissions.html
index 80b266e..d8ac9da 100644
--- a/polylan_submitter/templates/submissions.html
+++ b/polylan_submitter/templates/submissions.html
@@ -2,12 +2,12 @@
{% load django_vite %}
-{% block title %}{{ game.name }} Submissions{% endblock %}
+{% block title %}{{ scope.name }} Submissions{% endblock %}
{% block head %}
{% vite_asset 'src/submissions.ts' %}
{% endblock %}
{% block content %}
-
+
{% endblock %}
diff --git a/polylan_submitter/templates/submissions_select.html b/polylan_submitter/templates/submissions_select.html
new file mode 100644
index 0000000..992d348
--- /dev/null
+++ b/polylan_submitter/templates/submissions_select.html
@@ -0,0 +1,14 @@
+{% extends "base.html" %}
+
+{% load django_vite %}
+
+{% block title %}Select a match{% endblock %}
+
+{% block head %}
+ {% vite_asset 'src/submissions_select.ts' %}
+{% endblock %}
+
+{% block content %}
+ {{ scopes_data|json_script:"scopes-data" }}
+
+{% endblock %}
diff --git a/polylan_submitter/vite.config.ts b/polylan_submitter/vite.config.ts
index b1030c7..e1761e0 100644
--- a/polylan_submitter/vite.config.ts
+++ b/polylan_submitter/vite.config.ts
@@ -34,6 +34,7 @@ export default defineConfig({
noita: resolve('./src/noita.ts'),
market: resolve('./src/market.ts'),
submissions: resolve('./src/submissions.ts'),
+ submissions_select: resolve('./src/submissions_select.ts'),
}
}
},