Compare commits
40
Commits
9662181d4d
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e91ef77b6
|
||
|
|
215d68a0e1
|
||
|
|
47812ffd09
|
||
|
|
a5fe8aacaf
|
||
|
|
b437210eb3
|
||
|
|
5584e54b58
|
||
|
|
35ea54ecea
|
||
|
|
821e453bc0
|
||
|
|
9fd0122a67
|
||
|
|
e557fe2cda
|
||
|
|
79e7cef3ba
|
||
|
|
a264336bd8
|
||
|
|
42e3571fab
|
||
|
|
43b314bb20
|
||
|
|
f1afb2096f
|
||
|
|
62a81e57ad
|
||
|
|
303b9e1c8a
|
||
|
|
f7c7eba4da
|
||
|
|
ce30539808
|
||
|
|
544112b204
|
||
|
|
9f94fb3974
|
||
|
|
25072e8eb4
|
||
|
|
9e5ab8539a
|
||
|
|
4ba6a48246
|
||
|
|
92dddca964
|
||
|
|
bf21a5eae6
|
||
|
|
89faa7cd40
|
||
|
|
a800c7fff7
|
||
|
|
2ee8cd6be3
|
||
|
|
35824de310
|
||
|
|
65cc2e555f
|
||
|
|
6d996a4e2f
|
||
|
|
f774ff3340
|
||
|
|
779393106d
|
||
|
|
3e04f8312a
|
||
|
|
7cfab20826
|
||
|
|
754b0b0803
|
||
|
|
79f469a393
|
||
|
|
5aeff9c218
|
||
|
|
2264401a91
|
@@ -34,3 +34,6 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
return obj.get_cas_groups_display()
|
return obj.get_cas_groups_display()
|
||||||
|
|
||||||
get_cas_groups_display.short_description = "CAS Groups"
|
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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -14,6 +14,9 @@ class CustomUser(AbstractUser):
|
|||||||
# Additional fields that might come from CAS
|
# Additional fields that might come from CAS
|
||||||
cas_attributes = models.JSONField(default=dict, blank=True)
|
cas_attributes = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
# Market points balance
|
||||||
|
points = models.IntegerField(default=1000)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.username} ({self.cas_user_id})"
|
return f"{self.username} ({self.cas_user_id})"
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from ninja import Router
|
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 accounts.models import CustomUser
|
||||||
from animations.schemas import RankingSchema
|
from animations.schemas import (
|
||||||
from submissions.models import PuzzleResponse, SteamCollectionItem
|
RankingSchema,
|
||||||
|
TournamentSubmissionsOut,
|
||||||
|
TournamentPuzzleResultsOut,
|
||||||
|
PuzzleSubmissionsOut,
|
||||||
|
PuzzleResultsOut,
|
||||||
|
PuzzleSubmissionWithRankOut,
|
||||||
|
PuzzlePointsFactorOut,
|
||||||
|
WinnerResponseOut,
|
||||||
|
WinnerFileOut,
|
||||||
|
)
|
||||||
|
from opus_magnum.models import PuzzleResponse, SteamCollectionItem, SteamCollection
|
||||||
|
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
@@ -12,6 +25,12 @@ router = Router()
|
|||||||
|
|
||||||
@router.get("results", response=RankingSchema)
|
@router.get("results", response=RankingSchema)
|
||||||
def results(request: HttpRequest) -> dict:
|
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_userid = defaultdict(list)
|
||||||
responses_by_puzzleid = 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)
|
responses, key=lambda x: (x.rank_points is None, x.rank_points or 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
data = {
|
||||||
"users": CustomUser.objects.filter(pk__in=responses_by_userid.keys()),
|
"users": list(CustomUser.objects.filter(pk__in=responses_by_userid.keys())),
|
||||||
"puzzles": SteamCollectionItem.objects.all(),
|
"puzzles": list(SteamCollectionItem.objects.all()),
|
||||||
"responses_by_userid": responses_by_userid,
|
"responses_by_userid": dict(responses_by_userid),
|
||||||
"ranking_by_puzzle": ranking,
|
"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
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from ninja import ModelSchema, Schema
|
from ninja import ModelSchema, Schema
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
from submissions.models import PuzzleResponse
|
from opus_magnum.models import PuzzleResponse
|
||||||
from submissions.schemas import SteamCollectionItemOut, UserInfoOut
|
from opus_magnum.schemas import SteamCollectionItemOut
|
||||||
|
|
||||||
|
|
||||||
class PuzzleResponseRankingOut(ModelSchema):
|
class PuzzleResponseRankingOut(ModelSchema):
|
||||||
@@ -33,6 +34,7 @@ class PuzzleResponseRankingOut(ModelSchema):
|
|||||||
class UserDisplayOut(Schema):
|
class UserDisplayOut(Schema):
|
||||||
id: int
|
id: int
|
||||||
username: str
|
username: str
|
||||||
|
is_staff: bool
|
||||||
|
|
||||||
|
|
||||||
class RankingSchema(Schema):
|
class RankingSchema(Schema):
|
||||||
@@ -40,3 +42,74 @@ class RankingSchema(Schema):
|
|||||||
puzzles: list[SteamCollectionItemOut]
|
puzzles: list[SteamCollectionItemOut]
|
||||||
responses_by_userid: dict[int, list[PuzzleResponseRankingOut]]
|
responses_by_userid: dict[int, list[PuzzleResponseRankingOut]]
|
||||||
ranking_by_puzzle: 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]
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class SubmissionsConfig(AppConfig):
|
class GamesConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "submissions"
|
name = "games"
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -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})"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from ninja import Schema
|
||||||
|
|
||||||
|
|
||||||
|
class GameOut(Schema):
|
||||||
|
steam_app_id: int
|
||||||
|
name: str
|
||||||
|
path: str
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
@@ -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", {})
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
from django.contrib import admin
|
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)
|
@admin.register(LogfileSubmission)
|
||||||
@@ -23,13 +25,28 @@ class LogfileSubmissionAdmin(admin.ModelAdmin):
|
|||||||
("Processing", {"fields": ("processed",)}),
|
("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)
|
@admin.register(Objectiv)
|
||||||
class ObjectivAdmin(admin.ModelAdmin):
|
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")
|
list_filter = ("objectiv_id", "user")
|
||||||
search_fields = ("objectiv_id", "user__username")
|
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)
|
@admin.register(ObjectivPoint)
|
||||||
@@ -41,3 +58,15 @@ class ObjectivPointAdmin(admin.ModelAdmin):
|
|||||||
("Objective Information", {"fields": ("objectiv_id", "display_string")}),
|
("Objective Information", {"fields": ("objectiv_id", "display_string")}),
|
||||||
("Scoring", {"fields": ("max_count", "point")}),
|
("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
@@ -1,45 +1,54 @@
|
|||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
|
from django.core.cache import cache
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
F,
|
F,
|
||||||
Case,
|
Case,
|
||||||
When,
|
When,
|
||||||
Sum,
|
|
||||||
Count,
|
Count,
|
||||||
IntegerField,
|
IntegerField,
|
||||||
Subquery,
|
Subquery,
|
||||||
OuterRef,
|
OuterRef,
|
||||||
Window,
|
|
||||||
)
|
)
|
||||||
from django.db.models.functions import Rank
|
|
||||||
from ninja import Router, File
|
from ninja import Router, File
|
||||||
from ninja.files import UploadedFile
|
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 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
|
from .schemas import NoitaSubmissionOut
|
||||||
|
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
NOITA_APP_ID = 881100
|
||||||
|
|
||||||
@router.get("objectives", response=list[ObjectivOut])
|
|
||||||
def get_my_objectives(request: HttpRequest):
|
|
||||||
return Objectiv.objects.order_by("-count").filter(user=request.user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("results", response=ResultsOut)
|
@router.get("results", response=ResultsOut)
|
||||||
|
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||||
def get_results(request: HttpRequest):
|
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.
|
Get the user's score based on their objectives.
|
||||||
|
|
||||||
Calculates points as: ObjectivPoint.point * min(max_count, count) for each objective
|
Calculates points as: ObjectivPoint.point * min(max_count, count) for each objective
|
||||||
Uses Django ORM annotate for efficient queryset computation.
|
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
|
# 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
|
# Get points per objective from ObjectivPoint
|
||||||
points_per_objectiv=Subquery(
|
points_per_objectiv=Subquery(
|
||||||
ObjectivPoint.objects.filter(objectiv_id=OuterRef("objectiv_id")).values(
|
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"),
|
total_points=F("points_per_objectiv") * F("capped_count"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get total score
|
# Annotate seed + first-seen-at
|
||||||
total_score_result = user_objectives.aggregate(Sum("total_points"))[
|
user_objectives = user_objectives.annotate(
|
||||||
"total_points__sum"
|
seed=F("seed"),
|
||||||
]
|
first_seen_at=F("first_seen_at"),
|
||||||
total_score = total_score_result or 0
|
)
|
||||||
|
|
||||||
# Build response with all objectives
|
# Build response with all objectives and compute total score
|
||||||
objectives_with_points = [
|
total_score = 0
|
||||||
{
|
with_points = {}
|
||||||
|
for obj in ObjectivPoint.objects.all():
|
||||||
|
with_points[obj.objectiv_id] = {
|
||||||
"objectiv_id": obj.objectiv_id,
|
"objectiv_id": obj.objectiv_id,
|
||||||
"count": obj.count,
|
"display_string": obj.display_string,
|
||||||
"points_per_objectiv": obj.points_per_objectiv or 0,
|
"count": 0,
|
||||||
"total_points": obj.total_points or 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 {
|
for obj in user_objectives.order_by("-total_points"):
|
||||||
"total_score": total_score,
|
points = obj["total_points"] or 0
|
||||||
"objectives": objectives_with_points,
|
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)
|
@router.get("leaderboard", response=LeaderboardOut)
|
||||||
|
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||||
def get_leaderboard(request: HttpRequest):
|
def get_leaderboard(request: HttpRequest):
|
||||||
"""
|
"""
|
||||||
Get the global leaderboard for all users ranked by total score.
|
Get the global leaderboard for all users ranked by total score.
|
||||||
|
|
||||||
Uses Window functions to rank users by their total score in descending order.
|
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
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
User = 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 = (
|
all_objectives = (
|
||||||
Objectiv.objects.annotate(
|
Objectiv.objects.values("user", "objectiv_id")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.annotate(
|
||||||
# Fetch points from ObjectivPoint using Subquery
|
# Fetch points from ObjectivPoint using Subquery
|
||||||
points_per_objectiv=Subquery(
|
points_per_objectiv=Subquery(
|
||||||
ObjectivPoint.objects.filter(
|
ObjectivPoint.objects.filter(
|
||||||
@@ -146,48 +187,57 @@ def get_leaderboard(request: HttpRequest):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get user totals using subquery
|
# Build user totals by iterating through objectives
|
||||||
user_totals = (
|
user_totals_dict = {}
|
||||||
all_objectives.values("user")
|
for obj in all_objectives:
|
||||||
.annotate(total_score=Sum("total_points"))
|
user_id = obj["user"]
|
||||||
.values("user", "total_score")
|
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
|
# Get unique users and their scores, then apply ranking
|
||||||
leaderboard = (
|
users_with_scores = []
|
||||||
User.objects.filter(objectiv__isnull=False)
|
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()
|
.distinct()
|
||||||
.annotate(
|
.count()
|
||||||
total_score=Subquery(
|
|
||||||
user_totals.filter(user=OuterRef("id")).values("total_score")[:1],
|
|
||||||
output_field=IntegerField(),
|
|
||||||
)
|
)
|
||||||
)
|
deaths_count = DeathCounter.objects.filter(user_id=user_id).count()
|
||||||
.annotate(objectives_count=Count("objectiv", distinct=True))
|
users_with_scores.append(
|
||||||
.annotate(
|
{
|
||||||
rank=Window(
|
"user_id": user_id,
|
||||||
expression=Rank(),
|
"user": user,
|
||||||
order_by=F("total_score").desc(),
|
"total_score": total_score,
|
||||||
)
|
"objectives_count": objectives_count,
|
||||||
)
|
"deaths_count": deaths_count,
|
||||||
.values("rank", "username", "total_score", "objectives_count")
|
}
|
||||||
.order_by("rank")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
# Sort by score and add rank
|
||||||
"leaderboard": [
|
users_with_scores.sort(key=lambda x: x["total_score"], reverse=True)
|
||||||
|
leaderboard = [
|
||||||
{
|
{
|
||||||
"rank": entry["rank"],
|
"rank": idx + 1,
|
||||||
"username": entry["username"],
|
"username": entry["user"].username,
|
||||||
"total_score": entry["total_score"] or 0,
|
"is_staff": entry["user"].is_staff,
|
||||||
|
"total_score": entry["total_score"],
|
||||||
"objectives_count": entry["objectives_count"],
|
"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})
|
@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(...)):
|
def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
|
||||||
"""
|
"""
|
||||||
Submit a Noita run file (log file, screenshot, or video).
|
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 = [
|
allowed_types = [
|
||||||
"text/plain",
|
"text/plain",
|
||||||
"text/x-log",
|
"text/x-log",
|
||||||
"image/jpeg",
|
|
||||||
"image/jpg",
|
|
||||||
"image/png",
|
|
||||||
"image/gif",
|
|
||||||
"video/mp4",
|
|
||||||
"video/webm",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if file.content_type not in allowed_types:
|
if file.content_type not in allowed_types:
|
||||||
@@ -238,6 +282,11 @@ def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Invalidate caches on successful submission
|
||||||
|
if submission.user:
|
||||||
|
cache.delete(f"api:noita:results:{submission.user.id}")
|
||||||
|
cache.delete("api:noita:leaderboard")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": str(submission.id),
|
"id": str(submission.id),
|
||||||
"user_id": submission.user_id,
|
"user_id": submission.user_id,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from noita.models import ObjectivPoint
|
from noita.models import ObjectivPoint
|
||||||
from noita.services.decode import POINTS
|
from noita.services.decode import POINTS
|
||||||
|
from noita.services.spells import ALL_PERKS, ALL_SPELLS
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
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"\nCreated: {created_count}"))
|
||||||
self.stdout.write(self.style.SUCCESS(f"Updated: {updated_count}"))
|
self.stdout.write(self.style.SUCCESS(f"Updated: {updated_count}"))
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
|
|||||||
+31
@@ -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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@@ -49,7 +50,17 @@ class Objectiv(models.Model):
|
|||||||
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
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):
|
class ObjectivPoint(models.Model):
|
||||||
@@ -57,3 +68,17 @@ class ObjectivPoint(models.Model):
|
|||||||
display_string = models.CharField(max_length=255)
|
display_string = models.CharField(max_length=255)
|
||||||
max_count = models.IntegerField(default=1)
|
max_count = models.IntegerField(default=1)
|
||||||
point = models.IntegerField(default=0)
|
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",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from ninja import Schema, ModelSchema
|
from ninja import Schema
|
||||||
|
|
||||||
from noita.models import Objectiv
|
|
||||||
|
|
||||||
|
|
||||||
class ObjectivOut(ModelSchema):
|
|
||||||
class Meta:
|
|
||||||
model = Objectiv
|
|
||||||
fields = ["objectiv_id", "count"]
|
|
||||||
|
|
||||||
|
|
||||||
class NoitaSubmissionOut(Schema):
|
class NoitaSubmissionOut(Schema):
|
||||||
@@ -23,21 +15,28 @@ class NoitaSubmissionOut(Schema):
|
|||||||
|
|
||||||
class ObjectivResultOut(Schema):
|
class ObjectivResultOut(Schema):
|
||||||
objectiv_id: str
|
objectiv_id: str
|
||||||
|
display_string: str
|
||||||
|
first_seen_at: datetime | None
|
||||||
count: int
|
count: int
|
||||||
|
max_count: int
|
||||||
|
seed: str | None
|
||||||
points_per_objectiv: int
|
points_per_objectiv: int
|
||||||
total_points: int
|
total_points: int | None
|
||||||
|
|
||||||
|
|
||||||
class ResultsOut(Schema):
|
class ResultsOut(Schema):
|
||||||
total_score: int
|
total_score: int
|
||||||
|
deaths_count: int
|
||||||
objectives: list[ObjectivResultOut]
|
objectives: list[ObjectivResultOut]
|
||||||
|
|
||||||
|
|
||||||
class LeaderboardEntryOut(Schema):
|
class LeaderboardEntryOut(Schema):
|
||||||
rank: int
|
rank: int
|
||||||
username: str
|
username: str
|
||||||
|
is_staff: bool
|
||||||
total_score: int
|
total_score: int
|
||||||
objectives_count: int
|
objectives_count: int
|
||||||
|
deaths_count: int
|
||||||
|
|
||||||
|
|
||||||
class LeaderboardOut(Schema):
|
class LeaderboardOut(Schema):
|
||||||
|
|||||||
@@ -65,52 +65,95 @@ DEATH_PENALTY = 1
|
|||||||
# All scoreable events: base 1 pt for every spell and perk, overrides below.
|
# All scoreable events: base 1 pt for every spell and perk, overrides below.
|
||||||
POINTS = {
|
POINTS = {
|
||||||
# ── Spells ───────────────────────────────────────────────────────────────
|
# ── Spells ───────────────────────────────────────────────────────────────
|
||||||
**{sid: 1 for sid in ALL_SPELLS},
|
**{sid: 3 for sid in ALL_SPELLS},
|
||||||
"ADD_TRIGGER": 10,
|
"ADD_TRIGGER": 10,
|
||||||
|
"ADD_TIMER": 10,
|
||||||
|
"ADD_DEATH_TRIGGER": 10,
|
||||||
"NOLLA": 10,
|
"NOLLA": 10,
|
||||||
"CHAOTIC_TRANSMUTATION": 10,
|
"CHAOTIC_TRANSMUTATION": 10,
|
||||||
"DUPLICATE": 5,
|
"DUPLICATE": 5,
|
||||||
"OMEGA": 10,
|
"BURST_2": 10,
|
||||||
"HEALING_BOLT": 5,
|
"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 ─────────────────────────────────────────────────────────────────
|
# ── Perks ─────────────────────────────────────────────────────────────────
|
||||||
**{pid: 1 for pid in ALL_PERKS},
|
**{pid: 15 for pid in ALL_PERKS},
|
||||||
"EDIT_WANDS_EVERYWHERE": 10,
|
"PROTECTION_FIRE": 30,
|
||||||
|
"PROTECTION_RADIOACTIVITY": 30,
|
||||||
|
"PROTECTION_EXPLOSION": 30,
|
||||||
|
"PROTECTION_MELEE": 30,
|
||||||
|
"PROTECTION_ELECTRICITY": 30,
|
||||||
# ── Spell combos ─────────────────────────────────────────────────────────
|
# ── Spell combos ─────────────────────────────────────────────────────────
|
||||||
"PING_PONG_DRILL": 10,
|
"PING_PONG_DRILL": 40,
|
||||||
"HEAVY_SHOT_DISC": 10,
|
"HEAVY_SHOT_DISC": 40,
|
||||||
"TOUCH_OF_ANY": 5,
|
"TWO_ARC_MODIFIERS": 50,
|
||||||
"TWO_ARC_MODIFIERS": 10,
|
"TWO_TRAIL_MODIFIERS": 50,
|
||||||
"TWO_TRAIL_MODIFIERS": 10,
|
|
||||||
# ── Perk combos ──────────────────────────────────────────────────────────
|
# ── Perk combos ──────────────────────────────────────────────────────────
|
||||||
"CRIMSON_ALCHEMIST": 15,
|
"CRIMSON_ALCHEMIST": 50,
|
||||||
"GREEDY_GOBLIN_KING": 15,
|
"GREEDY_GOBLIN_KING": 50,
|
||||||
"STORM_TOUCHED_ASCENDANT": 15,
|
"STORM_TOUCHED_ASCENDANT": 50,
|
||||||
"ARCHMAGE_OF_CONTROL": 15,
|
"ARCHMAGE_OF_CONTROL": 50,
|
||||||
"HAUNTED_MAGE": 15,
|
"HAUNTED_MAGE": 50,
|
||||||
"GLASS_CANNON_MESSIAH": 15,
|
"GLASS_CANNON_MESSIAH": 50,
|
||||||
"INFINITE_ENGINE": 15,
|
"INFINITE_ENGINE": 50,
|
||||||
"PERFECT_ACCURACY_LOOP": 15,
|
"PERFECT_ACCURACY_LOOP": 50,
|
||||||
"HOMING_DEATH_SWARM": 15,
|
"HOMING_DEATH_SWARM": 50,
|
||||||
"UNTOUCHABLE_FIELD": 15,
|
"UNTOUCHABLE_FIELD": 50,
|
||||||
"ELECTRIC_SUSTAIN_LOOP": 15,
|
"ELECTRIC_SUSTAIN_LOOP": 50,
|
||||||
"IMMORTAL_LEECH_CORE": 15,
|
"IMMORTAL_LEECH_CORE": 50,
|
||||||
"PROJECTILE_OVERLOAD": 15,
|
"PROJECTILE_OVERLOAD": 50,
|
||||||
"CLOSE_RANGE_DEATH_MACHINE": 15,
|
"CLOSE_RANGE_DEATH_MACHINE": 50,
|
||||||
"CRITICAL_MASS": 15,
|
"CRITICAL_MASS": 50,
|
||||||
"HOLY_MOUNTAIN_ABUSER": 15,
|
"HOLY_MOUNTAIN_ABUSER": 50,
|
||||||
"DEFLECTOR_MATRIX": 15,
|
"DEFLECTOR_MATRIX": 50,
|
||||||
"STORMBORNE_LEVITATOR": 15,
|
"STORMBORNE_LEVITATOR": 50,
|
||||||
# ── Objectives ───────────────────────────────────────────────────────────
|
# ── Objectives ───────────────────────────────────────────────────────────
|
||||||
"HP_200": 5,
|
"HP_200": 50,
|
||||||
"HP_500": 5,
|
"HP_500": 50,
|
||||||
"HP_2000": 5,
|
"HP_2000": 100,
|
||||||
"HP_5000": 5,
|
"HP_5000": 500,
|
||||||
"GOLD_1000": 5,
|
"GOLD_1000": 5,
|
||||||
"GOLD_10000": 5,
|
"GOLD_10000": 50,
|
||||||
"GOLD_100000": 5,
|
"GOLD_100000": 100,
|
||||||
"GOLD_1000000": 5,
|
"GOLD_1000000": 200,
|
||||||
**{f"ORB_{i}": 5 for i in range(34)},
|
**{f"ORB_{i}": 25 for i in range(34)},
|
||||||
"FIND_AMBROSIA": 10,
|
**{f"BOSS_KILL_{i}": 75 * i for i in range(34)},
|
||||||
"WAND_MANA_500": 10,
|
"WAND_MANA_500": 10,
|
||||||
"WAND_MANA_1000": 10,
|
"WAND_MANA_1000": 10,
|
||||||
"WAND_MANA_1500": 10,
|
"WAND_MANA_1500": 10,
|
||||||
|
|||||||
@@ -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 noita.services.decode import parse_log, resolve
|
||||||
|
|
||||||
|
|
||||||
from collections import Counter
|
def parse_objectives_from_logfile(
|
||||||
|
logfile: LogfileSubmission,
|
||||||
|
) -> list[tuple[str, str, str]]:
|
||||||
def parse_objectives_from_logfile(logfile: LogfileSubmission) -> Counter:
|
|
||||||
"""Parse a log file, and output a count for each ID."""
|
"""Parse a log file, and output a count for each ID."""
|
||||||
file_data = logfile.file.read().decode()
|
file_data = logfile.file.read().decode()
|
||||||
|
|
||||||
ids = []
|
entries: list[tuple[str, str, str]] = []
|
||||||
for entry in parse_log(file_data):
|
for entry in parse_log(file_data):
|
||||||
idx, _seed = resolve(entry["hash"], entry["ts"])
|
idx, seed = resolve(entry["hash"], entry["ts"])
|
||||||
|
|
||||||
if idx:
|
if idx and seed:
|
||||||
ids.append(idx)
|
entries.append((idx, str(seed), entry["ts"]))
|
||||||
|
|
||||||
return Counter(ids)
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
|
def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
|
||||||
@@ -25,16 +24,31 @@ def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
|
|||||||
if not logfile.user:
|
if not logfile.user:
|
||||||
return
|
return
|
||||||
|
|
||||||
counter = parse_objectives_from_logfile(logfile)
|
objectives = []
|
||||||
|
deaths = []
|
||||||
for idx, count in counter.items():
|
for idx, seed, ts in parse_objectives_from_logfile(logfile):
|
||||||
if idx in {"-", "DEBUG"}:
|
if idx in {"-", "DEBUG", "polylan-mod"}:
|
||||||
continue
|
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,
|
objectiv_id=idx,
|
||||||
user=logfile.user,
|
user=logfile.user,
|
||||||
|
first_seen_at=ts,
|
||||||
|
seed=seed,
|
||||||
|
submission=logfile,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
obj.count += count
|
Objectiv.objects.bulk_create(
|
||||||
obj.save(update_fields=["count"])
|
objectives,
|
||||||
|
update_conflicts=True,
|
||||||
|
update_fields=["seed", "submission"],
|
||||||
|
unique_fields=["objectiv_id", "user", "first_seen_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
DeathCounter.objects.bulk_create(deaths, ignore_conflicts=True)
|
||||||
|
|||||||
@@ -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.contrib import admin
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from submissions.models import (
|
from opus_magnum.models import (
|
||||||
SteamAPIKey,
|
SteamAPIKey,
|
||||||
SteamCollection,
|
SteamCollection,
|
||||||
SteamCollectionItem,
|
SteamCollectionItem,
|
||||||
@@ -81,6 +81,7 @@ class SteamCollectionAdmin(admin.ModelAdmin):
|
|||||||
"current_favorites",
|
"current_favorites",
|
||||||
"last_fetched",
|
"last_fetched",
|
||||||
"is_active",
|
"is_active",
|
||||||
|
"accepting_submissions",
|
||||||
]
|
]
|
||||||
list_filter = ["is_active", "last_fetched", "created_at"]
|
list_filter = ["is_active", "last_fetched", "created_at"]
|
||||||
search_fields = ["title", "steam_id", "author_name", "description"]
|
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 import Router, File
|
||||||
from ninja.files import UploadedFile
|
from ninja.files import UploadedFile
|
||||||
from ninja.pagination import paginate
|
from ninja.pagination import paginate
|
||||||
|
from ninja.errors import HttpError
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
|
from django.core.cache import cache
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from typing import List
|
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 (
|
from .schemas import (
|
||||||
SubmissionIn,
|
SubmissionIn,
|
||||||
SubmissionOut,
|
SubmissionOut,
|
||||||
PuzzleResponseOut,
|
PuzzleResponseOut,
|
||||||
ValidationIn,
|
ValidationIn,
|
||||||
SteamCollectionItemOut,
|
SteamCollectionItemOut,
|
||||||
|
SteamCollectionOut,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
OPUS_MAGNUM_APP_ID = 558990
|
||||||
|
|
||||||
|
|
||||||
@router.get("/puzzles", response=List[SteamCollectionItemOut])
|
@router.get("/puzzles", response=List[SteamCollectionItemOut])
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def list_puzzles(request):
|
def list_puzzles(request):
|
||||||
"""Get list of available puzzles"""
|
"""Get list of available puzzles"""
|
||||||
return SteamCollectionItem.objects.select_related("collection").filter(
|
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])
|
@router.get("/submissions", response=List[SubmissionOut])
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
@paginate
|
@paginate
|
||||||
def list_submissions(request):
|
def list_submissions(request):
|
||||||
"""Get paginated list of submissions"""
|
"""Get paginated list of submissions"""
|
||||||
@@ -39,6 +61,7 @@ def list_submissions(request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/submissions/{submission_id}", response=SubmissionOut)
|
@router.get("/submissions/{submission_id}", response=SubmissionOut)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def get_submission(request, submission_id: str):
|
def get_submission(request, submission_id: str):
|
||||||
"""Get detailed submission by ID"""
|
"""Get detailed submission by ID"""
|
||||||
return get_object_or_404(
|
return get_object_or_404(
|
||||||
@@ -50,6 +73,7 @@ def get_submission(request, submission_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/submissions", response=SubmissionOut)
|
@router.post("/submissions", response=SubmissionOut)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def create_submission(
|
def create_submission(
|
||||||
request, data: SubmissionIn, files: List[UploadedFile] = File(...)
|
request, data: SubmissionIn, files: List[UploadedFile] = File(...)
|
||||||
):
|
):
|
||||||
@@ -64,7 +88,15 @@ def create_submission(
|
|||||||
if len(files) < len(data.responses):
|
if len(files) < len(data.responses):
|
||||||
return 400, {"detail": "Not enough files for all 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:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@@ -125,7 +157,6 @@ def create_submission(
|
|||||||
# Process files for this response
|
# Process files for this response
|
||||||
# For simplicity, we'll take one file per response
|
# For simplicity, we'll take one file per response
|
||||||
# In a real implementation, you'd need better file-to-response mapping
|
# In a real implementation, you'd need better file-to-response mapping
|
||||||
print("FI", file_index, files)
|
|
||||||
if file_index < len(files):
|
if file_index < len(files):
|
||||||
uploaded_file = files[file_index]
|
uploaded_file = files[file_index]
|
||||||
|
|
||||||
@@ -165,14 +196,17 @@ def create_submission(
|
|||||||
"responses__files", "responses__puzzle"
|
"responses__files", "responses__puzzle"
|
||||||
).get(id=submission.id)
|
).get(id=submission.id)
|
||||||
|
|
||||||
|
# Invalidate results cache on successful submission
|
||||||
|
cache.delete("api:results:results")
|
||||||
|
|
||||||
return submission
|
return submission
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
|
||||||
return 500, {"detail": f"Error creating submission: {str(e)}"}
|
return 500, {"detail": f"Error creating submission: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/responses/{response_id}/validate", response=PuzzleResponseOut)
|
@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):
|
def validate_response(request, response_id: int, data: ValidationIn):
|
||||||
"""Manually validate a puzzle response"""
|
"""Manually validate a puzzle response"""
|
||||||
|
|
||||||
@@ -201,10 +235,14 @@ def validate_response(request, response_id: int, data: ValidationIn):
|
|||||||
|
|
||||||
response.save()
|
response.save()
|
||||||
|
|
||||||
|
# Invalidate results cache when a response is validated
|
||||||
|
cache.delete("api:results:results")
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.put("/responses/{response_id}/validate/auto", response=PuzzleResponseOut)
|
@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):
|
def validate_auto(request, response_id: int):
|
||||||
"""Try to auto validate a puzzle response"""
|
"""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])
|
@router.get("/responses/needs-validation", response=List[PuzzleResponseOut])
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def list_responses_needing_validation(request):
|
def list_responses_needing_validation(request):
|
||||||
"""Get all responses that need manual validation"""
|
"""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)
|
@router.post("/submissions/{submission_id}/validate", response=SubmissionOut)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def validate_submission(request, submission_id: str):
|
def validate_submission(request, submission_id: str):
|
||||||
"""Mark entire submission as validated"""
|
"""Mark entire submission as validated"""
|
||||||
|
|
||||||
@@ -256,10 +296,14 @@ def validate_submission(request, submission_id: str):
|
|||||||
"responses__files", "responses__puzzle"
|
"responses__files", "responses__puzzle"
|
||||||
).get(id=submission.id)
|
).get(id=submission.id)
|
||||||
|
|
||||||
|
# Invalidate results cache when submission is validated
|
||||||
|
cache.delete("api:results:results")
|
||||||
|
|
||||||
return submission
|
return submission
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/submissions/{submission_id}")
|
@router.delete("/submissions/{submission_id}")
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def delete_submission(request, submission_id: str):
|
def delete_submission(request, submission_id: str):
|
||||||
"""Delete a submission (admin only)"""
|
"""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 = get_object_or_404(Submission, id=submission_id)
|
||||||
submission.delete()
|
submission.delete()
|
||||||
|
|
||||||
|
# Invalidate results cache when submission is deleted
|
||||||
|
cache.delete("api:results:results")
|
||||||
|
|
||||||
return {"detail": "Submission deleted successfully"}
|
return {"detail": "Submission deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def get_stats(request):
|
def get_stats(request):
|
||||||
"""Get submission statistics"""
|
"""Get submission statistics"""
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class OpusMagnumConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "opus_magnum"
|
||||||
+3
-3
@@ -3,8 +3,8 @@ Django management command to fetch Steam Workshop collections
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from submissions.utils import create_or_update_collection
|
from opus_magnum.utils import create_or_update_collection
|
||||||
from submissions.models import SteamAPIKey, SteamCollection
|
from opus_magnum.models import SteamAPIKey, SteamCollection
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -34,7 +34,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if collection already exists
|
# Check if collection already exists
|
||||||
from submissions.utils import SteamCollectionFetcher
|
from opus_magnum.utils import SteamCollectionFetcher
|
||||||
|
|
||||||
fetcher = SteamCollectionFetcher(api_key.api_key)
|
fetcher = SteamCollectionFetcher(api_key.api_key)
|
||||||
collection_id = fetcher.extract_collection_id(url)
|
collection_id = fetcher.extract_collection_id(url)
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from submissions.utils import verify_and_validate_ocr_date_for_submission
|
from opus_magnum.utils import verify_and_validate_ocr_date_for_submission
|
||||||
from submissions.models import SubmissionFile
|
from opus_magnum.models import SubmissionFile
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
+1
-1
@@ -205,7 +205,7 @@ class Migration(migrations.Migration):
|
|||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
related_name="items",
|
related_name="items",
|
||||||
to="submissions.steamcollection",
|
to="opus_magnum.steamcollection",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
+1
-1
@@ -5,7 +5,7 @@ from django.db import migrations
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("submissions", "0001_initial"),
|
("opus_magnum", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+1
-1
@@ -5,7 +5,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("submissions", "0002_delete_collection"),
|
("opus_magnum", "0002_delete_collection"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+6
-6
@@ -1,7 +1,7 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-29 01:32
|
# Generated by Django 5.2.7 on 2025-10-29 01:32
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import submissions.models
|
import opus_magnum.models
|
||||||
import uuid
|
import uuid
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
@@ -9,7 +9,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("submissions", "0003_steamapikey"),
|
("opus_magnum", "0003_steamapikey"),
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ class Migration(migrations.Migration):
|
|||||||
help_text="The puzzle this response is for",
|
help_text="The puzzle this response is for",
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
related_name="responses",
|
related_name="responses",
|
||||||
to="submissions.steamcollectionitem",
|
to="opus_magnum.steamcollectionitem",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -168,7 +168,7 @@ class Migration(migrations.Migration):
|
|||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
related_name="responses",
|
related_name="responses",
|
||||||
to="submissions.submission",
|
to="opus_magnum.submission",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -195,7 +195,7 @@ class Migration(migrations.Migration):
|
|||||||
"file",
|
"file",
|
||||||
models.FileField(
|
models.FileField(
|
||||||
help_text="Uploaded file (image/gif)",
|
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(
|
models.ForeignKey(
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
related_name="files",
|
related_name="files",
|
||||||
to="submissions.puzzleresponse",
|
to="opus_magnum.puzzleresponse",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
+1
-1
@@ -5,7 +5,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("submissions", "0004_submission_puzzleresponse_submissionfile"),
|
("opus_magnum", "0004_submission_puzzleresponse_submissionfile"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+1
-1
@@ -5,7 +5,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("submissions", "0005_alter_submission_notes"),
|
("opus_magnum", "0005_alter_submission_notes"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+1
-1
@@ -5,7 +5,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("submissions", "0006_remove_puzzleresponse_ocr_confidence_score_and_more"),
|
("opus_magnum", "0006_remove_puzzleresponse_ocr_confidence_score_and_more"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+1
-1
@@ -5,7 +5,7 @@ from django.db import migrations
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("submissions", "0007_submission_manual_validation_requested"),
|
("opus_magnum", "0007_submission_manual_validation_requested"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+1
-1
@@ -7,7 +7,7 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("animations", "0001_initial"),
|
("animations", "0001_initial"),
|
||||||
("submissions", "0008_alter_puzzleresponse_unique_together"),
|
("opus_magnum", "0008_alter_puzzleresponse_unique_together"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+1
-1
@@ -5,7 +5,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("submissions", "0009_steamcollectionitem_points_factor"),
|
("opus_magnum", "0009_steamcollectionitem_points_factor"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+1
-1
@@ -5,7 +5,7 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("submissions", "0010_alter_puzzleresponse_validated_area_and_more"),
|
("opus_magnum", "0010_alter_puzzleresponse_validated_area_and_more"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+1
-1
@@ -6,7 +6,7 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
(
|
(
|
||||||
"submissions",
|
"opus_magnum",
|
||||||
"0011_alter_puzzleresponse_area_alter_puzzleresponse_cost_and_more",
|
"0011_alter_puzzleresponse_area_alter_puzzleresponse_cost_and_more",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
+1
-1
@@ -7,7 +7,7 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("animations", "0002_puzzlepointsvalue"),
|
("animations", "0002_puzzlepointsvalue"),
|
||||||
("submissions", "0012_alter_puzzleresponse_validated_area_and_more"),
|
("opus_magnum", "0012_alter_puzzleresponse_validated_area_and_more"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
+20
@@ -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(
|
is_active = models.BooleanField(
|
||||||
default=True, help_text="Whether this collection is actively tracked"
|
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(
|
fetch_error = models.TextField(
|
||||||
blank=True, help_text="Last error encountered when fetching data"
|
blank=True, help_text="Last error encountered when fetching data"
|
||||||
)
|
)
|
||||||
+47
-1
@@ -3,7 +3,13 @@ from typing import List, Optional
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem
|
from .models import (
|
||||||
|
Submission,
|
||||||
|
PuzzleResponse,
|
||||||
|
SubmissionFile,
|
||||||
|
SteamCollectionItem,
|
||||||
|
SteamCollection,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Input Schemas
|
# Input Schemas
|
||||||
@@ -132,10 +138,39 @@ class ValidationIn(Schema):
|
|||||||
|
|
||||||
|
|
||||||
# Collection Schemas
|
# 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):
|
class SteamCollectionItemOut(ModelSchema):
|
||||||
"""Schema for Steam collection item output"""
|
"""Schema for Steam collection item output"""
|
||||||
|
|
||||||
steam_url: str
|
steam_url: str
|
||||||
|
points_factor: Optional[PuzzlePointsFactorOut] = None
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SteamCollectionItem
|
model = SteamCollectionItem
|
||||||
@@ -151,6 +186,16 @@ class SteamCollectionItemOut(ModelSchema):
|
|||||||
"updated_at",
|
"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
|
# Error Schemas
|
||||||
class ErrorOut(Schema):
|
class ErrorOut(Schema):
|
||||||
@@ -176,6 +221,7 @@ class UserInfoOut(Schema):
|
|||||||
first_name: Optional[str] = None
|
first_name: Optional[str] = None
|
||||||
last_name: Optional[str] = None
|
last_name: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
|
points: int = 0
|
||||||
is_authenticated: bool
|
is_authenticated: bool
|
||||||
is_staff: bool
|
is_staff: bool
|
||||||
is_superuser: bool
|
is_superuser: bool
|
||||||
@@ -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 re
|
||||||
import requests
|
import requests
|
||||||
from submissions.models import SteamCollection, SteamCollectionItem, SubmissionFile
|
from opus_magnum.models import SteamCollection, SteamCollectionItem, SubmissionFile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -7,12 +7,14 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc -b && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"schema": "openapi-ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vueuse/core": "^14.0.0",
|
"@vueuse/core": "^14.0.0",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
"install": "^0.13.0",
|
"install": "^0.13.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
"vue": "^3.5.22"
|
"vue": "^3.5.22"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@hey-api/openapi-ts": "^0.97.2",
|
||||||
"@mdi/font": "^7.4.47",
|
"@mdi/font": "^7.4.47",
|
||||||
"@types/node": "^24.6.0",
|
"@types/node": "^24.6.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
|||||||
Generated
+390
@@ -17,6 +17,9 @@ importers:
|
|||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^14.0.0
|
specifier: ^14.0.0
|
||||||
version: 14.0.0(vue@3.5.22(typescript@5.9.3))
|
version: 14.0.0(vue@3.5.22(typescript@5.9.3))
|
||||||
|
dayjs:
|
||||||
|
specifier: ^1.11.20
|
||||||
|
version: 1.11.20
|
||||||
install:
|
install:
|
||||||
specifier: ^0.13.0
|
specifier: ^0.13.0
|
||||||
version: 0.13.0
|
version: 0.13.0
|
||||||
@@ -33,6 +36,9 @@ importers:
|
|||||||
specifier: ^3.5.22
|
specifier: ^3.5.22
|
||||||
version: 3.5.22(typescript@5.9.3)
|
version: 3.5.22(typescript@5.9.3)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@hey-api/openapi-ts':
|
||||||
|
specifier: ^0.97.2
|
||||||
|
version: 0.97.2(typescript@5.9.3)
|
||||||
'@mdi/font':
|
'@mdi/font':
|
||||||
specifier: ^7.4.47
|
specifier: ^7.4.47
|
||||||
version: 7.4.47
|
version: 7.4.47
|
||||||
@@ -233,6 +239,31 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
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':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
|
|
||||||
@@ -249,6 +280,13 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
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':
|
'@mdi/font@7.4.47':
|
||||||
resolution: {integrity: sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==}
|
resolution: {integrity: sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==}
|
||||||
|
|
||||||
@@ -468,6 +506,9 @@ packages:
|
|||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
'@types/json-schema@7.0.15':
|
||||||
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
'@types/node@24.9.2':
|
'@types/node@24.9.2':
|
||||||
resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==}
|
resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==}
|
||||||
|
|
||||||
@@ -563,26 +604,89 @@ packages:
|
|||||||
alien-signals@3.0.3:
|
alien-signals@3.0.3:
|
||||||
resolution: {integrity: sha512-2JXjom6R7ZwrISpUphLhf4htUq1aKRCennTJ6u9kFfr3sLmC9+I4CxxVi+McoFnIg+p1HnVrfLT/iCt4Dlz//Q==}
|
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:
|
birpc@2.6.1:
|
||||||
resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==}
|
resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==}
|
||||||
|
|
||||||
bmp-js@0.1.0:
|
bmp-js@0.1.0:
|
||||||
resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==}
|
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:
|
copy-anything@4.0.5:
|
||||||
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
|
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
cross-spawn@7.0.6:
|
||||||
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
csstype@3.1.3:
|
csstype@3.1.3:
|
||||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||||
|
|
||||||
daisyui@5.3.10:
|
daisyui@5.3.10:
|
||||||
resolution: {integrity: sha512-vmjyPmm0hvFhA95KB6uiGmWakziB2pBv6CUcs5Ka/3iMBMn9S+C3SZYx9G9l2JrgTZ1EFn61F/HrPcwaUm2kLQ==}
|
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:
|
detect-libc@2.1.2:
|
||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
dotenv@17.4.2:
|
||||||
|
resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
enhanced-resolve@5.18.3:
|
enhanced-resolve@5.18.3:
|
||||||
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@@ -599,6 +703,9 @@ packages:
|
|||||||
estree-walker@2.0.2:
|
estree-walker@2.0.2:
|
||||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||||
|
|
||||||
|
exsolve@1.0.8:
|
||||||
|
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
|
||||||
|
|
||||||
fdir@6.5.0:
|
fdir@6.5.0:
|
||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -613,6 +720,13 @@ packages:
|
|||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
os: [darwin]
|
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:
|
graceful-fs@4.2.11:
|
||||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
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==}
|
resolution: {integrity: sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==}
|
||||||
engines: {node: '>= 0.10'}
|
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:
|
is-electron@2.2.2:
|
||||||
resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==}
|
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:
|
is-url@1.2.4:
|
||||||
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
|
||||||
|
|
||||||
@@ -636,10 +764,21 @@ packages:
|
|||||||
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
|
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
|
||||||
engines: {node: '>=18'}
|
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:
|
jiti@2.6.1:
|
||||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
js-yaml@4.1.1:
|
||||||
|
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
lightningcss-android-arm64@1.30.2:
|
lightningcss-android-arm64@1.30.2:
|
||||||
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -733,6 +872,13 @@ packages:
|
|||||||
encoding:
|
encoding:
|
||||||
optional: true
|
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:
|
opencollective-postinstall@2.0.3:
|
||||||
resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==}
|
resolution: {integrity: sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -740,9 +886,19 @@ packages:
|
|||||||
path-browserify@1.0.1:
|
path-browserify@1.0.1:
|
||||||
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
|
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:
|
perfect-debounce@1.0.0:
|
||||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||||
|
|
||||||
|
perfect-debounce@2.1.0:
|
||||||
|
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@@ -759,13 +915,30 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
pkg-types@2.3.1:
|
||||||
|
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
|
||||||
|
|
||||||
postcss@8.5.6:
|
postcss@8.5.6:
|
||||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
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:
|
regenerator-runtime@0.13.11:
|
||||||
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||||
|
|
||||||
|
resolve-pkg-maps@1.0.0:
|
||||||
|
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||||
|
|
||||||
rfdc@1.4.1:
|
rfdc@1.4.1:
|
||||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||||
|
|
||||||
@@ -774,6 +947,23 @@ packages:
|
|||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
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:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -880,6 +1070,15 @@ packages:
|
|||||||
whatwg-url@5.0.0:
|
whatwg-url@5.0.0:
|
||||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
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:
|
yaml@2.8.4:
|
||||||
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
|
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
|
||||||
engines: {node: '>= 14.6'}
|
engines: {node: '>= 14.6'}
|
||||||
@@ -981,6 +1180,56 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.25.11':
|
'@esbuild/win32-x64@0.25.11':
|
||||||
optional: true
|
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':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@@ -1000,6 +1249,10 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@jsdevtools/ono@7.1.3': {}
|
||||||
|
|
||||||
|
'@lukeed/ms@2.0.2': {}
|
||||||
|
|
||||||
'@mdi/font@7.4.47': {}
|
'@mdi/font@7.4.47': {}
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.29': {}
|
'@rolldown/pluginutils@1.0.0-beta.29': {}
|
||||||
@@ -1147,6 +1400,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
'@types/node@24.9.2':
|
'@types/node@24.9.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.16.0
|
undici-types: 7.16.0
|
||||||
@@ -1275,20 +1530,76 @@ snapshots:
|
|||||||
|
|
||||||
alien-signals@3.0.3: {}
|
alien-signals@3.0.3: {}
|
||||||
|
|
||||||
|
ansi-colors@4.1.3: {}
|
||||||
|
|
||||||
|
argparse@2.0.1: {}
|
||||||
|
|
||||||
birpc@2.6.1: {}
|
birpc@2.6.1: {}
|
||||||
|
|
||||||
bmp-js@0.1.0: {}
|
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:
|
copy-anything@4.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-what: 5.5.0
|
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: {}
|
csstype@3.1.3: {}
|
||||||
|
|
||||||
daisyui@5.3.10: {}
|
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: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
|
dotenv@17.4.2: {}
|
||||||
|
|
||||||
enhanced-resolve@5.18.3:
|
enhanced-resolve@5.18.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -1327,6 +1638,8 @@ snapshots:
|
|||||||
|
|
||||||
estree-walker@2.0.2: {}
|
estree-walker@2.0.2: {}
|
||||||
|
|
||||||
|
exsolve@1.0.8: {}
|
||||||
|
|
||||||
fdir@6.5.0(picomatch@4.0.3):
|
fdir@6.5.0(picomatch@4.0.3):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
@@ -1334,6 +1647,12 @@ snapshots:
|
|||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
get-tsconfig@4.14.0:
|
||||||
|
dependencies:
|
||||||
|
resolve-pkg-maps: 1.0.0
|
||||||
|
|
||||||
|
giget@3.2.0: {}
|
||||||
|
|
||||||
graceful-fs@4.2.11: {}
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
hookable@5.5.3: {}
|
hookable@5.5.3: {}
|
||||||
@@ -1342,14 +1661,32 @@ snapshots:
|
|||||||
|
|
||||||
install@0.13.0: {}
|
install@0.13.0: {}
|
||||||
|
|
||||||
|
is-docker@3.0.0: {}
|
||||||
|
|
||||||
is-electron@2.2.2: {}
|
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-url@1.2.4: {}
|
||||||
|
|
||||||
is-what@5.5.0: {}
|
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: {}
|
jiti@2.6.1: {}
|
||||||
|
|
||||||
|
js-yaml@4.1.1:
|
||||||
|
dependencies:
|
||||||
|
argparse: 2.0.1
|
||||||
|
|
||||||
lightningcss-android-arm64@1.30.2:
|
lightningcss-android-arm64@1.30.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1413,12 +1750,29 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
whatwg-url: 5.0.0
|
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: {}
|
opencollective-postinstall@2.0.3: {}
|
||||||
|
|
||||||
path-browserify@1.0.1: {}
|
path-browserify@1.0.1: {}
|
||||||
|
|
||||||
|
path-key@3.1.1: {}
|
||||||
|
|
||||||
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
perfect-debounce@1.0.0: {}
|
perfect-debounce@1.0.0: {}
|
||||||
|
|
||||||
|
perfect-debounce@2.1.0: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@4.0.3: {}
|
picomatch@4.0.3: {}
|
||||||
@@ -1430,14 +1784,31 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
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:
|
postcss@8.5.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.11
|
nanoid: 3.3.11
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.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: {}
|
regenerator-runtime@0.13.11: {}
|
||||||
|
|
||||||
|
resolve-pkg-maps@1.0.0: {}
|
||||||
|
|
||||||
rfdc@1.4.1: {}
|
rfdc@1.4.1: {}
|
||||||
|
|
||||||
rollup@4.52.5:
|
rollup@4.52.5:
|
||||||
@@ -1468,6 +1839,16 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc': 4.52.5
|
'@rollup/rollup-win32-x64-msvc': 4.52.5
|
||||||
fsevents: 2.3.3
|
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: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
speakingurl@14.0.1: {}
|
speakingurl@14.0.1: {}
|
||||||
@@ -1550,6 +1931,15 @@ snapshots:
|
|||||||
tr46: 0.0.3
|
tr46: 0.0.3
|
||||||
webidl-conversions: 3.0.1
|
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:
|
yaml@2.8.4:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
from ninja import NinjaAPI
|
from ninja import NinjaAPI
|
||||||
from submissions.api import router as submissions_router
|
from django.conf import settings
|
||||||
from submissions.schemas import UserInfoOut
|
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 animations.api import router as results_router
|
||||||
from noita.api import router as noita_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
|
# Create the main API instance
|
||||||
api = NinjaAPI(
|
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
|
# Add authentication for protected endpoints
|
||||||
# api.auth = django_auth # Uncomment if you want global auth
|
# api.auth = django_auth # Uncomment if you want global auth
|
||||||
|
|
||||||
# Include the submissions router
|
# Include the opus_magnum router
|
||||||
api.add_router("/submissions/", submissions_router, tags=["submissions"])
|
api.add_router("/opus-magnum/", submissions_router, tags=["opus-magnum"])
|
||||||
api.add_router("/results/", results_router, tags=["results"])
|
api.add_router("/results/", results_router, tags=["results"])
|
||||||
api.add_router("/noita/", noita_router, tags=["noita"])
|
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
|
# Health check endpoint
|
||||||
@@ -41,6 +50,19 @@ def health_check(request):
|
|||||||
return {"status": "healthy", "service": "polylan-submitter-api"}
|
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
|
# User info endpoint
|
||||||
@api.get("/user", response=UserInfoOut)
|
@api.get("/user", response=UserInfoOut)
|
||||||
def get_user_info(request):
|
def get_user_info(request):
|
||||||
@@ -48,18 +70,8 @@ def get_user_info(request):
|
|||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
if user.is_authenticated:
|
if user.is_authenticated:
|
||||||
return {
|
return user
|
||||||
"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 {
|
return {
|
||||||
"is_authenticated": False,
|
"is_authenticated": False,
|
||||||
"is_staff": False,
|
"is_staff": False,
|
||||||
|
|||||||
@@ -41,8 +41,10 @@ INSTALLED_APPS = [
|
|||||||
"django_vite",
|
"django_vite",
|
||||||
"accounts",
|
"accounts",
|
||||||
"animations",
|
"animations",
|
||||||
"submissions",
|
"opus_magnum",
|
||||||
"noita",
|
"noita",
|
||||||
|
"games",
|
||||||
|
"market",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -145,6 +147,18 @@ MEDIA_ROOT = BASE_DIR / "media"
|
|||||||
FILE_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB
|
FILE_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB
|
||||||
DATA_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 file types for submissions
|
||||||
ALLOWED_SUBMISSION_TYPES = [
|
ALLOWED_SUBMISSION_TYPES = [
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
@@ -155,6 +169,9 @@ ALLOWED_SUBMISSION_TYPES = [
|
|||||||
"video/webm",
|
"video/webm",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Market app settings
|
||||||
|
MARKET_ENABLED = os.environ.get("MARKET_ENABLED", "true").lower() == "true"
|
||||||
|
|
||||||
# Authentication backends
|
# Authentication backends
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
"django.contrib.auth.backends.ModelBackend",
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
@@ -181,7 +198,7 @@ STATICFILES_DIRS = [
|
|||||||
from polylan_submitter.settingsLocal import * # noqa
|
from polylan_submitter.settingsLocal import * # noqa
|
||||||
|
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk # noqa
|
||||||
|
|
||||||
sentry_sdk.init(
|
sentry_sdk.init(
|
||||||
dsn="https://cc62a4ce3f3470890b43accf02cc6d8c@sentry2.polylan.ch/12",
|
dsn="https://cc62a4ce3f3470890b43accf02cc6d8c@sentry2.polylan.ch/12",
|
||||||
|
|||||||
@@ -23,17 +23,31 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
|
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
|
||||||
|
from games.decorators import require_game_enabled
|
||||||
|
from market.views import market_home
|
||||||
from .api import api
|
from .api import api
|
||||||
|
|
||||||
|
NOITA_APP_ID = 881100
|
||||||
|
OPUS_MAGNUM_APP_ID = 558990
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def home(request: HttpRequest):
|
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
|
@login_required
|
||||||
|
@require_game_enabled(OPUS_MAGNUM_APP_ID)
|
||||||
def opus_magnum_home(request: HttpRequest):
|
def opus_magnum_home(request: HttpRequest):
|
||||||
from submissions.models import SteamCollection
|
from opus_magnum.models import SteamCollection
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
@@ -45,6 +59,7 @@ def opus_magnum_home(request: HttpRequest):
|
|||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@require_game_enabled(NOITA_APP_ID)
|
||||||
def noita_home(request: HttpRequest):
|
def noita_home(request: HttpRequest):
|
||||||
return render(request, "noita.html", {})
|
return render(request, "noita.html", {})
|
||||||
|
|
||||||
@@ -54,6 +69,7 @@ urlpatterns = [
|
|||||||
path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"),
|
path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"),
|
||||||
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
|
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
|
||||||
path("api/", api.urls),
|
path("api/", api.urls),
|
||||||
|
path("market", market_home, name="market.home"),
|
||||||
path("opus-magnum", opus_magnum_home, name="opus-magnum.home"),
|
path("opus-magnum", opus_magnum_home, name="opus-magnum.home"),
|
||||||
path("noita", noita_home, name="noita.home"),
|
path("noita", noita_home, name="noita.home"),
|
||||||
path("", home, name="home"),
|
path("", home, name="home"),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import requests
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.backends import BaseBackend
|
from django.contrib.auth.backends import BaseBackend
|
||||||
from furl import furl
|
|
||||||
|
|
||||||
|
|
||||||
class SimpleCASBackend(BaseBackend):
|
class SimpleCASBackend(BaseBackend):
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<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(() => [
|
interface Props {
|
||||||
{
|
marketEnabled?: string | boolean;
|
||||||
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",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
marketEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const games = ref<GamesApiListGamesResponse | undefined>();
|
||||||
|
const loading = ref(true);
|
||||||
const imageErrors = ref<Set<number>>(new Set());
|
const imageErrors = ref<Set<number>>(new Set());
|
||||||
|
|
||||||
const getHeaderImage = (appId: number) => {
|
const getHeaderImage = (appId: number) => {
|
||||||
@@ -31,6 +26,22 @@ const onImageError = (appId: number) => {
|
|||||||
const navigate = (path: string) => {
|
const navigate = (path: string) => {
|
||||||
window.location.href = path;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -44,13 +55,38 @@ const navigate = (path: string) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="flex justify-center py-20">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Cards Grid -->
|
<!-- Cards Grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
<div v-for="game in games" :key="game.id" @click="navigate(game.path)"
|
<!-- 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">
|
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">
|
<figure class="relative h-60 bg-base-300 overflow-hidden">
|
||||||
<img v-if="!imageErrors.has(game.appId)" :src="getHeaderImage(game.appId)" :alt="game.title"
|
<img v-if="!imageErrors.has(game.steam_app_id)" :src="getHeaderImage(game.steam_app_id)" :alt="game.name"
|
||||||
@error="onImageError(game.appId)" class="w-full h-full object-cover" />
|
@error="onImageError(game.steam_app_id)" class="w-full h-full object-cover" />
|
||||||
<div v-else
|
<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">
|
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>
|
<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>
|
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"></div>
|
||||||
</figure>
|
</figure>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl">{{ game.title }}</h2>
|
<h2 class="card-title text-2xl">{{ game.name }}</h2>
|
||||||
<p class="text-base-content/70">{{ game.description }}</p>
|
|
||||||
<div class="card-actions justify-end mt-4">
|
<div class="card-actions justify-end mt-4">
|
||||||
<button class="btn btn-primary">
|
<button class="btn btn-primary">
|
||||||
<i class="mdi mdi-arrow-right mr-2"></i>
|
<i class="mdi mdi-arrow-right mr-2"></i>
|
||||||
@@ -75,7 +110,5 @@ const navigate = (path: string) => {
|
|||||||
<p>Select a game above to begin submitting</p>
|
<p>Select a game above to begin submitting</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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
@@ -1,26 +1,106 @@
|
|||||||
<script setup lang="ts">
|
<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 {
|
const noitaStore = useNoitaStore();
|
||||||
objectiv_id: string;
|
const { userInfo, objectives, leaderboard, isLoadingLeaderboard, isUploading } = storeToRefs(noitaStore);
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userInfo = ref({
|
|
||||||
username: "Player",
|
|
||||||
rank: null as number | null,
|
|
||||||
score: 0,
|
|
||||||
runsSubmitted: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadedFiles = ref<File[]>([]);
|
const uploadedFiles = ref<File[]>([]);
|
||||||
const isUploading = ref(false);
|
|
||||||
const isDragover = ref(false);
|
const isDragover = ref(false);
|
||||||
const objectives = ref<Objective[]>([]);
|
|
||||||
const isLoadingObjectives = ref(false);
|
const columnHelper = createColumnHelper<Objective>();
|
||||||
const isLoadingLeaderboard = ref(false);
|
const sorting = ref<SortingState>([]);
|
||||||
const leaderboard = ref<any[]>([]);
|
const columnFilters = ref<ColumnFiltersState>([]);
|
||||||
const isLeaderboardModalOpen = ref(false);
|
|
||||||
|
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 handleFileUpload = (event: Event) => {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
@@ -54,41 +134,13 @@ const handleDrop = (event: DragEvent) => {
|
|||||||
const submitRun = async () => {
|
const submitRun = async () => {
|
||||||
if (uploadedFiles.value.length === 0) return;
|
if (uploadedFiles.value.length === 0) return;
|
||||||
|
|
||||||
isUploading.value = true;
|
|
||||||
try {
|
try {
|
||||||
for (const file of uploadedFiles.value) {
|
await noitaStore.submitRun(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadedFiles.value = [];
|
uploadedFiles.value = [];
|
||||||
alert("Run submitted successfully!");
|
alert("Run submitted successfully!");
|
||||||
|
|
||||||
// Refresh objectives, score, and rank after successful submission
|
|
||||||
await Promise.all([
|
|
||||||
fetchObjectives(),
|
|
||||||
fetchUserResults(),
|
|
||||||
fetchLeaderboard(),
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error submitting run:", error);
|
console.error("Error submitting run:", error);
|
||||||
alert("Error submitting run. Please try again.");
|
alert("Error submitting run. Please try again.");
|
||||||
} finally {
|
|
||||||
isUploading.value = false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,82 +148,18 @@ const goHome = () => {
|
|||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchObjectives = async () => {
|
const clearCache = async () => {
|
||||||
isLoadingObjectives.value = true;
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/noita/objectives");
|
await noitaStore.clearCache();
|
||||||
if (!response.ok) throw new Error("Failed to fetch objectives");
|
alert("Cache cleared successfully!");
|
||||||
|
|
||||||
objectives.value = await response.json();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching objectives:", error);
|
console.error("Error clearing cache:", error);
|
||||||
} finally {
|
alert("Error clearing cache. Please try again.");
|
||||||
isLoadingObjectives.value = false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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(() => {
|
onMounted(() => {
|
||||||
loadUserData();
|
noitaStore.loadUserData();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -179,7 +167,7 @@ onMounted(() => {
|
|||||||
<div class="min-h-screen bg-base-200">
|
<div class="min-h-screen bg-base-200">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="navbar bg-base-100 shadow-lg">
|
<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">
|
<button @click="goHome" class="btn btn-primary btn-sm">
|
||||||
<i class="mdi mdi-arrow-left"></i>
|
<i class="mdi mdi-arrow-left"></i>
|
||||||
Back
|
Back
|
||||||
@@ -192,19 +180,21 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container min-w-3/4 mx-auto px-4 py-8">
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<!-- Left Column: User Ranking -->
|
<!-- Left Column: User Ranking -->
|
||||||
<div class="lg:col-span-1">
|
<div class="lg:col-span-1">
|
||||||
<div class="card bg-base-100 shadow-lg sticky top-8">
|
<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">
|
<div class="bg-gradient-to-br from-purple-600 to-purple-400 p-8 text-white rounded-t-2xl">
|
||||||
<i class="mdi mdi-trophy text-4xl"></i>
|
<h2 class="text-3xl font-bold">
|
||||||
<h2 class="text-2xl font-bold mt-2">Your Ranking</h2>
|
<i class="mdi mdi-trophy text-3xl"></i>
|
||||||
|
Your Ranking
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body p-8">
|
||||||
<div class="text-center mb-6">
|
<div class="text-center mb-8">
|
||||||
<p class="text-sm text-base-content/70">Player</p>
|
<p class="text-base text-base-content/70">Player</p>
|
||||||
<p class="text-3xl font-bold">{{ userInfo.username }}</p>
|
<p class="text-4xl font-bold mt-2">{{ userInfo.username }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
@@ -213,36 +203,77 @@ onMounted(() => {
|
|||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-6">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-base-content/70 mb-1">Current Rank</p>
|
<p class="text-base text-base-content/70 mb-3">Current Rank</p>
|
||||||
<p v-if="userInfo.rank !== null" class="text-4xl font-bold text-primary">
|
<RankBadge :rank="userInfo.rank" />
|
||||||
#{{ userInfo.rank }}
|
|
||||||
</p>
|
|
||||||
<p v-else class="text-2xl text-base-content/50">No rank yet</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-base-content/70 mb-1">Total Score</p>
|
<p class="text-base text-base-content/70 mb-2">Total Score</p>
|
||||||
<p class="text-2xl font-bold">{{ userInfo.score.toLocaleString() }}</p>
|
<p class="text-3xl font-bold">{{ userInfo.score.toLocaleString() }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-base-content/70 mb-1">Objectives Completed</p>
|
<p class="text-base text-base-content/70 mb-2">Objectives Completed</p>
|
||||||
<p class="text-2xl font-bold">{{ userInfo.runsSubmitted }}</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button @click="isLeaderboardModalOpen = true" class="btn btn-outline btn-sm w-full mt-6">
|
<!-- Leaderboard Table -->
|
||||||
<i class="mdi mdi-trophy mr-1"></i>
|
<div class="mt-6">
|
||||||
View Full Leaderboard
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Column: Upload -->
|
<!-- 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 bg-base-100 shadow-lg">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl mb-6">
|
<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'
|
isDragover ? 'border-primary bg-primary/10' : 'border-base-300 hover:border-primary'
|
||||||
]">
|
]">
|
||||||
<input type="file" multiple @change="handleFileUpload" class="hidden" id="file-upload"
|
<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">
|
<label for="file-upload" class="cursor-pointer flex flex-col items-center gap-3">
|
||||||
<i
|
<i
|
||||||
:class="['mdi text-4xl', isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50']"></i>
|
:class="['mdi text-4xl', isDragover ? 'mdi-cloud-check text-primary' : 'mdi-file-upload text-base-content/50']"></i>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold">Click to upload or drag and drop</p>
|
<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>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,111 +340,97 @@ onMounted(() => {
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-2xl mb-6">
|
<h2 class="card-title text-2xl mb-6">
|
||||||
<i class="mdi mdi-view-list text-purple-500 mr-2"></i>
|
<i class="mdi mdi-view-list text-purple-500 mr-2"></i>
|
||||||
Your Objectives
|
Objectives
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div v-if="isLoadingObjectives" class="flex justify-center py-8">
|
<div v-if="objectives.length === 0" class="text-center py-8">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-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-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>
|
<p class="text-sm text-base-content/50">Submit your runs to unlock objectives!</p>
|
||||||
</div>
|
</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">
|
<table class="table table-zebra w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Objective ID</th>
|
<th v-for="header in table.getHeaderGroups()[0]?.headers" :key="header.id" :class="[
|
||||||
<th class="text-right">Count</th>
|
'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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="obj in objectives" :key="obj.objectiv_id">
|
<tr v-for="row in filteredObjectives" :key="row.id">
|
||||||
<td class="font-medium">{{ obj.objectiv_id }}</td>
|
<td v-for="cell in row.getVisibleCells()" :key="cell.id" :class="[
|
||||||
<td class="text-right">
|
cell.column.id === 'objectiv_id'
|
||||||
<span class="badge badge-primary badge-lg">{{ obj.count }}</span>
|
? '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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import PuzzleCard from "@/components/PuzzleCard.vue";
|
|||||||
import SubmissionForm from "@/components/SubmissionForm.vue";
|
import SubmissionForm from "@/components/SubmissionForm.vue";
|
||||||
import AdminPanel from "@/components/AdminPanel.vue";
|
import AdminPanel from "@/components/AdminPanel.vue";
|
||||||
import Results from "@/components/Results.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 { usePuzzlesStore } from "@/stores/puzzles";
|
||||||
import { useSubmissionsStore } from "@/stores/submissions";
|
import { useSubmissionsStore } from "@/stores/submissions";
|
||||||
import type { PuzzleResponse, UserInfo } from "@/types";
|
import type { PuzzleResponse, UserInfo, SteamCollection } from "@/types";
|
||||||
import { useCountdown } from "@vueuse/core";
|
import { useCountdown } from "@vueuse/core";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
@@ -26,6 +30,7 @@ const { openSubmissionModal, loadSubmissions, closeSubmissionModal } =
|
|||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const userInfo = ref<UserInfo | null>(null);
|
const userInfo = ref<UserInfo | null>(null);
|
||||||
|
const collection = ref<SteamCollection | null>(null);
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
const error = ref<string>("");
|
const error = ref<string>("");
|
||||||
|
|
||||||
@@ -34,6 +39,10 @@ const isSuperuser = computed(() => {
|
|||||||
return userInfo.value?.is_superuser || false;
|
return userInfo.value?.is_superuser || false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isTournamentClosed = computed(() => {
|
||||||
|
return !!(collection.value && !collection.value.accepting_submissions);
|
||||||
|
});
|
||||||
|
|
||||||
// Computed property to get responses grouped by puzzle
|
// Computed property to get responses grouped by puzzle
|
||||||
const responsesByPuzzle = computed(() => {
|
const responsesByPuzzle = computed(() => {
|
||||||
const grouped: Record<number, PuzzleResponse[]> = {};
|
const grouped: Record<number, PuzzleResponse[]> = {};
|
||||||
@@ -58,14 +67,24 @@ async function initialize() {
|
|||||||
|
|
||||||
// Load user info
|
// Load user info
|
||||||
console.log("Loading user info...");
|
console.log("Loading user info...");
|
||||||
const userResponse = await apiService.getUserInfo();
|
const userResponse = await polylanSubmitterApiGetUserInfo();
|
||||||
if (userResponse.data) {
|
if (userResponse.data) {
|
||||||
userInfo.value = userResponse.data;
|
userInfo.value = userResponse.data as UserInfo;
|
||||||
console.log("User info loaded:", userResponse.data);
|
console.log("User info loaded:", userResponse.data);
|
||||||
} else if (userResponse.error) {
|
} else if (userResponse.error) {
|
||||||
console.warn("User info error:", 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
|
// Load puzzles from API using store
|
||||||
console.log("Loading puzzles...");
|
console.log("Loading puzzles...");
|
||||||
await puzzlesStore.loadPuzzles();
|
await puzzlesStore.loadPuzzles();
|
||||||
@@ -118,7 +137,7 @@ const goHome = () => {
|
|||||||
<div class="min-h-screen bg-base-200">
|
<div class="min-h-screen bg-base-200">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="navbar bg-base-100 shadow-lg">
|
<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">
|
<button @click="goHome" class="btn btn-primary btn-sm">
|
||||||
<i class="mdi mdi-arrow-left"></i>
|
<i class="mdi mdi-arrow-left"></i>
|
||||||
Back
|
Back
|
||||||
@@ -126,17 +145,10 @@ const goHome = () => {
|
|||||||
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
|
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div
|
<div v-if="userInfo?.is_authenticated" class="flex items-center gap-2">
|
||||||
v-if="userInfo?.is_authenticated"
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<span class="font-medium">{{ userInfo.username }}</span>
|
<span class="font-medium">{{ userInfo.username }}</span>
|
||||||
<span
|
<span v-if="userInfo.is_superuser" class="badge badge-warning badge-xs ml-1">Admin</span>
|
||||||
v-if="userInfo.is_superuser"
|
|
||||||
class="badge badge-warning badge-xs ml-1"
|
|
||||||
>Admin</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-sm text-base-content/70">Not logged in</div>
|
<div v-else class="text-sm text-base-content/70">Not logged in</div>
|
||||||
@@ -147,7 +159,7 @@ const goHome = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- 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 -->
|
<!-- Loading State -->
|
||||||
<div v-if="userInfo?.is_superuser" class="flex justify-center">
|
<div v-if="userInfo?.is_superuser" class="flex justify-center">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
@@ -158,10 +170,7 @@ const goHome = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div v-if="isLoading" class="flex justify-center items-center min-h-[400px]">
|
||||||
v-if="isLoading"
|
|
||||||
class="flex justify-center items-center min-h-[400px]"
|
|
||||||
>
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
|
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
|
||||||
@@ -183,6 +192,16 @@ const goHome = () => {
|
|||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div v-else class="space-y-8">
|
<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 -->
|
<!-- Collection Info -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="card bg-base-100 shadow-lg">
|
<div class="card bg-base-100 shadow-lg">
|
||||||
@@ -192,11 +211,18 @@ const goHome = () => {
|
|||||||
{{ props.collectionDescription }}
|
{{ props.collectionDescription }}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-wrap gap-4 mt-4">
|
<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>
|
<i class="mdi mdi-plus mr-2"></i>
|
||||||
Submit Solution
|
Submit Solution
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,12 +236,8 @@ const goHome = () => {
|
|||||||
|
|
||||||
<!-- Puzzles Grid -->
|
<!-- Puzzles Grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<PuzzleCard
|
<PuzzleCard v-for="puzzle in puzzlesStore.puzzles" :key="puzzle.id" :puzzle="puzzle"
|
||||||
v-for="puzzle in puzzlesStore.puzzles"
|
:responses="responsesByPuzzle[puzzle.id] || []" />
|
||||||
:key="puzzle.id"
|
|
||||||
:puzzle="puzzle"
|
|
||||||
:responses="responsesByPuzzle[puzzle.id] || []"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
@@ -226,6 +248,7 @@ const goHome = () => {
|
|||||||
Check back later for new puzzle collections!
|
Check back later for new puzzle collections!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -234,18 +257,12 @@ const goHome = () => {
|
|||||||
<div class="modal-box max-w-6xl">
|
<div class="modal-box max-w-6xl">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h3 class="font-bold text-lg">Submit Solution</h3>
|
<h3 class="font-bold text-lg">Submit Solution</h3>
|
||||||
<button
|
<button @click="closeSubmissionModal" class="btn btn-sm btn-circle btn-ghost">
|
||||||
@click="closeSubmissionModal"
|
|
||||||
class="btn btn-sm btn-circle btn-ghost"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-close"></i>
|
<i class="mdi mdi-close"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SubmissionForm
|
<SubmissionForm :puzzles="puzzlesStore.puzzles" :find-puzzle-by-name="findPuzzleByName" />
|
||||||
:puzzles="puzzlesStore.puzzles"
|
|
||||||
:find-puzzle-by-name="findPuzzleByName"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
|
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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 aren’t 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 };
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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
Reference in New Issue
Block a user