diff --git a/polylan_submitter/animations/api.py b/polylan_submitter/animations/api.py index 7adaa9f..1872624 100644 --- a/polylan_submitter/animations/api.py +++ b/polylan_submitter/animations/api.py @@ -22,10 +22,12 @@ from opus_magnum.models import PuzzleResponse, SteamCollectionItem, SteamCollect router = Router() +PREFIX = "/events/{event_path}/results" -@router.get("results", response=RankingSchema) -def results(request: HttpRequest) -> dict: - cache_key = "api:results:results" + +@router.get(f"{PREFIX}/results", response=RankingSchema) +def results(request: HttpRequest, event_path: str) -> dict: + cache_key = f"api:{event_path}:results:results" cached_data = cache.get(cache_key) if cached_data is not None: @@ -35,7 +37,9 @@ def results(request: HttpRequest) -> dict: responses_by_puzzleid = defaultdict(list) for response in list( - PuzzleResponse.objects.filter(needs_manual_validation=False) + PuzzleResponse.objects.filter( + needs_manual_validation=False, submission__event=request.event + ) .filter_user_best_response() .prefetch_related("submission__user") ): @@ -51,25 +55,27 @@ def results(request: HttpRequest) -> dict: data = { "users": list(CustomUser.objects.filter(pk__in=responses_by_userid.keys())), - "puzzles": list(SteamCollectionItem.objects.all()), + "puzzles": list( + SteamCollectionItem.objects.filter(collection__event=request.event) + ), "responses_by_userid": dict(responses_by_userid), "ranking_by_puzzle": ranking, } - cache.set("api:results:results", data, 300) + cache.set(cache_key, data, 300) return data -@router.get("top-submissions", response=TournamentSubmissionsOut) -def top_submissions(request: HttpRequest, limit: int = 5) -> dict: +@router.get(f"{PREFIX}/top-submissions", response=TournamentSubmissionsOut) +def top_submissions(request: HttpRequest, event_path: str, limit: int = 5) -> dict: """Get tournament top submissions for each puzzle. Only available when tournament is closed.""" - cache_key = f"api:results:top_submissions:{limit}" + cache_key = f"api:{event_path}:results:top_submissions:{limit}" cached_data = cache.get(cache_key) if cached_data is not None: return cached_data - collection = get_object_or_404(SteamCollection, is_active=True) + collection = get_object_or_404(SteamCollection, is_active=True, event=request.event) # Only allow access when tournament is closed if collection.accepting_submissions: @@ -141,20 +147,20 @@ def top_submissions(request: HttpRequest, limit: int = 5) -> dict: ) data = {"submissions": submissions_list} - cache.set(f"api:results:top_submissions:{limit}", data, 300) + cache.set(cache_key, data, 300) return data -@router.get("puzzle-results", response=TournamentPuzzleResultsOut) -def puzzle_results(request: HttpRequest, limit: int = 5) -> dict: +@router.get(f"{PREFIX}/puzzle-results", response=TournamentPuzzleResultsOut) +def puzzle_results(request: HttpRequest, event_path: str, limit: int = 5) -> dict: """Get tournament results organized by puzzle with coefficients. Only available when tournament is closed.""" - cache_key = f"api:results:puzzle_results:{limit}" + cache_key = f"api:{event_path}:results:puzzle_results:{limit}" cached_data = cache.get(cache_key) if cached_data is not None: return cached_data - collection = get_object_or_404(SteamCollection, is_active=True) + collection = get_object_or_404(SteamCollection, is_active=True, event=request.event) # Only allow access when tournament is closed if collection.accepting_submissions: @@ -237,5 +243,5 @@ def puzzle_results(request: HttpRequest, limit: int = 5) -> dict: ) data = {"results": results_list} - cache.set(f"api:results:puzzle_results:{limit}", data, 300) + cache.set(cache_key, data, 300) return data diff --git a/polylan_submitter/events/__init__.py b/polylan_submitter/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/polylan_submitter/events/admin.py b/polylan_submitter/events/admin.py new file mode 100644 index 0000000..b0710c3 --- /dev/null +++ b/polylan_submitter/events/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from .models import Event + + +@admin.register(Event) +class EventAdmin(admin.ModelAdmin): + list_display = ["name", "path", "created_at"] + search_fields = ["name", "path"] + prepopulated_fields = {"path": ("name",)} + readonly_fields = ["created_at", "updated_at"] diff --git a/polylan_submitter/events/apps.py b/polylan_submitter/events/apps.py new file mode 100644 index 0000000..60b376c --- /dev/null +++ b/polylan_submitter/events/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class EventsConfig(AppConfig): + name = "events" diff --git a/polylan_submitter/events/middleware.py b/polylan_submitter/events/middleware.py new file mode 100644 index 0000000..a67f220 --- /dev/null +++ b/polylan_submitter/events/middleware.py @@ -0,0 +1,27 @@ +from django.shortcuts import get_object_or_404 + +from .models import Event +from .scoping import current_event + + +class EventContextMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + try: + return self.get_response(request) + finally: + # Not current_event.reset(token): under ASGI, process_view and this + # finally block can run in different contextvars.Context copies + # (sync_to_async(thread_sensitive=True) copies the context), and + # Token.reset() requires the exact Context it was created in. + # set(None) has no such constraint. + current_event.set(None) + + def process_view(self, request, view_func, view_args, view_kwargs): + event_path = view_kwargs.get("event_path") + if event_path: + request.event = get_object_or_404(Event, path=event_path) + current_event.set(request.event) + return None diff --git a/polylan_submitter/events/migrations/0001_initial.py b/polylan_submitter/events/migrations/0001_initial.py new file mode 100644 index 0000000..c529441 --- /dev/null +++ b/polylan_submitter/events/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 6.0.5 on 2026-09-15 21:08 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Event", + 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)), + ("name", models.CharField(max_length=255)), + ("path", models.SlugField(max_length=100, unique=True)), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/polylan_submitter/events/migrations/0002_seed_polylan_43.py b/polylan_submitter/events/migrations/0002_seed_polylan_43.py new file mode 100644 index 0000000..b976182 --- /dev/null +++ b/polylan_submitter/events/migrations/0002_seed_polylan_43.py @@ -0,0 +1,24 @@ +from django.db import migrations + +EVENT_NAME = "PolyLAN 43" +EVENT_PATH = "polylan-43" + + +def seed_event(apps, schema_editor): + Event = apps.get_model("events", "Event") + Event.objects.get_or_create(path=EVENT_PATH, defaults={"name": EVENT_NAME}) + + +def unseed_event(apps, schema_editor): + Event = apps.get_model("events", "Event") + Event.objects.filter(path=EVENT_PATH).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0001_initial"), + ] + + operations = [ + migrations.RunPython(seed_event, unseed_event), + ] diff --git a/polylan_submitter/events/migrations/__init__.py b/polylan_submitter/events/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/polylan_submitter/events/models.py b/polylan_submitter/events/models.py new file mode 100644 index 0000000..c89b7e4 --- /dev/null +++ b/polylan_submitter/events/models.py @@ -0,0 +1,22 @@ +from django.db import models + +import uuid + + +class BaseModel(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class Event(BaseModel): + name = models.CharField(max_length=255) + path = models.SlugField(max_length=100, unique=True) + + def __str__(self) -> str: + return self.name diff --git a/polylan_submitter/events/scoping.py b/polylan_submitter/events/scoping.py new file mode 100644 index 0000000..3817fc8 --- /dev/null +++ b/polylan_submitter/events/scoping.py @@ -0,0 +1,21 @@ +import contextvars + +from django.core.exceptions import ImproperlyConfigured +from django.db import models + +current_event = contextvars.ContextVar("current_event", default=None) + + +class EventScopedQuerySet(models.QuerySet): + def for_current_event(self): + event = current_event.get() + if event is None: + raise ImproperlyConfigured( + f"{self.model.__name__}.for_event was used outside an event-scoped request" + ) + return self.filter(event=event) + + +class EventScopedManager(models.Manager.from_queryset(EventScopedQuerySet)): + def get_queryset(self): + return super().get_queryset().for_current_event() diff --git a/polylan_submitter/events/tests.py b/polylan_submitter/events/tests.py new file mode 100644 index 0000000..ae0f2d1 --- /dev/null +++ b/polylan_submitter/events/tests.py @@ -0,0 +1,34 @@ +from django.core.exceptions import ImproperlyConfigured +from django.test import TestCase + +from games.models import Game +from submissions.models import Submission + +from .models import Event +from .scoping import current_event + + +class EventScopedManagerTests(TestCase): + def setUp(self): + self.event_a = Event.objects.create(name="Event A", path="event-a") + self.event_b = Event.objects.create(name="Event B", path="event-b") + self.game = Game.objects.create(name="Test Game", path="test-game") + self.submission_a = Submission.objects.create( + event=self.event_a, game=self.game + ) + self.submission_b = Submission.objects.create( + event=self.event_b, game=self.game + ) + + def test_for_event_raises_outside_event_context(self): + with self.assertRaises(ImproperlyConfigured): + list(Submission.for_event.all()) + + def test_for_event_filters_to_current_event(self): + token = current_event.set(self.event_a) + try: + results = list(Submission.for_event.all()) + finally: + current_event.reset(token) + + self.assertEqual(results, [self.submission_a]) diff --git a/polylan_submitter/events/views.py b/polylan_submitter/events/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/polylan_submitter/events/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/polylan_submitter/games/admin.py b/polylan_submitter/games/admin.py index c810eb4..65d9c6d 100644 --- a/polylan_submitter/games/admin.py +++ b/polylan_submitter/games/admin.py @@ -1,28 +1,23 @@ from django.contrib import admin -from .models import Game +from .models import EventGame, Game -def enable_games(modeladmin, request, queryset): - updated = queryset.update(enabled=True) - modeladmin.message_user(request, f"{updated} game(s) enabled successfully.") - - -enable_games.short_description = "Enable selected games" - - -def disable_games(modeladmin, request, queryset): - updated = queryset.update(enabled=False) - modeladmin.message_user(request, f"{updated} game(s) disabled successfully.") - - -disable_games.short_description = "Disable selected games" +class EventGameInline(admin.TabularInline): + model = EventGame + extra = 0 @admin.register(Game) class GameAdmin(admin.ModelAdmin): - list_display = ["name", "steam_app_id", "enabled", "updated_at"] - list_filter = ["enabled"] + list_display = ["name", "steam_app_id", "updated_at"] search_fields = ["name", "steam_app_id"] readonly_fields = ["created_at", "updated_at"] - actions = [enable_games, disable_games] + inlines = [EventGameInline] + + +@admin.register(EventGame) +class EventGameAdmin(admin.ModelAdmin): + list_display = ["game", "event", "enabled"] + list_filter = ["event", "enabled"] + search_fields = ["game__name", "event__name"] diff --git a/polylan_submitter/games/api.py b/polylan_submitter/games/api.py index e863b87..1bf0a1d 100644 --- a/polylan_submitter/games/api.py +++ b/polylan_submitter/games/api.py @@ -6,8 +6,14 @@ from .models import Game from .schemas import GameOut router = Router() +event_router = Router() @router.get("", response=List[GameOut]) def list_games(request): - return Game.objects.filter(enabled=True) + return Game.objects.all() + + +@event_router.get("/events/{event_path}/games", response=List[GameOut]) +def list_event_games(request, event_path: str): + return Game.objects.enabled_for_event(request.event) diff --git a/polylan_submitter/games/decorators.py b/polylan_submitter/games/decorators.py index 339ebad..212468a 100644 --- a/polylan_submitter/games/decorators.py +++ b/polylan_submitter/games/decorators.py @@ -1,7 +1,9 @@ from functools import wraps from django.core.exceptions import PermissionDenied +from django.shortcuts import get_object_or_404 +from events.models import Event from .models import Game @@ -9,12 +11,15 @@ def require_game_enabled(steam_app_id: int): def decorator(view_func): @wraps(view_func) def wrapper(request, *args, **kwargs): - try: - game = Game.objects.get(steam_app_id=steam_app_id) - except Game.DoesNotExist: - raise PermissionDenied - if not game.enabled: + event = get_object_or_404(Event, path=kwargs.get("event_path")) + + if ( + not Game.objects.enabled_for_event(event) + .filter(steam_app_id=steam_app_id) + .exists() + ): raise PermissionDenied + return view_func(request, *args, **kwargs) return wrapper diff --git a/polylan_submitter/games/migrations/0005_eventgame.py b/polylan_submitter/games/migrations/0005_eventgame.py new file mode 100644 index 0000000..ece9d03 --- /dev/null +++ b/polylan_submitter/games/migrations/0005_eventgame.py @@ -0,0 +1,52 @@ +# Generated by Django 6.0.5 on 2026-09-15 21:11 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0002_seed_polylan_43"), + ("games", "0004_game_image_alter_game_steam_app_id"), + ] + + operations = [ + migrations.CreateModel( + name="EventGame", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("enabled", models.BooleanField(default=True)), + ( + "event", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="event_games", + to="events.event", + ), + ), + ( + "game", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="event_games", + to="games.game", + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("event", "game"), name="unique_event_game" + ) + ], + }, + ), + ] diff --git a/polylan_submitter/games/migrations/0006_remove_game_enabled.py b/polylan_submitter/games/migrations/0006_remove_game_enabled.py new file mode 100644 index 0000000..da9ff91 --- /dev/null +++ b/polylan_submitter/games/migrations/0006_remove_game_enabled.py @@ -0,0 +1,16 @@ +# Generated by Django 6.0.5 on 2026-09-15 21:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("games", "0005_eventgame"), + ] + + operations = [ + migrations.RemoveField( + model_name="game", + name="enabled", + ), + ] diff --git a/polylan_submitter/games/models.py b/polylan_submitter/games/models.py index 2794c56..1c3b437 100644 --- a/polylan_submitter/games/models.py +++ b/polylan_submitter/games/models.py @@ -1,16 +1,24 @@ from django.db import models +from events.models import Event + + +class GameQuerySet(models.QuerySet): + def enabled_for_event(self, event): + return self.exclude(event_games__event=event, event_games__enabled=False) + 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) image = models.URLField(null=True, blank=True) - enabled = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + objects = models.Manager.from_queryset(GameQuerySet)() + class Meta: ordering = ["name"] @@ -18,3 +26,28 @@ class Game(models.Model): if self.steam_app_id: return f"{self.name} ({self.steam_app_id})" return self.name + + +class EventGame(models.Model): + event = models.ForeignKey( + Event, + on_delete=models.CASCADE, + related_name="event_games", + ) + game = models.ForeignKey( + Game, + on_delete=models.CASCADE, + related_name="event_games", + ) + enabled = models.BooleanField(default=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["event", "game"], + name="unique_event_game", + ), + ] + + def __str__(self) -> str: + return f"{self.game.name} @ {self.event.name} ({'enabled' if self.enabled else 'disabled'})" diff --git a/polylan_submitter/market/api.py b/polylan_submitter/market/api.py index f41be0f..507245b 100644 --- a/polylan_submitter/market/api.py +++ b/polylan_submitter/market/api.py @@ -17,11 +17,13 @@ from market.schemas import ( router = Router(tags=["market"]) +PREFIX = "/events/{event_path}/market" -@router.get("/", response=List[MarketListSchema]) -def list_markets(request): + +@router.get(f"{PREFIX}/", response=List[MarketListSchema]) +def list_markets(request, event_path: str): """List all markets (excludes draft markets).""" - markets = Market.objects.exclude(status=Market.Status.DRAFT) + markets = Market.for_event.exclude(status=Market.Status.DRAFT) # Prefetch options with total_bets annotation sorted by total_bets desc, then text asc options_queryset = MarketOption.objects.annotate( total_bets=Coalesce(Sum("user_bets__amount"), 0) @@ -30,38 +32,40 @@ def list_markets(request): return markets.prefetch_related(Prefetch("options", queryset=options_queryset)) -@router.get("/user/bets", response=List[UserBetSchema]) -def list_user_bets(request): +@router.get(f"{PREFIX}/user/bets", response=List[UserBetSchema]) +def list_user_bets(request, event_path: str): """List all bets placed by the current user.""" if not request.user.is_authenticated: raise HttpError(401, "Authentication required") return ( - UserBet.objects.filter(user=request.user) + UserBet.objects.filter(user=request.user, option__market__event=request.event) .select_related("option__market") .prefetch_related("option__market__options") ) -@router.post("/{market_uuid}/actions/close") -def close_market(request, market_uuid: str): +@router.post(f"{PREFIX}/{{market_uuid}}/actions/close") +def close_market(request, event_path: str, market_uuid: str): """Close a market. Admin only.""" if not request.user.is_staff: raise HttpError(403, "Permission denied") - market = get_object_or_404(Market, uuid=market_uuid) + market = get_object_or_404(Market, uuid=market_uuid, event=request.event) market.status = Market.Status.CLOSED market.save(update_fields=["status", "updated_at"]) return {"status": Market.Status.CLOSED} -@router.post("/{market_uuid}/actions/resolve", response=MarketListSchema) -def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema): +@router.post(f"{PREFIX}/{{market_uuid}}/actions/resolve", response=MarketListSchema) +def resolve_market( + request, event_path: str, market_uuid: str, payload: ResolveMarketSchema +): """Resolve a market with a winning option. Admin only.""" if not request.user.is_staff: raise HttpError(403, "Permission denied") - market = get_object_or_404(Market, uuid=market_uuid) + market = get_object_or_404(Market, uuid=market_uuid, event=request.event) winning_option = get_object_or_404(MarketOption, uuid=payload.winning_option_uuid) if winning_option.market_id != market.id: @@ -129,13 +133,15 @@ def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema): return market -@router.post("/{market_uuid}/bets", response=UserBetSchema) -def create_bet(request, market_uuid: str, payload: UserBetCreateSchema): +@router.post(f"{PREFIX}/{{market_uuid}}/bets", response=UserBetSchema) +def create_bet( + request, event_path: str, market_uuid: str, payload: UserBetCreateSchema +): """Place a bet on a market option.""" if not request.user.is_authenticated: raise HttpError(401, "Authentication required") - market = get_object_or_404(Market, uuid=market_uuid) + market = get_object_or_404(Market, uuid=market_uuid, event=request.event) option = get_object_or_404(MarketOption, uuid=payload.option_uuid) if option.market_id != market.id: diff --git a/polylan_submitter/market/migrations/0007_market_event.py b/polylan_submitter/market/migrations/0007_market_event.py new file mode 100644 index 0000000..2786a8b --- /dev/null +++ b/polylan_submitter/market/migrations/0007_market_event.py @@ -0,0 +1,24 @@ +# Generated by Django 6.0.5 on 2026-09-15 21:10 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0002_seed_polylan_43"), + ("market", "0006_alter_market_status"), + ] + + operations = [ + migrations.AddField( + model_name="market", + name="event", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="markets", + to="events.event", + ), + ), + ] diff --git a/polylan_submitter/market/migrations/0008_backfill_event.py b/polylan_submitter/market/migrations/0008_backfill_event.py new file mode 100644 index 0000000..74754af --- /dev/null +++ b/polylan_submitter/market/migrations/0008_backfill_event.py @@ -0,0 +1,25 @@ +from django.db import migrations + +EVENT_PATH = "polylan-43" + + +def backfill_event(apps, schema_editor): + Event = apps.get_model("events", "Event") + Market = apps.get_model("market", "Market") + event = Event.objects.get(path=EVENT_PATH) + Market.objects.filter(event__isnull=True).update(event=event) + + +def noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("market", "0007_market_event"), + ("events", "0002_seed_polylan_43"), + ] + + operations = [ + migrations.RunPython(backfill_event, noop), + ] diff --git a/polylan_submitter/market/migrations/0009_event_required.py b/polylan_submitter/market/migrations/0009_event_required.py new file mode 100644 index 0000000..c196963 --- /dev/null +++ b/polylan_submitter/market/migrations/0009_event_required.py @@ -0,0 +1,20 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("market", "0008_backfill_event"), + ] + + operations = [ + migrations.AlterField( + model_name="market", + name="event", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="markets", + to="events.event", + ), + ), + ] diff --git a/polylan_submitter/market/models.py b/polylan_submitter/market/models.py index ce94a92..507274b 100644 --- a/polylan_submitter/market/models.py +++ b/polylan_submitter/market/models.py @@ -1,6 +1,9 @@ import uuid from django.db import models +from events.models import Event +from events.scoping import EventScopedManager + class BaseModel(models.Model): uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) @@ -18,6 +21,12 @@ class Market(BaseModel): CLOSED = "closed", "Closed" RESOLVED = "resolved", "Resolved" + event = models.ForeignKey( + Event, + on_delete=models.CASCADE, + related_name="markets", + ) + title = models.CharField(max_length=255) description = models.TextField(blank=True) status = models.CharField( @@ -34,6 +43,9 @@ class Market(BaseModel): related_name="market_won", ) + objects = models.Manager() + for_event = EventScopedManager() + class Meta: ordering = ["-created_at"] indexes = [ diff --git a/polylan_submitter/market/views.py b/polylan_submitter/market/views.py index 7db4082..765d7b9 100644 --- a/polylan_submitter/market/views.py +++ b/polylan_submitter/market/views.py @@ -1,8 +1,11 @@ from django.contrib.auth.decorators import login_required -from django.shortcuts import render +from django.shortcuts import get_object_or_404, render from django.http import HttpRequest +from events.models import Event + @login_required -def market_home(request: HttpRequest): - return render(request, "market.html", {}) +def market_home(request: HttpRequest, event_path: str): + event = get_object_or_404(Event, path=event_path) + return render(request, "market.html", {"event": event}) diff --git a/polylan_submitter/noita/api.py b/polylan_submitter/noita/api.py index 0c53982..69544ef 100644 --- a/polylan_submitter/noita/api.py +++ b/polylan_submitter/noita/api.py @@ -25,11 +25,13 @@ from .schemas import NoitaSubmissionOut router = Router() NOITA_APP_ID = 881100 +PREFIX = "/events/{event_path}/noita" -@router.get("results", response=ResultsOut) + +@router.get(f"{PREFIX}/results", response=ResultsOut) @decorate_view(require_game_enabled(NOITA_APP_ID)) -def get_results(request: HttpRequest): - cache_key = f"api:noita:results:{request.user.id}" +def get_results(request: HttpRequest, event_path: str): + cache_key = f"api:{event_path}:noita:results:{request.user.id}" cached_data = cache.get(cache_key) if cached_data is not None: @@ -42,7 +44,7 @@ def get_results(request: HttpRequest): """ # Group objectives by objectiv_id and count occurrences user_objectives = ( - Objectiv.objects.filter(user=request.user) + Objectiv.objects.filter(user=request.user, submission__event=request.event) .values("objectiv_id") .annotate(count=Count("id")) ) @@ -118,7 +120,7 @@ def get_results(request: HttpRequest): total_score += points # Count deaths for the user - deaths_count = DeathCounter.objects.filter(user=request.user).count() + deaths_count = DeathCounter.for_event.filter(user=request.user).count() data = { "total_score": total_score, @@ -126,19 +128,19 @@ def get_results(request: HttpRequest): "objectives": list(with_points.values()), } - cache.set(f"api:noita:results:{request.user.id}", data, 300) + cache.set(cache_key, data, 300) return data -@router.get("leaderboard", response=LeaderboardOut) +@router.get(f"{PREFIX}/leaderboard", response=LeaderboardOut) @decorate_view(require_game_enabled(NOITA_APP_ID)) -def get_leaderboard(request: HttpRequest): +def get_leaderboard(request: HttpRequest, event_path: str): """ Get the global leaderboard for all users ranked by total score. Uses Window functions to rank users by their total score in descending order. """ - cache_key = "api:noita:leaderboard" + cache_key = f"api:{event_path}:noita:leaderboard" cached_data = cache.get(cache_key) if cached_data is not None: @@ -150,7 +152,8 @@ def get_leaderboard(request: HttpRequest): # Get all objectives with calculated points (grouped by objectiv_id and user) all_objectives = ( - Objectiv.objects.values("user", "objectiv_id") + Objectiv.objects.filter(submission__event=request.event) + .values("user", "objectiv_id") .annotate(count=Count("id")) .annotate( # Fetch points from ObjectivPoint using Subquery @@ -201,12 +204,12 @@ def get_leaderboard(request: HttpRequest): for user_id, total_score in user_totals_dict.items(): user = User.objects.get(id=user_id) objectives_count = ( - Objectiv.objects.filter(user_id=user_id) + Objectiv.objects.filter(user_id=user_id, submission__event=request.event) .values("objectiv_id") .distinct() .count() ) - deaths_count = DeathCounter.objects.filter(user_id=user_id).count() + deaths_count = DeathCounter.for_event.filter(user_id=user_id).count() users_with_scores.append( { "user_id": user_id, @@ -232,13 +235,15 @@ def get_leaderboard(request: HttpRequest): ] data = {"leaderboard": leaderboard} - cache.set("api:noita:leaderboard", data, 300) + cache.set(cache_key, data, 300) return data -@router.post("submit", response={200: NoitaSubmissionOut, 400: dict}) +@router.post(f"{PREFIX}/submit", response={200: NoitaSubmissionOut, 400: dict}) @decorate_view(require_game_enabled(NOITA_APP_ID)) -def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)): +def submit_log_file( + request: HttpRequest, event_path: str, file: UploadedFile = File(...) +): """ Submit a Noita run file (log file, screenshot, or video). @@ -267,6 +272,7 @@ def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)): try: # Create submission submission = LogfileSubmission.objects.create( + event=request.event, user=request.user if request.user.is_authenticated else None, content_type=file.content_type, file_size=file.size, @@ -284,8 +290,8 @@ def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)): # Invalidate caches on successful submission if submission.user: - cache.delete(f"api:noita:results:{submission.user.id}") - cache.delete("api:noita:leaderboard") + cache.delete(f"api:{event_path}:noita:results:{submission.user.id}") + cache.delete(f"api:{event_path}:noita:leaderboard") return { "id": str(submission.id), diff --git a/polylan_submitter/noita/migrations/0013_deathcounter_event_logfilesubmission_event.py b/polylan_submitter/noita/migrations/0013_deathcounter_event_logfilesubmission_event.py new file mode 100644 index 0000000..61520ed --- /dev/null +++ b/polylan_submitter/noita/migrations/0013_deathcounter_event_logfilesubmission_event.py @@ -0,0 +1,34 @@ +# Generated by Django 6.0.5 on 2026-09-15 21:10 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0002_seed_polylan_43"), + ("noita", "0012_deathcounter_unique_death_per_seen_at"), + ] + + operations = [ + migrations.AddField( + model_name="deathcounter", + name="event", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="death_counters", + to="events.event", + ), + ), + migrations.AddField( + model_name="logfilesubmission", + name="event", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="logfile_submissions", + to="events.event", + ), + ), + ] diff --git a/polylan_submitter/noita/migrations/0014_backfill_event.py b/polylan_submitter/noita/migrations/0014_backfill_event.py new file mode 100644 index 0000000..bea46e9 --- /dev/null +++ b/polylan_submitter/noita/migrations/0014_backfill_event.py @@ -0,0 +1,27 @@ +from django.db import migrations + +EVENT_PATH = "polylan-43" + + +def backfill_event(apps, schema_editor): + Event = apps.get_model("events", "Event") + LogfileSubmission = apps.get_model("noita", "LogfileSubmission") + DeathCounter = apps.get_model("noita", "DeathCounter") + event = Event.objects.get(path=EVENT_PATH) + LogfileSubmission.objects.filter(event__isnull=True).update(event=event) + DeathCounter.objects.filter(event__isnull=True).update(event=event) + + +def noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("noita", "0013_deathcounter_event_logfilesubmission_event"), + ("events", "0002_seed_polylan_43"), + ] + + operations = [ + migrations.RunPython(backfill_event, noop), + ] diff --git a/polylan_submitter/noita/migrations/0015_event_required.py b/polylan_submitter/noita/migrations/0015_event_required.py new file mode 100644 index 0000000..f2d4230 --- /dev/null +++ b/polylan_submitter/noita/migrations/0015_event_required.py @@ -0,0 +1,29 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("noita", "0014_backfill_event"), + ] + + operations = [ + migrations.AlterField( + model_name="deathcounter", + name="event", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="death_counters", + to="events.event", + ), + ), + migrations.AlterField( + model_name="logfilesubmission", + name="event", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="logfile_submissions", + to="events.event", + ), + ), + ] diff --git a/polylan_submitter/noita/models.py b/polylan_submitter/noita/models.py index 5ec82f3..afe5eea 100644 --- a/polylan_submitter/noita/models.py +++ b/polylan_submitter/noita/models.py @@ -4,6 +4,9 @@ from django.utils import timezone import uuid +from events.models import Event +from events.scoping import EventScopedManager + User = get_user_model() @@ -21,6 +24,12 @@ class LogfileSubmission(models.Model): # Identification id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + event = models.ForeignKey( + Event, + on_delete=models.CASCADE, + related_name="logfile_submissions", + ) + content_type = models.CharField(max_length=100, help_text="MIME type of the file") file_size = models.PositiveIntegerField(help_text="File size in bytes") file = models.FileField( @@ -44,6 +53,9 @@ class LogfileSubmission(models.Model): processed = models.BooleanField(default=False) + objects = models.Manager() + for_event = EventScopedManager() + class Objectiv(models.Model): objectiv_id = models.CharField(max_length=64) @@ -71,10 +83,18 @@ class ObjectivPoint(models.Model): class DeathCounter(models.Model): + event = models.ForeignKey( + Event, + on_delete=models.CASCADE, + related_name="death_counters", + ) user = models.ForeignKey(User, on_delete=models.CASCADE) seed = models.CharField(max_length=32) seen_at = models.DateTimeField() + objects = models.Manager() + for_event = EventScopedManager() + class Meta: constraints = [ models.UniqueConstraint( diff --git a/polylan_submitter/noita/services/objectives.py b/polylan_submitter/noita/services/objectives.py index fe57d31..5a38668 100644 --- a/polylan_submitter/noita/services/objectives.py +++ b/polylan_submitter/noita/services/objectives.py @@ -31,7 +31,11 @@ def parse_objectives_and_store(logfile: LogfileSubmission) -> None: continue if idx == "DEATH": - deaths.append(DeathCounter(user=logfile.user, seed=seed, seen_at=ts)) + deaths.append( + DeathCounter( + event=logfile.event, user=logfile.user, seed=seed, seen_at=ts + ) + ) continue objectives.append( diff --git a/polylan_submitter/opus_magnum/api.py b/polylan_submitter/opus_magnum/api.py index 03078ec..0f8dd5d 100644 --- a/polylan_submitter/opus_magnum/api.py +++ b/polylan_submitter/opus_magnum/api.py @@ -32,50 +32,51 @@ from .schemas import ( router = Router() OPUS_MAGNUM_APP_ID = 558990 +PREFIX = "/events/{event_path}/opus-magnum" -@router.get("/puzzles", response=List[SteamCollectionItemOut]) + +@router.get(f"{PREFIX}/puzzles", response=List[SteamCollectionItemOut]) @decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) -def list_puzzles(request): +def list_puzzles(request, event_path: str): """Get list of available puzzles""" return SteamCollectionItem.objects.select_related("collection").filter( - collection__is_active=True + collection__is_active=True, collection__event=request.event ) -@router.get("/collection", response=SteamCollectionOut) +@router.get(f"{PREFIX}/collection", response=SteamCollectionOut) @decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) -def get_collection(request): +def get_collection(request, event_path: str): """Get the active collection details""" - collection = get_object_or_404(SteamCollection, is_active=True) - return collection + return get_object_or_404(SteamCollection.for_event, is_active=True) -@router.get("/submissions", response=List[SubmissionOut]) +@router.get(f"{PREFIX}/submissions", response=List[SubmissionOut]) @decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) @paginate -def list_submissions(request): +def list_submissions(request, event_path: str): """Get paginated list of submissions""" - return Submission.objects.prefetch_related( + return Submission.for_event.prefetch_related( "responses__files", "responses__puzzle" ).filter(user=request.user) -@router.get("/submissions/{submission_id}", response=SubmissionOut) +@router.get(f"{PREFIX}/submissions/{{submission_id}}", response=SubmissionOut) @decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) -def get_submission(request, submission_id: str): +def get_submission(request, event_path: str, submission_id: str): """Get detailed submission by ID""" return get_object_or_404( - Submission.objects.prefetch_related( + Submission.for_event.prefetch_related( "responses__files", "responses__puzzle" ).filter(user=request.user), id=submission_id, ) -@router.post("/submissions", response=SubmissionOut) +@router.post(f"{PREFIX}/submissions", response=SubmissionOut) @decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) def create_submission( - request, data: SubmissionIn, files: List[UploadedFile] = File(...) + request, event_path: str, data: SubmissionIn, files: List[UploadedFile] = File(...) ): """Create a new submission with multiple puzzle responses""" @@ -92,7 +93,9 @@ def create_submission( if data.responses: for puzzle in data.responses: if not get_object_or_404( - SteamCollectionItem, id=puzzle.puzzle_id + SteamCollectionItem, + id=puzzle.puzzle_id, + collection__event=request.event, ).collection.accepting_submissions: raise HttpError( 403, "This tournament is no longer accepting submissions" @@ -119,6 +122,7 @@ def create_submission( # Create the submission submission = Submission.objects.create( + event=request.event, user=request.user if request.user.is_authenticated else None, notes=data.notes, manual_validation_requested=data.manual_validation_requested @@ -129,7 +133,9 @@ def create_submission( for response_data in data.responses: # Get the puzzle puzzle = get_object_or_404( - SteamCollectionItem, id=response_data.puzzle_id + SteamCollectionItem, + id=response_data.puzzle_id, + collection__event=request.event, ) # Create the puzzle response @@ -192,12 +198,12 @@ def create_submission( response.mark_for_validation("Incomplete OCR data") # Reload with relations for response - submission = Submission.objects.prefetch_related( + submission = Submission.for_event.prefetch_related( "responses__files", "responses__puzzle" ).get(id=submission.id) # Invalidate results cache on successful submission - cache.delete("api:results:results") + cache.delete(f"api:{event_path}:results:results") return submission @@ -205,18 +211,22 @@ def create_submission( return 500, {"detail": f"Error creating submission: {str(e)}"} -@router.put("/responses/{response_id}/validate", response=PuzzleResponseOut) +@router.put(f"{PREFIX}/responses/{{response_id}}/validate", response=PuzzleResponseOut) @decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) -def validate_response(request, response_id: int, data: ValidationIn): +def validate_response(request, event_path: str, response_id: int, data: ValidationIn): """Manually validate a puzzle response""" if not request.user.is_authenticated or not request.user.is_staff: return 403, {"detail": "Admin access required"} - response = get_object_or_404(PuzzleResponse, id=response_id) + response = get_object_or_404( + PuzzleResponse, id=response_id, submission__event=request.event + ) if data.puzzle is not None: - puzzle = get_object_or_404(SteamCollectionItem, id=data.puzzle) + puzzle = get_object_or_404( + SteamCollectionItem, id=data.puzzle, collection__event=request.event + ) response.puzzle = puzzle # Update validated values @@ -236,20 +246,24 @@ def validate_response(request, response_id: int, data: ValidationIn): response.save() # Invalidate results cache when a response is validated - cache.delete("api:results:results") + cache.delete(f"api:{event_path}:results:results") return response -@router.put("/responses/{response_id}/validate/auto", response=PuzzleResponseOut) +@router.put( + f"{PREFIX}/responses/{{response_id}}/validate/auto", response=PuzzleResponseOut +) @decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) -def validate_auto(request, response_id: int): +def validate_auto(request, event_path: str, response_id: int): """Try to auto validate a puzzle response""" if not request.user.is_authenticated or not request.user.is_staff: return 403, {"detail": "Admin access required"} - response = get_object_or_404(PuzzleResponse, id=response_id) + response = get_object_or_404( + PuzzleResponse, id=response_id, submission__event=request.event + ) for file in response.files.all(): verify_and_validate_ocr_date_for_submission(file) @@ -257,9 +271,9 @@ def validate_auto(request, response_id: int): return response -@router.get("/responses/needs-validation", response=List[PuzzleResponseOut]) +@router.get(f"{PREFIX}/responses/needs-validation", response=List[PuzzleResponseOut]) @decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) -def list_responses_needing_validation(request): +def list_responses_needing_validation(request, event_path: str): """Get all responses that need manual validation""" if not request.user.is_authenticated or not request.user.is_staff: @@ -267,21 +281,23 @@ def list_responses_needing_validation(request): return ( PuzzleResponse.objects.filter(needs_manual_validation=True) - .filter(puzzle__collection__is_active=True) + .filter( + puzzle__collection__is_active=True, puzzle__collection__event=request.event + ) .select_related("puzzle", "submission") .prefetch_related("files") ) -@router.post("/submissions/{submission_id}/validate", response=SubmissionOut) +@router.post(f"{PREFIX}/submissions/{{submission_id}}/validate", response=SubmissionOut) @decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) -def validate_submission(request, submission_id: str): +def validate_submission(request, event_path: str, submission_id: str): """Mark entire submission as validated""" if not request.user.is_authenticated or not request.user.is_staff: return 403, {"detail": "Admin access required"} - submission = get_object_or_404(Submission, id=submission_id) + submission = get_object_or_404(Submission.for_event, id=submission_id) submission.is_validated = True submission.validated_by = request.user @@ -292,44 +308,46 @@ def validate_submission(request, submission_id: str): submission.responses.update(needs_manual_validation=False) # Reload with relations - submission = Submission.objects.prefetch_related( + submission = Submission.for_event.prefetch_related( "responses__files", "responses__puzzle" ).get(id=submission.id) # Invalidate results cache when submission is validated - cache.delete("api:results:results") + cache.delete(f"api:{event_path}:results:results") return submission -@router.delete("/submissions/{submission_id}") +@router.delete(f"{PREFIX}/submissions/{{submission_id}}") @decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) -def delete_submission(request, submission_id: str): +def delete_submission(request, event_path: str, submission_id: str): """Delete a submission (admin only)""" if not request.user.is_authenticated or not request.user.is_staff: return 403, {"detail": "Admin access required"} - submission = get_object_or_404(Submission, id=submission_id) + submission = get_object_or_404(Submission.for_event, id=submission_id) submission.delete() # Invalidate results cache when submission is deleted - cache.delete("api:results:results") + cache.delete(f"api:{event_path}:results:results") return {"detail": "Submission deleted successfully"} -@router.get("/stats") +@router.get(f"{PREFIX}/stats") @decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) -def get_stats(request): +def get_stats(request, event_path: str): """Get submission statistics""" - total_submissions = Submission.objects.count() - total_responses = PuzzleResponse.objects.count() - needs_validation = PuzzleResponse.objects.filter( - needs_manual_validation=True + total_submissions = Submission.for_event.count() + total_responses = PuzzleResponse.objects.filter( + submission__event=request.event ).count() - validated_submissions = Submission.objects.filter(is_validated=True).count() + needs_validation = PuzzleResponse.objects.filter( + needs_manual_validation=True, submission__event=request.event + ).count() + validated_submissions = Submission.for_event.filter(is_validated=True).count() return { "total_submissions": total_submissions, diff --git a/polylan_submitter/opus_magnum/migrations/0015_migrate_data_from_submissions.py b/polylan_submitter/opus_magnum/migrations/0015_migrate_data_from_submissions.py index c208621..360c3a5 100644 --- a/polylan_submitter/opus_magnum/migrations/0015_migrate_data_from_submissions.py +++ b/polylan_submitter/opus_magnum/migrations/0015_migrate_data_from_submissions.py @@ -3,45 +3,31 @@ from django.db import migrations +TABLE_PAIRS = [ + ("submissions_steamapikey", "opus_magnum_steamapikey"), + ("submissions_steamcollection", "opus_magnum_steamcollection"), + ("submissions_steamcollectionitem", "opus_magnum_steamcollectionitem"), + ("submissions_submission", "opus_magnum_submission"), + ("submissions_puzzleresponse", "opus_magnum_puzzleresponse"), + ("submissions_submissionfile", "opus_magnum_submissionfile"), +] + + def migrate_data_forward(apps, schema_editor): - """Copy data from submissions_* tables to opus_magnum_* tables""" - # Use raw SQL to copy data while preserving all fields + """Copy data from submissions_* tables to opus_magnum_* tables. + + The source tables only exist on databases that predate the submissions -> + opus_magnum rename; a database built fresh from this migration history + never has them, so skip pairs whose source table is absent instead of + failing. + """ + existing_tables = set(schema_editor.connection.introspection.table_names()) + with schema_editor.connection.cursor() as cursor: - # Copy SteamAPIKey - cursor.execute(""" - INSERT INTO opus_magnum_steamapikey - SELECT * FROM submissions_steamapikey - """) - - # Copy SteamCollection - cursor.execute(""" - INSERT INTO opus_magnum_steamcollection - SELECT * FROM submissions_steamcollection - """) - - # Copy SteamCollectionItem - cursor.execute(""" - INSERT INTO opus_magnum_steamcollectionitem - SELECT * FROM submissions_steamcollectionitem - """) - - # Copy Submission - cursor.execute(""" - INSERT INTO opus_magnum_submission - SELECT * FROM submissions_submission - """) - - # Copy PuzzleResponse - cursor.execute(""" - INSERT INTO opus_magnum_puzzleresponse - SELECT * FROM submissions_puzzleresponse - """) - - # Copy SubmissionFile - cursor.execute(""" - INSERT INTO opus_magnum_submissionfile - SELECT * FROM submissions_submissionfile - """) + for source, dest in TABLE_PAIRS: + if source not in existing_tables: + continue + cursor.execute(f"INSERT INTO {dest} SELECT * FROM {source}") def migrate_data_backward(apps, schema_editor): diff --git a/polylan_submitter/opus_magnum/migrations/0016_steamcollection_event_submission_event.py b/polylan_submitter/opus_magnum/migrations/0016_steamcollection_event_submission_event.py new file mode 100644 index 0000000..8fbfc51 --- /dev/null +++ b/polylan_submitter/opus_magnum/migrations/0016_steamcollection_event_submission_event.py @@ -0,0 +1,34 @@ +# Generated by Django 6.0.5 on 2026-09-15 21:09 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0002_seed_polylan_43"), + ("opus_magnum", "0015_migrate_data_from_submissions"), + ] + + operations = [ + migrations.AddField( + model_name="steamcollection", + name="event", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="steam_collections", + to="events.event", + ), + ), + migrations.AddField( + model_name="submission", + name="event", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="opus_magnum_submissions", + to="events.event", + ), + ), + ] diff --git a/polylan_submitter/opus_magnum/migrations/0017_backfill_event.py b/polylan_submitter/opus_magnum/migrations/0017_backfill_event.py new file mode 100644 index 0000000..d724846 --- /dev/null +++ b/polylan_submitter/opus_magnum/migrations/0017_backfill_event.py @@ -0,0 +1,27 @@ +from django.db import migrations + +EVENT_PATH = "polylan-43" + + +def backfill_event(apps, schema_editor): + Event = apps.get_model("events", "Event") + SteamCollection = apps.get_model("opus_magnum", "SteamCollection") + Submission = apps.get_model("opus_magnum", "Submission") + event = Event.objects.get(path=EVENT_PATH) + SteamCollection.objects.filter(event__isnull=True).update(event=event) + Submission.objects.filter(event__isnull=True).update(event=event) + + +def noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("opus_magnum", "0016_steamcollection_event_submission_event"), + ("events", "0002_seed_polylan_43"), + ] + + operations = [ + migrations.RunPython(backfill_event, noop), + ] diff --git a/polylan_submitter/opus_magnum/migrations/0018_event_required.py b/polylan_submitter/opus_magnum/migrations/0018_event_required.py new file mode 100644 index 0000000..1dd4849 --- /dev/null +++ b/polylan_submitter/opus_magnum/migrations/0018_event_required.py @@ -0,0 +1,29 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("opus_magnum", "0017_backfill_event"), + ] + + operations = [ + migrations.AlterField( + model_name="steamcollection", + name="event", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="steam_collections", + to="events.event", + ), + ), + migrations.AlterField( + model_name="submission", + name="event", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="opus_magnum_submissions", + to="events.event", + ), + ), + ] diff --git a/polylan_submitter/opus_magnum/models.py b/polylan_submitter/opus_magnum/models.py index e1033ff..af1c399 100644 --- a/polylan_submitter/opus_magnum/models.py +++ b/polylan_submitter/opus_magnum/models.py @@ -8,6 +8,9 @@ from django.db.models.expressions import Window from django.db.models.functions import Cast, Rank, RowNumber from django.db.models.query import F +from events.models import Event +from events.scoping import EventScopedManager + User = get_user_model() @@ -108,6 +111,12 @@ class SteamAPIKey(models.Model): class SteamCollection(models.Model): """Model representing a Steam Workshop collection""" + event = models.ForeignKey( + Event, + on_delete=models.CASCADE, + related_name="steam_collections", + ) + # Basic collection info steam_id = models.CharField( max_length=50, unique=True, help_text="Steam collection ID from URL" @@ -164,6 +173,9 @@ class SteamCollection(models.Model): blank=True, help_text="Last error encountered when fetching data" ) + objects = models.Manager() + for_event = EventScopedManager() + class Meta: ordering = ["-created_at"] verbose_name = "Steam Collection" @@ -257,6 +269,12 @@ class Submission(models.Model): # Identification id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + event = models.ForeignKey( + Event, + on_delete=models.CASCADE, + related_name="opus_magnum_submissions", + ) + # User information (optional for anonymous submissions) user = models.ForeignKey( User, @@ -299,6 +317,9 @@ class Submission(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + objects = models.Manager() + for_event = EventScopedManager() + class Meta: ordering = ["-created_at"] verbose_name = "Submission" diff --git a/polylan_submitter/polylan_submitter/api.py b/polylan_submitter/polylan_submitter/api.py index 097f58a..dfc13de 100644 --- a/polylan_submitter/polylan_submitter/api.py +++ b/polylan_submitter/polylan_submitter/api.py @@ -6,7 +6,7 @@ from opus_magnum.api import router as opus_magnum_router from opus_magnum.schemas import UserInfoOut from animations.api import router as results_router from noita.api import router as noita_router -from games.api import router as games_router +from games.api import router as games_router, event_router as games_event_router from market.api import router as market_router from submissions.api import router as submissions_router @@ -35,14 +35,24 @@ The Noita Submission API allows clients to upload the result of the log file of # api.auth = django_auth # Uncomment if you want global auth # Include the routers -api.add_router("/opus-magnum/", opus_magnum_router, tags=["opus-magnum"]) -api.add_router("/results/", results_router, tags=["results"]) -api.add_router("/noita/", noita_router, tags=["noita"]) api.add_router("/games/", games_router, tags=["games"]) -api.add_router("/submissions/", submissions_router, tags=["submissions"]) + +# Event-scoped routers: all tournament/competition activity belongs to one event. +# Each operation's own path already starts with "/events/{event_path}//..." +# (see the PREFIX constant in each app's api.py) - django-ninja resolves a path +# parameter's source (path vs query) from the operation's own registered path +# string at decoration time, not from whatever prefix add_router() attaches later, +# so {event_path} has to live there to show up as a real templated path parameter +# in the generated OpenAPI schema (which the frontend's generated API client needs). +# These routers are therefore mounted at the root, with no extra prefix here. +api.add_router("", opus_magnum_router, tags=["opus-magnum"]) +api.add_router("", results_router, tags=["results"]) +api.add_router("", noita_router, tags=["noita"]) +api.add_router("", games_event_router, tags=["games"]) +api.add_router("", submissions_router, tags=["submissions"]) if settings.MARKET_ENABLED: - api.add_router("/market/", market_router) + api.add_router("", market_router, tags=["market"]) # Health check endpoint @@ -54,12 +64,13 @@ def health_check(request): # Cache management endpoint @api.post("/cache/clear") -def clear_cache(request: HttpRequest): - """Clear all API caches (admin only)""" +def clear_cache(request: HttpRequest, event_path: str | None = None): + """Clear API caches (admin only). Pass event_path to clear just that event's keys.""" if not request.user.is_authenticated or not request.user.is_staff: return 403, {"detail": "Admin access required"} - keys = cache.keys("api:*") + pattern = f"api:{event_path}:*" if event_path else "api:*" + keys = cache.keys(pattern) cache.delete_many(keys) return {"detail": f"Cleared {len(keys)} cache entries"} diff --git a/polylan_submitter/polylan_submitter/settings.py b/polylan_submitter/polylan_submitter/settings.py index 14a62f6..89fdd24 100644 --- a/polylan_submitter/polylan_submitter/settings.py +++ b/polylan_submitter/polylan_submitter/settings.py @@ -46,6 +46,7 @@ INSTALLED_APPS = [ "django_rq", "django_tasks_rq", "accounts", + "events", "animations", "opus_magnum", "noita", @@ -62,6 +63,7 @@ MIDDLEWARE = [ "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", # Removed CASMiddleware to avoid conflicts with our custom backend + "events.middleware.EventContextMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] @@ -240,6 +242,7 @@ STORAGES = { } +SENTRY_ENVIRONMENT = "production" from polylan_submitter.settingsLocal import * # noqa @@ -250,4 +253,5 @@ sentry_sdk.init( # Set traces_sample_rate to 1.0 to capture 100% # of transactions for tracing. traces_sample_rate=1.0, + environment=SENTRY_ENVIRONMENT, ) diff --git a/polylan_submitter/polylan_submitter/urls.py b/polylan_submitter/polylan_submitter/urls.py index 5dcff3e..0b235bc 100644 --- a/polylan_submitter/polylan_submitter/urls.py +++ b/polylan_submitter/polylan_submitter/urls.py @@ -16,13 +16,14 @@ Including another URLconf """ from django.contrib import admin -from django.http import HttpRequest -from django.shortcuts import render +from django.http import Http404, HttpRequest +from django.shortcuts import get_object_or_404, redirect, render from django.urls import path from django.contrib.auth.decorators import login_required from django.conf import settings from django.conf.urls.static import static 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 @@ -34,12 +35,21 @@ OPUS_MAGNUM_APP_ID = 558990 @login_required def home(request: HttpRequest): - from django.conf import settings + event = Event.objects.order_by("-created_at").first() + if event is None: + raise Http404("No event configured") + return redirect("event.home", event_path=event.path) + + +@login_required +def event_home(request: HttpRequest, event_path: str): + event = get_object_or_404(Event, path=event_path) return render( request, "home.html", { + "event": event, "market_enabled": settings.MARKET_ENABLED, }, ) @@ -47,22 +57,28 @@ def home(request: HttpRequest): @login_required @require_game_enabled(OPUS_MAGNUM_APP_ID) -def opus_magnum_home(request: HttpRequest): +def opus_magnum_home(request: HttpRequest, event_path: str): from opus_magnum.models import SteamCollection + event = get_object_or_404(Event, path=event_path) + return render( request, "opus-magnum.html", { - "collection": SteamCollection.objects.filter(is_active=True).last(), + "event": event, + "collection": SteamCollection.objects.filter( + is_active=True, event=event + ).last(), }, ) @login_required @require_game_enabled(NOITA_APP_ID) -def noita_home(request: HttpRequest): - return render(request, "noita.html", {}) +def noita_home(request: HttpRequest, event_path: str): + event = get_object_or_404(Event, path=event_path) + return render(request, "noita.html", {"event": event}) urlpatterns = [ @@ -70,10 +86,15 @@ urlpatterns = [ path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"), path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"), path("api/", api.urls), - path("market", market_home, name="market.home"), - path("opus-magnum", opus_magnum_home, name="opus-magnum.home"), - path("noita", noita_home, name="noita.home"), - path("submissions/", submission_home, name="submissions.home"), + path("/market", market_home, name="market.home"), + path("/opus-magnum", opus_magnum_home, name="opus-magnum.home"), + path("/noita", noita_home, name="noita.home"), + path( + "/submissions/", + submission_home, + name="submissions.home", + ), + path("/", event_home, name="event.home"), path("", home, name="home"), ] diff --git a/polylan_submitter/src/Home.vue b/polylan_submitter/src/Home.vue index a77e200..b5af2e2 100644 --- a/polylan_submitter/src/Home.vue +++ b/polylan_submitter/src/Home.vue @@ -1,9 +1,10 @@ diff --git a/polylan_submitter/src/OpusMagnum.vue b/polylan_submitter/src/OpusMagnum.vue index 15936fd..2442343 100644 --- a/polylan_submitter/src/OpusMagnum.vue +++ b/polylan_submitter/src/OpusMagnum.vue @@ -8,19 +8,24 @@ import Winners from "@/components/Winners.vue"; import PuzzleResults from "@/components/PuzzleResults.vue"; import TopUsersLeaderboard from "@/components/TopUsersLeaderboard.vue"; import { polylanSubmitterApiGetUserInfo, opusMagnumApiGetCollection } from "@/api"; -import { errorHelpers } from "@/services/apiService"; +import { apiService, errorHelpers } from "@/services/apiService"; import { usePuzzlesStore } from "@/stores/puzzles"; import { useSubmissionsStore } from "@/stores/submissions"; +import { provideEventPath } from "@/composables/useEventPath"; import type { PuzzleResponse, UserInfo, SteamCollection } from "@/types"; import { useCountdown } from "@vueuse/core"; import { storeToRefs } from "pinia"; const props = defineProps<{ + eventPath: string; collectionTitle: string; collectionUrl: string; collectionDescription: string; }>(); +provideEventPath(props.eventPath); +apiService.setEventPath(props.eventPath); + const puzzlesStore = usePuzzlesStore(); const submissionsStore = useSubmissionsStore(); @@ -77,7 +82,9 @@ async function initialize() { // Load collection data console.log("Loading collection..."); - const collectionResponse = await opusMagnumApiGetCollection(); + const collectionResponse = await opusMagnumApiGetCollection({ + path: { event_path: props.eventPath }, + }); if (collectionResponse.data) { collection.value = collectionResponse.data as SteamCollection; console.log("Collection loaded:", collectionResponse.data); @@ -87,7 +94,7 @@ async function initialize() { // Load puzzles from API using store console.log("Loading puzzles..."); - await puzzlesStore.loadPuzzles(); + await puzzlesStore.loadPuzzles(props.eventPath); console.log("Puzzles loaded:", puzzlesStore.puzzles.length); // Load existing submissions using store @@ -129,7 +136,7 @@ const reloadPage = () => { }; const goHome = () => { - window.location.href = "/"; + window.location.href = `/${props.eventPath}/`; }; diff --git a/polylan_submitter/src/Submissions.vue b/polylan_submitter/src/Submissions.vue index 1b215ec..a2b0da2 100644 --- a/polylan_submitter/src/Submissions.vue +++ b/polylan_submitter/src/Submissions.vue @@ -4,8 +4,10 @@ import { storeToRefs } from "pinia"; import { useUserStore } from "@/stores/user"; import { submissionsApiCreateSubmission, submissionsApiListUserSubmissions, submissionsApiCreateImageCrop, submissionsApiDeleteImageCrop, submissionsApiProcessFileOcr, submissionsApiProcessCropOcr } from "@/api"; import { useSubmissionsWebSocket } from "@/composables/useSubmissionsWebSocket"; +import { provideEventPath } from "@/composables/useEventPath"; interface Props { + eventPath: string; gamePath?: string; gameName?: string; gameImage?: string; @@ -19,6 +21,8 @@ const props = withDefaults(defineProps(), { gameSteamId: "", }); +provideEventPath(props.eventPath); + const userStore = useUserStore(); const { userInfo } = storeToRefs(userStore); @@ -238,7 +242,7 @@ const submitCrop = async () => { isCreatingCrop.value = true; try { const { data: newCrop } = await submissionsApiCreateImageCrop({ - path: { submission_file_id: selectedSubmissionFile.value.id }, + path: { event_path: props.eventPath, submission_file_id: selectedSubmissionFile.value.id }, body: { x1: Math.round(currentCrop.value.x1), y1: Math.round(currentCrop.value.y1), @@ -270,7 +274,7 @@ const deleteCrop = async (cropId: string) => { try { await submissionsApiDeleteImageCrop({ - path: { crop_id: cropId }, + path: { event_path: props.eventPath, crop_id: cropId }, }); selectedSubmissionFile.value.crops = selectedSubmissionFile.value.crops.filter( (c: any) => c.id !== cropId @@ -293,7 +297,7 @@ const processFileOcr = async () => { try { await submissionsApiProcessFileOcr({ - path: { file_id: selectedSubmissionFile.value.id }, + path: { event_path: props.eventPath, file_id: selectedSubmissionFile.value.id }, }); selectedSubmissionFile.value.ocr_status = "processing"; } catch (error) { @@ -305,7 +309,7 @@ const processFileOcr = async () => { const processCropOcr = async (cropId: string) => { try { await submissionsApiProcessCropOcr({ - path: { crop_id: cropId }, + path: { event_path: props.eventPath, crop_id: cropId }, }); // Update the crop status in the list if (selectedSubmissionFile.value?.crops) { @@ -398,6 +402,7 @@ const submitSubmission = async () => { isUploading.value = true; try { await submissionsApiCreateSubmission({ + path: { event_path: props.eventPath }, query: { game_path: props.gamePath }, body: { files: uploadedFiles.value, @@ -418,7 +423,7 @@ const submitSubmission = async () => { }; const goHome = () => { - window.location.href = "/"; + window.location.href = `/${props.eventPath}/`; }; const formatFileSize = (bytes: number) => { @@ -454,6 +459,7 @@ const loadUserSubmissions = async () => { isLoadingSubmissions.value = true; try { const { data } = await submissionsApiListUserSubmissions({ + path: { event_path: props.eventPath }, query: { game_path: props.gamePath }, }); if (data && Array.isArray(data)) { diff --git a/polylan_submitter/src/api/index.ts b/polylan_submitter/src/api/index.ts index 55e2115..977b91c 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, 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, 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, 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'; diff --git a/polylan_submitter/src/api/sdk.gen.ts b/polylan_submitter/src/api/sdk.gen.ts index 9b5c631..2a1c4b4 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, 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, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen'; export type Options = Options2 & { /** @@ -28,7 +28,7 @@ export const polylanSubmitterApiHealthCheck = (options?: Options) => (options?.client ?? client).post({ url: '/api/cache/clear', ...options }); @@ -39,26 +39,31 @@ export const polylanSubmitterApiClearCache = (options?: Options) => (options?.client ?? client).get({ url: '/api/user', ...options }); +/** + * List Games + */ +export const gamesApiListGames = (options?: Options) => (options?.client ?? client).get({ url: '/api/games/', ...options }); + /** * List Puzzles * * Get list of available puzzles */ -export const opusMagnumApiListPuzzles = (options?: Options) => (options?.client ?? client).get({ url: '/api/opus-magnum/puzzles', ...options }); +export const opusMagnumApiListPuzzles = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/opus-magnum/puzzles', ...options }); /** * Get Collection * * Get the active collection details */ -export const opusMagnumApiGetCollection = (options?: Options) => (options?.client ?? client).get({ url: '/api/opus-magnum/collection', ...options }); +export const opusMagnumApiGetCollection = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/opus-magnum/collection', ...options }); /** * List Submissions * * Get paginated list of submissions */ -export const opusMagnumApiListSubmissions = (options?: Options) => (options?.client ?? client).get({ url: '/api/opus-magnum/submissions', ...options }); +export const opusMagnumApiListSubmissions = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/opus-magnum/submissions', ...options }); /** * Create Submission @@ -67,7 +72,7 @@ export const opusMagnumApiListSubmissions = (options: Options) => (options.client ?? client).post({ ...formDataBodySerializer, - url: '/api/opus-magnum/submissions', + url: '/api/events/{event_path}/opus-magnum/submissions', ...options, headers: { 'Content-Type': null, @@ -80,14 +85,14 @@ export const opusMagnumApiCreateSubmission = (options: Options) => (options.client ?? client).delete({ url: '/api/opus-magnum/submissions/{submission_id}', ...options }); +export const opusMagnumApiDeleteSubmission = (options: Options) => (options.client ?? client).delete({ url: '/api/events/{event_path}/opus-magnum/submissions/{submission_id}', ...options }); /** * Get Submission * * Get detailed submission by ID */ -export const opusMagnumApiGetSubmission = (options: Options) => (options.client ?? client).get({ url: '/api/opus-magnum/submissions/{submission_id}', ...options }); +export const opusMagnumApiGetSubmission = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/opus-magnum/submissions/{submission_id}', ...options }); /** * Validate Response @@ -95,7 +100,7 @@ export const opusMagnumApiGetSubmission = * Manually validate a puzzle response */ export const opusMagnumApiValidateResponse = (options: Options) => (options.client ?? client).put({ - url: '/api/opus-magnum/responses/{response_id}/validate', + url: '/api/events/{event_path}/opus-magnum/responses/{response_id}/validate', ...options, headers: { 'Content-Type': 'application/json', @@ -108,52 +113,52 @@ export const opusMagnumApiValidateResponse = (options: Options) => (options.client ?? client).put({ url: '/api/opus-magnum/responses/{response_id}/validate/auto', ...options }); +export const opusMagnumApiValidateAuto = (options: Options) => (options.client ?? client).put({ url: '/api/events/{event_path}/opus-magnum/responses/{response_id}/validate/auto', ...options }); /** * List Responses Needing Validation * * Get all responses that need manual validation */ -export const opusMagnumApiListResponsesNeedingValidation = (options?: Options) => (options?.client ?? client).get({ url: '/api/opus-magnum/responses/needs-validation', ...options }); +export const opusMagnumApiListResponsesNeedingValidation = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/opus-magnum/responses/needs-validation', ...options }); /** * Validate Submission * * Mark entire submission as validated */ -export const opusMagnumApiValidateSubmission = (options: Options) => (options.client ?? client).post({ url: '/api/opus-magnum/submissions/{submission_id}/validate', ...options }); +export const opusMagnumApiValidateSubmission = (options: Options) => (options.client ?? client).post({ url: '/api/events/{event_path}/opus-magnum/submissions/{submission_id}/validate', ...options }); /** * Get Stats * * Get submission statistics */ -export const opusMagnumApiGetStats = (options?: Options) => (options?.client ?? client).get({ url: '/api/opus-magnum/stats', ...options }); +export const opusMagnumApiGetStats = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/opus-magnum/stats', ...options }); /** * Results */ -export const animationsApiResults = (options?: Options) => (options?.client ?? client).get({ url: '/api/results/results', ...options }); +export const animationsApiResults = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/results/results', ...options }); /** * Top Submissions * * Get tournament top submissions for each puzzle. Only available when tournament is closed. */ -export const animationsApiTopSubmissions = (options?: Options) => (options?.client ?? client).get({ url: '/api/results/top-submissions', ...options }); +export const animationsApiTopSubmissions = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/results/top-submissions', ...options }); /** * Puzzle Results * * Get tournament results organized by puzzle with coefficients. Only available when tournament is closed. */ -export const animationsApiPuzzleResults = (options?: Options) => (options?.client ?? client).get({ url: '/api/results/puzzle-results', ...options }); +export const animationsApiPuzzleResults = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/results/puzzle-results', ...options }); /** * Get Results */ -export const noitaApiGetResults = (options?: Options) => (options?.client ?? client).get({ url: '/api/noita/results', ...options }); +export const noitaApiGetResults = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/noita/results', ...options }); /** * Get Leaderboard @@ -162,7 +167,7 @@ export const noitaApiGetResults = (options * * Uses Window functions to rank users by their total score in descending order. */ -export const noitaApiGetLeaderboard = (options?: Options) => (options?.client ?? client).get({ url: '/api/noita/leaderboard', ...options }); +export const noitaApiGetLeaderboard = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/noita/leaderboard', ...options }); /** * Submit Log File @@ -178,7 +183,7 @@ export const noitaApiGetLeaderboard = (opt */ export const noitaApiSubmitLogFile = (options: Options) => (options.client ?? client).post({ ...formDataBodySerializer, - url: '/api/noita/submit', + url: '/api/events/{event_path}/noita/submit', ...options, headers: { 'Content-Type': null, @@ -187,16 +192,16 @@ export const noitaApiSubmitLogFile = (opti }); /** - * List Games + * List Event Games */ -export const gamesApiListGames = (options?: Options) => (options?.client ?? client).get({ url: '/api/games/', ...options }); +export const gamesApiListEventGames = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/games', ...options }); /** * Create Submission */ export const submissionsApiCreateSubmission = (options: Options) => (options.client ?? client).post({ ...formDataBodySerializer, - url: '/api/submissions/submission/create', + url: '/api/events/{event_path}/submissions/submission/create', ...options, headers: { 'Content-Type': null, @@ -207,23 +212,23 @@ export const submissionsApiCreateSubmission = (options: Options) => (options.client ?? client).get({ url: '/api/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 */ -export const submissionsApiListSubmissionsByGame = (options: Options) => (options.client ?? client).get({ url: '/api/submissions/submission/by/game', ...options }); +export const submissionsApiListSubmissionsByGame = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/submissions/submission/by/game', ...options }); /** * Get Submission */ -export const submissionsApiGetSubmission = (options: Options) => (options.client ?? client).get({ url: '/api/submissions/submission/{submission_id}', ...options }); +export const submissionsApiGetSubmission = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/submissions/submission/{submission_id}', ...options }); /** * Create Image Crop */ export const submissionsApiCreateImageCrop = (options: Options) => (options.client ?? client).post({ - url: '/api/submissions/crops/s/{submission_file_id}', + url: '/api/events/{event_path}/submissions/crops/s/{submission_file_id}', ...options, headers: { 'Content-Type': 'application/json', @@ -234,38 +239,38 @@ export const submissionsApiCreateImageCrop = (options: Options) => (options.client ?? client).delete({ url: '/api/submissions/crops/{crop_id}', ...options }); +export const submissionsApiDeleteImageCrop = (options: Options) => (options.client ?? client).delete({ url: '/api/events/{event_path}/submissions/crops/{crop_id}', ...options }); /** * Process File Ocr */ -export const submissionsApiProcessFileOcr = (options: Options) => (options.client ?? client).post({ url: '/api/submissions/files/{file_id}/process-ocr', ...options }); +export const submissionsApiProcessFileOcr = (options: Options) => (options.client ?? client).post({ url: '/api/events/{event_path}/submissions/files/{file_id}/process-ocr', ...options }); /** * Process Crop Ocr */ -export const submissionsApiProcessCropOcr = (options: Options) => (options.client ?? client).post({ url: '/api/submissions/crops/{crop_id}/process-ocr', ...options }); +export const submissionsApiProcessCropOcr = (options: Options) => (options.client ?? client).post({ url: '/api/events/{event_path}/submissions/crops/{crop_id}/process-ocr', ...options }); /** * List Markets * * List all markets (excludes draft markets). */ -export const marketApiListMarkets = (options?: Options) => (options?.client ?? client).get({ url: '/api/market/', ...options }); +export const marketApiListMarkets = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/market/', ...options }); /** * List User Bets * * List all bets placed by the current user. */ -export const marketApiListUserBets = (options?: Options) => (options?.client ?? client).get({ url: '/api/market/user/bets', ...options }); +export const marketApiListUserBets = (options: Options) => (options.client ?? client).get({ url: '/api/events/{event_path}/market/user/bets', ...options }); /** * Close Market * * Close a market. Admin only. */ -export const marketApiCloseMarket = (options: Options) => (options.client ?? client).post({ url: '/api/market/{market_uuid}/actions/close', ...options }); +export const marketApiCloseMarket = (options: Options) => (options.client ?? client).post({ url: '/api/events/{event_path}/market/{market_uuid}/actions/close', ...options }); /** * Resolve Market @@ -273,7 +278,7 @@ export const marketApiCloseMarket = (optio * Resolve a market with a winning option. Admin only. */ export const marketApiResolveMarket = (options: Options) => (options.client ?? client).post({ - url: '/api/market/{market_uuid}/actions/resolve', + url: '/api/events/{event_path}/market/{market_uuid}/actions/resolve', ...options, headers: { 'Content-Type': 'application/json', @@ -287,7 +292,7 @@ export const marketApiResolveMarket = (opt * Place a bet on a market option. */ export const marketApiCreateBet = (options: Options) => (options.client ?? client).post({ - url: '/api/market/{market_uuid}/bets', + url: '/api/events/{event_path}/market/{market_uuid}/bets', ...options, headers: { 'Content-Type': 'application/json', diff --git a/polylan_submitter/src/api/types.gen.ts b/polylan_submitter/src/api/types.gen.ts index d9ac113..1c3b42f 100644 --- a/polylan_submitter/src/api/types.gen.ts +++ b/polylan_submitter/src/api/types.gen.ts @@ -52,6 +52,32 @@ export type UserInfoOut = { cas_groups?: Array | null; }; +/** + * GameOut + */ +export type GameOut = { + /** + * Id + */ + id: number; + /** + * Steam App Id + */ + steam_app_id?: number | null; + /** + * Name + */ + name: string; + /** + * Path + */ + path: string; + /** + * Image + */ + image?: string | null; +}; + /** * PuzzlePointsFactorOut * @@ -890,32 +916,6 @@ export type NoitaSubmissionOut = { processed: boolean; }; -/** - * GameOut - */ -export type GameOut = { - /** - * Id - */ - id: number; - /** - * Steam App Id - */ - steam_app_id?: number | null; - /** - * Name - */ - name: string; - /** - * Path - */ - path: string; - /** - * Image - */ - image?: string | null; -}; - /** * ImageCropOut */ @@ -1102,7 +1102,12 @@ export type PolylanSubmitterApiHealthCheckResponses = { export type PolylanSubmitterApiClearCacheData = { body?: never; path?: never; - query?: never; + query?: { + /** + * Event Path + */ + event_path?: string | null; + }; url: '/api/cache/clear'; }; @@ -1129,11 +1134,34 @@ export type PolylanSubmitterApiGetUserInfoResponses = { export type PolylanSubmitterApiGetUserInfoResponse = PolylanSubmitterApiGetUserInfoResponses[keyof PolylanSubmitterApiGetUserInfoResponses]; -export type OpusMagnumApiListPuzzlesData = { +export type GamesApiListGamesData = { body?: never; path?: never; query?: never; - url: '/api/opus-magnum/puzzles'; + url: '/api/games/'; +}; + +export type GamesApiListGamesResponses = { + /** + * Response + * + * OK + */ + 200: Array; +}; + +export type GamesApiListGamesResponse = GamesApiListGamesResponses[keyof GamesApiListGamesResponses]; + +export type OpusMagnumApiListPuzzlesData = { + body?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; + query?: never; + url: '/api/events/{event_path}/opus-magnum/puzzles'; }; export type OpusMagnumApiListPuzzlesResponses = { @@ -1149,9 +1177,14 @@ export type OpusMagnumApiListPuzzlesResponse = OpusMagnumApiListPuzzlesResponses export type OpusMagnumApiGetCollectionData = { body?: never; - path?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; query?: never; - url: '/api/opus-magnum/collection'; + url: '/api/events/{event_path}/opus-magnum/collection'; }; export type OpusMagnumApiGetCollectionResponses = { @@ -1165,7 +1198,12 @@ export type OpusMagnumApiGetCollectionResponse = OpusMagnumApiGetCollectionRespo export type OpusMagnumApiListSubmissionsData = { body?: never; - path?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; query?: { /** * Limit @@ -1176,7 +1214,7 @@ export type OpusMagnumApiListSubmissionsData = { */ offset?: number; }; - url: '/api/opus-magnum/submissions'; + url: '/api/events/{event_path}/opus-magnum/submissions'; }; export type OpusMagnumApiListSubmissionsResponses = { @@ -1199,9 +1237,14 @@ export type OpusMagnumApiCreateSubmissionData = { files: Array; data: SubmissionIn; }; - path?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; query?: never; - url: '/api/opus-magnum/submissions'; + url: '/api/events/{event_path}/opus-magnum/submissions'; }; export type OpusMagnumApiCreateSubmissionResponses = { @@ -1216,13 +1259,17 @@ export type OpusMagnumApiCreateSubmissionResponse = OpusMagnumApiCreateSubmissio export type OpusMagnumApiDeleteSubmissionData = { body?: never; path: { + /** + * Event Path + */ + event_path: string; /** * Submission Id */ submission_id: string; }; query?: never; - url: '/api/opus-magnum/submissions/{submission_id}'; + url: '/api/events/{event_path}/opus-magnum/submissions/{submission_id}'; }; export type OpusMagnumApiDeleteSubmissionResponses = { @@ -1235,13 +1282,17 @@ export type OpusMagnumApiDeleteSubmissionResponses = { export type OpusMagnumApiGetSubmissionData = { body?: never; path: { + /** + * Event Path + */ + event_path: string; /** * Submission Id */ submission_id: string; }; query?: never; - url: '/api/opus-magnum/submissions/{submission_id}'; + url: '/api/events/{event_path}/opus-magnum/submissions/{submission_id}'; }; export type OpusMagnumApiGetSubmissionResponses = { @@ -1256,13 +1307,17 @@ export type OpusMagnumApiGetSubmissionResponse = OpusMagnumApiGetSubmissionRespo export type OpusMagnumApiValidateResponseData = { body: ValidationIn; path: { + /** + * Event Path + */ + event_path: string; /** * Response Id */ response_id: number; }; query?: never; - url: '/api/opus-magnum/responses/{response_id}/validate'; + url: '/api/events/{event_path}/opus-magnum/responses/{response_id}/validate'; }; export type OpusMagnumApiValidateResponseResponses = { @@ -1277,13 +1332,17 @@ export type OpusMagnumApiValidateResponseResponse = OpusMagnumApiValidateRespons export type OpusMagnumApiValidateAutoData = { body?: never; path: { + /** + * Event Path + */ + event_path: string; /** * Response Id */ response_id: number; }; query?: never; - url: '/api/opus-magnum/responses/{response_id}/validate/auto'; + url: '/api/events/{event_path}/opus-magnum/responses/{response_id}/validate/auto'; }; export type OpusMagnumApiValidateAutoResponses = { @@ -1297,9 +1356,14 @@ export type OpusMagnumApiValidateAutoResponse = OpusMagnumApiValidateAutoRespons export type OpusMagnumApiListResponsesNeedingValidationData = { body?: never; - path?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; query?: never; - url: '/api/opus-magnum/responses/needs-validation'; + url: '/api/events/{event_path}/opus-magnum/responses/needs-validation'; }; export type OpusMagnumApiListResponsesNeedingValidationResponses = { @@ -1316,13 +1380,17 @@ export type OpusMagnumApiListResponsesNeedingValidationResponse = OpusMagnumApiL export type OpusMagnumApiValidateSubmissionData = { body?: never; path: { + /** + * Event Path + */ + event_path: string; /** * Submission Id */ submission_id: string; }; query?: never; - url: '/api/opus-magnum/submissions/{submission_id}/validate'; + url: '/api/events/{event_path}/opus-magnum/submissions/{submission_id}/validate'; }; export type OpusMagnumApiValidateSubmissionResponses = { @@ -1336,9 +1404,14 @@ export type OpusMagnumApiValidateSubmissionResponse = OpusMagnumApiValidateSubmi export type OpusMagnumApiGetStatsData = { body?: never; - path?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; query?: never; - url: '/api/opus-magnum/stats'; + url: '/api/events/{event_path}/opus-magnum/stats'; }; export type OpusMagnumApiGetStatsResponses = { @@ -1350,9 +1423,14 @@ export type OpusMagnumApiGetStatsResponses = { export type AnimationsApiResultsData = { body?: never; - path?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; query?: never; - url: '/api/results/results'; + url: '/api/events/{event_path}/results/results'; }; export type AnimationsApiResultsResponses = { @@ -1366,14 +1444,19 @@ export type AnimationsApiResultsResponse = AnimationsApiResultsResponses[keyof A export type AnimationsApiTopSubmissionsData = { body?: never; - path?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; query?: { /** * Limit */ limit?: number; }; - url: '/api/results/top-submissions'; + url: '/api/events/{event_path}/results/top-submissions'; }; export type AnimationsApiTopSubmissionsResponses = { @@ -1387,14 +1470,19 @@ export type AnimationsApiTopSubmissionsResponse = AnimationsApiTopSubmissionsRes export type AnimationsApiPuzzleResultsData = { body?: never; - path?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; query?: { /** * Limit */ limit?: number; }; - url: '/api/results/puzzle-results'; + url: '/api/events/{event_path}/results/puzzle-results'; }; export type AnimationsApiPuzzleResultsResponses = { @@ -1408,9 +1496,14 @@ export type AnimationsApiPuzzleResultsResponse = AnimationsApiPuzzleResultsRespo export type NoitaApiGetResultsData = { body?: never; - path?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; query?: never; - url: '/api/noita/results'; + url: '/api/events/{event_path}/noita/results'; }; export type NoitaApiGetResultsResponses = { @@ -1424,9 +1517,14 @@ export type NoitaApiGetResultsResponse = NoitaApiGetResultsResponses[keyof Noita export type NoitaApiGetLeaderboardData = { body?: never; - path?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; query?: never; - url: '/api/noita/leaderboard'; + url: '/api/events/{event_path}/noita/leaderboard'; }; export type NoitaApiGetLeaderboardResponses = { @@ -1448,9 +1546,14 @@ export type NoitaApiSubmitLogFileData = { */ file: Blob | File; }; - path?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; query?: never; - url: '/api/noita/submit'; + url: '/api/events/{event_path}/noita/submit'; }; export type NoitaApiSubmitLogFileErrors = { @@ -1475,14 +1578,19 @@ export type NoitaApiSubmitLogFileResponses = { export type NoitaApiSubmitLogFileResponse = NoitaApiSubmitLogFileResponses[keyof NoitaApiSubmitLogFileResponses]; -export type GamesApiListGamesData = { +export type GamesApiListEventGamesData = { body?: never; - path?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; query?: never; - url: '/api/games/'; + url: '/api/events/{event_path}/games'; }; -export type GamesApiListGamesResponses = { +export type GamesApiListEventGamesResponses = { /** * Response * @@ -1491,7 +1599,7 @@ export type GamesApiListGamesResponses = { 200: Array; }; -export type GamesApiListGamesResponse = GamesApiListGamesResponses[keyof GamesApiListGamesResponses]; +export type GamesApiListEventGamesResponse = GamesApiListEventGamesResponses[keyof GamesApiListEventGamesResponses]; export type SubmissionsApiCreateSubmissionData = { /** @@ -1507,14 +1615,19 @@ export type SubmissionsApiCreateSubmissionData = { */ notes?: string | null; }; - path?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; query: { /** * Game Path */ game_path: string; }; - url: '/api/submissions/submission/create'; + url: '/api/events/{event_path}/submissions/submission/create'; }; export type SubmissionsApiCreateSubmissionResponses = { @@ -1528,14 +1641,19 @@ export type SubmissionsApiCreateSubmissionResponse = SubmissionsApiCreateSubmiss export type SubmissionsApiListUserSubmissionsData = { body?: never; - path?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; query: { /** * Game Path */ game_path: string; }; - url: '/api/submissions/submission/by/user'; + url: '/api/events/{event_path}/submissions/submission/by/user'; }; export type SubmissionsApiListUserSubmissionsResponses = { @@ -1551,14 +1669,19 @@ export type SubmissionsApiListUserSubmissionsResponse = SubmissionsApiListUserSu export type SubmissionsApiListSubmissionsByGameData = { body?: never; - path?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; query: { /** * Game Path */ game_path: string; }; - url: '/api/submissions/submission/by/game'; + url: '/api/events/{event_path}/submissions/submission/by/game'; }; export type SubmissionsApiListSubmissionsByGameResponses = { @@ -1575,13 +1698,17 @@ export type SubmissionsApiListSubmissionsByGameResponse = SubmissionsApiListSubm export type SubmissionsApiGetSubmissionData = { body?: never; path: { + /** + * Event Path + */ + event_path: string; /** * Submission Id */ submission_id: string; }; query?: never; - url: '/api/submissions/submission/{submission_id}'; + url: '/api/events/{event_path}/submissions/submission/{submission_id}'; }; export type SubmissionsApiGetSubmissionResponses = { @@ -1596,13 +1723,17 @@ export type SubmissionsApiGetSubmissionResponse = SubmissionsApiGetSubmissionRes export type SubmissionsApiCreateImageCropData = { body: ImageCropIn; path: { + /** + * Event Path + */ + event_path: string; /** * Submission File Id */ submission_file_id: string; }; query?: never; - url: '/api/submissions/crops/s/{submission_file_id}'; + url: '/api/events/{event_path}/submissions/crops/s/{submission_file_id}'; }; export type SubmissionsApiCreateImageCropResponses = { @@ -1617,13 +1748,17 @@ export type SubmissionsApiCreateImageCropResponse = SubmissionsApiCreateImageCro export type SubmissionsApiDeleteImageCropData = { body?: never; path: { + /** + * Event Path + */ + event_path: string; /** * Crop Id */ crop_id: string; }; query?: never; - url: '/api/submissions/crops/{crop_id}'; + url: '/api/events/{event_path}/submissions/crops/{crop_id}'; }; export type SubmissionsApiDeleteImageCropResponses = { @@ -1642,13 +1777,17 @@ export type SubmissionsApiDeleteImageCropResponse = SubmissionsApiDeleteImageCro export type SubmissionsApiProcessFileOcrData = { body?: never; path: { + /** + * Event Path + */ + event_path: string; /** * File Id */ file_id: string; }; query?: never; - url: '/api/submissions/files/{file_id}/process-ocr'; + url: '/api/events/{event_path}/submissions/files/{file_id}/process-ocr'; }; export type SubmissionsApiProcessFileOcrResponses = { @@ -1667,13 +1806,17 @@ export type SubmissionsApiProcessFileOcrResponse = SubmissionsApiProcessFileOcrR export type SubmissionsApiProcessCropOcrData = { body?: never; path: { + /** + * Event Path + */ + event_path: string; /** * Crop Id */ crop_id: string; }; query?: never; - url: '/api/submissions/crops/{crop_id}/process-ocr'; + url: '/api/events/{event_path}/submissions/crops/{crop_id}/process-ocr'; }; export type SubmissionsApiProcessCropOcrResponses = { @@ -1691,9 +1834,14 @@ export type SubmissionsApiProcessCropOcrResponse = SubmissionsApiProcessCropOcrR export type MarketApiListMarketsData = { body?: never; - path?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; query?: never; - url: '/api/market/'; + url: '/api/events/{event_path}/market/'; }; export type MarketApiListMarketsResponses = { @@ -1709,9 +1857,14 @@ export type MarketApiListMarketsResponse = MarketApiListMarketsResponses[keyof M export type MarketApiListUserBetsData = { body?: never; - path?: never; + path: { + /** + * Event Path + */ + event_path: string; + }; query?: never; - url: '/api/market/user/bets'; + url: '/api/events/{event_path}/market/user/bets'; }; export type MarketApiListUserBetsResponses = { @@ -1728,13 +1881,17 @@ export type MarketApiListUserBetsResponse = MarketApiListUserBetsResponses[keyof export type MarketApiCloseMarketData = { body?: never; path: { + /** + * Event Path + */ + event_path: string; /** * Market Uuid */ market_uuid: string; }; query?: never; - url: '/api/market/{market_uuid}/actions/close'; + url: '/api/events/{event_path}/market/{market_uuid}/actions/close'; }; export type MarketApiCloseMarketResponses = { @@ -1747,13 +1904,17 @@ export type MarketApiCloseMarketResponses = { export type MarketApiResolveMarketData = { body: ResolveMarketSchema; path: { + /** + * Event Path + */ + event_path: string; /** * Market Uuid */ market_uuid: string; }; query?: never; - url: '/api/market/{market_uuid}/actions/resolve'; + url: '/api/events/{event_path}/market/{market_uuid}/actions/resolve'; }; export type MarketApiResolveMarketResponses = { @@ -1768,13 +1929,17 @@ export type MarketApiResolveMarketResponse = MarketApiResolveMarketResponses[key export type MarketApiCreateBetData = { body: UserBetCreateSchema; path: { + /** + * Event Path + */ + event_path: string; /** * Market Uuid */ market_uuid: string; }; query?: never; - url: '/api/market/{market_uuid}/bets'; + url: '/api/events/{event_path}/market/{market_uuid}/bets'; }; export type MarketApiCreateBetResponses = { diff --git a/polylan_submitter/src/components/MarketCard.vue b/polylan_submitter/src/components/MarketCard.vue index 7baafae..914594c 100644 --- a/polylan_submitter/src/components/MarketCard.vue +++ b/polylan_submitter/src/components/MarketCard.vue @@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue"; import { storeToRefs } from "pinia"; import { marketApiCreateBet, marketApiCloseMarket, marketApiResolveMarket } from "../api"; import { useMarketStore } from "../stores/market"; +import { useEventPath } from "../composables/useEventPath"; import type { Market, MarketOption } from "../types"; import type { UserBetSchema } from "../api/types.gen"; @@ -14,6 +15,7 @@ const emit = defineEmits<{ refresh: []; }>(); +const eventPath = useEventPath(); const marketStore = useMarketStore(); const { userInfo, userBets } = storeToRefs(marketStore); @@ -82,7 +84,7 @@ const closeMarket = async () => { try { await marketApiCloseMarket({ - path: { market_uuid: props.market.uuid }, + path: { event_path: eventPath, market_uuid: props.market.uuid }, }); emit("refresh"); } catch (e) { @@ -103,7 +105,7 @@ const resolveMarket = async () => { try { await marketApiResolveMarket({ - path: { market_uuid: props.market.uuid }, + path: { event_path: eventPath, market_uuid: props.market.uuid }, body: { winning_option_uuid: selectedWinningOption.value }, }); emit("refresh"); @@ -132,6 +134,7 @@ const placeBet = async () => { try { const response = await marketApiCreateBet({ path: { + event_path: eventPath, market_uuid: props.market.uuid }, body: { @@ -142,7 +145,7 @@ const placeBet = async () => { if (!response.error) { // Reload user bets to reflect the new bet - await marketStore.loadUserBets(); + await marketStore.loadUserBets(eventPath); updateExistingBet(); betAmount.value = 0; } else { diff --git a/polylan_submitter/src/composables/useEventPath.ts b/polylan_submitter/src/composables/useEventPath.ts new file mode 100644 index 0000000..c4006ad --- /dev/null +++ b/polylan_submitter/src/composables/useEventPath.ts @@ -0,0 +1,15 @@ +import { inject, provide, type InjectionKey } from 'vue' + +export const EventPathKey: InjectionKey = Symbol('eventPath') + +export function provideEventPath(eventPath: string) { + provide(EventPathKey, eventPath) +} + +export function useEventPath(): string { + const eventPath = inject(EventPathKey) + if (!eventPath) { + throw new Error('useEventPath() called without a provided eventPath - is this component mounted under a page that calls provideEventPath()?') + } + return eventPath +} diff --git a/polylan_submitter/src/services/apiService.ts b/polylan_submitter/src/services/apiService.ts index 7544d64..ce27890 100644 --- a/polylan_submitter/src/services/apiService.ts +++ b/polylan_submitter/src/services/apiService.ts @@ -37,12 +37,28 @@ interface SubmissionStats { // API Service Class export class ApiService { + private eventPath: string | null = null + + // Every endpoint this service calls is event-scoped (/api/events/{event_path}/...), + // so the owning page sets this once (via provideEventPath()'s value) before any + // request is made, instead of threading eventPath through every method signature. + setEventPath(eventPath: string) { + this.eventPath = eventPath + } + + private buildUrl(endpoint: string): string { + if (!this.eventPath) { + throw new Error('ApiService.setEventPath() must be called before making requests') + } + return `${API_BASE_URL}/events/${this.eventPath}${endpoint}` + } + private async request( endpoint: string, options: RequestInit = {} ): Promise> { try { - const response = await fetch(`${API_BASE_URL}${endpoint}`, { + const response = await fetch(this.buildUrl(endpoint), { headers: { 'Content-Type': 'application/json', ...options.headers, @@ -76,7 +92,7 @@ export class ApiService { formData: FormData ): Promise> { try { - const response = await fetch(`${API_BASE_URL}${endpoint}`, { + const response = await fetch(this.buildUrl(endpoint), { method: 'POST', body: formData, }) diff --git a/polylan_submitter/src/stores/market.ts b/polylan_submitter/src/stores/market.ts index fff5bd7..a59ddae 100644 --- a/polylan_submitter/src/stores/market.ts +++ b/polylan_submitter/src/stores/market.ts @@ -25,9 +25,9 @@ export const useMarketStore = defineStore('market', () => { } } - const loadMarkets = async () => { + const loadMarkets = async (eventPath: string) => { try { - const response = await marketApiListMarkets() + const response = await marketApiListMarkets({ path: { event_path: eventPath } }) if (response.data) { markets.value = response.data as unknown as Market[] } @@ -37,9 +37,9 @@ export const useMarketStore = defineStore('market', () => { } } - const loadUserBets = async () => { + const loadUserBets = async (eventPath: string) => { try { - const response = await marketApiListUserBets() + const response = await marketApiListUserBets({ path: { event_path: eventPath } }) if (response.data) { userBets.value = response.data } @@ -49,30 +49,30 @@ export const useMarketStore = defineStore('market', () => { } } - const initializeMarketPage = async () => { + const initializeMarketPage = async (eventPath: string) => { isLoading.value = true error.value = '' try { await Promise.all([ loadUserInfo(), - loadMarkets(), + loadMarkets(eventPath), ]) // Load user bets if authenticated if (userInfo.value?.is_authenticated) { - await loadUserBets() + await loadUserBets(eventPath) } } finally { isLoading.value = false } } - const refreshPage = async () => { + const refreshPage = async (eventPath: string) => { await Promise.all([ loadUserInfo(), - loadMarkets(), - userInfo.value?.is_authenticated ? loadUserBets() : Promise.resolve(), + loadMarkets(eventPath), + userInfo.value?.is_authenticated ? loadUserBets(eventPath) : Promise.resolve(), ]) } diff --git a/polylan_submitter/src/stores/puzzles.ts b/polylan_submitter/src/stores/puzzles.ts index 7844263..ffa1f50 100644 --- a/polylan_submitter/src/stores/puzzles.ts +++ b/polylan_submitter/src/stores/puzzles.ts @@ -31,14 +31,14 @@ export const usePuzzlesStore = defineStore('puzzles', () => { }) // Actions - const loadPuzzles = async () => { + const loadPuzzles = async (eventPath: string) => { if (puzzles.value.length > 0) return // Already loaded try { isLoading.value = true error.value = '' - const response = await opusMagnumApiListPuzzles() + const response = await opusMagnumApiListPuzzles({ path: { event_path: eventPath } }) if (response.error) { error.value = String(response.error) console.error('Failed to load puzzles:', response.error) @@ -56,9 +56,9 @@ export const usePuzzlesStore = defineStore('puzzles', () => { } } - const refreshPuzzles = async () => { + const refreshPuzzles = async (eventPath: string) => { puzzles.value = [] - await loadPuzzles() + await loadPuzzles(eventPath) } return { diff --git a/polylan_submitter/submissions/api.py b/polylan_submitter/submissions/api.py index 0217f54..91d8ad5 100644 --- a/polylan_submitter/submissions/api.py +++ b/polylan_submitter/submissions/api.py @@ -12,17 +12,23 @@ from .tasks import process_submission_file, process_crop router = Router() +PREFIX = "/events/{event_path}/submissions" -@router.post("/submission/create", response=SubmissionOut) + +@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), ): - game = get_object_or_404(Game, path=game_path, enabled=True) + game = get_object_or_404( + Game.objects.enabled_for_event(request.event), path=game_path + ) submission = Submission.objects.create( + event=request.event, game=game, user=request.user if request.user.is_authenticated else None, notes=notes or None, @@ -39,32 +45,32 @@ def create_submission( return submission -@router.get("/submission/by/user", response=list[SubmissionOut]) -def list_user_submissions(request: HttpRequest, game_path: str): +@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) if not request.user.is_authenticated: return 403, {"detail": "Authentication required"} - submissions = Submission.objects.filter(game=game, user=request.user).order_by( + submissions = Submission.for_event.filter(game=game, user=request.user).order_by( "-created_at" ) return submissions -@router.get("/submission/by/game", response=list[SubmissionOut]) -def list_submissions_by_game(request: HttpRequest, game_path: str): +@router.get(f"{PREFIX}/submission/by/game", response=list[SubmissionOut]) +def list_submissions_by_game(request: HttpRequest, event_path: str, game_path: str): game = get_object_or_404(Game, path=game_path) if not request.user.is_staff: return 403, {"detail": "Admin access required"} - return Submission.objects.filter(game=game) + return Submission.for_event.filter(game=game) -@router.get("/submission/{submission_id}", response=SubmissionOut) -def get_submission(request: HttpRequest, submission_id: UUID): - submission = get_object_or_404(Submission, id=submission_id) +@router.get(f"{PREFIX}/submission/{{submission_id}}", response=SubmissionOut) +def get_submission(request: HttpRequest, event_path: str, submission_id: UUID): + submission = get_object_or_404(Submission.for_event, id=submission_id) if submission.user != request.user and not request.user.is_staff: return 403, {"detail": "Not authorized"} @@ -72,16 +78,19 @@ def get_submission(request: HttpRequest, submission_id: UUID): return submission -@router.post("/crops/s/{submission_file_id}", response=ImageCropOut) +@router.post(f"{PREFIX}/crops/s/{{submission_file_id}}", response=ImageCropOut) def create_image_crop( request: HttpRequest, + event_path: str, submission_file_id: UUID, data: ImageCropIn, ): if not request.user.is_staff: return 403, {"detail": "Admin access required"} - submission_file = get_object_or_404(SubmissionFile, id=submission_file_id) + submission_file = get_object_or_404( + SubmissionFile, id=submission_file_id, submission__event=request.event + ) crop = ImageCrop.objects.create( submission_file=submission_file, @@ -96,23 +105,27 @@ def create_image_crop( return crop -@router.delete("/crops/{crop_id}", response=dict) -def delete_image_crop(request: HttpRequest, crop_id: UUID): +@router.delete(f"{PREFIX}/crops/{{crop_id}}", response=dict) +def delete_image_crop(request: HttpRequest, event_path: str, crop_id: UUID): if not request.user.is_staff: return 403, {"detail": "Admin access required"} - crop = get_object_or_404(ImageCrop, id=crop_id) + crop = get_object_or_404( + ImageCrop, id=crop_id, submission_file__submission__event=request.event + ) crop.delete() return {"status": "deleted"} -@router.post("/files/{file_id}/process-ocr", response=dict) -def process_file_ocr(request: HttpRequest, file_id: UUID): +@router.post(f"{PREFIX}/files/{{file_id}}/process-ocr", response=dict) +def process_file_ocr(request: HttpRequest, event_path: str, file_id: UUID): if not request.user.is_staff: return 403, {"detail": "Admin access required"} - submission_file = get_object_or_404(SubmissionFile, id=file_id) + submission_file = get_object_or_404( + SubmissionFile, id=file_id, submission__event=request.event + ) submission_file.ocr_status = OCRStatus.TO_PROCESS submission_file.save() @@ -122,12 +135,14 @@ def process_file_ocr(request: HttpRequest, file_id: UUID): return {"status": "queued"} -@router.post("/crops/{crop_id}/process-ocr", response=dict) -def process_crop_ocr(request: HttpRequest, crop_id: UUID): +@router.post(f"{PREFIX}/crops/{{crop_id}}/process-ocr", response=dict) +def process_crop_ocr(request: HttpRequest, event_path: str, crop_id: UUID): if not request.user.is_staff: return 403, {"detail": "Admin access required"} - crop = get_object_or_404(ImageCrop, id=crop_id) + crop = get_object_or_404( + ImageCrop, id=crop_id, submission_file__submission__event=request.event + ) crop.ocr_status = OCRStatus.TO_PROCESS crop.save() diff --git a/polylan_submitter/submissions/migrations/0003_submission_event.py b/polylan_submitter/submissions/migrations/0003_submission_event.py new file mode 100644 index 0000000..0b7f2ce --- /dev/null +++ b/polylan_submitter/submissions/migrations/0003_submission_event.py @@ -0,0 +1,24 @@ +# Generated by Django 6.0.5 on 2026-09-15 21:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0002_seed_polylan_43"), + ("submissions", "0002_submission_game_alter_submission_user"), + ] + + operations = [ + migrations.AddField( + model_name="submission", + name="event", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="submissions", + to="events.event", + ), + ), + ] diff --git a/polylan_submitter/submissions/migrations/0004_backfill_submission_event.py b/polylan_submitter/submissions/migrations/0004_backfill_submission_event.py new file mode 100644 index 0000000..d28d2ba --- /dev/null +++ b/polylan_submitter/submissions/migrations/0004_backfill_submission_event.py @@ -0,0 +1,25 @@ +from django.db import migrations + +EVENT_PATH = "polylan-43" + + +def backfill_event(apps, schema_editor): + Event = apps.get_model("events", "Event") + Submission = apps.get_model("submissions", "Submission") + event = Event.objects.get(path=EVENT_PATH) + Submission.objects.filter(event__isnull=True).update(event=event) + + +def noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + ("submissions", "0003_submission_event"), + ("events", "0002_seed_polylan_43"), + ] + + operations = [ + migrations.RunPython(backfill_event, noop), + ] diff --git a/polylan_submitter/submissions/migrations/0005_submission_event_required.py b/polylan_submitter/submissions/migrations/0005_submission_event_required.py new file mode 100644 index 0000000..c96231c --- /dev/null +++ b/polylan_submitter/submissions/migrations/0005_submission_event_required.py @@ -0,0 +1,20 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("submissions", "0004_backfill_submission_event"), + ] + + operations = [ + migrations.AlterField( + model_name="submission", + name="event", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="submissions", + to="events.event", + ), + ), + ] diff --git a/polylan_submitter/submissions/models.py b/polylan_submitter/submissions/models.py index e617976..d91c4ae 100644 --- a/polylan_submitter/submissions/models.py +++ b/polylan_submitter/submissions/models.py @@ -4,6 +4,8 @@ from django.utils.translation import gettext_lazy as _ import uuid from accounts.models import CustomUser +from events.models import Event +from events.scoping import EventScopedManager from games.models import Game @@ -36,6 +38,12 @@ class BaseModel(models.Model): class Submission(BaseModel): + event = models.ForeignKey( + Event, + on_delete=models.CASCADE, + related_name="submissions", + ) + game = models.ForeignKey( Game, on_delete=models.CASCADE, @@ -60,6 +68,9 @@ class Submission(BaseModel): help_text="Optional notes about the submission", ) + objects = models.Manager() + for_event = EventScopedManager() + class SubmissionFile(BaseModel): submission = models.ForeignKey( diff --git a/polylan_submitter/submissions/views.py b/polylan_submitter/submissions/views.py index 2a48cfc..ce5419e 100644 --- a/polylan_submitter/submissions/views.py +++ b/polylan_submitter/submissions/views.py @@ -2,16 +2,21 @@ from django.http import HttpRequest from django.shortcuts import render, get_object_or_404 from django.contrib.auth.decorators import login_required +from events.models import Event from games.models import Game @login_required -def submission_home(request: HttpRequest, game: str): - game_obj = get_object_or_404(Game, path=f"/submissions/{game}", enabled=True) +def submission_home(request: HttpRequest, event_path: str, game: 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}" + ) return render( request, "submissions.html", { + "event": event, "game": game_obj, }, ) diff --git a/polylan_submitter/templates/home.html b/polylan_submitter/templates/home.html index aff100e..6d9e97f 100644 --- a/polylan_submitter/templates/home.html +++ b/polylan_submitter/templates/home.html @@ -9,5 +9,5 @@ {% endblock %} {% block content %} -
+
{% endblock %} diff --git a/polylan_submitter/templates/market.html b/polylan_submitter/templates/market.html index 498f3ec..0ab309d 100644 --- a/polylan_submitter/templates/market.html +++ b/polylan_submitter/templates/market.html @@ -9,5 +9,5 @@ {% endblock %} {% block content %} -
+
{% endblock %} diff --git a/polylan_submitter/templates/noita.html b/polylan_submitter/templates/noita.html index 8c4932d..9a48a8c 100644 --- a/polylan_submitter/templates/noita.html +++ b/polylan_submitter/templates/noita.html @@ -9,5 +9,5 @@ {% endblock %} {% block content %} -
+
{% endblock %} diff --git a/polylan_submitter/templates/opus-magnum.html b/polylan_submitter/templates/opus-magnum.html index c6b90f2..43754df 100644 --- a/polylan_submitter/templates/opus-magnum.html +++ b/polylan_submitter/templates/opus-magnum.html @@ -9,5 +9,5 @@ {% endblock %} {% block content %} -
+
{% endblock %} diff --git a/polylan_submitter/templates/submissions.html b/polylan_submitter/templates/submissions.html index 510af05..80b266e 100644 --- a/polylan_submitter/templates/submissions.html +++ b/polylan_submitter/templates/submissions.html @@ -9,5 +9,5 @@ {% endblock %} {% block content %} -
+
{% endblock %}