diff --git a/polylan_submitter/games/__init__.py b/polylan_submitter/games/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/polylan_submitter/games/admin.py b/polylan_submitter/games/admin.py new file mode 100644 index 0000000..5e84eb2 --- /dev/null +++ b/polylan_submitter/games/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from .models import Game + + +@admin.register(Game) +class GameAdmin(admin.ModelAdmin): + list_display = ["name", "steam_app_id", "enabled", "updated_at"] + list_filter = ["enabled"] + search_fields = ["name", "steam_app_id"] + readonly_fields = ["created_at", "updated_at"] diff --git a/polylan_submitter/games/api.py b/polylan_submitter/games/api.py new file mode 100644 index 0000000..e863b87 --- /dev/null +++ b/polylan_submitter/games/api.py @@ -0,0 +1,13 @@ +from typing import List + +from ninja import Router + +from .models import Game +from .schemas import GameOut + +router = Router() + + +@router.get("", response=List[GameOut]) +def list_games(request): + return Game.objects.filter(enabled=True) diff --git a/polylan_submitter/games/apps.py b/polylan_submitter/games/apps.py new file mode 100644 index 0000000..4c4fb1d --- /dev/null +++ b/polylan_submitter/games/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class GamesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "games" diff --git a/polylan_submitter/games/decorators.py b/polylan_submitter/games/decorators.py new file mode 100644 index 0000000..339ebad --- /dev/null +++ b/polylan_submitter/games/decorators.py @@ -0,0 +1,22 @@ +from functools import wraps + +from django.core.exceptions import PermissionDenied + +from .models import Game + + +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: + raise PermissionDenied + return view_func(request, *args, **kwargs) + + return wrapper + + return decorator diff --git a/polylan_submitter/games/migrations/0001_initial.py b/polylan_submitter/games/migrations/0001_initial.py new file mode 100644 index 0000000..c8f365a --- /dev/null +++ b/polylan_submitter/games/migrations/0001_initial.py @@ -0,0 +1,32 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Game", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("steam_app_id", models.PositiveIntegerField(unique=True)), + ("name", models.CharField(max_length=255)), + ("enabled", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "ordering": ["name"], + }, + ), + ] diff --git a/polylan_submitter/games/migrations/0002_seed_noita_and_opus_magnum.py b/polylan_submitter/games/migrations/0002_seed_noita_and_opus_magnum.py new file mode 100644 index 0000000..2b05614 --- /dev/null +++ b/polylan_submitter/games/migrations/0002_seed_noita_and_opus_magnum.py @@ -0,0 +1,31 @@ +from django.db import migrations + +NOITA_APP_ID = 881100 +OPUS_MAGNUM_APP_ID = 558990 + + +def seed_games(apps, schema_editor): + Game = apps.get_model("games", "Game") + Game.objects.get_or_create( + steam_app_id=NOITA_APP_ID, + defaults={"name": "Noita", "enabled": True}, + ) + Game.objects.get_or_create( + steam_app_id=OPUS_MAGNUM_APP_ID, + defaults={"name": "Opus Magnum", "enabled": True}, + ) + + +def unseed_games(apps, schema_editor): + Game = apps.get_model("games", "Game") + Game.objects.filter(steam_app_id__in=[NOITA_APP_ID, OPUS_MAGNUM_APP_ID]).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("games", "0001_initial"), + ] + + operations = [ + migrations.RunPython(seed_games, reverse_code=unseed_games), + ] diff --git a/polylan_submitter/games/migrations/0003_game_path.py b/polylan_submitter/games/migrations/0003_game_path.py new file mode 100644 index 0000000..dcc30f0 --- /dev/null +++ b/polylan_submitter/games/migrations/0003_game_path.py @@ -0,0 +1,31 @@ +from django.db import migrations, models + +NOITA_APP_ID = 881100 +OPUS_MAGNUM_APP_ID = 558990 + +PATHS = { + NOITA_APP_ID: "/noita", + OPUS_MAGNUM_APP_ID: "/opus-magnum", +} + + +def set_paths(apps, schema_editor): + Game = apps.get_model("games", "Game") + for app_id, path in PATHS.items(): + Game.objects.filter(steam_app_id=app_id).update(path=path) + + +class Migration(migrations.Migration): + dependencies = [ + ("games", "0002_seed_noita_and_opus_magnum"), + ] + + operations = [ + migrations.AddField( + model_name="game", + name="path", + field=models.CharField(default="", max_length=100), + preserve_default=False, + ), + migrations.RunPython(set_paths, reverse_code=migrations.RunPython.noop), + ] diff --git a/polylan_submitter/games/migrations/__init__.py b/polylan_submitter/games/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/polylan_submitter/games/models.py b/polylan_submitter/games/models.py new file mode 100644 index 0000000..42f1284 --- /dev/null +++ b/polylan_submitter/games/models.py @@ -0,0 +1,17 @@ +from django.db import models + + +class Game(models.Model): + steam_app_id = models.PositiveIntegerField(unique=True) + name = models.CharField(max_length=255) + path = models.CharField(max_length=100) + enabled = models.BooleanField(default=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["name"] + + def __str__(self) -> str: + return f"{self.name} ({self.steam_app_id})" diff --git a/polylan_submitter/games/schemas.py b/polylan_submitter/games/schemas.py new file mode 100644 index 0000000..8da0f75 --- /dev/null +++ b/polylan_submitter/games/schemas.py @@ -0,0 +1,7 @@ +from ninja import Schema + + +class GameOut(Schema): + steam_app_id: int + name: str + path: str diff --git a/polylan_submitter/noita/api.py b/polylan_submitter/noita/api.py index 26b2943..0c53982 100644 --- a/polylan_submitter/noita/api.py +++ b/polylan_submitter/noita/api.py @@ -12,18 +12,22 @@ from django.db.models import ( ) from ninja import Router, File from ninja.files import UploadedFile +from ninja.decorators import decorate_view from noita.schemas import ResultsOut, LeaderboardOut from noita.services.objectives import parse_objectives_and_store +from games.decorators import require_game_enabled from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter from .schemas import NoitaSubmissionOut router = Router() +NOITA_APP_ID = 881100 @router.get("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}" cached_data = cache.get(cache_key) @@ -127,6 +131,7 @@ def get_results(request: HttpRequest): @router.get("leaderboard", response=LeaderboardOut) +@decorate_view(require_game_enabled(NOITA_APP_ID)) def get_leaderboard(request: HttpRequest): """ Get the global leaderboard for all users ranked by total score. @@ -232,6 +237,7 @@ def get_leaderboard(request: HttpRequest): @router.post("submit", response={200: NoitaSubmissionOut, 400: dict}) +@decorate_view(require_game_enabled(NOITA_APP_ID)) def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)): """ Submit a Noita run file (log file, screenshot, or video). diff --git a/polylan_submitter/noita/services/decode.py b/polylan_submitter/noita/services/decode.py index a6e24f9..71b7c5b 100644 --- a/polylan_submitter/noita/services/decode.py +++ b/polylan_submitter/noita/services/decode.py @@ -72,7 +72,6 @@ POINTS = { "NOLLA": 10, "CHAOTIC_TRANSMUTATION": 10, "DUPLICATE": 5, - "OMEGA": 10, "BURST_2": 10, "BURST_3": 15, "BURST_4": 20, diff --git a/polylan_submitter/polylan_submitter/api.py b/polylan_submitter/polylan_submitter/api.py index 747e093..2a18131 100644 --- a/polylan_submitter/polylan_submitter/api.py +++ b/polylan_submitter/polylan_submitter/api.py @@ -5,6 +5,7 @@ from submissions.api import router as submissions_router from submissions.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 # Create the main API instance api = NinjaAPI( @@ -34,6 +35,7 @@ The Noita Submission API allows clients to upload the result of the log file of api.add_router("/submissions/", submissions_router, tags=["submissions"]) api.add_router("/results/", results_router, tags=["results"]) api.add_router("/noita/", noita_router, tags=["noita"]) +api.add_router("/games/", games_router, tags=["games"]) # Health check endpoint diff --git a/polylan_submitter/polylan_submitter/settings.py b/polylan_submitter/polylan_submitter/settings.py index 3642c36..b6f02f1 100644 --- a/polylan_submitter/polylan_submitter/settings.py +++ b/polylan_submitter/polylan_submitter/settings.py @@ -43,6 +43,7 @@ INSTALLED_APPS = [ "animations", "submissions", "noita", + "games", ] MIDDLEWARE = [ @@ -193,7 +194,7 @@ STATICFILES_DIRS = [ from polylan_submitter.settingsLocal import * # noqa -import sentry_sdk +import sentry_sdk # noqa sentry_sdk.init( dsn="https://cc62a4ce3f3470890b43accf02cc6d8c@sentry2.polylan.ch/12", diff --git a/polylan_submitter/polylan_submitter/urls.py b/polylan_submitter/polylan_submitter/urls.py index 9c220a5..28626a6 100644 --- a/polylan_submitter/polylan_submitter/urls.py +++ b/polylan_submitter/polylan_submitter/urls.py @@ -23,8 +23,12 @@ 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 games.decorators import require_game_enabled from .api import api +NOITA_APP_ID = 881100 +OPUS_MAGNUM_APP_ID = 558990 + @login_required def home(request: HttpRequest): @@ -32,6 +36,7 @@ def home(request: HttpRequest): @login_required +@require_game_enabled(OPUS_MAGNUM_APP_ID) def opus_magnum_home(request: HttpRequest): from submissions.models import SteamCollection @@ -45,6 +50,7 @@ def opus_magnum_home(request: HttpRequest): @login_required +@require_game_enabled(NOITA_APP_ID) def noita_home(request: HttpRequest): return render(request, "noita.html", {}) diff --git a/polylan_submitter/src/Home.vue b/polylan_submitter/src/Home.vue index aec533c..488e1c9 100644 --- a/polylan_submitter/src/Home.vue +++ b/polylan_submitter/src/Home.vue @@ -1,22 +1,10 @@ diff --git a/polylan_submitter/src/services/apiService.ts b/polylan_submitter/src/services/apiService.ts index 68c1c98..93ae6bc 100644 --- a/polylan_submitter/src/services/apiService.ts +++ b/polylan_submitter/src/services/apiService.ts @@ -1,4 +1,5 @@ import type { + Game, SteamCollection, SteamCollectionItem, Submission, @@ -99,6 +100,11 @@ export class ApiService { } } + // Games endpoint + async getGames(): Promise> { + return this.request('/games/') + } + // Puzzle endpoints async getPuzzles(): Promise> { return this.request('/submissions/puzzles') diff --git a/polylan_submitter/src/types/index.ts b/polylan_submitter/src/types/index.ts index 4f177b2..d12ae7b 100644 --- a/polylan_submitter/src/types/index.ts +++ b/polylan_submitter/src/types/index.ts @@ -1,3 +1,9 @@ +export interface Game { + steam_app_id: number + name: string + path: string +} + export interface SteamCollection { id: number steam_id: string diff --git a/polylan_submitter/submissions/api.py b/polylan_submitter/submissions/api.py index 436cba3..df30905 100644 --- a/polylan_submitter/submissions/api.py +++ b/polylan_submitter/submissions/api.py @@ -9,7 +9,9 @@ from django.utils import timezone from django.shortcuts import get_object_or_404 from typing import List +from games.decorators import require_game_enabled from submissions.utils import verify_and_validate_ocr_date_for_submission +from ninja.decorators import decorate_view from .models import ( Submission, @@ -28,9 +30,11 @@ from .schemas import ( ) router = Router() +OPUS_MAGNUM_APP_ID = 558990 @router.get("/puzzles", response=List[SteamCollectionItemOut]) +@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) def list_puzzles(request): """Get list of available puzzles""" return SteamCollectionItem.objects.select_related("collection").filter( @@ -39,6 +43,7 @@ def list_puzzles(request): @router.get("/collection", response=SteamCollectionOut) +@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) def get_collection(request): """Get the active collection details""" collection = get_object_or_404(SteamCollection, is_active=True) @@ -46,6 +51,7 @@ def get_collection(request): @router.get("/submissions", response=List[SubmissionOut]) +@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) @paginate def list_submissions(request): """Get paginated list of submissions""" @@ -55,6 +61,7 @@ def list_submissions(request): @router.get("/submissions/{submission_id}", response=SubmissionOut) +@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) def get_submission(request, submission_id: str): """Get detailed submission by ID""" return get_object_or_404( @@ -66,6 +73,7 @@ def get_submission(request, submission_id: str): @router.post("/submissions", response=SubmissionOut) +@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) def create_submission( request, data: SubmissionIn, files: List[UploadedFile] = File(...) ): @@ -198,6 +206,7 @@ def create_submission( @router.put("/responses/{response_id}/validate", response=PuzzleResponseOut) +@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) def validate_response(request, response_id: int, data: ValidationIn): """Manually validate a puzzle response""" @@ -233,6 +242,7 @@ def validate_response(request, response_id: int, data: ValidationIn): @router.put("/responses/{response_id}/validate/auto", response=PuzzleResponseOut) +@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) def validate_auto(request, response_id: int): """Try to auto validate a puzzle response""" @@ -248,6 +258,7 @@ def validate_auto(request, response_id: int): @router.get("/responses/needs-validation", response=List[PuzzleResponseOut]) +@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) def list_responses_needing_validation(request): """Get all responses that need manual validation""" @@ -263,6 +274,7 @@ def list_responses_needing_validation(request): @router.post("/submissions/{submission_id}/validate", response=SubmissionOut) +@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) def validate_submission(request, submission_id: str): """Mark entire submission as validated""" @@ -291,6 +303,7 @@ def validate_submission(request, submission_id: str): @router.delete("/submissions/{submission_id}") +@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) def delete_submission(request, submission_id: str): """Delete a submission (admin only)""" @@ -307,6 +320,7 @@ def delete_submission(request, submission_id: str): @router.get("/stats") +@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) def get_stats(request): """Get submission statistics"""