From 919e6118957ce2bea9f657e4b1f2d48cc63c90f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Gremaud?= Date: Fri, 16 Oct 2026 00:23:27 +0200 Subject: [PATCH] feat(market): enable/disable like any other games --- deploy/docker/polylan-submitter.env.dist | 1 - polylan_submitter/events/tests.py | 4 +- .../games/migrations/0009_game_description.py | 17 ++++++++ polylan_submitter/games/models.py | 1 + polylan_submitter/games/schemas.py | 1 + polylan_submitter/market/api.py | 9 +++++ .../migrations/0010_seed_market_game.py | 40 +++++++++++++++++++ polylan_submitter/market/tests.py | 24 ++++++++++- polylan_submitter/market/views.py | 2 + polylan_submitter/polylan_submitter/api.py | 18 +-------- polylan_submitter/polylan_submitter/asgi.py | 6 +-- .../polylan_submitter/settings.py | 3 -- polylan_submitter/src/Home.vue | 37 +++-------------- polylan_submitter/src/api/index.ts | 4 +- polylan_submitter/src/api/sdk.gen.ts | 7 +--- polylan_submitter/src/api/types.gen.ts | 30 ++------------ polylan_submitter/src/locales/en.json | 5 --- 17 files changed, 112 insertions(+), 97 deletions(-) create mode 100644 polylan_submitter/games/migrations/0009_game_description.py create mode 100644 polylan_submitter/market/migrations/0010_seed_market_game.py diff --git a/deploy/docker/polylan-submitter.env.dist b/deploy/docker/polylan-submitter.env.dist index f95a396..78fea24 100644 --- a/deploy/docker/polylan-submitter.env.dist +++ b/deploy/docker/polylan-submitter.env.dist @@ -35,7 +35,6 @@ AWS_STORAGE_BUCKET_NAME= SENTRY_ENVIRONMENT=production SENTRY_DSN= -MARKET_ENABLED=true STEAM_API_KEY= AANDT_API_BASE_URL= SUBMITTER_BASE_URL= diff --git a/polylan_submitter/events/tests.py b/polylan_submitter/events/tests.py index 73d0ab0..49236ff 100644 --- a/polylan_submitter/events/tests.py +++ b/polylan_submitter/events/tests.py @@ -50,12 +50,12 @@ class EventAvailabilityTests(TestCase): def test_unavailable_event_is_404_for_regular_user(self): self.client.force_login(self.user) - response = self.client.get(f"/{self.hidden.path}/market") + response = self.client.get(f"/{self.hidden.path}/") self.assertEqual(response.status_code, 404) def test_unavailable_event_is_visible_to_staff(self): self.client.force_login(self.staff) - response = self.client.get(f"/{self.hidden.path}/market") + response = self.client.get(f"/{self.hidden.path}/") self.assertEqual(response.status_code, 200) def test_available_to_filters_for_regular_user_only(self): diff --git a/polylan_submitter/games/migrations/0009_game_description.py b/polylan_submitter/games/migrations/0009_game_description.py new file mode 100644 index 0000000..8b101e7 --- /dev/null +++ b/polylan_submitter/games/migrations/0009_game_description.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.5 on 2026-10-15 22:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("games", "0008_game_custom_path"), + ] + + operations = [ + migrations.AddField( + model_name="game", + name="description", + field=models.TextField(blank=True, default=""), + ), + ] diff --git a/polylan_submitter/games/models.py b/polylan_submitter/games/models.py index 4a08333..bda5c5a 100644 --- a/polylan_submitter/games/models.py +++ b/polylan_submitter/games/models.py @@ -14,6 +14,7 @@ class Game(models.Model): path = models.CharField(max_length=100) custom_path = models.BooleanField(default=False) image = models.URLField(null=True, blank=True) + description = models.TextField(blank=True, default="") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/polylan_submitter/games/schemas.py b/polylan_submitter/games/schemas.py index e9ae513..cfb015d 100644 --- a/polylan_submitter/games/schemas.py +++ b/polylan_submitter/games/schemas.py @@ -10,3 +10,4 @@ class GameOut(Schema): path: str custom_path: bool image: Optional[str] = None + description: str diff --git a/polylan_submitter/market/api.py b/polylan_submitter/market/api.py index a344b4a..9714787 100644 --- a/polylan_submitter/market/api.py +++ b/polylan_submitter/market/api.py @@ -1,11 +1,13 @@ from typing import List from ninja import Router +from ninja.decorators import decorate_view from ninja.errors import HttpError from django.shortcuts import get_object_or_404 from django.db.models import Sum, Prefetch from django.db.models.functions import Coalesce from django.db import transaction +from games.decorators import require_game_enabled from market.models import ( Market, MarketOption, @@ -29,6 +31,7 @@ PREFIX = "/events/{event_path}/market" @router.get(f"{PREFIX}/", response=List[MarketListSchema]) +@decorate_view(require_game_enabled(path="market")) def list_markets(request, event_path: str): """List all markets (excludes draft markets).""" markets = Market.for_event.exclude(status=Market.Status.DRAFT) @@ -41,6 +44,7 @@ def list_markets(request, event_path: str): @router.get(f"{PREFIX}/user/bets", response=List[UserBetSchema]) +@decorate_view(require_game_enabled(path="market")) def list_user_bets(request, event_path: str): """List all bets placed by the current user.""" if not request.user.is_authenticated: @@ -54,6 +58,7 @@ def list_user_bets(request, event_path: str): @router.get(f"{PREFIX}/user/points", response=UserPointsSchema) +@decorate_view(require_game_enabled(path="market")) def get_user_points(request, event_path: str): """Get the current user's points balance for this event.""" if not request.user.is_authenticated: @@ -63,6 +68,7 @@ def get_user_points(request, event_path: str): @router.get(f"{PREFIX}/{{market_uuid}}/history", response=List[BetHistoryPointSchema]) +@decorate_view(require_game_enabled(path="market")) def bet_history(request, event_path: str, market_uuid: str): """Points added to each option over time (anonymous, one entry per bet placed/increased).""" market = get_object_or_404( @@ -93,6 +99,7 @@ def bet_history(request, event_path: str, market_uuid: str): @router.post(f"{PREFIX}/{{market_uuid}}/actions/close") +@decorate_view(require_game_enabled(path="market")) def close_market(request, event_path: str, market_uuid: str): """Close a market. Admin only.""" if not request.user.is_staff: @@ -105,6 +112,7 @@ def close_market(request, event_path: str, market_uuid: str): @router.post(f"{PREFIX}/{{market_uuid}}/actions/resolve", response=MarketListSchema) +@decorate_view(require_game_enabled(path="market")) def resolve_market( request, event_path: str, market_uuid: str, payload: ResolveMarketSchema ): @@ -172,6 +180,7 @@ def resolve_market( @router.post(f"{PREFIX}/{{market_uuid}}/bets", response=UserBetSchema) +@decorate_view(require_game_enabled(path="market")) def create_bet( request, event_path: str, market_uuid: str, payload: UserBetCreateSchema ): diff --git a/polylan_submitter/market/migrations/0010_seed_market_game.py b/polylan_submitter/market/migrations/0010_seed_market_game.py new file mode 100644 index 0000000..2d5da1b --- /dev/null +++ b/polylan_submitter/market/migrations/0010_seed_market_game.py @@ -0,0 +1,40 @@ +from django.db import migrations + +MARKET_PATH = "market" + + +def seed_market_game(apps, schema_editor): + """The market used to be toggled globally (MARKET_ENABLED); it's now a + custom Game like clock/place, enabled per event through EventGame. Enable + it for every existing event so nothing disappears on deploy.""" + Event = apps.get_model("events", "Event") + Game = apps.get_model("games", "Game") + EventGame = apps.get_model("games", "EventGame") + + game, _ = Game.objects.get_or_create( + path=MARKET_PATH, + custom_path=True, + defaults={ + "name": "Market", + "description": "Place your bets and compete", + }, + ) + for event in Event.objects.all(): + EventGame.objects.get_or_create(event=event, game=game) + + +def unseed_market_game(apps, schema_editor): + Game = apps.get_model("games", "Game") + Game.objects.filter(path=MARKET_PATH, custom_path=True).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0003_event_is_available"), + ("games", "0009_game_description"), + ("market", "0009_event_required"), + ] + + operations = [ + migrations.RunPython(seed_market_game, reverse_code=unseed_market_game), + ] diff --git a/polylan_submitter/market/tests.py b/polylan_submitter/market/tests.py index a39b155..d07fc0e 100644 --- a/polylan_submitter/market/tests.py +++ b/polylan_submitter/market/tests.py @@ -1 +1,23 @@ -# Create your tests here. +from django.test import TestCase + +from accounts.models import CustomUser +from events.models import Event +from games.models import EventGame, Game + + +class MarketGameGatingTests(TestCase): + def setUp(self): + self.event = Event.objects.create(name="Event", path="event") + self.game = Game.objects.get(path="market", custom_path=True) + self.user = CustomUser.objects.create_user(username="user", password="x") + self.client.force_login(self.user) + + def test_market_is_403_when_not_enabled_for_event(self): + response = self.client.get(f"/api/events/{self.event.path}/market/") + self.assertEqual(response.status_code, 403) + + def test_market_is_reachable_when_enabled_for_event(self): + EventGame.objects.create(event=self.event, game=self.game) + response = self.client.get(f"/api/events/{self.event.path}/market/") + self.assertEqual(response.status_code, 200) + self.assertEqual(self.client.get(f"/{self.event.path}/market").status_code, 200) diff --git a/polylan_submitter/market/views.py b/polylan_submitter/market/views.py index 9c9b58e..08b1e6c 100644 --- a/polylan_submitter/market/views.py +++ b/polylan_submitter/market/views.py @@ -3,9 +3,11 @@ from django.shortcuts import get_object_or_404, render from django.http import HttpRequest from events.models import Event +from games.decorators import require_game_enabled @login_required +@require_game_enabled(path="market") def market_home(request: HttpRequest, event_path: str): get_object_or_404(Event, path=event_path) return render(request, "app.html") diff --git a/polylan_submitter/polylan_submitter/api.py b/polylan_submitter/polylan_submitter/api.py index e2c9824..f749036 100644 --- a/polylan_submitter/polylan_submitter/api.py +++ b/polylan_submitter/polylan_submitter/api.py @@ -1,5 +1,4 @@ -from ninja import NinjaAPI, Schema -from django.conf import settings +from ninja import NinjaAPI from django.core.cache import cache from django.http import HttpRequest from opus_magnum.api import router as opus_magnum_router @@ -54,9 +53,7 @@ api.add_router("", games_event_router, tags=["games"]) api.add_router("", submissions_router, tags=["submissions"]) api.add_router("", clock_router, tags=["clock"]) api.add_router("", place_router, tags=["place"]) - -if settings.MARKET_ENABLED: - api.add_router("", market_router, tags=["market"]) +api.add_router("", market_router, tags=["market"]) # Health check endpoint @@ -66,17 +63,6 @@ def health_check(request): return {"status": "healthy", "service": "polylan-submitter-api"} -class ConfigOut(Schema): - market_enabled: bool - - -# Site-wide config the SPA needs but that isn't tied to any one event -# (e.g. whether to render the market card at all on the home page). -@api.get("/config", response=ConfigOut) -def get_config(request): - return {"market_enabled": settings.MARKET_ENABLED} - - # Cache management endpoint @api.post("/cache/clear") def clear_cache(request: HttpRequest, event_path: str | None = None): diff --git a/polylan_submitter/polylan_submitter/asgi.py b/polylan_submitter/polylan_submitter/asgi.py index 8883212..0a5369e 100644 --- a/polylan_submitter/polylan_submitter/asgi.py +++ b/polylan_submitter/polylan_submitter/asgi.py @@ -17,11 +17,11 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings") django_asgi_app = get_asgi_application() -from submissions.routing import ( +from submissions.routing import ( # noqa: E402 websocket_urlpatterns as submissions_websocket_urlpatterns, ) -from clock.routing import websocket_urlpatterns as clock_websocket_urlpatterns -from place.routing import websocket_urlpatterns as place_websocket_urlpatterns +from clock.routing import websocket_urlpatterns as clock_websocket_urlpatterns # noqa: E402 +from place.routing import websocket_urlpatterns as place_websocket_urlpatterns # noqa: E402 websocket_urlpatterns = [ *submissions_websocket_urlpatterns, diff --git a/polylan_submitter/polylan_submitter/settings.py b/polylan_submitter/polylan_submitter/settings.py index ebe0b31..ab0b546 100644 --- a/polylan_submitter/polylan_submitter/settings.py +++ b/polylan_submitter/polylan_submitter/settings.py @@ -307,9 +307,6 @@ ALLOWED_SUBMISSION_TYPES = [ "video/webm", ] -# Market app settings -MARKET_ENABLED = (os.environ.get("MARKET_ENABLED") or "true").lower() == "true" - # Authentication backends AUTHENTICATION_BACKENDS = [ "django.contrib.auth.backends.ModelBackend", diff --git a/polylan_submitter/src/Home.vue b/polylan_submitter/src/Home.vue index 9841bf8..9b30b97 100644 --- a/polylan_submitter/src/Home.vue +++ b/polylan_submitter/src/Home.vue @@ -3,7 +3,7 @@ import { ref, onMounted, computed } from "vue"; import { useRouter } from "vue-router"; import { useI18n } from "vue-i18n"; import { useTitle } from "@vueuse/core"; -import { gamesApiListEventGames, polylanSubmitterApiGetConfig } from "./api"; +import { gamesApiListEventGames } from "./api"; import type { GamesApiListEventGamesResponse } from "./api/types.gen"; interface Props { @@ -19,7 +19,6 @@ useTitle(computed(() => t("home.title"))); const games = ref(); const loading = ref(true); const imageErrors = ref>(new Set()); -const marketEnabled = ref(false); const getHeaderImage = (game: GamesApiListEventGamesResponse[number]) => { if (game.image) { @@ -35,26 +34,18 @@ const onImageError = (gameId: number) => { imageErrors.value.add(gameId); }; -const goToMarket = () => { - router.push(`/${props.eventPath}/market`); -}; - const goToGame = (game: GamesApiListEventGamesResponse[number]) => { const path = game.custom_path ? `/${game.path}` : `/submissions/${game.path}`; router.push(`/${props.eventPath}${path}`); }; onMounted(async () => { - const [gamesResponse, configResponse] = await Promise.all([ - gamesApiListEventGames({ path: { event_path: props.eventPath } }), - polylanSubmitterApiGetConfig(), - ]); + const gamesResponse = await gamesApiListEventGames({ + path: { event_path: props.eventPath }, + }); if (gamesResponse.data) { games.value = gamesResponse.data; } - if (configResponse.data) { - marketEnabled.value = configResponse.data.market_enabled; - } loading.value = false; }); @@ -77,25 +68,6 @@ onMounted(async () => {
- -
-
- -
-
-
-

{{ t("home.marketCard.title") }}

-

{{ t("home.marketCard.description") }}

-
- -
-
-
-
{

{{ game.name }}

+

{{ game.description }}