Compare commits

...
4 Commits
39 changed files with 978 additions and 109 deletions
+4
View File
@@ -16,4 +16,8 @@ if [ "${MIGRATE_ON_START:-true}" = "true" ]; then
python manage.py migrate --noinput python manage.py migrate --noinput
fi fi
# Kick off the periodic Steam store refresh (games.tasks). No-op when a refresh
# is already queued, so every replica can safely run it on start.
python manage.py schedule_steam_refresh || echo "Could not schedule Steam store refresh"
exec "$@" exec "$@"
+2 -1
View File
@@ -35,7 +35,8 @@ AWS_STORAGE_BUCKET_NAME=
SENTRY_ENVIRONMENT=production SENTRY_ENVIRONMENT=production
SENTRY_DSN= SENTRY_DSN=
MARKET_ENABLED=true
STEAM_API_KEY= STEAM_API_KEY=
STEAM_STORE_REFRESH_MINUTES=360
STEAM_STORE_COUNTRY=ch
AANDT_API_BASE_URL= AANDT_API_BASE_URL=
SUBMITTER_BASE_URL= SUBMITTER_BASE_URL=
+5 -3
View File
@@ -16,7 +16,9 @@ from .wire import encode, to_epoch_ms
class ClockConsumer(AsyncWebsocketConsumer): class ClockConsumer(AsyncWebsocketConsumer):
async def connect(self): async def connect(self):
self.event_path = self.scope["url_route"]["kwargs"]["event_path"] self.event_path = self.scope["url_route"]["kwargs"]["event_path"]
self.event = await self._get_enabled_event(self.event_path) self.event = await self._get_enabled_event(
self.event_path, self.scope.get("user")
)
if self.event is None: if self.event is None:
await self.close() await self.close()
return return
@@ -62,8 +64,8 @@ class ClockConsumer(AsyncWebsocketConsumer):
) )
@database_sync_to_async @database_sync_to_async
def _get_enabled_event(self, event_path): def _get_enabled_event(self, event_path, user):
event = Event.objects.filter(path=event_path).first() event = Event.objects.available_to(user).filter(path=event_path).first()
if event is None: if event is None:
return None return None
if not Game.objects.enabled_for_event(event).filter(path="clock").exists(): if not Game.objects.enabled_for_event(event).filter(path="clock").exists():
+3 -1
View File
@@ -5,7 +5,9 @@ from .models import Event
@admin.register(Event) @admin.register(Event)
class EventAdmin(admin.ModelAdmin): class EventAdmin(admin.ModelAdmin):
list_display = ["name", "path", "created_at"] list_display = ["name", "path", "is_available", "created_at"]
list_editable = ["is_available"]
list_filter = ["is_available"]
search_fields = ["name", "path"] search_fields = ["name", "path"]
prepopulated_fields = {"path": ("name",)} prepopulated_fields = {"path": ("name",)}
readonly_fields = ["created_at", "updated_at"] readonly_fields = ["created_at", "updated_at"]
+4 -1
View File
@@ -22,6 +22,9 @@ class EventContextMiddleware:
def process_view(self, request, view_func, view_args, view_kwargs): def process_view(self, request, view_func, view_args, view_kwargs):
event_path = view_kwargs.get("event_path") event_path = view_kwargs.get("event_path")
if event_path: if event_path:
request.event = get_object_or_404(Event, path=event_path) request.event = get_object_or_404(
Event.objects.available_to(getattr(request, "user", None)),
path=event_path,
)
current_event.set(request.event) current_event.set(request.event)
return None return None
@@ -0,0 +1,17 @@
# Generated by Django 6.0.5 on 2026-10-15 22:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("events", "0002_seed_polylan_43"),
]
operations = [
migrations.AddField(
model_name="event",
name="is_available",
field=models.BooleanField(default=True),
),
]
+12
View File
@@ -14,9 +14,21 @@ class BaseModel(models.Model):
abstract = True abstract = True
class EventQuerySet(models.QuerySet):
def available_to(self, user):
"""Unavailable events are hidden from everyone but staff, so they can
still be prepared before being opened up."""
if user is not None and user.is_staff:
return self
return self.filter(is_available=True)
class Event(BaseModel): class Event(BaseModel):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
path = models.SlugField(max_length=100, unique=True) path = models.SlugField(max_length=100, unique=True)
is_available = models.BooleanField(default=True)
objects = EventQuerySet.as_manager()
def __str__(self) -> str: def __str__(self) -> str:
return self.name return self.name
+40
View File
@@ -1,3 +1,5 @@
from datetime import timedelta
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.test import TestCase from django.test import TestCase
@@ -32,3 +34,41 @@ class EventScopedManagerTests(TestCase):
current_event.reset(token) current_event.reset(token)
self.assertEqual(results, [self.submission_a]) self.assertEqual(results, [self.submission_a])
class EventAvailabilityTests(TestCase):
def setUp(self):
from accounts.models import CustomUser
self.hidden = Event.objects.create(
name="Hidden", path="hidden", is_available=False
)
self.user = CustomUser.objects.create_user(username="user", password="x")
self.staff = CustomUser.objects.create_user(
username="staff", password="x", is_staff=True
)
def test_unavailable_event_is_404_for_regular_user(self):
self.client.force_login(self.user)
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}/")
self.assertEqual(response.status_code, 200)
def test_available_to_filters_for_regular_user_only(self):
self.assertNotIn(self.hidden, Event.objects.available_to(self.user))
self.assertIn(self.hidden, Event.objects.available_to(self.staff))
def test_home_prefers_available_event_for_staff(self):
available = Event.objects.create(name="Open", path="open")
Event.objects.filter(pk=self.hidden.pk).update(
created_at=available.created_at + timedelta(days=1)
)
self.client.force_login(self.staff)
response = self.client.get("/")
self.assertRedirects(
response, f"/{available.path}/", fetch_redirect_response=False
)
+24 -1
View File
@@ -8,6 +8,7 @@ from django_object_actions import (
from submissions.tasks import sync_event_game_scopes_task from submissions.tasks import sync_event_game_scopes_task
from .models import EventGame, Game from .models import EventGame, Game
from .tasks import refresh_steam_games_task
class EventGameInline(admin.TabularInline): class EventGameInline(admin.TabularInline):
@@ -19,8 +20,30 @@ class EventGameInline(admin.TabularInline):
class GameAdmin(admin.ModelAdmin): class GameAdmin(admin.ModelAdmin):
list_display = ["name", "steam_app_id", "updated_at"] list_display = ["name", "steam_app_id", "updated_at"]
search_fields = ["name", "steam_app_id"] search_fields = ["name", "steam_app_id"]
readonly_fields = ["created_at", "updated_at"] readonly_fields = [
"created_at",
"updated_at",
"steam_price_initial",
"steam_price_final",
"steam_discount_percent",
"steam_currency",
"steam_is_free",
"steam_short_description",
"steam_header_image",
"steam_info_updated_at",
]
inlines = [EventGameInline] inlines = [EventGameInline]
actions = ["refresh_steam_info"]
@admin.action(description="Refresh Steam store info")
def refresh_steam_info(self, request, queryset):
game_ids = list(
queryset.filter(steam_app_id__isnull=False).values_list("id", flat=True)
)
refresh_steam_games_task.enqueue(game_ids=game_ids, reschedule=False)
self.message_user(
request, f"Queued Steam store refresh for {len(game_ids)} game(s)."
)
@admin.register(EventGame) @admin.register(EventGame)
+12 -1
View File
@@ -3,7 +3,8 @@ from typing import List
from ninja import Router from ninja import Router
from .models import Game from .models import Game
from .schemas import GameOut from .schemas import GameOut, SteamGameOut
from .tasks import schedule_steam_refresh
router = Router() router = Router()
event_router = Router() event_router = Router()
@@ -17,3 +18,13 @@ def list_games(request):
@event_router.get("/events/{event_path}/games", response=List[GameOut]) @event_router.get("/events/{event_path}/games", response=List[GameOut])
def list_event_games(request, event_path: str): def list_event_games(request, event_path: str):
return Game.objects.enabled_for_event(request.event) return Game.objects.enabled_for_event(request.event)
@event_router.get("/events/{event_path}/games/steam", response=List[SteamGameOut])
def list_event_steam_games(request, event_path: str):
"""The event's Steam games with their store info (price, discount, blurb)."""
# Safety net in case the periodic refresh loop isn't running (no-op otherwise).
schedule_steam_refresh()
return Game.objects.enabled_for_event(request.event).filter(
steam_app_id__isnull=False
)
@@ -0,0 +1,13 @@
from django.core.management.base import BaseCommand
from games.tasks import schedule_steam_refresh
class Command(BaseCommand):
help = "Start the periodic Steam store refresh loop (no-op if already running)"
def handle(self, *args, **options):
if schedule_steam_refresh():
self.stdout.write(self.style.SUCCESS("Steam store refresh scheduled"))
else:
self.stdout.write("Steam store refresh already scheduled")
@@ -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=""),
),
]
@@ -0,0 +1,52 @@
# Generated by Django 6.0.5 on 2026-10-15 22:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0009_game_description"),
]
operations = [
migrations.AddField(
model_name="game",
name="steam_currency",
field=models.CharField(blank=True, default="", max_length=3),
),
migrations.AddField(
model_name="game",
name="steam_discount_percent",
field=models.PositiveSmallIntegerField(default=0),
),
migrations.AddField(
model_name="game",
name="steam_header_image",
field=models.URLField(blank=True, default="", max_length=500),
),
migrations.AddField(
model_name="game",
name="steam_info_updated_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="game",
name="steam_is_free",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="game",
name="steam_price_final",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="game",
name="steam_price_initial",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="game",
name="steam_short_description",
field=models.TextField(blank=True, default=""),
),
]
+12
View File
@@ -14,6 +14,18 @@ class Game(models.Model):
path = models.CharField(max_length=100) path = models.CharField(max_length=100)
custom_path = models.BooleanField(default=False) custom_path = models.BooleanField(default=False)
image = models.URLField(null=True, blank=True) image = models.URLField(null=True, blank=True)
description = models.TextField(blank=True, default="")
# Steam store info, refreshed in the background (see games.tasks).
# Prices are in cents, in steam_currency.
steam_price_initial = models.PositiveIntegerField(null=True, blank=True)
steam_price_final = models.PositiveIntegerField(null=True, blank=True)
steam_discount_percent = models.PositiveSmallIntegerField(default=0)
steam_currency = models.CharField(max_length=3, blank=True, default="")
steam_is_free = models.BooleanField(default=False)
steam_short_description = models.TextField(blank=True, default="")
steam_header_image = models.URLField(max_length=500, blank=True, default="")
steam_info_updated_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
+61
View File
@@ -1,3 +1,4 @@
from datetime import datetime
from typing import Optional from typing import Optional
from ninja import Schema from ninja import Schema
@@ -10,3 +11,63 @@ class GameOut(Schema):
path: str path: str
custom_path: bool custom_path: bool
image: Optional[str] = None image: Optional[str] = None
description: str
@staticmethod
def resolve_description(obj) -> str:
# The admin one wins, Steam games fall back to their store blurb
return obj.description or obj.steam_short_description
class SteamGameOut(Schema):
id: int
name: str
steam_app_id: int
store_url: str
header_image: str
short_description: str
is_free: bool
currency: str
price_initial: Optional[int] = None
price_final: Optional[int] = None
discount_percent: int
updated_at: Optional[datetime] = None
@staticmethod
def resolve_store_url(obj) -> str:
return f"https://store.steampowered.com/app/{obj.steam_app_id}"
@staticmethod
def resolve_header_image(obj) -> str:
return (
obj.steam_header_image
or f"https://cdn.akamai.steamstatic.com/steam/apps/{obj.steam_app_id}/header.jpg"
)
@staticmethod
def resolve_short_description(obj) -> str:
return obj.steam_short_description
@staticmethod
def resolve_is_free(obj) -> bool:
return obj.steam_is_free
@staticmethod
def resolve_currency(obj) -> str:
return obj.steam_currency
@staticmethod
def resolve_price_initial(obj) -> Optional[int]:
return obj.steam_price_initial
@staticmethod
def resolve_price_final(obj) -> Optional[int]:
return obj.steam_price_final
@staticmethod
def resolve_discount_percent(obj) -> int:
return obj.steam_discount_percent
@staticmethod
def resolve_updated_at(obj) -> Optional[datetime]:
return obj.steam_info_updated_at
@@ -0,0 +1,79 @@
import logging
import httpx
from django.conf import settings
from django.utils import timezone
from ..models import Game
logger = logging.getLogger(__name__)
# Public store endpoint, no API key needed. Rate limited to ~200 calls / 5 min,
# which is plenty for a handful of games every few hours.
APP_DETAILS_URL = "https://store.steampowered.com/api/appdetails"
class SteamStoreError(Exception):
"""Raised when the Steam store didn't return usable details for an app."""
def fetch_app_details(steam_app_id: int) -> dict:
response = httpx.get(
APP_DETAILS_URL,
params={
"appids": steam_app_id,
"cc": settings.STEAM_STORE_COUNTRY,
"l": "en",
"filters": "basic,price_overview",
},
timeout=10,
)
response.raise_for_status()
entry = (response.json() or {}).get(str(steam_app_id)) or {}
if not entry.get("success"):
raise SteamStoreError(f"No store details for app {steam_app_id}")
return entry["data"]
def refresh_steam_game(game: Game) -> None:
"""Pull the current store info for a Steam game into its steam_* fields."""
data = fetch_app_details(game.steam_app_id)
price = data.get("price_overview") or {}
game.steam_is_free = bool(data.get("is_free"))
game.steam_price_initial = price.get("initial")
game.steam_price_final = price.get("final")
game.steam_discount_percent = price.get("discount_percent") or 0
game.steam_currency = price.get("currency") or ""
game.steam_short_description = data.get("short_description") or ""
game.steam_header_image = data.get("header_image") or ""
game.steam_info_updated_at = timezone.now()
game.save(
update_fields=[
"steam_is_free",
"steam_price_initial",
"steam_price_final",
"steam_discount_percent",
"steam_currency",
"steam_short_description",
"steam_header_image",
"steam_info_updated_at",
"updated_at",
]
)
def refresh_steam_games(game_ids: list[int] | None = None) -> None:
"""Refresh every Steam game (or just game_ids). A failing game keeps its
previous info and doesn't stop the others."""
games = Game.objects.filter(steam_app_id__isnull=False)
if game_ids is not None:
games = games.filter(id__in=game_ids)
for game in games:
try:
refresh_steam_game(game)
except (httpx.HTTPError, ValueError, SteamStoreError) as exc:
logger.warning("Steam store refresh failed for %s: %s", game, exc)
+52
View File
@@ -0,0 +1,52 @@
from datetime import timedelta
from django.conf import settings
from django.core.cache import cache
from django.tasks import task
from django.utils import timezone
from .services.steam_store import refresh_steam_games
# Set while a periodic refresh is queued, so there's only ever one loop running,
# whatever the number of workers/containers trying to start it. It expires on
# its own if the loop dies (worker killed mid-job, Redis queue flushed, ...),
# letting the next schedule_steam_refresh() start a new one.
STEAM_REFRESH_GUARD_KEY = "games:steam_refresh:scheduled"
def steam_refresh_interval() -> timedelta:
return timedelta(minutes=settings.STEAM_STORE_REFRESH_MINUTES)
def _guard_timeout() -> int:
return int(steam_refresh_interval().total_seconds() * 2)
def schedule_steam_refresh() -> bool:
"""Start the periodic refresh loop right away, unless one is already queued.
Safe to call from anywhere, any number of times."""
if not cache.add(STEAM_REFRESH_GUARD_KEY, 1, timeout=_guard_timeout()):
return False
refresh_steam_games_task.enqueue()
return True
@task
def refresh_steam_games_task(
game_ids: list[int] | None = None, reschedule: bool = True
):
"""Refresh Steam store info, then re-enqueue itself every
STEAM_STORE_REFRESH_MINUTES (same self-rescheduling idea as
sync_event_game_scopes_task, no external scheduler needed).
reschedule=False is a one-off run (e.g. the admin action) that leaves the
periodic loop alone."""
try:
refresh_steam_games(game_ids)
finally:
if reschedule:
cache.set(STEAM_REFRESH_GUARD_KEY, 1, timeout=_guard_timeout())
refresh_steam_games_task.using(
run_after=timezone.now() + steam_refresh_interval()
).enqueue()
+157
View File
@@ -0,0 +1,157 @@
from unittest import mock
import httpx
from django.core.cache import cache
from django.test import TestCase, override_settings
from accounts.models import CustomUser
from events.models import Event
from .models import EventGame, Game
from .services.steam_store import refresh_steam_games
from .tasks import refresh_steam_games_task, schedule_steam_refresh
LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
def steam_response(app_id, data, success=True):
return httpx.Response(
200,
json={str(app_id): {"success": success, "data": data}},
request=httpx.Request("GET", "https://store.steampowered.com"),
)
class RefreshSteamGamesTests(TestCase):
def setUp(self):
self.game = Game.objects.create(name="Noita", path="noita", steam_app_id=881100)
@mock.patch("games.services.steam_store.httpx.get")
def test_stores_price_and_discount(self, get):
get.return_value = steam_response(
881100,
{
"is_free": False,
"short_description": "Every pixel is simulated.",
"header_image": "https://example.com/header.jpg",
"price_overview": {
"currency": "CHF",
"initial": 1950,
"final": 780,
"discount_percent": 60,
},
},
)
refresh_steam_games()
self.game.refresh_from_db()
self.assertEqual(self.game.steam_price_initial, 1950)
self.assertEqual(self.game.steam_price_final, 780)
self.assertEqual(self.game.steam_discount_percent, 60)
self.assertEqual(self.game.steam_currency, "CHF")
self.assertEqual(self.game.steam_short_description, "Every pixel is simulated.")
self.assertIsNotNone(self.game.steam_info_updated_at)
@mock.patch("games.services.steam_store.httpx.get")
def test_free_game_has_no_price(self, get):
get.return_value = steam_response(881100, {"is_free": True})
refresh_steam_games()
self.game.refresh_from_db()
self.assertTrue(self.game.steam_is_free)
self.assertIsNone(self.game.steam_price_final)
@mock.patch("games.services.steam_store.httpx.get")
def test_failure_keeps_previous_info(self, get):
Game.objects.filter(pk=self.game.pk).update(steam_price_final=999)
get.side_effect = httpx.ConnectError("down")
refresh_steam_games()
self.game.refresh_from_db()
self.assertEqual(self.game.steam_price_final, 999)
@override_settings(CACHES=LOCMEM_CACHE)
class ScheduleSteamRefreshTests(TestCase):
def setUp(self):
cache.clear()
@mock.patch("games.tasks.refresh_steam_games_task")
def test_only_one_loop_is_scheduled(self, task):
self.assertTrue(schedule_steam_refresh())
self.assertFalse(schedule_steam_refresh())
task.enqueue.assert_called_once_with()
@mock.patch("games.tasks.refresh_steam_games")
def test_task_reschedules_itself_even_on_failure(self, refresh):
refresh.side_effect = RuntimeError("boom")
with mock.patch("games.tasks.refresh_steam_games_task") as patched:
with self.assertRaises(RuntimeError):
refresh_steam_games_task.call()
patched.using.return_value.enqueue.assert_called_once_with()
@mock.patch("games.tasks.refresh_steam_games")
def test_one_off_run_does_not_reschedule(self, refresh):
with mock.patch("games.tasks.refresh_steam_games_task") as patched:
refresh_steam_games_task.call(game_ids=[1], reschedule=False)
refresh.assert_called_once_with([1])
patched.using.assert_not_called()
@override_settings(CACHES=LOCMEM_CACHE)
class EventSteamGamesApiTests(TestCase):
def setUp(self):
self.event = Event.objects.create(name="Event", path="event")
noita = Game.objects.create(
name="Noita", path="noita", steam_app_id=881100, steam_price_final=780
)
clock = Game.objects.create(name="Clock", path="clock", custom_path=True)
EventGame.objects.create(event=self.event, game=noita)
EventGame.objects.create(event=self.event, game=clock)
user = CustomUser.objects.create_user(username="user", password="x")
self.client.force_login(user)
@mock.patch("games.api.schedule_steam_refresh")
def test_lists_only_steam_games_with_store_info(self, schedule):
response = self.client.get("/api/events/event/games/steam")
self.assertEqual(response.status_code, 200)
[game] = response.json()
self.assertEqual(game["name"], "Noita")
self.assertEqual(game["price_final"], 780)
self.assertEqual(game["store_url"], "https://store.steampowered.com/app/881100")
schedule.assert_called_once()
class EventGamesDescriptionTests(TestCase):
def setUp(self):
self.event = Event.objects.create(name="Event", path="event")
self.user = CustomUser.objects.create_user(username="user", password="x")
self.client.force_login(self.user)
def test_steam_description_is_fallback_for_admin_one(self):
steam = Game.objects.create(
name="Noita",
path="noita",
steam_app_id=881100,
steam_short_description="From Steam",
)
custom = Game.objects.create(
name="Opus",
path="opus",
steam_app_id=558990,
description="From admin",
steam_short_description="From Steam",
)
EventGame.objects.create(event=self.event, game=steam)
EventGame.objects.create(event=self.event, game=custom)
response = self.client.get("/api/events/event/games")
descriptions = {g["name"]: g["description"] for g in response.json()}
self.assertEqual(descriptions, {"Noita": "From Steam", "Opus": "From admin"})
@@ -151,14 +151,20 @@ class MarketUser(HttpUser):
@task(5) @task(5)
def place_bet(self): def place_bet(self):
open_markets = [m for m in self.markets if m["status"] == "open" and m["options"]] open_markets = [
m for m in self.markets if m["status"] == "open" and m["options"]
]
if not open_markets or self.points < MIN_BET_INCREMENT: if not open_markets or self.points < MIN_BET_INCREMENT:
return return
market = random.choice(open_markets) market = random.choice(open_markets)
existing = self.bets.get(market["uuid"]) existing = self.bets.get(market["uuid"])
option = existing["option"] if existing else random.choice(market["options"])["uuid"] option = (
increment = random.randint(MIN_BET_INCREMENT, min(MAX_BET_INCREMENT, self.points)) existing["option"] if existing else random.choice(market["options"])["uuid"]
)
increment = random.randint(
MIN_BET_INCREMENT, min(MAX_BET_INCREMENT, self.points)
)
amount = (existing["amount"] if existing else 0) + increment amount = (existing["amount"] if existing else 0) + increment
with self.client.post( with self.client.post(
+9
View File
@@ -1,11 +1,13 @@
from typing import List from typing import List
from ninja import Router from ninja import Router
from ninja.decorators import decorate_view
from ninja.errors import HttpError from ninja.errors import HttpError
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.db.models import Sum, Prefetch from django.db.models import Sum, Prefetch
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.db import transaction from django.db import transaction
from games.decorators import require_game_enabled
from market.models import ( from market.models import (
Market, Market,
MarketOption, MarketOption,
@@ -29,6 +31,7 @@ PREFIX = "/events/{event_path}/market"
@router.get(f"{PREFIX}/", response=List[MarketListSchema]) @router.get(f"{PREFIX}/", response=List[MarketListSchema])
@decorate_view(require_game_enabled(path="market"))
def list_markets(request, event_path: str): def list_markets(request, event_path: str):
"""List all markets (excludes draft markets).""" """List all markets (excludes draft markets)."""
markets = Market.for_event.exclude(status=Market.Status.DRAFT) 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]) @router.get(f"{PREFIX}/user/bets", response=List[UserBetSchema])
@decorate_view(require_game_enabled(path="market"))
def list_user_bets(request, event_path: str): def list_user_bets(request, event_path: str):
"""List all bets placed by the current user.""" """List all bets placed by the current user."""
if not request.user.is_authenticated: 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) @router.get(f"{PREFIX}/user/points", response=UserPointsSchema)
@decorate_view(require_game_enabled(path="market"))
def get_user_points(request, event_path: str): def get_user_points(request, event_path: str):
"""Get the current user's points balance for this event.""" """Get the current user's points balance for this event."""
if not request.user.is_authenticated: 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]) @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): def bet_history(request, event_path: str, market_uuid: str):
"""Points added to each option over time (anonymous, one entry per bet placed/increased).""" """Points added to each option over time (anonymous, one entry per bet placed/increased)."""
market = get_object_or_404( 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") @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): def close_market(request, event_path: str, market_uuid: str):
"""Close a market. Admin only.""" """Close a market. Admin only."""
if not request.user.is_staff: 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) @router.post(f"{PREFIX}/{{market_uuid}}/actions/resolve", response=MarketListSchema)
@decorate_view(require_game_enabled(path="market"))
def resolve_market( def resolve_market(
request, event_path: str, market_uuid: str, payload: ResolveMarketSchema 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) @router.post(f"{PREFIX}/{{market_uuid}}/bets", response=UserBetSchema)
@decorate_view(require_game_enabled(path="market"))
def create_bet( def create_bet(
request, event_path: str, market_uuid: str, payload: UserBetCreateSchema request, event_path: str, market_uuid: str, payload: UserBetCreateSchema
): ):
@@ -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),
]
+23 -1
View File
@@ -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)
+2
View File
@@ -3,9 +3,11 @@ from django.shortcuts import get_object_or_404, render
from django.http import HttpRequest from django.http import HttpRequest
from events.models import Event from events.models import Event
from games.decorators import require_game_enabled
@login_required @login_required
@require_game_enabled(path="market")
def market_home(request: HttpRequest, event_path: str): def market_home(request: HttpRequest, event_path: str):
get_object_or_404(Event, path=event_path) get_object_or_404(Event, path=event_path)
return render(request, "app.html") return render(request, "app.html")
+5 -3
View File
@@ -16,7 +16,9 @@ from .wire import encode, to_epoch_ms
class PlaceConsumer(AsyncWebsocketConsumer): class PlaceConsumer(AsyncWebsocketConsumer):
async def connect(self): async def connect(self):
self.event_path = self.scope["url_route"]["kwargs"]["event_path"] self.event_path = self.scope["url_route"]["kwargs"]["event_path"]
self.event = await self._get_enabled_event(self.event_path) self.event = await self._get_enabled_event(
self.event_path, self.scope.get("user")
)
if self.event is None: if self.event is None:
await self.close() await self.close()
return return
@@ -80,8 +82,8 @@ class PlaceConsumer(AsyncWebsocketConsumer):
) )
@database_sync_to_async @database_sync_to_async
def _get_enabled_event(self, event_path): def _get_enabled_event(self, event_path, user):
event = Event.objects.filter(path=event_path).first() event = Event.objects.available_to(user).filter(path=event_path).first()
if event is None: if event is None:
return None return None
if not Game.objects.enabled_for_event(event).filter(path="place").exists(): if not Game.objects.enabled_for_event(event).filter(path="place").exists():
+1 -15
View File
@@ -1,5 +1,4 @@
from ninja import NinjaAPI, Schema from ninja import NinjaAPI
from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.http import HttpRequest from django.http import HttpRequest
from opus_magnum.api import router as opus_magnum_router from opus_magnum.api import router as opus_magnum_router
@@ -54,8 +53,6 @@ api.add_router("", games_event_router, tags=["games"])
api.add_router("", submissions_router, tags=["submissions"]) api.add_router("", submissions_router, tags=["submissions"])
api.add_router("", clock_router, tags=["clock"]) api.add_router("", clock_router, tags=["clock"])
api.add_router("", place_router, tags=["place"]) 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"])
@@ -66,17 +63,6 @@ def health_check(request):
return {"status": "healthy", "service": "polylan-submitter-api"} 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 # Cache management endpoint
@api.post("/cache/clear") @api.post("/cache/clear")
def clear_cache(request: HttpRequest, event_path: str | None = None): def clear_cache(request: HttpRequest, event_path: str | None = None):
+3 -3
View File
@@ -17,11 +17,11 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings")
django_asgi_app = get_asgi_application() django_asgi_app = get_asgi_application()
from submissions.routing import ( from submissions.routing import ( # noqa: E402
websocket_urlpatterns as submissions_websocket_urlpatterns, websocket_urlpatterns as submissions_websocket_urlpatterns,
) )
from clock.routing import websocket_urlpatterns as clock_websocket_urlpatterns from clock.routing import websocket_urlpatterns as clock_websocket_urlpatterns # noqa: E402
from place.routing import websocket_urlpatterns as place_websocket_urlpatterns from place.routing import websocket_urlpatterns as place_websocket_urlpatterns # noqa: E402
websocket_urlpatterns = [ websocket_urlpatterns = [
*submissions_websocket_urlpatterns, *submissions_websocket_urlpatterns,
@@ -231,6 +231,12 @@ LOAD_TEST_LOGIN_ENABLED = (
# Steam API Configuration # Steam API Configuration
STEAM_API_KEY = os.environ.get("STEAM_API_KEY") or None # Set via environment variable STEAM_API_KEY = os.environ.get("STEAM_API_KEY") or None # Set via environment variable
# Steam store info (price, discount, blurb) for the event's Steam games, refreshed
# in the background every STEAM_STORE_REFRESH_MINUTES (see games.tasks)
STEAM_STORE_REFRESH_MINUTES = int(os.environ.get("STEAM_STORE_REFRESH_MINUTES") or 360)
# Store region the prices are fetched for (ISO country code, drives the currency)
STEAM_STORE_COUNTRY = os.environ.get("STEAM_STORE_COUNTRY") or "ch"
# Base URL of the legacy PolyLAN site, used to sync AAndT matches/members # Base URL of the legacy PolyLAN site, used to sync AAndT matches/members
# (see submissions.services.aandt_sync) # (see submissions.services.aandt_sync)
AANDT_API_BASE_URL = os.environ.get("AANDT_API_BASE_URL") or "https://polylan.ch" AANDT_API_BASE_URL = os.environ.get("AANDT_API_BASE_URL") or "https://polylan.ch"
@@ -307,9 +313,6 @@ ALLOWED_SUBMISSION_TYPES = [
"video/webm", "video/webm",
] ]
# Market app settings
MARKET_ENABLED = (os.environ.get("MARKET_ENABLED") or "true").lower() == "true"
# Authentication backends # Authentication backends
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
+10 -1
View File
@@ -36,7 +36,9 @@ OPUS_MAGNUM_APP_ID = 558990
@login_required @login_required
def home(request: HttpRequest): def home(request: HttpRequest):
event = Event.objects.order_by("-created_at").first() # Staff land on an available event too; hidden ones are only a fallback.
events = Event.objects.available_to(request.user)
event = events.order_by("-is_available", "-created_at").first()
if event is None: if event is None:
raise Http404("No event configured") raise Http404("No event configured")
return redirect("event.home", event_path=event.path) return redirect("event.home", event_path=event.path)
@@ -48,6 +50,12 @@ def event_home(request: HttpRequest, event_path: str):
return render(request, "app.html") return render(request, "app.html")
@login_required
def games_home(request: HttpRequest, event_path: str):
get_object_or_404(Event, path=event_path)
return render(request, "app.html")
@login_required @login_required
@require_game_enabled(OPUS_MAGNUM_APP_ID) @require_game_enabled(OPUS_MAGNUM_APP_ID)
def opus_magnum_home(request: HttpRequest, event_path: str): def opus_magnum_home(request: HttpRequest, event_path: str):
@@ -93,6 +101,7 @@ urlpatterns = [
path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"), path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"),
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"), path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
path("api/", api.urls), path("api/", api.urls),
path("<slug:event_path>/games", games_home, name="games.home"),
path("<slug:event_path>/market", market_home, name="market.home"), path("<slug:event_path>/market", market_home, name="market.home"),
path("<slug:event_path>/opus-magnum", opus_magnum_home, name="opus-magnum.home"), path("<slug:event_path>/opus-magnum", opus_magnum_home, name="opus-magnum.home"),
path("<slug:event_path>/noita", noita_home, name="noita.home"), path("<slug:event_path>/noita", noita_home, name="noita.home"),
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core";
import { gamesApiListEventSteamGames } from "./api";
import type { SteamGameOut } from "./api/types.gen";
import SteamStoreCard from "./components/SteamStoreCard.vue";
interface Props {
eventPath: string;
}
const props = defineProps<Props>();
const { t } = useI18n();
useTitle(computed(() => t("games.title")));
const games = ref<SteamGameOut[]>([]);
const loading = ref(true);
onMounted(async () => {
const response = await gamesApiListEventSteamGames({
path: { event_path: props.eventPath },
});
if (response.data) {
games.value = response.data;
}
loading.value = false;
});
</script>
<template>
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
<div class="w-full max-w-6xl">
<!-- Header -->
<div class="text-center mb-12">
<h1 class="text-5xl font-bold mb-4">{{ t("games.title") }}</h1>
<p class="text-xl text-base-content/70">{{ t("games.subtitle") }}</p>
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span>
</div>
<p v-else-if="!games.length" class="text-center text-base-content/50 py-20">
{{ t("games.empty") }}
</p>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8">
<SteamStoreCard v-for="game in games" :key="game.id" :game="game" />
</div>
</div>
</div>
</template>
+14 -32
View File
@@ -3,7 +3,7 @@ import { ref, onMounted, computed } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import { gamesApiListEventGames, polylanSubmitterApiGetConfig } from "./api"; import { gamesApiListEventGames } from "./api";
import type { GamesApiListEventGamesResponse } from "./api/types.gen"; import type { GamesApiListEventGamesResponse } from "./api/types.gen";
interface Props { interface Props {
@@ -18,8 +18,8 @@ useTitle(computed(() => t("home.title")));
const games = ref<GamesApiListEventGamesResponse | undefined>(); const games = ref<GamesApiListEventGamesResponse | undefined>();
const loading = ref(true); const loading = ref(true);
const hasSteamGames = computed(() => games.value?.some((game) => game.steam_app_id) ?? false);
const imageErrors = ref<Set<number>>(new Set()); const imageErrors = ref<Set<number>>(new Set());
const marketEnabled = ref(false);
const getHeaderImage = (game: GamesApiListEventGamesResponse[number]) => { const getHeaderImage = (game: GamesApiListEventGamesResponse[number]) => {
if (game.image) { if (game.image) {
@@ -35,26 +35,18 @@ const onImageError = (gameId: number) => {
imageErrors.value.add(gameId); imageErrors.value.add(gameId);
}; };
const goToMarket = () => {
router.push(`/${props.eventPath}/market`);
};
const goToGame = (game: GamesApiListEventGamesResponse[number]) => { const goToGame = (game: GamesApiListEventGamesResponse[number]) => {
const path = game.custom_path ? `/${game.path}` : `/submissions/${game.path}`; const path = game.custom_path ? `/${game.path}` : `/submissions/${game.path}`;
router.push(`/${props.eventPath}${path}`); router.push(`/${props.eventPath}${path}`);
}; };
onMounted(async () => { onMounted(async () => {
const [gamesResponse, configResponse] = await Promise.all([ const gamesResponse = await gamesApiListEventGames({
gamesApiListEventGames({ path: { event_path: props.eventPath } }), path: { event_path: props.eventPath },
polylanSubmitterApiGetConfig(), });
]);
if (gamesResponse.data) { if (gamesResponse.data) {
games.value = gamesResponse.data; games.value = gamesResponse.data;
} }
if (configResponse.data) {
marketEnabled.value = configResponse.data.market_enabled;
}
loading.value = false; loading.value = false;
}); });
</script> </script>
@@ -77,25 +69,6 @@ onMounted(async () => {
<!-- Cards Grid --> <!-- Cards Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8"> <div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Market Card -->
<div v-if="marketEnabled" @click="goToMarket"
class="card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden">
<figure class="relative h-60 bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center">
<i class="mdi mdi-chart-box text-6xl text-white opacity-80"></i>
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"></div>
</figure>
<div class="card-body">
<h2 class="card-title text-2xl">{{ t("home.marketCard.title") }}</h2>
<p class="text-base-content/70">{{ t("home.marketCard.description") }}</p>
<div class="card-actions justify-end mt-4">
<button class="btn btn-primary">
<i class="mdi mdi-arrow-right mr-2"></i>
{{ t("home.marketCard.cta") }}
</button>
</div>
</div>
</div>
<!-- Game Cards --> <!-- Game Cards -->
<div v-for="game in games" :key="game.id" <div v-for="game in games" :key="game.id"
@click="goToGame(game)" @click="goToGame(game)"
@@ -111,6 +84,7 @@ onMounted(async () => {
</figure> </figure>
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-2xl">{{ game.name }}</h2> <h2 class="card-title text-2xl">{{ game.name }}</h2>
<p v-if="game.description" class="text-sm text-base-content/70 line-clamp-2" :title="game.description">{{ game.description }}</p>
<div class="card-actions justify-end mt-4"> <div class="card-actions justify-end mt-4">
<button class="btn btn-primary"> <button class="btn btn-primary">
<i class="mdi mdi-arrow-right mr-2"></i> <i class="mdi mdi-arrow-right mr-2"></i>
@@ -121,6 +95,14 @@ onMounted(async () => {
</div> </div>
</div> </div>
<!-- Link to the Steam games page -->
<div v-if="!loading && hasSteamGames" class="text-center mt-12">
<RouterLink :to="{ name: 'games', params: { eventPath } }" class="btn btn-outline">
<i class="mdi mdi-steam mr-2"></i>
{{ t("home.getTheGames") }}
</RouterLink>
</div>
<!-- Footer --> <!-- Footer -->
<div class="text-center mt-12 text-base-content/50"> <div class="text-center mt-12 text-base-content/50">
<p>{{ t("home.footer") }}</p> <p>{{ t("home.footer") }}</p>
File diff suppressed because one or more lines are too long
+8 -6
View File
@@ -2,7 +2,7 @@
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client'; import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
import { client } from './client.gen'; import { client } from './client.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, 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, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetConfigData, PolylanSubmitterApiGetConfigResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen'; import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, 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, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/** /**
@@ -25,11 +25,6 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
*/ */
export const polylanSubmitterApiHealthCheck = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiHealthCheckData, ThrowOnError>) => (options?.client ?? client).get<PolylanSubmitterApiHealthCheckResponses, unknown, ThrowOnError>({ url: '/api/health', ...options }); export const polylanSubmitterApiHealthCheck = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiHealthCheckData, ThrowOnError>) => (options?.client ?? client).get<PolylanSubmitterApiHealthCheckResponses, unknown, ThrowOnError>({ url: '/api/health', ...options });
/**
* Get Config
*/
export const polylanSubmitterApiGetConfig = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiGetConfigData, ThrowOnError>) => (options?.client ?? client).get<PolylanSubmitterApiGetConfigResponses, unknown, ThrowOnError>({ url: '/api/config', ...options });
/** /**
* Clear Cache * Clear Cache
* *
@@ -201,6 +196,13 @@ export const noitaApiSubmitLogFile = <ThrowOnError extends boolean = false>(opti
*/ */
export const gamesApiListEventGames = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventGamesData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventGamesResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/games', ...options }); export const gamesApiListEventGames = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventGamesData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventGamesResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/games', ...options });
/**
* List Event Steam Games
*
* The event's Steam games with their store info (price, discount, blurb).
*/
export const gamesApiListEventSteamGames = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventSteamGamesData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventSteamGamesResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/games/steam', ...options });
/** /**
* List My Scopes * List My Scopes
*/ */
+81 -26
View File
@@ -4,16 +4,6 @@ export type ClientOptions = {
baseUrl: string; baseUrl: string;
}; };
/**
* ConfigOut
*/
export type ConfigOut = {
/**
* Market Enabled
*/
market_enabled: boolean;
};
/** /**
* UserInfoOut * UserInfoOut
* *
@@ -90,6 +80,10 @@ export type GameOut = {
* Image * Image
*/ */
image?: string | null; image?: string | null;
/**
* Description
*/
description: string;
}; };
/** /**
@@ -930,6 +924,60 @@ export type NoitaSubmissionOut = {
processed: boolean; processed: boolean;
}; };
/**
* SteamGameOut
*/
export type SteamGameOut = {
/**
* Id
*/
id: number;
/**
* Name
*/
name: string;
/**
* Steam App Id
*/
steam_app_id: number;
/**
* Store Url
*/
store_url: string;
/**
* Header Image
*/
header_image: string;
/**
* Short Description
*/
short_description: string;
/**
* Is Free
*/
is_free: boolean;
/**
* Currency
*/
currency: string;
/**
* Price Initial
*/
price_initial?: number | null;
/**
* Price Final
*/
price_final?: number | null;
/**
* Discount Percent
*/
discount_percent: number;
/**
* Updated At
*/
updated_at?: string | null;
};
/** /**
* ScopeOut * ScopeOut
*/ */
@@ -1219,22 +1267,6 @@ export type PolylanSubmitterApiHealthCheckResponses = {
200: unknown; 200: unknown;
}; };
export type PolylanSubmitterApiGetConfigData = {
body?: never;
path?: never;
query?: never;
url: '/api/config';
};
export type PolylanSubmitterApiGetConfigResponses = {
/**
* OK
*/
200: ConfigOut;
};
export type PolylanSubmitterApiGetConfigResponse = PolylanSubmitterApiGetConfigResponses[keyof PolylanSubmitterApiGetConfigResponses];
export type PolylanSubmitterApiClearCacheData = { export type PolylanSubmitterApiClearCacheData = {
body?: never; body?: never;
path?: never; path?: never;
@@ -1737,6 +1769,29 @@ export type GamesApiListEventGamesResponses = {
export type GamesApiListEventGamesResponse = GamesApiListEventGamesResponses[keyof GamesApiListEventGamesResponses]; export type GamesApiListEventGamesResponse = GamesApiListEventGamesResponses[keyof GamesApiListEventGamesResponses];
export type GamesApiListEventSteamGamesData = {
body?: never;
path: {
/**
* Event Path
*/
event_path: string;
};
query?: never;
url: '/api/events/{event_path}/games/steam';
};
export type GamesApiListEventSteamGamesResponses = {
/**
* Response
*
* OK
*/
200: Array<SteamGameOut>;
};
export type GamesApiListEventSteamGamesResponse = GamesApiListEventSteamGamesResponses[keyof GamesApiListEventSteamGamesResponses];
export type SubmissionsApiListMyScopesData = { export type SubmissionsApiListMyScopesData = {
body?: never; body?: never;
path: { path: {
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import type { SteamGameOut } from "../api/types.gen";
const props = defineProps<{ game: SteamGameOut }>();
const { t } = useI18n();
// Steam gives prices in cents
const formatPrice = (cents: number) =>
new Intl.NumberFormat(undefined, {
style: "currency",
currency: props.game.currency || "CHF",
}).format(cents / 100);
const hasPrice = computed(() => props.game.price_final != null && !!props.game.currency);
const isDiscounted = computed(() => hasPrice.value && props.game.discount_percent > 0);
</script>
<template>
<div
class="steam-card flex flex-col overflow-hidden rounded-lg shadow-xl transition-transform hover:-translate-y-1">
<a :href="game.store_url" target="_blank" rel="noopener noreferrer">
<img :src="game.header_image" :alt="game.name" loading="lazy"
class="w-full aspect-[460/215] object-cover" />
</a>
<div class="flex flex-col flex-1 min-w-0 p-6 gap-3">
<a :href="game.store_url" target="_blank" rel="noopener noreferrer"
class="steam-title text-2xl font-semibold truncate hover:underline">
{{ t("games.buy", { game: game.name }) }}
</a>
<p v-if="game.short_description" class="steam-text line-clamp-3">
{{ game.short_description }}
</p>
<div class="mt-auto pt-2 flex flex-wrap justify-end gap-2">
<!-- Opens the Steam client: launches the game if installed, offers to install it otherwise -->
<a :href="`steam://run/${game.steam_app_id}`"
class="steam-play rounded-sm px-5 py-2 flex items-center gap-1">
<i class="mdi mdi-play"></i>
{{ t("games.play") }}
</a>
<div class="steam-purchase flex items-stretch rounded-sm overflow-hidden">
<template v-if="game.is_free">
<span class="steam-price px-3 py-2 flex items-center">{{ t("games.free") }}</span>
</template>
<template v-else-if="hasPrice">
<span v-if="isDiscounted" class="steam-discount px-3 flex items-center text-xl font-medium">
-{{ game.discount_percent }}%
</span>
<span class="steam-price px-3 flex flex-col justify-center leading-tight">
<span v-if="isDiscounted" class="steam-original text-xs line-through">
{{ formatPrice(game.price_initial!) }}
</span>
<span :class="{ 'steam-discounted': isDiscounted }">{{ formatPrice(game.price_final!) }}</span>
</span>
</template>
<a :href="game.store_url" target="_blank" rel="noopener noreferrer"
class="steam-button px-5 py-2 flex items-center gap-1">
<i class="mdi mdi-steam"></i>
{{ t("games.viewOnSteam") }}
</a>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Steam store palette, on purpose regardless of the site theme */
.steam-card {
background: linear-gradient(135deg, #1b2838 0%, #2a475e 100%);
}
.steam-title {
color: #ffffff;
}
.steam-text {
color: #c6d4df;
}
.steam-purchase {
background: rgba(0, 0, 0, 0.2);
}
.steam-discount {
background: #4c6b22;
color: #beee11;
}
.steam-price {
color: #c6d4df;
}
.steam-original {
color: #738895;
}
.steam-discounted {
color: #beee11;
}
.steam-button {
color: #d2efa9;
background: linear-gradient(to right, #75b022 5%, #588a1b 95%);
}
.steam-button:hover {
color: #ffffff;
background: linear-gradient(to right, #8ed629 5%, #6aa621 95%);
}
.steam-play {
color: #ffffff;
background: linear-gradient(to right, #47bfff 5%, #1a44c2 95%);
}
.steam-play:hover {
background: linear-gradient(to right, #6fceff 5%, #2d5fd8 95%);
}
</style>
+11 -5
View File
@@ -63,14 +63,20 @@
"home": { "home": {
"title": "PolyLAN Submitter", "title": "PolyLAN Submitter",
"subtitle": "Choose a game and submit your best solutions", "subtitle": "Choose a game and submit your best solutions",
"marketCard": {
"title": "Market",
"description": "Place your bets and compete",
"cta": "Place bets"
},
"submitResults": "Submit results", "submitResults": "Submit results",
"getTheGames": "Get the games",
"footer": "Select a game above to begin submitting" "footer": "Select a game above to begin submitting"
}, },
"games": {
"header": "Games",
"title": "Get the games",
"subtitle": "Don't own them yet? Grab them on Steam",
"empty": "No Steam games for this event",
"buy": "Buy {game}",
"free": "Free to Play",
"viewOnSteam": "View on Steam",
"play": "Play"
},
"clock": { "clock": {
"pageTitle": "Beat the Clock", "pageTitle": "Beat the Clock",
"header": "Beat the Clock", "header": "Beat the Clock",
+8
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory, type RouteLocationNormalizedLoaded, type RouteLocationRaw } from "vue-router"; import { createRouter, createWebHistory, type RouteLocationNormalizedLoaded, type RouteLocationRaw } from "vue-router";
import Clock from "@/Clock.vue"; import Clock from "@/Clock.vue";
import Games from "@/Games.vue";
import Home from "@/Home.vue"; import Home from "@/Home.vue";
import Market from "@/Market.vue"; import Market from "@/Market.vue";
import Noita from "@/Noita.vue"; import Noita from "@/Noita.vue";
@@ -37,6 +38,13 @@ export const router = createRouter({
component: Home, component: Home,
props: true, props: true,
}, },
{
path: "/:eventPath/games",
name: "games",
component: Games,
props: true,
meta: { titleKey: "games.header", getBackRoute: eventHome },
},
{ {
path: "/:eventPath/market", path: "/:eventPath/market",
name: "market", name: "market",