diff --git a/polylan_submitter/games/admin.py b/polylan_submitter/games/admin.py index 65d9c6d..2dfca54 100644 --- a/polylan_submitter/games/admin.py +++ b/polylan_submitter/games/admin.py @@ -1,4 +1,6 @@ -from django.contrib import admin +from django.contrib import admin, messages + +from submissions.tasks import sync_event_game_scopes_task from .models import EventGame, Game @@ -18,6 +20,29 @@ class GameAdmin(admin.ModelAdmin): @admin.register(EventGame) class EventGameAdmin(admin.ModelAdmin): - list_display = ["game", "event", "enabled"] + list_display = ["game", "event", "enabled", "has_apibot_token"] list_filter = ["event", "enabled"] search_fields = ["game__name", "event__name"] + actions = ["sync_scopes"] + + @admin.display(description="AAndT token", boolean=True) + def has_apibot_token(self, obj): + return bool(obj.apibot_token) + + @admin.action(description="Sync scopes from AAndT for selected EventGames") + def sync_scopes(self, request, queryset): + tokened = queryset.exclude(apibot_token__isnull=True).exclude(apibot_token="") + skipped = queryset.count() - tokened.count() + + for event_game in tokened: + sync_event_game_scopes_task.enqueue(event_game.id) + + self.message_user( + request, f"Queued scope sync for {tokened.count()} EventGame(s)." + ) + if skipped: + self.message_user( + request, + f"Skipped {skipped} EventGame(s) with no AAndT token configured.", + level=messages.WARNING, + ) diff --git a/polylan_submitter/games/migrations/0007_eventgame_apibot_token.py b/polylan_submitter/games/migrations/0007_eventgame_apibot_token.py new file mode 100644 index 0000000..caf0e2b --- /dev/null +++ b/polylan_submitter/games/migrations/0007_eventgame_apibot_token.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("games", "0006_remove_game_enabled"), + ] + + operations = [ + migrations.AddField( + model_name="eventgame", + name="apibot_token", + field=models.CharField( + blank=True, + help_text=( + "Bearer token for the legacy PolyLAN AAndT API (from the AAndT's " + "'apibot' admin page), used to auto-sync Scopes and their assigned users." + ), + max_length=512, + null=True, + ), + ), + ] diff --git a/polylan_submitter/games/migrations/0008_game_custom_path.py b/polylan_submitter/games/migrations/0008_game_custom_path.py new file mode 100644 index 0000000..d99685b --- /dev/null +++ b/polylan_submitter/games/migrations/0008_game_custom_path.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.5 on 2026-09-16 00:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("games", "0007_eventgame_apibot_token"), + ] + + operations = [ + migrations.AddField( + model_name="game", + name="custom_path", + field=models.BooleanField(default=False), + ), + ] diff --git a/polylan_submitter/games/models.py b/polylan_submitter/games/models.py index 1c3b437..cf08c05 100644 --- a/polylan_submitter/games/models.py +++ b/polylan_submitter/games/models.py @@ -12,6 +12,7 @@ class Game(models.Model): steam_app_id = models.PositiveIntegerField(null=True, blank=True) name = models.CharField(max_length=255) path = models.CharField(max_length=100) + custom_path = models.BooleanField(default=False) image = models.URLField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) @@ -41,6 +42,16 @@ class EventGame(models.Model): ) enabled = models.BooleanField(default=True) + apibot_token = models.CharField( + max_length=512, + blank=True, + null=True, + help_text=( + "Bearer token for the legacy PolyLAN AAndT API (from the AAndT's " + "'apibot' admin page), used to auto-sync Scopes and their assigned users." + ), + ) + class Meta: constraints = [ models.UniqueConstraint( diff --git a/polylan_submitter/games/schemas.py b/polylan_submitter/games/schemas.py index cd15419..e9ae513 100644 --- a/polylan_submitter/games/schemas.py +++ b/polylan_submitter/games/schemas.py @@ -8,4 +8,5 @@ class GameOut(Schema): steam_app_id: Optional[int] = None name: str path: str + custom_path: bool image: Optional[str] = None diff --git a/polylan_submitter/polylan_submitter/settings.py b/polylan_submitter/polylan_submitter/settings.py index 89fdd24..2998408 100644 --- a/polylan_submitter/polylan_submitter/settings.py +++ b/polylan_submitter/polylan_submitter/settings.py @@ -149,6 +149,14 @@ CAS_SERVER_URL = "https://polylan.ch/cas/" # Steam API Configuration STEAM_API_KEY = os.environ.get("STEAM_API_KEY", None) # Set via environment variable +# Base URL of the legacy PolyLAN site, used to sync AAndT matches/members +# (see submissions.services.aandt_sync) +AANDT_API_BASE_URL = os.environ.get("AANDT_API_BASE_URL", "https://polylan.ch") + +# Base URL of this site, used to build submission links pushed back to the +# legacy AAndT match messages (see submissions.services.aandt_sync) +SUBMITTER_BASE_URL = os.environ.get("SUBMITTER_BASE_URL", "http://127.0.0.1:7777") + # File Upload Settings MEDIA_URL = "/media/" MEDIA_ROOT = BASE_DIR / "media" diff --git a/polylan_submitter/polylan_submitter/urls.py b/polylan_submitter/polylan_submitter/urls.py index 0b235bc..69dda8d 100644 --- a/polylan_submitter/polylan_submitter/urls.py +++ b/polylan_submitter/polylan_submitter/urls.py @@ -26,7 +26,7 @@ from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView from events.models import Event from games.decorators import require_game_enabled from market.views import market_home -from submissions.views import submission_home +from submissions.views import submission_home, submission_view from .api import api NOITA_APP_ID = 881100 @@ -90,10 +90,15 @@ urlpatterns = [ path("/opus-magnum", opus_magnum_home, name="opus-magnum.home"), path("/noita", noita_home, name="noita.home"), path( - "/submissions/", + "/submissions/", submission_home, name="submissions.home", ), + path( + "/submissions//", + submission_view, + name="submissions.view", + ), path("/", event_home, name="event.home"), path("", home, name="home"), ] diff --git a/polylan_submitter/simple_cas_backend.py b/polylan_submitter/simple_cas_backend.py index 94b6fa2..868d242 100644 --- a/polylan_submitter/simple_cas_backend.py +++ b/polylan_submitter/simple_cas_backend.py @@ -7,6 +7,7 @@ import requests from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.backends import BaseBackend +import sentry_sdk class SimpleCASBackend(BaseBackend): @@ -23,9 +24,6 @@ class SimpleCASBackend(BaseBackend): if not cas_user_id: return None - print(f"CAS User ID: {cas_user_id}") - print(f"CAS Attributes: {attributes}") - User = get_user_model() # Try to find user by CAS user ID first, then by username @@ -80,7 +78,7 @@ class SimpleCASBackend(BaseBackend): return None, None except Exception as e: - print(f"CAS validation error: {e}") + sentry_sdk.capture_exception(e) return None, None def get_user(self, user_id): diff --git a/polylan_submitter/src/Home.vue b/polylan_submitter/src/Home.vue index b5af2e2..46baba4 100644 --- a/polylan_submitter/src/Home.vue +++ b/polylan_submitter/src/Home.vue @@ -91,7 +91,8 @@ onMounted(async () => { -
(), { - gamePath: "", + scopeName: "Match", gameName: "Game", + gamePath: "", gameImage: "", gameSteamId: "", }); @@ -403,7 +406,7 @@ const submitSubmission = async () => { try { await submissionsApiCreateSubmission({ path: { event_path: props.eventPath }, - query: { game_path: props.gamePath }, + query: { scope_id: props.scopeId }, body: { files: uploadedFiles.value, notes: notes.value || null, @@ -423,7 +426,7 @@ const submitSubmission = async () => { }; const goHome = () => { - window.location.href = `/${props.eventPath}/`; + window.location.href = `/${props.eventPath}/submissions/${props.gamePath}`; }; const formatFileSize = (bytes: number) => { @@ -454,13 +457,11 @@ const getOCRStatusColor = (status: string) => { }; const loadUserSubmissions = async () => { - if (!props.gamePath) return; - isLoadingSubmissions.value = true; try { const { data } = await submissionsApiListUserSubmissions({ path: { event_path: props.eventPath }, - query: { game_path: props.gamePath }, + query: { scope_id: props.scopeId }, }); if (data && Array.isArray(data)) { userSubmissions.value = data; @@ -522,7 +523,7 @@ onUnmounted(() => { Back -

{{ gameName }} Submissions

+

{{ gameName }} Submissions — {{ scopeName }}

@@ -555,7 +556,8 @@ onUnmounted(() => {

{{ gameName }}

-

Submit your best results for this game

+

Submitting for match: {{ scopeName }} +

@@ -568,10 +570,11 @@ onUnmounted(() => { -
+
-
@@ -699,15 +702,6 @@ onUnmounted(() => { processed.

- -
- -
-

- - Pro tip: Include clear screenshots or documentation with your submission for faster verification. -

-
@@ -925,8 +919,7 @@ onUnmounted(() => {

OCR processing failed

-
+

Waiting for OCR processing...

@@ -934,9 +927,9 @@ onUnmounted(() => {
-
-

Crop OCR Results ({{ selectedSubmissionFile.crops.length }}):

+
+

Crop OCR Results ({{ + selectedSubmissionFile.crops.length }}):

{{ crop.label || `Crop (${crop.x1}, ${crop.y1})` }}

@@ -946,7 +939,8 @@ onUnmounted(() => { class="bg-base-100 p-2 rounded text-xs whitespace-pre-wrap break-words font-mono max-h-32 overflow-y-auto"> {{ crop.ocr_raw_data }}
-
+
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 @@ + + + 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'), } } },