Compare commits

...
40 Commits
Author SHA1 Message Date
Legrems 3e91ef77b6 chore: vite build 2026-05-24 19:21:05 +02:00
Legrems 215d68a0e1 chore: vite build 2026-05-24 19:14:08 +02:00
Legrems 47812ffd09 feat(market): settings toggle 2026-05-24 19:11:02 +02:00
Legrems a5fe8aacaf feat(migration): frontend migration submissions -> opus-magnum 2026-05-24 18:54:14 +02:00
Legrems b437210eb3 feat(migration): old submissions -> opus magnum app 2026-05-24 18:51:01 +02:00
Legrems 5584e54b58 migrate to opus-magnum app 2026-05-24 18:48:14 +02:00
Legrems 35ea54ecea chore: market build app 2026-05-24 18:19:06 +02:00
Legrems 821e453bc0 feat(noita): stores 2026-05-24 09:29:30 +02:00
Legrems 9fd0122a67 chore: market app build 2026-05-24 09:24:56 +02:00
Legrems e557fe2cda feat(market): add draft status and multiplier 2026-05-24 09:20:01 +02:00
Legrems 79e7cef3ba feat(market): resolve for admin 2026-05-23 20:33:21 +02:00
Legrems a264336bd8 feat(market): track user points change 2026-05-23 20:26:59 +02:00
Legrems 42e3571fab fix(market): better market card display 2026-05-23 20:06:17 +02:00
Legrems 43b314bb20 fix(openapi-ts): replace api call with generated api client 2026-05-23 19:51:08 +02:00
Legrems f1afb2096f fix(openapi-ts): first pass for replacing with openapi-ts 2026-05-23 18:53:33 +02:00
Legrems 62a81e57ad feat(openapi-ts): add generated api client 2026-05-23 18:50:26 +02:00
Legrems 303b9e1c8a feat(frontend): add openapi-ts 2026-05-23 18:50:19 +02:00
Legrems f7c7eba4da feat(market): basic page + submit 2026-05-23 18:30:50 +02:00
Legrems ce30539808 feat(market): base models 2026-05-23 17:48:27 +02:00
Legrems 544112b204 feat(games): add games to disable + path 2026-05-23 14:18:44 +02:00
Legrems 9f94fb3974 fix: add puzzle title in results page 2026-05-22 17:24:29 +02:00
Legrems 25072e8eb4 cache for result api 2026-05-22 17:21:53 +02:00
Legrems 9e5ab8539a top winners + by puzzle 2026-05-22 09:32:13 +02:00
Legrems 4ba6a48246 fix ranking order + display winning gifs 2026-05-22 06:32:18 +02:00
Legrems 92dddca964 tournament outputs 2026-05-22 06:18:11 +02:00
Legrems bf21a5eae6 fix(noita): better display name 2026-05-15 21:11:23 +02:00
Legrems 89faa7cd40 chore: build assets 2026-05-15 20:47:22 +02:00
Legrems a800c7fff7 fix(noita): all objectives display 2026-05-15 20:43:30 +02:00
Legrems 2ee8cd6be3 chore: vite build 2026-05-15 14:51:05 +02:00
Legrems 35824de310 admin display 2026-05-15 14:50:59 +02:00
Legrems 65cc2e555f fix: parse + store 2026-05-15 03:31:20 +02:00
Legrems 6d996a4e2f chore: vite build 2026-05-15 03:26:35 +02:00
Legrems f774ff3340 feat(opus): add coef subcards 2026-05-15 03:24:07 +02:00
Legrems 779393106d fix(opus): wrong frontend ordering points -> rank-points 2026-05-15 03:05:33 +02:00
Legrems 3e04f8312a feat(noita): death counter + ranking display 2026-05-15 02:15:00 +02:00
Legrems 7cfab20826 rework noita objectives + deathcounter 2026-05-15 01:50:34 +02:00
Legrems 754b0b0803 noita update 2026-05-10 23:09:42 +02:00
Legrems 79f469a393 chore: build vite assets 2026-05-10 04:01:57 +02:00
Legrems 5aeff9c218 feat(api): add cache + busting for admin 2026-05-10 04:00:53 +02:00
Legrems 2264401a91 fix: correct mime type for noita log file, opus card status display 2026-05-10 03:47:37 +02:00
137 changed files with 8679 additions and 826 deletions
+3
View File
@@ -34,3 +34,6 @@ class CustomUserAdmin(UserAdmin):
return obj.get_cas_groups_display()
get_cas_groups_display.short_description = "CAS Groups"
def has_delete_permission(self, request, obj=None):
return False
@@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2026-05-23 18:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="customuser",
name="points",
field=models.IntegerField(default=1000),
),
]
+3
View File
@@ -14,6 +14,9 @@ class CustomUser(AbstractUser):
# Additional fields that might come from CAS
cas_attributes = models.JSONField(default=dict, blank=True)
# Market points balance
points = models.IntegerField(default=1000)
def __str__(self):
return f"{self.username} ({self.cas_user_id})"
+209 -6
View File
@@ -1,10 +1,23 @@
from collections import defaultdict
from django.http import HttpRequest
from ninja import Router
from ninja.errors import HttpError
from django.core.cache import cache
from django.shortcuts import get_object_or_404
from accounts.models import CustomUser
from animations.schemas import RankingSchema
from submissions.models import PuzzleResponse, SteamCollectionItem
from animations.schemas import (
RankingSchema,
TournamentSubmissionsOut,
TournamentPuzzleResultsOut,
PuzzleSubmissionsOut,
PuzzleResultsOut,
PuzzleSubmissionWithRankOut,
PuzzlePointsFactorOut,
WinnerResponseOut,
WinnerFileOut,
)
from opus_magnum.models import PuzzleResponse, SteamCollectionItem, SteamCollection
router = Router()
@@ -12,6 +25,12 @@ router = Router()
@router.get("results", response=RankingSchema)
def results(request: HttpRequest) -> dict:
cache_key = "api:results:results"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
responses_by_userid = defaultdict(list)
responses_by_puzzleid = defaultdict(list)
@@ -30,9 +49,193 @@ def results(request: HttpRequest) -> dict:
responses, key=lambda x: (x.rank_points is None, x.rank_points or 0)
)
return {
"users": CustomUser.objects.filter(pk__in=responses_by_userid.keys()),
"puzzles": SteamCollectionItem.objects.all(),
"responses_by_userid": responses_by_userid,
data = {
"users": list(CustomUser.objects.filter(pk__in=responses_by_userid.keys())),
"puzzles": list(SteamCollectionItem.objects.all()),
"responses_by_userid": dict(responses_by_userid),
"ranking_by_puzzle": ranking,
}
cache.set("api:results:results", data, 300)
return data
@router.get("top-submissions", response=TournamentSubmissionsOut)
def top_submissions(request: HttpRequest, 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}"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
collection = get_object_or_404(SteamCollection, is_active=True)
# Only allow access when tournament is closed
if collection.accepting_submissions:
raise HttpError(403, "Tournament is still accepting submissions")
# Get all puzzles
puzzles = SteamCollectionItem.objects.filter(collection=collection).order_by(
"order_index"
)
# Build response
submissions_list = []
for puzzle in puzzles:
# Get the top N responses for this puzzle (ranked by points, highest first)
top_responses = (
PuzzleResponse.objects.filter(puzzle=puzzle, needs_manual_validation=False)
.filter_user_best_response()
.annotate_rank_points()
.order_by("-rank_points")[:limit]
)
# Build submission list for this puzzle
puzzle_submissions = []
for response in top_responses:
# Get submission files
files = response.files.all()
response_files = [
WinnerFileOut(
file_url=file.file_url or "",
original_filename=file.original_filename,
)
for file in files
]
# Calculate total coefficient
total_coef = None
if (
puzzle.points_factor
and response.final_cost is not None
and response.final_cycles is not None
and response.final_area is not None
):
total_coef = (
puzzle.points_factor.cost * response.final_cost
+ puzzle.points_factor.cycles * response.final_cycles
+ puzzle.points_factor.area * response.final_area
)
submission_data = WinnerResponseOut(
user_id=response.submission.user.id if response.submission.user else 0,
username=response.submission.user.username
if response.submission.user
else "Anonymous",
final_cost=response.final_cost,
final_cycles=response.final_cycles,
final_area=response.final_area,
rank_points=response.rank_points,
total_coef=total_coef,
files=response_files,
)
puzzle_submissions.append(submission_data)
submissions_list.append(
PuzzleSubmissionsOut(
puzzle_id=puzzle.id,
puzzle_title=puzzle.title,
submissions=puzzle_submissions,
)
)
data = {"submissions": submissions_list}
cache.set(f"api:results:top_submissions:{limit}", data, 300)
return data
@router.get("puzzle-results", response=TournamentPuzzleResultsOut)
def puzzle_results(request: HttpRequest, 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}"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
collection = get_object_or_404(SteamCollection, is_active=True)
# Only allow access when tournament is closed
if collection.accepting_submissions:
raise HttpError(403, "Tournament is still accepting submissions")
# Get all puzzles
puzzles = SteamCollectionItem.objects.filter(collection=collection).order_by(
"order_index"
)
# Build response
results_list = []
for puzzle in puzzles:
# Get the top N responses for this puzzle (ranked by points)
top_responses = (
PuzzleResponse.objects.filter(puzzle=puzzle, needs_manual_validation=False)
.filter_user_best_response()
.annotate_rank_points()
.order_by("-rank_points")[:limit]
)
# Build submission list for this puzzle with rank
puzzle_submissions = []
for rank, response in enumerate(top_responses, 1):
# Get submission files
files = response.files.all()
response_files = [
WinnerFileOut(
file_url=file.file_url or "",
original_filename=file.original_filename,
)
for file in files
]
# Calculate total coefficient
total_coef = None
if (
puzzle.points_factor
and response.final_cost is not None
and response.final_cycles is not None
and response.final_area is not None
):
total_coef = (
puzzle.points_factor.cost * response.final_cost
+ puzzle.points_factor.cycles * response.final_cycles
+ puzzle.points_factor.area * response.final_area
)
submission_data = PuzzleSubmissionWithRankOut(
rank=rank,
user_id=response.submission.user.id if response.submission.user else 0,
username=response.submission.user.username
if response.submission.user
else "Anonymous",
final_cost=response.final_cost,
final_cycles=response.final_cycles,
final_area=response.final_area,
rank_points=response.rank_points,
total_coef=total_coef,
files=response_files,
)
puzzle_submissions.append(submission_data)
# Get points factor if available
points_factor = None
if puzzle.points_factor:
points_factor = PuzzlePointsFactorOut(
cost=puzzle.points_factor.cost,
cycles=puzzle.points_factor.cycles,
area=puzzle.points_factor.area,
)
results_list.append(
PuzzleResultsOut(
puzzle_id=puzzle.id,
puzzle_title=puzzle.title,
points_factor=points_factor,
submissions=puzzle_submissions,
)
)
data = {"results": results_list}
cache.set(f"api:results:puzzle_results:{limit}", data, 300)
return data
+75 -2
View File
@@ -1,7 +1,8 @@
from ninja import ModelSchema, Schema
from typing import List, Optional
from submissions.models import PuzzleResponse
from submissions.schemas import SteamCollectionItemOut, UserInfoOut
from opus_magnum.models import PuzzleResponse
from opus_magnum.schemas import SteamCollectionItemOut
class PuzzleResponseRankingOut(ModelSchema):
@@ -33,6 +34,7 @@ class PuzzleResponseRankingOut(ModelSchema):
class UserDisplayOut(Schema):
id: int
username: str
is_staff: bool
class RankingSchema(Schema):
@@ -40,3 +42,74 @@ class RankingSchema(Schema):
puzzles: list[SteamCollectionItemOut]
responses_by_userid: dict[int, list[PuzzleResponseRankingOut]]
ranking_by_puzzle: dict[int, list[PuzzleResponseRankingOut]]
class WinnerFileOut(Schema):
"""Schema for winner submission file"""
file_url: str
original_filename: str
class WinnerResponseOut(Schema):
"""Schema for winner response with files"""
user_id: int
username: str
final_cost: Optional[int]
final_cycles: Optional[int]
final_area: Optional[int]
rank_points: Optional[int]
total_coef: Optional[int]
files: List[WinnerFileOut]
class PuzzleSubmissionsOut(Schema):
"""Schema for puzzle with all top submissions"""
puzzle_id: int
puzzle_title: str
submissions: List[WinnerResponseOut]
class TournamentSubmissionsOut(Schema):
"""Schema for tournament top submissions results"""
submissions: List[PuzzleSubmissionsOut]
class PuzzlePointsFactorOut(Schema):
"""Schema for puzzle points factor"""
cost: int
cycles: int
area: int
class PuzzleSubmissionWithRankOut(Schema):
"""Schema for puzzle submission with rank"""
rank: int
user_id: int
username: str
final_cost: Optional[int]
final_cycles: Optional[int]
final_area: Optional[int]
rank_points: Optional[int]
total_coef: Optional[int]
files: List[WinnerFileOut]
class PuzzleResultsOut(Schema):
"""Schema for puzzle-specific results with coefficients"""
puzzle_id: int
puzzle_title: str
points_factor: Optional[PuzzlePointsFactorOut]
submissions: List[PuzzleSubmissionWithRankOut]
class TournamentPuzzleResultsOut(Schema):
"""Schema for tournament puzzle-specific results"""
results: List[PuzzleResultsOut]
+11
View File
@@ -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"]
+13
View File
@@ -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)
@@ -1,6 +1,6 @@
from django.apps import AppConfig
class SubmissionsConfig(AppConfig):
class GamesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "submissions"
name = "games"
+22
View File
@@ -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
@@ -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"],
},
),
]
@@ -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),
]
@@ -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),
]
+17
View File
@@ -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})"
+7
View File
@@ -0,0 +1,7 @@
from ninja import Schema
class GameOut(Schema):
steam_app_id: int
name: str
path: str
+92
View File
@@ -0,0 +1,92 @@
from django.contrib import admin
from market.models import Market, MarketOption, UserBet, UserPointChange
class MarketOptionInline(admin.TabularInline):
model = MarketOption
extra = 1
fields = ["text"]
@admin.register(Market)
class MarketAdmin(admin.ModelAdmin):
list_display = ["title", "status", "end_date", "created_by", "created_at"]
list_filter = ["status", "created_at"]
search_fields = ["uuid", "title"]
readonly_fields = [
"uuid",
"created_at",
"updated_at",
"created_by",
"winning_option",
]
inlines = [MarketOptionInline]
fieldsets = (
("Info", {"fields": ["uuid", "title", "description"]}),
("Configuration", {"fields": ["end_date", "multiplier"]}),
("Status", {"fields": ["status", "winning_option"]}),
("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}),
)
def has_change_permission(self, request, obj=None):
# Prevent any changes to resolved markets
if obj and obj.status == Market.Status.RESOLVED:
return False
return super().has_change_permission(request, obj)
def save_model(self, request, obj, form, change):
if not change: # Creating new market
obj.created_by = request.user
super().save_model(request, obj, form, change)
@admin.action(description="Publish selected draft markets")
def publish_markets(self, request, queryset):
updated = queryset.filter(status=Market.Status.DRAFT).update(
status=Market.Status.OPEN
)
self.message_user(request, f"Published {updated} market(s).")
@admin.action(description="Close selected markets")
def close_markets(self, request, queryset):
updated = queryset.filter(status=Market.Status.OPEN).update(
status=Market.Status.CLOSED
)
self.message_user(request, f"Closed {updated} market(s).")
actions = ["publish_markets", "close_markets"]
@admin.register(MarketOption)
class MarketOptionAdmin(admin.ModelAdmin):
list_display = ["text", "market"]
list_filter = ["market"]
search_fields = ["uuid", "text", "market__title"]
readonly_fields = ["uuid"]
@admin.register(UserBet)
class UserBetAdmin(admin.ModelAdmin):
list_display = ["user", "option", "amount", "created_at"]
list_filter = ["user", "created_at", "option__market"]
search_fields = ["uuid", "user__username", "option__text"]
readonly_fields = ["uuid", "user", "option", "amount", "created_at", "updated_at"]
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.register(UserPointChange)
class UserPointChangeAdmin(admin.ModelAdmin):
list_display = ["user", "market", "amount", "reason", "created_at"]
list_filter = ["user", "reason", "created_at", "market"]
search_fields = ["uuid", "user__username", "market__title"]
readonly_fields = ["uuid", "created_at", "updated_at"]
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
+184
View File
@@ -0,0 +1,184 @@
from typing import List
from ninja import Router
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 market.models import Market, MarketOption, UserBet, UserPointChange
from market.schemas import (
MarketListSchema,
ResolveMarketSchema,
UserBetCreateSchema,
UserBetSchema,
)
router = Router(tags=["market"])
@router.get("/", response=List[MarketListSchema])
def list_markets(request):
"""List all markets (excludes draft markets)."""
markets = Market.objects.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)
).order_by("-total_bets", "text")
return markets.prefetch_related(Prefetch("options", queryset=options_queryset))
@router.get("/user/bets", response=List[UserBetSchema])
def list_user_bets(request):
"""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)
.select_related("option__market")
.prefetch_related("option__market__options")
)
@router.post("/{market_uuid}/actions/close")
def close_market(request, 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.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):
"""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)
winning_option = get_object_or_404(MarketOption, uuid=payload.winning_option_uuid)
if winning_option.market_id != market.id:
raise HttpError(400, "Option does not belong to this market")
market.winning_option = winning_option
market.status = Market.Status.RESOLVED
market.save(update_fields=["winning_option", "status", "updated_at"])
# Calculate and distribute winnings
all_bets = list(
UserBet.objects.filter(option__market=market).select_related("user")
)
# Calculate total pot
total_pot = sum(bet.amount for bet in all_bets)
if total_pot == 0:
return market
# Separate winning and losing bets
winning_bets = [bet for bet in all_bets if bet.option_id == winning_option.id]
losing_bets = [bet for bet in all_bets if bet.option_id != winning_option.id]
total_winning = sum(bet.amount for bet in winning_bets)
point_changes = []
users_to_update = []
with transaction.atomic():
# Award payouts to winners with multiplier
if total_winning > 0:
for bet in winning_bets:
payout = round(
bet.amount / total_winning * total_pot * market.multiplier
)
bet.user.points += payout
users_to_update.append(bet.user)
point_changes.append(
UserPointChange(
user=bet.user,
market=market,
amount=payout,
reason=UserPointChange.Reason.BET_WON,
)
)
# Record losing bets (points already deducted)
for bet in losing_bets:
point_changes.append(
UserPointChange(
user=bet.user,
market=market,
amount=-bet.amount,
reason=UserPointChange.Reason.BET_LOST,
)
)
# Bulk update users
for user in users_to_update:
user.save(update_fields=["points"])
# Bulk create point changes
UserPointChange.objects.bulk_create(point_changes)
return market
@router.post("/{market_uuid}/bets", response=UserBetSchema)
def create_bet(request, 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)
option = get_object_or_404(MarketOption, uuid=payload.option_uuid)
if option.market_id != market.id:
raise HttpError(400, "Option does not belong to this market")
if market.status != Market.Status.OPEN:
raise HttpError(400, "Market is not open for betting")
# Check if user already has a bet on a different option in this market
existing_bet_on_market = (
UserBet.objects.filter(user=request.user, option__market=market)
.exclude(option=option)
.first()
)
if existing_bet_on_market:
raise HttpError(400, "You can only bet on one option per market")
# Check if user already has a bet on this option
existing_bet = UserBet.objects.filter(user=request.user, option=option).first()
if existing_bet and payload.amount < existing_bet.amount:
raise HttpError(400, "Cannot decrease bet amount. You can only increase it.")
# Calculate delta (amount to deduct from user's points)
delta = payload.amount - (existing_bet.amount if existing_bet else 0)
# Check if user has enough points
if request.user.points < delta:
raise HttpError(400, "Insufficient points for this bet")
user_bet, created = UserBet.objects.update_or_create(
user=request.user,
option=option,
defaults={"amount": payload.amount},
)
# Deduct points and record the change
request.user.points -= delta
request.user.save(update_fields=["points"])
UserPointChange.objects.create(
user=request.user,
market=market,
amount=-delta,
reason=UserPointChange.Reason.BET_PLACED,
)
return user_bet
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class MarketConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "market"
@@ -0,0 +1,187 @@
# Generated by Django 5.2.7 on 2026-05-23 15:45
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Market",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"uuid",
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("title", models.CharField(max_length=255)),
("description", models.TextField(blank=True)),
(
"type",
models.CharField(
choices=[("yes_no", "Yes/No"), ("multiple", "Multiple Choice")],
default="yes_no",
max_length=10,
),
),
(
"status",
models.CharField(
choices=[
("open", "Open"),
("closed", "Closed"),
("resolved", "Resolved"),
],
default="open",
max_length=10,
),
),
("end_date", models.DateTimeField()),
(
"created_by",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name="MarketOption",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"uuid",
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("text", models.CharField(max_length=255)),
("position", models.PositiveIntegerField(default=0)),
(
"market",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="options",
to="market.market",
),
),
],
options={
"ordering": ["position"],
},
),
migrations.AddField(
model_name="market",
name="winning_option",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="market_won",
to="market.marketoption",
),
),
migrations.CreateModel(
name="UserBet",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"uuid",
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("amount", models.PositiveIntegerField()),
(
"option",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="user_bets",
to="market.marketoption",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="bets",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddIndex(
model_name="marketoption",
index=models.Index(
fields=["market", "position"], name="market_mark_market__8679ce_idx"
),
),
migrations.AddConstraint(
model_name="marketoption",
constraint=models.UniqueConstraint(
fields=("market", "position"), name="unique_market_option_position"
),
),
migrations.AddIndex(
model_name="market",
index=models.Index(
fields=["status", "-created_at"], name="market_mark_status_1ef6c3_idx"
),
),
migrations.AddIndex(
model_name="market",
index=models.Index(
fields=["end_date"], name="market_mark_end_dat_26bec0_idx"
),
),
migrations.AddIndex(
model_name="userbet",
index=models.Index(
fields=["user", "option"], name="market_user_user_id_5e43d9_idx"
),
),
migrations.AddConstraint(
model_name="userbet",
constraint=models.UniqueConstraint(
fields=("user", "option"), name="unique_user_bet_per_option"
),
),
]
@@ -0,0 +1,73 @@
# Generated by Django 5.2.7 on 2026-05-23 18:11
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("market", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="UserPointChange",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"uuid",
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("amount", models.IntegerField()),
(
"reason",
models.CharField(
choices=[
("bet_placed", "Bet Placed"),
("bet_won", "Bet Won"),
("bet_lost", "Bet Lost"),
],
max_length=20,
),
),
(
"market",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="point_changes",
to="market.market",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="point_changes",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created_at"],
"indexes": [
models.Index(
fields=["user", "-created_at"],
name="market_user_user_id_631ba9_idx",
)
],
},
),
]
@@ -0,0 +1,16 @@
# Generated by Django 5.2.7 on 2026-05-23 18:19
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("market", "0002_userpointchange"),
]
operations = [
migrations.RemoveField(
model_name="market",
name="type",
),
]
@@ -0,0 +1,40 @@
# Generated by Django 5.2.7 on 2026-05-23 18:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("market", "0003_remove_market_type"),
]
operations = [
migrations.AlterModelOptions(
name="marketoption",
options={"ordering": ["text"]},
),
migrations.RemoveConstraint(
model_name="marketoption",
name="unique_market_option_position",
),
migrations.RemoveIndex(
model_name="marketoption",
name="market_mark_market__8679ce_idx",
),
migrations.RemoveField(
model_name="marketoption",
name="position",
),
migrations.AddIndex(
model_name="marketoption",
index=models.Index(
fields=["market"], name="market_mark_market__67f63b_idx"
),
),
migrations.AddConstraint(
model_name="marketoption",
constraint=models.UniqueConstraint(
fields=("market", "text"), name="unique_market_option_text"
),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2026-05-23 18:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("market", "0004_alter_marketoption_options_and_more"),
]
operations = [
migrations.AddField(
model_name="market",
name="multiplier",
field=models.FloatField(default=1.0),
),
]
@@ -0,0 +1,26 @@
# Generated by Django 5.2.7 on 2026-05-23 18:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("market", "0005_market_multiplier"),
]
operations = [
migrations.AlterField(
model_name="market",
name="status",
field=models.CharField(
choices=[
("draft", "Draft"),
("open", "Open"),
("closed", "Closed"),
("resolved", "Resolved"),
],
default="draft",
max_length=10,
),
),
]
+114
View File
@@ -0,0 +1,114 @@
import uuid
from django.db import models
class BaseModel(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
class Market(BaseModel):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
OPEN = "open", "Open"
CLOSED = "closed", "Closed"
RESOLVED = "resolved", "Resolved"
title = models.CharField(max_length=255)
description = models.TextField(blank=True)
status = models.CharField(
max_length=10, choices=Status.choices, default=Status.DRAFT
)
end_date = models.DateTimeField()
multiplier = models.FloatField(default=1.0)
created_by = models.ForeignKey("accounts.CustomUser", on_delete=models.PROTECT)
winning_option = models.ForeignKey(
"MarketOption",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="market_won",
)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["status", "-created_at"]),
models.Index(fields=["end_date"]),
]
def __str__(self):
return self.title
class MarketOption(BaseModel):
market = models.ForeignKey(Market, on_delete=models.CASCADE, related_name="options")
text = models.CharField(max_length=255)
class Meta:
ordering = ["text"]
constraints = [
models.UniqueConstraint(
fields=["market", "text"],
name="unique_market_option_text",
),
]
indexes = [
models.Index(fields=["market"]),
]
def __str__(self):
return f"{self.market.title} - {self.text}"
class UserBet(BaseModel):
user = models.ForeignKey(
"accounts.CustomUser", on_delete=models.CASCADE, related_name="bets"
)
option = models.ForeignKey(
MarketOption, on_delete=models.CASCADE, related_name="user_bets"
)
amount = models.PositiveIntegerField()
class Meta:
constraints = [
models.UniqueConstraint(
fields=["user", "option"],
name="unique_user_bet_per_option",
),
]
indexes = [
models.Index(fields=["user", "option"]),
]
def __str__(self):
return f"{self.user.username} bet {self.amount} on {self.option.text}"
class UserPointChange(BaseModel):
class Reason(models.TextChoices):
BET_PLACED = "bet_placed", "Bet Placed"
BET_WON = "bet_won", "Bet Won"
BET_LOST = "bet_lost", "Bet Lost"
user = models.ForeignKey(
"accounts.CustomUser", on_delete=models.CASCADE, related_name="point_changes"
)
market = models.ForeignKey(
Market, on_delete=models.CASCADE, related_name="point_changes"
)
amount = models.IntegerField()
reason = models.CharField(max_length=20, choices=Reason.choices)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["user", "-created_at"]),
]
def __str__(self):
return f"{self.user.username} {self.reason}: {self.amount} pts on {self.market.title}"
+59
View File
@@ -0,0 +1,59 @@
from datetime import datetime
from typing import List, Optional, Any
from uuid import UUID
from ninja import Schema
from pydantic import field_serializer, model_validator
class MarketOptionSchema(Schema):
uuid: UUID
text: str
total_bets: int = 0
@field_serializer("uuid")
def serialize_uuid(self, value: UUID) -> str:
return str(value)
class MarketListSchema(Schema):
uuid: UUID
title: str
description: str
status: str
end_date: datetime
multiplier: float = 1.0
created_at: datetime
options: List[MarketOptionSchema]
winning_option: Optional[MarketOptionSchema] = None
@field_serializer("uuid")
def serialize_uuid(self, value: UUID) -> str:
return str(value)
class ResolveMarketSchema(Schema):
winning_option_uuid: str
class UserBetCreateSchema(Schema):
option_uuid: str
amount: int
class UserBetSchema(Schema):
uuid: UUID
amount: int
created_at: datetime
option: MarketOptionSchema
market: Optional[MarketListSchema] = None
@field_serializer("uuid")
def serialize_uuid(self, value: UUID) -> str:
return str(value)
@model_validator(mode="before")
@classmethod
def resolve_market_from_option(cls, data: Any) -> Any:
if hasattr(data, "option") and hasattr(data.option, "market"):
data.market = data.option.market
return data
+8
View File
@@ -0,0 +1,8 @@
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from django.http import HttpRequest
@login_required
def market_home(request: HttpRequest):
return render(request, "market.html", {})
+32 -3
View File
@@ -1,5 +1,7 @@
from django.contrib import admin
from .models import LogfileSubmission, Objectiv, ObjectivPoint
from noita.services.objectives import parse_objectives_and_store
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
@admin.register(LogfileSubmission)
@@ -23,13 +25,28 @@ class LogfileSubmissionAdmin(admin.ModelAdmin):
("Processing", {"fields": ("processed",)}),
)
actions = ["validate_submission"]
def validate_submission(self, request, queryset):
for logfile in queryset:
parse_objectives_and_store(logfile)
self.message_user(request, f"{queryset.count()} submissions validated.")
@admin.register(Objectiv)
class ObjectivAdmin(admin.ModelAdmin):
list_display = ("objectiv_id", "user", "count")
list_display = ("objectiv_id", "user", "first_seen_at", "get_user_objectiv_count")
list_filter = ("objectiv_id", "user")
search_fields = ("objectiv_id", "user__username")
readonly_fields = ("user",)
readonly_fields = ("user", "first_seen_at")
def get_user_objectiv_count(self, obj):
return Objectiv.objects.filter(
objectiv_id=obj.objectiv_id, user=obj.user
).count()
get_user_objectiv_count.short_description = "Count"
@admin.register(ObjectivPoint)
@@ -41,3 +58,15 @@ class ObjectivPointAdmin(admin.ModelAdmin):
("Objective Information", {"fields": ("objectiv_id", "display_string")}),
("Scoring", {"fields": ("max_count", "point")}),
)
@admin.register(DeathCounter)
class DeathCounterAdmin(admin.ModelAdmin):
list_display = ("user_id", "seed", "seen_at")
list_filter = ("user_id", "seed")
search_fields = ("user_id", "seed")
fieldsets = (
("User Information", {"fields": ("user_id",)}),
("Scoring", {"fields": ("seed",)}),
)
+113 -64
View File
@@ -1,45 +1,54 @@
from django.http import HttpRequest
from django.core.files.base import ContentFile
from django.core.cache import cache
from django.db.models import (
F,
Case,
When,
Sum,
Count,
IntegerField,
Subquery,
OuterRef,
Window,
)
from django.db.models.functions import Rank
from ninja import Router, File
from ninja.files import UploadedFile
from ninja.decorators import decorate_view
from noita.schemas import ObjectivOut, ResultsOut, LeaderboardOut
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
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
from .schemas import NoitaSubmissionOut
router = Router()
@router.get("objectives", response=list[ObjectivOut])
def get_my_objectives(request: HttpRequest):
return Objectiv.objects.order_by("-count").filter(user=request.user)
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)
if cached_data is not None:
return cached_data
"""
Get the user's score based on their objectives.
Calculates points as: ObjectivPoint.point * min(max_count, count) for each objective
Uses Django ORM annotate for efficient queryset computation.
"""
# Group objectives by objectiv_id and count occurrences
user_objectives = (
Objectiv.objects.filter(user=request.user)
.values("objectiv_id")
.annotate(count=Count("id"))
)
# Fetch points from ObjectivPoint using Subquery
user_objectives = Objectiv.objects.filter(user=request.user).annotate(
user_objectives = user_objectives.annotate(
# Get points per objective from ObjectivPoint
points_per_objectiv=Subquery(
ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values(
@@ -75,43 +84,75 @@ def get_results(request: HttpRequest):
total_points=F("points_per_objectiv") * F("capped_count"),
)
# Get total score
total_score_result = user_objectives.aggregate(Sum("total_points"))[
"total_points__sum"
]
total_score = total_score_result or 0
# Annotate seed + first-seen-at
user_objectives = user_objectives.annotate(
seed=F("seed"),
first_seen_at=F("first_seen_at"),
)
# Build response with all objectives
objectives_with_points = [
{
# Build response with all objectives and compute total score
total_score = 0
with_points = {}
for obj in ObjectivPoint.objects.all():
with_points[obj.objectiv_id] = {
"objectiv_id": obj.objectiv_id,
"count": obj.count,
"points_per_objectiv": obj.points_per_objectiv or 0,
"total_points": obj.total_points or 0,
"display_string": obj.display_string,
"count": 0,
"max_count": obj.max_count,
"points_per_objectiv": obj.point,
"total_points": 0,
"first_seen_at": None,
"seed": None,
}
for obj in user_objectives.order_by("-total_points")
]
return {
"total_score": total_score,
"objectives": objectives_with_points,
for obj in user_objectives.order_by("-total_points"):
points = obj["total_points"] or 0
with_points[obj["objectiv_id"]].update(
{
"count": obj["count"],
"total_points": points,
"first_seen_at": obj["first_seen_at"],
"seed": obj["seed"],
}
)
total_score += points
# Count deaths for the user
deaths_count = DeathCounter.objects.filter(user=request.user).count()
data = {
"total_score": total_score,
"deaths_count": deaths_count,
"objectives": list(with_points.values()),
}
cache.set(f"api:noita:results:{request.user.id}", data, 300)
return data
@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.
Uses Window functions to rank users by their total score in descending order.
"""
cache_key = "api:noita:leaderboard"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
from django.contrib.auth import get_user_model
User = get_user_model()
# Get all objectives with calculated points
# Get all objectives with calculated points (grouped by objectiv_id and user)
all_objectives = (
Objectiv.objects.annotate(
Objectiv.objects.values("user", "objectiv_id")
.annotate(count=Count("id"))
.annotate(
# Fetch points from ObjectivPoint using Subquery
points_per_objectiv=Subquery(
ObjectivPoint.objects.filter(
@@ -146,48 +187,57 @@ def get_leaderboard(request: HttpRequest):
)
)
# Get user totals using subquery
user_totals = (
all_objectives.values("user")
.annotate(total_score=Sum("total_points"))
.values("user", "total_score")
)
# Build user totals by iterating through objectives
user_totals_dict = {}
for obj in all_objectives:
user_id = obj["user"]
points = obj["total_points"] or 0
if user_id not in user_totals_dict:
user_totals_dict[user_id] = 0
user_totals_dict[user_id] += points
# Get unique users and their scores, then apply ranking
leaderboard = (
User.objects.filter(objectiv__isnull=False)
users_with_scores = []
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)
.values("objectiv_id")
.distinct()
.annotate(
total_score=Subquery(
user_totals.filter(user=OuterRef("id")).values("total_score")[:1],
output_field=IntegerField(),
.count()
)
)
.annotate(objectives_count=Count("objectiv", distinct=True))
.annotate(
rank=Window(
expression=Rank(),
order_by=F("total_score").desc(),
)
)
.values("rank", "username", "total_score", "objectives_count")
.order_by("rank")
deaths_count = DeathCounter.objects.filter(user_id=user_id).count()
users_with_scores.append(
{
"user_id": user_id,
"user": user,
"total_score": total_score,
"objectives_count": objectives_count,
"deaths_count": deaths_count,
}
)
return {
"leaderboard": [
# Sort by score and add rank
users_with_scores.sort(key=lambda x: x["total_score"], reverse=True)
leaderboard = [
{
"rank": entry["rank"],
"username": entry["username"],
"total_score": entry["total_score"] or 0,
"rank": idx + 1,
"username": entry["user"].username,
"is_staff": entry["user"].is_staff,
"total_score": entry["total_score"],
"objectives_count": entry["objectives_count"],
"deaths_count": entry["deaths_count"],
}
for entry in leaderboard
for idx, entry in enumerate(users_with_scores)
]
}
data = {"leaderboard": leaderboard}
cache.set("api:noita:leaderboard", data, 300)
return data
@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).
@@ -203,12 +253,6 @@ def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
allowed_types = [
"text/plain",
"text/x-log",
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"video/mp4",
"video/webm",
]
if file.content_type not in allowed_types:
@@ -238,6 +282,11 @@ def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
except Exception:
pass
# Invalidate caches on successful submission
if submission.user:
cache.delete(f"api:noita:results:{submission.user.id}")
cache.delete("api:noita:leaderboard")
return {
"id": str(submission.id),
"user_id": submission.user_id,
@@ -1,6 +1,7 @@
from django.core.management.base import BaseCommand
from noita.models import ObjectivPoint
from noita.services.decode import POINTS
from noita.services.spells import ALL_PERKS, ALL_SPELLS
class Command(BaseCommand):
@@ -55,6 +56,20 @@ class Command(BaseCommand):
)
)
for op in ObjectivPoint.objects.filter(objectiv_id__in=ALL_SPELLS):
op.display_string = f"Spell: {op.display_string}"
op.save()
for op in ObjectivPoint.objects.filter(objectiv_id__in=ALL_PERKS):
op.display_string = f"Perk: {op.display_string}"
op.save()
for op in ObjectivPoint.objects.filter(objectiv_id__startswith="BOSS_KILL_"):
b, k, c = op.display_string.split(" ")
op.display_string = f"Perk: {op.display_string}"
op.display_string = f"{b} {k} (with {c} orbs)"
op.save()
self.stdout.write(self.style.SUCCESS(f"\nCreated: {created_count}"))
self.stdout.write(self.style.SUCCESS(f"Updated: {updated_count}"))
self.stdout.write(
@@ -0,0 +1,31 @@
# Generated by Django 5.2.7 on 2026-05-11 08:20
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0006_objectivpoint"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name="objectiv",
name="count",
),
migrations.AddField(
model_name="objectiv",
name="first_seen_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddConstraint(
model_name="objectiv",
constraint=models.UniqueConstraint(
fields=("objectiv_id", "user", "first_seen_at"),
name="unique_objectiv_per_user_timestamp",
),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-05-11 08:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0007_remove_objectiv_count_objectiv_first_seen_at_and_more"),
]
operations = [
migrations.AddField(
model_name="objectiv",
name="seed",
field=models.CharField(default="", max_length=32),
preserve_default=False,
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2026-05-11 08:31
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0008_objectiv_seed"),
]
operations = [
migrations.AddField(
model_name="objectiv",
name="submission",
field=models.ForeignKey(
default=1,
on_delete=django.db.models.deletion.CASCADE,
to="noita.logfilesubmission",
),
preserve_default=False,
),
]
@@ -0,0 +1,34 @@
# Generated by Django 5.2.7 on 2026-05-14 23:36
from django.db import migrations, models
def fw_func(apps, _schema_editor):
Objectiv = apps.get_model("noita", "Objectiv")
Objectiv.objects.filter(objectiv_id="DEATH").delete()
class Migration(migrations.Migration):
dependencies = [
("noita", "0009_objectiv_submission"),
]
operations = [
migrations.CreateModel(
name="DeathCounter",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("seed", models.CharField(max_length=32)),
("seen_at", models.DateTimeField()),
],
),
migrations.RunPython(fw_func, migrations.RunPython.noop),
]
@@ -0,0 +1,25 @@
# Generated by Django 5.2.7 on 2026-05-14 23:43
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0010_deathcounter"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="deathcounter",
name="user",
field=models.ForeignKey(
default=1,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
preserve_default=False,
),
]
@@ -0,0 +1,20 @@
# Generated by Django 5.2.7 on 2026-05-14 23:45
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0011_deathcounter_user"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddConstraint(
model_name="deathcounter",
constraint=models.UniqueConstraint(
fields=("user_id", "seen_at"), name="unique_death_per_seen_at"
),
),
]
+26 -1
View File
@@ -1,5 +1,6 @@
from django.contrib.auth import get_user_model
from django.db import models
from django.utils import timezone
import uuid
@@ -49,7 +50,17 @@ class Objectiv(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
count = models.IntegerField(default=1)
first_seen_at = models.DateTimeField(default=timezone.now)
seed = models.CharField(max_length=32)
submission = models.ForeignKey("LogfileSubmission", on_delete=models.CASCADE)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["objectiv_id", "user", "first_seen_at"],
name="unique_objectiv_per_user_timestamp",
)
]
class ObjectivPoint(models.Model):
@@ -57,3 +68,17 @@ class ObjectivPoint(models.Model):
display_string = models.CharField(max_length=255)
max_count = models.IntegerField(default=1)
point = models.IntegerField(default=0)
class DeathCounter(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
seed = models.CharField(max_length=32)
seen_at = models.DateTimeField()
class Meta:
constraints = [
models.UniqueConstraint(
fields=["user_id", "seen_at"],
name="unique_death_per_seen_at",
)
]
+9 -10
View File
@@ -1,14 +1,6 @@
from typing import Optional
from datetime import datetime
from ninja import Schema, ModelSchema
from noita.models import Objectiv
class ObjectivOut(ModelSchema):
class Meta:
model = Objectiv
fields = ["objectiv_id", "count"]
from ninja import Schema
class NoitaSubmissionOut(Schema):
@@ -23,21 +15,28 @@ class NoitaSubmissionOut(Schema):
class ObjectivResultOut(Schema):
objectiv_id: str
display_string: str
first_seen_at: datetime | None
count: int
max_count: int
seed: str | None
points_per_objectiv: int
total_points: int
total_points: int | None
class ResultsOut(Schema):
total_score: int
deaths_count: int
objectives: list[ObjectivResultOut]
class LeaderboardEntryOut(Schema):
rank: int
username: str
is_staff: bool
total_score: int
objectives_count: int
deaths_count: int
class LeaderboardOut(Schema):
+80 -37
View File
@@ -65,52 +65,95 @@ DEATH_PENALTY = 1
# All scoreable events: base 1 pt for every spell and perk, overrides below.
POINTS = {
# ── Spells ───────────────────────────────────────────────────────────────
**{sid: 1 for sid in ALL_SPELLS},
**{sid: 3 for sid in ALL_SPELLS},
"ADD_TRIGGER": 10,
"ADD_TIMER": 10,
"ADD_DEATH_TRIGGER": 10,
"NOLLA": 10,
"CHAOTIC_TRANSMUTATION": 10,
"DUPLICATE": 5,
"OMEGA": 10,
"HEALING_BOLT": 5,
"BURST_2": 10,
"BURST_3": 15,
"BURST_4": 20,
"BURST_8": 20,
"BURST_X": 20,
"HEAL_BULLET": 5,
"ANTIHEAL": 5,
"NUKE": 5,
"NUKE_GIGA": 5,
"TELEPORT_PROJECTILE": 5,
"TELEPORT_PROJECTILE_SHORT": 5,
"TOUCH_BLOOD": 100,
"TOUCH_GOLD": 100,
"TOUCH_PISS": 100,
"TOUCH_GRASS": 100,
"TOUCH_OIL": 100,
"TOUCH_SMOKE": 100,
"TOUCH_ALCOHOL": 100,
"TOUCH_WATER": 100,
"SPELLS_TO_POWER": 10,
"ALL_SPELLS": 100,
"DIVIDE_2": 10,
"DIVIDE_3": 15,
"DIVIDE_4": 20,
"DIVIDE_10": 50,
"ALPHA": 50,
"GAMMA": 50,
"MU": 50,
"OMEGA": 50,
"PHI": 50,
"SIGMA": 50,
"TAU": 50,
"ZETA": 50,
"DISC_BULLET_BIGGER": 20,
"SUMMON_WANDGHOST": 50,
"ALL_BLACKHOLES": 20,
"ALL_DEATHCROSSES": 20,
"ALL_ROCKETS": 20,
"ALL_NUKES": 20,
"ALL_DISCS": 20,
# ── Perks ─────────────────────────────────────────────────────────────────
**{pid: 1 for pid in ALL_PERKS},
"EDIT_WANDS_EVERYWHERE": 10,
**{pid: 15 for pid in ALL_PERKS},
"PROTECTION_FIRE": 30,
"PROTECTION_RADIOACTIVITY": 30,
"PROTECTION_EXPLOSION": 30,
"PROTECTION_MELEE": 30,
"PROTECTION_ELECTRICITY": 30,
# ── Spell combos ─────────────────────────────────────────────────────────
"PING_PONG_DRILL": 10,
"HEAVY_SHOT_DISC": 10,
"TOUCH_OF_ANY": 5,
"TWO_ARC_MODIFIERS": 10,
"TWO_TRAIL_MODIFIERS": 10,
"PING_PONG_DRILL": 40,
"HEAVY_SHOT_DISC": 40,
"TWO_ARC_MODIFIERS": 50,
"TWO_TRAIL_MODIFIERS": 50,
# ── Perk combos ──────────────────────────────────────────────────────────
"CRIMSON_ALCHEMIST": 15,
"GREEDY_GOBLIN_KING": 15,
"STORM_TOUCHED_ASCENDANT": 15,
"ARCHMAGE_OF_CONTROL": 15,
"HAUNTED_MAGE": 15,
"GLASS_CANNON_MESSIAH": 15,
"INFINITE_ENGINE": 15,
"PERFECT_ACCURACY_LOOP": 15,
"HOMING_DEATH_SWARM": 15,
"UNTOUCHABLE_FIELD": 15,
"ELECTRIC_SUSTAIN_LOOP": 15,
"IMMORTAL_LEECH_CORE": 15,
"PROJECTILE_OVERLOAD": 15,
"CLOSE_RANGE_DEATH_MACHINE": 15,
"CRITICAL_MASS": 15,
"HOLY_MOUNTAIN_ABUSER": 15,
"DEFLECTOR_MATRIX": 15,
"STORMBORNE_LEVITATOR": 15,
"CRIMSON_ALCHEMIST": 50,
"GREEDY_GOBLIN_KING": 50,
"STORM_TOUCHED_ASCENDANT": 50,
"ARCHMAGE_OF_CONTROL": 50,
"HAUNTED_MAGE": 50,
"GLASS_CANNON_MESSIAH": 50,
"INFINITE_ENGINE": 50,
"PERFECT_ACCURACY_LOOP": 50,
"HOMING_DEATH_SWARM": 50,
"UNTOUCHABLE_FIELD": 50,
"ELECTRIC_SUSTAIN_LOOP": 50,
"IMMORTAL_LEECH_CORE": 50,
"PROJECTILE_OVERLOAD": 50,
"CLOSE_RANGE_DEATH_MACHINE": 50,
"CRITICAL_MASS": 50,
"HOLY_MOUNTAIN_ABUSER": 50,
"DEFLECTOR_MATRIX": 50,
"STORMBORNE_LEVITATOR": 50,
# ── Objectives ───────────────────────────────────────────────────────────
"HP_200": 5,
"HP_500": 5,
"HP_2000": 5,
"HP_5000": 5,
"HP_200": 50,
"HP_500": 50,
"HP_2000": 100,
"HP_5000": 500,
"GOLD_1000": 5,
"GOLD_10000": 5,
"GOLD_100000": 5,
"GOLD_1000000": 5,
**{f"ORB_{i}": 5 for i in range(34)},
"FIND_AMBROSIA": 10,
"GOLD_10000": 50,
"GOLD_100000": 100,
"GOLD_1000000": 200,
**{f"ORB_{i}": 25 for i in range(34)},
**{f"BOSS_KILL_{i}": 75 * i for i in range(34)},
"WAND_MANA_500": 10,
"WAND_MANA_1000": 10,
"WAND_MANA_1500": 10,
+31 -17
View File
@@ -1,22 +1,21 @@
from noita.models import LogfileSubmission, Objectiv
from noita.models import LogfileSubmission, Objectiv, DeathCounter
from noita.services.decode import parse_log, resolve
from collections import Counter
def parse_objectives_from_logfile(logfile: LogfileSubmission) -> Counter:
def parse_objectives_from_logfile(
logfile: LogfileSubmission,
) -> list[tuple[str, str, str]]:
"""Parse a log file, and output a count for each ID."""
file_data = logfile.file.read().decode()
ids = []
entries: list[tuple[str, str, str]] = []
for entry in parse_log(file_data):
idx, _seed = resolve(entry["hash"], entry["ts"])
idx, seed = resolve(entry["hash"], entry["ts"])
if idx:
ids.append(idx)
if idx and seed:
entries.append((idx, str(seed), entry["ts"]))
return Counter(ids)
return entries
def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
@@ -25,16 +24,31 @@ def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
if not logfile.user:
return
counter = parse_objectives_from_logfile(logfile)
for idx, count in counter.items():
if idx in {"-", "DEBUG"}:
objectives = []
deaths = []
for idx, seed, ts in parse_objectives_from_logfile(logfile):
if idx in {"-", "DEBUG", "polylan-mod"}:
continue
obj, created = Objectiv.objects.get_or_create(
if idx == "DEATH":
deaths.append(DeathCounter(user=logfile.user, seed=seed, seen_at=ts))
continue
objectives.append(
Objectiv(
objectiv_id=idx,
user=logfile.user,
first_seen_at=ts,
seed=seed,
submission=logfile,
)
)
obj.count += count
obj.save(update_fields=["count"])
Objectiv.objects.bulk_create(
objectives,
update_conflicts=True,
update_fields=["seed", "submission"],
unique_fields=["objectiv_id", "user", "first_seen_at"],
)
DeathCounter.objects.bulk_create(deaths, ignore_conflicts=True)
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
input: `http://localhost:7777/api/openapi.json`,
output: 'src/api/',
});
@@ -1,7 +1,7 @@
from django.contrib import admin
from django.utils.html import format_html
from django.utils import timezone
from submissions.models import (
from opus_magnum.models import (
SteamAPIKey,
SteamCollection,
SteamCollectionItem,
@@ -81,6 +81,7 @@ class SteamCollectionAdmin(admin.ModelAdmin):
"current_favorites",
"last_fetched",
"is_active",
"accepting_submissions",
]
list_filter = ["is_active", "last_fetched", "created_at"]
search_fields = ["title", "steam_id", "author_name", "description"]
@@ -115,7 +116,7 @@ class SteamCollectionAdmin(admin.ModelAdmin):
)
},
),
("Status", {"fields": ("fetch_error",)}),
("Status", {"fields": ("fetch_error", "accepting_submissions")}),
)
@@ -1,27 +1,40 @@
from ninja import Router, File
from ninja.files import UploadedFile
from ninja.pagination import paginate
from ninja.errors import HttpError
from django.db import transaction
from django.core.files.base import ContentFile
from django.core.cache import cache
from django.utils import timezone
from django.shortcuts import get_object_or_404
from typing import List
from submissions.utils import verify_and_validate_ocr_date_for_submission
from games.decorators import require_game_enabled
from opus_magnum.utils import verify_and_validate_ocr_date_for_submission
from ninja.decorators import decorate_view
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem
from .models import (
Submission,
PuzzleResponse,
SubmissionFile,
SteamCollectionItem,
SteamCollection,
)
from .schemas import (
SubmissionIn,
SubmissionOut,
PuzzleResponseOut,
ValidationIn,
SteamCollectionItemOut,
SteamCollectionOut,
)
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(
@@ -29,7 +42,16 @@ 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)
return collection
@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"""
@@ -39,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(
@@ -50,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(...)
):
@@ -64,7 +88,15 @@ def create_submission(
if len(files) < len(data.responses):
return 400, {"detail": "Not enough files for all responses"}
print(data, files)
# Check if collection is accepting submissions
if data.responses:
for puzzle in data.responses:
if not get_object_or_404(
SteamCollectionItem, id=puzzle.puzzle_id
).collection.accepting_submissions:
raise HttpError(
403, "This tournament is no longer accepting submissions"
)
try:
with transaction.atomic():
@@ -125,7 +157,6 @@ def create_submission(
# Process files for this response
# For simplicity, we'll take one file per response
# In a real implementation, you'd need better file-to-response mapping
print("FI", file_index, files)
if file_index < len(files):
uploaded_file = files[file_index]
@@ -165,14 +196,17 @@ def create_submission(
"responses__files", "responses__puzzle"
).get(id=submission.id)
# Invalidate results cache on successful submission
cache.delete("api:results:results")
return submission
except Exception as e:
print(e)
return 500, {"detail": f"Error creating submission: {str(e)}"}
@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"""
@@ -201,10 +235,14 @@ def validate_response(request, response_id: int, data: ValidationIn):
response.save()
# Invalidate results cache when a response is validated
cache.delete("api:results:results")
return response
@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"""
@@ -220,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"""
@@ -235,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"""
@@ -256,10 +296,14 @@ def validate_submission(request, submission_id: str):
"responses__files", "responses__puzzle"
).get(id=submission.id)
# Invalidate results cache when submission is validated
cache.delete("api:results:results")
return submission
@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)"""
@@ -268,10 +312,15 @@ def delete_submission(request, submission_id: str):
submission = get_object_or_404(Submission, id=submission_id)
submission.delete()
# Invalidate results cache when submission is deleted
cache.delete("api:results:results")
return {"detail": "Submission deleted successfully"}
@router.get("/stats")
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def get_stats(request):
"""Get submission statistics"""
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class OpusMagnumConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "opus_magnum"
@@ -3,8 +3,8 @@ Django management command to fetch Steam Workshop collections
"""
from django.core.management.base import BaseCommand, CommandError
from submissions.utils import create_or_update_collection
from submissions.models import SteamAPIKey, SteamCollection
from opus_magnum.utils import create_or_update_collection
from opus_magnum.models import SteamAPIKey, SteamCollection
class Command(BaseCommand):
@@ -34,7 +34,7 @@ class Command(BaseCommand):
try:
# Check if collection already exists
from submissions.utils import SteamCollectionFetcher
from opus_magnum.utils import SteamCollectionFetcher
fetcher = SteamCollectionFetcher(api_key.api_key)
collection_id = fetcher.extract_collection_id(url)
@@ -1,6 +1,6 @@
from django.core.management.base import BaseCommand
from submissions.utils import verify_and_validate_ocr_date_for_submission
from submissions.models import SubmissionFile
from opus_magnum.utils import verify_and_validate_ocr_date_for_submission
from opus_magnum.models import SubmissionFile
class Command(BaseCommand):
@@ -205,7 +205,7 @@ class Migration(migrations.Migration):
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="items",
to="submissions.steamcollection",
to="opus_magnum.steamcollection",
),
),
],
@@ -5,7 +5,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("submissions", "0001_initial"),
("opus_magnum", "0001_initial"),
]
operations = [
@@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0002_delete_collection"),
("opus_magnum", "0002_delete_collection"),
]
operations = [
@@ -1,7 +1,7 @@
# Generated by Django 5.2.7 on 2025-10-29 01:32
import django.db.models.deletion
import submissions.models
import opus_magnum.models
import uuid
from django.conf import settings
from django.db import migrations, models
@@ -9,7 +9,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0003_steamapikey"),
("opus_magnum", "0003_steamapikey"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
@@ -160,7 +160,7 @@ class Migration(migrations.Migration):
help_text="The puzzle this response is for",
on_delete=django.db.models.deletion.CASCADE,
related_name="responses",
to="submissions.steamcollectionitem",
to="opus_magnum.steamcollectionitem",
),
),
(
@@ -168,7 +168,7 @@ class Migration(migrations.Migration):
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="responses",
to="submissions.submission",
to="opus_magnum.submission",
),
),
],
@@ -195,7 +195,7 @@ class Migration(migrations.Migration):
"file",
models.FileField(
help_text="Uploaded file (image/gif)",
upload_to=submissions.models.submission_file_upload_path,
upload_to=opus_magnum.models.submission_file_upload_path,
),
),
(
@@ -239,7 +239,7 @@ class Migration(migrations.Migration):
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="files",
to="submissions.puzzleresponse",
to="opus_magnum.puzzleresponse",
),
),
],
@@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0004_submission_puzzleresponse_submissionfile"),
("opus_magnum", "0004_submission_puzzleresponse_submissionfile"),
]
operations = [
@@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0005_alter_submission_notes"),
("opus_magnum", "0005_alter_submission_notes"),
]
operations = [
@@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0006_remove_puzzleresponse_ocr_confidence_score_and_more"),
("opus_magnum", "0006_remove_puzzleresponse_ocr_confidence_score_and_more"),
]
operations = [
@@ -5,7 +5,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("submissions", "0007_submission_manual_validation_requested"),
("opus_magnum", "0007_submission_manual_validation_requested"),
]
operations = [
@@ -7,7 +7,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("animations", "0001_initial"),
("submissions", "0008_alter_puzzleresponse_unique_together"),
("opus_magnum", "0008_alter_puzzleresponse_unique_together"),
]
operations = [
@@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0009_steamcollectionitem_points_factor"),
("opus_magnum", "0009_steamcollectionitem_points_factor"),
]
operations = [
@@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("submissions", "0010_alter_puzzleresponse_validated_area_and_more"),
("opus_magnum", "0010_alter_puzzleresponse_validated_area_and_more"),
]
operations = [
@@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"submissions",
"opus_magnum",
"0011_alter_puzzleresponse_area_alter_puzzleresponse_cost_and_more",
),
]
@@ -7,7 +7,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("animations", "0002_puzzlepointsvalue"),
("submissions", "0012_alter_puzzleresponse_validated_area_and_more"),
("opus_magnum", "0012_alter_puzzleresponse_validated_area_and_more"),
]
operations = [
@@ -0,0 +1,20 @@
# Generated by Django 5.2.7 on 2026-05-21 10:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("opus_magnum", "0013_steamcollectionitem_points_value"),
]
operations = [
migrations.AddField(
model_name="steamcollection",
name="accepting_submissions",
field=models.BooleanField(
default=True,
help_text="Whether the tournament is accepting new submissions",
),
),
]
@@ -0,0 +1,66 @@
# Data migration: Copy all data from submissions app tables to opus_magnum app tables
from django.db import migrations
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
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
""")
def migrate_data_backward(apps, schema_editor):
"""Delete all data from opus_magnum_* tables"""
with schema_editor.connection.cursor() as cursor:
# Delete in reverse order of foreign key dependencies
cursor.execute("DELETE FROM opus_magnum_submissionfile")
cursor.execute("DELETE FROM opus_magnum_puzzleresponse")
cursor.execute("DELETE FROM opus_magnum_submission")
cursor.execute("DELETE FROM opus_magnum_steamcollectionitem")
cursor.execute("DELETE FROM opus_magnum_steamcollection")
cursor.execute("DELETE FROM opus_magnum_steamapikey")
class Migration(migrations.Migration):
dependencies = [
("opus_magnum", "0014_steamcollection_accepting_submissions"),
]
operations = [
migrations.RunPython(migrate_data_forward, migrate_data_backward),
]
@@ -157,6 +157,9 @@ class SteamCollection(models.Model):
is_active = models.BooleanField(
default=True, help_text="Whether this collection is actively tracked"
)
accepting_submissions = models.BooleanField(
default=True, help_text="Whether the tournament is accepting new submissions"
)
fetch_error = models.TextField(
blank=True, help_text="Last error encountered when fetching data"
)
@@ -3,7 +3,13 @@ from typing import List, Optional
from datetime import datetime
from uuid import UUID
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem
from .models import (
Submission,
PuzzleResponse,
SubmissionFile,
SteamCollectionItem,
SteamCollection,
)
# Input Schemas
@@ -132,10 +138,39 @@ class ValidationIn(Schema):
# Collection Schemas
class PuzzlePointsFactorOut(Schema):
"""Schema for puzzle points factor output"""
cost: int
cycles: int
area: int
class SteamCollectionOut(ModelSchema):
"""Schema for Steam collection output"""
class Meta:
model = SteamCollection
fields = [
"id",
"steam_id",
"title",
"description",
"author_name",
"total_items",
"unique_visitors",
"current_favorites",
"accepting_submissions",
"created_at",
"updated_at",
]
class SteamCollectionItemOut(ModelSchema):
"""Schema for Steam collection item output"""
steam_url: str
points_factor: Optional[PuzzlePointsFactorOut] = None
class Meta:
model = SteamCollectionItem
@@ -151,6 +186,16 @@ class SteamCollectionItemOut(ModelSchema):
"updated_at",
]
@staticmethod
def resolve_points_factor(obj) -> Optional[PuzzlePointsFactorOut]:
if obj.points_factor:
return PuzzlePointsFactorOut(
cost=obj.points_factor.cost,
cycles=obj.points_factor.cycles,
area=obj.points_factor.area,
)
return None
# Error Schemas
class ErrorOut(Schema):
@@ -176,6 +221,7 @@ class UserInfoOut(Schema):
first_name: Optional[str] = None
last_name: Optional[str] = None
email: Optional[str] = None
points: int = 0
is_authenticated: bool
is_staff: bool
is_superuser: bool
+1
View File
@@ -0,0 +1 @@
# Create your tests here.
@@ -4,7 +4,7 @@ Utilities for fetching Steam Workshop collection data using Steam Web API
import re
import requests
from submissions.models import SteamCollection, SteamCollectionItem, SubmissionFile
from opus_magnum.models import SteamCollection, SteamCollectionItem, SubmissionFile
from datetime import datetime
from django.utils import timezone
from django.conf import settings
+4 -1
View File
@@ -7,12 +7,14 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"schema": "openapi-ts"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.16",
"@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.0.0",
"dayjs": "^1.11.20",
"install": "^0.13.0",
"pinia": "^3.0.3",
"tailwindcss": "^4.1.16",
@@ -20,6 +22,7 @@
"vue": "^3.5.22"
},
"devDependencies": {
"@hey-api/openapi-ts": "^0.97.2",
"@mdi/font": "^7.4.47",
"@types/node": "^24.6.0",
"@vitejs/plugin-vue": "^6.0.1",
+390
View File
@@ -17,6 +17,9 @@ importers:
'@vueuse/core':
specifier: ^14.0.0
version: 14.0.0(vue@3.5.22(typescript@5.9.3))
dayjs:
specifier: ^1.11.20
version: 1.11.20
install:
specifier: ^0.13.0
version: 0.13.0
@@ -33,6 +36,9 @@ importers:
specifier: ^3.5.22
version: 3.5.22(typescript@5.9.3)
devDependencies:
'@hey-api/openapi-ts':
specifier: ^0.97.2
version: 0.97.2(typescript@5.9.3)
'@mdi/font':
specifier: ^7.4.47
version: 7.4.47
@@ -233,6 +239,31 @@ packages:
cpu: [x64]
os: [win32]
'@hey-api/codegen-core@0.8.1':
resolution: {integrity: sha512-Iciv2vUCJTW9lWM/ROvyZLblmcbYJHPuXfzb1SzeDVVn4xEXu2ilLU1pq3fn+09FZ/Y0P7VyvRE47UDU6om8xA==}
engines: {node: '>=22.13.0'}
'@hey-api/json-schema-ref-parser@1.4.2':
resolution: {integrity: sha512-ZhCFSKI2ipZHEbgmtUHdyddvRU3wJ4elgCfYUC7T7hZa4EivSrVflTQf2w+v3TuaYxR1Y2V2kq3otqTttrrK8Q==}
engines: {node: '>=22.13.0'}
'@hey-api/openapi-ts@0.97.2':
resolution: {integrity: sha512-nA+y0/I5O9loQMeJKumi6BQ40/Y71N0hIMmXZ/I7rh8jEOzYxSxmf5a4TBEI2Ap4RAfZyh7RJzJfVzT98KUYQQ==}
engines: {node: '>=22.13.0'}
hasBin: true
peerDependencies:
typescript: '>=5.5.3 || >=6.0.0 || 6.0.1-rc'
'@hey-api/shared@0.4.4':
resolution: {integrity: sha512-UZgaQNEdo/OSGLeNXhSv0VQTHQQm5Q2mHOuoYhFPJkNvLVrz7KZtGdKR8O4QPrhyblshxY+caJli08WKM0gREg==}
engines: {node: '>=22.13.0'}
'@hey-api/spec-types@0.2.0':
resolution: {integrity: sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg==}
'@hey-api/types@0.1.4':
resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -249,6 +280,13 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
'@lukeed/ms@2.0.2':
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'}
'@mdi/font@7.4.47':
resolution: {integrity: sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==}
@@ -468,6 +506,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@24.9.2':
resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==}
@@ -563,26 +604,89 @@ packages:
alien-signals@3.0.3:
resolution: {integrity: sha512-2JXjom6R7ZwrISpUphLhf4htUq1aKRCennTJ6u9kFfr3sLmC9+I4CxxVi+McoFnIg+p1HnVrfLT/iCt4Dlz//Q==}
ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
birpc@2.6.1:
resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==}
bmp-js@0.1.0:
resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==}
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
c12@3.3.4:
resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==}
peerDependencies:
magicast: '*'
peerDependenciesMeta:
magicast:
optional: true
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
color-support@1.1.3:
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
hasBin: true
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
confbox@0.2.4:
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
copy-anything@4.0.5:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
daisyui@5.3.10:
resolution: {integrity: sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==}
dayjs@1.11.20:
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
default-browser-id@5.0.1:
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
engines: {node: '>=18'}
default-browser@5.5.0:
resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
engines: {node: '>=18'}
define-lazy-prop@3.0.0:
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
engines: {node: '>=12'}
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dotenv@17.4.2:
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
engines: {node: '>=12'}
enhanced-resolve@5.18.3:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
@@ -599,6 +703,9 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -613,6 +720,13 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
get-tsconfig@4.14.0:
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
giget@3.2.0:
resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==}
hasBin: true
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -626,9 +740,23 @@ packages:
resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==}
engines: {node: '>= 0.10'}
is-docker@3.0.0:
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
hasBin: true
is-electron@2.2.2:
resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==}
is-in-ssh@1.0.0:
resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==}
engines: {node: '>=20'}
is-inside-container@1.0.0:
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
engines: {node: '>=14.16'}
hasBin: true
is-url@1.2.4:
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
@@ -636,10 +764,21 @@ packages:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
is-wsl@3.1.1:
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
engines: {node: '>=16'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
engines: {node: '>= 12.0.0'}
@@ -733,6 +872,13 @@ packages:
encoding:
optional: true
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
open@11.0.0:
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
engines: {node: '>=20'}
opencollective-postinstall@2.0.3:
resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==}
hasBin: true
@@ -740,9 +886,19 @@ packages:
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
perfect-debounce@2.1.0:
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -759,13 +915,30 @@ packages:
typescript:
optional: true
pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
powershell-utils@0.1.0:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
rc9@3.0.1:
resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==}
readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
regenerator-runtime@0.13.11:
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
@@ -774,6 +947,23 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
run-applescript@7.1.0:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -880,6 +1070,15 @@ packages:
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
wsl-utils@0.3.1:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
yaml@2.8.4:
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
engines: {node: '>= 14.6'}
@@ -981,6 +1180,56 @@ snapshots:
'@esbuild/win32-x64@0.25.11':
optional: true
'@hey-api/codegen-core@0.8.1':
dependencies:
'@hey-api/types': 0.1.4
ansi-colors: 4.1.3
c12: 3.3.4
color-support: 1.1.3
transitivePeerDependencies:
- magicast
'@hey-api/json-schema-ref-parser@1.4.2':
dependencies:
'@jsdevtools/ono': 7.1.3
'@types/json-schema': 7.0.15
js-yaml: 4.1.1
'@hey-api/openapi-ts@0.97.2(typescript@5.9.3)':
dependencies:
'@hey-api/codegen-core': 0.8.1
'@hey-api/json-schema-ref-parser': 1.4.2
'@hey-api/shared': 0.4.4
'@hey-api/spec-types': 0.2.0
'@hey-api/types': 0.1.4
'@lukeed/ms': 2.0.2
ansi-colors: 4.1.3
color-support: 1.1.3
commander: 14.0.3
get-tsconfig: 4.14.0
typescript: 5.9.3
transitivePeerDependencies:
- magicast
'@hey-api/shared@0.4.4':
dependencies:
'@hey-api/codegen-core': 0.8.1
'@hey-api/json-schema-ref-parser': 1.4.2
'@hey-api/spec-types': 0.2.0
'@hey-api/types': 0.1.4
ansi-colors: 4.1.3
cross-spawn: 7.0.6
open: 11.0.0
semver: 7.7.4
transitivePeerDependencies:
- magicast
'@hey-api/spec-types@0.2.0':
dependencies:
'@hey-api/types': 0.1.4
'@hey-api/types@0.1.4': {}
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1000,6 +1249,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@jsdevtools/ono@7.1.3': {}
'@lukeed/ms@2.0.2': {}
'@mdi/font@7.4.47': {}
'@rolldown/pluginutils@1.0.0-beta.29': {}
@@ -1147,6 +1400,8 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {}
'@types/node@24.9.2':
dependencies:
undici-types: 7.16.0
@@ -1275,20 +1530,76 @@ snapshots:
alien-signals@3.0.3: {}
ansi-colors@4.1.3: {}
argparse@2.0.1: {}
birpc@2.6.1: {}
bmp-js@0.1.0: {}
bundle-name@4.1.0:
dependencies:
run-applescript: 7.1.0
c12@3.3.4:
dependencies:
chokidar: 5.0.0
confbox: 0.2.4
defu: 6.1.7
dotenv: 17.4.2
exsolve: 1.0.8
giget: 3.2.0
jiti: 2.6.1
ohash: 2.0.11
pathe: 2.0.3
perfect-debounce: 2.1.0
pkg-types: 2.3.1
rc9: 3.0.1
chokidar@5.0.0:
dependencies:
readdirp: 5.0.0
color-support@1.1.3: {}
commander@14.0.3: {}
confbox@0.2.4: {}
copy-anything@4.0.5:
dependencies:
is-what: 5.5.0
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
csstype@3.1.3: {}
daisyui@5.3.10: {}
dayjs@1.11.20: {}
default-browser-id@5.0.1: {}
default-browser@5.5.0:
dependencies:
bundle-name: 4.1.0
default-browser-id: 5.0.1
define-lazy-prop@3.0.0: {}
defu@6.1.7: {}
destr@2.0.5: {}
detect-libc@2.1.2: {}
dotenv@17.4.2: {}
enhanced-resolve@5.18.3:
dependencies:
graceful-fs: 4.2.11
@@ -1327,6 +1638,8 @@ snapshots:
estree-walker@2.0.2: {}
exsolve@1.0.8: {}
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@@ -1334,6 +1647,12 @@ snapshots:
fsevents@2.3.3:
optional: true
get-tsconfig@4.14.0:
dependencies:
resolve-pkg-maps: 1.0.0
giget@3.2.0: {}
graceful-fs@4.2.11: {}
hookable@5.5.3: {}
@@ -1342,14 +1661,32 @@ snapshots:
install@0.13.0: {}
is-docker@3.0.0: {}
is-electron@2.2.2: {}
is-in-ssh@1.0.0: {}
is-inside-container@1.0.0:
dependencies:
is-docker: 3.0.0
is-url@1.2.4: {}
is-what@5.5.0: {}
is-wsl@3.1.1:
dependencies:
is-inside-container: 1.0.0
isexe@2.0.0: {}
jiti@2.6.1: {}
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
lightningcss-android-arm64@1.30.2:
optional: true
@@ -1413,12 +1750,29 @@ snapshots:
dependencies:
whatwg-url: 5.0.0
ohash@2.0.11: {}
open@11.0.0:
dependencies:
default-browser: 5.5.0
define-lazy-prop: 3.0.0
is-in-ssh: 1.0.0
is-inside-container: 1.0.0
powershell-utils: 0.1.0
wsl-utils: 0.3.1
opencollective-postinstall@2.0.3: {}
path-browserify@1.0.1: {}
path-key@3.1.1: {}
pathe@2.0.3: {}
perfect-debounce@1.0.0: {}
perfect-debounce@2.1.0: {}
picocolors@1.1.1: {}
picomatch@4.0.3: {}
@@ -1430,14 +1784,31 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
pkg-types@2.3.1:
dependencies:
confbox: 0.2.4
exsolve: 1.0.8
pathe: 2.0.3
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
powershell-utils@0.1.0: {}
rc9@3.0.1:
dependencies:
defu: 6.1.7
destr: 2.0.5
readdirp@5.0.0: {}
regenerator-runtime@0.13.11: {}
resolve-pkg-maps@1.0.0: {}
rfdc@1.4.1: {}
rollup@4.52.5:
@@ -1468,6 +1839,16 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.52.5
fsevents: 2.3.3
run-applescript@7.1.0: {}
semver@7.7.4: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
shebang-regex@3.0.0: {}
source-map-js@1.2.1: {}
speakingurl@14.0.1: {}
@@ -1550,6 +1931,15 @@ snapshots:
tr46: 0.0.3
webidl-conversions: 3.0.1
which@2.0.2:
dependencies:
isexe: 2.0.0
wsl-utils@0.3.1:
dependencies:
is-wsl: 3.1.1
powershell-utils: 0.1.0
yaml@2.8.4:
optional: true
+28 -16
View File
@@ -1,8 +1,13 @@
from ninja import NinjaAPI
from submissions.api import router as submissions_router
from submissions.schemas import UserInfoOut
from django.conf import settings
from django.core.cache import cache
from django.http import HttpRequest
from opus_magnum.api import router as submissions_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 market.api import router as market_router
# Create the main API instance
api = NinjaAPI(
@@ -28,10 +33,14 @@ The Noita Submission API allows clients to upload the result of the log file of
# Add authentication for protected endpoints
# api.auth = django_auth # Uncomment if you want global auth
# Include the submissions router
api.add_router("/submissions/", submissions_router, tags=["submissions"])
# Include the opus_magnum router
api.add_router("/opus-magnum/", submissions_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"])
if settings.MARKET_ENABLED:
api.add_router("/market/", market_router)
# Health check endpoint
@@ -41,6 +50,19 @@ def health_check(request):
return {"status": "healthy", "service": "polylan-submitter-api"}
# Cache management endpoint
@api.post("/cache/clear")
def clear_cache(request: HttpRequest):
"""Clear all API caches (admin only)"""
if not request.user.is_authenticated or not request.user.is_staff:
return 403, {"detail": "Admin access required"}
keys = cache.keys("api:*")
cache.delete_many(keys)
return {"detail": f"Cleared {len(keys)} cache entries"}
# User info endpoint
@api.get("/user", response=UserInfoOut)
def get_user_info(request):
@@ -48,18 +70,8 @@ def get_user_info(request):
user = request.user
if user.is_authenticated:
return {
"id": user.id,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"email": user.email,
"is_authenticated": True,
"is_staff": user.is_staff,
"is_superuser": user.is_superuser,
"cas_groups": getattr(user, "cas_groups", []),
}
else:
return user
return {
"is_authenticated": False,
"is_staff": False,
@@ -41,8 +41,10 @@ INSTALLED_APPS = [
"django_vite",
"accounts",
"animations",
"submissions",
"opus_magnum",
"noita",
"games",
"market",
]
MIDDLEWARE = [
@@ -145,6 +147,18 @@ MEDIA_ROOT = BASE_DIR / "media"
FILE_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB
DATA_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB
# Caching Configuration
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/11",
"TIMEOUT": 300, # 5 minutes default
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
}
}
# Allowed file types for submissions
ALLOWED_SUBMISSION_TYPES = [
"image/jpeg",
@@ -155,6 +169,9 @@ ALLOWED_SUBMISSION_TYPES = [
"video/webm",
]
# Market app settings
MARKET_ENABLED = os.environ.get("MARKET_ENABLED", "true").lower() == "true"
# Authentication backends
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
@@ -181,7 +198,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",
+18 -2
View File
@@ -23,17 +23,31 @@ 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 market.views import market_home
from .api import api
NOITA_APP_ID = 881100
OPUS_MAGNUM_APP_ID = 558990
@login_required
def home(request: HttpRequest):
return render(request, "home.html", {})
from django.conf import settings
return render(
request,
"home.html",
{
"market_enabled": settings.MARKET_ENABLED,
},
)
@login_required
@require_game_enabled(OPUS_MAGNUM_APP_ID)
def opus_magnum_home(request: HttpRequest):
from submissions.models import SteamCollection
from opus_magnum.models import SteamCollection
return render(
request,
@@ -45,6 +59,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", {})
@@ -54,6 +69,7 @@ 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("", home, name="home"),
-1
View File
@@ -7,7 +7,6 @@ import requests
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import BaseBackend
from furl import furl
class SimpleCASBackend(BaseBackend):
+58 -25
View File
@@ -1,23 +1,18 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { ref, onMounted } from "vue";
import { gamesApiListGames } from "./api";
import type { GamesApiListGamesResponse } from "./api/types.gen";
const games = computed(() => [
{
id: "opus-magnum",
title: "Opus Magnum",
description: "Submit your best Opus Magnum puzzle solutions",
appId: 558990,
path: "/opus-magnum",
},
{
id: "noita",
title: "Noita",
description: "Submit your greatest Noita achievements",
appId: 881100,
path: "/noita",
},
]);
interface Props {
marketEnabled?: string | boolean;
}
const props = withDefaults(defineProps<Props>(), {
marketEnabled: true,
});
const games = ref<GamesApiListGamesResponse | undefined>();
const loading = ref(true);
const imageErrors = ref<Set<number>>(new Set());
const getHeaderImage = (appId: number) => {
@@ -31,6 +26,22 @@ const onImageError = (appId: number) => {
const navigate = (path: string) => {
window.location.href = path;
};
const isMarketEnabled = () => {
if (typeof props.marketEnabled === 'string') {
return props.marketEnabled === 'true';
}
return Boolean(props.marketEnabled);
};
onMounted(async () => {
// Fetch games list
const response = await gamesApiListGames();
if (response.data) {
games.value = response.data;
}
loading.value = false;
});
</script>
<template>
@@ -44,13 +55,38 @@ const navigate = (path: string) => {
</p>
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span>
</div>
<!-- Cards Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div v-for="game in games" :key="game.id" @click="navigate(game.path)"
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Market Card -->
<div v-if="isMarketEnabled()" @click="navigate('/market')"
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">Market</h2>
<p class="text-base-content/70">Place your bets and compete</p>
<div class="card-actions justify-end mt-4">
<button class="btn btn-primary">
<i class="mdi mdi-arrow-right mr-2"></i>
Place bets
</button>
</div>
</div>
</div>
<!-- Game Cards -->
<div v-for="game in games" :key="game.steam_app_id" @click="navigate(game.path)"
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-base-300 overflow-hidden">
<img v-if="!imageErrors.has(game.appId)" :src="getHeaderImage(game.appId)" :alt="game.title"
@error="onImageError(game.appId)" class="w-full h-full object-cover" />
<img v-if="!imageErrors.has(game.steam_app_id)" :src="getHeaderImage(game.steam_app_id)" :alt="game.name"
@error="onImageError(game.steam_app_id)" class="w-full h-full object-cover" />
<div v-else
class="w-full h-full bg-gradient-to-br from-blue-600 to-blue-400 flex items-center justify-center text-white">
<i class="mdi mdi-gamepad-variant text-5xl"></i>
@@ -58,8 +94,7 @@ const navigate = (path: string) => {
<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">{{ game.title }}</h2>
<p class="text-base-content/70">{{ game.description }}</p>
<h2 class="card-title text-2xl">{{ game.name }}</h2>
<div class="card-actions justify-end mt-4">
<button class="btn btn-primary">
<i class="mdi mdi-arrow-right mr-2"></i>
@@ -75,7 +110,5 @@ const navigate = (path: string) => {
<p>Select a game above to begin submitting</p>
</div>
</div>
</div>
</template>
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useMarketStore } from "./stores/market";
import MarketCard from "./components/MarketCard.vue";
import UserBets from "./components/UserBets.vue";
const marketStore = useMarketStore();
const { markets, userInfo, isLoading } = storeToRefs(marketStore);
const goHome = () => {
window.location.href = "/";
};
const reloadPage = async () => {
await marketStore.refreshPage();
};
onMounted(() => {
marketStore.initializeMarketPage();
});
</script>
<template>
<div class="min-h-screen bg-base-200">
<!-- Header -->
<div class="navbar bg-base-100 shadow-lg">
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
<button @click="goHome" class="btn btn-primary btn-sm">
<i class="mdi mdi-arrow-left"></i>
Back
</button>
<h1 class="text-xl font-bold">Market</h1>
<div class="flex-1"></div>
<div class="flex items-center gap-4">
<div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
<div class="text-sm">
<span class="font-medium">{{ userInfo.username }}</span>
<span v-if="userInfo.is_superuser" class="badge badge-warning badge-xs ml-1">Admin</span>
</div>
</div>
<div v-else class="text-sm text-base-content/70">Not logged in</div>
<a href="/api/docs" class="btn btn-xs">API docs</a>
<a href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
</div>
</div>
</div>
<!-- Main Content -->
<div class="container min-w-3/4 mx-auto px-4 py-8">
<!-- Header -->
<div class="mb-8">
<h2 class="text-3xl font-bold mb-2">Market</h2>
<p class="text-base-content/70">Place your bets on upcoming events</p>
</div>
<!-- Loading -->
<div v-if="isLoading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span>
</div>
<!-- Content -->
<template v-else>
<div class="space-y-8">
<!-- My Bets Section -->
<div v-if="userInfo?.is_authenticated">
<div class="flex justify-between items-center mb-4">
<h3 class="text-2xl font-bold flex items-center gap-2">
<i class="mdi mdi-heart text-error"></i>
My Bets
</h3>
<div class="text-lg font-semibold">
<span class="text-primary">{{ userInfo.points }}</span>
<span class="text-base-content/60 ml-1">pts</span>
</div>
</div>
<UserBets :markets="markets" @refresh="reloadPage" />
</div>
<!-- All Markets Section -->
<div>
<div class="flex justify-between items-center mb-4">
<h3 class="text-2xl font-bold flex items-center gap-2">
<i class="mdi mdi-list"></i>
All Markets
</h3>
<a v-if="userInfo?.is_superuser" href="/admin/market/market/add/" class="btn btn-sm btn-primary">
<i class="mdi mdi-plus"></i>
Create Market
</a>
</div>
<div v-if="markets.length === 0" class="alert">
<i class="mdi mdi-information mr-2"></i>
<span>No markets available</span>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<MarketCard v-for="market in markets" :key="market.uuid" :market="market" @refresh="reloadPage" />
</div>
</div>
</div>
</template>
</div>
</div>
</template>
+243 -226
View File
@@ -1,26 +1,106 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ref, computed, onMounted } from "vue";
import { storeToRefs } from "pinia";
import dayjs from "dayjs";
import RankBadge from "@/components/RankBadge.vue";
import { useNoitaStore, type Objective } from "@/stores/noita";
import {
createColumnHelper,
useVueTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
type ColumnFiltersState,
type SortingState,
} from "@tanstack/vue-table";
interface Objective {
objectiv_id: string;
count: number;
}
const userInfo = ref({
username: "Player",
rank: null as number | null,
score: 0,
runsSubmitted: 0,
});
const noitaStore = useNoitaStore();
const { userInfo, objectives, leaderboard, isLoadingLeaderboard, isUploading } = storeToRefs(noitaStore);
const uploadedFiles = ref<File[]>([]);
const isUploading = ref(false);
const isDragover = ref(false);
const objectives = ref<Objective[]>([]);
const isLoadingObjectives = ref(false);
const isLoadingLeaderboard = ref(false);
const leaderboard = ref<any[]>([]);
const isLeaderboardModalOpen = ref(false);
const columnHelper = createColumnHelper<Objective>();
const sorting = ref<SortingState>([]);
const columnFilters = ref<ColumnFiltersState>([]);
const formatDate = (dateString: string | null) => {
if (!dateString) {
return ""
}
const date = dayjs(dateString);
return date.format("MMM DD, YYYY HH:mm");
};
const columns = [
columnHelper.accessor("objectiv_id", {
header: "Objective ID",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("total_points", {
header: "Your points",
cell: (info) => info.getValue() || 0,
}),
columnHelper.accessor("first_seen_at", {
header: "First seen",
cell: (info) => formatDate(info.getValue()),
sortingFn: (rowA, rowB) => {
const dateA = dayjs(rowA.original.first_seen_at);
const dateB = dayjs(rowB.original.first_seen_at);
if (!rowA.original.first_seen_at) {
return rowB.original.first_seen_at ? 1 : 0
}
if (!rowB.original.first_seen_at) {
return rowA.original.first_seen_at ? 0 : 1
}
return dateA.isBefore(dateB) ? -1 : dateA.isAfter(dateB) ? 1 : 0;
},
}),
columnHelper.accessor("seed", {
header: "Seed",
cell: (info) => info.getValue(),
}),
];
const table = computed(() =>
useVueTable({
get data() {
return objectives.value;
},
columns,
state: {
get sorting() {
return sorting.value;
},
get columnFilters() {
return columnFilters.value;
},
},
onSortingChange: (updater) => {
sorting.value =
typeof updater === "function" ? updater(sorting.value) : updater;
},
onColumnFiltersChange: (updater) => {
columnFilters.value =
typeof updater === "function" ? updater(columnFilters.value) : updater;
},
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
filterFns: {
fuzzy: (row, columnId, value) => {
const itemData = row.getValue(columnId);
const searchValue = value.toLowerCase();
if (columnId === "first_seen_at") {
return formatDate(itemData as string).includes(searchValue)
}
return String(itemData).toLowerCase().includes(searchValue);
},
},
})
);
const filteredObjectives = computed(() => table.value.getRowModel().rows);
const handleFileUpload = (event: Event) => {
const input = event.target as HTMLInputElement;
@@ -54,41 +134,13 @@ const handleDrop = (event: DragEvent) => {
const submitRun = async () => {
if (uploadedFiles.value.length === 0) return;
isUploading.value = true;
try {
for (const file of uploadedFiles.value) {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/noita/submit", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
alert(`Error submitting ${file.name}: ${error.detail || "Unknown error"}`);
return;
}
const result = await response.json();
console.log("Submission successful:", result);
}
await noitaStore.submitRun(uploadedFiles.value);
uploadedFiles.value = [];
alert("Run submitted successfully!");
// Refresh objectives, score, and rank after successful submission
await Promise.all([
fetchObjectives(),
fetchUserResults(),
fetchLeaderboard(),
]);
} catch (error) {
console.error("Error submitting run:", error);
alert("Error submitting run. Please try again.");
} finally {
isUploading.value = false;
}
};
@@ -96,82 +148,18 @@ const goHome = () => {
window.location.href = "/";
};
const fetchObjectives = async () => {
isLoadingObjectives.value = true;
const clearCache = async () => {
try {
const response = await fetch("/api/noita/objectives");
if (!response.ok) throw new Error("Failed to fetch objectives");
objectives.value = await response.json();
await noitaStore.clearCache();
alert("Cache cleared successfully!");
} catch (error) {
console.error("Error fetching objectives:", error);
} finally {
isLoadingObjectives.value = false;
console.error("Error clearing cache:", error);
alert("Error clearing cache. Please try again.");
}
};
const fetchUserResults = async () => {
try {
const response = await fetch("/api/noita/results");
if (!response.ok) throw new Error("Failed to fetch results");
const results = await response.json();
userInfo.value.score = results.total_score;
userInfo.value.runsSubmitted = results.objectives.length;
} catch (error) {
console.error("Error fetching results:", error);
}
};
const fetchLeaderboard = async () => {
isLoadingLeaderboard.value = true;
try {
const response = await fetch("/api/noita/leaderboard");
if (!response.ok) throw new Error("Failed to fetch leaderboard");
const data = await response.json();
leaderboard.value = data.leaderboard;
// Find current user's rank
const userRank = leaderboard.value.find(
(entry: any) => entry.username === userInfo.value.username
);
if (userRank) {
userInfo.value.rank = userRank.rank;
userInfo.value.score = userRank.total_score;
}
} catch (error) {
console.error("Error fetching leaderboard:", error);
} finally {
isLoadingLeaderboard.value = false;
}
};
const loadUserData = async () => {
// Get user info first
try {
const response = await fetch("/api/user");
if (response.ok) {
const user = await response.json();
if (user.is_authenticated) {
userInfo.value.username = user.username;
}
}
} catch (error) {
console.error("Error fetching user info:", error);
}
// Fetch objectives, results, and leaderboard
await Promise.all([
fetchObjectives(),
fetchUserResults(),
fetchLeaderboard(),
]);
};
onMounted(() => {
loadUserData();
noitaStore.loadUserData();
});
</script>
@@ -179,7 +167,7 @@ onMounted(() => {
<div class="min-h-screen bg-base-200">
<!-- Header -->
<div class="navbar bg-base-100 shadow-lg">
<div class="container mx-auto w-full flex items-center gap-4">
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
<button @click="goHome" class="btn btn-primary btn-sm">
<i class="mdi mdi-arrow-left"></i>
Back
@@ -192,19 +180,21 @@ onMounted(() => {
</div>
<!-- Main Content -->
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="container min-w-3/4 mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Left Column: User Ranking -->
<div class="lg:col-span-1">
<div class="card bg-base-100 shadow-lg sticky top-8">
<div class="bg-gradient-to-br from-purple-600 to-purple-400 p-6 text-white rounded-t-2xl">
<i class="mdi mdi-trophy text-4xl"></i>
<h2 class="text-2xl font-bold mt-2">Your Ranking</h2>
<div class="bg-gradient-to-br from-purple-600 to-purple-400 p-8 text-white rounded-t-2xl">
<h2 class="text-3xl font-bold">
<i class="mdi mdi-trophy text-3xl"></i>
Your Ranking
</h2>
</div>
<div class="card-body">
<div class="text-center mb-6">
<p class="text-sm text-base-content/70">Player</p>
<p class="text-3xl font-bold">{{ userInfo.username }}</p>
<div class="card-body p-8">
<div class="text-center mb-8">
<p class="text-base text-base-content/70">Player</p>
<p class="text-4xl font-bold mt-2">{{ userInfo.username }}</p>
</div>
<div class="divider"></div>
@@ -213,36 +203,77 @@ onMounted(() => {
<span class="loading loading-spinner loading-lg"></span>
</div>
<div v-else class="space-y-4">
<div v-else class="space-y-6">
<div class="text-center">
<p class="text-sm text-base-content/70 mb-1">Current Rank</p>
<p v-if="userInfo.rank !== null" class="text-4xl font-bold text-primary">
#{{ userInfo.rank }}
</p>
<p v-else class="text-2xl text-base-content/50">No rank yet</p>
<p class="text-base text-base-content/70 mb-3">Current Rank</p>
<RankBadge :rank="userInfo.rank" />
</div>
<div class="text-center">
<p class="text-sm text-base-content/70 mb-1">Total Score</p>
<p class="text-2xl font-bold">{{ userInfo.score.toLocaleString() }}</p>
<p class="text-base text-base-content/70 mb-2">Total Score</p>
<p class="text-3xl font-bold">{{ userInfo.score.toLocaleString() }}</p>
</div>
<div class="text-center">
<p class="text-sm text-base-content/70 mb-1">Objectives Completed</p>
<p class="text-2xl font-bold">{{ userInfo.runsSubmitted }}</p>
<p class="text-base text-base-content/70 mb-2">Objectives Completed</p>
<p class="text-3xl font-bold">{{ userInfo.runsSubmitted }}</p>
</div>
<div class="text-center">
<p class="text-base text-base-content/70 mb-2">Deaths</p>
<p class="text-3xl font-bold text-error">{{ userInfo.deathsCount }}</p>
</div>
</div>
<button @click="isLeaderboardModalOpen = true" class="btn btn-outline btn-sm w-full mt-6">
<i class="mdi mdi-trophy mr-1"></i>
View Full Leaderboard
<!-- Leaderboard Table -->
<div class="mt-6">
<h3 class="font-bold text-lg mb-3">Global Leaderboard</h3>
<div class="overflow-x-auto">
<table class="table table-sm w-full">
<thead>
<tr>
<th>Rank</th>
<th>Player</th>
<th class="text-right">Score</th>
<th class="text-right">Objectives</th>
<th class="text-right">Deaths</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in leaderboard" :key="entry.username"
:class="{ 'bg-primary/20': entry.username === userInfo.username }">
<td class="font-bold">
<RankBadge :rank="entry.rank" />
</td>
<td class="text-sm">
{{ entry.username }}
<span v-if="entry.username === userInfo.username" class="badge badge-primary badge-sm ml-1">
You
</span>
<span v-if="entry.is_staff" class="badge badge-warning badge-sm ml-1">
admin
</span>
</td>
<td class="text-right text-sm font-bold text-primary">{{ entry.total_score.toLocaleString() }}
</td>
<td class="text-right text-sm">{{ entry.objectives_count }}</td>
<td class="text-right text-sm text-error">{{ entry.deaths_count }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<button v-if="userInfo.isStaff" @click="clearCache" class="btn btn-error btn-sm w-full mt-3">
<i class="mdi mdi-cache-clear mr-1"></i>
Clear Cache
</button>
</div>
</div>
</div>
<!-- Right Column: Upload -->
<div class="lg:col-span-2">
<div class="lg:col-span-1">
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title text-2xl mb-6">
@@ -256,13 +287,13 @@ onMounted(() => {
isDragover ? 'border-primary bg-primary/10' : 'border-base-300 hover:border-primary'
]">
<input type="file" multiple @change="handleFileUpload" class="hidden" id="file-upload"
accept="video/*,image/*" />
accept="text/plain,text/x-log" />
<label for="file-upload" class="cursor-pointer flex flex-col items-center gap-3">
<i
:class="['mdi text-4xl', isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50']"></i>
<div>
<p class="font-semibold">Click to upload or drag and drop</p>
<p class="text-sm text-base-content/70">Video or image files (MP4, PNG, etc.)</p>
<p class="text-sm text-base-content/70">The log file <code>polylan_mod_log.txt</code></p>
</div>
</label>
</div>
@@ -309,111 +340,97 @@ onMounted(() => {
<div class="card-body">
<h2 class="card-title text-2xl mb-6">
<i class="mdi mdi-view-list text-purple-500 mr-2"></i>
Your Objectives
Objectives
</h2>
<div v-if="isLoadingObjectives" class="flex justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
<div v-else-if="objectives.length === 0" class="text-center py-8">
<div v-if="objectives.length === 0" class="text-center py-8">
<p class="text-base-content/70 mb-2">No objectives completed yet</p>
<p class="text-sm text-base-content/50">Submit your runs to unlock objectives!</p>
</div>
<div v-else class="overflow-x-auto">
<div v-if="objectives.length > 0" class="space-y-4">
<!-- Search Input -->
<input :value="columnFilters.find((f) => f.id === 'objectiv_id')?.value ?? ''" @input="
(e) => {
const target = e.target as HTMLInputElement;
table.getColumn('objectiv_id')?.setFilterValue(target.value);
}
" type="text" placeholder="Search objectives..." class="input input-bordered w-full" />
<!-- Results Summary -->
<div class="text-sm text-base-content/70">
Showing {{ filteredObjectives.length }} of {{ objectives.length }} objectives
</div>
<!-- Objectives Table -->
<div v-if="filteredObjectives.length > 0" class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Objective ID</th>
<th class="text-right">Count</th>
<th v-for="header in table.getHeaderGroups()[0]?.headers" :key="header.id" :class="[
'cursor-pointer hover:bg-base-300',
header.column.columnDef.id === 'objectiv_id' ? 'text-left' : 'text-right',
]" @click="header.column.toggleSorting()">
<div class="flex items-center justify-between">
<span v-if="header.column.columnDef.id === 'objectiv_id'">
{{ header.isPlaceholder ? null : header.column.columnDef.header }}
</span>
<span v-else class="ml-auto">
{{ header.isPlaceholder ? null : header.column.columnDef.header }}
</span>
<i v-if="header.column.getIsSorted()" :class="[
'mdi ml-2',
header.column.getIsSorted() === 'desc'
? 'mdi-arrow-down'
: 'mdi-arrow-up',
]"></i>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="obj in objectives" :key="obj.objectiv_id">
<td class="font-medium">{{ obj.objectiv_id }}</td>
<td class="text-right">
<span class="badge badge-primary badge-lg">{{ obj.count }}</span>
<tr v-for="row in filteredObjectives" :key="row.id">
<td v-for="cell in row.getVisibleCells()" :key="cell.id" :class="[
cell.column.id === 'objectiv_id'
? 'font-medium'
: 'text-right',
]">
<template v-if="cell.column.id === 'objectiv_id'">
<a :href="`https://noita.wiki.gg/wiki/${row.original.objectiv_id}`" target="_blank">
{{ row.original.display_string }}
<i class="mdi mdi-open-in-new"></i>
</a>
</template>
<template v-else-if="cell.column.id === 'total_points'">
<span :class="row.original.count >= row.original.max_count ? 'text-primary' : 'text-error'">
{{ row.original.total_points }} / {{ row.original.points_per_objectiv *
row.original.max_count }}
</span>
</template>
<template v-else-if="cell.column.id === 'first_seen_at'">
<span :title="formatDate(row.original.first_seen_at)">
{{ formatDate(row.original.first_seen_at) }}
</span>
</template>
<template v-else>
{{ cell.renderValue() }}
</template>
</td>
</tr>
</tbody>
</table>
</div>
<!-- No Results -->
<div v-if="filteredObjectives.length === 0" class="text-center py-8">
<p class="text-base-content/70">No objectives match your search</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Leaderboard Modal -->
<div v-if="isLeaderboardModalOpen" class="modal modal-open">
<div class="modal-box max-w-4xl">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">
<i class="mdi mdi-trophy text-yellow-500 mr-2"></i>
Global Leaderboard
</h3>
<button
@click="isLeaderboardModalOpen = false"
class="btn btn-sm btn-circle btn-ghost"
>
<i class="mdi mdi-close"></i>
</button>
</div>
<!-- Leaderboard Table -->
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Rank</th>
<th>Username</th>
<th class="text-right">Objectives</th>
<th class="text-right">Score</th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in leaderboard"
:key="entry.username"
:class="{ 'bg-primary/20': entry.username === userInfo.username }"
>
<td class="font-bold">
<span
v-if="entry.rank === 1"
class="badge badge-warning badge-lg"
>
🏆 #{{ entry.rank }}
</span>
<span v-else-if="entry.rank === 2" class="badge badge-lg">
🥈 #{{ entry.rank }}
</span>
<span v-else-if="entry.rank === 3" class="badge badge-lg">
🥉 #{{ entry.rank }}
</span>
<span v-else>#{{ entry.rank }}</span>
</td>
<td class="font-medium">
{{ entry.username }}
<span
v-if="entry.username === userInfo.username"
class="badge badge-primary badge-sm ml-2"
>
You
</span>
</td>
<td class="text-right">{{ entry.objectives_count }}</td>
<td class="text-right font-bold">{{ entry.total_score.toLocaleString() }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="leaderboard.length === 0" class="text-center py-8">
<p class="text-base-content/70">No entries yet</p>
</div>
</div>
<div class="modal-backdrop" @click="isLeaderboardModalOpen = false"></div>
</div>
</div>
</template>
+51 -34
View File
@@ -4,10 +4,14 @@ import PuzzleCard from "@/components/PuzzleCard.vue";
import SubmissionForm from "@/components/SubmissionForm.vue";
import AdminPanel from "@/components/AdminPanel.vue";
import Results from "@/components/Results.vue";
import { apiService, errorHelpers } from "@/services/apiService";
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 { usePuzzlesStore } from "@/stores/puzzles";
import { useSubmissionsStore } from "@/stores/submissions";
import type { PuzzleResponse, UserInfo } from "@/types";
import type { PuzzleResponse, UserInfo, SteamCollection } from "@/types";
import { useCountdown } from "@vueuse/core";
import { storeToRefs } from "pinia";
@@ -26,6 +30,7 @@ const { openSubmissionModal, loadSubmissions, closeSubmissionModal } =
// Local state
const userInfo = ref<UserInfo | null>(null);
const collection = ref<SteamCollection | null>(null);
const isLoading = ref(true);
const error = ref<string>("");
@@ -34,6 +39,10 @@ const isSuperuser = computed(() => {
return userInfo.value?.is_superuser || false;
});
const isTournamentClosed = computed(() => {
return !!(collection.value && !collection.value.accepting_submissions);
});
// Computed property to get responses grouped by puzzle
const responsesByPuzzle = computed(() => {
const grouped: Record<number, PuzzleResponse[]> = {};
@@ -58,14 +67,24 @@ async function initialize() {
// Load user info
console.log("Loading user info...");
const userResponse = await apiService.getUserInfo();
const userResponse = await polylanSubmitterApiGetUserInfo();
if (userResponse.data) {
userInfo.value = userResponse.data;
userInfo.value = userResponse.data as UserInfo;
console.log("User info loaded:", userResponse.data);
} else if (userResponse.error) {
console.warn("User info error:", userResponse.error);
}
// Load collection data
console.log("Loading collection...");
const collectionResponse = await opusMagnumApiGetCollection();
if (collectionResponse.data) {
collection.value = collectionResponse.data as SteamCollection;
console.log("Collection loaded:", collectionResponse.data);
} else if (collectionResponse.error) {
console.warn("Collection error:", collectionResponse.error);
}
// Load puzzles from API using store
console.log("Loading puzzles...");
await puzzlesStore.loadPuzzles();
@@ -118,7 +137,7 @@ const goHome = () => {
<div class="min-h-screen bg-base-200">
<!-- Header -->
<div class="navbar bg-base-100 shadow-lg">
<div class="container mx-auto w-full flex items-center gap-4">
<div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
<button @click="goHome" class="btn btn-primary btn-sm">
<i class="mdi mdi-arrow-left"></i>
Back
@@ -126,17 +145,10 @@ const goHome = () => {
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
<div class="flex-1"></div>
<div class="flex items-center gap-4">
<div
v-if="userInfo?.is_authenticated"
class="flex items-center gap-2"
>
<div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
<div class="text-sm">
<span class="font-medium">{{ userInfo.username }}</span>
<span
v-if="userInfo.is_superuser"
class="badge badge-warning badge-xs ml-1"
>Admin</span
>
<span v-if="userInfo.is_superuser" class="badge badge-warning badge-xs ml-1">Admin</span>
</div>
</div>
<div v-else class="text-sm text-base-content/70">Not logged in</div>
@@ -147,7 +159,7 @@ const goHome = () => {
</div>
<!-- Main Content -->
<div class="container mx-auto px-4 py-8">
<div class="container min-w-3/4 mx-auto px-4 py-8">
<!-- Loading State -->
<div v-if="userInfo?.is_superuser" class="flex justify-center">
<div class="text-center">
@@ -158,10 +170,7 @@ const goHome = () => {
</div>
</div>
<div
v-if="isLoading"
class="flex justify-center items-center min-h-[400px]"
>
<div v-if="isLoading" class="flex justify-center items-center min-h-[400px]">
<div class="text-center">
<span class="loading loading-spinner loading-lg"></span>
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
@@ -183,6 +192,16 @@ const goHome = () => {
<!-- Main Content -->
<div v-else class="space-y-8">
<!-- Results Section (only when tournament is closed) -->
<div v-if="isTournamentClosed" class="space-y-8">
<TopUsersLeaderboard />
<PuzzleResults />
<Winners />
</div>
<template v-else>
<!-- Collection Info -->
<div class="mb-8">
<div class="card bg-base-100 shadow-lg">
@@ -192,11 +211,18 @@ const goHome = () => {
{{ props.collectionDescription }}
</p>
<div class="flex flex-wrap gap-4 mt-4">
<button @click="openSubmissionModal" class="btn btn-primary">
<button @click="openSubmissionModal" class="btn btn-primary" :disabled="isTournamentClosed">
<i class="mdi mdi-plus mr-2"></i>
Submit Solution
</button>
</div>
<div v-if="isTournamentClosed" class="alert alert-warning mt-4">
<i class="mdi mdi-alert-circle text-xl"></i>
<div>
<h3 class="font-bold">Tournament Closed</h3>
<div class="text-sm">This tournament is no longer accepting new submissions.</div>
</div>
</div>
</div>
</div>
</div>
@@ -210,12 +236,8 @@ const goHome = () => {
<!-- Puzzles Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<PuzzleCard
v-for="puzzle in puzzlesStore.puzzles"
:key="puzzle.id"
:puzzle="puzzle"
:responses="responsesByPuzzle[puzzle.id] || []"
/>
<PuzzleCard v-for="puzzle in puzzlesStore.puzzles" :key="puzzle.id" :puzzle="puzzle"
:responses="responsesByPuzzle[puzzle.id] || []" />
</div>
<!-- Empty State -->
@@ -226,6 +248,7 @@ const goHome = () => {
Check back later for new puzzle collections!
</p>
</div>
</template>
</div>
</div>
@@ -234,18 +257,12 @@ const goHome = () => {
<div class="modal-box max-w-6xl">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">Submit Solution</h3>
<button
@click="closeSubmissionModal"
class="btn btn-sm btn-circle btn-ghost"
>
<button @click="closeSubmissionModal" class="btn btn-sm btn-circle btn-ghost">
<i class="mdi mdi-close"></i>
</button>
</div>
<SubmissionForm
:puzzles="puzzlesStore.puzzles"
:find-puzzle-by-name="findPuzzleByName"
/>
<SubmissionForm :puzzles="puzzlesStore.puzzles" :find-puzzle-by-name="findPuzzleByName" />
</div>
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
</div>
+16
View File
@@ -0,0 +1,16 @@
// This file is auto-generated by @hey-api/openapi-ts
import { type ClientOptions, type Config, createClient, createConfig } from './client';
import type { ClientOptions as ClientOptions2 } from './types.gen';
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions2> = (override?: Config<ClientOptions & T>) => Config<Required<ClientOptions> & T>;
export const client = createClient(createConfig<ClientOptions2>());
@@ -0,0 +1,277 @@
// This file is auto-generated by @hey-api/openapi-ts
import { createSseClient } from '../core/serverSentEvents.gen';
import type { HttpMethod } from '../core/types.gen';
import { getValidRequestBody } from '../core/utils.gen';
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen';
import {
buildUrl,
createConfig,
createInterceptors,
getParseAs,
mergeConfigs,
mergeHeaders,
setAuthParams,
} from './utils.gen';
type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
body?: any;
headers: ReturnType<typeof mergeHeaders>;
};
export const createClient = (config: Config = {}): Client => {
let _config = mergeConfigs(createConfig(), config);
const getConfig = (): Config => ({ ..._config });
const setConfig = (config: Config): Config => {
_config = mergeConfigs(_config, config);
return getConfig();
};
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();
const beforeRequest = async <
TData = unknown,
TResponseStyle extends 'data' | 'fields' = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
>(
options: RequestOptions<TData, TResponseStyle, ThrowOnError, Url>,
) => {
const opts = {
..._config,
...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers),
serializedBody: undefined as string | undefined,
};
if (opts.security) {
await setAuthParams(opts);
}
if (opts.requestValidator) {
await opts.requestValidator(opts);
}
if (opts.body !== undefined && opts.bodySerializer) {
opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined;
}
// remove Content-Type header if body is empty to avoid sending invalid requests
if (opts.body === undefined || opts.serializedBody === '') {
opts.headers.delete('Content-Type');
}
const resolvedOpts = opts as typeof opts &
ResolvedRequestOptions<TResponseStyle, ThrowOnError, Url>;
const url = buildUrl(resolvedOpts);
return { opts: resolvedOpts, url };
};
const request: Client['request'] = async (options) => {
const throwOnError = options.throwOnError ?? _config.throwOnError;
const responseStyle = options.responseStyle ?? _config.responseStyle;
let request: Request | undefined;
let response: Response | undefined;
try {
const { opts, url } = await beforeRequest(options);
const requestInit: ReqInit = {
redirect: 'follow',
...opts,
body: getValidRequestBody(opts),
};
request = new Request(url, requestInit);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!;
response = await _fetch(request);
for (const fn of interceptors.response.fns) {
if (fn) {
response = await fn(response, request, opts);
}
}
const result = {
request,
response,
};
if (response.ok) {
const parseAs =
(opts.parseAs === 'auto'
? getParseAs(response.headers.get('Content-Type'))
: opts.parseAs) ?? 'json';
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
let emptyData: any;
switch (parseAs) {
case 'arrayBuffer':
case 'blob':
case 'text':
emptyData = await response[parseAs]();
break;
case 'formData':
emptyData = new FormData();
break;
case 'stream':
emptyData = response.body;
break;
case 'json':
default:
emptyData = {};
break;
}
return opts.responseStyle === 'data'
? emptyData
: {
data: emptyData,
...result,
};
}
let data: any;
switch (parseAs) {
case 'arrayBuffer':
case 'blob':
case 'formData':
case 'text':
data = await response[parseAs]();
break;
case 'json': {
// Some servers return 200 with no Content-Length and empty body.
// response.json() would throw; read as text and parse if non-empty.
const text = await response.text();
data = text ? JSON.parse(text) : {};
break;
}
case 'stream':
return opts.responseStyle === 'data'
? response.body
: {
data: response.body,
...result,
};
}
if (parseAs === 'json') {
if (opts.responseValidator) {
await opts.responseValidator(data);
}
if (opts.responseTransformer) {
data = await opts.responseTransformer(data);
}
}
return opts.responseStyle === 'data'
? data
: {
data,
...result,
};
}
const textError = await response.text();
let jsonError: unknown;
try {
jsonError = JSON.parse(textError);
} catch {
// noop
}
throw jsonError ?? textError;
} catch (error) {
let finalError = error;
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = await fn(finalError, response, request, options as ResolvedRequestOptions);
}
}
finalError = finalError || {};
if (throwOnError) {
throw finalError;
}
// TODO: we probably want to return error and improve types
return responseStyle === 'data'
? undefined
: {
error: finalError,
request,
response,
};
}
};
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
request({ ...options, method });
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options);
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
method,
onRequest: async (url, init) => {
let request = new Request(url, init);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
return request;
},
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
url,
});
};
const _buildUrl: Client['buildUrl'] = (options) => buildUrl({ ..._config, ...options });
return {
buildUrl: _buildUrl,
connect: makeMethodFn('CONNECT'),
delete: makeMethodFn('DELETE'),
get: makeMethodFn('GET'),
getConfig,
head: makeMethodFn('HEAD'),
interceptors,
options: makeMethodFn('OPTIONS'),
patch: makeMethodFn('PATCH'),
post: makeMethodFn('POST'),
put: makeMethodFn('PUT'),
request,
setConfig,
sse: {
connect: makeSseFn('CONNECT'),
delete: makeSseFn('DELETE'),
get: makeSseFn('GET'),
head: makeSseFn('HEAD'),
options: makeSseFn('OPTIONS'),
patch: makeSseFn('PATCH'),
post: makeSseFn('POST'),
put: makeSseFn('PUT'),
trace: makeSseFn('TRACE'),
},
trace: makeMethodFn('TRACE'),
} as Client;
};
+25
View File
@@ -0,0 +1,25 @@
// This file is auto-generated by @hey-api/openapi-ts
export type { Auth } from '../core/auth.gen';
export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
export {
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer,
} from '../core/bodySerializer.gen';
export { buildClientParams } from '../core/params.gen';
export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
export { createClient } from './client.gen';
export type {
Client,
ClientOptions,
Config,
CreateClientConfig,
Options,
RequestOptions,
RequestResult,
ResolvedRequestOptions,
ResponseStyle,
TDataShape,
} from './types.gen';
export { createConfig, mergeHeaders } from './utils.gen';
@@ -0,0 +1,217 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth } from '../core/auth.gen';
import type {
ServerSentEventsOptions,
ServerSentEventsResult,
} from '../core/serverSentEvents.gen';
import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen';
import type { Middleware } from './utils.gen';
export type ResponseStyle = 'data' | 'fields';
export interface Config<T extends ClientOptions = ClientOptions>
extends Omit<RequestInit, 'body' | 'headers' | 'method'>, CoreConfig {
/**
* Base URL for all requests made by this client.
*/
baseUrl?: T['baseUrl'];
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* Please don't use the Fetch client for Next.js applications. The `next`
* options won't have any effect.
*
* Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
*/
next?: never;
/**
* Return the response data parsed in a specified format. By default, `auto`
* will infer the appropriate method from the `Content-Type` response header.
* You can override this behavior with any of the {@link Body} methods.
* Select `stream` if you don't want to parse response data at all.
*
* @default 'auto'
*/
parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text';
/**
* Should we return only data or multiple fields (data, error, response, etc.)?
*
* @default 'fields'
*/
responseStyle?: ResponseStyle;
/**
* Throw an error instead of returning it in the response?
*
* @default false
*/
throwOnError?: T['throwOnError'];
}
export interface RequestOptions<
TData = unknown,
TResponseStyle extends ResponseStyle = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
>
extends
Config<{
responseStyle: TResponseStyle;
throwOnError: ThrowOnError;
}>,
Pick<
ServerSentEventsOptions<TData>,
| 'onRequest'
| 'onSseError'
| 'onSseEvent'
| 'sseDefaultRetryDelay'
| 'sseMaxRetryAttempts'
| 'sseMaxRetryDelay'
> {
/**
* Any body that you want to add to your request.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
*/
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
/**
* Security mechanism(s) to use for the request.
*/
security?: ReadonlyArray<Auth>;
url: Url;
}
export interface ResolvedRequestOptions<
TResponseStyle extends ResponseStyle = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
headers: Headers;
serializedBody?: string;
}
export type RequestResult<
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = boolean,
TResponseStyle extends ResponseStyle = 'fields',
> = ThrowOnError extends true
? Promise<
TResponseStyle extends 'data'
? TData extends Record<string, unknown>
? TData[keyof TData]
: TData
: {
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
request: Request;
response: Response;
}
>
: Promise<
TResponseStyle extends 'data'
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
: (
| {
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
error: undefined;
}
| {
data: undefined;
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
}
) & {
/** request may be undefined, because error may be from building the request object itself */
request?: Request;
/** response may be undefined, because error may be from building the request object itself or from a network error */
response?: Response;
}
>;
export interface ClientOptions {
baseUrl?: string;
responseStyle?: ResponseStyle;
throwOnError?: boolean;
}
type MethodFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type SseFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<never, TResponseStyle, ThrowOnError>, 'method'>,
) => Promise<ServerSentEventsResult<TData, TError>>;
type RequestFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, 'method'>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;
type BuildUrlFn = <
TData extends {
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
url: string;
},
>(
options: TData & Options<TData>,
) => string;
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
};
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T>;
export interface TDataShape {
body?: unknown;
headers?: unknown;
path?: unknown;
query?: unknown;
url: string;
}
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
export type Options<
TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean,
TResponse = unknown,
TResponseStyle extends ResponseStyle = 'fields',
> = OmitKeys<
RequestOptions<TResponse, TResponseStyle, ThrowOnError>,
'body' | 'path' | 'query' | 'url'
> &
([TData] extends [never] ? unknown : Omit<TData, 'url'>);
@@ -0,0 +1,316 @@
// This file is auto-generated by @hey-api/openapi-ts
import { getAuthToken } from '../core/auth.gen';
import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
import { jsonBodySerializer } from '../core/bodySerializer.gen';
import {
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from '../core/pathSerializer.gen';
import { getUrl } from '../core/utils.gen';
import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
export const createQuerySerializer = <T = unknown>({
parameters = {},
...args
}: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => {
const search: string[] = [];
if (queryParams && typeof queryParams === 'object') {
for (const name in queryParams) {
const value = queryParams[name];
if (value === undefined || value === null) {
continue;
}
const options = parameters[name] || args;
if (Array.isArray(value)) {
const serializedArray = serializeArrayParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: 'form',
value,
...options.array,
});
if (serializedArray) search.push(serializedArray);
} else if (typeof value === 'object') {
const serializedObject = serializeObjectParam({
allowReserved: options.allowReserved,
explode: true,
name,
style: 'deepObject',
value: value as Record<string, unknown>,
...options.object,
});
if (serializedObject) search.push(serializedObject);
} else {
const serializedPrimitive = serializePrimitiveParam({
allowReserved: options.allowReserved,
name,
value: value as string,
});
if (serializedPrimitive) search.push(serializedPrimitive);
}
}
}
return search.join('&');
};
return querySerializer;
};
/**
* Infers parseAs value from provided Content-Type header.
*/
export const getParseAs = (contentType: string | null): Exclude<Config['parseAs'], 'auto'> => {
if (!contentType) {
// If no Content-Type header is provided, the best we can do is return the raw response body,
// which is effectively the same as the 'stream' option.
return 'stream';
}
const cleanContent = contentType.split(';')[0]?.trim();
if (!cleanContent) {
return;
}
if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) {
return 'json';
}
if (cleanContent === 'multipart/form-data') {
return 'formData';
}
if (
['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type))
) {
return 'blob';
}
if (cleanContent.startsWith('text/')) {
return 'text';
}
return;
};
const checkForExistence = (
options: Pick<RequestOptions, 'auth' | 'query'> & {
headers: Headers;
},
name?: string,
): boolean => {
if (!name) {
return false;
}
if (
options.headers.has(name) ||
options.query?.[name] ||
options.headers.get('Cookie')?.includes(`${name}=`)
) {
return true;
}
return false;
};
export async function setAuthParams(
options: Pick<RequestOptions, 'auth' | 'query' | 'security'> & {
headers: Headers;
},
): Promise<void> {
for (const auth of options.security ?? []) {
if (checkForExistence(options, auth.name)) {
continue;
}
const token = await getAuthToken(auth, options.auth);
if (!token) {
continue;
}
const name = auth.name ?? 'Authorization';
switch (auth.in) {
case 'query':
if (!options.query) {
options.query = {};
}
options.query[name] = token;
break;
case 'cookie':
options.headers.append('Cookie', `${name}=${token}`);
break;
case 'header':
default:
options.headers.set(name, token);
break;
}
}
}
export const buildUrl: Client['buildUrl'] = (options) =>
getUrl({
baseUrl: options.baseUrl as string,
path: options.path,
query: options.query,
querySerializer:
typeof options.querySerializer === 'function'
? options.querySerializer
: createQuerySerializer(options.querySerializer),
url: options.url,
});
export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b };
if (config.baseUrl?.endsWith('/')) {
config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
}
config.headers = mergeHeaders(a.headers, b.headers);
return config;
};
const headersEntries = (headers: Headers): Array<[string, string]> => {
const entries: Array<[string, string]> = [];
headers.forEach((value, key) => {
entries.push([key, value]);
});
return entries;
};
export const mergeHeaders = (
...headers: Array<Required<Config>['headers'] | undefined>
): Headers => {
const mergedHeaders = new Headers();
for (const header of headers) {
if (!header) {
continue;
}
const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
for (const [key, value] of iterator) {
if (value === null) {
mergedHeaders.delete(key);
} else if (Array.isArray(value)) {
for (const v of value) {
mergedHeaders.append(key, v as string);
}
} else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e., their
// content value in OpenAPI specification is 'application/json'
mergedHeaders.set(
key,
typeof value === 'object' ? JSON.stringify(value) : (value as string),
);
}
}
}
return mergedHeaders;
};
type ErrInterceptor<Err, Res, Req, Options> = (
error: Err,
/** response may be undefined due to a network error where no response object is produced */
response: Res | undefined,
/** request may be undefined, because error may be from building the request object itself */
request: Req | undefined,
options: Options,
) => Err | Promise<Err>;
type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>;
type ResInterceptor<Res, Req, Options> = (
response: Res,
request: Req,
options: Options,
) => Res | Promise<Res>;
class Interceptors<Interceptor> {
fns: Array<Interceptor | null> = [];
clear(): void {
this.fns = [];
}
eject(id: number | Interceptor): void {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = null;
}
}
exists(id: number | Interceptor): boolean {
const index = this.getInterceptorIndex(id);
return Boolean(this.fns[index]);
}
getInterceptorIndex(id: number | Interceptor): number {
if (typeof id === 'number') {
return this.fns[id] ? id : -1;
}
return this.fns.indexOf(id);
}
update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
const index = this.getInterceptorIndex(id);
if (this.fns[index]) {
this.fns[index] = fn;
return id;
}
return false;
}
use(fn: Interceptor): number {
this.fns.push(fn);
return this.fns.length - 1;
}
}
export interface Middleware<Req, Res, Err, Options> {
error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
request: Interceptors<ReqInterceptor<Req, Options>>;
response: Interceptors<ResInterceptor<Res, Req, Options>>;
}
export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
Req,
Res,
Err,
Options
> => ({
error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
request: new Interceptors<ReqInterceptor<Req, Options>>(),
response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
});
const defaultQuerySerializer = createQuerySerializer({
allowReserved: false,
array: {
explode: true,
style: 'form',
},
object: {
explode: true,
style: 'deepObject',
},
});
const defaultHeaders = {
'Content-Type': 'application/json',
};
export const createConfig = <T extends ClientOptions = ClientOptions>(
override: Config<Omit<ClientOptions, keyof T> & T> = {},
): Config<Omit<ClientOptions, keyof T> & T> => ({
...jsonBodySerializer,
headers: defaultHeaders,
parseAs: 'auto',
querySerializer: defaultQuerySerializer,
...override,
});
@@ -0,0 +1,41 @@
// This file is auto-generated by @hey-api/openapi-ts
export type AuthToken = string | undefined;
export interface Auth {
/**
* Which part of the request do we use to send the auth?
*
* @default 'header'
*/
in?: 'header' | 'query' | 'cookie';
/**
* Header or query parameter name.
*
* @default 'Authorization'
*/
name?: string;
scheme?: 'basic' | 'bearer';
type: 'apiKey' | 'http';
}
export const getAuthToken = async (
auth: Auth,
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
): Promise<string | undefined> => {
const token = typeof callback === 'function' ? await callback(auth) : callback;
if (!token) {
return;
}
if (auth.scheme === 'bearer') {
return `Bearer ${token}`;
}
if (auth.scheme === 'basic') {
return `Basic ${btoa(token)}`;
}
return token;
};
@@ -0,0 +1,82 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen';
export type QuerySerializer = (query: Record<string, unknown>) => string;
export type BodySerializer = (body: unknown) => unknown;
type QuerySerializerOptionsObject = {
allowReserved?: boolean;
array?: Partial<SerializerOptions<ArrayStyle>>;
object?: Partial<SerializerOptions<ObjectStyle>>;
};
export type QuerySerializerOptions = QuerySerializerOptionsObject & {
/**
* Per-parameter serialization overrides. When provided, these settings
* override the global array/object settings for specific parameter names.
*/
parameters?: Record<string, QuerySerializerOptionsObject>;
};
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
if (typeof value === 'string' || value instanceof Blob) {
data.append(key, value);
} else if (value instanceof Date) {
data.append(key, value.toISOString());
} else {
data.append(key, JSON.stringify(value));
}
};
const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
if (typeof value === 'string') {
data.append(key, value);
} else {
data.append(key, JSON.stringify(value));
}
};
export const formDataBodySerializer = {
bodySerializer: (body: unknown): FormData => {
const data = new FormData();
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeFormDataPair(data, key, v));
} else {
serializeFormDataPair(data, key, value);
}
});
return data;
},
};
export const jsonBodySerializer = {
bodySerializer: (body: unknown): string =>
JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
};
export const urlSearchParamsBodySerializer = {
bodySerializer: (body: unknown): string => {
const data = new URLSearchParams();
Object.entries(body as Record<string, unknown>).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
} else {
serializeUrlSearchParamsPair(data, key, value);
}
});
return data.toString();
},
};
@@ -0,0 +1,169 @@
// This file is auto-generated by @hey-api/openapi-ts
type Slot = 'body' | 'headers' | 'path' | 'query';
export type Field =
| {
in: Exclude<Slot, 'body'>;
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If omitted, we use the same value as `key`.
*/
map?: string;
}
| {
in: Extract<Slot, 'body'>;
/**
* Key isn't required for bodies.
*/
key?: string;
map?: string;
}
| {
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If `in` is omitted, `map` aliases `key` to the transport layer.
*/
map: Slot;
};
export interface Fields {
allowExtra?: Partial<Record<Slot, boolean>>;
args?: ReadonlyArray<Field>;
}
export type FieldsConfig = ReadonlyArray<Field | Fields>;
const extraPrefixesMap: Record<string, Slot> = {
$body_: 'body',
$headers_: 'headers',
$path_: 'path',
$query_: 'query',
};
const extraPrefixes = Object.entries(extraPrefixesMap);
type KeyMap = Map<
string,
| {
in: Slot;
map?: string;
}
| {
in?: never;
map: Slot;
}
>;
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
if (!map) {
map = new Map();
}
for (const config of fields) {
if ('in' in config) {
if (config.key) {
map.set(config.key, {
in: config.in,
map: config.map,
});
}
} else if ('key' in config) {
map.set(config.key, {
map: config.map,
});
} else if (config.args) {
buildKeyMap(config.args, map);
}
}
return map;
};
interface Params {
body: unknown;
headers: Record<string, unknown>;
path: Record<string, unknown>;
query: Record<string, unknown>;
}
const stripEmptySlots = (params: Params) => {
for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) {
delete params[slot as Slot];
}
}
};
export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
const params: Params = {
body: {},
headers: {},
path: {},
query: {},
};
const map = buildKeyMap(fields);
let config: FieldsConfig[number] | undefined;
for (const [index, arg] of args.entries()) {
if (fields[index]) {
config = fields[index];
}
if (!config) {
continue;
}
if ('in' in config) {
if (config.key) {
const field = map.get(config.key)!;
const name = field.map || config.key;
if (field.in) {
(params[field.in] as Record<string, unknown>)[name] = arg;
}
} else {
params.body = arg;
}
} else {
for (const [key, value] of Object.entries(arg ?? {})) {
const field = map.get(key);
if (field) {
if (field.in) {
const name = field.map || key;
(params[field.in] as Record<string, unknown>)[name] = value;
} else {
params[field.map] = value;
}
} else {
const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
if (extra) {
const [prefix, slot] = extra;
(params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
} else if ('allowExtra' in config && config.allowExtra) {
for (const [slot, allowed] of Object.entries(config.allowExtra)) {
if (allowed) {
(params[slot as Slot] as Record<string, unknown>)[key] = value;
break;
}
}
}
}
}
}
}
stripEmptySlots(params);
return params;
};
@@ -0,0 +1,171 @@
// This file is auto-generated by @hey-api/openapi-ts
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
interface SerializePrimitiveOptions {
allowReserved?: boolean;
name: string;
}
export interface SerializerOptions<T> {
/**
* @default true
*/
explode: boolean;
style: T;
}
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
type MatrixStyle = 'label' | 'matrix' | 'simple';
export type ObjectStyle = 'form' | 'deepObject';
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
value: string;
}
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'form':
return ',';
case 'pipeDelimited':
return '|';
case 'spaceDelimited':
return '%20';
default:
return ',';
}
};
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
export const serializeArrayParam = ({
allowReserved,
explode,
name,
style,
value,
}: SerializeOptions<ArraySeparatorStyle> & {
value: unknown[];
}) => {
if (!explode) {
const joinedValues = (
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
).join(separatorArrayNoExplode(style));
switch (style) {
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
case 'simple':
return joinedValues;
default:
return `${name}=${joinedValues}`;
}
}
const separator = separatorArrayExplode(style);
const joinedValues = value
.map((v) => {
if (style === 'label' || style === 'simple') {
return allowReserved ? v : encodeURIComponent(v as string);
}
return serializePrimitiveParam({
allowReserved,
name,
value: v as string,
});
})
.join(separator);
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
};
export const serializePrimitiveParam = ({
allowReserved,
name,
value,
}: SerializePrimitiveParam) => {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'object') {
throw new Error(
'Deeply-nested arrays/objects arent supported. Provide your own `querySerializer()` to handle these.',
);
}
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
};
export const serializeObjectParam = ({
allowReserved,
explode,
name,
style,
value,
valueOnly,
}: SerializeOptions<ObjectSeparatorStyle> & {
value: Record<string, unknown> | Date;
valueOnly?: boolean;
}) => {
if (value instanceof Date) {
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
}
if (style !== 'deepObject' && !explode) {
let values: string[] = [];
Object.entries(value).forEach(([key, v]) => {
values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
});
const joinedValues = values.join(',');
switch (style) {
case 'form':
return `${name}=${joinedValues}`;
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
default:
return joinedValues;
}
}
const separator = separatorObjectExplode(style);
const joinedValues = Object.entries(value)
.map(([key, v]) =>
serializePrimitiveParam({
allowReserved,
name: style === 'deepObject' ? `${name}[${key}]` : key,
value: v as string,
}),
)
.join(separator);
return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
};
@@ -0,0 +1,117 @@
// This file is auto-generated by @hey-api/openapi-ts
/**
* JSON-friendly union that mirrors what Pinia Colada can hash.
*/
export type JsonValue =
| null
| string
| number
| boolean
| JsonValue[]
| { [key: string]: JsonValue };
/**
* Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
*/
export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
return undefined;
}
if (typeof value === 'bigint') {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
return value;
};
/**
* Safely stringifies a value and parses it back into a JsonValue.
*/
export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
try {
const json = JSON.stringify(input, queryKeyJsonReplacer);
if (json === undefined) {
return undefined;
}
return JSON.parse(json) as JsonValue;
} catch {
return undefined;
}
};
/**
* Detects plain objects (including objects with a null prototype).
*/
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (value === null || typeof value !== 'object') {
return false;
}
const prototype = Object.getPrototypeOf(value as object);
return prototype === Object.prototype || prototype === null;
};
/**
* Turns URLSearchParams into a sorted JSON object for deterministic keys.
*/
const serializeSearchParams = (params: URLSearchParams): JsonValue => {
const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
const result: Record<string, JsonValue> = {};
for (const [key, value] of entries) {
const existing = result[key];
if (existing === undefined) {
result[key] = value;
continue;
}
if (Array.isArray(existing)) {
(existing as string[]).push(value);
} else {
result[key] = [existing, value];
}
}
return result;
};
/**
* Normalizes any accepted value into a JSON-friendly shape for query keys.
*/
export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
if (value === null) {
return null;
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
return undefined;
}
if (typeof value === 'bigint') {
return value.toString();
}
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return stringifyToJsonValue(value);
}
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) {
return serializeSearchParams(value);
}
if (isPlainObject(value)) {
return stringifyToJsonValue(value);
}
return undefined;
};
@@ -0,0 +1,242 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Config } from './types.gen';
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, 'method'> &
Pick<Config, 'method' | 'responseTransformer' | 'responseValidator'> & {
/**
* Fetch API implementation. You can use this option to provide a custom
* fetch instance.
*
* @default globalThis.fetch
*/
fetch?: typeof fetch;
/**
* Implementing clients can call request interceptors inside this hook.
*/
onRequest?: (url: string, init: RequestInit) => Promise<Request>;
/**
* Callback invoked when a network or parsing error occurs during streaming.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param error The error that occurred.
*/
onSseError?: (error: unknown) => void;
/**
* Callback invoked when an event is streamed from the server.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param event Event streamed from the server.
* @returns Nothing (void).
*/
onSseEvent?: (event: StreamEvent<TData>) => void;
serializedBody?: RequestInit['body'];
/**
* Default retry delay in milliseconds.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 3000
*/
sseDefaultRetryDelay?: number;
/**
* Maximum number of retry attempts before giving up.
*/
sseMaxRetryAttempts?: number;
/**
* Maximum retry delay in milliseconds.
*
* Applies only when exponential backoff is used.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 30000
*/
sseMaxRetryDelay?: number;
/**
* Optional sleep function for retry backoff.
*
* Defaults to using `setTimeout`.
*/
sseSleepFn?: (ms: number) => Promise<void>;
url: string;
};
export interface StreamEvent<TData = unknown> {
data: TData;
event?: string;
id?: string;
retry?: number;
}
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
stream: AsyncGenerator<
TData extends Record<string, unknown> ? TData[keyof TData] : TData,
TReturn,
TNext
>;
};
export function createSseClient<TData = unknown>({
onRequest,
onSseError,
onSseEvent,
responseTransformer,
responseValidator,
sseDefaultRetryDelay,
sseMaxRetryAttempts,
sseMaxRetryDelay,
sseSleepFn,
url,
...options
}: ServerSentEventsOptions): ServerSentEventsResult<TData> {
let lastEventId: string | undefined;
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
const createStream = async function* () {
let retryDelay: number = sseDefaultRetryDelay ?? 3000;
let attempt = 0;
const signal = options.signal ?? new AbortController().signal;
while (true) {
if (signal.aborted) break;
attempt++;
const headers =
options.headers instanceof Headers
? options.headers
: new Headers(options.headers as Record<string, string> | undefined);
if (lastEventId !== undefined) {
headers.set('Last-Event-ID', lastEventId);
}
try {
const requestInit: RequestInit = {
redirect: 'follow',
...options,
body: options.serializedBody,
headers,
signal,
};
let request = new Request(url, requestInit);
if (onRequest) {
request = await onRequest(url, requestInit);
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = options.fetch ?? globalThis.fetch;
const response = await _fetch(request);
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
if (!response.body) throw new Error('No body in SSE response');
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
const abortHandler = () => {
try {
reader.cancel();
} catch {
// noop
}
};
signal.addEventListener('abort', abortHandler);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks) {
const lines = chunk.split('\n');
const dataLines: Array<string> = [];
let eventName: string | undefined;
for (const line of lines) {
if (line.startsWith('data:')) {
dataLines.push(line.replace(/^data:\s*/, ''));
} else if (line.startsWith('event:')) {
eventName = line.replace(/^event:\s*/, '');
} else if (line.startsWith('id:')) {
lastEventId = line.replace(/^id:\s*/, '');
} else if (line.startsWith('retry:')) {
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10);
if (!Number.isNaN(parsed)) {
retryDelay = parsed;
}
}
}
let data: unknown;
let parsedJson = false;
if (dataLines.length) {
const rawData = dataLines.join('\n');
try {
data = JSON.parse(rawData);
parsedJson = true;
} catch {
data = rawData;
}
}
if (parsedJson) {
if (responseValidator) {
await responseValidator(data);
}
if (responseTransformer) {
data = await responseTransformer(data);
}
}
onSseEvent?.({
data,
event: eventName,
id: lastEventId,
retry: retryDelay,
});
if (dataLines.length) {
yield data as any;
}
}
}
} finally {
signal.removeEventListener('abort', abortHandler);
reader.releaseLock();
}
break; // exit loop on normal completion
} catch (error) {
// connection failed or aborted; retry after delay
onSseError?.(error);
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
break; // stop after firing error
}
// exponential backoff: double retry each attempt, cap at 30s
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
await sleep(backoff);
}
}
};
const stream = createStream();
return { stream };
}
+104
View File
@@ -0,0 +1,104 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth, AuthToken } from './auth.gen';
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen';
export type HttpMethod =
| 'connect'
| 'delete'
| 'get'
| 'head'
| 'options'
| 'patch'
| 'post'
| 'put'
| 'trace';
export type Client<
RequestFn = never,
Config = unknown,
MethodFn = never,
BuildUrlFn = never,
SseFn = never,
> = {
/**
* Returns the final request URL.
*/
buildUrl: BuildUrlFn;
getConfig: () => Config;
request: RequestFn;
setConfig: (config: Config) => Config;
} & {
[K in HttpMethod]: MethodFn;
} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } });
export interface Config {
/**
* Auth token or a function returning auth token. The resolved value will be
* added to the request payload as defined by its `security` array.
*/
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
/**
* A function for serializing request body parameter. By default,
* {@link JSON.stringify()} will be used.
*/
bodySerializer?: BodySerializer | null;
/**
* An object containing any HTTP headers that you want to pre-populate your
* `Headers` object with.
*
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
*/
headers?:
| RequestInit['headers']
| Record<
string,
string | number | boolean | (string | number | boolean)[] | null | undefined | unknown
>;
/**
* The request method.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
*/
method?: Uppercase<HttpMethod>;
/**
* A function for serializing request query parameters. By default, arrays
* will be exploded in form style, objects will be exploded in deepObject
* style, and reserved characters are percent-encoded.
*
* This method will have no effect if the native `paramsSerializer()` Axios
* API function is used.
*
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
*/
querySerializer?: QuerySerializer | QuerySerializerOptions;
/**
* A function validating request data. This is useful if you want to ensure
* the request conforms to the desired shape, so it can be safely sent to
* the server.
*/
requestValidator?: (data: unknown) => Promise<unknown>;
/**
* A function transforming response data before it's returned. This is useful
* for post-processing data, e.g., converting ISO strings into Date objects.
*/
responseTransformer?: (data: unknown) => Promise<unknown>;
/**
* A function validating response data. This is useful if you want to ensure
* the response conforms to the desired shape, so it can be safely passed to
* the transformers and returned to the user.
*/
responseValidator?: (data: unknown) => Promise<unknown>;
}
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
? true
: [T] extends [never | undefined]
? [undefined] extends [T]
? false
: true
: false;
export type OmitNever<T extends Record<string, unknown>> = {
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true ? never : K]: T[K];
};
+140
View File
@@ -0,0 +1,140 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
import {
type ArraySeparatorStyle,
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from './pathSerializer.gen';
export interface PathSerializer {
path: Record<string, unknown>;
url: string;
}
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url;
const matches = _url.match(PATH_PARAM_RE);
if (matches) {
for (const match of matches) {
let explode = false;
let name = match.substring(1, match.length - 1);
let style: ArraySeparatorStyle = 'simple';
if (name.endsWith('*')) {
explode = true;
name = name.substring(0, name.length - 1);
}
if (name.startsWith('.')) {
name = name.substring(1);
style = 'label';
} else if (name.startsWith(';')) {
name = name.substring(1);
style = 'matrix';
}
const value = path[name];
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
continue;
}
if (typeof value === 'object') {
url = url.replace(
match,
serializeObjectParam({
explode,
name,
style,
value: value as Record<string, unknown>,
valueOnly: true,
}),
);
continue;
}
if (style === 'matrix') {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string,
})}`,
);
continue;
}
const replaceValue = encodeURIComponent(
style === 'label' ? `.${value as string}` : (value as string),
);
url = url.replace(match, replaceValue);
}
}
return url;
};
export const getUrl = ({
baseUrl,
path,
query,
querySerializer,
url: _url,
}: {
baseUrl?: string;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
querySerializer: QuerySerializer;
url: string;
}) => {
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
let url = (baseUrl ?? '') + pathUrl;
if (path) {
url = defaultPathSerializer({ path, url });
}
let search = query ? querySerializer(query) : '';
if (search.startsWith('?')) {
search = search.substring(1);
}
if (search) {
url += `?${search}`;
}
return url;
};
export function getValidRequestBody(options: {
body?: unknown;
bodySerializer?: BodySerializer | null;
serializedBody?: unknown;
}) {
const hasBody = options.body !== undefined;
const isSerializedBody = hasBody && options.bodySerializer;
if (isSerializedBody) {
if ('serializedBody' in options) {
const hasSerializedBody =
options.serializedBody !== undefined && options.serializedBody !== '';
return hasSerializedBody ? options.serializedBody : null;
}
// not all clients implement a serializedBody property (i.e., client-axios)
return options.body !== '' ? options.body : null;
}
// plain/text body
if (hasBody) {
return options.body;
}
// no body was provided
return undefined;
}
+4
View File
@@ -0,0 +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 } from './sdk.gen';
export type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponse, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponse, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponse, AnimationsApiTopSubmissionsResponses, ClientOptions, GameOut, GamesApiListGamesData, GamesApiListGamesResponse, GamesApiListGamesResponses, 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, TournamentPuzzleResultsOut, TournamentSubmissionsOut, UserBetCreateSchema, UserBetSchema, UserDisplayOut, UserInfoOut, ValidationIn, WinnerFileOut, WinnerResponseOut } from './types.gen';
+241
View File
@@ -0,0 +1,241 @@
// This file is auto-generated by @hey-api/openapi-ts
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 } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/**
* You can provide a client instance returned by `createClient()` instead of
* individual options. This might be also useful if you want to implement a
* custom client.
*/
client?: Client;
/**
* You can pass arbitrary values through the `meta` object. This can be
* used to access values that aren't defined as part of the SDK function.
*/
meta?: Record<string, unknown>;
};
/**
* Health Check
*
* Health check endpoint
*/
export const polylanSubmitterApiHealthCheck = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiHealthCheckData, ThrowOnError>) => (options?.client ?? client).get<PolylanSubmitterApiHealthCheckResponses, unknown, ThrowOnError>({ url: '/api/health', ...options });
/**
* Clear Cache
*
* Clear all API caches (admin only)
*/
export const polylanSubmitterApiClearCache = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiClearCacheData, ThrowOnError>) => (options?.client ?? client).post<PolylanSubmitterApiClearCacheResponses, unknown, ThrowOnError>({ url: '/api/cache/clear', ...options });
/**
* Get User Info
*
* Get current user information
*/
export const polylanSubmitterApiGetUserInfo = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiGetUserInfoData, ThrowOnError>) => (options?.client ?? client).get<PolylanSubmitterApiGetUserInfoResponses, unknown, ThrowOnError>({ url: '/api/user', ...options });
/**
* List Puzzles
*
* Get list of available puzzles
*/
export const opusMagnumApiListPuzzles = <ThrowOnError extends boolean = false>(options?: Options<OpusMagnumApiListPuzzlesData, ThrowOnError>) => (options?.client ?? client).get<OpusMagnumApiListPuzzlesResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/puzzles', ...options });
/**
* Get Collection
*
* Get the active collection details
*/
export const opusMagnumApiGetCollection = <ThrowOnError extends boolean = false>(options?: Options<OpusMagnumApiGetCollectionData, ThrowOnError>) => (options?.client ?? client).get<OpusMagnumApiGetCollectionResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/collection', ...options });
/**
* List Submissions
*
* Get paginated list of submissions
*/
export const opusMagnumApiListSubmissions = <ThrowOnError extends boolean = false>(options?: Options<OpusMagnumApiListSubmissionsData, ThrowOnError>) => (options?.client ?? client).get<OpusMagnumApiListSubmissionsResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/submissions', ...options });
/**
* Create Submission
*
* Create a new submission with multiple puzzle responses
*/
export const opusMagnumApiCreateSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiCreateSubmissionData, ThrowOnError>) => (options.client ?? client).post<OpusMagnumApiCreateSubmissionResponses, unknown, ThrowOnError>({
...formDataBodySerializer,
url: '/api/opus-magnum/submissions',
...options,
headers: {
'Content-Type': null,
...options.headers
}
});
/**
* Delete Submission
*
* Delete a submission (admin only)
*/
export const opusMagnumApiDeleteSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiDeleteSubmissionData, ThrowOnError>) => (options.client ?? client).delete<OpusMagnumApiDeleteSubmissionResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/submissions/{submission_id}', ...options });
/**
* Get Submission
*
* Get detailed submission by ID
*/
export const opusMagnumApiGetSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiGetSubmissionData, ThrowOnError>) => (options.client ?? client).get<OpusMagnumApiGetSubmissionResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/submissions/{submission_id}', ...options });
/**
* Validate Response
*
* Manually validate a puzzle response
*/
export const opusMagnumApiValidateResponse = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateResponseData, ThrowOnError>) => (options.client ?? client).put<OpusMagnumApiValidateResponseResponses, unknown, ThrowOnError>({
url: '/api/opus-magnum/responses/{response_id}/validate',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Validate Auto
*
* Try to auto validate a puzzle response
*/
export const opusMagnumApiValidateAuto = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateAutoData, ThrowOnError>) => (options.client ?? client).put<OpusMagnumApiValidateAutoResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/responses/{response_id}/validate/auto', ...options });
/**
* List Responses Needing Validation
*
* Get all responses that need manual validation
*/
export const opusMagnumApiListResponsesNeedingValidation = <ThrowOnError extends boolean = false>(options?: Options<OpusMagnumApiListResponsesNeedingValidationData, ThrowOnError>) => (options?.client ?? client).get<OpusMagnumApiListResponsesNeedingValidationResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/responses/needs-validation', ...options });
/**
* Validate Submission
*
* Mark entire submission as validated
*/
export const opusMagnumApiValidateSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateSubmissionData, ThrowOnError>) => (options.client ?? client).post<OpusMagnumApiValidateSubmissionResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/submissions/{submission_id}/validate', ...options });
/**
* Get Stats
*
* Get submission statistics
*/
export const opusMagnumApiGetStats = <ThrowOnError extends boolean = false>(options?: Options<OpusMagnumApiGetStatsData, ThrowOnError>) => (options?.client ?? client).get<OpusMagnumApiGetStatsResponses, unknown, ThrowOnError>({ url: '/api/opus-magnum/stats', ...options });
/**
* Results
*/
export const animationsApiResults = <ThrowOnError extends boolean = false>(options?: Options<AnimationsApiResultsData, ThrowOnError>) => (options?.client ?? client).get<AnimationsApiResultsResponses, unknown, ThrowOnError>({ url: '/api/results/results', ...options });
/**
* Top Submissions
*
* Get tournament top submissions for each puzzle. Only available when tournament is closed.
*/
export const animationsApiTopSubmissions = <ThrowOnError extends boolean = false>(options?: Options<AnimationsApiTopSubmissionsData, ThrowOnError>) => (options?.client ?? client).get<AnimationsApiTopSubmissionsResponses, unknown, ThrowOnError>({ url: '/api/results/top-submissions', ...options });
/**
* Puzzle Results
*
* Get tournament results organized by puzzle with coefficients. Only available when tournament is closed.
*/
export const animationsApiPuzzleResults = <ThrowOnError extends boolean = false>(options?: Options<AnimationsApiPuzzleResultsData, ThrowOnError>) => (options?.client ?? client).get<AnimationsApiPuzzleResultsResponses, unknown, ThrowOnError>({ url: '/api/results/puzzle-results', ...options });
/**
* Get Results
*/
export const noitaApiGetResults = <ThrowOnError extends boolean = false>(options?: Options<NoitaApiGetResultsData, ThrowOnError>) => (options?.client ?? client).get<NoitaApiGetResultsResponses, unknown, ThrowOnError>({ url: '/api/noita/results', ...options });
/**
* Get Leaderboard
*
* Get the global leaderboard for all users ranked by total score.
*
* Uses Window functions to rank users by their total score in descending order.
*/
export const noitaApiGetLeaderboard = <ThrowOnError extends boolean = false>(options?: Options<NoitaApiGetLeaderboardData, ThrowOnError>) => (options?.client ?? client).get<NoitaApiGetLeaderboardResponses, unknown, ThrowOnError>({ url: '/api/noita/leaderboard', ...options });
/**
* Submit Log File
*
* Submit a Noita run file (log file, screenshot, or video).
*
* Accepts:
* - Text files (.txt) for polylan_mod_log.txt
* - Images (.png, .jpg, .gif)
* - Videos (.mp4, .webm)
*
* Max file size: 256 MB
*/
export const noitaApiSubmitLogFile = <ThrowOnError extends boolean = false>(options: Options<NoitaApiSubmitLogFileData, ThrowOnError>) => (options.client ?? client).post<NoitaApiSubmitLogFileResponses, NoitaApiSubmitLogFileErrors, ThrowOnError>({
...formDataBodySerializer,
url: '/api/noita/submit',
...options,
headers: {
'Content-Type': null,
...options.headers
}
});
/**
* List Games
*/
export const gamesApiListGames = <ThrowOnError extends boolean = false>(options?: Options<GamesApiListGamesData, ThrowOnError>) => (options?.client ?? client).get<GamesApiListGamesResponses, unknown, ThrowOnError>({ url: '/api/games/', ...options });
/**
* List Markets
*
* List all markets (excludes draft markets).
*/
export const marketApiListMarkets = <ThrowOnError extends boolean = false>(options?: Options<MarketApiListMarketsData, ThrowOnError>) => (options?.client ?? client).get<MarketApiListMarketsResponses, unknown, ThrowOnError>({ url: '/api/market/', ...options });
/**
* List User Bets
*
* List all bets placed by the current user.
*/
export const marketApiListUserBets = <ThrowOnError extends boolean = false>(options?: Options<MarketApiListUserBetsData, ThrowOnError>) => (options?.client ?? client).get<MarketApiListUserBetsResponses, unknown, ThrowOnError>({ url: '/api/market/user/bets', ...options });
/**
* Close Market
*
* Close a market. Admin only.
*/
export const marketApiCloseMarket = <ThrowOnError extends boolean = false>(options: Options<MarketApiCloseMarketData, ThrowOnError>) => (options.client ?? client).post<MarketApiCloseMarketResponses, unknown, ThrowOnError>({ url: '/api/market/{market_uuid}/actions/close', ...options });
/**
* Resolve Market
*
* Resolve a market with a winning option. Admin only.
*/
export const marketApiResolveMarket = <ThrowOnError extends boolean = false>(options: Options<MarketApiResolveMarketData, ThrowOnError>) => (options.client ?? client).post<MarketApiResolveMarketResponses, unknown, ThrowOnError>({
url: '/api/market/{market_uuid}/actions/resolve',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/**
* Create Bet
*
* Place a bet on a market option.
*/
export const marketApiCreateBet = <ThrowOnError extends boolean = false>(options: Options<MarketApiCreateBetData, ThrowOnError>) => (options.client ?? client).post<MarketApiCreateBetResponses, unknown, ThrowOnError>({
url: '/api/market/{market_uuid}/bets',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More