Compare commits

..
1 Commits
Author SHA1 Message Date
Legrems 5509fdb8db feat(games): add steam pages with steam price/reduc 2026-10-16 00:59:43 +02:00
98 changed files with 505 additions and 7491 deletions
-2
View File
@@ -16,8 +16,6 @@
DJANGO_SECRET_KEY= DJANGO_SECRET_KEY=
DJANGO_DEBUG=false DJANGO_DEBUG=false
DJANGO_ALLOWED_HOSTS=submitter.polylan.ch DJANGO_ALLOWED_HOSTS=submitter.polylan.ch
DJANGO_CSRF_TRUSTED_ORIGINS=https://polylan-submitter.legrems.xyz
DJANGO_BEHIND_TLS_PROXY=true
POSTGRES_DB= POSTGRES_DB=
POSTGRES_USER= POSTGRES_USER=
-125
View File
@@ -1,125 +0,0 @@
#!/usr/bin/env bash
# Zero-downtime prod deploy: pull, sync deps, build front, migrate, then
# gracefully reload gunicorn (see polylan-submitter.service, installed and
# restarted instead when it changed) and restart the RQ worker (see
# polylan-submitter-worker.conf).
#
# Run from anywhere as the user owning the checkout, needs sudo for
# systemctl/supervisorctl (no-op when already root):
# ./deploy/systemd/deploy.sh
#
# Migrations run while the old code still serves requests, so keep them
# backward compatible (nullable/default columns, drop/rename in a later deploy).
set -euo pipefail
SERVICE="${SERVICE:-polylan-submitter}"
WORKER="${WORKER:-polylan-submitter-worker}"
SUPERVISOR_CONF_DIR="${SUPERVISOR_CONF_DIR:-/etc/supervisor/conf.d}"
WEB_URL="${WEB_URL:-https://polylan-submitter.legrems.xyz/}"
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
DEPLOY_DIR="$REPO_DIR/deploy/systemd"
APP_DIR="$REPO_DIR/polylan_submitter"
PYTHON="$REPO_DIR/.venv/bin/python"
# Same as the unit's Environment=, so migrate/collectstatic see the prod settings.
# Relative to $APP_DIR, where manage.py runs.
export SETTINGS_ENV_FILE_LOCATION="${SETTINGS_ENV_FILE_LOCATION:-polylan_submitter/.env}"
# gunicorn's master doesn't reload itself on HUP, so a server upgrade needs a
# full restart instead.
server_versions() {
"$PYTHON" - <<'EOF' 2>/dev/null || true
import importlib.metadata as m
for pkg in ("gunicorn", "uvicorn", "uvicorn-worker"):
try:
print(pkg, m.version(pkg))
except m.PackageNotFoundError:
print(pkg, "missing")
EOF
}
# wait_for <description> <command...>: retry for ~30s, fail the deploy if it
# never succeeds.
wait_for() {
local what="$1"
shift
for _ in $(seq 1 30); do
if "$@" >/dev/null 2>&1; then
echo " $what: ok"
return 0
fi
sleep 1
done
echo " $what: FAILED" >&2
return 1
}
web_responds() {
# 000 = nothing listening, 5xx = Caddy up but gunicorn down (502) or broken.
local code
code="$(curl -s -o /dev/null -w '%{http_code}' "$WEB_URL")"
[ "$code" != "000" ] && [ "$code" -lt 500 ]
}
worker_running() {
sudo supervisorctl status "$WORKER" | grep -q RUNNING
}
cd "$REPO_DIR"
echo "==> git pull"
git pull --ff-only
before="$(server_versions)"
echo "==> uv sync"
uv sync --frozen --no-dev
after="$(server_versions)"
echo "==> frontend build"
cd "$APP_DIR"
pnpm install --frozen-lockfile
pnpm build
echo "==> migrate"
"$PYTHON" manage.py migrate --noinput
echo "==> collectstatic"
"$PYTHON" manage.py collectstatic --noinput
echo "==> $SERVICE (systemd)"
unit_changed=false
if ! cmp -s "$DEPLOY_DIR/$SERVICE.service" "/etc/systemd/system/$SERVICE.service"; then
sudo install -m 644 "$DEPLOY_DIR/$SERVICE.service" "/etc/systemd/system/$SERVICE.service"
sudo systemctl daemon-reload
sudo systemctl enable "$SERVICE"
unit_changed=true
fi
# HUP re-forks workers with the master's original args, so a new ExecStart
# (unit change) or a new gunicorn/uvicorn needs a full restart.
if [ "$unit_changed" = true ]; then
echo "==> unit file changed, full restart of $SERVICE"
sudo systemctl restart "$SERVICE"
elif [ "$before" != "$after" ]; then
echo "==> server packages changed, full restart of $SERVICE"
sudo systemctl restart "$SERVICE"
else
echo "==> graceful reload of $SERVICE"
sudo systemctl reload "$SERVICE"
fi
wait_for "$SERVICE active" systemctl is-active --quiet "$SERVICE"
wait_for "$SERVICE responds on $WEB_URL" web_responds
echo "==> $WORKER (supervisor)"
sudo install -m 644 "$DEPLOY_DIR/$WORKER.conf" "$SUPERVISOR_CONF_DIR/$WORKER.conf"
# reread+update picks up config changes (and starts it on first install),
# restart loads the new code - TERM is a warm shutdown, the running job ends first.
sudo supervisorctl reread
sudo supervisorctl update "$WORKER"
sudo supervisorctl restart "$WORKER"
wait_for "$WORKER running" worker_running
echo "==> done"
@@ -1,24 +0,0 @@
; Prod RQ worker (submission OCR, AAndT sync, Steam refresh...), run by the
; host's supervisord next to the polylan-submitter systemd unit (web).
;
; Installed to /etc/supervisor/conf.d/ by deploy/systemd/deploy.sh, which also
; restarts it on every deploy so it picks up the new code.
;
; Logs: supervisorctl tail -f polylan-submitter-worker
[program:polylan-submitter-worker]
command=/home/caddy/polylan-submitter/.venv/bin/python manage.py rqworker default --job-class django_tasks_rq.Job --with-scheduler
directory=/home/caddy/polylan-submitter/polylan_submitter
; Relative to directory, same as the systemd unit.
environment=SETTINGS_ENV_FILE_LOCATION="polylan_submitter/.env",PYTHONUNBUFFERED="1"
user=root
autostart=true
autorestart=true
startsecs=5
; rqworker does a warm shutdown on TERM: it finishes the current job (OCR can
; take a while) before exiting, only SIGKILLed after stopwaitsecs.
stopsignal=TERM
stopwaitsecs=120
stopasgroup=true
killasgroup=true
redirect_stderr=true
-39
View File
@@ -1,39 +0,0 @@
# Prod web process: gunicorn master + uvicorn ASGI workers, behind Caddy.
#
# `systemctl reload polylan-submitter` sends HUP to the gunicorn master: it
# keeps the :7777 socket open, boots new workers on the new code, then
# gracefully stops the old ones - no downtime. See deploy/systemd/deploy.sh.
#
# Installed to /etc/systemd/system/ by deploy.sh: when this file changed it
# does daemon-reload + a full restart (HUP keeps the old ExecStart args).
[Unit]
Description=PolyLAN submitter (gunicorn + uvicorn workers)
After=network.target
[Service]
Type=simple
Restart=always
RestartSec=1
User=root
WorkingDirectory=/home/caddy/polylan-submitter/polylan_submitter
# Relative to WorkingDirectory, loaded by settings.py via python-dotenv.
Environment="SETTINGS_ENV_FILE_LOCATION=polylan_submitter/.env"
# No --preload: with it, HUP re-forks from the already imported (old) code.
# --forwarded-allow-ips replaces daphne's --proxy-headers: trust
# X-Forwarded-* only from Caddy (local here).
ExecStart=/home/caddy/polylan-submitter/.venv/bin/gunicorn polylan_submitter.asgi:application \
--worker-class uvicorn_worker.UvicornWorker \
--workers 2 \
--bind 0.0.0.0:7777 \
--graceful-timeout 20 \
--forwarded-allow-ips 127.0.0.1
ExecReload=/bin/kill -s HUP $MAINPID
# SIGTERM to the master only, it then drains its workers (graceful-timeout).
KillMode=mixed
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target
+5 -5
View File
@@ -54,8 +54,8 @@ def current_state(request: HttpRequest, event_path: str):
def my_stats(request: HttpRequest, event_path: str): def my_stats(request: HttpRequest, event_path: str):
ranked = list( ranked = list(
ranked_players(request.event) ranked_players(request.event)
.values("id", "clock_points", "rank") .values("id", "points", "rank")
.order_by("-clock_points", "id") .order_by("-points", "id")
) )
total_players = len(ranked) total_players = len(ranked)
my_index = next( my_index = next(
@@ -71,9 +71,9 @@ def my_stats(request: HttpRequest, event_path: str):
) )
return { return {
"points": mine["clock_points"] if mine else 0.0, "points": mine["points"] if mine else 0.0,
"rank": mine["rank"] if mine else total_players, "rank": mine["rank"] if mine else total_players,
"total_players": total_players, "total_players": total_players,
"points_above": above["clock_points"] if above else None, "points_above": above["points"] if above else None,
"points_below": below["clock_points"] if below else None, "points_below": below["points"] if below else None,
} }
+3 -4
View File
@@ -22,8 +22,7 @@ def _press_points_expression():
def ranked_players(event: Event): def ranked_players(event: Event):
"""All users annotated with their live total `clock_points` (not `points`, """All users annotated with their live total points for this event and
that one is the market balance field on CustomUser) for this event and
DB-computed RANK(). Points only ever come from presses made in `event` - DB-computed RANK(). Points only ever come from presses made in `event` -
a player's history in another event never leaks into this leaderboard.""" a player's history in another event never leaks into this leaderboard."""
from accounts.models import CustomUser from accounts.models import CustomUser
@@ -33,6 +32,6 @@ def ranked_players(event: Event):
Value(0.0), Value(0.0),
output_field=FloatField(), output_field=FloatField(),
) )
return CustomUser.objects.annotate(clock_points=points).annotate( return CustomUser.objects.annotate(points=points).annotate(
rank=Window(expression=Rank(), order_by=F("clock_points").desc()) rank=Window(expression=Rank(), order_by=F("points").desc())
) )
+6 -16
View File
@@ -12,7 +12,6 @@ acts.
""" """
from datetime import timedelta from datetime import timedelta
from uuid import uuid4
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
@@ -31,20 +30,13 @@ HEARTBEAT_INTERVAL_SECONDS = 1
# One chain ticks every event with the game enabled - this lock keeps a # One chain ticks every event with the game enabled - this lock keeps a
# second chain from starting underneath an already-running one (e.g. two # second chain from starting underneath an already-running one (e.g. two
# EventGame saves close together). Refreshed every tick, wide enough that a # EventGame saves close together). Refreshed every tick, wide enough that a
# single slow tick doesn't let a second chain slip in. The lock holds the # single slow tick doesn't let a second chain slip in.
# chain id: if it expires anyway (worker down/restarted for a few seconds) and
# a new chain starts, the old one stops at its next tick (instead of both
# ticking forever).
HEARTBEAT_LOCK_KEY = "clock:heartbeat:lock" HEARTBEAT_LOCK_KEY = "clock:heartbeat:lock"
HEARTBEAT_LOCK_TIMEOUT_SECONDS = HEARTBEAT_INTERVAL_SECONDS * 5 HEARTBEAT_LOCK_TIMEOUT_SECONDS = HEARTBEAT_INTERVAL_SECONDS * 5
@task @task
def clock_heartbeat_task(chain_id: str): def clock_heartbeat_task():
if cache.get(HEARTBEAT_LOCK_KEY) != chain_id:
# Another chain owns the lock (or it expired), stop this one
return
event_games = list( event_games = list(
EventGame.objects.filter(game__path="clock", enabled=True).select_related( EventGame.objects.filter(game__path="clock", enabled=True).select_related(
"event" "event"
@@ -54,7 +46,6 @@ def clock_heartbeat_task(chain_id: str):
if not event_games: if not event_games:
# Nothing left to tick - let the chain die. start_clock_heartbeat() # Nothing left to tick - let the chain die. start_clock_heartbeat()
# starts a fresh one next time an event enables the game. # starts a fresh one next time an event enables the game.
if cache.get(HEARTBEAT_LOCK_KEY) == chain_id:
cache.delete(HEARTBEAT_LOCK_KEY) cache.delete(HEARTBEAT_LOCK_KEY)
return return
@@ -76,14 +67,13 @@ def clock_heartbeat_task(chain_id: str):
}, },
) )
cache.set(HEARTBEAT_LOCK_KEY, chain_id, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS) cache.set(HEARTBEAT_LOCK_KEY, 1, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS)
clock_heartbeat_task.using( clock_heartbeat_task.using(
run_after=timezone.now() + timedelta(seconds=HEARTBEAT_INTERVAL_SECONDS) run_after=timezone.now() + timedelta(seconds=HEARTBEAT_INTERVAL_SECONDS)
).enqueue(chain_id) ).enqueue()
def start_clock_heartbeat(): def start_clock_heartbeat():
"""Enqueues the heartbeat chain, unless one is already running.""" """Enqueues the heartbeat chain, unless one is already running."""
chain_id = uuid4().hex if cache.add(HEARTBEAT_LOCK_KEY, 1, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS):
if cache.add(HEARTBEAT_LOCK_KEY, chain_id, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS): clock_heartbeat_task.enqueue()
clock_heartbeat_task.enqueue(chain_id)
-57
View File
@@ -1,57 +0,0 @@
from unittest import mock
from django.core.cache import cache
from django.test import TestCase, override_settings
from events.models import Event
from games.models import EventGame, Game
from .tasks import HEARTBEAT_LOCK_KEY, clock_heartbeat_task, start_clock_heartbeat
LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
@override_settings(CACHES=LOCMEM_CACHE)
@mock.patch("clock.tasks.get_channel_layer")
class ClockHeartbeatTests(TestCase):
def setUp(self):
cache.clear()
event = Event.objects.create(name="Event", path="event")
clock = Game.objects.create(name="Clock", path="clock", custom_path=True)
self.event_game = EventGame.objects.create(event=event, game=clock)
cache.clear() # post_save signal can start a chain
@mock.patch("clock.tasks.clock_heartbeat_task")
def test_only_one_chain_is_started(self, task, _):
start_clock_heartbeat()
start_clock_heartbeat()
task.enqueue.assert_called_once_with(cache.get(HEARTBEAT_LOCK_KEY))
def test_owning_chain_reschedules_itself(self, channel_layer):
channel_layer.return_value.group_send = mock.AsyncMock()
cache.set(HEARTBEAT_LOCK_KEY, "chain")
with mock.patch("clock.tasks.clock_heartbeat_task") as patched:
clock_heartbeat_task.call("chain")
patched.using.return_value.enqueue.assert_called_once_with("chain")
channel_layer.return_value.group_send.assert_awaited_once()
self.assertEqual(cache.get(HEARTBEAT_LOCK_KEY), "chain")
def test_superseded_chain_stops(self, channel_layer):
cache.set(HEARTBEAT_LOCK_KEY, "new-chain")
with mock.patch("clock.tasks.clock_heartbeat_task") as patched:
clock_heartbeat_task.call("old-chain")
patched.using.assert_not_called()
channel_layer.assert_not_called()
self.assertEqual(cache.get(HEARTBEAT_LOCK_KEY), "new-chain")
def test_chain_dies_and_releases_lock_when_game_disabled(self, _):
EventGame.objects.filter(pk=self.event_game.pk).update(enabled=False)
cache.set(HEARTBEAT_LOCK_KEY, "chain")
with mock.patch("clock.tasks.clock_heartbeat_task") as patched:
clock_heartbeat_task.call("chain")
patched.using.assert_not_called()
self.assertIsNone(cache.get(HEARTBEAT_LOCK_KEY))
+8 -48
View File
@@ -5,9 +5,9 @@ from django_object_actions import (
takes_instance_or_queryset, takes_instance_or_queryset,
) )
from submissions.tasks import schedule_scope_sync, stop_scope_sync from submissions.tasks import sync_event_game_scopes_task
from .models import EventGame, Game, PlanningSlot from .models import EventGame, Game
from .tasks import refresh_steam_games_task from .tasks import refresh_steam_games_task
@@ -40,7 +40,7 @@ class GameAdmin(admin.ModelAdmin):
game_ids = list( game_ids = list(
queryset.filter(steam_app_id__isnull=False).values_list("id", flat=True) queryset.filter(steam_app_id__isnull=False).values_list("id", flat=True)
) )
refresh_steam_games_task.enqueue(game_ids=game_ids) refresh_steam_games_task.enqueue(game_ids=game_ids, reschedule=False)
self.message_user( self.message_user(
request, f"Queued Steam store refresh for {len(game_ids)} game(s)." request, f"Queued Steam store refresh for {len(game_ids)} game(s)."
) )
@@ -51,35 +51,13 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
list_display = ["game", "event", "enabled", "has_apibot_token"] list_display = ["game", "event", "enabled", "has_apibot_token"]
list_filter = ["event", "enabled"] list_filter = ["event", "enabled"]
search_fields = ["game__name", "event__name"] search_fields = ["game__name", "event__name"]
actions = ["enable", "disable", "sync_scopes", "stop_scope_sync"] actions = ["sync_scopes"]
change_actions = ["sync_scopes", "stop_scope_sync"] change_actions = ["sync_scopes"]
@admin.display(description="AAndT token", boolean=True) @admin.display(description="AAndT token", boolean=True)
def has_apibot_token(self, obj): def has_apibot_token(self, obj):
return bool(obj.apibot_token) return bool(obj.apibot_token)
def _set_enabled(self, request, queryset, enabled: bool):
# save() per row (not update()) so post_save fires, e.g. the clock heartbeat
changed = 0
for event_game in queryset.select_related("game").filter(enabled=not enabled):
event_game.enabled = enabled
event_game.save(update_fields=["enabled"])
changed += 1
self.message_user(
request,
f"{'Enabled' if enabled else 'Disabled'} {changed} EventGame(s)"
f" ({queryset.count() - changed} already {'enabled' if enabled else 'disabled'}).",
)
@admin.action(description="Enable selected EventGames")
def enable(self, request, queryset):
self._set_enabled(request, queryset, enabled=True)
@admin.action(description="Disable selected EventGames")
def disable(self, request, queryset):
self._set_enabled(request, queryset, enabled=False)
@action( @action(
label="Sync scopes", label="Sync scopes",
description="Sync scopes from AAndT for selected EventGames", description="Sync scopes from AAndT for selected EventGames",
@@ -89,12 +67,11 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
tokened = queryset.exclude(apibot_token__isnull=True).exclude(apibot_token="") tokened = queryset.exclude(apibot_token__isnull=True).exclude(apibot_token="")
skipped = queryset.count() - tokened.count() skipped = queryset.count() - tokened.count()
started = sum(schedule_scope_sync(event_game.id) for event_game in tokened) for event_game in tokened:
sync_event_game_scopes_task.enqueue(event_game.id)
self.message_user( self.message_user(
request, request, f"Queued scope sync for {tokened.count()} EventGame(s)."
f"Started scope sync for {started} EventGame(s)"
f" ({tokened.count() - started} already running).",
) )
if skipped: if skipped:
self.message_user( self.message_user(
@@ -102,20 +79,3 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
f"Skipped {skipped} EventGame(s) with no AAndT token configured.", f"Skipped {skipped} EventGame(s) with no AAndT token configured.",
level=messages.WARNING, level=messages.WARNING,
) )
@action(
label="Stop scope sync",
description="Stop the scope sync loop of selected EventGames",
)
@takes_instance_or_queryset
def stop_scope_sync(self, request, queryset):
stopped = sum(stop_scope_sync(event_game.id) for event_game in queryset)
self.message_user(request, f"Stopped scope sync for {stopped} EventGame(s).")
@admin.register(PlanningSlot)
class PlanningSlotAdmin(admin.ModelAdmin):
list_display = ["title", "event", "begin", "end", "column", "is_multi_anims"]
list_filter = ["event", "is_multi_anims"]
search_fields = ["title"]
+1 -8
View File
@@ -3,8 +3,7 @@ from typing import List
from ninja import Router from ninja import Router
from .models import Game from .models import Game
from .schemas import GameOut, PlanningSlotOut, SteamGameOut from .schemas import GameOut, SteamGameOut
from .services.planning import planning_with_games
from .tasks import schedule_steam_refresh from .tasks import schedule_steam_refresh
router = Router() router = Router()
@@ -29,9 +28,3 @@ def list_event_steam_games(request, event_path: str):
return Game.objects.enabled_for_event(request.event).filter( return Game.objects.enabled_for_event(request.event).filter(
steam_app_id__isnull=False steam_app_id__isnull=False
) )
@event_router.get("/events/{event_path}/planning", response=List[PlanningSlotOut])
def list_event_planning(request, event_path: str):
"""The event planning, with the covers of the games we know."""
return planning_with_games(request.event.planning_slots.all())
@@ -1,64 +0,0 @@
import json
import sys
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from events.models import Event
from games.services.planning import (
create_missing_steam_games,
import_planning,
slot_game_names,
)
from games.tasks import refresh_steam_games_task
class Command(BaseCommand):
help = (
"Import an event planning from the legacy PolyLAN planning JSON "
"(slots missing from the file are deleted)"
)
def add_arguments(self, parser):
parser.add_argument("event_path", help="Path of the event, e.g. polylan-43")
parser.add_argument("json_file", help="Planning JSON file, '-' for stdin")
parser.add_argument(
"--no-steam",
action="store_true",
help="Don't look up unknown games on the Steam store",
)
def handle(self, *args, **options):
try:
event = Event.objects.get(path=options["event_path"])
except Event.DoesNotExist:
raise CommandError(f"No event with path {options['event_path']!r}")
if options["json_file"] == "-":
data = json.load(sys.stdin)
else:
with open(options["json_file"], encoding="utf-8") as f:
data = json.load(f)
entries = data["list"] if isinstance(data, dict) else data
with transaction.atomic():
created, updated, deleted = import_planning(event, entries)
self.stdout.write(
self.style.SUCCESS(
f"Planning of {event}: {created} created, {updated} updated, {deleted} deleted"
)
)
if options["no_steam"]:
return
games = create_missing_steam_games(event, slot_game_names(entries))
for game in games:
self.stdout.write(f"+ {game.name} (Steam app {game.steam_app_id})")
if games:
# Prices, blurb, ... for the games page
refresh_steam_games_task.enqueue(game_ids=[game.id for game in games])
self.stdout.write(
self.style.SUCCESS(f"{len(games)} game(s) found on Steam and added to {event}")
)
@@ -1,18 +0,0 @@
# Generated by Django 6.0.5 on 2026-10-16 00:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0010_game_steam_store_info"),
]
operations = [
migrations.AddField(
model_name="game",
name="library_image",
field=models.URLField(blank=True, default="", max_length=500),
),
]
@@ -1,53 +0,0 @@
# Generated by Django 6.0.5 on 2026-10-16 03:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("events", "0003_event_is_available"),
("games", "0011_game_library_image"),
]
operations = [
migrations.CreateModel(
name="PlanningSlot",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("source_pk", models.PositiveIntegerField()),
("title", models.CharField(max_length=255)),
("begin", models.DateTimeField()),
("end", models.DateTimeField()),
("column", models.PositiveSmallIntegerField(default=0)),
("color", models.CharField(blank=True, default="", max_length=7)),
("is_multi_anims", models.BooleanField(default=False)),
("game_names", models.JSONField(blank=True, default=list)),
(
"event",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="planning_slots",
to="events.event",
),
),
],
options={
"ordering": ["begin", "column"],
"constraints": [
models.UniqueConstraint(
fields=("event", "source_pk"), name="unique_event_planning_slot"
)
],
},
),
]
-49
View File
@@ -15,8 +15,6 @@ class Game(models.Model):
custom_path = models.BooleanField(default=False) custom_path = models.BooleanField(default=False)
image = models.URLField(null=True, blank=True) image = models.URLField(null=True, blank=True)
description = models.TextField(blank=True, default="") description = models.TextField(blank=True, default="")
# Vertical (600x900) cover for games/showcase, empty = Steam's library capsule
library_image = models.URLField(max_length=500, blank=True, default="")
# Steam store info, refreshed in the background (see games.tasks). # Steam store info, refreshed in the background (see games.tasks).
# Prices are in cents, in steam_currency. # Prices are in cents, in steam_currency.
@@ -42,19 +40,6 @@ class Game(models.Model):
return f"{self.name} ({self.steam_app_id})" return f"{self.name} ({self.steam_app_id})"
return self.name return self.name
@property
def store_url(self) -> str:
if not self.steam_app_id:
return ""
return f"https://store.steampowered.com/app/{self.steam_app_id}"
@property
def library_image_url(self) -> str:
"""Vertical cover, like in the Steam library. Empty if there's none."""
if self.library_image or not self.steam_app_id:
return self.library_image
return f"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/{self.steam_app_id}/library_600x900.jpg"
class EventGame(models.Model): class EventGame(models.Model):
event = models.ForeignKey( event = models.ForeignKey(
@@ -89,37 +74,3 @@ class EventGame(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.game.name} @ {self.event.name} ({'enabled' if self.enabled else 'disabled'})" return f"{self.game.name} @ {self.event.name} ({'enabled' if self.enabled else 'disabled'})"
class PlanningSlot(models.Model):
"""One slot of the event planning, imported from the legacy PolyLAN planning
(see the import_planning command)."""
event = models.ForeignKey(
Event,
on_delete=models.CASCADE,
related_name="planning_slots",
)
# pk of the slot in the legacy planning, to update it on re-import
source_pk = models.PositiveIntegerField()
title = models.CharField(max_length=255)
begin = models.DateTimeField()
end = models.DateTimeField()
column = models.PositiveSmallIntegerField(default=0)
color = models.CharField(max_length=7, blank=True, default="")
# Multi-anims (e.g. "Fils rouges") run several games over the whole event
is_multi_anims = models.BooleanField(default=False)
# Names of the games run in this slot (the legacy "aandts")
game_names = models.JSONField(default=list, blank=True)
class Meta:
ordering = ["begin", "column"]
constraints = [
models.UniqueConstraint(
fields=["event", "source_pk"],
name="unique_event_planning_slot",
),
]
def __str__(self) -> str:
return f"{self.title} @ {self.event.name} ({self.begin:%a %H:%M})"
+1 -26
View File
@@ -25,7 +25,6 @@ class SteamGameOut(Schema):
steam_app_id: int steam_app_id: int
store_url: str store_url: str
header_image: str header_image: str
library_image: str
short_description: str short_description: str
is_free: bool is_free: bool
currency: str currency: str
@@ -36,7 +35,7 @@ class SteamGameOut(Schema):
@staticmethod @staticmethod
def resolve_store_url(obj) -> str: def resolve_store_url(obj) -> str:
return obj.store_url return f"https://store.steampowered.com/app/{obj.steam_app_id}"
@staticmethod @staticmethod
def resolve_header_image(obj) -> str: def resolve_header_image(obj) -> str:
@@ -45,10 +44,6 @@ class SteamGameOut(Schema):
or f"https://cdn.akamai.steamstatic.com/steam/apps/{obj.steam_app_id}/header.jpg" or f"https://cdn.akamai.steamstatic.com/steam/apps/{obj.steam_app_id}/header.jpg"
) )
@staticmethod
def resolve_library_image(obj) -> str:
return obj.library_image_url
@staticmethod @staticmethod
def resolve_short_description(obj) -> str: def resolve_short_description(obj) -> str:
return obj.steam_short_description return obj.steam_short_description
@@ -76,23 +71,3 @@ class SteamGameOut(Schema):
@staticmethod @staticmethod
def resolve_updated_at(obj) -> Optional[datetime]: def resolve_updated_at(obj) -> Optional[datetime]:
return obj.steam_info_updated_at return obj.steam_info_updated_at
class PlanningGameOut(Schema):
name: str
# Empty when the name didn't match any of our games
library_image: str = ""
store_url: str = ""
steam_app_id: Optional[int] = None
class PlanningSlotOut(Schema):
id: int
title: str
begin: datetime
end: datetime
column: int
color: str
is_multi_anims: bool
# Single slot: the slot's game. Multi-anims: each of their games.
games: list[PlanningGameOut]
@@ -1,172 +0,0 @@
import logging
import re
import unicodedata
import httpx
from django.utils.text import slugify
from ..models import EventGame, Game, PlanningSlot
from .steam_store import search_store
logger = logging.getLogger(__name__)
# "Cosmoteer: Starship Architect & Commander" is still "Cosmoteer"
SUBTITLE_SEPARATOR = re.compile(r"\s*(?::|\s-\s|\s–\s|\s—\s)")
def normalize_name(name: str) -> str:
"""'Towerfall: Ascension' and 'TowerFall Ascension' are the same game:
no accents, no case, letters and digits only."""
# Before NFKD, which turns ™ into "TM"
name = re.sub(r"[™®©]", "", name)
name = unicodedata.normalize("NFKD", name)
name = "".join(c for c in name if not unicodedata.combining(c))
return re.sub(r"[^0-9a-z]", "", name.casefold())
def _steam_match(name: str) -> dict | None:
"""The Steam store app named name (or name + a subtitle), if there's one.
Strict on purpose: "Jass" or "Crochet" must not end up as some random game."""
wanted = normalize_name(name)
if not wanted:
return None
# Steam's search chokes on punctuation ("Puyo Puyo — Tetris" finds nothing)
term = " ".join(re.sub(r"[^\w\s]", " ", name).split())
for item in search_store(term):
steam_name = item.get("name") or ""
if wanted in (
normalize_name(steam_name),
normalize_name(SUBTITLE_SEPARATOR.split(steam_name, maxsplit=1)[0]),
):
return item
return None
def _unique_path(name: str) -> str:
base = slugify(name)[:90] or "game"
path, n = base, 2
while Game.objects.filter(path=path).exists():
path, n = f"{base}-{n}", n + 1
return path
def create_missing_steam_games(event, names: list[str]) -> list[Game]:
"""For each name without a Game, look it up on the Steam store and create
the Game + its EventGame when found. Returns the created games."""
known = {normalize_name(game.name) for game in Game.objects.all()}
created = []
for name in dict.fromkeys(names): # unique, keeps order
if normalize_name(name) in known:
continue
try:
item = _steam_match(name)
except (httpx.HTTPError, ValueError) as exc:
logger.warning("Steam store search failed for %r: %s", name, exc)
continue
if item is None:
continue
if Game.objects.filter(steam_app_id=item["id"]).exists():
# Already there under another name, the planning won't match it
logger.warning(
"%r is Steam app %s, already a Game under another name",
name,
item["id"],
)
continue
# Named like in the planning, so the slot matches it
game = Game.objects.create(
name=name, steam_app_id=item["id"], path=_unique_path(name)
)
EventGame.objects.get_or_create(event=event, game=game)
known.add(normalize_name(name))
created.append(game)
return created
def slot_game_names(entries: list[dict]) -> list[str]:
"""Names to look for in a planning: titles of single slots, game names of
multi-anims (their title, like "Fils rouges", isn't a game)."""
names = []
for entry in entries:
if not entry.get("is_multi_anims"):
names.append(entry["title"])
names += [a["name"] for a in entry.get("aandts") or []]
return names
def _game_out(name: str, game: Game | None) -> dict:
if game is None:
return {"name": name}
return {
"name": game.name,
"library_image": game.library_image_url,
"store_url": game.store_url,
"steam_app_id": game.steam_app_id,
}
def planning_with_games(slots) -> list[dict]:
"""Slots as PlanningSlotOut dicts, with their games matched by name to ours."""
games_by_name = {normalize_name(game.name): game for game in Game.objects.all()}
def match(name: str) -> Game | None:
return games_by_name.get(normalize_name(name))
out = []
for slot in slots:
if slot.is_multi_anims:
games = [_game_out(name, match(name)) for name in slot.game_names]
else:
# The title is the usual match, the legacy game names are a fallback
# ("Golf it!" slot running the "Golf It!" game)
game = next(
filter(None, map(match, [slot.title, *slot.game_names])), None
)
games = [_game_out(slot.title, game)]
out.append(
{
"id": slot.id,
"title": slot.title,
"begin": slot.begin,
"end": slot.end,
"column": slot.column,
"color": slot.color,
"is_multi_anims": slot.is_multi_anims,
"games": games,
}
)
return out
def import_planning(event, entries: list[dict]) -> tuple[int, int, int]:
"""Sync the event planning with the legacy planning entries (its "list").
Returns (created, updated, deleted)."""
created = updated = 0
for entry in entries:
_, was_created = PlanningSlot.objects.update_or_create(
event=event,
source_pk=entry["pk"],
defaults={
"title": entry["title"],
"begin": entry["begin"],
"end": entry["end"],
"column": entry.get("colonneS") or 0,
"color": entry.get("color_code") or "",
"is_multi_anims": bool(entry.get("is_multi_anims")),
"game_names": [a["name"] for a in entry.get("aandts") or []],
},
)
if was_created:
created += 1
else:
updated += 1
deleted, _ = (
PlanningSlot.objects.filter(event=event)
.exclude(source_pk__in=[entry["pk"] for entry in entries])
.delete()
)
return created, updated, deleted
@@ -11,7 +11,6 @@ logger = logging.getLogger(__name__)
# Public store endpoint, no API key needed. Rate limited to ~200 calls / 5 min, # Public store endpoint, no API key needed. Rate limited to ~200 calls / 5 min,
# which is plenty for a handful of games every few hours. # which is plenty for a handful of games every few hours.
APP_DETAILS_URL = "https://store.steampowered.com/api/appdetails" APP_DETAILS_URL = "https://store.steampowered.com/api/appdetails"
STORE_SEARCH_URL = "https://store.steampowered.com/api/storesearch/"
class SteamStoreError(Exception): class SteamStoreError(Exception):
@@ -38,17 +37,6 @@ def fetch_app_details(steam_app_id: int) -> dict:
return entry["data"] return entry["data"]
def search_store(term: str) -> list[dict]:
"""Store search results for term, as [{"id": app id, "name": ...}, ...]."""
response = httpx.get(
STORE_SEARCH_URL,
params={"term": term, "cc": settings.STEAM_STORE_COUNTRY, "l": "english"},
timeout=10,
)
response.raise_for_status()
return (response.json() or {}).get("items") or []
def refresh_steam_game(game: Game) -> None: def refresh_steam_game(game: Game) -> None:
"""Pull the current store info for a Steam game into its steam_* fields.""" """Pull the current store info for a Steam game into its steam_* fields."""
data = fetch_app_details(game.steam_app_id) data = fetch_app_details(game.steam_app_id)
+9 -18
View File
@@ -1,5 +1,4 @@
from datetime import timedelta from datetime import timedelta
from uuid import uuid4
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
@@ -11,9 +10,7 @@ from .services.steam_store import refresh_steam_games
# Set while a periodic refresh is queued, so there's only ever one loop running, # Set while a periodic refresh is queued, so there's only ever one loop running,
# whatever the number of workers/containers trying to start it. It expires on # whatever the number of workers/containers trying to start it. It expires on
# its own if the loop dies (worker killed mid-job, Redis queue flushed, ...), # its own if the loop dies (worker killed mid-job, Redis queue flushed, ...),
# letting the next schedule_steam_refresh() start a new one. The guard holds the # letting the next schedule_steam_refresh() start a new one.
# loop id, so if it expires while the old loop is just late, the old one stops
# at its next run (instead of 2 loops running side by side).
STEAM_REFRESH_GUARD_KEY = "games:steam_refresh:scheduled" STEAM_REFRESH_GUARD_KEY = "games:steam_refresh:scheduled"
@@ -28,34 +25,28 @@ def _guard_timeout() -> int:
def schedule_steam_refresh() -> bool: def schedule_steam_refresh() -> bool:
"""Start the periodic refresh loop right away, unless one is already queued. """Start the periodic refresh loop right away, unless one is already queued.
Safe to call from anywhere, any number of times.""" Safe to call from anywhere, any number of times."""
loop_id = uuid4().hex if not cache.add(STEAM_REFRESH_GUARD_KEY, 1, timeout=_guard_timeout()):
if not cache.add(STEAM_REFRESH_GUARD_KEY, loop_id, timeout=_guard_timeout()):
return False return False
refresh_steam_games_task.enqueue(loop_id=loop_id) refresh_steam_games_task.enqueue()
return True return True
@task @task
def refresh_steam_games_task( def refresh_steam_games_task(
game_ids: list[int] | None = None, loop_id: str | None = None game_ids: list[int] | None = None, reschedule: bool = True
): ):
"""Refresh Steam store info, then re-enqueue itself every """Refresh Steam store info, then re-enqueue itself every
STEAM_STORE_REFRESH_MINUTES (same self-rescheduling idea as STEAM_STORE_REFRESH_MINUTES (same self-rescheduling idea as
sync_event_game_scopes_task, no external scheduler needed). sync_event_game_scopes_task, no external scheduler needed).
Without loop_id it's a one-off run (e.g. the admin action) that leaves the reschedule=False is a one-off run (e.g. the admin action) that leaves the
periodic loop alone. With loop_id, it only continues while the guard still periodic loop alone."""
holds this loop_id."""
if loop_id is not None and cache.get(STEAM_REFRESH_GUARD_KEY) != loop_id:
# Another loop owns the guard (or it expired), stop this one
return
try: try:
refresh_steam_games(game_ids) refresh_steam_games(game_ids)
finally: finally:
if loop_id is not None: if reschedule:
cache.set(STEAM_REFRESH_GUARD_KEY, loop_id, timeout=_guard_timeout()) cache.set(STEAM_REFRESH_GUARD_KEY, 1, timeout=_guard_timeout())
refresh_steam_games_task.using( refresh_steam_games_task.using(
run_after=timezone.now() + steam_refresh_interval() run_after=timezone.now() + steam_refresh_interval()
).enqueue(loop_id=loop_id) ).enqueue()
+5 -20
View File
@@ -9,11 +9,7 @@ from events.models import Event
from .models import EventGame, Game from .models import EventGame, Game
from .services.steam_store import refresh_steam_games from .services.steam_store import refresh_steam_games
from .tasks import ( from .tasks import refresh_steam_games_task, schedule_steam_refresh
STEAM_REFRESH_GUARD_KEY,
refresh_steam_games_task,
schedule_steam_refresh,
)
LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}} LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
@@ -87,32 +83,21 @@ class ScheduleSteamRefreshTests(TestCase):
def test_only_one_loop_is_scheduled(self, task): def test_only_one_loop_is_scheduled(self, task):
self.assertTrue(schedule_steam_refresh()) self.assertTrue(schedule_steam_refresh())
self.assertFalse(schedule_steam_refresh()) self.assertFalse(schedule_steam_refresh())
task.enqueue.assert_called_once_with(loop_id=cache.get(STEAM_REFRESH_GUARD_KEY)) task.enqueue.assert_called_once_with()
@mock.patch("games.tasks.refresh_steam_games") @mock.patch("games.tasks.refresh_steam_games")
def test_task_reschedules_itself_even_on_failure(self, refresh): def test_task_reschedules_itself_even_on_failure(self, refresh):
cache.set(STEAM_REFRESH_GUARD_KEY, "loop")
refresh.side_effect = RuntimeError("boom") refresh.side_effect = RuntimeError("boom")
with mock.patch("games.tasks.refresh_steam_games_task") as patched: with mock.patch("games.tasks.refresh_steam_games_task") as patched:
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
refresh_steam_games_task.call(loop_id="loop") refresh_steam_games_task.call()
patched.using.return_value.enqueue.assert_called_once_with(loop_id="loop") patched.using.return_value.enqueue.assert_called_once_with()
@mock.patch("games.tasks.refresh_steam_games")
def test_superseded_loop_stops(self, refresh):
cache.set(STEAM_REFRESH_GUARD_KEY, "new-loop")
with mock.patch("games.tasks.refresh_steam_games_task") as patched:
refresh_steam_games_task.call(loop_id="old-loop")
refresh.assert_not_called()
patched.using.assert_not_called()
self.assertEqual(cache.get(STEAM_REFRESH_GUARD_KEY), "new-loop")
@mock.patch("games.tasks.refresh_steam_games") @mock.patch("games.tasks.refresh_steam_games")
def test_one_off_run_does_not_reschedule(self, refresh): def test_one_off_run_does_not_reschedule(self, refresh):
with mock.patch("games.tasks.refresh_steam_games_task") as patched: with mock.patch("games.tasks.refresh_steam_games_task") as patched:
refresh_steam_games_task.call(game_ids=[1]) refresh_steam_games_task.call(game_ids=[1], reschedule=False)
refresh.assert_called_once_with([1]) refresh.assert_called_once_with([1])
patched.using.assert_not_called() patched.using.assert_not_called()
+1 -3
View File
@@ -239,9 +239,7 @@ def get_leaderboard(request: HttpRequest, event_path: str):
return data return data
@router.post( @router.post(f"{PREFIX}/submit", response={200: NoitaSubmissionOut, 400: dict})
f"{PREFIX}/submit", response={200: NoitaSubmissionOut, 400: dict, 500: dict}
)
@decorate_view(require_game_enabled(NOITA_APP_ID)) @decorate_view(require_game_enabled(NOITA_APP_ID))
def submit_log_file( def submit_log_file(
request: HttpRequest, event_path: str, file: UploadedFile = File(...) request: HttpRequest, event_path: str, file: UploadedFile = File(...)
-1
View File
@@ -3,5 +3,4 @@ import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({ export default defineConfig({
input: `http://localhost:7777/api/openapi.json`, input: `http://localhost:7777/api/openapi.json`,
output: 'src/api/', output: 'src/api/',
plugins: ['@hey-api/typescript', '@hey-api/sdk', '@tanstack/vue-query'],
}); });
+1 -10
View File
@@ -95,16 +95,7 @@ class SteamCollectionAdmin(admin.ModelAdmin):
fieldsets = ( fieldsets = (
( (
"Basic Information", "Basic Information",
{ {"fields": ("steam_id", "url", "title", "description", "is_active")},
"fields": (
"event",
"steam_id",
"url",
"title",
"description",
"is_active",
)
},
), ),
("Author Information", {"fields": ("author_name", "author_steam_id")}), ("Author Information", {"fields": ("author_name", "author_steam_id")}),
( (
+10 -89
View File
@@ -1,5 +1,3 @@
import csv
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
@@ -7,8 +5,6 @@ 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.core.cache import cache
from django.http import HttpRequest, HttpResponse
from ninja.security import django_auth_is_staff
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
@@ -29,7 +25,6 @@ from .schemas import (
SubmissionOut, SubmissionOut,
PuzzleResponseOut, PuzzleResponseOut,
ValidationIn, ValidationIn,
SubmissionFileOut,
SteamCollectionItemOut, SteamCollectionItemOut,
SteamCollectionOut, SteamCollectionOut,
) )
@@ -78,9 +73,7 @@ def get_submission(request, event_path: str, submission_id: str):
) )
@router.post( @router.post(f"{PREFIX}/submissions", response=SubmissionOut)
f"{PREFIX}/submissions", response={200: SubmissionOut, 400: dict, 500: dict}
)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) @decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def create_submission( def create_submission(
request, event_path: str, data: SubmissionIn, files: List[UploadedFile] = File(...) request, event_path: str, data: SubmissionIn, files: List[UploadedFile] = File(...)
@@ -127,17 +120,13 @@ def create_submission(
for response_data in data.responses for response_data in data.responses
) )
# Non-admin submissions always go through manual validation
needs_validation = (
data.manual_validation_requested or not request.user.is_staff
)
# Create the submission # Create the submission
submission = Submission.objects.create( submission = Submission.objects.create(
event=request.event, event=request.event,
user=request.user if request.user.is_authenticated else None, user=request.user if request.user.is_authenticated else None,
notes=data.notes, notes=data.notes,
manual_validation_requested=needs_validation or auto_request_validation, manual_validation_requested=data.manual_validation_requested
or auto_request_validation,
) )
file_index = 0 file_index = 0
@@ -157,7 +146,7 @@ def create_submission(
cost=response_data.cost, cost=response_data.cost,
cycles=response_data.cycles, cycles=response_data.cycles,
area=response_data.area, area=response_data.area,
needs_manual_validation=needs_validation, needs_manual_validation=data.manual_validation_requested,
ocr_confidence_cost=response_data.ocr_confidence_cost, ocr_confidence_cost=response_data.ocr_confidence_cost,
ocr_confidence_cycles=response_data.ocr_confidence_cycles, ocr_confidence_cycles=response_data.ocr_confidence_cycles,
ocr_confidence_area=response_data.ocr_confidence_area, ocr_confidence_area=response_data.ocr_confidence_area,
@@ -167,7 +156,7 @@ def create_submission(
"validated_cycles": response_data.cycles, "validated_cycles": response_data.cycles,
"validated_area": response_data.area, "validated_area": response_data.area,
} }
if not needs_validation if not data.manual_validation_requested
else {}, else {},
) )
@@ -222,10 +211,7 @@ def create_submission(
return 500, {"detail": f"Error creating submission: {str(e)}"} return 500, {"detail": f"Error creating submission: {str(e)}"}
@router.put( @router.put(f"{PREFIX}/responses/{{response_id}}/validate", response=PuzzleResponseOut)
f"{PREFIX}/responses/{{response_id}}/validate",
response={200: PuzzleResponseOut, 403: dict},
)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) @decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def validate_response(request, event_path: str, response_id: int, data: ValidationIn): def validate_response(request, event_path: str, response_id: int, data: ValidationIn):
"""Manually validate a puzzle response""" """Manually validate a puzzle response"""
@@ -266,8 +252,7 @@ def validate_response(request, event_path: str, response_id: int, data: Validati
@router.put( @router.put(
f"{PREFIX}/responses/{{response_id}}/validate/auto", f"{PREFIX}/responses/{{response_id}}/validate/auto", response=PuzzleResponseOut
response={200: PuzzleResponseOut, 403: dict},
) )
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) @decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def validate_auto(request, event_path: str, response_id: int): def validate_auto(request, event_path: str, response_id: int):
@@ -286,10 +271,7 @@ def validate_auto(request, event_path: str, response_id: int):
return response return response
@router.get( @router.get(f"{PREFIX}/responses/needs-validation", response=List[PuzzleResponseOut])
f"{PREFIX}/responses/needs-validation",
response={200: List[PuzzleResponseOut], 403: dict},
)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) @decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def list_responses_needing_validation(request, event_path: str): def list_responses_needing_validation(request, event_path: str):
"""Get all responses that need manual validation""" """Get all responses that need manual validation"""
@@ -307,28 +289,7 @@ def list_responses_needing_validation(request, event_path: str):
) )
@router.get( @router.post(f"{PREFIX}/submissions/{{submission_id}}/validate", response=SubmissionOut)
f"{PREFIX}/responses/{{response_id}}/files",
response={200: List[SubmissionFileOut], 403: dict},
)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def list_response_files(request, event_path: str, response_id: int):
"""Get the solution files of any player's response (admin only)"""
if not request.user.is_authenticated or not request.user.is_staff:
return 403, {"detail": "Admin access required"}
response = get_object_or_404(
PuzzleResponse, id=response_id, submission__event=request.event
)
return response.files.all()
@router.post(
f"{PREFIX}/submissions/{{submission_id}}/validate",
response={200: SubmissionOut, 403: dict},
)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) @decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def validate_submission(request, event_path: str, submission_id: str): def validate_submission(request, event_path: str, submission_id: str):
"""Mark entire submission as validated""" """Mark entire submission as validated"""
@@ -357,9 +318,7 @@ def validate_submission(request, event_path: str, submission_id: str):
return submission return submission
@router.delete( @router.delete(f"{PREFIX}/submissions/{{submission_id}}")
f"{PREFIX}/submissions/{{submission_id}}", response={200: dict, 403: dict}
)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID)) @decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def delete_submission(request, event_path: str, submission_id: str): def delete_submission(request, event_path: str, submission_id: str):
"""Delete a submission (admin only)""" """Delete a submission (admin only)"""
@@ -399,41 +358,3 @@ def get_stats(request, event_path: str):
if total_responses if total_responses
else 0, else 0,
} }
@router.get(f"{PREFIX}/points.csv", auth=django_auth_is_staff)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def points_csv(request: HttpRequest, event_path: str):
"""Admin-only: points per player for each puzzle of the active collection,
one column per puzzle (1 puzzle = 1 round), `;` separated."""
puzzles = list(
SteamCollectionItem.objects.filter(
collection__is_active=True, collection__event=request.event
).order_by("order_index")
)
points = {}
for response in (
PuzzleResponse.objects.filter(
needs_manual_validation=False,
submission__event=request.event,
submission__user__isnull=False,
puzzle__in=puzzles,
)
.filter_user_best_response()
.select_related("submission__user")
):
user_points = points.setdefault(response.submission.user.username, {})
user_points[response.puzzle_id] = response.rank_points or 0
# text/plain so browsers render it inline - most force-download text/csv
response = HttpResponse(content_type="text/plain; charset=utf-8")
writer = csv.writer(response, delimiter=";")
writer.writerow(["username", *(puzzle.title for puzzle in puzzles)])
for username, user_points in sorted(
points.items(), key=lambda item: (-sum(item[1].values()), item[0])
):
writer.writerow(
[username, *(user_points.get(puzzle.id, 0) for puzzle in puzzles)]
)
return response
@@ -4,7 +4,6 @@ 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 opus_magnum.utils import create_or_update_collection from opus_magnum.utils import create_or_update_collection
from events.models import Event
from opus_magnum.models import SteamAPIKey, SteamCollection from opus_magnum.models import SteamAPIKey, SteamCollection
@@ -13,12 +12,6 @@ class Command(BaseCommand):
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument("url", type=str, help="Steam Workshop collection URL") parser.add_argument("url", type=str, help="Steam Workshop collection URL")
parser.add_argument(
"--event",
type=str,
required=True,
help="Path of the event the collection belongs to",
)
parser.add_argument( parser.add_argument(
"--force", "--force",
action="store_true", action="store_true",
@@ -29,11 +22,6 @@ class Command(BaseCommand):
url = options["url"] url = options["url"]
force = options["force"] force = options["force"]
try:
event = Event.objects.get(path=options["event"])
except Event.DoesNotExist:
raise CommandError(f"No event with path '{options['event']}'")
self.stdout.write(f"Fetching Steam collection from: {url}") self.stdout.write(f"Fetching Steam collection from: {url}")
api_key = SteamAPIKey.objects.filter(is_active=True).first() api_key = SteamAPIKey.objects.filter(is_active=True).first()
@@ -65,7 +53,7 @@ class Command(BaseCommand):
return return
# Fetch and create/update collection # Fetch and create/update collection
collection, created = create_or_update_collection(url, event) collection, created = create_or_update_collection(url)
if created: if created:
self.stdout.write( self.stdout.write(
@@ -1,33 +0,0 @@
# 0015 copied rows with their ids via raw INSERT ... SELECT *, which doesn't
# bump postgres sequences -> new inserts collide on the pkey. Resync them.
from django.core.management.color import no_style
from django.db import migrations
MODEL_NAMES = [
"SteamAPIKey",
"SteamCollection",
"SteamCollectionItem",
"Submission",
"PuzzleResponse",
"SubmissionFile",
]
def reset_sequences(apps, schema_editor):
connection = schema_editor.connection
models = [apps.get_model("opus_magnum", name) for name in MODEL_NAMES]
statements = connection.ops.sequence_reset_sql(no_style(), models)
with connection.cursor() as cursor:
for sql in statements:
cursor.execute(sql)
class Migration(migrations.Migration):
dependencies = [
("opus_magnum", "0018_event_required"),
]
operations = [
migrations.RunPython(reset_sequences, migrations.RunPython.noop),
]
@@ -1,34 +0,0 @@
# Generated by Django 6.0.5 on 2026-10-23 21:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("opus_magnum", "0019_reset_sequences"),
]
operations = [
migrations.AlterField(
model_name="puzzleresponse",
name="area",
field=models.IntegerField(
blank=True, help_text="Area value from OCR", null=True
),
),
migrations.AlterField(
model_name="puzzleresponse",
name="cost",
field=models.IntegerField(
blank=True, help_text="Cost value from OCR", null=True
),
),
migrations.AlterField(
model_name="puzzleresponse",
name="cycles",
field=models.IntegerField(
blank=True, help_text="Cycles value from OCR", null=True
),
),
]
+3 -3
View File
@@ -400,9 +400,9 @@ class PuzzleResponse(models.Model):
puzzle_name = models.CharField( puzzle_name = models.CharField(
max_length=255, help_text="Puzzle name as detected by OCR" max_length=255, help_text="Puzzle name as detected by OCR"
) )
cost = models.IntegerField(null=True, blank=True, help_text="Cost value from OCR") cost = models.IntegerField(blank=True, help_text="Cost value from OCR")
cycles = models.IntegerField(null=True, blank=True, help_text="Cycles value from OCR") cycles = models.IntegerField(blank=True, help_text="Cycles value from OCR")
area = models.IntegerField(null=True, blank=True, help_text="Area value from OCR") area = models.IntegerField(blank=True, help_text="Area value from OCR")
# Validation flags # Validation flags
needs_manual_validation = models.BooleanField( needs_manual_validation = models.BooleanField(
+1 -74
View File
@@ -1,74 +1 @@
from django.test import TestCase # Create your tests here.
from accounts.models import CustomUser
from animations.models import PuzzlePointsFactor, PuzzlePointsValue
from events.models import Event
from games.models import EventGame, Game
from .api import OPUS_MAGNUM_APP_ID
from .models import PuzzleResponse, SteamCollection, SteamCollectionItem, Submission
URL = "/api/events/event/opus-magnum/points.csv"
class PointsCsvTests(TestCase):
def setUp(self):
self.event = Event.objects.create(name="Event", path="event")
game = Game.objects.create(name="Opus Magnum", steam_app_id=OPUS_MAGNUM_APP_ID)
EventGame.objects.create(event=self.event, game=game)
collection = SteamCollection.objects.create(
event=self.event, steam_id="1", url="https://example.com", is_active=True
)
factor = PuzzlePointsFactor.objects.create(cost=1, cycles=1, area=1)
value = PuzzlePointsValue.objects.create(points=[10, 5, 1])
self.p1, self.p2 = (
SteamCollectionItem.objects.create(
collection=collection,
steam_item_id=str(i),
title=f"Puzzle {i}",
order_index=i,
points_factor=factor,
points_value=value,
)
for i in (1, 2)
)
self.alice = CustomUser.objects.create_user(username="alice", password="x")
self.bob = CustomUser.objects.create_user(username="bob", password="x")
self.admin = CustomUser.objects.create_user(
username="admin", password="x", is_staff=True
)
def respond(self, user, puzzle, score):
submission = Submission.objects.create(event=self.event, user=user)
PuzzleResponse.objects.create(
submission=submission,
puzzle=puzzle,
puzzle_name=puzzle.title,
validated_cost=score,
validated_cycles=0,
validated_area=0,
)
def test_requires_staff(self):
self.client.force_login(self.alice)
response = self.client.get(URL)
self.assertEqual(response.status_code, 401)
def test_points_per_puzzle(self):
self.respond(self.alice, self.p1, 10)
self.respond(self.bob, self.p1, 20)
self.respond(self.bob, self.p2, 30)
self.client.force_login(self.admin)
response = self.client.get(URL)
self.assertEqual(
response.content.decode().splitlines(),
[
"username;Puzzle 1;Puzzle 2",
"bob;5;10",
"alice;10;0",
],
)
+1 -6
View File
@@ -4,7 +4,6 @@ Utilities for fetching Steam Workshop collection data using Steam Web API
import re import re
import requests import requests
from events.models import Event
from opus_magnum.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
@@ -374,15 +373,12 @@ def fetch_steam_collection(url: str) -> Dict:
return fetcher.fetch_collection_data(url) return fetcher.fetch_collection_data(url)
def create_or_update_collection( def create_or_update_collection(url: str) -> Tuple[SteamCollection, bool]:
url: str, event: Event
) -> Tuple[SteamCollection, bool]:
""" """
Create or update a Steam collection in the database Create or update a Steam collection in the database
Args: Args:
url: Steam Workshop collection URL url: Steam Workshop collection URL
event: Event the collection belongs to
Returns: Returns:
Tuple of (SteamCollection instance, created_flag) Tuple of (SteamCollection instance, created_flag)
@@ -399,7 +395,6 @@ def create_or_update_collection(
collection, created = SteamCollection.objects.update_or_create( collection, created = SteamCollection.objects.update_or_create(
steam_id=data["steam_id"], steam_id=data["steam_id"],
defaults={ defaults={
"event": event,
"url": data["url"], "url": data["url"],
"title": data["title"], "title": data["title"],
"description": data["description"], "description": data["description"],
-3
View File
@@ -12,9 +12,6 @@
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"@tanstack/query-persist-client-core": "^5.104.3",
"@tanstack/query-sync-storage-persister": "^5.104.3",
"@tanstack/vue-query": "^5.104.3",
"@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", "dayjs": "^1.11.20",
+2 -2
View File
@@ -7,8 +7,8 @@ from .models import PixelPlacement, PlaceSettings
@admin.register(PlaceSettings) @admin.register(PlaceSettings)
class PlaceSettingsAdmin(DjangoObjectActions, admin.ModelAdmin): class PlaceSettingsAdmin(DjangoObjectActions, admin.ModelAdmin):
list_display = ("event", "width", "height", "placement_ends_at") list_display = ("event", "width", "height")
list_editable = ("width", "height", "placement_ends_at") list_editable = ("width", "height")
list_filter = ("event",) list_filter = ("event",)
change_actions = ("resync_canvas",) change_actions = ("resync_canvas",)
+5 -117
View File
@@ -1,7 +1,5 @@
import csv
import struct import struct
from django.db.models import Count
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from ninja import Router from ninja import Router
from ninja.decorators import decorate_view from ninja.decorators import decorate_view
@@ -10,17 +8,8 @@ from ninja.security import django_auth_is_staff
from games.decorators import require_game_enabled from games.decorators import require_game_enabled
from .canvas import read_canvas from .canvas import read_canvas
from .export import build_final_png
from .gif import build_evolution_gif from .gif import build_evolution_gif
from .heatmap import ( from .models import PlaceSettings
DecayMode,
build_heatmap_decay_gif,
build_heatmap_evolution_gif,
build_heatmap_png,
)
from .models import PixelPlacement, PlaceSettings
from .schemas import PlaceStatsOut
from .stats import build_place_stats
router = Router() router = Router()
@@ -36,116 +25,15 @@ HEADER_FORMAT = "<HH"
@decorate_view(require_game_enabled(path="place")) @decorate_view(require_game_enabled(path="place"))
def canvas(request: HttpRequest, event_path: str): def canvas(request: HttpRequest, event_path: str):
"""The one-time full-grid load. Every placement after that arrives as a """The one-time full-grid load. Every placement after that arrives as a
single websocket message, never a re-fetch. Once placement is over the single websocket message, never a re-fetch."""
board is shown wiped (Redis keeps the real final board, see tasks.py)."""
place_settings = PlaceSettings.current(request.event) place_settings = PlaceSettings.current(request.event)
header = struct.pack(HEADER_FORMAT, place_settings.width, place_settings.height) header = struct.pack(HEADER_FORMAT, place_settings.width, place_settings.height)
if place_settings.placement_ended:
pixels = bytes(place_settings.width * place_settings.height)
else:
pixels = read_canvas(event_path, place_settings.width, place_settings.height) pixels = read_canvas(event_path, place_settings.width, place_settings.height)
return HttpResponse(header + pixels, content_type="application/octet-stream") return HttpResponse(header + pixels, content_type="application/octet-stream")
@router.get(f"{PREFIX}/evolution.gif", auth=django_auth_is_staff) @router.get(f"{PREFIX}/evolution.gif", auth=django_auth_is_staff)
@decorate_view(require_game_enabled(path="place")) @decorate_view(require_game_enabled(path="place"))
def evolution_gif(request: HttpRequest, event_path: str, players: str = ""): def evolution_gif(request: HttpRequest, event_path: str):
"""Admin-only: replays the full placement history as an animated GIF. """Admin-only: replays the full placement history as an animated GIF."""
return HttpResponse(build_evolution_gif(request.event), content_type="image/gif")
`players` is an optional comma-separated list of usernames to only
replay those players' placements (combined)."""
usernames = [name.strip() for name in players.split(",") if name.strip()]
return HttpResponse(
build_evolution_gif(request.event, usernames), content_type="image/gif"
)
@router.get(f"{PREFIX}/final.png", auth=django_auth_is_staff)
@decorate_view(require_game_enabled(path="place"))
def final_png(request: HttpRequest, event_path: str):
"""Admin-only: the real final board as a PNG, even once placement is
over and everyone else sees it wiped."""
return HttpResponse(build_final_png(request.event), content_type="image/png")
@router.get(f"{PREFIX}/heatmap.png", auth=django_auth_is_staff)
@decorate_view(require_game_enabled(path="place"))
def heatmap_png(request: HttpRequest, event_path: str, players: str = ""):
"""Admin-only: how many times each pixel was placed, as a heatmap PNG.
`players` is an optional comma-separated list of usernames to only
count those players' placements (combined)."""
usernames = [name.strip() for name in players.split(",") if name.strip()]
return HttpResponse(
build_heatmap_png(request.event, usernames), content_type="image/png"
)
@router.get(f"{PREFIX}/heatmap-evolution.gif", auth=django_auth_is_staff)
@decorate_view(require_game_enabled(path="place"))
def heatmap_evolution_gif(request: HttpRequest, event_path: str, players: str = ""):
"""Admin-only: the heatmap building up over time, as an animated GIF.
`players` is an optional comma-separated list of usernames to only
count those players' placements (combined)."""
usernames = [name.strip() for name in players.split(",") if name.strip()]
return HttpResponse(
build_heatmap_evolution_gif(request.event, usernames),
content_type="image/gif",
)
@router.get(f"{PREFIX}/heatmap-decay.gif", auth=django_auth_is_staff)
@decorate_view(require_game_enabled(path="place"))
def heatmap_decay_gif(
request: HttpRequest,
event_path: str,
players: str = "",
half_life: float | None = None,
mode: DecayMode = "placements",
):
"""Admin-only: the heatmap over time, but heat decays so pixels nobody
touches anymore fade back to white.
`players` is an optional comma-separated list of usernames to only
count those players' placements (combined). `mode` is "placements"
(decay per placement, `half_life` in placements, defaults to 10 frames'
worth) or "time" (decay over time, `half_life` in minutes, defaults
to 60)."""
usernames = [name.strip() for name in players.split(",") if name.strip()]
return HttpResponse(
build_heatmap_decay_gif(request.event, usernames, half_life, mode),
content_type="image/gif",
)
@router.get(f"{PREFIX}/stats", auth=django_auth_is_staff, response=PlaceStatsOut)
@decorate_view(require_game_enabled(path="place"))
def stats(request: HttpRequest, event_path: str):
"""Admin-only: aggregated stats about the game - totals, coverage, top
players, most contested pixels, color usage and activity per hour."""
return build_place_stats(request.event)
@router.get(f"{PREFIX}/placements.csv", auth=django_auth_is_staff)
@decorate_view(require_game_enabled(path="place"))
def placements_csv(request: HttpRequest, event_path: str):
"""Admin-only: pixel placement count per player, most active first."""
rows = (
PixelPlacement.objects.filter(event=request.event)
.values("player__username")
.annotate(count=Count("id"))
.order_by("-count", "player__username")
)
# text/plain so browsers render it inline - most force-download text/csv
response = HttpResponse(content_type="text/plain; charset=utf-8")
writer = csv.writer(response)
writer.writerow(["username", "placements"])
for row in rows:
writer.writerow(
[
row["player__username"],
row["count"],
]
)
return response
+1 -20
View File
@@ -13,10 +13,6 @@ from .models import PALETTE, PLACE_COOLDOWN_SECONDS, PixelPlacement, PlaceSettin
from .wire import encode, to_epoch_ms from .wire import encode, to_epoch_ms
def _epoch_ms_or_none(dt):
return None if dt is None else to_epoch_ms(dt)
class PlaceConsumer(AsyncWebsocketConsumer): class PlaceConsumer(AsyncWebsocketConsumer):
async def connect(self): async def connect(self):
self.event_path = self.scope["url_route"]["kwargs"]["event_path"] self.event_path = self.scope["url_route"]["kwargs"]["event_path"]
@@ -49,10 +45,6 @@ class PlaceConsumer(AsyncWebsocketConsumer):
await self.send(text_data=encode("r", "a")) await self.send(text_data=encode("r", "a"))
return return
if place_settings.placement_ended:
await self.send(text_data=encode("r", "d"))
return
parts = text_data.split("|") parts = text_data.split("|")
if len(parts) != 4: if len(parts) != 4:
await self.send(text_data=encode("r", "i")) await self.send(text_data=encode("r", "i"))
@@ -150,7 +142,6 @@ class PlaceConsumer(AsyncWebsocketConsumer):
next_placement_at, next_placement_at,
place_settings.width, place_settings.width,
place_settings.height, place_settings.height,
_epoch_ms_or_none(place_settings.placement_ends_at),
) )
@database_sync_to_async @database_sync_to_async
@@ -161,14 +152,4 @@ class PlaceConsumer(AsyncWebsocketConsumer):
await self.send(text_data=encode("p", event["x"], event["y"], event["c"])) await self.send(text_data=encode("p", event["x"], event["y"], event["c"]))
async def settings_changed(self, event): async def settings_changed(self, event):
await self.send( await self.send(text_data=encode("e", event["width"], event["height"]))
text_data=encode(
"e",
event["width"],
event["height"],
event["ends_at"],
)
)
async def wipe(self, event):
await self.send(text_data=encode("w"))
-31
View File
@@ -1,31 +0,0 @@
"""Exports one event's final board as a PNG.
Reads the live Redis canvas rather than replaying the history like gif.py -
once placement is over the canvas endpoint serves a wiped board, but Redis
still holds the real final one (see tasks.py).
"""
from io import BytesIO
from events.models import Event
from .canvas import read_canvas
from .gif import _render_frame
from .models import PlaceSettings
def build_final_png(event: Event) -> bytes:
"""Renders `event`'s current board as a PNG, upscaled like gif.py.
Redis only pads up to the last written pixel, so a short canvas is
padded with white (and a longer one, e.g. from before a shrink, cut)
to the current grid dimensions."""
place_settings = PlaceSettings.current(event)
width, height = place_settings.width, place_settings.height
size = width * height
canvas = read_canvas(event.path, width, height)[:size].ljust(size, b"\0")
buffer = BytesIO()
_render_frame(bytearray(canvas), width, height).save(buffer, format="PNG")
return buffer.getvalue()
+6 -9
View File
@@ -25,25 +25,22 @@ _PALETTE_RGB = [
] ]
def build_evolution_gif(event: Event, usernames: list[str] | None = None) -> bytes: def build_evolution_gif(event: Event) -> bytes:
"""Renders `event`'s full placement history as an animated GIF, upscaled """Renders `event`'s full placement history as an animated GIF, upscaled
for visibility. Returns GIF bytes, even if there are zero placements. for visibility. Returns GIF bytes, even if there are zero placements.
If `usernames` is given, only those players' placements are replayed
(combined on the same board).
Always replays at the *current* grid dimensions - a placement made Always replays at the *current* grid dimensions - a placement made
before a resize that now falls outside them is skipped rather than before a resize that now falls outside them is skipped rather than
raising.""" raising."""
place_settings = PlaceSettings.current(event) place_settings = PlaceSettings.current(event)
width, height = place_settings.width, place_settings.height width, height = place_settings.width, place_settings.height
placements = PixelPlacement.objects.filter(event=event) placements = (
if usernames: PixelPlacement.objects.filter(event=event)
placements = placements.filter(player__username__in=usernames) .order_by("placed_at")
placements = list( .values_list("x", "y", "color_index")
placements.order_by("placed_at").values_list("x", "y", "color_index")
) )
placements = list(placements)
grid = bytearray(width * height) grid = bytearray(width * height)
frames: list[Image.Image] = [] frames: list[Image.Image] = []
-240
View File
@@ -1,240 +0,0 @@
"""Renders one event's pixel placement history as a heatmap.
Same source as gif.py (the durable PixelPlacement history), but instead of
replaying colors, counts how many times each pixel was placed and maps that
count to a white -> red gradient, so an admin can see
where the action was - either as a single PNG of the final counts, as an
animated GIF of the counts building up over time, or as an animated GIF
where the heat decays so only recent activity stays red.
"""
import math
from io import BytesIO
from typing import Literal
from django.db.models import Count
from PIL import Image
from events.models import Event
from .gif import FINAL_FRAME_HOLD_MS, FRAME_DURATION_MS, MAX_FRAMES
from .models import PixelPlacement, PlaceSettings
SCALE = 8
# Gradient stops, from "never placed" to "most placed"
_GRADIENT = [
(255, 255, 255),
(193, 1, 2),
]
def build_heatmap_png(event: Event, usernames: list[str] | None = None) -> bytes:
"""Renders `event`'s placement counts per pixel as a PNG heatmap,
upscaled for visibility. Returns PNG bytes, even if there are zero
placements (all white).
If `usernames` is given, only those players' placements are counted
(combined on the same board).
Counts are log-scaled, otherwise a few heavily fought-over pixels make
everything else look white. Placements outside the *current* grid
dimensions are skipped, like in gif.py."""
place_settings = PlaceSettings.current(event)
width, height = place_settings.width, place_settings.height
placements = _placements(event, usernames)
counts = placements.values("x", "y").annotate(count=Count("id")).order_by()
grid = [0] * (width * height)
for row in counts:
if row["x"] >= width or row["y"] >= height:
continue
grid[row["y"] * width + row["x"]] = row["count"]
colors = _color_table(max(grid, default=0))
buffer = BytesIO()
_render_frame(grid, width, height, colors).save(buffer, format="PNG")
return buffer.getvalue()
def build_heatmap_evolution_gif(
event: Event, usernames: list[str] | None = None
) -> bytes:
"""Same as build_heatmap_png, but as an animated GIF where the counts
accumulate placement by placement (sampled down to MAX_FRAMES, like
gif.py). Returns GIF bytes, even if there are zero placements.
Every frame uses the *final* max count for its color scale, so a pixel's
color only ever heats up - otherwise early frames would look as hot as
the end."""
place_settings = PlaceSettings.current(event)
width, height = place_settings.width, place_settings.height
placements = [
(x, y)
for x, y in _placements(event, usernames)
.order_by("placed_at")
.values_list("x", "y")
if x < width and y < height
]
final_grid = [0] * (width * height)
for x, y in placements:
final_grid[y * width + x] += 1
colors = _color_table(max(final_grid, default=0))
grid = [0] * (width * height)
frames: list[Image.Image] = []
step = max(1, len(placements) // MAX_FRAMES)
for i, (x, y) in enumerate(placements):
grid[y * width + x] += 1
if i % step == 0 or i == len(placements) - 1:
frames.append(_render_frame(grid, width, height, colors))
if not frames:
frames.append(_render_frame(grid, width, height, colors))
return _save_gif(frames)
DecayMode = Literal["placements", "time"]
DEFAULT_HALF_LIFE_MINUTES = 60
def build_heatmap_decay_gif(
event: Event,
usernames: list[str] | None = None,
half_life: float | None = None,
mode: DecayMode = "placements",
) -> bytes:
"""Like build_heatmap_evolution_gif, but every pixel's heat decays
exponentially, so a pixel nobody touches anymore fades back to white
and the GIF shows where the activity is *right now*.
`mode` picks what drives the decay (and the frame spacing):
- "placements": `half_life` is in placements (after that many more
placements anywhere on the board, a pixel's heat is halved), defaults
to 10 frames' worth. Frames are one every N placements, so quiet
periods take almost no frames and nothing fades during them.
- "time": `half_life` is in minutes, defaults to an hour. Frames are
evenly spaced in time between the first and last placement, so quiet
periods actually show the board fading out.
Every frame shares the same color scale (the hottest pixel across the
whole replay), so a quiet period actually looks quiet."""
place_settings = PlaceSettings.current(event)
width, height = place_settings.width, place_settings.height
rows = [
(placed_at, x, y)
for placed_at, x, y in _placements(event, usernames)
.order_by("placed_at")
.values_list("placed_at", "x", "y")
if x < width and y < height
]
# Both modes boil down to a "clock" per placement, the clock values to
# snapshot a frame at, and a half-life in that clock's unit.
if mode == "time":
clocks = [placed_at.timestamp() for placed_at, _, _ in rows]
frame_clocks = _evenly_spaced(clocks, MAX_FRAMES)
half_life_clock = (half_life or DEFAULT_HALF_LIFE_MINUTES) * 60
else:
clocks = list(range(len(rows)))
step = max(1, len(rows) // MAX_FRAMES)
frame_clocks = [i for i in clocks if i % step == 0 or i == len(rows) - 1]
half_life_clock = half_life or step * 10
decay = 0.5 ** (1 / max(1e-9, half_life_clock))
# Decay is applied lazily: each pixel stores its heat as of the clock it
# was last touched at, so a placement is O(1) instead of decaying the
# whole grid every time.
heat = [0.0] * (width * height)
touched_at = [0.0] * (width * height)
snapshots: list[list[float]] = []
placement_index = 0
for frame_clock in frame_clocks:
while placement_index < len(rows) and clocks[placement_index] <= frame_clock:
_, x, y = rows[placement_index]
clock = clocks[placement_index]
index = y * width + x
heat[index] = heat[index] * decay ** (clock - touched_at[index]) + 1
touched_at[index] = clock
placement_index += 1
snapshots.append(
[h * decay ** (frame_clock - t) for h, t in zip(heat, touched_at)]
)
if not snapshots:
snapshots.append(heat)
max_log = math.log1p(max(max(snapshot) for snapshot in snapshots)) or 1
colors = [_gradient(i / 255) for i in range(256)]
frames = []
for snapshot in snapshots:
image = Image.new("RGB", (width, height))
image.putdata([colors[round(math.log1p(h) / max_log * 255)] for h in snapshot])
frames.append(image.resize((width * SCALE, height * SCALE), Image.NEAREST))
return _save_gif(frames)
def _evenly_spaced(clocks: list[float], count: int) -> list[float]:
"""`count` clock values evenly spread from the first to the last one
(just the one if they're all the same, none if `clocks` is empty)."""
if not clocks:
return []
first, last = clocks[0], clocks[-1]
if first == last:
return [first]
return [first + (last - first) * i / (count - 1) for i in range(count)]
def _save_gif(frames: list[Image.Image]) -> bytes:
buffer = BytesIO()
durations = [FRAME_DURATION_MS] * len(frames)
durations[-1] = FINAL_FRAME_HOLD_MS
frames[0].save(
buffer,
format="GIF",
save_all=True,
append_images=frames[1:],
duration=durations,
loop=0,
disposal=2,
)
return buffer.getvalue()
def _placements(event: Event, usernames: list[str] | None):
placements = PixelPlacement.objects.filter(event=event)
if usernames:
placements = placements.filter(player__username__in=usernames)
return placements
def _color_table(max_count: int) -> list[tuple[int, int, int]]:
"""Color for every count in [0, max_count], log-scaled."""
max_log = math.log1p(max_count) or 1
return [_gradient(math.log1p(count) / max_log) for count in range(max_count + 1)]
def _render_frame(
grid: list[int], width: int, height: int, colors: list[tuple[int, int, int]]
) -> Image.Image:
image = Image.new("RGB", (width, height))
image.putdata([colors[count] for count in grid])
return image.resize((width * SCALE, height * SCALE), Image.NEAREST)
def _gradient(t: float) -> tuple[int, int, int]:
"""Linearly interpolates `_GRADIENT` at `t` in [0, 1]."""
position = t * (len(_GRADIENT) - 1)
index = min(int(position), len(_GRADIENT) - 2)
frac = position - index
start, end = _GRADIENT[index], _GRADIENT[index + 1]
r, g, b = (round(a + (z - a) * frac) for a, z in zip(start, end))
return r, g, b
@@ -1,18 +0,0 @@
# Generated by Django 6.0.5 on 2026-10-25 09:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("place", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="placesettings",
name="placement_enabled",
field=models.BooleanField(default=True),
),
]
@@ -1,21 +0,0 @@
# Generated by Django 6.0.5 on 2026-10-25 09:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("place", "0002_placesettings_placement_enabled"),
]
operations = [
migrations.RemoveField(
model_name="placesettings",
name="placement_enabled",
),
migrations.AddField(
model_name="placesettings",
name="placement_ends_at",
field=models.DateTimeField(blank=True, null=True),
),
]
-13
View File
@@ -1,6 +1,5 @@
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.utils import timezone
from events.models import Event from events.models import Event
from events.scoping import EventScopedManager from events.scoping import EventScopedManager
@@ -48,22 +47,10 @@ class PlaceSettings(models.Model):
) )
width = models.PositiveSmallIntegerField(default=DEFAULT_GRID_WIDTH) width = models.PositiveSmallIntegerField(default=DEFAULT_GRID_WIDTH)
height = models.PositiveSmallIntegerField(default=DEFAULT_GRID_HEIGHT) height = models.PositiveSmallIntegerField(default=DEFAULT_GRID_HEIGHT)
# Past this, the game is over without taking it down: every placement
# is rejected and the board is shown wiped (see tasks.py). Only visual -
# the PixelPlacement history and the Redis canvas are kept as-is, so
# admin stats/heatmaps still see the final board. Empty = no end.
placement_ends_at = models.DateTimeField(null=True, blank=True)
def __str__(self): def __str__(self):
return f"PlaceSettings({self.event}, size={self.width}x{self.height})" return f"PlaceSettings({self.event}, size={self.width}x{self.height})"
@property
def placement_ended(self) -> bool:
return (
self.placement_ends_at is not None
and self.placement_ends_at <= timezone.now()
)
@classmethod @classmethod
def current(cls, event: Event) -> "PlaceSettings": def current(cls, event: Event) -> "PlaceSettings":
obj, _ = cls.objects.get_or_create(event=event) obj, _ = cls.objects.get_or_create(event=event)
-48
View File
@@ -1,48 +0,0 @@
from datetime import datetime
from ninja import Schema
class PlayerCountOut(Schema):
username: str
placements: int
class PixelCountOut(Schema):
x: int
y: int
placements: int
players: int
class ColorCountOut(Schema):
color_index: int
color: str
placements: int
# How many pixels of the live board currently have this color
on_canvas: int
class HourCountOut(Schema):
hour: datetime
placements: int
players: int
class PlaceStatsOut(Schema):
width: int
height: int
total_placements: int
total_players: int
first_placement_at: datetime | None
last_placement_at: datetime | None
# Distinct pixels placed at least once (within the current grid)
pixels_touched: int
coverage: float
# Placements that landed on an already placed pixel
overwrites: int
avg_placements_per_player: float
top_players: list[PlayerCountOut]
most_contested_pixels: list[PixelCountOut]
colors: list[ColorCountOut]
per_hour: list[HourCountOut]
+2 -11
View File
@@ -1,14 +1,13 @@
"""Reacts to an admin editing a PlaceSettings row (grid size, end time) by: """Reacts to an admin editing a PlaceSettings row (grid size) by:
1. Rebuilding that event's Redis canvas from history at the (possibly new) 1. Rebuilding that event's Redis canvas from history at the (possibly new)
dimensions - a resize invalidates the old Redis bytes (wrong length, dimensions - a resize invalidates the old Redis bytes (wrong length,
wrong meaning), so this keeps Redis self-healing instead of relying on wrong meaning), so this keeps Redis self-healing instead of relying on
the admin remembering to click "Resync canvas from DB" after every the admin remembering to click "Resync canvas from DB" after every
resize. resize.
2. Pushing the new settings to that event's already-connected clients. Unlike 2. Pushing the new size to that event's already-connected clients. Unlike
the clock app, place has no periodic heartbeat broadcast to piggyback the clock app, place has no periodic heartbeat broadcast to piggyback
this onto, so it's broadcast explicitly here instead. this onto, so it's broadcast explicitly here instead.
3. Queueing the wipe for the end time, if any (see tasks.py).
""" """
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
@@ -19,14 +18,11 @@ from django.dispatch import receiver
from .broadcast import place_group from .broadcast import place_group
from .canvas import rebuild_canvas_from_history from .canvas import rebuild_canvas_from_history
from .models import PlaceSettings from .models import PlaceSettings
from .tasks import schedule_wipe
from .wire import to_epoch_ms
@receiver(post_save, sender=PlaceSettings) @receiver(post_save, sender=PlaceSettings)
def on_settings_saved(sender, instance, **kwargs): def on_settings_saved(sender, instance, **kwargs):
rebuild_canvas_from_history(instance.event, instance.width, instance.height) rebuild_canvas_from_history(instance.event, instance.width, instance.height)
schedule_wipe(instance)
channel_layer = get_channel_layer() channel_layer = get_channel_layer()
if channel_layer is None: if channel_layer is None:
@@ -37,10 +33,5 @@ def on_settings_saved(sender, instance, **kwargs):
"type": "settings_changed", "type": "settings_changed",
"width": instance.width, "width": instance.width,
"height": instance.height, "height": instance.height,
"ends_at": (
None
if instance.placement_ends_at is None
else to_epoch_ms(instance.placement_ends_at)
),
}, },
) )
-97
View File
@@ -1,97 +0,0 @@
"""Aggregated stats about one event's place game, for the admin stats
endpoint. Everything comes from the durable PixelPlacement history, except
the colors currently on the board, which are read off the live Redis canvas.
"""
from django.db.models import Count, Max, Min
from django.db.models.functions import TruncHour
from events.models import Event
from .canvas import read_canvas
from .models import PALETTE, PixelPlacement, PlaceSettings
TOP_N = 10
def build_place_stats(event: Event) -> dict:
place_settings = PlaceSettings.current(event)
width, height = place_settings.width, place_settings.height
placements = PixelPlacement.objects.filter(event=event)
totals = placements.aggregate(
total=Count("id"),
players=Count("player", distinct=True),
first=Min("placed_at"),
last=Max("placed_at"),
)
total, players = totals["total"], totals["players"]
# Same as heatmap.py, placements outside the current grid are skipped
in_grid = placements.filter(x__lt=width, y__lt=height)
pixels_touched = in_grid.values("x", "y").distinct().count()
top_players = (
placements.values("player__username")
.annotate(count=Count("id"))
.order_by("-count", "player__username")[:TOP_N]
)
contested = (
in_grid.values("x", "y")
.annotate(count=Count("id"), players=Count("player", distinct=True))
.order_by("-count", "-players", "y", "x")[:TOP_N]
)
color_counts = dict(
placements.values_list("color_index")
.annotate(count=Count("id"))
.order_by()
)
# Redis only pads up to the last written pixel, and keeps old bytes
# after a shrink, so normalize to the current grid size
size = width * height
canvas = read_canvas(event.path, width, height)[:size].ljust(size, b"\0")
per_hour = (
placements.annotate(hour=TruncHour("placed_at"))
.values("hour")
.annotate(count=Count("id"), players=Count("player", distinct=True))
.order_by("hour")
)
return {
"width": width,
"height": height,
"total_placements": total,
"total_players": players,
"first_placement_at": totals["first"],
"last_placement_at": totals["last"],
"pixels_touched": pixels_touched,
"coverage": pixels_touched / size if size else 0.0,
"overwrites": in_grid.count() - pixels_touched,
"avg_placements_per_player": total / players if players else 0.0,
"top_players": [
{"username": row["player__username"], "placements": row["count"]}
for row in top_players
],
"most_contested_pixels": [
{
"x": row["x"],
"y": row["y"],
"placements": row["count"],
"players": row["players"],
}
for row in contested
],
"colors": [
{
"color_index": index,
"color": color,
"placements": color_counts.get(index, 0),
"on_canvas": canvas.count(index),
}
for index, color in enumerate(PALETTE)
],
"per_hour": [
{"hour": row["hour"], "placements": row["count"], "players": row["players"]}
for row in per_hour
],
}
-65
View File
@@ -1,65 +0,0 @@
"""Ends the game at PlaceSettings.placement_ends_at: a task queued to run at
that time broadcasts the wipe to everyone connected, so they all see the
board cleared at the same moment (no client-side timer to drift).
The wipe is only visual. Nothing is deleted, the Redis canvas still holds the
final board (admin stats, heatmaps, resync keep working), it's just that the
canvas endpoint serves a blank board once placement is over (see api.py).
"""
from datetime import timedelta
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.core.cache import cache
from django.tasks import task
from django.utils import timezone
from .broadcast import place_group
from .models import PlaceSettings
from .wire import to_epoch_ms
# Kept a bit past the end time, so re-saving the settings with the same end
# time (or with only the size changed) doesn't queue the same wipe twice.
WIPE_GUARD_MARGIN = timedelta(hours=1)
def _wipe_guard_key(settings_id: int, ends_at_ms: int) -> str:
return f"place:wipe:{settings_id}:{ends_at_ms}"
def schedule_wipe(place_settings: PlaceSettings) -> bool:
"""Queue the wipe for this end time, unless it's already queued or the end
time is already past. Safe to call on every save."""
ends_at = place_settings.placement_ends_at
if ends_at is None or ends_at <= timezone.now():
return False
ends_at_ms = to_epoch_ms(ends_at)
timeout = int((ends_at - timezone.now() + WIPE_GUARD_MARGIN).total_seconds())
if not cache.add(_wipe_guard_key(place_settings.id, ends_at_ms), True, timeout):
return False
wipe_canvas_task.using(run_after=ends_at).enqueue(place_settings.id, ends_at_ms)
return True
@task
def wipe_canvas_task(settings_id: int, ends_at_ms: int):
"""Broadcast the wipe wave, if `ends_at_ms` is still the event's end time -
an admin moving or clearing it leaves the old job queued, it just stops
here."""
place_settings = (
PlaceSettings.objects.filter(id=settings_id).select_related("event").first()
)
if place_settings is None or place_settings.placement_ends_at is None:
return
if to_epoch_ms(place_settings.placement_ends_at) != ends_at_ms:
return
channel_layer = get_channel_layer()
if channel_layer is None:
return
async_to_sync(channel_layer.group_send)(
place_group(place_settings.event.path), {"type": "wipe"}
)
-210
View File
@@ -1,210 +0,0 @@
from datetime import timedelta
from io import BytesIO
from unittest import mock
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.utils import timezone
from PIL import Image
from accounts.models import CustomUser
from events.models import Event
from games.models import EventGame, Game
from .models import PixelPlacement, PlaceSettings
from .tasks import schedule_wipe, wipe_canvas_task
from .wire import to_epoch_ms
URL = "/api/events/event/place/stats"
CANVAS_URL = "/api/events/event/place/canvas"
FINAL_URL = "/api/events/event/place/final.png"
LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
@mock.patch("place.stats.read_canvas")
class PlaceStatsTests(TestCase):
def setUp(self):
self.event = Event.objects.create(name="Event", path="event")
place = Game.objects.create(name="Place", path="place", custom_path=True)
EventGame.objects.create(event=self.event, game=place)
PlaceSettings.objects.create(event=self.event, width=4, height=2)
self.alice = CustomUser.objects.create_user(username="alice", password="x")
self.bob = CustomUser.objects.create_user(username="bob", password="x")
self.admin = CustomUser.objects.create_user(
username="admin", password="x", is_staff=True
)
def place(self, player, x, y, color_index):
PixelPlacement.objects.create(
event=self.event, player=player, x=x, y=y, color_index=color_index
)
def test_requires_staff(self, _):
self.client.force_login(self.alice)
response = self.client.get(URL)
self.assertEqual(response.status_code, 401)
def test_empty_game(self, read_canvas):
read_canvas.return_value = b""
self.client.force_login(self.admin)
data = self.client.get(URL).json()
self.assertEqual(data["total_placements"], 0)
self.assertEqual(data["total_players"], 0)
self.assertEqual(data["coverage"], 0.0)
self.assertEqual(data["avg_placements_per_player"], 0.0)
self.assertEqual(data["colors"][0]["on_canvas"], 8)
self.assertEqual(data["per_hour"], [])
def test_stats(self, read_canvas):
self.place(self.alice, 0, 0, 5)
self.place(self.bob, 0, 0, 3)
self.place(self.alice, 0, 0, 5)
self.place(self.alice, 1, 0, 3)
# Outside the current grid, e.g. from before a shrink
self.place(self.bob, 9, 9, 3)
# Short canvas, Redis only padded up to the last written pixel
read_canvas.return_value = bytes([5, 3])
self.client.force_login(self.admin)
data = self.client.get(URL).json()
self.assertEqual(data["total_placements"], 5)
self.assertEqual(data["total_players"], 2)
self.assertEqual(data["pixels_touched"], 2)
self.assertEqual(data["coverage"], 0.25)
self.assertEqual(data["overwrites"], 2)
self.assertEqual(data["avg_placements_per_player"], 2.5)
self.assertEqual(
data["top_players"],
[
{"username": "alice", "placements": 3},
{"username": "bob", "placements": 2},
],
)
self.assertEqual(
data["most_contested_pixels"][0],
{"x": 0, "y": 0, "placements": 3, "players": 2},
)
colors = {row["color_index"]: row for row in data["colors"]}
self.assertEqual(colors[5]["placements"], 2)
self.assertEqual(colors[3]["placements"], 3)
self.assertEqual(colors[5]["on_canvas"], 1)
self.assertEqual(colors[0]["on_canvas"], 6)
self.assertEqual(sum(row["placements"] for row in data["per_hour"]), 5)
@mock.patch("place.export.read_canvas")
class FinalPngTests(TestCase):
def setUp(self):
self.event = Event.objects.create(name="Event", path="event")
place = Game.objects.create(name="Place", path="place", custom_path=True)
EventGame.objects.create(event=self.event, game=place)
PlaceSettings.objects.create(
event=self.event,
width=2,
height=1,
# Ended, the real board is still exported
placement_ends_at=timezone.now() - timedelta(hours=1),
)
self.alice = CustomUser.objects.create_user(username="alice", password="x")
self.admin = CustomUser.objects.create_user(
username="admin", password="x", is_staff=True
)
def test_requires_staff(self, _):
self.client.force_login(self.alice)
response = self.client.get(FINAL_URL)
self.assertEqual(response.status_code, 401)
def test_final_png(self, read_canvas):
# Short canvas, Redis only padded up to the last written pixel
read_canvas.return_value = bytes([5])
self.client.force_login(self.admin)
response = self.client.get(FINAL_URL)
self.assertEqual(response["Content-Type"], "image/png")
image = Image.open(BytesIO(response.content))
self.assertEqual(image.size, (16, 8))
self.assertEqual(image.getpixel((0, 0)), (229, 0, 0))
self.assertEqual(image.getpixel((15, 0)), (255, 255, 255))
@override_settings(CACHES=LOCMEM_CACHE)
@mock.patch("place.signals.rebuild_canvas_from_history")
class PlacementEndTests(TestCase):
def setUp(self):
cache.clear()
self.event = Event.objects.create(name="Event", path="event")
place = Game.objects.create(name="Place", path="place", custom_path=True)
EventGame.objects.create(event=self.event, game=place)
self.alice = CustomUser.objects.create_user(username="alice", password="x")
def settings(self, ends_at):
with mock.patch("place.signals.schedule_wipe"):
return PlaceSettings.objects.create(
event=self.event, width=2, height=1, placement_ends_at=ends_at
)
@mock.patch("place.api.read_canvas")
def test_canvas_is_blank_once_ended(self, read_canvas, _):
read_canvas.return_value = bytes([5, 3])
place_settings = self.settings(timezone.now() + timedelta(hours=1))
self.client.force_login(self.alice)
self.assertEqual(self.client.get(CANVAS_URL).content[4:], bytes([5, 3]))
place_settings.placement_ends_at = timezone.now() - timedelta(seconds=1)
with mock.patch("place.signals.schedule_wipe"):
place_settings.save()
self.assertEqual(self.client.get(CANVAS_URL).content[4:], bytes(2))
@mock.patch("place.tasks.wipe_canvas_task")
def test_wipe_is_scheduled_once_per_end_time(self, task, _):
ends_at = timezone.now() + timedelta(hours=1)
place_settings = self.settings(ends_at)
self.assertTrue(schedule_wipe(place_settings))
self.assertFalse(schedule_wipe(place_settings))
task.using.assert_called_once_with(run_after=ends_at)
task.using.return_value.enqueue.assert_called_once_with(
place_settings.id, to_epoch_ms(ends_at)
)
@mock.patch("place.tasks.wipe_canvas_task")
def test_no_wipe_without_future_end(self, task, _):
self.assertFalse(schedule_wipe(self.settings(None)))
past = PlaceSettings(placement_ends_at=timezone.now() - timedelta(seconds=1))
self.assertFalse(schedule_wipe(past))
task.using.assert_not_called()
@mock.patch("place.tasks.get_channel_layer")
def test_task_broadcasts_wipe(self, get_channel_layer, _):
ends_at = timezone.now()
place_settings = self.settings(ends_at)
layer = get_channel_layer.return_value
layer.group_send = mock.AsyncMock()
wipe_canvas_task.call(place_settings.id, to_epoch_ms(ends_at))
layer.group_send.assert_awaited_once_with("place_room_event", {"type": "wipe"})
@mock.patch("place.tasks.get_channel_layer")
def test_task_skips_moved_end_time(self, get_channel_layer, _):
ends_at = timezone.now()
place_settings = self.settings(ends_at + timedelta(hours=1))
layer = get_channel_layer.return_value
layer.group_send = mock.AsyncMock()
wipe_canvas_task.call(place_settings.id, to_epoch_ms(ends_at))
layer.group_send.assert_not_awaited()
+6 -14
View File
@@ -6,10 +6,8 @@ either). Each message is a short string: a one-letter type tag followed by
'|'-separated fields, in a fixed order the client already knows from this '|'-separated fields, in a fixed order the client already knows from this
table: table:
s|<next>|<width>|<height>|<end> s|<next>|<width>|<height> initial personal state, once per connection
initial personal state, once per connection (next = epoch ms cooldown ends, or empty if free now)
(next = epoch ms cooldown ends, or empty if free now;
end = epoch ms placement ends, or empty if never)
p|<x>|<y>|<c> a pixel was placed - broadcast to everyone, p|<x>|<y>|<c> a pixel was placed - broadcast to everyone,
including the placer (no separate ack needed) including the placer (no separate ack needed)
k|<next> your own placement was accepted; carries your k|<next> your own placement was accepted; carries your
@@ -17,16 +15,10 @@ table:
r|c|<next> rejected: still on cooldown r|c|<next> rejected: still on cooldown
r|a rejected: not logged in r|a rejected: not logged in
r|i rejected: invalid x/y/color r|i rejected: invalid x/y/color
r|d rejected: placement is over (past <end>) e|<width>|<height> an admin resized the grid for this event -
e|<width>|<height>|<end> broadcast to everyone. A client that sees this
an admin edited this event's settings (grid size should re-fetch /api/.../place/canvas rather than
and/or end time) - broadcast to everyone. try to remap its local pixels.
If the size changed, a client should re-fetch
/api/.../place/canvas rather than try to remap its
local pixels.
w placement just ended - broadcast to everyone at
<end> (see tasks.py). Clients play the wipe wave
and blank their board.
Client -> server has exactly one message shape, the same as the broadcast: Client -> server has exactly one message shape, the same as the broadcast:
p|<x>|<y>|<c> p|<x>|<y>|<c>
-74
View File
@@ -11,15 +11,6 @@ importers:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.1.16 specifier: ^4.1.16
version: 4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4)) version: 4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4))
'@tanstack/query-persist-client-core':
specifier: ^5.104.3
version: 5.104.3
'@tanstack/query-sync-storage-persister':
specifier: ^5.104.3
version: 5.104.3
'@tanstack/vue-query':
specifier: ^5.104.3
version: 5.104.3(vue@3.5.22(typescript@5.9.3))
'@tanstack/vue-table': '@tanstack/vue-table':
specifier: ^8.21.3 specifier: ^8.21.3
version: 8.21.3(vue@3.5.22(typescript@5.9.3)) version: 8.21.3(vue@3.5.22(typescript@5.9.3))
@@ -524,32 +515,10 @@ packages:
peerDependencies: peerDependencies:
vite: ^5.2.0 || ^6 || ^7 vite: ^5.2.0 || ^6 || ^7
'@tanstack/match-sorter-utils@9.2.6':
resolution: {integrity: sha512-DIpUSOhPfQ7u/qfUi/Wgm0wBO56MyBYXGvnlI0NcauDRPqdBUlAZZjW2Dxm92m+k1UWVhKw8YJ4f47+75Aujyw==}
engines: {node: '>=20'}
'@tanstack/query-core@5.104.3':
resolution: {integrity: sha512-DZY9TuJAOgU3UyHUhXb4CcTYEHNZszlwkPg4VViIwTxOKzLprPUU+rbaPZQd3srqkGOtpaDdKpW972UV/LKlcg==}
'@tanstack/query-persist-client-core@5.104.3':
resolution: {integrity: sha512-6nhc/QbMpkKeuAM81SFSdcsJ9B8dm3fGHPODbau2cRSDyqcXiSl7sXQM7POVktch/YdgIGYPIQyZOQQaRarHMA==}
'@tanstack/query-sync-storage-persister@5.104.3':
resolution: {integrity: sha512-sNM9X7QZdTHBlzHuNxuHQjECn3/zncuMgw5pesOFXu8XQ01UXBGASdMKZSRaxfNOhigRjm5souHhfmByN97NEg==}
'@tanstack/table-core@8.21.3': '@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'} engines: {node: '>=12'}
'@tanstack/vue-query@5.104.3':
resolution: {integrity: sha512-bdVlhyEFPllLfHioHx8kwtCAQ1YJQrvYczlsNzyiYFsp6n5yz9isNXiy6MIu+QGmG+RyghVdnp1UmiNDqLS22g==}
peerDependencies:
'@vue/composition-api': ^1.1.2
vue: ^2.6.0 || ^3.3.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
'@tanstack/vue-table@8.21.3': '@tanstack/vue-table@8.21.3':
resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==} resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -992,9 +961,6 @@ packages:
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==}
remove-accents@0.5.0:
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
resolve-pkg-maps@1.0.0: resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@@ -1106,17 +1072,6 @@ packages:
vscode-uri@3.1.0: vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-i18n@11.4.12: vue-i18n@11.4.12:
resolution: {integrity: sha512-yvArvvo3ErLKFNlQrZUGmRkxL/ctM5xFYtE9FxTKiE/PalWP1bnTopBgbSRGLluYEyYSNl1Pt8l2J7zVavbXww==} resolution: {integrity: sha512-yvArvvo3ErLKFNlQrZUGmRkxL/ctM5xFYtE9FxTKiE/PalWP1bnTopBgbSRGLluYEyYSNl1Pt8l2J7zVavbXww==}
engines: {node: '>= 22'} engines: {node: '>= 22'}
@@ -1490,31 +1445,8 @@ snapshots:
tailwindcss: 4.1.16 tailwindcss: 4.1.16
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4) vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4)
'@tanstack/match-sorter-utils@9.2.6':
dependencies:
remove-accents: 0.5.0
'@tanstack/query-core@5.104.3': {}
'@tanstack/query-persist-client-core@5.104.3':
dependencies:
'@tanstack/query-core': 5.104.3
'@tanstack/query-sync-storage-persister@5.104.3':
dependencies:
'@tanstack/query-core': 5.104.3
'@tanstack/query-persist-client-core': 5.104.3
'@tanstack/table-core@8.21.3': {} '@tanstack/table-core@8.21.3': {}
'@tanstack/vue-query@5.104.3(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@tanstack/match-sorter-utils': 9.2.6
'@tanstack/query-core': 5.104.3
'@vue/devtools-api': 6.6.4
vue: 3.5.22(typescript@5.9.3)
vue-demi: 0.14.10(vue@3.5.22(typescript@5.9.3))
'@tanstack/vue-table@8.21.3(vue@3.5.22(typescript@5.9.3))': '@tanstack/vue-table@8.21.3(vue@3.5.22(typescript@5.9.3))':
dependencies: dependencies:
'@tanstack/table-core': 8.21.3 '@tanstack/table-core': 8.21.3
@@ -1931,8 +1863,6 @@ snapshots:
regenerator-runtime@0.13.11: {} regenerator-runtime@0.13.11: {}
remove-accents@0.5.0: {}
resolve-pkg-maps@1.0.0: {} resolve-pkg-maps@1.0.0: {}
rfdc@1.4.1: {} rfdc@1.4.1: {}
@@ -2032,10 +1962,6 @@ snapshots:
vscode-uri@3.1.0: {} vscode-uri@3.1.0: {}
vue-demi@0.14.10(vue@3.5.22(typescript@5.9.3)):
dependencies:
vue: 3.5.22(typescript@5.9.3)
vue-i18n@11.4.12(vue@3.5.22(typescript@5.9.3)): vue-i18n@11.4.12(vue@3.5.22(typescript@5.9.3)):
dependencies: dependencies:
'@intlify/core-base': 11.4.12 '@intlify/core-base': 11.4.12
+2 -2
View File
@@ -57,14 +57,14 @@ api.add_router("", market_router, tags=["market"])
# Health check endpoint # Health check endpoint
@api.get("/health", response=dict) @api.get("/health")
def health_check(request): def health_check(request):
"""Health check endpoint""" """Health check endpoint"""
return {"status": "healthy", "service": "polylan-submitter-api"} return {"status": "healthy", "service": "polylan-submitter-api"}
# Cache management endpoint # Cache management endpoint
@api.post("/cache/clear", response={200: dict, 403: dict}) @api.post("/cache/clear")
def clear_cache(request: HttpRequest, event_path: str | None = None): def clear_cache(request: HttpRequest, event_path: str | None = None):
"""Clear API caches (admin only). Pass event_path to clear just that event's keys.""" """Clear API caches (admin only). Pass event_path to clear just that event's keys."""
if not request.user.is_authenticated or not request.user.is_staff: if not request.user.is_authenticated or not request.user.is_staff:
@@ -64,26 +64,6 @@ ALLOWED_HOSTS = [
if host if host
] ]
# TLS is terminated by the reverse proxy in front of the container, so daphne
# only ever sees plain http. Without these, Django compares the browser's
# `Origin: https://...` against `http://...` and rejects every POST (admin
# login included) with "CSRF verification failed - Origin checking failed".
# Full origins with scheme, e.g. "https://submitter.polylan.ch".
CSRF_TRUSTED_ORIGINS = [
origin
for origin in (os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS") or "").split(",")
if origin
]
# Trust the proxy's X-Forwarded-Proto so request.is_secure() /
# build_absolute_uri() report https. Only safe if the proxy overwrites that
# header (client-supplied values must not pass through) - opt-in for that
# reason.
if os.environ.get("DJANGO_BEHIND_TLS_PROXY", "false").lower() == "true":
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
# Application definition # Application definition
@@ -402,6 +382,6 @@ sentry_sdk.init(
dsn=SENTRY_DSN, dsn=SENTRY_DSN,
# Set traces_sample_rate to 1.0 to capture 100% # Set traces_sample_rate to 1.0 to capture 100%
# of transactions for tracing. # of transactions for tracing.
traces_sample_rate=0.1, traces_sample_rate=1.0,
environment=SENTRY_ENVIRONMENT, environment=SENTRY_ENVIRONMENT,
) )
@@ -56,12 +56,6 @@ def games_home(request: HttpRequest, event_path: str):
return render(request, "app.html") return render(request, "app.html")
@login_required
def planning_home(request: HttpRequest, event_path: str):
get_object_or_404(Event, path=event_path)
return render(request, "app.html")
@login_required @login_required
@require_game_enabled(OPUS_MAGNUM_APP_ID) @require_game_enabled(OPUS_MAGNUM_APP_ID)
def opus_magnum_home(request: HttpRequest, event_path: str): def opus_magnum_home(request: HttpRequest, event_path: str):
@@ -108,7 +102,6 @@ urlpatterns = [
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"), path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
path("api/", api.urls), path("api/", api.urls),
path("<slug:event_path>/games", games_home, name="games.home"), path("<slug:event_path>/games", games_home, name="games.home"),
path("<slug:event_path>/planning", planning_home, name="planning.home"),
path("<slug:event_path>/market", market_home, name="market.home"), path("<slug:event_path>/market", market_home, name="market.home"),
path("<slug:event_path>/opus-magnum", opus_magnum_home, name="opus-magnum.home"), path("<slug:event_path>/opus-magnum", opus_magnum_home, name="opus-magnum.home"),
path("<slug:event_path>/noita", noita_home, name="noita.home"), path("<slug:event_path>/noita", noita_home, name="noita.home"),
+7 -1
View File
@@ -1,14 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed, onMounted } from "vue";
import { RouterView, useRoute } from "vue-router"; import { RouterView, useRoute } from "vue-router";
import AppHeader from "@/layout/AppHeader.vue"; import AppHeader from "@/layout/AppHeader.vue";
import AppFooter from "@/layout/AppFooter.vue"; import AppFooter from "@/layout/AppFooter.vue";
import { useUserStore } from "@/stores/user";
const userStore = useUserStore();
const route = useRoute(); const route = useRoute();
// Embed routes (e.g. place/embed) are meant to be dropped into an <iframe> // Embed routes (e.g. place/embed) are meant to be dropped into an <iframe>
// as-is - the header/footer chrome would just eat into the fixed space a // as-is - the header/footer chrome would just eat into the fixed space a
// stream overlay or OBS browser source gives it. // stream overlay or OBS browser source gives it.
const isEmbed = computed(() => route.meta.embed === true); const isEmbed = computed(() => route.meta.embed === true);
onMounted(() => {
userStore.loadUserData();
});
</script> </script>
<template> <template>
+4 -2
View File
@@ -3,7 +3,6 @@ import { computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import { useClockSocket } from "@/composables/useClockSocket"; import { useClockSocket } from "@/composables/useClockSocket";
import { useLiveBadge } from "@/composables/useLiveBadge";
interface Props { interface Props {
eventPath: string; eventPath: string;
@@ -26,7 +25,6 @@ const {
cooldownDurationSeconds, cooldownDurationSeconds,
press, press,
} = useClockSocket(props.eventPath); } = useClockSocket(props.eventPath);
useLiveBadge("clock", connected);
const remainingDisplay = computed(() => { const remainingDisplay = computed(() => {
const totalSeconds = Math.floor(remainingMs.value / 1000); const totalSeconds = Math.floor(remainingMs.value / 1000);
@@ -40,6 +38,10 @@ const onCooldown = computed(() => cooldownRemainingMs.value > 0);
<template> <template>
<div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-8"> <div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-8">
<span class="badge" :class="connected ? 'badge-success' : 'badge-error'">
{{ connected ? t("clock.connected") : t("clock.reconnecting") }}
</span>
<div class="font-bold text-7xl tabular-nums"> <div class="font-bold text-7xl tabular-nums">
{{ remainingDisplay }} {{ remainingDisplay }}
</div> </div>
+4 -19
View File
@@ -1,10 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from "vue"; import { ref, onMounted, computed } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useStorage, useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import { gamesApiListEventSteamGames } from "./api"; import { gamesApiListEventSteamGames } from "./api";
import type { SteamGameOut } from "./api/types.gen"; import type { SteamGameOut } from "./api/types.gen";
import SteamLibraryCard from "./components/SteamLibraryCard.vue";
import SteamStoreCard from "./components/SteamStoreCard.vue"; import SteamStoreCard from "./components/SteamStoreCard.vue";
interface Props { interface Props {
@@ -18,8 +17,6 @@ useTitle(computed(() => t("games.title")));
const games = ref<SteamGameOut[]>([]); const games = ref<SteamGameOut[]>([]);
const loading = ref(true); const loading = ref(true);
// Library (vertical covers) by default, the store cards show the prices
const showPrices = useStorage("games.showPrices", true);
onMounted(async () => { onMounted(async () => {
const response = await gamesApiListEventSteamGames({ const response = await gamesApiListEventSteamGames({
@@ -34,20 +31,13 @@ onMounted(async () => {
<template> <template>
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12"> <div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
<div class="w-full max-w-7xl"> <div class="w-full max-w-6xl">
<!-- Header --> <!-- Header -->
<div class="text-center mb-8"> <div class="text-center mb-12">
<h1 class="text-5xl font-bold mb-4">{{ t("games.title") }}</h1> <h1 class="text-5xl font-bold mb-4">{{ t("games.title") }}</h1>
<p class="text-xl text-base-content/70">{{ t("games.subtitle") }}</p> <p class="text-xl text-base-content/70">{{ t("games.subtitle") }}</p>
</div> </div>
<div class="flex justify-end mb-6">
<label class="label cursor-pointer gap-2">
<span>{{ t("games.showPrices") }}</span>
<input v-model="showPrices" type="checkbox" class="toggle toggle-primary" />
</label>
</div>
<!-- Loading --> <!-- Loading -->
<div v-if="loading" class="flex justify-center py-20"> <div v-if="loading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg"></span>
@@ -57,14 +47,9 @@ onMounted(async () => {
{{ t("games.empty") }} {{ t("games.empty") }}
</p> </p>
<div v-else-if="showPrices" class="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8">
<SteamStoreCard v-for="game in games" :key="game.id" :game="game" /> <SteamStoreCard v-for="game in games" :key="game.id" :game="game" />
</div> </div>
<div v-else
class="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] sm:grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-4 sm:gap-6">
<SteamLibraryCard v-for="game in games" :key="game.id" :game="game" />
</div>
</div> </div>
</div> </div>
</template> </template>
+17 -14
View File
@@ -1,10 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from "vue"; import { ref, onMounted, computed } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import { useQuery } from "@tanstack/vue-query"; import { gamesApiListEventGames } from "./api";
import { eventGamesQuery, eventPlanningQuery } from "@/queries";
import type { GamesApiListEventGamesResponse } from "./api/types.gen"; import type { GamesApiListEventGamesResponse } from "./api/types.gen";
interface Props { interface Props {
@@ -17,10 +16,9 @@ const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
useTitle(computed(() => t("home.title"))); useTitle(computed(() => t("home.title")));
const { data: games, isPending: loading } = useQuery(eventGamesQuery(props.eventPath)); const games = ref<GamesApiListEventGamesResponse | undefined>();
const loading = ref(true);
const hasSteamGames = computed(() => games.value?.some((game) => game.steam_app_id) ?? false); const hasSteamGames = computed(() => games.value?.some((game) => game.steam_app_id) ?? false);
const { data: planning } = useQuery(eventPlanningQuery(props.eventPath));
const hasPlanning = computed(() => !!planning.value?.length);
const imageErrors = ref<Set<number>>(new Set()); const imageErrors = ref<Set<number>>(new Set());
const getHeaderImage = (game: GamesApiListEventGamesResponse[number]) => { const getHeaderImage = (game: GamesApiListEventGamesResponse[number]) => {
@@ -42,11 +40,20 @@ const goToGame = (game: GamesApiListEventGamesResponse[number]) => {
router.push(`/${props.eventPath}${path}`); router.push(`/${props.eventPath}${path}`);
}; };
onMounted(async () => {
const gamesResponse = await gamesApiListEventGames({
path: { event_path: props.eventPath },
});
if (gamesResponse.data) {
games.value = gamesResponse.data;
}
loading.value = false;
});
</script> </script>
<template> <template>
<div class="flex-1 bg-base-300 flex items-center justify-center px-4"> <div class="flex-1 bg-base-300 flex items-center justify-center px-4">
<div class="w-full max-w-7xl"> <div class="w-full max-w-6xl">
<!-- Header --> <!-- Header -->
<div class="text-center mb-12"> <div class="text-center mb-12">
<h1 class="text-5xl font-bold mb-4">{{ t("home.title") }}</h1> <h1 class="text-5xl font-bold mb-4">{{ t("home.title") }}</h1>
@@ -88,13 +95,9 @@ const goToGame = (game: GamesApiListEventGamesResponse[number]) => {
</div> </div>
</div> </div>
<!-- Links to the planning and the Steam games page --> <!-- Link to the Steam games page -->
<div v-if="!loading && (hasPlanning || hasSteamGames)" class="flex flex-wrap justify-center gap-4 mt-12"> <div v-if="!loading && hasSteamGames" class="text-center mt-12">
<RouterLink v-if="hasPlanning" :to="{ name: 'planning', params: { eventPath } }" class="btn btn-outline"> <RouterLink :to="{ name: 'games', params: { eventPath } }" class="btn btn-outline">
<i class="mdi mdi-calendar-clock mr-2"></i>
{{ t("home.planning") }}
</RouterLink>
<RouterLink v-if="hasSteamGames" :to="{ name: 'games', params: { eventPath } }" class="btn btn-outline">
<i class="mdi mdi-steam mr-2"></i> <i class="mdi mdi-steam mr-2"></i>
{{ t("home.getTheGames") }} {{ t("home.getTheGames") }}
</RouterLink> </RouterLink>
+8 -3
View File
@@ -9,11 +9,10 @@ import Results from "@/components/Results.vue";
import Winners from "@/components/Winners.vue"; import Winners from "@/components/Winners.vue";
import PuzzleResults from "@/components/PuzzleResults.vue"; import PuzzleResults from "@/components/PuzzleResults.vue";
import TopUsersLeaderboard from "@/components/TopUsersLeaderboard.vue"; import TopUsersLeaderboard from "@/components/TopUsersLeaderboard.vue";
import { opusMagnumApiGetCollection } from "@/api"; import { polylanSubmitterApiGetUserInfo, opusMagnumApiGetCollection } from "@/api";
import { apiService, errorHelpers } from "@/services/apiService"; import { apiService, 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 { queryClient, userQuery } from "@/queries";
import { provideEventPath } from "@/composables/useEventPath"; import { provideEventPath } from "@/composables/useEventPath";
import type { PuzzleResponse, UserInfo, SteamCollection } from "@/types"; import type { PuzzleResponse, UserInfo, SteamCollection } from "@/types";
import { useCountdown } from "@vueuse/core"; import { useCountdown } from "@vueuse/core";
@@ -75,7 +74,13 @@ async function initialize() {
// Load user info // Load user info
console.log("Loading user info..."); console.log("Loading user info...");
userInfo.value = (await queryClient.fetchQuery(userQuery()).catch(() => null)) as UserInfo | null; const userResponse = await polylanSubmitterApiGetUserInfo();
if (userResponse.data) {
userInfo.value = userResponse.data as UserInfo;
console.log("User info loaded:", userResponse.data);
} else if (userResponse.error) {
console.warn("User info error:", userResponse.error);
}
// Load collection data // Load collection data
console.log("Loading collection..."); console.log("Loading collection...");
+20 -45
View File
@@ -1,11 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import { usePlaceSocket } from "@/composables/usePlaceSocket"; import { usePlaceSocket } from "@/composables/usePlaceSocket";
import { useLiveBadge } from "@/composables/useLiveBadge";
import { PALETTE } from "@/palette"; import { PALETTE } from "@/palette";
import { playWipeWave } from "@/placeWipe";
interface Props { interface Props {
eventPath: string; eventPath: string;
@@ -41,39 +39,20 @@ function drawPixel(x: number, y: number, colorIndex: number) {
pixelCtx.fillRect(x, y, 1, 1); pixelCtx.fillRect(x, y, 1, 1);
} }
let cancelWipe: (() => void) | null = null;
function wipe() {
if (!pixelCtx) return;
cancelWipe?.();
cancelWipe = playWipeWave(pixelCtx, gridWidth.value, gridHeight.value);
}
onBeforeUnmount(() => cancelWipe?.());
const { const {
connected, connected,
cooldownRemainingMs, cooldownRemainingMs,
placementEndMs,
placementEnded,
cooldownDurationSeconds, cooldownDurationSeconds,
gridWidth, gridWidth,
gridHeight, gridHeight,
place, place,
} = usePlaceSocket(props.eventPath, drawPixel, undefined, wipe); } = usePlaceSocket(props.eventPath, drawPixel);
useLiveBadge("place", connected);
const overlayWidth = computed(() => gridWidth.value * PX_PER_CELL); const overlayWidth = computed(() => gridWidth.value * PX_PER_CELL);
const overlayHeight = computed(() => gridHeight.value * PX_PER_CELL); const overlayHeight = computed(() => gridHeight.value * PX_PER_CELL);
const cooldownSecondsDisplay = computed(() => (cooldownRemainingMs.value / 1000).toFixed(1)); const cooldownSecondsDisplay = computed(() => (cooldownRemainingMs.value / 1000).toFixed(1));
const onCooldown = computed(() => cooldownRemainingMs.value > 0); const onCooldown = computed(() => cooldownRemainingMs.value > 0);
const canPlace = computed(() => !placementEnded.value && !onCooldown.value);
const placementEndDisplay = computed(() =>
placementEndMs.value !== null
? new Date(placementEndMs.value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
: null,
);
// The overlay canvas never touches the pixel colors - it only draws grid // The overlay canvas never touches the pixel colors - it only draws grid
// lines plus a preview of the hovered cell, so it can freely redraw on // lines plus a preview of the hovered cell, so it can freely redraw on
@@ -105,7 +84,7 @@ function drawOverlay() {
} }
} }
if (hoveredCell.value && canPlace.value) { if (hoveredCell.value && !onCooldown.value) {
const { x, y } = hoveredCell.value; const { x, y } = hoveredCell.value;
gridCtx.fillStyle = `${PALETTE[selectedColorIndex.value]}80`; gridCtx.fillStyle = `${PALETTE[selectedColorIndex.value]}80`;
gridCtx.fillRect(x * PX_PER_CELL, y * PX_PER_CELL, PX_PER_CELL, PX_PER_CELL); gridCtx.fillRect(x * PX_PER_CELL, y * PX_PER_CELL, PX_PER_CELL, PX_PER_CELL);
@@ -115,7 +94,7 @@ function drawOverlay() {
} }
} }
watch([selectedColorIndex, canPlace, showGrid, gridWidth, gridHeight], drawOverlay); watch([selectedColorIndex, onCooldown, showGrid, gridWidth, gridHeight], drawOverlay);
onMounted(() => { onMounted(() => {
pixelCtx = pixelCanvasRef.value?.getContext("2d") ?? null; pixelCtx = pixelCanvasRef.value?.getContext("2d") ?? null;
@@ -144,7 +123,7 @@ function handleMouseLeave() {
} }
function handleClick(event: MouseEvent) { function handleClick(event: MouseEvent) {
if (!canPlace.value) return; if (onCooldown.value) return;
const { x, y } = cellFromEvent(event); const { x, y } = cellFromEvent(event);
place(x, y, selectedColorIndex.value); place(x, y, selectedColorIndex.value);
} }
@@ -152,36 +131,24 @@ function handleClick(event: MouseEvent) {
<template> <template>
<div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-6"> <div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-6">
<!-- Sized to fill the width, but capped so the full height (minus the <span class="badge" :class="connected ? 'badge-success' : 'badge-error'">
navbar and the controls below) still fits in the viewport. --> {{ connected ? t("place.connected") : t("place.reconnecting") }}
<div class="relative" :style="{ </span>
aspectRatio: `${gridWidth} / ${gridHeight}`,
width: `min(100%, calc((100dvh - 16rem) * ${gridWidth} / ${gridHeight}))`, <div class="relative w-full max-w-[512px]" :style="{ aspectRatio: `${gridWidth} / ${gridHeight}` }">
}">
<canvas ref="pixelCanvasRef" :width="gridWidth" :height="gridHeight" <canvas ref="pixelCanvasRef" :width="gridWidth" :height="gridHeight"
class="absolute inset-0 w-full h-full border border-base-300 rounded-box shadow-lg" :style="{ class="absolute inset-0 w-full h-full border border-base-300 rounded-box shadow-lg" :style="{
imageRendering: 'pixelated', imageRendering: 'pixelated',
cursor: canPlace ? 'crosshair' : 'not-allowed', cursor: onCooldown ? 'not-allowed' : 'crosshair',
}" @click="handleClick" @mousemove="handleMouseMove" @mouseleave="handleMouseLeave" /> }" @click="handleClick" @mousemove="handleMouseMove" @mouseleave="handleMouseLeave" />
<canvas ref="gridCanvasRef" :width="overlayWidth" :height="overlayHeight" <canvas ref="gridCanvasRef" :width="overlayWidth" :height="overlayHeight"
class="absolute inset-0 w-full h-full rounded-box pointer-events-none" /> class="absolute inset-0 w-full h-full rounded-box pointer-events-none" />
</div> </div>
<!-- Timer shares the toggle row so showing it doesn't push the page past <div class="flex items-center gap-2">
the viewport height the grid is sized against. -->
<div class="flex items-center gap-4 h-8">
<button class="btn btn-ghost btn-sm" @click="showGrid = !showGrid"> <button class="btn btn-ghost btn-sm" @click="showGrid = !showGrid">
{{ showGrid ? t("place.hideGrid") : t("place.showGrid") }} {{ showGrid ? t("place.hideGrid") : t("place.showGrid") }}
</button> </button>
<span v-if="placementEnded" class="badge badge-warning">{{ t("place.placementEnded") }}</span>
<template v-else-if="onCooldown">
<span class="font-bold text-xl tabular-nums">{{ cooldownSecondsDisplay }}s</span>
<progress class="progress progress-primary w-48"
:value="cooldownDurationSeconds - cooldownRemainingMs / 1000" :max="cooldownDurationSeconds" />
</template>
<span v-if="placementEndDisplay && !placementEnded" class="text-sm opacity-70">
{{ t("place.endsAt", { time: placementEndDisplay }) }}
</span>
</div> </div>
<div class="flex flex-wrap justify-center gap-2 max-w-md"> <div class="flex flex-wrap justify-center gap-2 max-w-md">
@@ -189,5 +156,13 @@ function handleClick(event: MouseEvent) {
:class="selectedColorIndex === index ? 'border-primary scale-110' : 'border-base-300'" :class="selectedColorIndex === index ? 'border-primary scale-110' : 'border-base-300'"
:style="{ backgroundColor: color }" @click="selectedColorIndex = index" /> :style="{ backgroundColor: color }" @click="selectedColorIndex = index" />
</div> </div>
<div class="flex flex-col items-center gap-2">
<span v-if="onCooldown" class="font-bold text-2xl tabular-nums">
{{ cooldownSecondsDisplay }}s
</span>
<progress v-if="onCooldown" class="progress progress-primary w-48"
:value="cooldownDurationSeconds - cooldownRemainingMs / 1000" :max="cooldownDurationSeconds" />
</div>
</div> </div>
</template> </template>
+2 -18
View File
@@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { usePlaceSocket } from "@/composables/usePlaceSocket"; import { usePlaceSocket } from "@/composables/usePlaceSocket";
import { PALETTE } from "@/palette"; import { PALETTE } from "@/palette";
import { playWipeWave } from "@/placeWipe";
interface Props { interface Props {
eventPath: string; eventPath: string;
@@ -23,22 +22,7 @@ function drawPixel(x: number, y: number, colorIndex: number) {
ctx.fillRect(x, y, 1, 1); ctx.fillRect(x, y, 1, 1);
} }
let cancelWipe: (() => void) | null = null; const { connected, gridWidth, gridHeight } = usePlaceSocket(props.eventPath, drawPixel);
function wipe() {
if (!ctx) return;
cancelWipe?.();
cancelWipe = playWipeWave(ctx, gridWidth.value, gridHeight.value);
}
onBeforeUnmount(() => cancelWipe?.());
const { connected, gridWidth, gridHeight } = usePlaceSocket(
props.eventPath,
drawPixel,
undefined,
wipe,
);
onMounted(() => { onMounted(() => {
ctx = canvasRef.value?.getContext("2d") ?? null; ctx = canvasRef.value?.getContext("2d") ?? null;
-245
View File
@@ -1,245 +0,0 @@
<script setup lang="ts">
import { computed, useTemplateRef } from "vue";
import { useI18n } from "vue-i18n";
import { useElementSize, useTitle } from "@vueuse/core";
import { useQuery } from "@tanstack/vue-query";
import { eventPlanningQuery } from "@/queries";
import type { PlanningSlotOut } from "./api/types.gen";
import PlanningCover from "./components/PlanningCover.vue";
import PlanningMultiSlot from "./components/PlanningMultiSlot.vue";
interface Props {
eventPath: string;
}
const props = defineProps<Props>();
const { t } = useI18n();
useTitle(computed(() => t("planning.title")));
const { data: slots, isPending: loading } = useQuery(eventPlanningQuery(props.eventPath));
const HOUR = 3600e3;
// Hour height per px of column width: a 2h slot ends up close to the 2:3 cover ratio
const HOUR_RATIO = 104 / 150;
const BREAK_PX = 34;
const DAY_PX = 32;
const AXIS_PX = 56;
// Columns shrink to fit the page, the planning only scrolls sideways below the min
const MIN_COL_PX = 80;
const MAX_COL_PX = 150;
const GAP_PX = 10;
// The multi-anims column is the big one, its covers 1.75x the size of the others
const WIDE_RATIO = 1.75;
// Room on the left for the day names, they stick out of the time axis
const DAY_LABEL_ROOM_PX = 120;
// Empty runs at least this long (nights) get squashed into a thin break
const MIN_BREAK_HOURS = 3;
const fmtTime = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
const fmtDay = new Intl.DateTimeFormat(undefined, { weekday: "long", day: "numeric", month: "short" });
interface Segment {
from: number;
to: number;
y: number;
h: number;
kind: "hour" | "idle" | "break" | "day";
label?: string;
}
interface Placed {
slot: PlanningSlotOut;
top: number;
height: number;
when: string;
}
const scroller = useTemplateRef<HTMLElement>("scroller");
const { width: available } = useElementSize(scroller);
const layout = computed(() => {
const list = (slots.value ?? []).map((slot) => ({
slot,
b: Date.parse(slot.begin),
e: Date.parse(slot.end),
}));
if (!list.length) return null;
// Unused columns are dropped, the multi-anims ones are wider
const columnIds = [...new Set(list.map(({ slot }) => slot.column))].sort((a, b) => a - b);
const isWide = (id: number) => list.some(({ slot }) => slot.column === id && slot.is_multi_anims);
const wideCount = columnIds.filter(isWide).length;
const fit =
(available.value - DAY_LABEL_ROOM_PX - AXIS_PX - (columnIds.length - 1) * GAP_PX) /
(columnIds.length + (WIDE_RATIO - 1) * wideCount);
const colPx = available.value ? Math.min(MAX_COL_PX, Math.max(MIN_COL_PX, fit)) : MAX_COL_PX;
const hourPx = colPx * HOUR_RATIO;
// Multi-anims run the whole event, only the other slots decide what's busy
const singles = list.filter(({ slot }) => !slot.is_multi_anims);
const busy = (t: number) => singles.some(({ b, e }) => b < t + HOUR && e > t);
const start = Math.floor(Math.min(...list.map(({ b }) => b)) / HOUR) * HOUR;
const end = Math.ceil(Math.max(...list.map(({ e }) => e)) / HOUR) * HOUR;
const segments: Segment[] = [];
let y = 0;
let lastDay = "";
for (let t = start; t < end;) {
if (busy(t)) {
// Each day gets its own header row, takes no time
const day = fmtDay.format(t);
if (day !== lastDay) {
segments.push({ from: t, to: t, y, h: DAY_PX, kind: "day", label: day });
y += DAY_PX;
lastDay = day;
}
segments.push({ from: t, to: t + HOUR, y, h: hourPx, kind: "hour" });
y += hourPx;
t += HOUR;
continue;
}
let u = t;
while (u < end && !busy(u)) u += HOUR;
const hours = (u - t) / HOUR;
const isBreak = hours >= MIN_BREAK_HOURS;
const h = isBreak ? BREAK_PX : hourPx * hours;
segments.push({ from: t, to: u, y, h, kind: isBreak ? "break" : "idle" });
y += h;
t = u;
}
// Day headers have no duration, they never contain a time
const timed = segments.filter((s) => s.kind !== "day");
const yOf = (t: number) => {
const s = timed.find((s) => t >= s.from && t < s.to) ?? timed[timed.length - 1]!;
return s.y + s.h * Math.min(1, (t - s.from) / (s.to - s.from));
};
const days = segments
.filter((s) => s.kind === "day")
.map((s) => ({ y: s.y, label: s.label! }));
// No tick right under a day header, it would sit on its line
const dayEnds = new Set(days.map((d) => d.y + DAY_PX));
const ticks = timed
.filter((s) => s.kind !== "break")
.flatMap((s) => {
const out = [];
for (let t = s.from; t < s.to; t += HOUR) out.push({ y: yOf(t), label: fmtTime.format(t) });
return out;
})
.filter((tick) => !dayEnds.has(tick.y));
const breaks = segments
.filter((s) => s.kind === "break")
.map((s) => ({ y: s.y, h: s.h, label: `${fmtTime.format(s.from)} – ${fmtTime.format(s.to)}` }));
let left = AXIS_PX;
const columns = columnIds.map((id) => {
const placed = list
.filter(({ slot }) => slot.column === id)
.map<Placed>(({ slot, b, e }) => ({
slot,
top: yOf(b) + 3,
height: yOf(e) - yOf(b) - 6,
when: `${fmtTime.format(b)} – ${fmtTime.format(e)}`,
}));
const width = isWide(id) ? WIDE_RATIO * colPx : colPx;
const column = { left, width, placed };
left += width + GAP_PX;
return column;
});
return {
height: y,
width: left - GAP_PX,
days,
ticks,
breaks,
columns,
};
});
</script>
<template>
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
<!-- min-w-0: lets the planning scroll sideways instead of overflowing the page -->
<div class="w-full max-w-7xl min-w-0">
<div class="text-center mb-12">
<h1 class="text-5xl font-bold mb-4">{{ t("planning.title") }}</h1>
</div>
<div v-if="loading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span>
</div>
<p v-else-if="!layout" class="text-center text-base-content/50 py-20">
{{ t("planning.empty") }}
</p>
<!-- pr/pt/pb: room for the Fils rouges glow, the scroll box would clip it -->
<div v-else ref="scroller" class="overflow-x-auto pr-10 pt-4 pb-10">
<div class="relative" :style="{
width: `${layout.width}px`,
height: `${layout.height}px`,
marginLeft: `max(${DAY_LABEL_ROOM_PX}px, (100% - ${layout.width}px) / 2)`,
}">
<!-- Time axis: a header row per day, hour ticks on the side -->
<!-- Above the cards: slots running past midnight go over the header row -->
<div v-for="day in layout.days" :key="`day-${day.y}`"
class="absolute left-0 right-0 z-20 pointer-events-none border-b border-base-content/20 flex items-end text-sm font-semibold uppercase tracking-wide"
:style="{ top: `${day.y}px`, height: `${DAY_PX}px` }">
<!-- Ends at the time axis and grows to the left, never over the cards -->
<span class="absolute bottom-0 whitespace-nowrap bg-base-300 rounded-t px-2 py-0.5"
:style="{ right: `calc(100% - ${AXIS_PX}px)` }">{{ day.label }}</span>
</div>
<template v-for="tick in layout.ticks" :key="tick.y">
<span class="absolute -translate-y-1/2 text-xs text-base-content/40 text-right pr-2"
:style="{ top: `${tick.y}px`, width: `${AXIS_PX}px` }">
{{ tick.label }}
</span>
<div class="absolute right-0 border-t border-dashed border-base-content/5"
:style="{ top: `${tick.y}px`, left: `${AXIS_PX}px` }"></div>
</template>
<!-- Nights -->
<div v-for="b in layout.breaks" :key="`break-${b.y}`"
class="planning-break absolute left-0 right-0 rounded flex items-center justify-center text-xs text-base-content/40"
:style="{ top: `${b.y}px`, height: `${b.h}px` }">
{{ b.label }}
</div>
<!-- Slots -->
<div v-for="column in layout.columns" :key="column.left" class="absolute top-0 h-full"
:style="{ left: `${column.left}px`, width: `${column.width}px` }">
<template v-for="{ slot, top, height, when } in column.placed" :key="slot.id">
<!-- Multi-anims: one long shiny card, its games follow the scroll -->
<PlanningMultiSlot v-if="slot.is_multi_anims" :entry="slot" class="absolute inset-x-0"
:style="{ top: `${top}px`, height: `${height}px` }" />
<component :is="slot.games[0]?.store_url ? 'a' : 'div'" v-else
:href="slot.games[0]?.store_url || undefined" target="_blank" rel="noopener noreferrer"
class="group absolute inset-x-0 block rounded-md overflow-hidden bg-base-200 hover:bg-base-100
shadow-xl hover:shadow-2xl transition-all transform hover:-translate-y-2 hover:scale-[1.05] hover:z-10"
:style="{ top: `${top}px`, height: `${height}px` }">
<PlanningCover :game="slot.games[0] ?? { name: slot.title }" :color="slot.color" :when="when" />
<span class="absolute top-1.5 left-1.5 text-xs px-1.5 rounded bg-black/70 text-white opacity-0
group-hover:opacity-100 transition-opacity">
{{ when }}
</span>
</component>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.planning-break {
background: repeating-linear-gradient(45deg, color-mix(in srgb, currentColor 6%, transparent) 0 6px, transparent 6px 12px);
}
</style>
+41 -91
View File
@@ -1,14 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch } from "vue"; import { ref, onMounted, onUnmounted, computed } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import { useQueryClient } from "@tanstack/vue-query"; import { useUserStore } from "@/stores/user";
import { useUser } from "@/composables/useUser";
import { eventGamesQuery } from "@/queries";
import { usePageHeaderStore } from "@/stores/pageHeader"; import { usePageHeaderStore } from "@/stores/pageHeader";
import { submissionsApiListAvailableScopes, submissionsApiCreateSubmission, submissionsApiListUserSubmissions, submissionsApiCreateImageCrop, submissionsApiDeleteImageCrop, submissionsApiProcessFileOcr, submissionsApiProcessCropOcr } from "@/api"; import { gamesApiListEventGames, submissionsApiListAvailableScopes, submissionsApiCreateSubmission, submissionsApiListUserSubmissions, submissionsApiCreateImageCrop, submissionsApiDeleteImageCrop, submissionsApiProcessFileOcr, submissionsApiProcessCropOcr } from "@/api";
import type { SubmissionOut } from "@/api";
import { useSubmissionsWebSocket } from "@/composables/useSubmissionsWebSocket"; import { useSubmissionsWebSocket } from "@/composables/useSubmissionsWebSocket";
import { provideEventPath } from "@/composables/useEventPath"; import { provideEventPath } from "@/composables/useEventPath";
@@ -33,8 +31,8 @@ const gameSteamId = ref("");
useTitle(computed(() => t("submissions.pageTitle", { game: gameName.value, scope: scopeName.value }))); useTitle(computed(() => t("submissions.pageTitle", { game: gameName.value, scope: scopeName.value })));
const queryClient = useQueryClient(); const userStore = useUserStore();
const userInfo = useUser(); const { userInfo } = storeToRefs(userStore);
const uploadedFiles = ref<File[]>([]); const uploadedFiles = ref<File[]>([]);
const isDragover = ref(false); const isDragover = ref(false);
@@ -43,12 +41,11 @@ const notes = ref("");
const selectedFile = ref<File | null>(null); const selectedFile = ref<File | null>(null);
const filePreviewUrl = ref<string>(""); const filePreviewUrl = ref<string>("");
const isModalOpen = ref(false); const isModalOpen = ref(false);
const userSubmissions = ref<SubmissionOut[]>([]); const userSubmissions = ref<any[]>([]);
const isLoadingSubmissions = ref(false); const isLoadingSubmissions = ref(false);
const selectedSubmissionFile = ref<any>(null); const selectedSubmissionFile = ref<any>(null);
const isSubmissionFileModalOpen = ref(false); const isSubmissionFileModalOpen = ref(false);
// Feedback shown on the copy button that was clicked, keyed by "file" or crop id const copyFeedback = ref<string>("");
const copyFeedback = ref<{ key: string; message: string } | null>(null);
const isDrawingCrop = ref(false); const isDrawingCrop = ref(false);
const cropStartX = ref(0); const cropStartX = ref(0);
const cropStartY = ref(0); const cropStartY = ref(0);
@@ -142,23 +139,23 @@ const closeSubmissionFileModal = () => {
selectedSubmissionFile.value = null; selectedSubmissionFile.value = null;
}; };
const copyOCRText = async (text: string | undefined, key: string) => { const copyOCRText = async () => {
if (!text) return; if (selectedSubmissionFile.value?.ocr_raw_data) {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(selectedSubmissionFile.value.ocr_raw_data);
copyFeedback.value = { key, message: t("common.copied") }; copyFeedback.value = t("common.copied");
} catch (err) {
copyFeedback.value = { key, message: t("submissions.copyFailed") };
}
setTimeout(() => { setTimeout(() => {
if (copyFeedback.value?.key === key) copyFeedback.value = null; copyFeedback.value = "";
}, 2000); }, 2000);
} catch (err) {
copyFeedback.value = t("submissions.copyFailed");
setTimeout(() => {
copyFeedback.value = "";
}, 2000);
}
}
}; };
const copyLabel = (key: string) =>
copyFeedback.value?.key === key ? copyFeedback.value.message : t("common.copy");
const getCanvasCoordinates = (canvas: HTMLCanvasElement, clientX: number, clientY: number) => { const getCanvasCoordinates = (canvas: HTMLCanvasElement, clientX: number, clientY: number) => {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width; const scaleX = canvas.width / rect.width;
@@ -247,12 +244,6 @@ const redrawCropCanvas = () => {
img.src = selectedSubmissionFile.value?.file_url; img.src = selectedSubmissionFile.value?.file_url;
}; };
// Crops can also arrive over the websocket (e.g. drawn by another admin)
watch(
() => selectedSubmissionFile.value?.crops?.length,
() => redrawCropCanvas(),
);
const submitCrop = async () => { const submitCrop = async () => {
if (!currentCrop.value || !selectedSubmissionFile.value) return; if (!currentCrop.value || !selectedSubmissionFile.value) return;
@@ -269,13 +260,11 @@ const submitCrop = async () => {
}, },
}); });
// Add the new crop to the list immediately, unless the websocket beat us to it // Add the new crop to the list immediately
if (newCrop) { if (newCrop) {
selectedSubmissionFile.value.crops = selectedSubmissionFile.value.crops || []; selectedSubmissionFile.value.crops = selectedSubmissionFile.value.crops || [];
if (!selectedSubmissionFile.value.crops.some((c: any) => c.id === newCrop.id)) {
selectedSubmissionFile.value.crops.push(newCrop); selectedSubmissionFile.value.crops.push(newCrop);
} }
}
currentCrop.value = null; currentCrop.value = null;
cropLabel.value = ""; cropLabel.value = "";
@@ -415,26 +404,6 @@ const removeFile = (index: number) => {
uploadedFiles.value.splice(index, 1); uploadedFiles.value.splice(index, 1);
}; };
// Thumbnails of the selected images, revoked once the file is removed/submitted
const thumbnailUrls = new Map<File, string>();
const thumbnails = computed(() =>
uploadedFiles.value.map((file) => {
if (!file.type.startsWith("image/")) return null;
if (!thumbnailUrls.has(file)) thumbnailUrls.set(file, URL.createObjectURL(file));
return thumbnailUrls.get(file)!;
}),
);
const revokeThumbnails = (keep: File[] = []) => {
thumbnailUrls.forEach((url, file) => {
if (keep.includes(file)) return;
URL.revokeObjectURL(url);
thumbnailUrls.delete(file);
});
};
watch(uploadedFiles, (files) => revokeThumbnails(files), { deep: true });
const submitSubmission = async () => { const submitSubmission = async () => {
if (uploadedFiles.value.length === 0) return; if (uploadedFiles.value.length === 0) return;
@@ -505,17 +474,7 @@ const loadUserSubmissions = async () => {
} }
}; };
// Match over (or user moved off it): back to the picker, which auto-enters const { isConnected: wsConnected } = useSubmissionsWebSocket(userSubmissions);
// the next match when there's only one, so players follow along without a refresh.
useSubmissionsWebSocket({
userSubmissions,
scopeId: props.scopeId,
onScopeUpdate: (scope) => {
if (scope.id !== props.scopeId || (scope.is_active && scope.assigned)) return;
router.replace({ name: "submissions-select", params: { eventPath: props.eventPath, gamePath: props.gamePath } });
},
});
const isUploadAreaHovered = ref(false); const isUploadAreaHovered = ref(false);
const handlePasteOnUploadArea = (event: ClipboardEvent) => { const handlePasteOnUploadArea = (event: ClipboardEvent) => {
@@ -544,16 +503,17 @@ const handlePasteOnUploadArea = (event: ClipboardEvent) => {
}; };
onMounted(async () => { onMounted(async () => {
userStore.loadUserData();
loadUserSubmissions(); loadUserSubmissions();
const [games, scopesResponse] = await Promise.all([ const [gamesResponse, scopesResponse] = await Promise.all([
queryClient.ensureQueryData(eventGamesQuery(props.eventPath)), gamesApiListEventGames({ path: { event_path: props.eventPath } }),
submissionsApiListAvailableScopes({ submissionsApiListAvailableScopes({
path: { event_path: props.eventPath, game_path: props.gamePath }, path: { event_path: props.eventPath, game_path: props.gamePath },
}), }),
]); ]);
const game = games.find((g) => g.path === props.gamePath); const game = gamesResponse.data?.find((g) => g.path === props.gamePath);
if (game) { if (game) {
gameName.value = game.name; gameName.value = game.name;
gameImage.value = game.image ?? ""; gameImage.value = game.image ?? "";
@@ -581,7 +541,6 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('paste', handlePasteOnUploadArea); window.removeEventListener('paste', handlePasteOnUploadArea);
revokeThumbnails();
}); });
</script> </script>
@@ -589,6 +548,14 @@ onUnmounted(() => {
<div class="flex-1 bg-base-200"> <div class="flex-1 bg-base-200">
<!-- Main Content --> <!-- Main Content -->
<div class="container min-w-3/4 mx-auto px-4 py-8"> <div class="container min-w-3/4 mx-auto px-4 py-8">
<!-- WebSocket Status Indicator -->
<div class="flex justify-end mb-4">
<div :class="['badge', wsConnected ? 'badge-success' : 'badge-warning']">
<i :class="['mdi mr-1', wsConnected ? 'mdi-wifi' : 'mdi-wifi-off']"></i>
{{ wsConnected ? t("submissions.live") : t("submissions.offline") }}
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Right Column: Upload --> <!-- Right Column: Upload -->
<div class="lg:col-span-1 flex flex-col gap-8 order-1 lg:order-2"> <div class="lg:col-span-1 flex flex-col gap-8 order-1 lg:order-2">
@@ -650,24 +617,17 @@ onUnmounted(() => {
<!-- Uploaded Files List --> <!-- Uploaded Files List -->
<div v-if="uploadedFiles.length > 0" class="mb-6"> <div v-if="uploadedFiles.length > 0" class="mb-6">
<p class="font-semibold mb-3">{{ t("common.selectedFiles") }}</p> <p class="font-semibold mb-3">{{ t("common.selectedFiles") }}</p>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> <div class="space-y-2">
<div v-for="(file, index) in uploadedFiles" :key="index" <div v-for="(file, index) in uploadedFiles" :key="index"
class="relative bg-base-200 rounded-lg overflow-hidden cursor-pointer hover:bg-base-300 transition-colors group" class="flex items-center gap-3 bg-base-200 p-3 rounded-lg cursor-pointer hover:bg-base-300 transition-colors group"
@click="openFilePreview(file)"> @click="openFilePreview(file)">
<div class="aspect-video bg-base-300 flex items-center justify-center">
<img v-if="thumbnails[index]" :src="thumbnails[index]" :alt="file.name"
class="w-full h-full object-contain" />
<i v-else :class="['mdi text-5xl text-primary', getFileIcon(file)]"></i>
</div>
<div class="flex items-center gap-2 p-2">
<i :class="['mdi text-primary', getFileIcon(file)]"></i> <i :class="['mdi text-primary', getFileIcon(file)]"></i>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate group-hover:text-primary" :title="file.name">{{ file.name }}</p> <p class="font-medium truncate group-hover:text-primary">{{ file.name }}</p>
<p class="text-xs text-base-content/70">{{ formatFileSize(file.size) }}</p> <p class="text-xs text-base-content/70">{{ formatFileSize(file.size) }}</p>
</div> </div>
</div>
<button @click.stop="removeFile(index)" <button @click.stop="removeFile(index)"
class="btn btn-circle btn-xs absolute top-2 right-2 sm:opacity-0 sm:group-hover:opacity-100"> class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100">
<i class="mdi mdi-close"></i> <i class="mdi mdi-close"></i>
</button> </button>
</div> </div>
@@ -767,7 +727,7 @@ onUnmounted(() => {
<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-history text-primary mr-2"></i> <i class="mdi mdi-history text-primary mr-2"></i>
{{ userInfo.isStaff ? t("submissions.allSubmissions") : t("submissions.yourSubmissions") }} {{ t("submissions.yourSubmissions") }}
</h2> </h2>
<div v-if="isLoadingSubmissions" class="flex justify-center py-8"> <div v-if="isLoadingSubmissions" class="flex justify-center py-8">
@@ -784,7 +744,6 @@ onUnmounted(() => {
<thead> <thead>
<tr> <tr>
<th>{{ t("common.date") }}</th> <th>{{ t("common.date") }}</th>
<th v-if="userInfo.isStaff">{{ t("common.user") }}</th>
<th>{{ t("common.files") }}</th> <th>{{ t("common.files") }}</th>
<th>{{ t("submissions.fileStatus") }}</th> <th>{{ t("submissions.fileStatus") }}</th>
<th>{{ t("submissions.ocrStatus") }}</th> <th>{{ t("submissions.ocrStatus") }}</th>
@@ -794,7 +753,6 @@ onUnmounted(() => {
<tbody> <tbody>
<tr v-for="submission in userSubmissions" :key="submission.id" class="hover"> <tr v-for="submission in userSubmissions" :key="submission.id" class="hover">
<td class="text-sm">{{ formatDate(submission.created_at) }}</td> <td class="text-sm">{{ formatDate(submission.created_at) }}</td>
<td v-if="userInfo.isStaff" class="text-sm">{{ submission.username ?? "-" }}</td>
<td class="text-sm"> <td class="text-sm">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<div v-for="file in submission.files" :key="file.id" class="flex items-center gap-2"> <div v-for="file in submission.files" :key="file.id" class="flex items-center gap-2">
@@ -947,10 +905,9 @@ onUnmounted(() => {
<div> <div>
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<p class="text-xs font-semibold text-base-content/70">{{ t("submissions.extractedText") }}</p> <p class="text-xs font-semibold text-base-content/70">{{ t("submissions.extractedText") }}</p>
<button @click="copyOCRText(selectedSubmissionFile.ocr_raw_data, 'file')" <button @click="copyOCRText" class="btn btn-xs btn-ghost gap-1">
class="btn btn-xs btn-ghost gap-1">
<i class="mdi mdi-content-copy"></i> <i class="mdi mdi-content-copy"></i>
{{ copyLabel("file") }} {{ copyFeedback || t("common.copy") }}
</button> </button>
</div> </div>
<div <div
@@ -987,15 +944,8 @@ onUnmounted(() => {
<div v-for="crop in selectedSubmissionFile.crops" :key="crop.id" class="space-y-2"> <div v-for="crop in selectedSubmissionFile.crops" :key="crop.id" class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p class="text-xs font-medium">{{ crop.label || t("submissions.cropLabelFallback", { x: crop.x1, y: crop.y1 }) }}</p> <p class="text-xs font-medium">{{ crop.label || t("submissions.cropLabelFallback", { x: crop.x1, y: crop.y1 }) }}</p>
<div class="flex items-center gap-1">
<button v-if="crop.ocr_status === 'processed' && crop.ocr_raw_data"
@click="copyOCRText(crop.ocr_raw_data, crop.id)" class="btn btn-xs btn-ghost gap-1">
<i class="mdi mdi-content-copy"></i>
{{ copyLabel(crop.id) }}
</button>
<span :class="getOCRStatusColor(crop.ocr_status)">{{ crop.ocr_status }}</span> <span :class="getOCRStatusColor(crop.ocr_status)">{{ crop.ocr_status }}</span>
</div> </div>
</div>
<div v-if="crop.ocr_status === 'processed' && crop.ocr_raw_data" <div v-if="crop.ocr_status === 'processed' && crop.ocr_raw_data"
class="bg-base-100 p-2 rounded text-xs whitespace-pre-wrap break-words font-mono max-h-32 overflow-y-auto"> class="bg-base-100 p-2 rounded text-xs whitespace-pre-wrap break-words font-mono max-h-32 overflow-y-auto">
{{ crop.ocr_raw_data }} {{ crop.ocr_raw_data }}
+14 -54
View File
@@ -3,12 +3,7 @@ import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import { submissionsApiListAvailableScopes } from "@/api"; import { gamesApiListEventGames, submissionsApiListAvailableScopes } from "@/api";
import { useQueryClient } from "@tanstack/vue-query";
import { eventGamesQuery, userQuery } from "@/queries";
import { useUser } from "@/composables/useUser";
import { useSubmissionsWebSocket } from "@/composables/useSubmissionsWebSocket";
import ScopeSyncModal from "@/components/ScopeSyncModal.vue";
import type { ScopeOut } from "@/api/types.gen"; import type { ScopeOut } from "@/api/types.gen";
interface Props { interface Props {
@@ -19,18 +14,13 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
const { t } = useI18n(); const { t } = useI18n();
const userInfo = useUser();
useTitle(computed(() => t("submissionsSelect.title"))); useTitle(computed(() => t("submissionsSelect.title")));
const gameName = ref(""); const gameName = ref("");
const scopes = ref<ScopeOut[]>([]); const scopes = ref<ScopeOut[]>([]);
const loading = ref(true); const loading = ref(true);
const notAssigned = ref(false); const notAssigned = ref(false);
const notFound = ref(false);
const isSyncModalOpen = ref(false);
const scopeRoute = (scope: ScopeOut) => ({ const scopeRoute = (scope: ScopeOut) => ({
name: "submissions", name: "submissions",
@@ -41,67 +31,40 @@ const goToScope = (scope: ScopeOut) => {
router.push(scopeRoute(scope)); router.push(scopeRoute(scope));
}; };
const loadScopes = async () => { onMounted(async () => {
const [games, scopesResponse, user] = await Promise.all([ const [gamesResponse, scopesResponse] = await Promise.all([
queryClient.ensureQueryData(eventGamesQuery(props.eventPath)), gamesApiListEventGames({ path: { event_path: props.eventPath } }),
submissionsApiListAvailableScopes({ submissionsApiListAvailableScopes({
path: { event_path: props.eventPath, game_path: props.gamePath }, path: { event_path: props.eventPath, game_path: props.gamePath },
}), }),
queryClient.ensureQueryData(userQuery()),
]); ]);
// Not a game of this event (or one with its own page, not submissions): gameName.value = gamesResponse.data?.find((game) => game.path === props.gamePath)?.name ?? "";
// the backend just serves the app for any game path, this is our 404.
const game = games.find((game) => game.path === props.gamePath && !game.custom_path);
if (!game || scopesResponse.error) {
notFound.value = true;
loading.value = false;
return;
}
gameName.value = game.name;
const availableScopes = scopesResponse.data ?? []; const availableScopes = scopesResponse.data ?? [];
// Mirrors the server-side behavior this replaced: skip the picker // Mirrors the server-side behavior this replaced: skip the picker
// straight to the only option, or bounce out if there's nothing to pick. // straight to the only option, or bounce out if there's nothing to pick.
// Superusers always land here, it's where the scope sync button lives. if (availableScopes.length === 1) {
if (availableScopes.length === 1 && !user.is_superuser) {
router.replace(scopeRoute(availableScopes[0])); router.replace(scopeRoute(availableScopes[0]));
return; return;
} }
scopes.value = availableScopes; if (availableScopes.length === 0) {
notAssigned.value = availableScopes.length === 0; notAssigned.value = true;
loading.value = false; loading.value = false;
}; return;
}
// A match opened/closed or players got (re)assigned: reload, so waiting scopes.value = availableScopes;
// players get auto-entered into their next match without a refresh. loading.value = false;
useSubmissionsWebSocket({ onScopeUpdate: () => loadScopes() }); });
onMounted(loadScopes);
</script> </script>
<template> <template>
<div class="flex-1 bg-base-200 flex flex-col items-center justify-center gap-4 p-4"> <div class="flex-1 bg-base-200 flex items-center justify-center p-4">
<div v-if="userInfo.isSuperuser && !loading && !notFound" class="w-full max-w-md flex justify-end">
<button @click="isSyncModalOpen = true" class="btn btn-primary btn-sm">
<i class="mdi mdi-sync"></i>
{{ t("submissionsSelect.syncScopes") }}
</button>
</div>
<div v-if="loading" class="flex justify-center py-20"> <div v-if="loading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg"></span>
</div> </div>
<div v-else-if="notFound" class="text-center max-w-md">
<i class="mdi mdi-alert-circle-outline text-6xl text-primary"></i>
<h2 class="text-2xl font-semibold mt-4">{{ t("errorPage.notFound.title") }}</h2>
<p class="text-base-content/70 mt-2">{{ t("errorPage.notFound.message") }}</p>
<RouterLink :to="{ name: 'home', params: { eventPath } }" class="btn btn-primary mt-6">
<i class="mdi mdi-home mr-2"></i>
{{ t("errorPage.goHome") }}
</RouterLink>
</div>
<div v-else-if="notAssigned" class="text-center max-w-md"> <div v-else-if="notAssigned" class="text-center max-w-md">
<i class="mdi mdi-account-alert-outline text-6xl text-warning"></i> <i class="mdi mdi-account-alert-outline text-6xl text-warning"></i>
<h2 class="text-2xl font-semibold mt-4">{{ t("submissionsSelect.notAssignedTitle") }}</h2> <h2 class="text-2xl font-semibold mt-4">{{ t("submissionsSelect.notAssignedTitle") }}</h2>
@@ -123,8 +86,5 @@ onMounted(loadScopes);
</div> </div>
</div> </div>
</div> </div>
<ScopeSyncModal v-if="userInfo.isSuperuser && !notFound" v-model:open="isSyncModalOpen" :event-path="eventPath"
:game-path="gamePath" :game-name="gameName" />
</div> </div>
</template> </template>
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+15 -170
View File
@@ -2,7 +2,7 @@
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client'; import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
import { client } from './client.gen'; import { client } from './client.gen';
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListEventPlanningData, GamesApiListEventPlanningResponses, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionErrors, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionErrors, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponseFilesData, OpusMagnumApiListResponseFilesErrors, OpusMagnumApiListResponseFilesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationErrors, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoErrors, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseErrors, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionErrors, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PlaceApiFinalPngData, PlaceApiFinalPngResponses, PlaceApiHeatmapDecayGifData, PlaceApiHeatmapDecayGifResponses, PlaceApiHeatmapEvolutionGifData, PlaceApiHeatmapEvolutionGifResponses, PlaceApiHeatmapPngData, PlaceApiHeatmapPngResponses, PlaceApiPlacementsCsvData, PlaceApiPlacementsCsvResponses, PlaceApiStatsData, PlaceApiStatsResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheErrors, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropErrors, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropErrors, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionErrors, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameErrors, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrErrors, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrErrors, SubmissionsApiProcessFileOcrResponses, SubmissionsApiStopSyncScopesData, SubmissionsApiStopSyncScopesErrors, SubmissionsApiStopSyncScopesResponses, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesErrors, SubmissionsApiSyncScopesResponses, SubmissionsApiSyncScopesStatusData, SubmissionsApiSyncScopesStatusErrors, SubmissionsApiSyncScopesStatusResponses } from './types.gen'; import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/** /**
@@ -30,7 +30,7 @@ export const polylanSubmitterApiHealthCheck = <ThrowOnError extends boolean = fa
* *
* Clear API caches (admin only). Pass event_path to clear just that event's keys. * Clear API caches (admin only). Pass event_path to clear just that event's keys.
*/ */
export const polylanSubmitterApiClearCache = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiClearCacheData, ThrowOnError>) => (options?.client ?? client).post<PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiClearCacheErrors, ThrowOnError>({ url: '/api/cache/clear', ...options }); 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 User Info
@@ -70,7 +70,7 @@ export const opusMagnumApiListSubmissions = <ThrowOnError extends boolean = fals
* *
* Create a new submission with multiple puzzle responses * Create a new submission with multiple puzzle responses
*/ */
export const opusMagnumApiCreateSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiCreateSubmissionData, ThrowOnError>) => (options.client ?? client).post<OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiCreateSubmissionErrors, ThrowOnError>({ export const opusMagnumApiCreateSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiCreateSubmissionData, ThrowOnError>) => (options.client ?? client).post<OpusMagnumApiCreateSubmissionResponses, unknown, ThrowOnError>({
...formDataBodySerializer, ...formDataBodySerializer,
url: '/api/events/{event_path}/opus-magnum/submissions', url: '/api/events/{event_path}/opus-magnum/submissions',
...options, ...options,
@@ -85,7 +85,7 @@ export const opusMagnumApiCreateSubmission = <ThrowOnError extends boolean = fal
* *
* Delete a submission (admin only) * Delete a submission (admin only)
*/ */
export const opusMagnumApiDeleteSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiDeleteSubmissionData, ThrowOnError>) => (options.client ?? client).delete<OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiDeleteSubmissionErrors, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/submissions/{submission_id}', ...options }); export const opusMagnumApiDeleteSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiDeleteSubmissionData, ThrowOnError>) => (options.client ?? client).delete<OpusMagnumApiDeleteSubmissionResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/submissions/{submission_id}', ...options });
/** /**
* Get Submission * Get Submission
@@ -99,7 +99,7 @@ export const opusMagnumApiGetSubmission = <ThrowOnError extends boolean = false>
* *
* Manually validate a puzzle response * Manually validate a puzzle response
*/ */
export const opusMagnumApiValidateResponse = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateResponseData, ThrowOnError>) => (options.client ?? client).put<OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateResponseErrors, ThrowOnError>({ export const opusMagnumApiValidateResponse = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateResponseData, ThrowOnError>) => (options.client ?? client).put<OpusMagnumApiValidateResponseResponses, unknown, ThrowOnError>({
url: '/api/events/{event_path}/opus-magnum/responses/{response_id}/validate', url: '/api/events/{event_path}/opus-magnum/responses/{response_id}/validate',
...options, ...options,
headers: { headers: {
@@ -113,28 +113,21 @@ export const opusMagnumApiValidateResponse = <ThrowOnError extends boolean = fal
* *
* Try to auto validate a puzzle response * Try to auto validate a puzzle response
*/ */
export const opusMagnumApiValidateAuto = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateAutoData, ThrowOnError>) => (options.client ?? client).put<OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateAutoErrors, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/responses/{response_id}/validate/auto', ...options }); export const opusMagnumApiValidateAuto = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateAutoData, ThrowOnError>) => (options.client ?? client).put<OpusMagnumApiValidateAutoResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/responses/{response_id}/validate/auto', ...options });
/** /**
* List Responses Needing Validation * List Responses Needing Validation
* *
* Get all responses that need manual validation * Get all responses that need manual validation
*/ */
export const opusMagnumApiListResponsesNeedingValidation = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiListResponsesNeedingValidationData, ThrowOnError>) => (options.client ?? client).get<OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListResponsesNeedingValidationErrors, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/responses/needs-validation', ...options }); export const opusMagnumApiListResponsesNeedingValidation = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiListResponsesNeedingValidationData, ThrowOnError>) => (options.client ?? client).get<OpusMagnumApiListResponsesNeedingValidationResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/responses/needs-validation', ...options });
/**
* List Response Files
*
* Get the solution files of any player's response (admin only)
*/
export const opusMagnumApiListResponseFiles = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiListResponseFilesData, ThrowOnError>) => (options.client ?? client).get<OpusMagnumApiListResponseFilesResponses, OpusMagnumApiListResponseFilesErrors, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/responses/{response_id}/files', ...options });
/** /**
* Validate Submission * Validate Submission
* *
* Mark entire submission as validated * Mark entire submission as validated
*/ */
export const opusMagnumApiValidateSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateSubmissionData, ThrowOnError>) => (options.client ?? client).post<OpusMagnumApiValidateSubmissionResponses, OpusMagnumApiValidateSubmissionErrors, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/submissions/{submission_id}/validate', ...options }); export const opusMagnumApiValidateSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateSubmissionData, ThrowOnError>) => (options.client ?? client).post<OpusMagnumApiValidateSubmissionResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/submissions/{submission_id}/validate', ...options });
/** /**
* Get Stats * Get Stats
@@ -210,13 +203,6 @@ export const gamesApiListEventGames = <ThrowOnError extends boolean = false>(opt
*/ */
export const gamesApiListEventSteamGames = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventSteamGamesData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventSteamGamesResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/games/steam', ...options }); export const gamesApiListEventSteamGames = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventSteamGamesData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventSteamGamesResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/games/steam', ...options });
/**
* List Event Planning
*
* The event planning, with the covers of the games we know.
*/
export const gamesApiListEventPlanning = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventPlanningData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventPlanningResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/planning', ...options });
/** /**
* List My Scopes * List My Scopes
*/ */
@@ -233,38 +219,6 @@ export const submissionsApiListMyScopes = <ThrowOnError extends boolean = false>
*/ */
export const submissionsApiListAvailableScopes = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListAvailableScopesData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListAvailableScopesResponses, SubmissionsApiListAvailableScopesErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/{game_path}/available-scopes', ...options }); export const submissionsApiListAvailableScopes = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListAvailableScopesData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListAvailableScopesResponses, SubmissionsApiListAvailableScopesErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/{game_path}/available-scopes', ...options });
/**
* Stop Sync Scopes
*
* Stop the AAndT scope sync loop of a (non custom path) game, superusers
* only. Cancels its scheduled job, scopes are left as they are.
*/
export const submissionsApiStopSyncScopes = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiStopSyncScopesData, ThrowOnError>) => (options.client ?? client).delete<SubmissionsApiStopSyncScopesResponses, SubmissionsApiStopSyncScopesErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/{game_path}/sync-scopes', ...options });
/**
* Sync Scopes Status
*
* State of the AAndT scope sync loop of a (non custom path) game,
* superusers only.
*/
export const submissionsApiSyncScopesStatus = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiSyncScopesStatusData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiSyncScopesStatusResponses, SubmissionsApiSyncScopesStatusErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/{game_path}/sync-scopes', ...options });
/**
* Sync Scopes
*
* Start the AAndT scope sync loop for a (non custom path) game, superusers
* only. Stores `apibot_token` first when given; without one and none stored
* yet, answers 400 with `token_required` so the frontend asks for it.
*/
export const submissionsApiSyncScopes = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiSyncScopesData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiSyncScopesResponses, SubmissionsApiSyncScopesErrors, ThrowOnError>({
url: '/api/events/{event_path}/submissions/{game_path}/sync-scopes',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
/** /**
* Create Submission * Create Submission
*/ */
@@ -286,17 +240,17 @@ export const submissionsApiListUserSubmissions = <ThrowOnError extends boolean =
/** /**
* List Submissions By Game * List Submissions By Game
*/ */
export const submissionsApiListSubmissionsByGame = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListSubmissionsByGameData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListSubmissionsByGameErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/submission/by/game', ...options }); export const submissionsApiListSubmissionsByGame = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListSubmissionsByGameData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListSubmissionsByGameResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/submissions/submission/by/game', ...options });
/** /**
* Get Submission * Get Submission
*/ */
export const submissionsApiGetSubmission = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiGetSubmissionData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiGetSubmissionResponses, SubmissionsApiGetSubmissionErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/submission/{submission_id}', ...options }); export const submissionsApiGetSubmission = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiGetSubmissionData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiGetSubmissionResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/submissions/submission/{submission_id}', ...options });
/** /**
* Create Image Crop * Create Image Crop
*/ */
export const submissionsApiCreateImageCrop = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiCreateImageCropData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateImageCropErrors, ThrowOnError>({ export const submissionsApiCreateImageCrop = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiCreateImageCropData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiCreateImageCropResponses, unknown, ThrowOnError>({
url: '/api/events/{event_path}/submissions/crops/s/{submission_file_id}', url: '/api/events/{event_path}/submissions/crops/s/{submission_file_id}',
...options, ...options,
headers: { headers: {
@@ -308,17 +262,17 @@ export const submissionsApiCreateImageCrop = <ThrowOnError extends boolean = fal
/** /**
* Delete Image Crop * Delete Image Crop
*/ */
export const submissionsApiDeleteImageCrop = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiDeleteImageCropData, ThrowOnError>) => (options.client ?? client).delete<SubmissionsApiDeleteImageCropResponses, SubmissionsApiDeleteImageCropErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/crops/{crop_id}', ...options }); export const submissionsApiDeleteImageCrop = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiDeleteImageCropData, ThrowOnError>) => (options.client ?? client).delete<SubmissionsApiDeleteImageCropResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/submissions/crops/{crop_id}', ...options });
/** /**
* Process File Ocr * Process File Ocr
*/ */
export const submissionsApiProcessFileOcr = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiProcessFileOcrData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiProcessFileOcrResponses, SubmissionsApiProcessFileOcrErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/files/{file_id}/process-ocr', ...options }); export const submissionsApiProcessFileOcr = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiProcessFileOcrData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiProcessFileOcrResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/submissions/files/{file_id}/process-ocr', ...options });
/** /**
* Process Crop Ocr * Process Crop Ocr
*/ */
export const submissionsApiProcessCropOcr = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiProcessCropOcrData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessCropOcrErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/crops/{crop_id}/process-ocr', ...options }); export const submissionsApiProcessCropOcr = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiProcessCropOcrData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiProcessCropOcrResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/submissions/crops/{crop_id}/process-ocr', ...options });
/** /**
* Current State * Current State
@@ -334,8 +288,7 @@ export const clockApiMyStats = <ThrowOnError extends boolean = false>(options: O
* Canvas * Canvas
* *
* The one-time full-grid load. Every placement after that arrives as a * The one-time full-grid load. Every placement after that arrives as a
* single websocket message, never a re-fetch. Once placement is over the * single websocket message, never a re-fetch.
* board is shown wiped (Redis keeps the real final board, see tasks.py).
*/ */
export const placeApiCanvas = <ThrowOnError extends boolean = false>(options: Options<PlaceApiCanvasData, ThrowOnError>) => (options.client ?? client).get<PlaceApiCanvasResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/place/canvas', ...options }); export const placeApiCanvas = <ThrowOnError extends boolean = false>(options: Options<PlaceApiCanvasData, ThrowOnError>) => (options.client ?? client).get<PlaceApiCanvasResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/place/canvas', ...options });
@@ -343,9 +296,6 @@ export const placeApiCanvas = <ThrowOnError extends boolean = false>(options: Op
* Evolution Gif * Evolution Gif
* *
* Admin-only: replays the full placement history as an animated GIF. * Admin-only: replays the full placement history as an animated GIF.
*
* `players` is an optional comma-separated list of usernames to only
* replay those players' placements (combined).
*/ */
export const placeApiEvolutionGif = <ThrowOnError extends boolean = false>(options: Options<PlaceApiEvolutionGifData, ThrowOnError>) => (options.client ?? client).get<PlaceApiEvolutionGifResponses, unknown, ThrowOnError>({ export const placeApiEvolutionGif = <ThrowOnError extends boolean = false>(options: Options<PlaceApiEvolutionGifData, ThrowOnError>) => (options.client ?? client).get<PlaceApiEvolutionGifResponses, unknown, ThrowOnError>({
security: [{ security: [{
@@ -357,111 +307,6 @@ export const placeApiEvolutionGif = <ThrowOnError extends boolean = false>(optio
...options ...options
}); });
/**
* Final Png
*
* Admin-only: the real final board as a PNG, even once placement is
* over and everyone else sees it wiped.
*/
export const placeApiFinalPng = <ThrowOnError extends boolean = false>(options: Options<PlaceApiFinalPngData, ThrowOnError>) => (options.client ?? client).get<PlaceApiFinalPngResponses, unknown, ThrowOnError>({
security: [{
in: 'cookie',
name: 'sessionid',
type: 'apiKey'
}],
url: '/api/events/{event_path}/place/final.png',
...options
});
/**
* Heatmap Png
*
* Admin-only: how many times each pixel was placed, as a heatmap PNG.
*
* `players` is an optional comma-separated list of usernames to only
* count those players' placements (combined).
*/
export const placeApiHeatmapPng = <ThrowOnError extends boolean = false>(options: Options<PlaceApiHeatmapPngData, ThrowOnError>) => (options.client ?? client).get<PlaceApiHeatmapPngResponses, unknown, ThrowOnError>({
security: [{
in: 'cookie',
name: 'sessionid',
type: 'apiKey'
}],
url: '/api/events/{event_path}/place/heatmap.png',
...options
});
/**
* Heatmap Evolution Gif
*
* Admin-only: the heatmap building up over time, as an animated GIF.
*
* `players` is an optional comma-separated list of usernames to only
* count those players' placements (combined).
*/
export const placeApiHeatmapEvolutionGif = <ThrowOnError extends boolean = false>(options: Options<PlaceApiHeatmapEvolutionGifData, ThrowOnError>) => (options.client ?? client).get<PlaceApiHeatmapEvolutionGifResponses, unknown, ThrowOnError>({
security: [{
in: 'cookie',
name: 'sessionid',
type: 'apiKey'
}],
url: '/api/events/{event_path}/place/heatmap-evolution.gif',
...options
});
/**
* Heatmap Decay Gif
*
* Admin-only: the heatmap over time, but heat decays so pixels nobody
* touches anymore fade back to white.
*
* `players` is an optional comma-separated list of usernames to only
* count those players' placements (combined). `mode` is "placements"
* (decay per placement, `half_life` in placements, defaults to 10 frames'
* worth) or "time" (decay over time, `half_life` in minutes, defaults
* to 60).
*/
export const placeApiHeatmapDecayGif = <ThrowOnError extends boolean = false>(options: Options<PlaceApiHeatmapDecayGifData, ThrowOnError>) => (options.client ?? client).get<PlaceApiHeatmapDecayGifResponses, unknown, ThrowOnError>({
security: [{
in: 'cookie',
name: 'sessionid',
type: 'apiKey'
}],
url: '/api/events/{event_path}/place/heatmap-decay.gif',
...options
});
/**
* Stats
*
* Admin-only: aggregated stats about the game - totals, coverage, top
* players, most contested pixels, color usage and activity per hour.
*/
export const placeApiStats = <ThrowOnError extends boolean = false>(options: Options<PlaceApiStatsData, ThrowOnError>) => (options.client ?? client).get<PlaceApiStatsResponses, unknown, ThrowOnError>({
security: [{
in: 'cookie',
name: 'sessionid',
type: 'apiKey'
}],
url: '/api/events/{event_path}/place/stats',
...options
});
/**
* Placements Csv
*
* Admin-only: pixel placement count per player, most active first.
*/
export const placeApiPlacementsCsv = <ThrowOnError extends boolean = false>(options: Options<PlaceApiPlacementsCsvData, ThrowOnError>) => (options.client ?? client).get<PlaceApiPlacementsCsvResponses, unknown, ThrowOnError>({
security: [{
in: 'cookie',
name: 'sessionid',
type: 'apiKey'
}],
url: '/api/events/{event_path}/place/placements.csv',
...options
});
/** /**
* List Markets * List Markets
* *
File diff suppressed because it is too large Load Diff
@@ -1,33 +0,0 @@
<script setup lang="ts">
import { ref } from "vue";
import type { PlanningGameOut } from "../api/types.gen";
defineProps<{
game: PlanningGameOut;
color: string;
// Slot time, shown on the colored card (the cover has its own hover badge)
when?: string;
}>();
// Not every game has a library capsule on Steam, fall back to the colored card
const coverFailed = ref(false);
</script>
<template>
<img v-if="game.library_image && !coverFailed" :src="game.library_image" :alt="game.name" :title="game.name"
loading="lazy" class="w-full h-full object-cover object-top" @error="coverFailed = true" />
<div v-else class="planning-fallback w-full h-full flex flex-col justify-center gap-1 p-3 border-l-4"
:style="{ '--slot-color': color }">
<span class="font-semibold leading-tight text-base-content">{{ game.name }}</span>
<span v-if="when" class="text-xs text-base-content/70">{{ when }}</span>
</div>
</template>
<style scoped>
.planning-fallback {
border-color: var(--slot-color);
background: linear-gradient(160deg,
color-mix(in srgb, var(--slot-color) 35%, var(--color-base-200)),
var(--color-base-200));
}
</style>
@@ -1,127 +0,0 @@
<script setup lang="ts">
import { computed, useTemplateRef } from "vue";
import { useElementBounding, useElementSize, useWindowSize } from "@vueuse/core";
import type { PlanningSlotOut } from "../api/types.gen";
import PlanningCover from "./PlanningCover.vue";
defineProps<{ entry: PlanningSlotOut }>();
const track = useTemplateRef<HTMLElement>("track");
const stack = useTemplateRef<HTMLElement>("stack");
const { top: trackTop, height: trackHeight } = useElementBounding(track);
const { height: stackHeight } = useElementSize(stack);
const { height: windowHeight } = useWindowSize();
// The games follow the scroll: kept in the middle of the screen, but never
// leaving the slot (stuck to its top/bottom when it's only partly visible)
const offset = computed(() => {
const wanted = windowHeight.value / 2 - stackHeight.value / 2 - trackTop.value;
const max = Math.max(0, trackHeight.value - stackHeight.value);
return Math.min(max, Math.max(0, wanted));
});
</script>
<template>
<div class="planning-multi h-full rounded-md p-[2px]" :style="{ '--slot-color': entry.color }">
<div class="planning-multi-inner relative h-full rounded-[5px] p-2 flex flex-col overflow-hidden">
<div class="planning-multi-title font-black uppercase text-sm tracking-widest mb-2">
<i class="mdi mdi-star-four-points mr-1"></i>{{ entry.title }}
</div>
<div ref="track" class="relative flex-1">
<div ref="stack" class="absolute inset-x-0 flex flex-col gap-3"
:style="{ transform: `translateY(${offset}px)` }">
<component :is="game.store_url ? 'a' : 'div'" v-for="game in entry.games" :key="game.name"
:href="game.store_url || undefined" target="_blank" rel="noopener noreferrer"
class="block aspect-[2/3] rounded overflow-hidden shadow-lg bg-base-200
transition-transform hover:scale-[1.05]">
<PlanningCover :game="game" :color="entry.color" />
</component>
</div>
</div>
<!-- Light sweeping down the whole slot -->
<div class="planning-multi-sheen pointer-events-none absolute inset-0"></div>
</div>
</div>
</template>
<style scoped>
@property --planning-angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
/* Rotating gradient border + glow in the slot color */
.planning-multi {
background: conic-gradient(from var(--planning-angle),
var(--slot-color), #ffd76a, var(--slot-color), #ffffff, var(--slot-color));
box-shadow: 0 0 24px -4px var(--slot-color);
animation: planning-spin 6s linear infinite, planning-glow 3s ease-in-out infinite alternate;
}
.planning-multi-inner {
background: linear-gradient(180deg,
color-mix(in srgb, var(--slot-color) 35%, var(--color-base-200)),
var(--color-base-200) 40%,
color-mix(in srgb, var(--slot-color) 20%, var(--color-base-200)));
}
.planning-multi-title {
background: linear-gradient(90deg, #fff 20%, #ffd76a 40%, #fff 60%);
background-size: 200% 100%;
background-clip: text;
color: transparent;
animation: planning-shimmer 3s linear infinite;
}
.planning-multi-sheen {
background: linear-gradient(180deg, transparent 0%, rgb(255 255 255 / 0.12) 50%, transparent 100%);
background-size: 100% 600px;
background-repeat: no-repeat;
animation: planning-sheen 7s ease-in-out infinite;
}
@keyframes planning-spin {
to {
--planning-angle: 360deg;
}
}
@keyframes planning-glow {
to {
box-shadow: 0 0 40px 0 var(--slot-color);
}
}
@keyframes planning-shimmer {
from {
background-position: 100% 0;
}
to {
background-position: -100% 0;
}
}
@keyframes planning-sheen {
from {
background-position: 0 -600px;
}
to {
background-position: 0 calc(100% + 600px);
}
}
@media (prefers-reduced-motion: reduce) {
.planning-multi,
.planning-multi-title,
.planning-multi-sheen {
animation: none;
}
}
</style>
+3 -87
View File
@@ -2,13 +2,7 @@
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import RankBadge from "./RankBadge.vue"; import RankBadge from "./RankBadge.vue";
import { import { animationsApiResults, polylanSubmitterApiClearCache, polylanSubmitterApiGetUserInfo } from "@/api";
animationsApiResults,
opusMagnumApiListResponseFiles,
polylanSubmitterApiClearCache,
} from "@/api";
import type { SubmissionFileOut } from "@/api/types.gen";
import { queryClient, userQuery } from "@/queries";
import { useEventPath } from "@/composables/useEventPath"; import { useEventPath } from "@/composables/useEventPath";
const { t } = useI18n(); const { t } = useI18n();
@@ -66,32 +60,6 @@ const isLoading = ref(true);
const resultsData = ref<ResultsData | null>(null); const resultsData = ref<ResultsData | null>(null);
const selectedTab = ref<"overall" | "byPuzzle">("overall"); const selectedTab = ref<"overall" | "byPuzzle">("overall");
const expandedPuzzleId = ref<number | null>(null); const expandedPuzzleId = ref<number | null>(null);
// Solution viewer (staff only)
const solutionModal = ref<{ username: string; puzzleTitle: string } | null>(null);
const solutionFiles = ref<SubmissionFileOut[]>([]);
const isLoadingSolution = ref(false);
const openSolution = async (responseId: number, username: string, puzzleTitle: string) => {
solutionModal.value = { username, puzzleTitle };
solutionFiles.value = [];
isLoadingSolution.value = true;
try {
const response = await opusMagnumApiListResponseFiles({
path: { event_path: eventPath, response_id: responseId },
});
solutionFiles.value = response.data ?? [];
} catch (error) {
console.error("Error fetching solution files:", error);
} finally {
isLoadingSolution.value = false;
}
};
const closeSolution = () => {
solutionModal.value = null;
solutionFiles.value = [];
};
const userInfo = ref({ const userInfo = ref({
username: "Player", username: "Player",
rank: null as number | null, rank: null as number | null,
@@ -139,7 +107,6 @@ const getPuzzleRanking = (puzzleId: number) => {
return ranking.map((response) => { return ranking.map((response) => {
const user = resultsData.value!.users.find((u) => u.id === response.user_id) as User; const user = resultsData.value!.users.find((u) => u.id === response.user_id) as User;
return { return {
id: response.id,
user: user, user: user,
cost: response.final_cost, cost: response.final_cost,
cycles: response.final_cycles, cycles: response.final_cycles,
@@ -175,7 +142,8 @@ const clearCache = async () => {
const loadUserData = async () => { const loadUserData = async () => {
try { try {
const user = await queryClient.fetchQuery(userQuery()); const response = await polylanSubmitterApiGetUserInfo();
const user = response.data;
if (user?.is_authenticated) { if (user?.is_authenticated) {
userInfo.value.username = user.username ?? ""; userInfo.value.username = user.username ?? "";
userInfo.value.isStaff = user.is_staff || false; userInfo.value.isStaff = user.is_staff || false;
@@ -440,7 +408,6 @@ onMounted(() => {
</th> </th>
<th class="text-center">{{ t("components.results.totalWithCoef") }}</th> <th class="text-center">{{ t("components.results.totalWithCoef") }}</th>
<th class="text-right">{{ t("common.points") }}</th> <th class="text-right">{{ t("common.points") }}</th>
<th v-if="userInfo.isStaff" class="w-12"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -470,12 +437,6 @@ onMounted(() => {
<span v-else class="text-base-content/40">—</span> <span v-else class="text-base-content/40">—</span>
</td> </td>
<td class="text-right font-bold text-primary text-lg">{{ response.rank_points }}</td> <td class="text-right font-bold text-primary text-lg">{{ response.rank_points }}</td>
<td v-if="userInfo.isStaff">
<button @click="openSolution(response.id, response.user.username, puzzle.title)"
class="btn btn-ghost btn-xs" :title="t('components.results.viewSolution')">
<i class="mdi mdi-eye"></i>
</button>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -489,50 +450,5 @@ onMounted(() => {
</div> </div>
</div> </div>
</div> </div>
<!-- Solution Modal (staff only) -->
<div v-if="solutionModal" class="modal modal-open">
<div class="modal-box max-w-7xl w-full">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">
{{ t("components.results.solutionTitle", solutionModal) }}
</h3>
<button @click="closeSolution" class="btn btn-sm btn-circle btn-ghost">
<i class="mdi mdi-close"></i>
</button>
</div>
<div v-if="isLoadingSolution" class="flex justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
<div v-else-if="solutionFiles.length === 0" class="text-center py-8">
<p class="text-base-content/70">{{ t("components.results.noSolutionFiles") }}</p>
</div>
<div v-else class="space-y-4">
<div v-for="file in solutionFiles" :key="file.id" class="bg-base-200 rounded-lg p-4">
<div class="flex justify-center">
<video v-if="file.content_type.startsWith('video/')" :src="file.file_url ?? undefined" controls
autoplay loop class="max-h-[70vh] object-contain"></video>
<img v-else :src="file.file_url ?? undefined" :alt="file.original_filename"
class="max-h-[70vh] object-contain" />
</div>
<div class="flex justify-between items-center mt-2 text-sm">
<span class="text-base-content/70">{{ file.original_filename }}</span>
<a v-if="file.file_url" :href="file.file_url" target="_blank" class="btn btn-primary btn-xs">
<i class="mdi mdi-download"></i>
{{ t("common.download") }}
</a>
</div>
</div>
</div>
<div class="modal-action mt-4">
<button @click="closeSolution" class="btn btn-sm">{{ t("common.close") }}</button>
</div>
</div>
<div class="modal-backdrop" @click="closeSolution"></div>
</div>
</div> </div>
</template> </template>
@@ -1,203 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import dayjs from "dayjs";
import {
submissionsApiStopSyncScopesMutation,
submissionsApiSyncScopesMutation,
submissionsApiSyncScopesStatusOptions,
submissionsApiSyncScopesStatusQueryKey,
} from "@/api/@tanstack/vue-query.gen";
interface Props {
eventPath: string;
gamePath: string;
gameName: string;
}
const props = defineProps<Props>();
const open = defineModel<boolean>("open", { required: true });
const { t } = useI18n();
const queryClient = useQueryClient();
const path = computed(() => ({ event_path: props.eventPath, game_path: props.gamePath }));
const statusKey = computed(() => submissionsApiSyncScopesStatusQueryKey({ path: path.value }));
// Polled while open, so admins see each run land (last/next run, errors).
const { data: status, isLoading } = useQuery(
computed(() => ({
...submissionsApiSyncScopesStatusOptions({ path: path.value }),
enabled: open.value,
refetchInterval: 5000,
})),
);
const apibotToken = ref("");
const message = ref<{ type: "success" | "info" | "error"; text: string } | null>(null);
watch(open, (isOpen) => {
if (!isOpen) {
apibotToken.value = "";
message.value = null;
}
});
const refreshStatus = () => queryClient.invalidateQueries({ queryKey: statusKey.value });
const start = useMutation({
...submissionsApiSyncScopesMutation(),
onSuccess: (data) => {
apibotToken.value = "";
message.value = data.started
? { type: "success", text: t("scopeSync.started") }
: { type: "info", text: t("scopeSync.alreadyRunning") };
},
onError: () => {
message.value = { type: "error", text: t("scopeSync.startFailed") };
},
onSettled: refreshStatus,
});
const stop = useMutation({
...submissionsApiStopSyncScopesMutation(),
onSuccess: (data) => {
message.value = data.stopped
? { type: "success", text: t("scopeSync.stopped") }
: { type: "info", text: t("scopeSync.notRunning") };
},
onError: () => {
message.value = { type: "error", text: t("scopeSync.stopFailed") };
},
onSettled: refreshStatus,
});
const busy = computed(() => start.isPending.value || stop.isPending.value);
const hasToken = computed(() => status.value?.has_token ?? false);
const tokenTyped = computed(() => apibotToken.value.trim() !== "");
const submit = () => {
start.mutate({
path: path.value,
body: { apibot_token: tokenTyped.value ? apibotToken.value.trim() : null },
});
};
const AANDT_STATUSES: Record<string, string> = {
"0prep": "prep",
"1publ": "published",
"3insc": "registrations",
"5inscdone": "registrationsDone",
"7inprog": "inProgress",
"9done": "done",
"11reviewed": "reviewed",
};
const aandtStatusLabel = computed(() => {
const value = status.value?.aandt_status;
if (!value) return t("scopeSync.unknown");
return AANDT_STATUSES[value] ? t(`scopeSync.aandtStatus.${AANDT_STATUSES[value]}`) : value;
});
const formatDate = (value?: string | null) =>
value ? dayjs(value).format("DD/MM HH:mm:ss") : t("scopeSync.never");
</script>
<template>
<div :class="['modal', { 'modal-open': open }]">
<div class="modal-box w-11/12 max-w-4xl max-h-[90vh] overflow-y-auto">
<h3 class="font-bold text-xl flex items-center gap-2">
<i class="mdi mdi-sync"></i>
{{ t("scopeSync.title", { game: gameName }) }}
</h3>
<!-- What this actually does -->
<div class="mt-4 space-y-3 text-base-content/80">
<p>{{ t("scopeSync.intro") }}</p>
<ul class="list-disc pl-6 space-y-1">
<li>{{ t("scopeSync.whatScopes") }}</li>
<li>{{ t("scopeSync.whatPlayers") }}</li>
<li>{{ t("scopeSync.whatClose") }}</li>
<li>{{ t("scopeSync.whatLink") }}</li>
<li>{{ t("scopeSync.whatLive") }}</li>
</ul>
</div>
<div class="alert alert-info mt-4 items-start">
<i class="mdi mdi-autorenew text-2xl"></i>
<div>
<h4 class="font-semibold">{{ t("scopeSync.autoTitle") }}</h4>
<p class="text-sm">{{ t("scopeSync.autoInterval") }}</p>
<p class="text-sm">{{ t("scopeSync.autoStop") }}</p>
<p class="text-sm">{{ t("scopeSync.autoErrors") }}</p>
</div>
</div>
<!-- Current state -->
<div class="mt-6">
<h4 class="font-semibold mb-2">{{ t("scopeSync.statusTitle") }}</h4>
<div v-if="isLoading" class="flex justify-center py-6">
<span class="loading loading-spinner"></span>
</div>
<div v-else-if="status" class="stats stats-vertical md:stats-horizontal shadow w-full">
<div class="stat">
<div class="stat-title">{{ t("scopeSync.loop") }}</div>
<div class="stat-value text-lg flex items-center gap-2">
<span :class="['badge', status.running ? 'badge-success' : 'badge-ghost']">
{{ status.running ? t("scopeSync.running") : t("scopeSync.notRunningShort") }}
</span>
</div>
<div class="stat-desc">
{{ t("scopeSync.nextRun") }}: {{ status.running ? formatDate(status.next_run_at) : "—" }}
</div>
</div>
<div class="stat">
<div class="stat-title">{{ t("scopeSync.aandt") }}</div>
<div class="stat-value text-lg">{{ aandtStatusLabel }}</div>
<div class="stat-desc">{{ t("scopeSync.lastRun") }}: {{ formatDate(status.last_run_at) }}</div>
</div>
<div class="stat">
<div class="stat-title">{{ t("scopeSync.scopes") }}</div>
<div class="stat-value text-lg">{{ status.active_scope_count }} / {{ status.scope_count }}</div>
<div class="stat-desc">{{ t("scopeSync.activeOfTotal") }}</div>
</div>
</div>
<div v-if="status?.error" class="alert alert-error mt-3">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ t("scopeSync.lastError") }}: {{ status.error }}</span>
</div>
</div>
<!-- Token -->
<form class="mt-6" @submit.prevent="submit">
<label class="font-semibold block mb-1" for="apibot-token">{{ t("scopeSync.tokenTitle") }}</label>
<p class="text-sm text-base-content/70 mb-2">
{{ hasToken ? t("scopeSync.tokenReplace") : t("scopeSync.tokenMissing") }}
</p>
<input id="apibot-token" v-model="apibotToken" type="password" autocomplete="off"
:placeholder="hasToken ? t('scopeSync.tokenConfigured') : t('scopeSync.tokenPlaceholder')"
class="input input-bordered w-full" />
<div v-if="message" :class="['alert mt-4', `alert-${message.type}`]">{{ message.text }}</div>
<div class="modal-action flex-wrap">
<button type="button" @click="open = false" class="btn">{{ t("common.close") }}</button>
<button v-if="status?.running" type="button" :disabled="busy" class="btn btn-error"
@click="stop.mutate({ path })">
<span v-if="stop.isPending.value" class="loading loading-spinner loading-xs"></span>
<i v-else class="mdi mdi-stop"></i>
{{ t("scopeSync.stop") }}
</button>
<button type="submit" class="btn btn-primary"
:disabled="busy || (!hasToken && !tokenTyped) || (status?.running && !tokenTyped)">
<span v-if="start.isPending.value" class="loading loading-spinner loading-xs"></span>
<i v-else class="mdi mdi-play"></i>
{{ status?.running ? t("scopeSync.saveToken") : t("scopeSync.start") }}
</button>
</div>
</form>
</div>
<div class="modal-backdrop" @click="open = false"></div>
</div>
</template>
@@ -1,44 +0,0 @@
<script setup lang="ts">
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import type { SteamGameOut } from "../api/types.gen";
defineProps<{ game: SteamGameOut }>();
const { t } = useI18n();
// Not every game has a library capsule on Steam, fall back to the header + name
const capsuleFailed = ref(false);
</script>
<template>
<div class="group relative aspect-[2/3] rounded-md overflow-hidden bg-base-200 hover:bg-base-100
shadow-xl hover:shadow-2xl transition-all transform hover:-translate-y-2 hover:scale-[1.05]">
<a :href="game.store_url" target="_blank" rel="noopener noreferrer" :title="game.name" class="block w-full h-full">
<img v-if="!capsuleFailed" :src="game.library_image" :alt="game.name" loading="lazy"
class="w-full h-full object-cover" @error="capsuleFailed = true" />
<div v-else class="w-full h-full flex flex-col justify-center gap-4 p-3 bg-gradient-to-b from-base-100 to-base-300">
<img :src="game.header_image" :alt="game.name" loading="lazy" class="w-full rounded-sm" />
<span class="text-center font-medium line-clamp-3">{{ game.name }}</span>
</div>
</a>
<!-- Opens the Steam client: launches the game if installed, offers to install it otherwise -->
<a :href="`steam://run/${game.steam_app_id}`" :title="t('games.play')"
class="steam-play absolute bottom-2 right-2 rounded-full w-10 h-10 flex items-center justify-center text-2xl
opacity-0 translate-y-1 transition-all group-hover:opacity-100 group-hover:translate-y-0 focus:opacity-100">
<i class="mdi mdi-play"></i>
</a>
</div>
</template>
<style scoped>
.steam-play {
color: #ffffff;
background: linear-gradient(to right, #47bfff 5%, #1a44c2 95%);
}
.steam-play:hover {
background: linear-gradient(to right, #6fceff 5%, #2d5fd8 95%);
}
</style>
@@ -20,41 +20,41 @@ const isDiscounted = computed(() => hasPrice.value && props.game.discount_percen
<template> <template>
<div <div
class="card card-xl bg-base-200 hover:bg-base-100 overflow-hidden shadow-xl hover:shadow-2xl transition-all transform hover:-translate-y-2 hover:scale-[1.05]"> class="steam-card flex flex-col overflow-hidden rounded-lg shadow-xl transition-transform hover:-translate-y-1">
<a :href="game.store_url" target="_blank" rel="noopener noreferrer"> <a :href="game.store_url" target="_blank" rel="noopener noreferrer">
<img :src="game.header_image" :alt="game.name" loading="lazy" <img :src="game.header_image" :alt="game.name" loading="lazy"
class="w-full aspect-[460/215] object-cover" /> class="w-full aspect-[460/215] object-cover" />
</a> </a>
<div class="card-body min-w-0"> <div class="flex flex-col flex-1 min-w-0 p-6 gap-3">
<a :href="game.store_url" target="_blank" rel="noopener noreferrer" <a :href="game.store_url" target="_blank" rel="noopener noreferrer"
class="card-title text-2xl block truncate hover:underline"> class="steam-title text-2xl font-semibold truncate hover:underline">
{{ t("games.buy", { game: game.name }) }} {{ t("games.buy", { game: game.name }) }}
</a> </a>
<p v-if="game.short_description" class="text-sm text-base-content/70 line-clamp-3"> <p v-if="game.short_description" class="steam-text line-clamp-3">
{{ game.short_description }} {{ game.short_description }}
</p> </p>
<div class="mt-auto pt-2 flex flex-wrap sm:flex-nowrap justify-end gap-2 whitespace-nowrap"> <div class="mt-auto pt-2 flex flex-wrap justify-end gap-2">
<!-- Opens the Steam client: launches the game if installed, offers to install it otherwise --> <!-- Opens the Steam client: launches the game if installed, offers to install it otherwise -->
<a :href="`steam://run/${game.steam_app_id}`" <a :href="`steam://run/${game.steam_app_id}`"
class="steam-play rounded-sm px-5 py-2 flex items-center gap-1"> class="steam-play rounded-sm px-5 py-2 flex items-center gap-1">
<i class="mdi mdi-play"></i> <i class="mdi mdi-play"></i>
{{ t("games.play") }} {{ t("games.play") }}
</a> </a>
<div class="bg-base-300 flex items-stretch rounded-sm overflow-hidden"> <div class="steam-purchase flex items-stretch rounded-sm overflow-hidden">
<template v-if="game.is_free"> <template v-if="game.is_free">
<span class="px-3 py-2 flex items-center">{{ t("games.free") }}</span> <span class="steam-price px-3 py-2 flex items-center">{{ t("games.free") }}</span>
</template> </template>
<template v-else-if="hasPrice"> <template v-else-if="hasPrice">
<span v-if="isDiscounted" class="steam-discount px-3 flex items-center text-xl font-medium"> <span v-if="isDiscounted" class="steam-discount px-3 flex items-center text-xl font-medium">
-{{ game.discount_percent }}% -{{ game.discount_percent }}%
</span> </span>
<span class="px-3 flex flex-col justify-center leading-tight"> <span class="steam-price px-3 flex flex-col justify-center leading-tight">
<span v-if="isDiscounted" class="text-base-content/50 text-xs line-through"> <span v-if="isDiscounted" class="steam-original text-xs line-through">
{{ formatPrice(game.price_initial!) }} {{ formatPrice(game.price_initial!) }}
</span> </span>
<span :class="{ 'text-success font-medium': isDiscounted }">{{ formatPrice(game.price_final!) }}</span> <span :class="{ 'steam-discounted': isDiscounted }">{{ formatPrice(game.price_final!) }}</span>
</span> </span>
</template> </template>
<a :href="game.store_url" target="_blank" rel="noopener noreferrer" <a :href="game.store_url" target="_blank" rel="noopener noreferrer"
@@ -69,12 +69,40 @@ const isDiscounted = computed(() => hasPrice.value && props.game.discount_percen
</template> </template>
<style scoped> <style scoped>
/* Steam palette for the purchase buttons, the card itself follows the site theme */ /* Steam store palette, on purpose regardless of the site theme */
.steam-card {
background: linear-gradient(135deg, #1b2838 0%, #2a475e 100%);
}
.steam-title {
color: #ffffff;
}
.steam-text {
color: #c6d4df;
}
.steam-purchase {
background: rgba(0, 0, 0, 0.2);
}
.steam-discount { .steam-discount {
background: #4c6b22; background: #4c6b22;
color: #beee11; color: #beee11;
} }
.steam-price {
color: #c6d4df;
}
.steam-original {
color: #738895;
}
.steam-discounted {
color: #beee11;
}
.steam-button { .steam-button {
color: #d2efa9; color: #d2efa9;
background: linear-gradient(to right, #75b022 5%, #588a1b 95%); background: linear-gradient(to right, #75b022 5%, #588a1b 95%);
@@ -1,10 +0,0 @@
import { watch, type Ref } from "vue";
import { usePageHeaderStore, type LiveKind } from "@/stores/pageHeader";
// Shows the page's live (websocket) state as a badge mid-header, with a
// popover explaining what it means on this page (liveStatus.<kind> strings).
// Reset by the router on the next navigation.
export const useLiveBadge = (kind: LiveKind, connected: Ref<boolean>) => {
const pageHeaderStore = usePageHeaderStore();
watch(connected, (value) => pageHeaderStore.setLive(value, kind), { immediate: true });
};
@@ -1,4 +1,4 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useWebSocket } from '@vueuse/core' import { useWebSocket } from '@vueuse/core'
import { placeApiCanvas } from '@/api' import { placeApiCanvas } from '@/api'
@@ -16,7 +16,6 @@ const PLACE_COOLDOWN_SECONDS = 30
export type OnPixel = (x: number, y: number, colorIndex: number) => void export type OnPixel = (x: number, y: number, colorIndex: number) => void
export type OnResize = (width: number, height: number) => void export type OnResize = (width: number, height: number) => void
export type OnWipe = () => void
// Plain (non-reactive) - a several-thousand-cell reactive array is // Plain (non-reactive) - a several-thousand-cell reactive array is
// pointless overhead. Kept module-level, alongside its dimensions, so the // pointless overhead. Kept module-level, alongside its dimensions, so the
@@ -27,18 +26,9 @@ let grid = new Uint8Array(DEFAULT_GRID_SIZE * DEFAULT_GRID_SIZE)
const gridWidth = ref(DEFAULT_GRID_SIZE) const gridWidth = ref(DEFAULT_GRID_SIZE)
const gridHeight = ref(DEFAULT_GRID_SIZE) const gridHeight = ref(DEFAULT_GRID_SIZE)
export function usePlaceSocket( export function usePlaceSocket(eventPath: string, onPixel?: OnPixel, onResize?: OnResize) {
eventPath: string,
onPixel?: OnPixel,
onResize?: OnResize,
onWipe?: OnWipe,
) {
const cooldownEndMs = ref<number | null>(null) const cooldownEndMs = ref<number | null>(null)
const cooldownRemainingMs = ref(0) const cooldownRemainingMs = ref(0)
// PlaceSettings.placement_ends_at - the server rejects placements past it
// anyway, this just lets the UI say so.
const placementEndMs = ref<number | null>(null)
const placementEnded = ref(false)
let animationFrame: number | null = null let animationFrame: number | null = null
@@ -46,23 +36,9 @@ export function usePlaceSocket(
cooldownEndMs.value = nextMs ?? null cooldownEndMs.value = nextMs ?? null
} }
// Returns true if placement went from over to open again (an admin moved
// the end time later or cleared it), so the caller can bring the real
// board back - the server serves it again.
function applyPlacementEnd(endMs: string): boolean {
const wasEnded = placementEnded.value
placementEndMs.value = endMs !== '' ? Number(endMs) : null
placementEnded.value = placementEndMs.value !== null && placementEndMs.value <= Date.now()
return wasEnded && !placementEnded.value
}
function tickCooldown() { function tickCooldown() {
const now = Date.now()
cooldownRemainingMs.value = cooldownRemainingMs.value =
cooldownEndMs.value !== null ? Math.max(0, cooldownEndMs.value - now) : 0 cooldownEndMs.value !== null ? Math.max(0, cooldownEndMs.value - Date.now()) : 0
// Only ever flips it on: the server's own 'w'/'r|d' may beat a skewed
// local clock, and reopening goes through applyPlacementEnd.
if (placementEndMs.value !== null && placementEndMs.value <= now) placementEnded.value = true
animationFrame = requestAnimationFrame(tickCooldown) animationFrame = requestAnimationFrame(tickCooldown)
} }
@@ -89,10 +65,9 @@ export function usePlaceSocket(
switch (parts[0]) { switch (parts[0]) {
case 's': { case 's': {
const [, next, width, height, end] = parts const [, next, width, height] = parts
applyCooldown(next !== '' ? Number(next) : null) applyCooldown(next !== '' ? Number(next) : null)
applyGridSize(Number(width), Number(height)) applyGridSize(Number(width), Number(height))
applyPlacementEnd(end)
break break
} }
case 'p': { case 'p': {
@@ -106,24 +81,14 @@ export function usePlaceSocket(
} }
case 'r': { case 'r': {
if (parts[1] === 'c') applyCooldown(Number(parts[2])) if (parts[1] === 'c') applyCooldown(Number(parts[2]))
if (parts[1] === 'd') placementEnded.value = true
break break
} }
case 'e': { case 'e': {
const [, width, height, end] = parts const [, width, height] = parts
const reopened = applyPlacementEnd(end)
// The server already rebuilt its canvas at the new size (see // The server already rebuilt its canvas at the new size (see
// place/signals.py) - just re-fetch it rather than guess how to // place/signals.py) - just re-fetch it rather than guess how to
// remap our local pixels. // remap our local pixels.
if (applyGridSize(Number(width), Number(height)) || reopened) loadCanvas() if (applyGridSize(Number(width), Number(height))) loadCanvas()
break
}
case 'w': {
// Placement is over: the server now serves a blank canvas, match it
// locally and let the page animate the wipe instead of redrawing.
placementEnded.value = true
grid.fill(0)
onWipe?.()
break break
} }
default: default:
@@ -143,15 +108,6 @@ export function usePlaceSocket(
const connected = computed(() => status.value === 'OPEN') const connected = computed(() => status.value === 'OPEN')
// Pixels placed while disconnected never reach us: catch up by reloading
// the whole canvas on every reconnect (the first load is done on mount).
let hasConnected = false
watch(connected, (isConnected) => {
if (!isConnected) return
if (hasConnected) loadCanvas()
hasConnected = true
})
async function loadCanvas() { async function loadCanvas() {
const response = await placeApiCanvas({ path: { event_path: eventPath } }) const response = await placeApiCanvas({ path: { event_path: eventPath } })
if (!response.data) return if (!response.data) return
@@ -186,8 +142,6 @@ export function usePlaceSocket(
return { return {
connected, connected,
cooldownRemainingMs, cooldownRemainingMs,
placementEndMs,
placementEnded,
cooldownDurationSeconds: PLACE_COOLDOWN_SECONDS, cooldownDurationSeconds: PLACE_COOLDOWN_SECONDS,
gridWidth, gridWidth,
gridHeight, gridHeight,
@@ -1,11 +1,9 @@
import { ref, computed } from 'vue' import { ref, computed, onBeforeUnmount } from 'vue'
import { useWebSocket } from '@vueuse/core' import { useWebSocket } from '@vueuse/core'
import { useLiveBadge } from '@/composables/useLiveBadge'
interface SubmissionUpdate { interface SubmissionUpdate {
id: string id: string
scope_id?: string scope_id?: string
username?: string | null
notes?: string notes?: string
files?: SubmissionFileUpdate[] files?: SubmissionFileUpdate[]
created_at: string created_at: string
@@ -25,7 +23,6 @@ interface SubmissionFileUpdate {
interface ImageCropUpdate { interface ImageCropUpdate {
id: string id: string
submission_file_id: string
x1: number x1: number
y1: number y1: number
x2: number x2: number
@@ -36,36 +33,13 @@ interface ImageCropUpdate {
created_at: string created_at: string
} }
// A scope (match) the user was or is assigned to opened/closed, or the user type WebSocketData = SubmissionUpdate | SubmissionFileUpdate | ImageCropUpdate
// got (un)assigned from it - see broadcast_scope_updates in signals.py.
export interface ScopeUpdate {
id: string
is_active: boolean
assigned: boolean
}
// See broadcast_image_crop_delete in signals.py export function useSubmissionsWebSocket(userSubmissions: any) {
interface DeletedCrop {
id: string
submission_file_id: string
}
type WebSocketData =
| { deleted_crop: DeletedCrop }
| SubmissionUpdate
| SubmissionFileUpdate
| ImageCropUpdate
| { scope: ScopeUpdate }
interface Options {
userSubmissions?: any
// Only submissions of this scope get added to the list (staff get them all)
scopeId?: string
onScopeUpdate?: (scope: ScopeUpdate) => void
}
export function useSubmissionsWebSocket({ userSubmissions, scopeId, onScopeUpdate }: Options) {
const isConnected = ref(false) const isConnected = ref(false)
const reconnectAttempts = ref(0)
const maxReconnectAttempts = 10
const reconnectDelay = ref(3000)
const wsUrl = computed(() => { const wsUrl = computed(() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -79,11 +53,14 @@ export function useSubmissionsWebSocket({ userSubmissions, scopeId, onScopeUpdat
} = useWebSocket(wsUrl.value, { } = useWebSocket(wsUrl.value, {
onConnected: () => { onConnected: () => {
isConnected.value = true isConnected.value = true
reconnectAttempts.value = 0
reconnectDelay.value = 3000
console.log('✓ Connected to submissions WebSocket') console.log('✓ Connected to submissions WebSocket')
}, },
onDisconnected: () => { onDisconnected: () => {
isConnected.value = false isConnected.value = false
console.log('✗ Disconnected from submissions WebSocket') console.log('✗ Disconnected from submissions WebSocket, attempting to reconnect...')
attemptReconnect()
}, },
onError: (event) => { onError: (event) => {
console.error('WebSocket error:', event) console.error('WebSocket error:', event)
@@ -93,13 +70,7 @@ export function useSubmissionsWebSocket({ userSubmissions, scopeId, onScopeUpdat
const messageData = event.data as string const messageData = event.data as string
const data = JSON.parse(messageData) as WebSocketData const data = JSON.parse(messageData) as WebSocketData
if ('scope' in data) { if ('files' in data) {
onScopeUpdate?.(data.scope)
} else if (!userSubmissions) {
return
} else if ('deleted_crop' in data) {
handleCropDelete(data.deleted_crop)
} else if ('files' in data) {
handleSubmissionUpdate(data as SubmissionUpdate) handleSubmissionUpdate(data as SubmissionUpdate)
} else if ('original_filename' in data) { } else if ('original_filename' in data) {
handleFileUpdate(data as SubmissionFileUpdate) handleFileUpdate(data as SubmissionFileUpdate)
@@ -111,21 +82,25 @@ export function useSubmissionsWebSocket({ userSubmissions, scopeId, onScopeUpdat
} }
}, },
immediate: true, immediate: true,
// vueuse's own reconnect stops once close() is called (it also closes
// on unmount), unlike a hand-rolled setTimeout(open) that would reopen
// the socket after leaving the page.
autoReconnect: {
retries: 10,
delay: 3000,
onFailed: () => console.error('Max reconnection attempts reached'),
},
}) })
useLiveBadge('submissions', isConnected) const attemptReconnect = () => {
if (reconnectAttempts.value >= maxReconnectAttempts) {
console.error('Max reconnection attempts reached')
return
}
reconnectAttempts.value++
const delay = reconnectDelay.value * Math.pow(1.5, reconnectAttempts.value - 1)
console.log(`Reconnecting (attempt ${reconnectAttempts.value}/${maxReconnectAttempts}) in ${Math.round(delay / 1000)}s...`)
setTimeout(() => {
open()
}, delay)
}
const handleSubmissionUpdate = (submission: SubmissionUpdate) => { const handleSubmissionUpdate = (submission: SubmissionUpdate) => {
if (scopeId && submission.scope_id !== scopeId) return
const existingIndex = userSubmissions.value.findIndex((s: any) => s.id === submission.id) const existingIndex = userSubmissions.value.findIndex((s: any) => s.id === submission.id)
if (existingIndex >= 0) { if (existingIndex >= 0) {
@@ -152,8 +127,6 @@ export function useSubmissionsWebSocket({ userSubmissions, scopeId, onScopeUpdat
} }
const handleCropUpdate = (crop: ImageCropUpdate) => { const handleCropUpdate = (crop: ImageCropUpdate) => {
let found = false
userSubmissions.value.forEach((submission: any) => { userSubmissions.value.forEach((submission: any) => {
if (!submission.files) return if (!submission.files) return
@@ -164,38 +137,15 @@ export function useSubmissionsWebSocket({ userSubmissions, scopeId, onScopeUpdat
if (cropIndex >= 0) { if (cropIndex >= 0) {
// Merge into existing object to preserve references // Merge into existing object to preserve references
Object.assign(file.crops[cropIndex], crop) Object.assign(file.crops[cropIndex], crop)
found = true
console.log('Updated crop:', crop.id) console.log('Updated crop:', crop.id)
} }
}) })
}) })
if (!found) handleNewCrop(crop)
} }
const handleCropDelete = (crop: DeletedCrop) => { onBeforeUnmount(() => {
userSubmissions.value.forEach((submission: any) => { close()
submission.files?.forEach((file: any) => {
if (file.id !== crop.submission_file_id || !file.crops) return
file.crops = file.crops.filter((c: any) => c.id !== crop.id)
console.log('Deleted crop:', crop.id)
}) })
})
}
// New crop (e.g. drawn by another admin): add it to its file
const handleNewCrop = (crop: ImageCropUpdate) => {
userSubmissions.value.forEach((submission: any) => {
submission.files?.forEach((file: any) => {
if (file.id !== crop.submission_file_id) return
file.crops = file.crops || []
file.crops.push(crop)
console.log('Added crop:', crop.id)
})
})
}
return { return {
isConnected, isConnected,
@@ -1,14 +0,0 @@
import { computed } from "vue";
import { useQuery } from "@tanstack/vue-query";
import { userQuery } from "@/queries";
export const useUser = () => {
const { data } = useQuery(userQuery());
return computed(() => ({
username: data.value?.username ?? "",
isAuthenticated: data.value?.is_authenticated ?? false,
isStaff: data.value?.is_staff ?? false,
isSuperuser: data.value?.is_superuser ?? false,
}));
};
+8 -46
View File
@@ -3,27 +3,16 @@ import { computed } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { useUser } from "@/composables/useUser"; import { useUserStore } from "@/stores/user";
import { usePageHeaderStore } from "@/stores/pageHeader"; import { usePageHeaderStore } from "@/stores/pageHeader";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { t, tm, rt } = useI18n(); const { t } = useI18n();
const userInfo = useUser(); const userStore = useUserStore();
const { userInfo } = storeToRefs(userStore);
const pageHeaderStore = usePageHeaderStore(); const pageHeaderStore = usePageHeaderStore();
const { title: dynamicTitle, backRoute: dynamicBackRoute, live, liveKind } = storeToRefs(pageHeaderStore); const { title: dynamicTitle, backRoute: dynamicBackRoute } = storeToRefs(pageHeaderStore);
// Popover text of the live badge, per page (liveStatus.<kind>.live/offline)
const liveHelp = computed(() => {
if (!liveKind.value) return null;
const key = `liveStatus.${liveKind.value}.${live.value ? "live" : "offline"}`;
return {
title: t(`${key}.title`),
intro: t(`${key}.intro`),
points: (tm(`${key}.points`) as unknown[]).map((point) => rt(point as Parameters<typeof rt>[0])),
outro: t(`${key}.outro`),
};
});
const title = computed(() => { const title = computed(() => {
if (dynamicTitle.value) return dynamicTitle.value; if (dynamicTitle.value) return dynamicTitle.value;
@@ -41,39 +30,13 @@ const goBack = () => {
<template> <template>
<div class="navbar bg-base-100 shadow-lg"> <div class="navbar bg-base-100 shadow-lg">
<!-- 3 columns so the live badge stays centered whatever the sides' width --> <div class="container min-w-3/4 mx-auto w-full flex items-center gap-4">
<div class="container min-w-3/4 mx-auto w-full grid grid-cols-[1fr_auto_1fr] items-center gap-4">
<div class="flex items-center gap-4 min-w-0">
<button v-if="route.meta.getBackRoute" @click="goBack" class="btn btn-primary btn-sm"> <button v-if="route.meta.getBackRoute" @click="goBack" class="btn btn-primary btn-sm">
<i class="mdi mdi-arrow-left"></i> <i class="mdi mdi-arrow-left"></i>
{{ t("common.back") }} {{ t("common.back") }}
</button> </button>
<h1 class="text-xl font-bold truncate">{{ title }}</h1> <h1 class="text-xl font-bold">{{ title }}</h1>
</div> <div class="flex-1"></div>
<div>
<!-- Hover (or tap, on mobile) to explain what live means for the player -->
<div v-if="live !== null" class="dropdown dropdown-hover dropdown-center">
<div tabindex="0" role="button" :class="['badge cursor-help', live ? 'badge-success' : 'badge-warning']">
<i :class="['mdi mr-1', live ? 'mdi-wifi' : 'mdi-wifi-off']"></i>
{{ live ? t("submissions.live") : t("submissions.offline") }}
<i class="mdi mdi-information-outline ml-1 opacity-70"></i>
</div>
<div tabindex="0" class="dropdown-content card card-sm bg-base-200 shadow-xl z-50 w-80 mt-2">
<div v-if="liveHelp" class="card-body">
<h3 class="card-title text-base">
<i :class="['mdi', live ? 'mdi-wifi text-success' : 'mdi-wifi-off text-warning']"></i>
{{ liveHelp.title }}
</h3>
<p>{{ liveHelp.intro }}</p>
<ul v-if="liveHelp.points.length" class="list-disc pl-5 space-y-1">
<li v-for="point in liveHelp.points" :key="point">{{ point }}</li>
</ul>
<p class="text-base-content/70">{{ liveHelp.outro }}</p>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-end gap-4">
<div v-if="userInfo.isAuthenticated" class="flex items-center gap-2"> <div v-if="userInfo.isAuthenticated" 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>
@@ -85,5 +48,4 @@ const goBack = () => {
<a v-if="userInfo.isStaff" href="/admin" class="btn btn-xs btn-warning">{{ t("common.adminPanel") }}</a> <a v-if="userInfo.isStaff" href="/admin" class="btn btn-xs btn-warning">{{ t("common.adminPanel") }}</a>
</div> </div>
</div> </div>
</div>
</template> </template>
+8 -129
View File
@@ -24,7 +24,6 @@
"puzzle": "Puzzle", "puzzle": "Puzzle",
"files": "Files", "files": "Files",
"date": "Date", "date": "Date",
"user": "User",
"notes": "Notes", "notes": "Notes",
"actions": "Actions", "actions": "Actions",
"noRankYet": "No rank yet", "noRankYet": "No rank yet",
@@ -66,7 +65,6 @@
"subtitle": "Choose a game and submit your best solutions", "subtitle": "Choose a game and submit your best solutions",
"submitResults": "Submit results", "submitResults": "Submit results",
"getTheGames": "Get the games", "getTheGames": "Get the games",
"planning": "Planning",
"footer": "Select a game above to begin submitting" "footer": "Select a game above to begin submitting"
}, },
"games": { "games": {
@@ -77,17 +75,13 @@
"buy": "Buy {game}", "buy": "Buy {game}",
"free": "Free to Play", "free": "Free to Play",
"viewOnSteam": "View on Steam", "viewOnSteam": "View on Steam",
"play": "Play", "play": "Play"
"showPrices": "Show prices"
},
"planning": {
"header": "Planning",
"title": "Planning",
"empty": "No planning for this event yet"
}, },
"clock": { "clock": {
"pageTitle": "Beat the Clock", "pageTitle": "Beat the Clock",
"header": "Beat the Clock", "header": "Beat the Clock",
"connected": "connected",
"reconnecting": "reconnecting…",
"press": "PRESS", "press": "PRESS",
"nextPlayer": "▲ next player", "nextPlayer": "▲ next player",
"chasingYou": "▼ chasing you", "chasingYou": "▼ chasing you",
@@ -96,10 +90,10 @@
"place": { "place": {
"pageTitle": "Place", "pageTitle": "Place",
"header": "Place", "header": "Place",
"connected": "connected",
"reconnecting": "reconnecting…",
"showGrid": "Show grid", "showGrid": "Show grid",
"hideGrid": "Hide grid", "hideGrid": "Hide grid"
"placementEnded": "Pixel placement is over",
"endsAt": "Ends at {time}"
}, },
"market": { "market": {
"pageTitle": "Market", "pageTitle": "Market",
@@ -178,7 +172,6 @@
} }
}, },
"yourSubmissions": "Your Submissions", "yourSubmissions": "Your Submissions",
"allSubmissions": "All Submissions",
"noSubmissionsHint": "Upload your first submission above to get started", "noSubmissionsHint": "Upload your first submission above to get started",
"fileStatus": "File Status", "fileStatus": "File Status",
"ocrStatus": "OCR Status", "ocrStatus": "OCR Status",
@@ -216,118 +209,7 @@
"title": "Select your match", "title": "Select your match",
"description": "You're assigned to multiple matches for {game}. Pick which one you want to submit for.", "description": "You're assigned to multiple matches for {game}. Pick which one you want to submit for.",
"notAssignedTitle": "No match assigned", "notAssignedTitle": "No match assigned",
"notAssignedMessage": "You are not assigned to a scope/matches, you cannot submit a score.", "notAssignedMessage": "You are not assigned to a scope/matches, you cannot submit a score."
"syncScopes": "Sync scopes"
},
"scopeSync": {
"title": "AAndT scope sync — {game}",
"intro": "Keeps this game's matches (scopes) in line with its AAndT on the PolyLAN site, using the AAndT's apibot API.",
"whatScopes": "Every AAndT match becomes a scope here, created or updated (name, open/closed) on each sync.",
"whatPlayers": "Players of each match are assigned to its scope, matched through their PolyLAN account. Players without an account here yet are skipped until they log in once.",
"whatClose": "A scope is open only while its match is playing. Finished matches, and matches removed from the AAndT, get closed.",
"whatLink": "Each playing match gets a \"Submit your proof here\" link on the PolyLAN site, pointing to its submission page here.",
"whatLive": "Players are moved live: when their match closes, they're sent back to the match list, and straight into their next match if there's only one. No refresh needed.",
"autoTitle": "Starts once, then keeps going on its own",
"autoInterval": "After starting, the sync re-triggers itself: every 15 seconds while the AAndT is in progress, every minute before that (registrations, preparation, ...).",
"autoStop": "It stops by itself once the AAndT is done or reviewed, or when you stop it here (or with the \"Stop scope sync\" admin action). Scopes are kept as they are when it stops.",
"autoErrors": "If a sync fails (AAndT site down, wrong token, ...), it's retried a minute later. The last error is shown below.",
"statusTitle": "Current state",
"loop": "Sync",
"running": "Running",
"notRunningShort": "Stopped",
"nextRun": "Next run",
"lastRun": "Last run",
"aandt": "AAndT status",
"scopes": "Scopes",
"activeOfTotal": "open / total",
"lastError": "Last run failed",
"never": "never",
"unknown": "Unknown",
"aandtStatus": {
"prep": "Preparation",
"published": "Published",
"registrations": "Registrations",
"registrationsDone": "Registrations closed",
"inProgress": "In progress",
"done": "Done",
"reviewed": "Reviewed"
},
"tokenTitle": "AAndT apibot token",
"tokenMissing": "No token is configured for this game yet. Paste the one from the AAndT's \"apibot\" admin page on the PolyLAN site.",
"tokenReplace": "A token is already configured. Paste a new one only to replace it (e.g. for a new AAndT); a running sync picks it up on its next run.",
"tokenPlaceholder": "Apibot token",
"tokenConfigured": "Token configured (leave empty to keep it)",
"start": "Start sync",
"saveToken": "Save token",
"stop": "Stop sync",
"started": "Sync started. It keeps running on its own until the AAndT is done.",
"alreadyRunning": "Sync is already running.",
"startFailed": "Failed to start the sync.",
"stopped": "Sync stopped.",
"notRunning": "Sync wasn't running.",
"stopFailed": "Failed to stop the sync."
},
"liveStatus": {
"submissions": {
"live": {
"title": "You're connected live",
"intro": "This page updates on its own, you don't need to refresh it:",
"points": [
"Your submissions update as they're processed (uploaded files, text read from your screenshots).",
"When your match ends, you're sent back to the match list, and straight into your next match if you have only one."
],
"outro": "Just keep this page open between matches."
},
"offline": {
"title": "Live connection lost",
"intro": "The page can't get updates right now:",
"points": [
"It won't move you to your next match on its own.",
"Processing results of your submissions won't show up by themselves."
],
"outro": "It reconnects automatically, usually within a few seconds. Still offline after a while? Refresh the page, anything you already submitted is saved."
}
},
"clock": {
"live": {
"title": "You're connected live",
"intro": "Everything here is real time, shared with every player:",
"points": [
"The countdown is the same for everyone, and moves as soon as someone presses.",
"Your points and rank update right after each of your presses."
],
"outro": "Presses go through this live connection, keep it green to play."
},
"offline": {
"title": "Live connection lost",
"intro": "You can't play right now:",
"points": [
"Your presses can't reach the server.",
"The countdown shown may be off, other players' presses aren't coming in."
],
"outro": "It reconnects automatically within a second or so, and your stats are refreshed when it does. Still offline? Refresh the page."
}
},
"place": {
"live": {
"title": "You're connected live",
"intro": "The canvas is shared with every player, in real time:",
"points": [
"Pixels placed by others show up as soon as they're placed.",
"Your pixels go through this live connection, then the cooldown starts."
],
"outro": "Just keep this page open, no refresh needed."
},
"offline": {
"title": "Live connection lost",
"intro": "You can't place pixels right now:",
"points": [
"Your clicks can't reach the server.",
"Pixels placed by others aren't coming in, the canvas may be out of date."
],
"outro": "It reconnects automatically within a second or so, and reloads the whole canvas to catch up. Still offline? Refresh the page."
}
}
}, },
"errorPage": { "errorPage": {
"notFound": { "notFound": {
@@ -479,10 +361,7 @@
"second": "🥈 2nd Place", "second": "🥈 2nd Place",
"third": "🥉 3rd Place" "third": "🥉 3rd Place"
}, },
"totalWithCoef": "Total (with coef.)", "totalWithCoef": "Total (with coef.)"
"viewSolution": "View solution",
"solutionTitle": "{username} — {puzzleTitle}",
"noSolutionFiles": "No file attached to this solution"
}, },
"topUsersLeaderboard": { "topUsersLeaderboard": {
"title": "Top 10 Users" "title": "Top 10 Users"
-7
View File
@@ -1,19 +1,12 @@
import { createApp } from "vue"; import { createApp } from "vue";
import { VueQueryPlugin } from "@tanstack/vue-query";
import App from "@/App.vue"; import App from "@/App.vue";
import { router } from "@/router"; import { router } from "@/router";
import { pinia } from "@/stores"; import { pinia } from "@/stores";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
import { onQueryCacheRestored, queryClient, restoreQueryCache } from "@/queries";
import "@/style.css"; import "@/style.css";
const app = createApp(App); const app = createApp(App);
app.use(pinia); app.use(pinia);
app.use(VueQueryPlugin, {
queryClient,
clientPersister: restoreQueryCache,
clientPersisterOnSuccess: onQueryCacheRestored,
});
app.use(router); app.use(router);
app.use(i18n); app.use(i18n);
app.mount("#app"); app.mount("#app");
-68
View File
@@ -1,68 +0,0 @@
// The end-of-game wipe (see place/tasks.py): a wavy front sweeps the board
// diagonally from the top-left, a cyan crest leaving plain white (palette
// index 0, what the server's blank canvas holds) behind it.
const WIPE_DURATION_MS = 2200
// Crest thickness and front wobble, in cells
const CREST_WIDTH = 6
const WOBBLE = 2.5
const CREST_RGB = [0x00, 0xd3, 0xdd]
function easeInOut(t: number) {
return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2
}
// Works on a snapshot of the canvas through ImageData rather than per-cell
// fillRect, so it stays smooth on a big grid. Returns a cancel function.
export function playWipeWave(ctx: CanvasRenderingContext2D, width: number, height: number) {
const before = ctx.getImageData(0, 0, width, height)
const frame = ctx.createImageData(width, height)
const out = frame.data
const src = before.data
// Distance of each cell from the start corner, wobbled so the front reads
// as a wave rather than a straight line.
const distance = new Float32Array(width * height)
const wobblePeriod = Math.max(width, height) / 5
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
distance[y * width + x] = x + y + WOBBLE * Math.sin((x - y) / wobblePeriod)
}
}
const travel = width + height + 2 * WOBBLE + CREST_WIDTH
let start: number | null = null
let animationFrame: number | null = null
function step(now: number) {
start ??= now
const progress = Math.min(1, (now - start) / WIPE_DURATION_MS)
const front = easeInOut(progress) * travel - WOBBLE
for (let i = 0; i < distance.length; i++) {
const o = i * 4
const behind = front - distance[i]
if (behind <= 0) {
out[o] = src[o]
out[o + 1] = src[o + 1]
out[o + 2] = src[o + 2]
} else if (behind < CREST_WIDTH) {
// Cyan at the leading edge, fading to white at the back of the crest
const t = behind / CREST_WIDTH
out[o] = CREST_RGB[0] + (255 - CREST_RGB[0]) * t
out[o + 1] = CREST_RGB[1] + (255 - CREST_RGB[1]) * t
out[o + 2] = CREST_RGB[2] + (255 - CREST_RGB[2]) * t
} else {
out[o] = out[o + 1] = out[o + 2] = 255
}
out[o + 3] = 255
}
ctx.putImageData(frame, 0, 0)
animationFrame = progress < 1 ? requestAnimationFrame(step) : null
}
animationFrame = requestAnimationFrame(step)
return () => {
if (animationFrame !== null) cancelAnimationFrame(animationFrame)
}
}
-52
View File
@@ -1,52 +0,0 @@
import { QueryClient } from "@tanstack/vue-query";
import { persistQueryClient } from "@tanstack/query-persist-client-core";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import {
gamesApiListEventGamesOptions,
gamesApiListEventPlanningOptions,
polylanSubmitterApiGetUserInfoOptions,
polylanSubmitterApiGetUserInfoQueryKey,
} from "@/api/@tanstack/vue-query.gen";
// Exported (not just provided by VueQueryPlugin) so Pinia stores can
// fetchQuery() through the same cache.
export const queryClient = new QueryClient();
// Login/logout are full-page redirects, but roles/scopes can still change
// server-side, so don't keep the user forever.
export const userQuery = () => ({
...polylanSubmitterApiGetUserInfoOptions(),
staleTime: 5 * 60 * 1000,
});
// Games only change when staff enable/disable one, a few minutes of
// staleness is fine.
export const eventGamesQuery = (eventPath: string) => ({
...gamesApiListEventGamesOptions({ path: { event_path: eventPath } }),
staleTime: 5 * 60 * 1000,
});
// Imported by staff a handful of times per event.
export const eventPlanningQuery = (eventPath: string) => ({
...gamesApiListEventPlanningOptions({ path: { event_path: eventPath } }),
staleTime: 5 * 60 * 1000,
});
const PERSISTED_QUERIES = new Set(["polylanSubmitterApiGetUserInfo", "gamesApiListEventGames"]);
// Restores user + games from localStorage before the app mounts, so pages
// render right away on reload instead of waiting on the API.
export const restoreQueryCache = (client: QueryClient) =>
persistQueryClient({
queryClient: client,
persister: createSyncStoragePersister({ storage: window.localStorage }),
dehydrateOptions: {
shouldDehydrateQuery: (query) =>
query.state.status === "success" &&
PERSISTED_QUERIES.has((query.queryKey[0] as { _id: string })._id),
},
});
// The restored user is only a placeholder until the real one comes back.
export const onQueryCacheRestored = (client: QueryClient) =>
client.invalidateQueries({ queryKey: polylanSubmitterApiGetUserInfoQueryKey() });
-8
View File
@@ -5,7 +5,6 @@ import Home from "@/Home.vue";
import Market from "@/Market.vue"; import Market from "@/Market.vue";
import Noita from "@/Noita.vue"; import Noita from "@/Noita.vue";
import OpusMagnum from "@/OpusMagnum.vue"; import OpusMagnum from "@/OpusMagnum.vue";
import Planning from "@/Planning.vue";
import Place from "@/Place.vue"; import Place from "@/Place.vue";
import PlaceEmbed from "@/PlaceEmbed.vue"; import PlaceEmbed from "@/PlaceEmbed.vue";
import Submissions from "@/Submissions.vue"; import Submissions from "@/Submissions.vue";
@@ -46,13 +45,6 @@ export const router = createRouter({
props: true, props: true,
meta: { titleKey: "games.header", getBackRoute: eventHome }, meta: { titleKey: "games.header", getBackRoute: eventHome },
}, },
{
path: "/:eventPath/planning",
name: "planning",
component: Planning,
props: true,
meta: { titleKey: "planning.header", getBackRoute: eventHome },
},
{ {
path: "/:eventPath/market", path: "/:eventPath/market",
name: "market", name: "market",
+10 -3
View File
@@ -1,8 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { marketApiListMarkets, marketApiListUserBets, marketApiGetUserPoints } from '@/api' import { polylanSubmitterApiGetUserInfo, marketApiListMarkets, marketApiListUserBets, marketApiGetUserPoints } from '@/api'
import type { Market } from '@/types' import type { Market } from '@/types'
import { queryClient, userQuery } from '@/queries'
import type { UserInfoOut, UserBetSchema } from '@/api/types.gen' import type { UserInfoOut, UserBetSchema } from '@/api/types.gen'
export const useMarketStore = defineStore('market', () => { export const useMarketStore = defineStore('market', () => {
@@ -16,7 +15,15 @@ export const useMarketStore = defineStore('market', () => {
// Actions // Actions
const loadUserInfo = async () => { const loadUserInfo = async () => {
userInfo.value = await queryClient.fetchQuery(userQuery()).catch(() => undefined) try {
const response = await polylanSubmitterApiGetUserInfo()
if (response.data) {
userInfo.value = response.data
}
} catch (err) {
error.value = 'Failed to load user info'
console.error('Error loading user info:', err)
}
} }
const loadMarkets = async (eventPath: string) => { const loadMarkets = async (eventPath: string) => {
+9 -5
View File
@@ -4,10 +4,10 @@ import {
noitaApiGetResults, noitaApiGetResults,
noitaApiGetLeaderboard, noitaApiGetLeaderboard,
noitaApiSubmitLogFile, noitaApiSubmitLogFile,
polylanSubmitterApiGetUserInfo,
polylanSubmitterApiClearCache, polylanSubmitterApiClearCache,
} from '@/api' } from '@/api'
import type { ObjectivResultOut, LeaderboardEntryOut } from '@/api/types.gen' import type { ObjectivResultOut, LeaderboardEntryOut } from '@/api/types.gen'
import { queryClient, userQuery } from '@/queries'
export type Objective = ObjectivResultOut export type Objective = ObjectivResultOut
@@ -80,10 +80,14 @@ export const useNoitaStore = defineStore('noita', () => {
} }
const loadUserData = async (eventPath: string) => { const loadUserData = async (eventPath: string) => {
const user = await queryClient.fetchQuery(userQuery()).catch(() => undefined) try {
if (user?.is_authenticated) { const response = await polylanSubmitterApiGetUserInfo()
userInfo.value.username = user.username ?? '' if (response.data?.is_authenticated) {
userInfo.value.isStaff = user.is_staff || false userInfo.value.username = response.data.username ?? ''
userInfo.value.isStaff = response.data.is_staff || false
}
} catch (err) {
console.error('Error fetching user info:', err)
} }
await Promise.all([fetchUserResults(eventPath), fetchLeaderboard(eventPath)]) await Promise.all([fetchUserResults(eventPath), fetchLeaderboard(eventPath)])
+1 -15
View File
@@ -2,9 +2,6 @@ import { defineStore } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
import type { RouteLocationRaw } from "vue-router"; import type { RouteLocationRaw } from "vue-router";
// Which page's explanation the live badge popover shows (liveStatus.<kind>)
export type LiveKind = "submissions" | "clock" | "place";
// Lets a routed page hand AppHeader a title built from data only that page // Lets a routed page hand AppHeader a title built from data only that page
// has fetched (e.g. "Noita Submissions — Semifinal", assembled from an API // has fetched (e.g. "Noita Submissions — Semifinal", assembled from an API
// call keyed by the scopeId route param). Cleared on every navigation so a // call keyed by the scopeId route param). Cleared on every navigation so a
@@ -14,10 +11,6 @@ export const usePageHeaderStore = defineStore("pageHeader", () => {
// Overrides route.meta.getBackRoute when the right target depends on // Overrides route.meta.getBackRoute when the right target depends on
// fetched data (e.g. skip the scope picker when it would auto-redirect). // fetched data (e.g. skip the scope picker when it would auto-redirect).
const backRoute = ref<RouteLocationRaw | null>(null); const backRoute = ref<RouteLocationRaw | null>(null);
// Live (websocket) connection state of the page, shown mid-header; null
// when the page has no live connection (no badge at all).
const live = ref<boolean | null>(null);
const liveKind = ref<LiveKind | null>(null);
const setTitle = (value: string) => { const setTitle = (value: string) => {
title.value = value; title.value = value;
@@ -27,17 +20,10 @@ export const usePageHeaderStore = defineStore("pageHeader", () => {
backRoute.value = value; backRoute.value = value;
}; };
const setLive = (value: boolean, kind: LiveKind) => {
live.value = value;
liveKind.value = kind;
};
const clear = () => { const clear = () => {
title.value = ""; title.value = "";
backRoute.value = null; backRoute.value = null;
live.value = null;
liveKind.value = null;
}; };
return { title, backRoute, live, liveKind, setTitle, setBackRoute, setLive, clear }; return { title, backRoute, setTitle, setBackRoute, clear };
}); });
+44
View File
@@ -0,0 +1,44 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { polylanSubmitterApiGetUserInfo } from '@/api'
interface UserInfo {
username: string
isAuthenticated: boolean
isStaff: boolean
}
export const useUserStore = defineStore('user', () => {
const userInfo = ref<UserInfo>({
username: '',
isAuthenticated: false,
isStaff: false,
})
const isLoading = ref(false)
const error = ref<string>('')
const loadUserData = async () => {
isLoading.value = true
try {
const response = await polylanSubmitterApiGetUserInfo()
if (response.data) {
const user = response.data
userInfo.value.isAuthenticated = user.is_authenticated || false
userInfo.value.username = user.username || ''
userInfo.value.isStaff = user.is_staff || false
}
} catch (err) {
error.value = 'Failed to fetch user info'
console.error('Error fetching user info:', err)
} finally {
isLoading.value = false
}
}
return {
userInfo,
isLoading,
error,
loadUserData,
}
})
-40
View File
@@ -1,7 +1,6 @@
from django.contrib import admin from django.contrib import admin
from django.urls import reverse from django.urls import reverse
from .models import Submission, SubmissionFile, ImageCrop, Scope from .models import Submission, SubmissionFile, ImageCrop, Scope
from .signals import broadcast_scope_updates
class ImageCropInline(admin.TabularInline): class ImageCropInline(admin.TabularInline):
@@ -78,45 +77,6 @@ class ScopeAdmin(admin.ModelAdmin):
}, },
) )
# Players on a scope edited here get moved like with the AAndT sync (see
# broadcast_scope_updates): state before the save is kept on the instance,
# compared once the assigned users (m2m) are saved too in save_related.
def save_model(self, request, obj, form, change):
previous = Scope.objects.filter(pk=obj.pk).first() if change else None
obj._state_before_save = (
(
previous.is_active,
set(previous.assigned_users.values_list("id", flat=True)),
)
if previous
else (False, set())
)
super().save_model(request, obj, form, change)
def save_related(self, request, form, formsets, change):
super().save_related(request, form, formsets, change)
scope = form.instance
was_active, previous_user_ids = scope._state_before_save
user_ids = set(scope.assigned_users.values_list("id", flat=True))
if scope.is_active != was_active or user_ids != previous_user_ids:
broadcast_scope_updates({scope: user_ids | previous_user_ids})
def delete_model(self, request, obj):
self.delete_queryset(request, Scope.objects.filter(pk=obj.pk))
def delete_queryset(self, request, queryset):
changes = {
scope: {user.id for user in scope.assigned_users.all()}
for scope in queryset.prefetch_related("assigned_users")
}
for scope in changes:
scope.is_active = False
super().delete_queryset(request, queryset)
broadcast_scope_updates(changes)
@admin.register(Submission) @admin.register(Submission)
class SubmissionAdmin(admin.ModelAdmin): class SubmissionAdmin(admin.ModelAdmin):
+13 -111
View File
@@ -5,25 +5,10 @@ from ninja import Router, File, Form
from ninja.files import UploadedFile from ninja.files import UploadedFile
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from games.models import EventGame, Game from games.models import Game
from .models import Submission, SubmissionFile, ImageCrop, OCRStatus, Scope from .models import Submission, SubmissionFile, ImageCrop, OCRStatus, Scope
from .schemas import ( from .schemas import SubmissionOut, ImageCropIn, ImageCropOut, ScopeOut
SubmissionOut, from .tasks import process_submission_file, process_crop
ImageCropIn,
ImageCropOut,
ScopeOut,
SyncScopesIn,
SyncScopesOut,
SyncScopesStatusOut,
SyncScopesStopOut,
)
from .tasks import (
get_scope_sync_state,
process_submission_file,
process_crop,
schedule_scope_sync,
stop_scope_sync,
)
router = Router() router = Router()
@@ -81,81 +66,6 @@ def list_available_scopes(request: HttpRequest, event_path: str, game_path: str)
return list(scopes.order_by("name")) return list(scopes.order_by("name"))
def _syncable_event_game(request: HttpRequest, game_path: str) -> EventGame:
return get_object_or_404(
EventGame,
event=request.event,
game__path=game_path,
game__custom_path=False,
enabled=True,
)
@router.get(
f"{PREFIX}/{{game_path}}/sync-scopes",
response={200: SyncScopesStatusOut, 403: dict},
)
def sync_scopes_status(request: HttpRequest, event_path: str, game_path: str):
"""State of the AAndT scope sync loop of a (non custom path) game,
superusers only."""
if not request.user.is_superuser:
return 403, {"detail": "Superuser required"}
event_game = _syncable_event_game(request, game_path)
scopes = Scope.objects.filter(event_game=event_game)
return {
**vars(get_scope_sync_state(event_game.id)),
"has_token": bool(event_game.apibot_token),
"scope_count": scopes.count(),
"active_scope_count": scopes.filter(is_active=True).count(),
}
@router.post(
f"{PREFIX}/{{game_path}}/sync-scopes",
response={200: SyncScopesOut, 400: dict, 403: dict},
)
def sync_scopes(
request: HttpRequest, event_path: str, game_path: str, payload: SyncScopesIn
):
"""Start the AAndT scope sync loop for a (non custom path) game, superusers
only. Stores `apibot_token` first when given; without one and none stored
yet, answers 400 with `token_required` so the frontend asks for it."""
if not request.user.is_superuser:
return 403, {"detail": "Superuser required"}
event_game = _syncable_event_game(request, game_path)
token = (payload.apibot_token or "").strip()
if token:
event_game.apibot_token = token
event_game.save(update_fields=["apibot_token"])
if not event_game.apibot_token:
return 400, {
"detail": "No AAndT apibot token configured",
"token_required": True,
}
return {"started": schedule_scope_sync(event_game.id)}
@router.delete(
f"{PREFIX}/{{game_path}}/sync-scopes",
response={200: SyncScopesStopOut, 403: dict},
)
def stop_sync_scopes(request: HttpRequest, event_path: str, game_path: str):
"""Stop the AAndT scope sync loop of a (non custom path) game, superusers
only. Cancels its scheduled job, scopes are left as they are."""
if not request.user.is_superuser:
return 403, {"detail": "Superuser required"}
event_game = _syncable_event_game(request, game_path)
return {"stopped": stop_scope_sync(event_game.id)}
@router.post(f"{PREFIX}/submission/create", response={200: SubmissionOut, 403: dict}) @router.post(f"{PREFIX}/submission/create", response={200: SubmissionOut, 403: dict})
def create_submission( def create_submission(
request: HttpRequest, request: HttpRequest,
@@ -202,17 +112,13 @@ def list_user_submissions(request: HttpRequest, event_path: str, scope_id: UUID)
if not request.user.is_authenticated: if not request.user.is_authenticated:
return 403, {"detail": "Authentication required"} return 403, {"detail": "Authentication required"}
submissions = Submission.for_event.filter(scope_id=scope_id) submissions = Submission.for_event.filter(
# Admins see every player's submissions for the scope scope_id=scope_id, user=request.user
if not request.user.is_staff: ).order_by("-created_at")
submissions = submissions.filter(user=request.user) return submissions
return submissions.select_related("user").order_by("-created_at")
@router.get( @router.get(f"{PREFIX}/submission/by/game", response=list[SubmissionOut])
f"{PREFIX}/submission/by/game", response={200: list[SubmissionOut], 403: dict}
)
def list_submissions_by_game(request: HttpRequest, event_path: str, game_path: str): def list_submissions_by_game(request: HttpRequest, event_path: str, game_path: str):
game = get_object_or_404(Game, path=game_path) game = get_object_or_404(Game, path=game_path)
@@ -222,9 +128,7 @@ def list_submissions_by_game(request: HttpRequest, event_path: str, game_path: s
return Submission.for_event.filter(scope__event_game__game=game) return Submission.for_event.filter(scope__event_game__game=game)
@router.get( @router.get(f"{PREFIX}/submission/{{submission_id}}", response=SubmissionOut)
f"{PREFIX}/submission/{{submission_id}}", response={200: SubmissionOut, 403: dict}
)
def get_submission(request: HttpRequest, event_path: str, submission_id: UUID): def get_submission(request: HttpRequest, event_path: str, submission_id: UUID):
submission = get_object_or_404(Submission.for_event, id=submission_id) submission = get_object_or_404(Submission.for_event, id=submission_id)
@@ -234,9 +138,7 @@ def get_submission(request: HttpRequest, event_path: str, submission_id: UUID):
return submission return submission
@router.post( @router.post(f"{PREFIX}/crops/s/{{submission_file_id}}", response=ImageCropOut)
f"{PREFIX}/crops/s/{{submission_file_id}}", response={200: ImageCropOut, 403: dict}
)
def create_image_crop( def create_image_crop(
request: HttpRequest, request: HttpRequest,
event_path: str, event_path: str,
@@ -263,7 +165,7 @@ def create_image_crop(
return crop return crop
@router.delete(f"{PREFIX}/crops/{{crop_id}}", response={200: dict, 403: dict}) @router.delete(f"{PREFIX}/crops/{{crop_id}}", response=dict)
def delete_image_crop(request: HttpRequest, event_path: str, crop_id: UUID): def delete_image_crop(request: HttpRequest, event_path: str, crop_id: UUID):
if not request.user.is_staff: if not request.user.is_staff:
return 403, {"detail": "Admin access required"} return 403, {"detail": "Admin access required"}
@@ -276,7 +178,7 @@ def delete_image_crop(request: HttpRequest, event_path: str, crop_id: UUID):
return {"status": "deleted"} return {"status": "deleted"}
@router.post(f"{PREFIX}/files/{{file_id}}/process-ocr", response={200: dict, 403: dict}) @router.post(f"{PREFIX}/files/{{file_id}}/process-ocr", response=dict)
def process_file_ocr(request: HttpRequest, event_path: str, file_id: UUID): def process_file_ocr(request: HttpRequest, event_path: str, file_id: UUID):
if not request.user.is_staff: if not request.user.is_staff:
return 403, {"detail": "Admin access required"} return 403, {"detail": "Admin access required"}
@@ -293,7 +195,7 @@ def process_file_ocr(request: HttpRequest, event_path: str, file_id: UUID):
return {"status": "queued"} return {"status": "queued"}
@router.post(f"{PREFIX}/crops/{{crop_id}}/process-ocr", response={200: dict, 403: dict}) @router.post(f"{PREFIX}/crops/{{crop_id}}/process-ocr", response=dict)
def process_crop_ocr(request: HttpRequest, event_path: str, crop_id: UUID): def process_crop_ocr(request: HttpRequest, event_path: str, crop_id: UUID):
if not request.user.is_staff: if not request.user.is_staff:
return 403, {"detail": "Admin access required"} return 403, {"detail": "Admin access required"}
+3 -17
View File
@@ -2,8 +2,6 @@ import json
from channels.generic.websocket import AsyncWebsocketConsumer from channels.generic.websocket import AsyncWebsocketConsumer
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from submissions.signals import STAFF_GROUP_NAME
class SubmissionsConsumer(AsyncWebsocketConsumer): class SubmissionsConsumer(AsyncWebsocketConsumer):
async def connect(self): async def connect(self):
@@ -13,18 +11,12 @@ class SubmissionsConsumer(AsyncWebsocketConsumer):
await self.close() await self.close()
return return
self.group_names = [f"submissions_user_{self.user.id}"] self.user_group_name = f"submissions_user_{self.user.id}"
if self.user.is_staff: await self.channel_layer.group_add(self.user_group_name, self.channel_name)
self.group_names.append(STAFF_GROUP_NAME)
for group_name in self.group_names:
await self.channel_layer.group_add(group_name, self.channel_name)
await self.accept() await self.accept()
async def disconnect(self, close_code): async def disconnect(self, close_code):
# closed before joining (anonymous) await self.channel_layer.group_discard(self.user_group_name, self.channel_name)
for group_name in getattr(self, "group_names", []):
await self.channel_layer.group_discard(group_name, self.channel_name)
async def submission_updated(self, event): async def submission_updated(self, event):
submission_data = event["submission"] submission_data = event["submission"]
@@ -37,9 +29,3 @@ class SubmissionsConsumer(AsyncWebsocketConsumer):
async def submission_crop_updated(self, event): async def submission_crop_updated(self, event):
crop_data = event["crop"] crop_data = event["crop"]
await self.send(text_data=json.dumps(crop_data)) await self.send(text_data=json.dumps(crop_data))
async def submission_crop_deleted(self, event):
await self.send(text_data=json.dumps({"deleted_crop": event["crop"]}))
async def scope_updated(self, event):
await self.send(text_data=json.dumps({"scope": event["scope"]}))
@@ -1,7 +1,7 @@
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from games.models import EventGame from games.models import EventGame
from submissions.tasks import schedule_scope_sync, sync_all_event_games_task from submissions.tasks import sync_all_event_games_task, sync_event_game_scopes_task
class Command(BaseCommand): class Command(BaseCommand):
@@ -25,15 +25,9 @@ class Command(BaseCommand):
if not EventGame.objects.filter(id=event_game_id).exists(): if not EventGame.objects.filter(id=event_game_id).exists():
raise CommandError(f"No EventGame with id={event_game_id}") raise CommandError(f"No EventGame with id={event_game_id}")
if schedule_scope_sync(event_game_id): sync_event_game_scopes_task.enqueue(event_game_id)
self.stdout.write( self.stdout.write(
self.style.SUCCESS(f"Started sync for EventGame {event_game_id}") self.style.SUCCESS(f"Queued sync for EventGame {event_game_id}")
)
else:
self.stdout.write(
self.style.WARNING(
f"Sync already running for EventGame {event_game_id}"
)
) )
return return
@@ -1,58 +0,0 @@
from django.db import migrations
# Before the submissions -> opus_magnum rename, this app owned the opus magnum
# tables and its 0001_initial was already applied. The new 0001_initial has the
# same name, so on those databases Django skipped it and kept the legacy
# submissions_submission / submissions_submissionfile layout (is_validated NOT
# NULL, response_id, ...) and never created submissions_imagecrop.
LEGACY_TABLES = [
"submissions_imagecrop",
"submissions_submissionfile",
"submissions_puzzleresponse",
"submissions_submission",
"submissions_steamcollectionitem",
"submissions_steamcollection",
"submissions_steamapikey",
]
def repair_legacy_tables(apps, schema_editor):
connection = schema_editor.connection
with connection.cursor() as cursor:
columns = {
col.name
for col in connection.introspection.get_table_description(
cursor, "submissions_submission"
)
}
if "is_validated" not in columns:
return
# Data was copied to opus_magnum (0015) and 0007 wiped submissions, so
# this should be empty: refuse to drop anything otherwise.
cursor.execute("SELECT COUNT(*) FROM submissions_submission")
if cursor.fetchone()[0]:
raise RuntimeError(
"submissions_submission has the legacy layout but is not empty"
)
existing_tables = set(connection.introspection.table_names())
for table in LEGACY_TABLES:
if table in existing_tables:
schema_editor.execute(f"DROP TABLE {schema_editor.quote_name(table)}")
for model_name in ["Submission", "SubmissionFile", "ImageCrop"]:
schema_editor.create_model(apps.get_model("submissions", model_name))
class Migration(migrations.Migration):
dependencies = [
("submissions", "0008_remove_submission_game"),
]
operations = [
migrations.RunPython(repair_legacy_tables, migrations.RunPython.noop),
]
-33
View File
@@ -10,33 +10,6 @@ class ScopeOut(Schema):
created_at: datetime created_at: datetime
class SyncScopesIn(Schema):
apibot_token: str | None = None
class SyncScopesOut(Schema):
# False when a sync loop was already running for this game
started: bool
class SyncScopesStopOut(Schema):
# False when no sync loop was running
stopped: bool
class SyncScopesStatusOut(Schema):
running: bool
has_token: bool
# Last AAndT status seen (e.g. "7inprog"), see games' AAndT status values
aandt_status: str | None = None
last_run_at: datetime | None = None
next_run_at: datetime | None = None
# Last run's error, if it failed
error: str | None = None
scope_count: int
active_scope_count: int
class ImageCropIn(Schema): class ImageCropIn(Schema):
x1: int x1: int
y1: int y1: int
@@ -47,7 +20,6 @@ class ImageCropIn(Schema):
class ImageCropOut(Schema): class ImageCropOut(Schema):
id: UUID id: UUID
submission_file_id: UUID
x1: int x1: int
y1: int y1: int
x2: int x2: int
@@ -72,12 +44,7 @@ class SubmissionFileOut(Schema):
class SubmissionOut(Schema): class SubmissionOut(Schema):
id: UUID id: UUID
scope_id: UUID scope_id: UUID
username: str | None = None
notes: str | None = None notes: str | None = None
files: list[SubmissionFileOut] = [] files: list[SubmissionFileOut] = []
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@staticmethod
def resolve_username(obj) -> str | None:
return obj.user.username if obj.user else None
@@ -12,7 +12,6 @@ from accounts.models import CustomUser
from games.models import EventGame from games.models import EventGame
from ..models import Scope from ..models import Scope
from ..signals import broadcast_scope_updates
from .aandt_client import AuthenticatedClient from .aandt_client import AuthenticatedClient
from .aandt_client.api.default import ( from .aandt_client.api.default import (
aandt_api_infos, aandt_api_infos,
@@ -121,9 +120,6 @@ def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
Scopes whose match is no longer returned by the API (deleted, or from a Scopes whose match is no longer returned by the API (deleted, or from a
previous AAndT run reusing the same token) are deactivated. previous AAndT run reusing the same token) are deactivated.
Users of scopes that opened/closed or got their players changed are
notified over the submissions websocket (see `broadcast_scope_updates`).
Args: Args:
event_game: The EventGame to sync; must have `apibot_token` set. event_game: The EventGame to sync; must have `apibot_token` set.
@@ -177,11 +173,10 @@ def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
result = ScopeSyncResult(status=status.value if status is not None else None) result = ScopeSyncResult(status=status.value if status is not None else None)
now = timezone.now() now = timezone.now()
synced_match_ids: set[int] = set() synced_match_ids: set[int] = set()
changes: dict[Scope, set[int]] = {}
for match in matches: for match in matches:
match_id = _unwrap(match.id) match_id = _unwrap(match.id)
created, warnings, change = _sync_match( created, warnings = _sync_match(
event_game, client, match, userids_by_member_id, now event_game, client, match, userids_by_member_id, now
) )
if created is None: if created is None:
@@ -190,22 +185,14 @@ def sync_event_game_scopes(event_game: EventGame, /) -> ScopeSyncResult:
result.created += created result.created += created
result.updated += not created result.updated += not created
result.warnings.extend(warnings) result.warnings.extend(warnings)
if change is not None:
changes[change[0]] = change[1]
gone = list( (
Scope.objects.filter( Scope.objects.filter(
event_game=event_game, is_active=True, external_id__isnull=False event_game=event_game, is_active=True, external_id__isnull=False
) )
.exclude(external_id__in=synced_match_ids) .exclude(external_id__in=synced_match_ids)
.prefetch_related("assigned_users") .update(is_active=False)
) )
Scope.objects.filter(id__in=[scope.id for scope in gone]).update(is_active=False)
for scope in gone:
scope.is_active = False
changes[scope] = {user.id for user in scope.assigned_users.all()}
broadcast_scope_updates(changes)
return result return result
@@ -216,17 +203,10 @@ def _sync_match(
match: Match, match: Match,
userids_by_member_id: dict[int, set[int]], userids_by_member_id: dict[int, set[int]],
now: datetime, now: datetime,
) -> tuple[bool | None, list[str], tuple[Scope, set[int]] | None]: ) -> tuple[bool | None, list[str]]:
"""Upsert the Scope of one match.
Returns:
Whether the Scope was created (None when the match has no id), the
warnings, and when it opened/closed or its players changed, the Scope
with every user id that was or now is assigned to it.
"""
match_id = _unwrap(match.id) match_id = _unwrap(match.id)
if match_id is None: if match_id is None:
return None, [], None return None, []
warnings: list[str] = [] warnings: list[str] = []
@@ -238,12 +218,6 @@ def _sync_match(
users = list(CustomUser.objects.filter(cas_user_id__in=userids)) users = list(CustomUser.objects.filter(cas_user_id__in=userids))
previous = Scope.objects.filter(event_game=event_game, external_id=match_id).first()
was_active = previous.is_active if previous else False
previous_user_ids = (
set(previous.assigned_users.values_list("id", flat=True)) if previous else set()
)
scope, created = Scope.objects.update_or_create( scope, created = Scope.objects.update_or_create(
event_game=event_game, event_game=event_game,
external_id=match_id, external_id=match_id,
@@ -267,12 +241,7 @@ def _sync_match(
if scope.is_active: if scope.is_active:
warnings.extend(_push_submission_link(client, match_id, scope)) warnings.extend(_push_submission_link(client, match_id, scope))
user_ids = {user.id for user in users} return created, warnings
change = None
if scope.is_active != was_active or user_ids != previous_user_ids:
change = (scope, user_ids | previous_user_ids)
return created, warnings, change
def _push_submission_link( def _push_submission_link(
+32 -71
View File
@@ -1,102 +1,63 @@
from django.db import transaction from django.db.models.signals import post_save
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver from django.dispatch import receiver
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
from submissions.models import Submission, SubmissionFile, ImageCrop, Scope from submissions.models import Submission, SubmissionFile, ImageCrop
from submissions.schemas import SubmissionOut, SubmissionFileOut, ImageCropOut from submissions.schemas import SubmissionOut, SubmissionFileOut, ImageCropOut
# Staff see (and OCR/crop) every player's submissions, so they get every update channel_layer = get_channel_layer()
STAFF_GROUP_NAME = "submissions_staff"
def _broadcast_submission_event(submission: Submission, message: dict):
channel_layer = get_channel_layer()
groups = [STAFF_GROUP_NAME]
if submission.user_id:
groups.append(f"submissions_user_{submission.user_id}")
for group_name in groups:
async_to_sync(channel_layer.group_send)(group_name, message)
@receiver(post_save, sender=Submission) @receiver(post_save, sender=Submission)
def broadcast_submission_update(sender, instance, created, **kwargs): def broadcast_submission_update(sender, instance, created, **kwargs):
_broadcast_submission_event( if not instance.user:
instance, return
user_group_name = f"submissions_user_{instance.user.id}"
submission_data = SubmissionOut.from_orm(instance).dict()
async_to_sync(channel_layer.group_send)(
user_group_name,
{ {
"type": "submission.updated", "type": "submission.updated",
"submission": SubmissionOut.from_orm(instance).dict(), "submission": submission_data,
}, },
) )
@receiver(post_save, sender=SubmissionFile) @receiver(post_save, sender=SubmissionFile)
def broadcast_submission_file_update(sender, instance, created, **kwargs): def broadcast_submission_file_update(sender, instance, created, **kwargs):
_broadcast_submission_event( submission = instance.submission
instance.submission, if not submission.user:
return
user_group_name = f"submissions_user_{submission.user.id}"
file_data = SubmissionFileOut.from_orm(instance).dict()
async_to_sync(channel_layer.group_send)(
user_group_name,
{ {
"type": "submission.file.updated", "type": "submission.file.updated",
"file": SubmissionFileOut.from_orm(instance).dict(), "file": file_data,
}, },
) )
@receiver(post_save, sender=ImageCrop) @receiver(post_save, sender=ImageCrop)
def broadcast_image_crop_update(sender, instance, created, **kwargs): def broadcast_image_crop_update(sender, instance, created, **kwargs):
_broadcast_submission_event( submission = instance.submission_file.submission
instance.submission_file.submission, if not submission.user:
return
user_group_name = f"submissions_user_{submission.user.id}"
crop_data = ImageCropOut.from_orm(instance).dict()
async_to_sync(channel_layer.group_send)(
user_group_name,
{ {
"type": "submission.crop.updated", "type": "submission.crop.updated",
"crop": ImageCropOut.from_orm(instance).dict(), "crop": crop_data,
}, },
) )
@receiver(post_delete, sender=ImageCrop)
def broadcast_image_crop_delete(sender, instance, **kwargs):
_broadcast_submission_event(
instance.submission_file.submission,
{
"type": "submission.crop.deleted",
"crop": {
"id": str(instance.id),
"submission_file_id": str(instance.submission_file_id),
},
},
)
def broadcast_scope_updates(changes: dict[Scope, set[int]], /):
"""Tell users their scope changed (closed/opened, (un)assigned), once the
current transaction commits, so the frontend can move them to the right
match without a refresh.
Args:
changes: Changed scopes, each with every user id that was or now is
assigned to it.
"""
def send():
channel_layer = get_channel_layer()
for scope, user_ids in changes.items():
assigned_ids = set(scope.assigned_users.values_list("id", flat=True))
for user_id in user_ids:
async_to_sync(channel_layer.group_send)(
f"submissions_user_{user_id}",
{
"type": "scope.updated",
"scope": {
"id": str(scope.id),
"is_active": scope.is_active,
"assigned": user_id in assigned_ids,
},
},
)
if changes:
# robust: a websocket hiccup only gets logged, it mustn't fail the
# (already committed) sync or admin save
transaction.on_commit(send, robust=True)
+15 -175
View File
@@ -1,10 +1,5 @@
import logging from datetime import timedelta
from dataclasses import dataclass
from datetime import datetime, timedelta
from uuid import uuid4
import django_rq
from django.core.cache import cache
from django.tasks import task from django.tasks import task
from django.utils import timezone from django.utils import timezone
from games.models import EventGame from games.models import EventGame
@@ -16,117 +11,9 @@ from submissions.services.aandt_sync import (
from PIL import Image from PIL import Image
import pytesseract import pytesseract
from rq.exceptions import InvalidJobOperation, NoSuchJobError
from rq.job import Job, JobStatus
logger = logging.getLogger(__name__) # How often to repoll an EventGame's AAndT while it's still "in progress".
# How often to repoll an EventGame's AAndT while it's "in progress", and while
# it isn't yet (registrations, preparation, ...) or the run failed.
SCOPE_SYNC_REPOLL_INTERVAL = timedelta(seconds=15) SCOPE_SYNC_REPOLL_INTERVAL = timedelta(seconds=15)
SCOPE_SYNC_IDLE_INTERVAL = timedelta(minutes=1)
# AAndT statuses after which there's nothing left to sync, the loop stops.
AANDT_FINISHED_STATUSES = {"9done", "11reviewed"}
def _scope_sync_guard_key(event_game_id: int) -> str:
return f"submissions:scope_sync:{event_game_id}"
def _scope_sync_job_key(event_game_id: int) -> str:
return f"submissions:scope_sync:{event_game_id}:job"
def _scope_sync_state_key(event_game_id: int) -> str:
return f"submissions:scope_sync:{event_game_id}:state"
def _scope_sync_guard_timeout() -> int:
return int(SCOPE_SYNC_IDLE_INTERVAL.total_seconds() * 3)
# Last run's outcome, shown to admins in the sync modal. Kept around a while
# after the loop stops, so they still see why it stopped.
SCOPE_SYNC_STATE_TIMEOUT = int(timedelta(days=1).total_seconds())
@dataclass
class ScopeSyncState:
running: bool
aandt_status: str | None = None
last_run_at: datetime | None = None
next_run_at: datetime | None = None
error: str | None = None
def get_scope_sync_state(event_game_id: int, /) -> ScopeSyncState:
state = cache.get(_scope_sync_state_key(event_game_id)) or {}
running = cache.get(_scope_sync_guard_key(event_game_id)) is not None
return ScopeSyncState(
running=running,
aandt_status=state.get("aandt_status"),
last_run_at=state.get("last_run_at"),
next_run_at=state.get("next_run_at") if running else None,
error=state.get("error"),
)
def _enqueue_scope_sync(event_game_id: int, loop_id: str, run_after: datetime | None):
task = (
sync_event_game_scopes_task.using(run_after=run_after)
if run_after
else sync_event_game_scopes_task
)
result = task.enqueue(event_game_id, loop_id=loop_id)
# Same id as the rq job (see django_tasks_rq), so stop_scope_sync can cancel it
cache.set(
_scope_sync_job_key(event_game_id),
result.id,
timeout=_scope_sync_guard_timeout(),
)
def schedule_scope_sync(event_game_id: int, /) -> bool:
"""Start the sync loop of an EventGame, unless one is already running.
Safe to call any number of times (admin button spam, multiple workers...).
The loop holds a per-EventGame guard with its id (same idea as
games.tasks.schedule_steam_refresh), so if the guard expires while an old
loop is just late, the old one stops at its next run."""
loop_id = uuid4().hex
if not cache.add(
_scope_sync_guard_key(event_game_id),
loop_id,
timeout=_scope_sync_guard_timeout(),
):
return False
_enqueue_scope_sync(event_game_id, loop_id, run_after=None)
return True
def stop_scope_sync(event_game_id: int, /) -> bool:
"""Stop the sync loop of an EventGame: cancels its scheduled rq job and
releases the guard (a run already in progress won't reschedule itself).
Returns:
Whether a loop was running.
"""
running = cache.get(_scope_sync_guard_key(event_game_id)) is not None
job_id = cache.get(_scope_sync_job_key(event_game_id))
cache.delete_many(
[_scope_sync_guard_key(event_game_id), _scope_sync_job_key(event_game_id)]
)
if job_id:
try:
job = Job.fetch(job_id, connection=django_rq.get_connection())
if job.get_status() in (JobStatus.SCHEDULED, JobStatus.QUEUED):
job.cancel()
except (NoSuchJobError, InvalidJobOperation):
pass
return running
def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool: def _process_ocr_file(obj, file, crop: ImageCrop | None = None) -> bool:
@@ -203,73 +90,26 @@ def sync_all_event_games_task():
) )
for event_game in event_games: for event_game in event_games:
schedule_scope_sync(event_game.id) sync_event_game_scopes_task.enqueue(event_game.id)
@task @task
def sync_event_game_scopes_task(event_game_id: int, loop_id: str | None = None): def sync_event_game_scopes_task(event_game_id: int):
"""Sync an EventGame's Scopes, then re-enqueue itself until the AAndT is done. """Sync an EventGame's Scopes, repolling every minute while matches are in progress.
Started once (see schedule_scope_sync), it keeps going on its own without Each run re-checks the AAndT's status (from the `/` endpoint). While it's
an external scheduler: every SCOPE_SYNC_REPOLL_INTERVAL while the AAndT is still "in progress" (`7inprog`), the task re-enqueues itself with a delay
"in progress" (`7inprog`), every SCOPE_SYNC_IDLE_INTERVAL before that or instead of relying on an external scheduler; once the status moves on, it
when the run failed. Stops once the AAndT is done/reviewed, when the simply doesn't reschedule and the loop stops.
EventGame (or its token) is gone, or when another loop took the guard.
Without loop_id it's a one-off run that leaves the loop alone.
""" """
guard_key = _scope_sync_guard_key(event_game_id)
if loop_id is not None and cache.get(guard_key) != loop_id:
# Another loop owns the guard (or it expired/was stopped), stop this one
return
event_game = EventGame.objects.filter(id=event_game_id).first() event_game = EventGame.objects.filter(id=event_game_id).first()
if not event_game or not event_game.apibot_token: if not event_game:
if loop_id is not None:
cache.delete(guard_key)
return return
status = None result = sync_event_game_scopes(event_game)
error = None
try:
status = sync_event_game_scopes(event_game).status
except Exception as exc:
if loop_id is None:
raise
# Retry later rather than letting the loop die (AAndT site down, bug, ...),
# it'd otherwise only restart once the guard expires and someone restarts it
logger.exception("Scope sync failed for EventGame %s", event_game_id)
error = str(exc) or exc.__class__.__name__
if loop_id is None: if result.status == AANDT_STATUS_IN_PROGRESS:
return sync_event_game_scopes_task.using(
run_after=timezone.now() + SCOPE_SYNC_REPOLL_INTERVAL
now = timezone.now() ).enqueue(event_game_id)
next_run_at = None
# Re-checked: the loop may have been stopped while this run was syncing
if status not in AANDT_FINISHED_STATUSES and cache.get(guard_key) == loop_id:
next_run_at = now + (
SCOPE_SYNC_REPOLL_INTERVAL
if status == AANDT_STATUS_IN_PROGRESS
else SCOPE_SYNC_IDLE_INTERVAL
)
cache.set(
_scope_sync_state_key(event_game_id),
{
"aandt_status": status,
"last_run_at": now,
"next_run_at": next_run_at,
"error": error,
},
timeout=SCOPE_SYNC_STATE_TIMEOUT,
)
if next_run_at is None:
if cache.get(guard_key) == loop_id:
cache.delete(guard_key)
return
cache.set(guard_key, loop_id, timeout=_scope_sync_guard_timeout())
_enqueue_scope_sync(event_game_id, loop_id, run_after=next_run_at)
+1 -488
View File
@@ -1,488 +1 @@
from contextlib import ExitStack # Create your tests here.
from unittest import mock
from django.core.cache import cache
from django.test import TestCase
from rq.job import JobStatus
from accounts.models import CustomUser
from events.models import Event
from games.models import EventGame, Game
from . import tasks
from .models import ImageCrop, Scope, Submission, SubmissionFile
from .services.aandt_client.models import (
AandtApiInfosResponse200,
AandtApiMatchesResponse200,
AandtApiMembersResponse200,
)
from .services.aandt_sync import ScopeSyncError, ScopeSyncResult, sync_event_game_scopes
class SyncScopesApiTests(TestCase):
def setUp(self):
self.event = Event.objects.create(name="Event", path="event")
self.game = Game.objects.create(name="Game", path="game")
self.event_game = EventGame.objects.create(event=self.event, game=self.game)
self.url = (
f"/api/events/{self.event.path}/submissions/{self.game.path}/sync-scopes"
)
self.superuser = CustomUser.objects.create_user(
username="admin", password="x", is_staff=True, is_superuser=True
)
patcher = mock.patch("submissions.api.schedule_scope_sync", return_value=True)
self.schedule = patcher.start()
self.addCleanup(patcher.stop)
def _post(self, body=None):
return self.client.post(self.url, body or {}, content_type="application/json")
def test_staff_is_forbidden(self):
staff = CustomUser.objects.create_user(
username="staff", password="x", is_staff=True
)
self.client.force_login(staff)
self.assertEqual(self._post().status_code, 403)
self.schedule.assert_not_called()
def test_asks_for_token_when_missing(self):
self.client.force_login(self.superuser)
response = self._post()
self.assertEqual(response.status_code, 400)
self.assertTrue(response.json()["token_required"])
self.schedule.assert_not_called()
def test_stores_token_and_starts_sync(self):
self.client.force_login(self.superuser)
response = self._post({"apibot_token": " abc "})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"started": True})
self.event_game.refresh_from_db()
self.assertEqual(self.event_game.apibot_token, "abc")
self.schedule.assert_called_once_with(self.event_game.id)
def test_status(self):
Scope.objects.create(event_game=self.event_game, name="M1")
Scope.objects.create(event_game=self.event_game, name="M2", is_active=False)
self.client.force_login(self.superuser)
with mock.patch(
"submissions.api.get_scope_sync_state",
return_value=tasks.ScopeSyncState(running=True, aandt_status="7inprog"),
):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data["running"])
self.assertFalse(data["has_token"])
self.assertEqual(data["aandt_status"], "7inprog")
self.assertEqual((data["active_scope_count"], data["scope_count"]), (1, 2))
def test_stop(self):
self.client.force_login(self.superuser)
with mock.patch("submissions.api.stop_scope_sync", return_value=True) as stop:
response = self.client.delete(self.url)
self.assertEqual(response.json(), {"stopped": True})
stop.assert_called_once_with(self.event_game.id)
def test_status_and_stop_forbidden_for_staff(self):
staff = CustomUser.objects.create_user(
username="staff", password="x", is_staff=True
)
self.client.force_login(staff)
self.assertEqual(self.client.get(self.url).status_code, 403)
self.assertEqual(self.client.delete(self.url).status_code, 403)
def test_custom_path_game_is_404(self):
self.game.custom_path = True
self.game.save()
self.client.force_login(self.superuser)
self.assertEqual(self._post({"apibot_token": "abc"}).status_code, 404)
class SyncEventGameScopesTaskTests(TestCase):
def setUp(self):
event = Event.objects.create(name="Event", path="event")
game = Game.objects.create(name="Game", path="game")
self.event_game = EventGame.objects.create(
event=event, game=game, apibot_token="abc"
)
self.guard_key = tasks._scope_sync_guard_key(self.event_game.id)
self.loop_id = "loop"
self.job_key = tasks._scope_sync_job_key(self.event_game.id)
self.state_key = tasks._scope_sync_state_key(self.event_game.id)
cache.set(self.guard_key, self.loop_id)
self.addCleanup(
cache.delete_many, [self.guard_key, self.job_key, self.state_key]
)
@staticmethod
def _task_mock(task):
# enqueue() returns a TaskResult, whose id ends up in the cache
task.enqueue.return_value.id = "job-1"
task.using.return_value.enqueue.return_value.id = "job-2"
return task
def _run(self, status=None, exc=None, during_sync=None):
run = tasks.sync_event_game_scopes_task.func
def sync_side_effect(_event_game):
if during_sync:
during_sync()
if exc:
raise exc
return ScopeSyncResult(status=status)
with (
mock.patch.object(
tasks, "sync_event_game_scopes", side_effect=sync_side_effect
) as sync,
mock.patch.object(tasks, "sync_event_game_scopes_task") as task,
mock.patch.object(tasks, "logger"),
):
self._task_mock(task)
run(self.event_game.id, loop_id=self.loop_id)
return sync, task
def test_schedule_starts_only_one_loop(self):
cache.delete(self.guard_key)
with mock.patch.object(tasks, "sync_event_game_scopes_task") as task:
self._task_mock(task)
self.assertTrue(tasks.schedule_scope_sync(self.event_game.id))
self.assertFalse(tasks.schedule_scope_sync(self.event_game.id))
task.enqueue.assert_called_once()
def test_reschedules_before_and_during_aandt(self):
for status in ["3insc", "7inprog"]:
with self.subTest(status=status):
_, task = self._run(status=status)
task.using.assert_called_once()
def test_reschedules_after_api_failure(self):
_, task = self._run(exc=ScopeSyncError("down"))
task.using.assert_called_once()
def test_reschedules_after_unexpected_error(self):
_, task = self._run(exc=RuntimeError("boom"))
task.using.assert_called_once()
state = tasks.get_scope_sync_state(self.event_game.id)
self.assertEqual(state.error, "boom")
self.assertTrue(state.running)
def test_records_job_and_state(self):
self._run(status="7inprog")
self.assertEqual(cache.get(self.job_key), "job-2")
state = tasks.get_scope_sync_state(self.event_game.id)
self.assertEqual(state.aandt_status, "7inprog")
self.assertIsNotNone(state.next_run_at)
def test_stop_cancels_scheduled_job(self):
cache.set(self.job_key, "job-1")
job = mock.Mock(get_status=mock.Mock(return_value=JobStatus.SCHEDULED))
with (
mock.patch.object(tasks.Job, "fetch", return_value=job) as fetch,
mock.patch.object(tasks.django_rq, "get_connection"),
):
self.assertTrue(tasks.stop_scope_sync(self.event_game.id))
fetch.assert_called_once_with("job-1", connection=mock.ANY)
job.cancel.assert_called_once()
self.assertIsNone(cache.get(self.guard_key))
self.assertFalse(tasks.stop_scope_sync(self.event_game.id))
def test_stop_during_run_does_not_reschedule(self):
_, task = self._run(
status="7inprog", during_sync=lambda: cache.delete(self.guard_key)
)
task.using.assert_not_called()
self.assertIsNone(cache.get(self.guard_key))
def test_stops_once_aandt_is_done(self):
_, task = self._run(status="9done")
task.using.assert_not_called()
self.assertIsNone(cache.get(self.guard_key))
def test_stale_loop_stops(self):
cache.set(self.guard_key, "another-loop")
sync, task = self._run(status="7inprog")
sync.assert_not_called()
task.using.assert_not_called()
class SyncScopeBroadcastTests(TestCase):
def setUp(self):
event = Event.objects.create(name="Event", path="event")
game = Game.objects.create(name="Game", path="game")
self.event_game = EventGame.objects.create(
event=event, game=game, apibot_token="abc"
)
self.player = CustomUser.objects.create_user(
username="player", password="x", cas_user_id="42"
)
def _sync(self, matches):
api = "submissions.services.aandt_sync"
responses = {
"aandt_api_infos": AandtApiInfosResponse200.from_dict(
{"aandt": {"status": "7inprog"}}
),
"aandt_api_members": AandtApiMembersResponse200.from_dict(
{"members": [{"id": 1, "user": {"id": 42}}]}
),
"aandt_api_matches": AandtApiMatchesResponse200.from_dict(
{"matches": matches}
),
}
with ExitStack() as stack:
for module, parsed in responses.items():
stack.enter_context(
mock.patch(
f"{api}.{module}.sync_detailed",
return_value=mock.Mock(parsed=parsed),
)
)
stack.enter_context(
mock.patch(f"{api}._push_submission_link", return_value=[])
)
stack.enter_context(mock.patch("submissions.signals.get_channel_layer"))
send = stack.enter_context(mock.patch("submissions.signals.async_to_sync"))
with self.captureOnCommitCallbacks(execute=True):
sync_event_game_scopes(self.event_game)
return [c.args[1]["scope"] for c in send.return_value.call_args_list]
@staticmethod
def _match(match_id, status):
return {
"id": match_id,
"title": f"M{match_id}",
"status": status,
"players": {"1": 0},
}
def test_match_done_and_next_one_playing_notifies_player(self):
self._sync([self._match(1, "playing"), self._match(2, "waitPlayer")])
sent = self._sync([self._match(1, "done"), self._match(2, "playing")])
by_external_id = {
Scope.objects.get(id=scope["id"]).external_id: scope for scope in sent
}
self.assertFalse(by_external_id[1]["is_active"])
self.assertTrue(by_external_id[2]["is_active"])
self.assertTrue(by_external_id[2]["assigned"])
def test_unchanged_scopes_send_nothing(self):
self._sync([self._match(1, "playing")])
self.assertEqual(self._sync([self._match(1, "playing")]), [])
def test_removed_match_is_closed_and_notified(self):
self._sync([self._match(1, "playing")])
sent = self._sync([])
self.assertEqual(len(sent), 1)
self.assertFalse(sent[0]["is_active"])
class ScopeAdminBroadcastTests(TestCase):
def setUp(self):
event = Event.objects.create(name="Event", path="event")
game = Game.objects.create(name="Game", path="game")
event_game = EventGame.objects.create(event=event, game=game)
self.player = CustomUser.objects.create_user(username="player", password="x")
self.scope = Scope.objects.create(event_game=event_game, name="Match 1")
self.scope.assigned_users.set([self.player])
self.client.force_login(
CustomUser.objects.create_user(
username="admin", password="x", is_staff=True, is_superuser=True
)
)
self.form = {
"event_game": event_game.id,
"name": "Match 1",
"assigned_users": [self.player.id],
"is_active": "on",
}
def _sent(self, request):
with (
mock.patch("submissions.signals.get_channel_layer"),
mock.patch("submissions.signals.async_to_sync") as send,
self.captureOnCommitCallbacks(execute=True),
):
response = request()
self.assertEqual(response.status_code, 302)
return [c.args[1]["scope"] for c in send.return_value.call_args_list]
def _change(self, **changes):
url = f"/admin/submissions/scope/{self.scope.id}/change/"
data = {k: v for k, v in {**self.form, **changes}.items() if v is not None}
return self._sent(lambda: self.client.post(url, data))
def test_closing_scope_notifies_player(self):
sent = self._change(is_active=None)
self.assertEqual(
sent, [{"id": str(self.scope.id), "is_active": False, "assigned": True}]
)
def test_unassigning_player_notifies_them(self):
sent = self._change(assigned_users=[])
self.assertEqual(
sent, [{"id": str(self.scope.id), "is_active": True, "assigned": False}]
)
def test_unchanged_save_sends_nothing(self):
self.assertEqual(self._change(name="Renamed"), [])
def test_delete_notifies_player(self):
url = f"/admin/submissions/scope/{self.scope.id}/delete/"
sent = self._sent(lambda: self.client.post(url, {"post": "yes"}))
self.assertEqual(
sent, [{"id": str(self.scope.id), "is_active": False, "assigned": False}]
)
class SubmissionHomeViewTests(TestCase):
def setUp(self):
self.event = Event.objects.create(name="Event", path="event")
self.client.force_login(
CustomUser.objects.create_user(username="player", password="x")
)
def test_any_game_path_serves_the_app(self):
# Unknown game / no scope: the frontend shows its own not-found state
response = self.client.get(f"/{self.event.path}/submissions/unknown-game")
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "app.html")
def test_unknown_event_is_404(self):
response = self.client.get("/nope/submissions/unknown-game")
self.assertEqual(response.status_code, 404)
class ListUserSubmissionsApiTests(TestCase):
def setUp(self):
self.event = Event.objects.create(name="Event", path="event")
game = Game.objects.create(name="Game", path="game")
event_game = EventGame.objects.create(event=self.event, game=game)
self.scope = Scope.objects.create(event_game=event_game, name="M1")
other_scope = Scope.objects.create(event_game=event_game, name="M2")
self.player = CustomUser.objects.create_user(username="player", password="x")
other = CustomUser.objects.create_user(username="other", password="x")
self.mine = Submission.objects.create(
event=self.event, scope=self.scope, user=self.player
)
self.theirs = Submission.objects.create(
event=self.event, scope=self.scope, user=other
)
Submission.objects.create(event=self.event, scope=other_scope, user=other)
self.url = f"/api/events/{self.event.path}/submissions/submission/by/user"
def _list(self):
response = self.client.get(self.url, {"scope_id": self.scope.id})
self.assertEqual(response.status_code, 200)
return response.json()
def test_player_sees_only_own_submissions(self):
self.client.force_login(self.player)
data = self._list()
self.assertEqual([s["id"] for s in data], [str(self.mine.id)])
self.assertEqual(data[0]["username"], "player")
def test_staff_sees_all_submissions_of_scope(self):
staff = CustomUser.objects.create_user(
username="staff", password="x", is_staff=True
)
self.client.force_login(staff)
data = self._list()
self.assertEqual(
{s["id"] for s in data}, {str(self.mine.id), str(self.theirs.id)}
)
class SubmissionBroadcastTests(TestCase):
def setUp(self):
event = Event.objects.create(name="Event", path="event")
game = Game.objects.create(name="Game", path="game")
event_game = EventGame.objects.create(event=event, game=game)
scope = Scope.objects.create(event_game=event_game, name="M1")
self.player = CustomUser.objects.create_user(username="player", password="x")
submission = Submission.objects.create(
event=event, scope=scope, user=self.player
)
# bulk_create: skips the file broadcast, which would sign an S3 url
[submission_file] = SubmissionFile.objects.bulk_create(
[
SubmissionFile(
submission=submission,
file="shot.png",
original_filename="shot.png",
file_size=1,
content_type="image/png",
)
]
)
self.crop = ImageCrop.objects.create(
submission_file=submission_file, x1=0, y1=0, x2=1, y2=1
)
def test_crop_update_reaches_owner_and_staff(self):
with mock.patch("submissions.signals.get_channel_layer") as get_layer:
get_layer.return_value.group_send = mock.AsyncMock()
self.crop.save()
groups = [c.args[0] for c in get_layer.return_value.group_send.call_args_list]
self.assertEqual(
groups, ["submissions_staff", f"submissions_user_{self.player.id}"]
)
def test_crop_delete_reaches_owner_and_staff(self):
crop_id = str(self.crop.id)
with mock.patch("submissions.signals.get_channel_layer") as get_layer:
get_layer.return_value.group_send = mock.AsyncMock()
self.crop.delete()
calls = get_layer.return_value.group_send.call_args_list
self.assertEqual(
[c.args[0] for c in calls],
["submissions_staff", f"submissions_user_{self.player.id}"],
)
self.assertEqual(calls[0].args[1]["type"], "submission.crop.deleted")
self.assertEqual(calls[0].args[1]["crop"]["id"], crop_id)
+31 -4
View File
@@ -1,17 +1,44 @@
from uuid import UUID from uuid import UUID
from django.http import HttpRequest, Http404 from django.http import HttpRequest, Http404
from django.shortcuts import render, get_object_or_404 from django.shortcuts import redirect, render, get_object_or_404
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from events.models import Event from events.models import Event
from games.models import Game
from .models import Scope from .models import Scope
@login_required @login_required
def submission_home(request: HttpRequest, event_path: str, game_path: str): def submission_home(request: HttpRequest, event_path: str, game_path: str):
# Unknown game, no scope assigned, auto-entering the only scope: all event = get_object_or_404(Event, path=event_path)
# handled by the frontend (SubmissionsSelect.vue) game = get_object_or_404(Game, path=game_path)
get_object_or_404(Event, path=event_path)
scopes = (
Scope.objects.filter(
event_game__event=event,
event_game__game=game,
is_active=True,
)
if request.user.is_superuser
else Scope.objects.filter(
event_game__event=event,
event_game__game=game,
assigned_users=request.user,
is_active=True,
)
)
if scopes.count() == 1:
return redirect(
"submissions.view",
event_path=event.path,
game_path=game.path,
scope_id=scopes[0].id,
)
if not scopes:
raise Http404
return render(request, "app.html") return render(request, "app.html")
File diff suppressed because one or more lines are too long
-168
View File
@@ -1,168 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Games Planning Preview</title>
<style>
:root {
--bg: #1d232a; --surface: #2a323c; --text: #a6adbb; --muted: #6b7280; --line: #ffffff10;
--hour: 104px; --col: 150px; --gap: 10px; --break: 34px;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; }
.page { max-width: 1280px; margin: 0 auto; padding: 48px 16px; }
h1 { text-align: center; font-size: 3rem; margin: 0 0 16px; color: #e5e7eb; }
.sub { text-align: center; font-size: 1.25rem; margin: 0 0 32px; }
.toolbar { display: flex; justify-content: flex-end; gap: 8px; align-items: center; margin-bottom: 24px; }
.toggle { width: 44px; height: 24px; border-radius: 999px; border: 2px solid var(--muted); position: relative; }
.toggle::after { content: ""; position: absolute; left: 2px; top: 2px; width: 16px; height: 16px; border-radius: 50%; background: var(--muted); }
.scroller { overflow-x: auto; }
.planning { display: grid; gap: var(--gap); width: max-content; margin: 0 auto; }
.axis, .column { position: relative; }
.axis { width: 56px; }
.tick { position: absolute; right: 8px; font-size: 12px; color: var(--muted); transform: translateY(-50%); }
.day { position: absolute; left: 0; right: calc(-1 * (var(--col) + var(--gap)) * 6); border-top: 1px solid #ffffff30;
font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .05em; color: #e5e7eb; padding-top: 2px; }
.hourline { position: absolute; left: 56px; right: 0; border-top: 1px dashed var(--line); }
.break { position: absolute; left: 0; right: 0; display: flex; align-items: center; justify-content: center;
font-size: 11px; color: var(--muted); background: repeating-linear-gradient(45deg, #ffffff06 0 6px, transparent 6px 12px); border-radius: 4px; }
.column { width: var(--col); }
.card { position: absolute; left: 0; right: 0; border-radius: 6px; overflow: hidden; background: var(--surface);
box-shadow: 0 20px 25px -5px #0006; transition: all .15s; cursor: pointer; }
.card:hover { transform: translateY(-8px) scale(1.05); box-shadow: 0 25px 50px -12px #000a; z-index: 5; }
.card img.cover { width: 100%; height: 100%; object-fit: cover; object-position: top; display: block; }
.card .time { position: absolute; top: 6px; left: 6px; font-size: 11px; padding: 1px 6px; border-radius: 4px;
background: #000a; color: #fff; opacity: 0; transition: opacity .15s; }
.card:hover .time { opacity: 1; }
.fallback { height: 100%; display: flex; flex-direction: column; justify-content: center; gap: 6px; padding: 10px;
border-left: 4px solid var(--c); background: linear-gradient(160deg, color-mix(in srgb, var(--c) 35%, var(--surface)), var(--surface)); }
.fallback .title { color: #f3f4f6; font-weight: 600; line-height: 1.2; }
.fallback .when { font-size: 12px; color: #d1d5db; }
/* multi-anims: one long card, its games stacked & sticky while you scroll */
.multi { background: linear-gradient(180deg, color-mix(in srgb, var(--c) 30%, var(--surface)), var(--surface)); padding: 8px; overflow: visible; }
.multi:hover { transform: none; }
.multi .head { color: #f3f4f6; font-weight: 700; text-transform: uppercase; font-size: 13px; margin-bottom: 8px; letter-spacing: .05em; }
.multi .sub-card { position: sticky; top: 8px; aspect-ratio: 2/3; border-radius: 4px; overflow: hidden; margin-bottom: 12px;
box-shadow: 0 10px 15px -3px #0008; background: var(--surface); }
.multi .sub-card .fallback { border-left: none; }
</style>
</head>
<body>
<div class="page">
<h1>Get the games</h1>
<p class="sub">Don't own them yet? Grab them on Steam</p>
<div class="toolbar"><span>Show prices</span><span class="toggle"></span></div>
<div class="scroller"><div class="planning" id="planning"></div></div>
</div>
<script src="data.js"></script>
<script>
// Preview only: in the app this comes from Game.name matching + library_image
const STEAM = {
"factorio": 427520, "fall guys": 1097150, "lethal company": 1966720, "rocket league": 252950,
"r.e.p.o.": 3241660, "tricky towers": 437920, "rounds": 1557740, "cosmoteer": 799600,
"golf it!": 571740, "towerfall: ascension": 251470, "puyo puyo — tetris": 546050,
"peak": 3527290, "straftat": 2386720, "trackmania": 2225070, "slay the spire 2": 2868840,
};
const coverOf = (name) => {
const id = STEAM[name.toLowerCase()];
return id && `https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/${id}/library_600x900.jpg`;
};
const HOUR = 3600e3;
const css = getComputedStyle(document.documentElement);
const HOUR_PX = parseFloat(css.getPropertyValue("--hour"));
const BREAK_PX = parseFloat(css.getPropertyValue("--break"));
const TZ = "Europe/Zurich";
const fmtTime = new Intl.DateTimeFormat("en-GB", { timeZone: TZ, hour: "2-digit", minute: "2-digit" });
const fmtDay = new Intl.DateTimeFormat("en-GB", { timeZone: TZ, weekday: "long", day: "numeric", month: "short" });
const slots = PLANNING.list.map((s) => ({ ...s, b: Date.parse(s.begin), e: Date.parse(s.end) }));
const singles = slots.filter((s) => !s.is_multi_anims);
// Hours with something going on; long empty runs (nights) get squashed into a thin break
const start = Math.floor(Math.min(...slots.map((s) => s.b)) / HOUR) * HOUR;
const end = Math.ceil(Math.max(...slots.map((s) => s.e)) / HOUR) * HOUR;
const busy = (t) => singles.some((s) => s.b < t + HOUR && s.e > t);
const segments = []; // {from, to, y, h, kind}
let y = 0;
for (let t = start; t < end;) {
if (busy(t)) { segments.push({ from: t, to: t + HOUR, y, h: HOUR_PX, kind: "hour" }); y += HOUR_PX; t += HOUR; continue; }
let u = t; while (u < end && !busy(u)) u += HOUR;
const short = (u - t) / HOUR < 3;
const h = short ? HOUR_PX * (u - t) / HOUR : BREAK_PX;
segments.push({ from: t, to: u, y, h, kind: short ? "idle" : "break" }); y += h; t = u;
}
const total = y;
const yOf = (t) => {
const s = segments.find((s) => t >= s.from && t <= s.to) || segments.at(-1);
return s.y + s.h * Math.min(1, (t - s.from) / (s.to - s.from));
};
const cols = [...new Set(slots.map((s) => s.colonneS))].sort((a, b) => a - b); // drop unused columns
const root = document.getElementById("planning");
root.style.gridTemplateColumns = `56px repeat(${cols.length}, var(--col))`;
// Time axis + day headers + hour lines
const axis = document.createElement("div");
axis.className = "axis"; axis.style.height = total + "px";
root.appendChild(axis);
let lastDay = null;
for (const s of segments) {
if (s.kind === "break") {
const el = document.createElement("div");
el.className = "break"; el.style.top = s.y + "px"; el.style.height = s.h + "px";
el.style.right = `calc(-1 * (var(--col) + var(--gap)) * ${cols.length})`;
el.textContent = `${fmtTime.format(s.from)} – ${fmtTime.format(s.to)}`;
axis.appendChild(el);
continue;
}
for (let t = s.from; t < s.to; t += HOUR) {
const ty = yOf(t), day = fmtDay.format(t);
if (day !== lastDay) {
const d = document.createElement("div");
d.className = "day"; d.style.top = ty + "px"; d.textContent = day;
d.style.right = `calc(-1 * (var(--col) + var(--gap)) * ${cols.length})`;
axis.appendChild(d); lastDay = day;
} else {
const tick = document.createElement("div");
tick.className = "tick"; tick.style.top = ty + "px"; tick.textContent = fmtTime.format(t);
axis.appendChild(tick);
}
}
}
const fallback = (title, when, color) =>
`<div class="fallback" style="--c:${color}"><div class="title">${title}</div>${when ? `<div class="when">${when}</div>` : ""}</div>`;
const coverOrFallback = (name, when, color) => {
const src = coverOf(name);
if (!src) return fallback(name, when, color);
// Not every game has a library capsule, swap to the colored card if it 404s
return `<img class="cover" src="${src}" alt="${name}" title="${name}"
onerror="this.outerHTML = decodeURIComponent('${encodeURIComponent(fallback(name, when, color))}')">`;
};
for (const c of cols) {
const col = document.createElement("div");
col.className = "column"; col.style.height = total + "px";
for (const s of slots.filter((s) => s.colonneS === c)) {
const top = yOf(s.b) + 3, height = yOf(s.e) - yOf(s.b) - 6;
const when = `${fmtTime.format(s.b)} – ${fmtTime.format(s.e)}`;
const card = document.createElement("div");
card.style.top = top + "px"; card.style.height = height + "px";
if (s.is_multi_anims) {
card.className = "card multi"; card.style.setProperty("--c", s.color_code);
card.innerHTML = `<div class="head">${s.title}</div>` +
s.aandts.map((a) => `<div class="sub-card">${coverOrFallback(a.name, "", s.color_code)}</div>`).join("");
} else {
card.className = "card";
card.innerHTML = coverOrFallback(s.title, when, s.color_code) + `<span class="time">${when}</span>`;
}
col.appendChild(card);
}
root.appendChild(col);
}
</script>
</body>
</html>
-2
View File
@@ -30,8 +30,6 @@ dependencies = [
"python-dotenv>=1.2.3", "python-dotenv>=1.2.3",
"django-command-palette>=2022.11.25", "django-command-palette>=2022.11.25",
"django-admin-interface>=0.32.0", "django-admin-interface>=0.32.0",
"uvicorn[standard]>=0.54.0",
"uvicorn-worker>=0.4.0",
] ]
[dependency-groups] [dependency-groups]
Generated
-544
View File
@@ -1468,118 +1468,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
] ]
[[package]]
name = "httptools"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3a/ec/deed52912ab7ca6c0b12859330c571c60c61d7267b341b28951fcbf13694/httptools-0.9.0.tar.gz", hash = "sha256:d484ebb7e3a3f3597b0f645fbd1b85633674ca808c1f5ba11c2caf7c66f5c8b6", size = 282523, upload-time = "2026-10-09T19:57:04.301Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/6e/bd4f866032541728bdfaa33277856c68e608343205d8772027988ab00bab/httptools-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eacf0f45ca3ff84c01481c60c15da9ee56711f7292f66663df0f57af61e011c2", size = 119786, upload-time = "2026-10-09T19:53:47.604Z" },
{ url = "https://files.pythonhosted.org/packages/8f/f1/4a98e04117cf5d74a2079d759a68c1f29dc734636c05e34af7605e039191/httptools-0.9.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:f0ef48ce353f6b6a52232ba23d0983d4c2c84c84a778899404e34b4718509bf2", size = 115361, upload-time = "2026-10-09T19:53:49.28Z" },
{ url = "https://files.pythonhosted.org/packages/a6/16/b0400f48db7a4d4cdfa9301331d55cfd1c872c7f7b593d8be0897a517e55/httptools-0.9.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4a85401b0c3f893cf5695c1199e8679fbf673f7f78c2f6c11d6b1850f8c7e358", size = 487031, upload-time = "2026-10-09T19:53:50.9Z" },
{ url = "https://files.pythonhosted.org/packages/49/68/a07f16edaecc4830078b4f839f5bbda3be8da961bf79f8e28aa6eb69191e/httptools-0.9.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecf7037e491c220cd73987838c1ac3958d787bb098c3be0bfaf7f04204a6162c", size = 486897, upload-time = "2026-10-09T19:53:52.724Z" },
{ url = "https://files.pythonhosted.org/packages/d0/47/06aff715edb56e0439817e52db2b50a39a8a32b6ff9d91412406ea26cf4c/httptools-0.9.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:563e4568217dc907a91843f38c737be865222c0400a38cdcd0d26ce92b3db271", size = 512626, upload-time = "2026-10-09T19:53:54.528Z" },
{ url = "https://files.pythonhosted.org/packages/a6/0e/6c199ce5c8f4682dd541573194adfe12935ca5ffbfd7845dd01ffbbdd283/httptools-0.9.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cbbfcd5d15056fbd1edd5e725cf3feeb47c7cbccbe205927ebab422cc229f417", size = 449273, upload-time = "2026-10-09T19:53:56.101Z" },
{ url = "https://files.pythonhosted.org/packages/98/23/6e7cd2490b88d5567ac17fcc0e5aa2b06d4f6e2183cccbbee1b222adb0ce/httptools-0.9.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5332a020a60bbe32ede4bda1a62b3d56c4831d309cdf0932842c0fca8ad6aaa3", size = 470973, upload-time = "2026-10-09T19:53:58.471Z" },
{ url = "https://files.pythonhosted.org/packages/67/53/a7945c1b4b4d24b3249d47ec28817ff8e313f8206c8abb6ce4e391f21d88/httptools-0.9.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:48c705bd0b1afb6253ed71eca9f9ba7ac7d47838e5fed1ef7891d67f21ecd4de", size = 495861, upload-time = "2026-10-09T19:54:00.32Z" },
{ url = "https://files.pythonhosted.org/packages/24/f1/55b6709cc242e40aefc48eae6e9c7c908848c017c98d2661b4b33e335077/httptools-0.9.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ead1a40543a033a6732a9e1e515944979a19db3737ce77363fc0660e38554344", size = 444182, upload-time = "2026-10-09T19:54:02.369Z" },
{ url = "https://files.pythonhosted.org/packages/88/9b/046a3dbe803a631603eea9d64b0c0fa2285553975c18c4f158b15b27e02d/httptools-0.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:310266a2db1377ffae3bdf6556ab4973f4f94508a8ce37b2f6bb096a89bcefa1", size = 473538, upload-time = "2026-10-09T19:54:04.093Z" },
{ url = "https://files.pythonhosted.org/packages/c8/48/c013bb37d2c21e464db23499e7369565f41299675f745104e0406d3d0ad6/httptools-0.9.0-cp310-cp310-win32.whl", hash = "sha256:ae9bb62a7902e2ab65782447cd3eeb753510feace4e3ea03937a85489b01b16b", size = 86276, upload-time = "2026-10-09T19:54:05.662Z" },
{ url = "https://files.pythonhosted.org/packages/f6/63/68b4ee7f944191a64360f51af9ba4cd5ed59f751f8825d877c32019582ba/httptools-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:5cc5d3a29f9ec86ce406e5ec09c241dd8dc4d30e838f74f68d728b89131a3acf", size = 92640, upload-time = "2026-10-09T19:54:07.083Z" },
{ url = "https://files.pythonhosted.org/packages/fe/e8/86b1e6f43d13accfe72ac95aa72840b584d9c48012cdc52cfc24daa06051/httptools-0.9.0-cp310-cp310-win_arm64.whl", hash = "sha256:cb3e7a4fd0168e362673a980380bf4fd6ae3b1555150e60c5390b4b10d9c50c4", size = 89519, upload-time = "2026-10-09T19:54:08.379Z" },
{ url = "https://files.pythonhosted.org/packages/44/85/1b1e9e6f2f769dc48610f5e71b9a7d50d5a9532985fbd1f1a8b579f62b0e/httptools-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0fd73d0bbf700a30dd87e4412adf41cfa71542a533d6b390c7244bbb8a1152bb", size = 119077, upload-time = "2026-10-09T19:54:10.012Z" },
{ url = "https://files.pythonhosted.org/packages/e4/30/72d0caf79e54eb1356527c870daac40f8d06f86cd078fdf73c6bf3f7d100/httptools-0.9.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:d2b095129b9a98eb46a271ee9631089529c4e40354576b4aa74e24de9d2bf2f7", size = 114529, upload-time = "2026-10-09T19:54:11.35Z" },
{ url = "https://files.pythonhosted.org/packages/f7/0b/6498fe8218db1ed5f785010c303bfef50516d0988e64988bb8f9f59d70ab/httptools-0.9.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b68fb053b37c258a473ab67f4965c3b439500dc160fe364667035a6833eaf50a", size = 499139, upload-time = "2026-10-09T19:54:13.002Z" },
{ url = "https://files.pythonhosted.org/packages/2e/a9/81795025aa1ac0ca5346917571756c3e77ae3d0aa11d70fee11b5f89f713/httptools-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2780e33a58a93f27cc3bb74a55bae6f9a8278a1dbabdff392940d30d381671", size = 498141, upload-time = "2026-10-09T19:54:14.725Z" },
{ url = "https://files.pythonhosted.org/packages/5b/e9/f9070a752f6efb42381c0c63fec08385bfbc6d63be15191c424f6d740575/httptools-0.9.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:272db0c51e8b71e953c1f2ecbe63402b819680e4564be2ef285cfd4584ee8355", size = 524457, upload-time = "2026-10-09T19:54:16.424Z" },
{ url = "https://files.pythonhosted.org/packages/0a/29/201ca4636ebe7cb2c931d6545ed4be0acd5fb90f7351ea0458b5ab7319ec/httptools-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22ab1b10b06d357f01092e60f5e6856a0d479ed79b0ec2166a339ea26c699be2", size = 455418, upload-time = "2026-10-09T19:54:18.024Z" },
{ url = "https://files.pythonhosted.org/packages/24/97/2cc1ad7a28243e35002dcd9c4dd98074bc47c9a0a72be12de01fff10e2a5/httptools-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8a59c749a73fbdbc8e63b895a3079825fa085d752e75bc0a500042cb8a801e48", size = 483407, upload-time = "2026-10-09T19:54:20.032Z" },
{ url = "https://files.pythonhosted.org/packages/88/98/c7ca6a34d92010561eb5af95bf0d2b667ce4ea3e83ff7fda68da52e037bf/httptools-0.9.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f6ac1414556b910a879c108d79736f77e797871f9919ed0d2c3cf8cf3ecca986", size = 506748, upload-time = "2026-10-09T19:54:21.886Z" },
{ url = "https://files.pythonhosted.org/packages/ef/62/6aec88e4d1da59005184f1038f5abfaad6143499fbf4baa46c2fed8e5b59/httptools-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:13873eb8aef5972fcfee614f63d47064312ad4efbfe65ade15b8a3b77f8c8659", size = 450253, upload-time = "2026-10-09T19:54:23.863Z" },
{ url = "https://files.pythonhosted.org/packages/5a/06/4be91efa577ccae9a16694a413bb8a7c30cb0ec2dc972627b64e108517b8/httptools-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5042aa1c7e2b1a24c17dab31d8770b63a5101c9abc25f832c6aef6b201e1ca4f", size = 485588, upload-time = "2026-10-09T19:54:25.592Z" },
{ url = "https://files.pythonhosted.org/packages/10/eb/3224a5e3145b784e7344a370a8cc9a0d448035a913ddece6e4c35067315f/httptools-0.9.0-cp311-cp311-win32.whl", hash = "sha256:a4d1ecad62e83cc65b411ea0125972cf3af98821e8117129947fd1e3a113f8d2", size = 85785, upload-time = "2026-10-09T19:54:28.216Z" },
{ url = "https://files.pythonhosted.org/packages/9c/41/214e2da998e6348eb68fd0883ffa9774c83ab7a5da12d97595aafb07d0ba/httptools-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c4fa57d3c31889722f64bfa785545a5e603a893b6f29ac1a41bfa830abeaefd5", size = 92421, upload-time = "2026-10-09T19:54:29.574Z" },
{ url = "https://files.pythonhosted.org/packages/96/af/d8fc6b8581045899a780018842a3db0365c4b8ca46519a528e9b8bc6095e/httptools-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:ecfeee649184ffd800955068be9a6b579a0f33fc3c98535d685d5779cb59347f", size = 89093, upload-time = "2026-10-09T19:54:31.269Z" },
{ url = "https://files.pythonhosted.org/packages/da/ed/0916b8b7ebd1deeaf22acba71b68c57b4b6b69aa1918f3812dea208b4276/httptools-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f9ccc9884241efceb4547a92955d128574c864681f11b7ea3ecbde295fafbe8b", size = 119039, upload-time = "2026-10-09T19:54:32.556Z" },
{ url = "https://files.pythonhosted.org/packages/c2/0b/9b6de4a01a563a904d0826c9069c824b330e1816df26c9bdf93f60b50857/httptools-0.9.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:45b3002392948dcf578029c89f6318e1289a993a1a5ec38a4161560fab60f811", size = 115051, upload-time = "2026-10-09T19:54:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/85/3f/642113e9882f53158ecddf58003d25f18ded2c210ed23bf6eb663d4d51c3/httptools-0.9.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3e3201fe4d46e0d15d7ff9fafc94a605da9eb82d2c5b9837f0368acb325481f1", size = 552848, upload-time = "2026-10-09T19:54:35.434Z" },
{ url = "https://files.pythonhosted.org/packages/95/4c/3ecc59c99c28652d8d08d9b5be65770a14d2cadc616dad94224cee2b0e7e/httptools-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58a1b0ec4cbb930e69669f9771715b2c7898d3cdf064d9811f7a66afef96b544", size = 549181, upload-time = "2026-10-09T19:54:37.099Z" },
{ url = "https://files.pythonhosted.org/packages/43/ce/21f5b2759590b7054e38d3b704a3c6b853c3395c370b3d6f16c45aea0fc0/httptools-0.9.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c58dc91aefb31adad500aa68054334f429b840b36dd29e34e834101044cb2ef", size = 569121, upload-time = "2026-10-09T19:54:38.772Z" },
{ url = "https://files.pythonhosted.org/packages/52/c3/7c523aa8d0fa7a57010a3e1bbdebc209585009076465f3d1ae6a3f54b814/httptools-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b900073e7b8481ef1aaf4f6c1789d210a1db01a9da8789821578cfeb4c2d540", size = 487649, upload-time = "2026-10-09T19:54:40.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/e2/d90d60002692b8afcbc06fb49ca3a4365b32abed6c40fb2b612c67721a00/httptools-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c12d0393a903b58bc5f5a7406d6c5290acfb8284290d68547ce620c06f7d133", size = 529583, upload-time = "2026-10-09T19:54:42.296Z" },
{ url = "https://files.pythonhosted.org/packages/46/c0/19172874cde0344a20c85877a0b2d0dcfca31111729ad8a79e8b4ac4e207/httptools-0.9.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:29b0d823e3c1e7cd1093a5dc889245db693ef13ada624cd66e2262421ef38867", size = 552414, upload-time = "2026-10-09T19:54:44.19Z" },
{ url = "https://files.pythonhosted.org/packages/1b/b8/02ea7910f69e5371986b025fb3b410592106df54e977a5732fd1d95917b5/httptools-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6ebd39ee26db460cfe5ab8b71a15d1149b289139a0d3981522757d6af620887e", size = 482400, upload-time = "2026-10-09T19:54:46.064Z" },
{ url = "https://files.pythonhosted.org/packages/de/97/f05eac916d44cbbfe43668a6a40ab93e7fd8f94d5120d1ce2d8e55c69871/httptools-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4efbee349138a3fee7a4cc3a95abd2d499fae70dd5bff9fed9138d6f570f4283", size = 536148, upload-time = "2026-10-09T19:54:47.695Z" },
{ url = "https://files.pythonhosted.org/packages/b6/e9/9435dfcb7f1a1d6ebdc79a902164dfca70e33774c80bb60d25c630c31ef1/httptools-0.9.0-cp312-cp312-win32.whl", hash = "sha256:36fac804b8cfd6b935ae64f71349f833d2b6298404626d017a2c57bb942bc643", size = 86274, upload-time = "2026-10-09T19:54:49.1Z" },
{ url = "https://files.pythonhosted.org/packages/8b/69/813f1bf90be507d4166c437be1a413574d0e0abf36e2fec10c266661b0ee/httptools-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:7e32b83bd8c2f8b6fa726ef34e63e21c4d7eddc277d40d4ef7245ea3ed28e5b6", size = 92446, upload-time = "2026-10-09T19:54:50.498Z" },
{ url = "https://files.pythonhosted.org/packages/ae/e0/1d29e328c4cafe843403341e1455e0aec18b0e6910fbb14f12b36b563f19/httptools-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:813a32f94991b9627795528053c73a57d2ce3eb98ede89f0e1c7a31095938e81", size = 88676, upload-time = "2026-10-09T19:54:51.844Z" },
{ url = "https://files.pythonhosted.org/packages/9c/04/223994f8589750d2a36ceb43203e739cf75bd9e12c226680d73567766908/httptools-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fb995082fe41ec410b33c48b54fb1d44abb8a6ee762c31e8c42519e8c3a30a9", size = 117115, upload-time = "2026-10-09T19:54:53.356Z" },
{ url = "https://files.pythonhosted.org/packages/31/d8/b4407836e567a862ce79d78a628d785db99aba52e63496d68c60eed0d475/httptools-0.9.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:b9cd15cb7cf0d5cc41f649fd789aae12c56c3b83eff593f8e095c1d4555ad5c3", size = 113225, upload-time = "2026-10-09T19:54:54.81Z" },
{ url = "https://files.pythonhosted.org/packages/79/f6/0caa51b077492a7306bdbd9dfb907a2246985f0aed1fe2d086255921848b/httptools-0.9.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:088de1738e1af624466a01c35d652dbe6fb825be887c76d68aa850621d81db88", size = 520112, upload-time = "2026-10-09T19:54:56.3Z" },
{ url = "https://files.pythonhosted.org/packages/fa/da/7a47b7c2106bb10e6d4c04a139d045257a4f93c672fae6f0b9e92b1f7bc2/httptools-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b1ac7f1bc6c0dbf90684b77571a51a21b2463909fd916ce0ac9bfc4d566dc75", size = 516079, upload-time = "2026-10-09T19:54:57.938Z" },
{ url = "https://files.pythonhosted.org/packages/0f/4d/417b42d2663acf4f5aeb2718dc894ec2be4e3dcfd8caa2d3bf9ee2dce511/httptools-0.9.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9430f65db521db7962ad951571d446171213686f96c998a54dc18ed574821e2", size = 535040, upload-time = "2026-10-09T19:54:59.769Z" },
{ url = "https://files.pythonhosted.org/packages/cb/de/8df4c09a33ddaf50f697719f20201cf93631ef4b50cec05e42acf179a7c1/httptools-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:52fe0176682a25b15370f23f5b0f1366a84771df89144fb0cd979cb72a94b5ca", size = 462799, upload-time = "2026-10-09T19:55:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/e8/90/1bfe91e3fca29c541d85d7ba8ed92a406d4dd13608c281baf7ec75369fec/httptools-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:757e3f79cb865a7db94e0db5f4d0ed3284a69e39d53568f433982ea13c60cac1", size = 497596, upload-time = "2026-10-09T19:55:03.201Z" },
{ url = "https://files.pythonhosted.org/packages/b0/af/2bbd5af0dd7a0e0c3b63bfefafd87a07041eb13d7cd710fbf30708b70773/httptools-0.9.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6ff5f0ed70783dcb9562dbd20edca51c3d4d277f128223709e3da6b75986d1d4", size = 517110, upload-time = "2026-10-09T19:55:05.011Z" },
{ url = "https://files.pythonhosted.org/packages/d4/7a/9f165817c3e27df9098f3d50a675417d8721253f1073434f48a3f9d9a6c2/httptools-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0f537e5e8152e8d9cae82804024790cb973061abd3b7ef8f66f46e2b5c7bb51", size = 459198, upload-time = "2026-10-09T19:55:06.985Z" },
{ url = "https://files.pythonhosted.org/packages/93/20/b93279e334946c359d39aaf405241c6fd60f9e60da709bc4156731a4413c/httptools-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a7f1df31829c258158be01bb04eb668c4fba7df1ddf2262131a972962e651b6", size = 502996, upload-time = "2026-10-09T19:55:08.733Z" },
{ url = "https://files.pythonhosted.org/packages/86/c9/ac3657943d40c5a9949b72565ee03151e480fb18c062c7c13c0c0276df6f/httptools-0.9.0-cp313-cp313-win32.whl", hash = "sha256:714bf348f468532d86bed670837e7d5ddff3834dd7f5d3c08066da400c86f088", size = 85878, upload-time = "2026-10-09T19:55:10.275Z" },
{ url = "https://files.pythonhosted.org/packages/74/69/d23079cd4bc16d11e49c3f51c2540c018736f26701a2a73183cae9255a1c/httptools-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:805b0f2618e5d4c3e28f45b731eb1a0539691ae4a2f97b4ce014de0bf96a1ff5", size = 91549, upload-time = "2026-10-09T19:55:11.701Z" },
{ url = "https://files.pythonhosted.org/packages/0b/ed/5ff678a774b721f054c095f04d84fc536e7369ea4f4c9af3813a518d95b6/httptools-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:bfdabac0c6d3d6a5be8c2a100a001c92c14a39bbafd5999545a675c493626e64", size = 88043, upload-time = "2026-10-09T19:55:13.046Z" },
{ url = "https://files.pythonhosted.org/packages/31/39/0965023968452245ece67b161adbf7c5652f8d0697ac69312f9d21849411/httptools-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a4050a651e1f2faf05eb028ce9f2168abbcee9e24b209f5c1f2eb96d8c569e4", size = 118142, upload-time = "2026-10-09T19:55:14.491Z" },
{ url = "https://files.pythonhosted.org/packages/31/39/a6ec662d81059e505e953af709797038e83e489014df721e506f4fd0d3c5/httptools-0.9.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:130635fea6e611a6b2026120037965ddb88b3dafd11bb64e264b101a70a76630", size = 113943, upload-time = "2026-10-09T19:55:15.887Z" },
{ url = "https://files.pythonhosted.org/packages/72/04/4ecb7251a6c55bef61b157bb93fd44678943c35702a5966e4d5ebda2d450/httptools-0.9.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18d800aaa2d6bff7d889df810d1b19a5fde72b1f6c0ca96e8d9f28a692fe5460", size = 514397, upload-time = "2026-10-09T19:55:17.48Z" },
{ url = "https://files.pythonhosted.org/packages/31/5a/0c26c98ee06f0f39608de715e7ca868baec942171a77feace5a0ba548ca6/httptools-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e45def4d9ce7073e2226535572442d9d6efb4047c7a5fd8960807e877ce70a", size = 514383, upload-time = "2026-10-09T19:55:19.221Z" },
{ url = "https://files.pythonhosted.org/packages/d4/6c/0f85d4f1f579c49aea6e4946dd304e9f33a680382b5117970ab887885bc7/httptools-0.9.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f6da814aeecbc6cb8872d6d3e85ed16e8ab1653f9557cea8658725ce212348a", size = 534993, upload-time = "2026-10-09T19:55:20.992Z" },
{ url = "https://files.pythonhosted.org/packages/3b/32/97a836533b7bc9e269fc6d075c2d27669ca9786bf43f229158b9b4b15021/httptools-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e1e037bb57dbc549c6fe20370b763ea74bdb09413cdcf857e4f14d9e4e2fb13", size = 461494, upload-time = "2026-10-09T19:55:22.785Z" },
{ url = "https://files.pythonhosted.org/packages/67/cf/a2d5e8dc3bad9b0b966bb546170234b4614275346cccbc01f6cdb6fce3b3/httptools-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cd3e55223a77d6e08d5730ebacb4930ecca5d2ce7c57e7ba10833be7e52903f1", size = 495859, upload-time = "2026-10-09T19:55:24.9Z" },
{ url = "https://files.pythonhosted.org/packages/bd/d9/7472c4ca2aa1cfe6d0f9923380784b034cb77addc88589f2e5c92fd3b4df/httptools-0.9.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:beb2c8a34cc90fb4d862b7284eafdb322030d6a8b2ee5eb6a744f84205beedc3", size = 516303, upload-time = "2026-10-09T19:55:26.84Z" },
{ url = "https://files.pythonhosted.org/packages/c1/dd/f9be002ba859714cc306fe86204b7cb12bac091be66a7e23d7bb25d259bb/httptools-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0cc339a807c156d840b54f8bf050ba0fc265eb81692c24bca8535b52fbd797c6", size = 458188, upload-time = "2026-10-09T19:55:28.571Z" },
{ url = "https://files.pythonhosted.org/packages/89/7a/ed8bb5344071afd12c87e57e8839fa65abc3895b92a5d065be79ecacb919/httptools-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b6ee42112d785a913dd63ec0335435a3dddbea5040c151252db815b0095cf066", size = 498356, upload-time = "2026-10-09T19:55:30.301Z" },
{ url = "https://files.pythonhosted.org/packages/04/8d/3f1390c901d4a266ad9d5b988c47c4883e322e6f6cc021c592b9a050fb19/httptools-0.9.0-cp314-cp314-win32.whl", hash = "sha256:d1e329a1866981efe0201d05a374617f6c6cf14434a501d78ab22793d1ab1fa6", size = 88479, upload-time = "2026-10-09T19:55:32.071Z" },
{ url = "https://files.pythonhosted.org/packages/99/05/7de70a4eea3b52d31a95fe64eb5775ccdead01e4913e4741b4424e9ef180/httptools-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:edd5aa045fa3cc57143db018dd32ce7962bd5b525d05230709015d7e570100aa", size = 94809, upload-time = "2026-10-09T19:55:33.423Z" },
{ url = "https://files.pythonhosted.org/packages/e8/79/7f6c354a8f8f74381fd473f365d2db3cd976ee8d1422b8dd7455dfc52b62/httptools-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:6ff0145b34610e57c9fae20df4e133c8d54266447387de6fcc0bdabfe4db4569", size = 91508, upload-time = "2026-10-09T19:55:34.764Z" },
{ url = "https://files.pythonhosted.org/packages/94/0c/f9e8148ca684b41b4b5d0ced0860530b9a9bcb7c38bf727d83dcbfea42d0/httptools-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:80eae881cfb69383303e9a4d7961a478025b89c24f38f2e69b30c516fa0d57f2", size = 123684, upload-time = "2026-10-09T19:55:36.445Z" },
{ url = "https://files.pythonhosted.org/packages/3d/54/3c1d910e8f0bc9ee0ba7867b687e3272c8ae4a7da2df2fbf1b2bce77f0f9/httptools-0.9.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b2ab3aad55d75d0b8df8d8a1b5920baaec9b161112cd5e95984848b4d2cd3dfe", size = 118378, upload-time = "2026-10-09T19:55:37.851Z" },
{ url = "https://files.pythonhosted.org/packages/d4/ce/3b9694880da927ae69b5629b8847cfe73d14584be2aa974a92ed2675b7da/httptools-0.9.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db735a23ecb0f0450d2b24e0a05fb00a8a35c9db172919c4d3e023e7c7ee4c9b", size = 591295, upload-time = "2026-10-09T19:55:39.501Z" },
{ url = "https://files.pythonhosted.org/packages/3c/89/1ff2835b6adf5c08a477d3a199e72b71e7f26df55ceaaed7d7364d745a1d/httptools-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:995b52f7c260ac7023640221f27472303968753cb6fc6fce1ddfb0e9db59a398", size = 603709, upload-time = "2026-10-09T19:55:41.404Z" },
{ url = "https://files.pythonhosted.org/packages/24/40/4f59a0d9dca6d60002e7cb5dbf1441b558ced5a65b5b4131d57cbbd7c806/httptools-0.9.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3af4e45ff455fce5511fdf2653c1ce428ef09c56fe37a83eb4d924c2d474f31e", size = 607379, upload-time = "2026-10-09T19:55:43.119Z" },
{ url = "https://files.pythonhosted.org/packages/bf/19/381d444a3ba704cd5c67eb4617ae7a08e920a8239c688f23ba0de07a270b/httptools-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ce8e723b4637034b76f5382a30a6b725518c332273e8d62a6c7d46e90837c947", size = 530031, upload-time = "2026-10-09T19:55:44.85Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c5/c9ba7758bf266240f598934510af4a800edafd9c8eb1fcf15feac0427063/httptools-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:465bc1526debf53a3be92022a16ca0c38f891ea3b5c1587af4f52e44020f8a07", size = 575129, upload-time = "2026-10-09T19:55:46.536Z" },
{ url = "https://files.pythonhosted.org/packages/db/87/c17f3a53616a3849681f7c8e913ce966487b95038504bbb035c38f5f2fbe/httptools-0.9.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8463b34ebde3f000627e9dbd8a545f995ad49fbf7ff9dd5abc0cd507da98a603", size = 582996, upload-time = "2026-10-09T19:55:48.545Z" },
{ url = "https://files.pythonhosted.org/packages/88/e3/cb33ba1348ddfa5853f96021f4c38674ac383b92c944492cf7638bd6bfd0/httptools-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f9489c1d87160c126f73b004742fe8654fa1ce37ed89e9e01330a1c10aaecde4", size = 526150, upload-time = "2026-10-09T19:55:50.261Z" },
{ url = "https://files.pythonhosted.org/packages/e9/00/af0e2f33ba5be60803a492ad377e798714d0c970e76015e313849b351ef7/httptools-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:06bfe7fad972a417269d8a5fc53b87e4eca970354abf5e9e24336fd06d64292e", size = 571731, upload-time = "2026-10-09T19:55:52.422Z" },
{ url = "https://files.pythonhosted.org/packages/b6/35/e67e9c9dd3da036ebfcbd273eec44bd39213f952d638858b09b9f3ecaf3f/httptools-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:c42424213c28804f8d0e20f5692106cfb57bf72e1dbc4092b8481fb2f9e4c707", size = 94622, upload-time = "2026-10-09T19:55:53.982Z" },
{ url = "https://files.pythonhosted.org/packages/c5/5c/af620c73de59b5f3d431ae778c7412d30bba7bf56ca8b4140107a8ac0e54/httptools-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bb1533541c729ad422f870a780d8b4af924f9817d45b5f580390418cda72eaa2", size = 101934, upload-time = "2026-10-09T19:55:55.417Z" },
{ url = "https://files.pythonhosted.org/packages/90/90/fc6019b5179d13007c6c3039346ea2696cf2e94369d6ca96e57f23b01989/httptools-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6f9549ca354a1d6d6167c458a1f1b12147726b968f02dd64b6a5801dba91ae0f", size = 97211, upload-time = "2026-10-09T19:55:56.878Z" },
{ url = "https://files.pythonhosted.org/packages/d2/77/e226b16a2f291f2a4ce25a24a3297e98749d80b8a713b8f3b11d8a82e904/httptools-0.9.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:d3906b5c549ff2ad2473cb711e1fc65d76715c2726a402108fbf55eab6c6b49d", size = 117817, upload-time = "2026-10-09T19:55:58.295Z" },
{ url = "https://files.pythonhosted.org/packages/ff/08/050ad8985ec34064e4401e6e5aeca7238685bc218eaff20025f7c04b0723/httptools-0.9.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:cb2bb3ac0af7fdab2311b895c9eb95442b45deb14cc949b9e65545e74aa0be69", size = 113669, upload-time = "2026-10-09T19:55:59.915Z" },
{ url = "https://files.pythonhosted.org/packages/52/0f/af812488a4963ce59d97b73a00c72bba49f5eebca1a13ab6f114372b5e82/httptools-0.9.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63d38e9a9a10a20fb57593742e63c6b1e78dd7f6ef5472de8e0b1e4cf4f3db26", size = 515633, upload-time = "2026-10-09T19:56:01.529Z" },
{ url = "https://files.pythonhosted.org/packages/50/6d/73c987b84e0d02fa6c4109c7ce6ea00518d0aa3005fb92b75553ffd5ddf8/httptools-0.9.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eae4e9c7a0785a1a715de0a74fb822ab40084c060f444f18f075d05e322aa7ef", size = 516049, upload-time = "2026-10-09T19:56:03.327Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f9/74cc01fba5a0ea05501eb39eddba4baa00c10e4d1caebdb78f23eaacafe5/httptools-0.9.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0adc974916efe1fbf89d0363a86dcb2c746727643e362ff398de1a4b50b6bc77", size = 534832, upload-time = "2026-10-09T19:56:05.068Z" },
{ url = "https://files.pythonhosted.org/packages/8c/a2/a7bb90643c059e8136c2a5fdfb0d7e1a18b2c5c4f1a78f2de14b1303184d/httptools-0.9.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050f84b7ec46a6efe0e5f521cf8729e3397c1cef4384f62ed8d5d68ca0045776", size = 466489, upload-time = "2026-10-09T19:56:06.757Z" },
{ url = "https://files.pythonhosted.org/packages/5e/19/bb3f18e05cbad9628e7f1254176c475e05ac79c72697ec7c144fc2cc877f/httptools-0.9.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9b4da5789d7cf576c7e81f0088c632f6ee3786d87d17f08e90e703c22ce15633", size = 497146, upload-time = "2026-10-09T19:56:08.641Z" },
{ url = "https://files.pythonhosted.org/packages/25/e6/90e2433d7a947bec66a5ad22e948626a26672ff62aa3ebf949899f687a3e/httptools-0.9.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:f78f7ae1c2e5aabf29583fc0d302d8081a663776f84578025662eb6f5d63a921", size = 516153, upload-time = "2026-10-09T19:56:10.415Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c7/86373edd9d800eb723b8b68d3fce0e31d3e3211f9d7b0eaf8c3deadfada0/httptools-0.9.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b2cc6991f16f6d666d48e4b57318104e7b29109e32e2f6b86e9d44c4e6a27f4e", size = 463141, upload-time = "2026-10-09T19:56:12.406Z" },
{ url = "https://files.pythonhosted.org/packages/65/46/8dc41d9ebf78fa56f609f251ed8ac5a9f66513b0ce712040bd7ada7b19cc/httptools-0.9.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:dbc9fd1521e573045d71b6afab7398439c5cc259e8cb9d416fe62d485c4899c6", size = 499125, upload-time = "2026-10-09T19:56:14.109Z" },
{ url = "https://files.pythonhosted.org/packages/7a/41/38db94fda8b266dcde50722a4fcef825b189380a220e02c682518bc1b430/httptools-0.9.0-cp315-cp315-win32.whl", hash = "sha256:34266cec8c1d4e3e91fcca7efe38971d6bdda64a7944f2a46ab576da15173680", size = 88370, upload-time = "2026-10-09T19:56:15.873Z" },
{ url = "https://files.pythonhosted.org/packages/4a/cd/347f12eb16e20972dcdacbca907f2c52d72a36542199a5bf3ca342c92098/httptools-0.9.0-cp315-cp315-win_amd64.whl", hash = "sha256:b5a3f5f70967a1aa2bc47fec42a1e19d2fb38c61700e3ee62b63a4af4f4fd001", size = 94617, upload-time = "2026-10-09T19:56:17.257Z" },
{ url = "https://files.pythonhosted.org/packages/f3/08/086ba2f53989d504a05f4669b03673a04fc72554bc37d4696c3c6132be75/httptools-0.9.0-cp315-cp315-win_arm64.whl", hash = "sha256:e0acbd474d0af4afacc6e66c4273f8a19e25f8af4379fc816388095ea6b01371", size = 91358, upload-time = "2026-10-09T19:56:18.641Z" },
{ url = "https://files.pythonhosted.org/packages/3e/3a/9ba59ec76d45bf8eb7ad3a18f2c6e9074fa4ce5cbbd3900fffb8d840f9e7/httptools-0.9.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:02bc5b3dcb6394b9d825fd62a7bfa0b2943063a3c89abc4492ad45e334a20eb5", size = 123063, upload-time = "2026-10-09T19:56:20.023Z" },
{ url = "https://files.pythonhosted.org/packages/18/2d/49eb389bda75a8ef0d04bf025dfb8412a3646637051c8a88bdeea700e343/httptools-0.9.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:fc1a4f9d18d32a6e0a0a0a382986a60a2126f5144dd08715be7adb8df18e8a46", size = 117328, upload-time = "2026-10-09T19:56:21.439Z" },
{ url = "https://files.pythonhosted.org/packages/a0/6b/2d6439378fd3d1f9c06272b35d61f4519e2d9bf9967611df069fa6c23044/httptools-0.9.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df3867518b205be3648e2fbd522bf380c851b5c2500588047505afdd786b6669", size = 588680, upload-time = "2026-10-09T19:56:23.056Z" },
{ url = "https://files.pythonhosted.org/packages/08/65/3fb50e861bbb6103ca58fd88b4127d346fc909eb9f06d250455033a3f698/httptools-0.9.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26e1d9629f3bf70d23f0d22238152aec51c837a7c9e384cb74f356fdccad7eb3", size = 600031, upload-time = "2026-10-09T19:56:25.216Z" },
{ url = "https://files.pythonhosted.org/packages/90/9b/40d33d4098fde007845804b1c923ddf5a27fd48aca1c8080bdbdac6c16fa/httptools-0.9.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:050f7ab098121873c8f13e35857f97ab60a76185c8302bde9a384939bb7c3b96", size = 604407, upload-time = "2026-10-09T19:56:27.04Z" },
{ url = "https://files.pythonhosted.org/packages/17/37/472afc9000aca3c7dd61a9b8ac6f3e2765900e3614f8d7f13e772c9c5438/httptools-0.9.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8d90d10e9b6594c28f27896a68fab97fd784c43804e9fe419dab8e8dcfcf4b02", size = 529459, upload-time = "2026-10-09T19:56:28.944Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/9956910fb1d181578249cd2cc966c0c46ad3c558b43ac2b79af50f94589f/httptools-0.9.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b928ab0ecaa664e8caecc529dcb8bc881b6b35bb2b74bf9a39ae25f982ee8812", size = 570845, upload-time = "2026-10-09T19:56:30.602Z" },
{ url = "https://files.pythonhosted.org/packages/30/8c/d1c160a3cc2c18e41a6f763c3aad979530dfb295039449312b8814e19753/httptools-0.9.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2319858018eedd0c0b2f950a620413c0a9d1352607be4267eb28209eca8b1e3f", size = 579194, upload-time = "2026-10-09T19:56:32.353Z" },
{ url = "https://files.pythonhosted.org/packages/90/3c/3f7cc49925928a8c82f4141d504b8b8c2901c4b35cb88800211828312561/httptools-0.9.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:931f45f84e15daafec5f82cc92e6710569e1f50933f3253d206eab4132bec678", size = 524950, upload-time = "2026-10-09T19:56:34.103Z" },
{ url = "https://files.pythonhosted.org/packages/19/98/8e2154e99b8e8818fad3e6c5dd7cf21c050f6314b1bd8072e8dc29f49eb5/httptools-0.9.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f67db0ba2bedafec15b8e5330d40da1e1c7921559fa715af021252bfef81a6f8", size = 568603, upload-time = "2026-10-09T19:56:35.876Z" },
{ url = "https://files.pythonhosted.org/packages/79/a3/86fe9fef3a1bfab5db62262f8880c294cbf8a8d94cffe2a2aa8b4aeed40c/httptools-0.9.0-cp315-cp315t-win32.whl", hash = "sha256:2095207b75a83c9e947346da9c127fb7e4fb29f41589df2643764f06b750989c", size = 94042, upload-time = "2026-10-09T19:56:37.441Z" },
{ url = "https://files.pythonhosted.org/packages/54/4d/f2d88782251467325a62ec4ad704249bb1b09c21aacb997181a9f4421f30/httptools-0.9.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bca180cbe84e4fba7807eb408a8655295f697928512324517e30a091ede522a8", size = 100837, upload-time = "2026-10-09T19:56:38.831Z" },
{ url = "https://files.pythonhosted.org/packages/00/4b/5e96c4e0d171f959a0064971c3fced9cea5a19e5fab7a8e7d57aceb80506/httptools-0.9.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a4d8c2c7e73ba5967be74d7c3a5ff81fde815ee1b48d9c5c0f14de8463a847b", size = 95947, upload-time = "2026-10-09T19:56:40.562Z" },
]
[[package]] [[package]]
name = "httpx" name = "httpx"
version = "0.28.1" version = "0.28.1"
@@ -2504,8 +2392,6 @@ dependencies = [
{ name = "redis" }, { name = "redis" },
{ name = "requests" }, { name = "requests" },
{ name = "sentry-sdk", extra = ["django"] }, { name = "sentry-sdk", extra = ["django"] },
{ name = "uvicorn", extra = ["standard"] },
{ name = "uvicorn-worker" },
{ name = "whitenoise" }, { name = "whitenoise" },
] ]
@@ -2553,8 +2439,6 @@ requires-dist = [
{ name = "redis" }, { name = "redis" },
{ name = "requests" }, { name = "requests" },
{ name = "sentry-sdk", extras = ["django"] }, { name = "sentry-sdk", extras = ["django"] },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.54.0" },
{ name = "uvicorn-worker", specifier = ">=0.4.0" },
{ name = "whitenoise" }, { name = "whitenoise" },
] ]
@@ -3635,100 +3519,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
] ]
[[package]]
name = "uvicorn"
version = "0.54.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
]
[package.optional-dependencies]
standard = [
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "websockets", version = "17.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
[[package]]
name = "uvicorn-worker"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "gunicorn" },
{ name = "uvicorn" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/59/9101b9c0680fd80e9d26c07deb822a5d18a324339fcf9cd017885ee808ad/uvicorn_worker-0.4.0.tar.gz", hash = "sha256:8ee5306070d8f38dce124adce488c3c0b50f20cf0c0222b12c66188da7214493", size = 9361, upload-time = "2025-09-20T10:47:01.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/25/09cd7a90c8bb7fb693be0d6704fccd5f9778d5513214b7a01cc4a94ff314/uvicorn_worker-0.4.0-py3-none-any.whl", hash = "sha256:e2ed952cef976f5e9e429d7269640bbcafbd36c80aa80f1003c8c77a6797abde", size = 5364, upload-time = "2025-09-20T10:46:59.776Z" },
]
[[package]]
name = "uvloop"
version = "0.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/aa/a67389d92dc118bb6b48cb57b08bf6f24925a07e05de196e4b998c339017/uvloop-0.23.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ce17bc317d089f361b33521654c13e30eacfd3d2034fd34e613ca9c51c969686", size = 1420655, upload-time = "2026-10-01T03:15:21.22Z" },
{ url = "https://files.pythonhosted.org/packages/79/70/749d8bad691e6036f83d7c7e3cb34306261e01de847ce4ce46eb7aec5240/uvloop-0.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:53c2c5d7e2024e46776c2d90e6c637d01102126b61aaf5faa5edaf05f8b5722a", size = 780765, upload-time = "2026-10-01T03:15:22.842Z" },
{ url = "https://files.pythonhosted.org/packages/bc/44/a4b7bea44d55c882e23fc858eebed9e157486650cdbecdb951577e89362f/uvloop-0.23.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:42feced24b9b44b856c633eafb5cc5dec354972da55ce77598db6844c054bc7c", size = 3795900, upload-time = "2026-10-01T03:15:25.507Z" },
{ url = "https://files.pythonhosted.org/packages/76/4a/488d9ee6eb87899273d84ebeaf7023c551ff8f8d44f7e7c0f78d06b6da25/uvloop-0.23.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bf08e4b6362dd1c08623bbfa2d061e8bac0f1da8fc2007062cfe1dc360a49fa", size = 3850999, upload-time = "2026-10-01T03:15:27.308Z" },
{ url = "https://files.pythonhosted.org/packages/fc/51/6146339b0a4e0f880ed1abd98517b21a6021ac0988cbc83c7339d7ee346f/uvloop-0.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4bb7f5d0b62b5afaaaea2b7b60d508921c24b0fe39c22c1438bec1811ffe10ec", size = 3655020, upload-time = "2026-10-01T03:15:28.908Z" },
{ url = "https://files.pythonhosted.org/packages/7a/76/c2576407efee20fdfbf08ad35122ec9b2eb439a9090016e7f025c41259ab/uvloop-0.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0305871ac712f54b62af73f943dbf21ae3ce80a44bc0f0151424484affa85645", size = 3758530, upload-time = "2026-10-01T03:15:30.5Z" },
{ url = "https://files.pythonhosted.org/packages/2f/b1/948067eab45d5307f04b34e50eb7bd1f7352aee866fa5f0706b061ddacf0/uvloop-0.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24c58ae4a83e93a04c504bcc678125e36a0bfc44af928ad69444880c60f187a5", size = 1415276, upload-time = "2026-10-01T03:15:32.634Z" },
{ url = "https://files.pythonhosted.org/packages/8a/6f/ee3ee84c5d27f2f0a47ae8b67a6adeacf9841b193c0e07412a1403586ce2/uvloop-0.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0efdd55bddbd36bb2fcb842d64c0d5f6407c6958c68088cc25df8c09edc5b5fd", size = 779533, upload-time = "2026-10-01T03:15:34.062Z" },
{ url = "https://files.pythonhosted.org/packages/25/0d/b5f69dae3736d96a8753c6ecd32d676ecd212be7ba3252e9c379ad9cc05c/uvloop-0.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fcd721113260ffb5e38bf14a8725b17d431f34209f7d1c7005b667946e630b3", size = 3896377, upload-time = "2026-10-01T03:15:35.816Z" },
{ url = "https://files.pythonhosted.org/packages/16/fd/8cbf6124607863399008ae4b0d2bb50c22ed83526deec28dca08d635eb6d/uvloop-0.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab17b3a8aa754be0de0e397f7b95f13b14e56f077a4c6ae295e3d4afd199b325", size = 3956355, upload-time = "2026-10-01T03:15:37.688Z" },
{ url = "https://files.pythonhosted.org/packages/a7/7a/b73007866e7198519067a1f1afc343b4973ae924d2b7afcea67c44320a98/uvloop-0.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:80cac5cb90ed7b9b72a217a1d6982b15b829cdbd0ee6bc19b93e3a9e47fb0ac9", size = 3755618, upload-time = "2026-10-01T03:15:39.27Z" },
{ url = "https://files.pythonhosted.org/packages/3c/28/e50816f1ce38b97b28d62bc4adf7c82c33b7c68fa902e41a39adc8a3d189/uvloop-0.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:93087a845cdfb35753e539354ac9551bdd2ff528c202a98df0ae46e852bcf021", size = 3863192, upload-time = "2026-10-01T03:15:40.882Z" },
{ url = "https://files.pythonhosted.org/packages/05/98/04e766a6de99e6f7f955ecb7829e8d5a557de3427cb85be2236de54dda0c/uvloop-0.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:93935ab27b6eaef4c3e5489aebc84284f0644592f7ab516df60ee1b27eaf5eb3", size = 1393055, upload-time = "2026-10-01T03:15:42.526Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/499e7b863a848ede009539bce39806b66205da5f8779354228e785601144/uvloop-0.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4448e9124537620f9c25d004c227bb5104440b58955c19bbd312d910af919a63", size = 768909, upload-time = "2026-10-01T03:15:43.974Z" },
{ url = "https://files.pythonhosted.org/packages/3d/95/a880f8ce3b87ac5b307c354e8ee480be4658d24bf01f87921d57e3530b4a/uvloop-0.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7548ede3ee908cfabc0d068106e303a9a2d811af959cdf6ab85676344cedcda", size = 4419106, upload-time = "2026-10-01T03:15:45.551Z" },
{ url = "https://files.pythonhosted.org/packages/51/27/c1d2f9fa977f8f42ea294604166df10e0027e6dc6cd17f85ede386c9bf36/uvloop-0.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:090865d8ce7a03986755a3ce711b7dd0d4b44eb14ab74368b717f3fad1180208", size = 4532597, upload-time = "2026-10-01T03:15:47.258Z" },
{ url = "https://files.pythonhosted.org/packages/42/dd/2cb6a2c8a30ca55c07a882dd4ae4ceae0fa7d8c15b25b3b7cb9a4b6cf4ca/uvloop-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd6f2f81c7b9da99d301c0b16b82044e76fe887086e42e1590ecf520b94dbdac", size = 4230048, upload-time = "2026-10-01T03:15:49.119Z" },
{ url = "https://files.pythonhosted.org/packages/f4/52/29989cbaa4022dc4ef35c1dd60a4ab989e4c2065f341ed483ae71d2bd950/uvloop-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a6ac96da66c35bf789bdcde78a88dc7d56b7907d8379648c54adc1c61594575d", size = 4394152, upload-time = "2026-10-01T03:15:50.829Z" },
{ url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" },
{ url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" },
{ url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" },
{ url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" },
{ url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" },
{ url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" },
{ url = "https://files.pythonhosted.org/packages/4e/a4/00e85345871c59c834a23c136c1771205856028ecc8ba940b3951178e59b/uvloop-0.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd", size = 1421363, upload-time = "2026-10-01T03:16:02.599Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a9/e5f0f3cfde30af3ec32eba8ec07bccdba2b5116afbd1ecc53edfeb0a0790/uvloop-0.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476", size = 785177, upload-time = "2026-10-01T03:16:04.018Z" },
{ url = "https://files.pythonhosted.org/packages/9e/79/9ddf78f8cd75a15c14a09a57f59c587b8cd9d82802c5c8368b9c3ebefa0b/uvloop-0.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e", size = 4381060, upload-time = "2026-10-01T03:16:05.642Z" },
{ url = "https://files.pythonhosted.org/packages/1e/20/57d63c44d32326878fcad5c63854afc9deb394ed95673c1b1a429178c79d/uvloop-0.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330", size = 4418891, upload-time = "2026-10-01T03:16:07.326Z" },
{ url = "https://files.pythonhosted.org/packages/12/c5/0795abecda2cc3dfe41033f880a32a9ff103be4e6b177ac736833c153a0e/uvloop-0.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f", size = 4214811, upload-time = "2026-10-01T03:16:09.13Z" },
{ url = "https://files.pythonhosted.org/packages/20/18/9010dacd5221eec1bd79a4a83ac68f3db6a42d7bb657f7b640c4838ca6b6/uvloop-0.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410", size = 4294876, upload-time = "2026-10-01T03:16:10.875Z" },
{ url = "https://files.pythonhosted.org/packages/b1/08/f6384a03c771d00067cba4f542a69b2fc1a982e9fd78b357c2f788678d72/uvloop-0.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208", size = 1494811, upload-time = "2026-10-01T03:16:12.399Z" },
{ url = "https://files.pythonhosted.org/packages/ac/01/756a4fb24a449f313cf4a153eb0c6210b49cfe5539255ec9fb1e17d2c4ef/uvloop-0.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d", size = 819396, upload-time = "2026-10-01T03:16:14.094Z" },
{ url = "https://files.pythonhosted.org/packages/3e/45/e314b0c600b14f53dad3a3c2d7a922a249a88225fd727652b53e1854b9dd/uvloop-0.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f", size = 4734966, upload-time = "2026-10-01T03:16:15.815Z" },
{ url = "https://files.pythonhosted.org/packages/66/0d/8686a7f0b1b2d55ebd770ba21f8e0e4ffa0cde5ab738f43ffb8264499052/uvloop-0.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49", size = 4584963, upload-time = "2026-10-01T03:16:18.198Z" },
{ url = "https://files.pythonhosted.org/packages/78/b2/034a2d47e435ac02357c42956246887167bdc0357bdd6ad31c5f6d94497b/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507", size = 4421388, upload-time = "2026-10-01T03:16:19.953Z" },
{ url = "https://files.pythonhosted.org/packages/f0/77/131f4b583e6b4b715c404a66b51c812d701db20f25c9018b188a2b00062c/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405", size = 4402414, upload-time = "2026-10-01T03:16:21.716Z" },
{ url = "https://files.pythonhosted.org/packages/58/3d/ee11f4718ea1280595c67ed25c83d4c92115dc100bbdfd192d3ed9339168/uvloop-0.23.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d", size = 1418095, upload-time = "2026-10-01T03:16:23.241Z" },
{ url = "https://files.pythonhosted.org/packages/f8/0c/7ca516a0671418517d79a09d3ff2ccbb44af94c75711afa6e4cf58aa6f65/uvloop-0.23.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5", size = 784837, upload-time = "2026-10-01T03:16:24.666Z" },
{ url = "https://files.pythonhosted.org/packages/35/95/75d4e28e596d505b7ae11de517646b4ca3d369fb8537ba755410380da11a/uvloop-0.23.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2", size = 4380276, upload-time = "2026-10-01T03:16:26.389Z" },
{ url = "https://files.pythonhosted.org/packages/10/99/68daf827ad62efaf4667d1f3fda127046d42161178396bdd93aab3684082/uvloop-0.23.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53", size = 4451496, upload-time = "2026-10-01T03:16:28.364Z" },
{ url = "https://files.pythonhosted.org/packages/71/69/f67e696ee688f426a96f99099bae26fec14a1d0fa75dccdd6518ee267c0c/uvloop-0.23.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a", size = 4212541, upload-time = "2026-10-01T03:16:30.014Z" },
{ url = "https://files.pythonhosted.org/packages/f1/6a/c8c436a9d7453297b4be70bdf6a9f9fc9400da45e0059ddf7b28ab63f4c7/uvloop-0.23.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027", size = 4319377, upload-time = "2026-10-01T03:16:31.705Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2c/8fc15a03489299aab8a6212dfe0f137dc39836f915c87f7fd9d9ddd814de/uvloop-0.23.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4", size = 1493428, upload-time = "2026-10-01T03:16:33.859Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7c/05e4a210790229607f71460fcb2ed4a2c7bc72668d8a928ce577c22e38f8/uvloop-0.23.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254", size = 818115, upload-time = "2026-10-01T03:16:35.45Z" },
{ url = "https://files.pythonhosted.org/packages/65/14/a40b11c6c024213803b13955664a15754c72f64c873a33d986b26ec9ff5b/uvloop-0.23.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8", size = 4734149, upload-time = "2026-10-01T03:16:37.025Z" },
{ url = "https://files.pythonhosted.org/packages/9f/83/f421a077712c1e87603bfec62744c3cd3a2f4b47378025db3d740df9af0d/uvloop-0.23.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc", size = 4661763, upload-time = "2026-10-01T03:16:38.719Z" },
{ url = "https://files.pythonhosted.org/packages/f5/62/25dcaa6b7e7b48f82ce633854ce96597ab768f9650931f4f86c572de392c/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55", size = 4421324, upload-time = "2026-10-01T03:16:40.488Z" },
{ url = "https://files.pythonhosted.org/packages/05/46/04628239b43dcef703af314202a3307d6060918e2d76aa86c5b1188f5551/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f", size = 4462501, upload-time = "2026-10-01T03:16:42.359Z" },
]
[[package]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "21.3.3" version = "21.3.3"
@@ -3745,49 +3535,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" },
] ]
[[package]]
name = "watchfiles"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" },
{ url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" },
{ url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" },
{ url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" },
{ url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" },
{ url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" },
{ url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" },
{ url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" },
{ url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" },
{ url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" },
{ url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" },
{ url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/a122743607939429ab6a7272672f49303aa02bbd34c3f06676765a3861de/watchfiles-1.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e0204f90677b7fb8d3a824279a71a01166bea9f6e53923b8056592d87b33b0b6", size = 395978, upload-time = "2026-09-21T09:08:32.747Z" },
{ url = "https://files.pythonhosted.org/packages/3e/9d/0ee1b0ea42acfe72afa2484f0222e2386dd09992e9034a81e63cc3cdab0a/watchfiles-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c1125e99f84934a92996ae343501285617373acd66f16b59fd5654b27ad8d80", size = 387997, upload-time = "2026-09-21T09:08:33.953Z" },
{ url = "https://files.pythonhosted.org/packages/b7/f2/4319c6b683e8bd28465dfc53c750617a2a07f072eb2e3ab0182ccf812229/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a85e243c87109d9f48a2e0d10d6abdee8768cc114848b32281ec83d7f81d86ef", size = 448067, upload-time = "2026-09-21T09:08:35.104Z" },
{ url = "https://files.pythonhosted.org/packages/b7/71/9f37ac192a51a24de3a2d7fe9aac1e99b2b2668300674ff7a9f85d95beb1/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd538c71766c59c732e2c5cedc39323c99c11897b99f93eac773438e54af206a", size = 452881, upload-time = "2026-09-21T09:08:36.238Z" },
{ url = "https://files.pythonhosted.org/packages/34/07/1224667c32a4a96876ddd642da69733cbfe6a15897db0865aec5a882cebc/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da213822ec9082b62cadf3008e07a8c690ec7a3c89f6dc5e55a8cf07b56a1b26", size = 484802, upload-time = "2026-09-21T09:08:37.717Z" },
{ url = "https://files.pythonhosted.org/packages/bd/4d/7f29cd87e1f826df86fc0418d0b8f370ca0311c29c618406dc492aad9951/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4593392e87669836670f87d62fc754059eaeb0e6157af44060a46558cebe5a3", size = 567522, upload-time = "2026-09-21T09:08:38.94Z" },
{ url = "https://files.pythonhosted.org/packages/e3/ac/3aa1f84abd4dbbfbae99a817dd931f77b439261c911189cad4c30fdfd470/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26b81bc515a0f03f66a69a6fea86eea54c8eca4173c46dd7222569f79dd2f977", size = 461997, upload-time = "2026-09-21T09:08:40.142Z" },
{ url = "https://files.pythonhosted.org/packages/82/1c/27c66764d5b0dbb70a36770a32eca217eba739c28b24e8583a035087a3d7/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca39661934749df580d89f3dde266f2af7c6968f4c21c72ce2dc387d299e6aa7", size = 451856, upload-time = "2026-09-21T09:08:41.305Z" },
{ url = "https://files.pythonhosted.org/packages/1e/b3/a45827084d953c1d0dd3d75abf85dcac26a0f5d115ad2fe0b7e9e931573b/watchfiles-1.3.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:04c5500faa725d69a99b0f63750e80795e455bb951849e9c98a811b4324997c4", size = 459889, upload-time = "2026-09-21T09:08:42.58Z" },
{ url = "https://files.pythonhosted.org/packages/7a/3d/07740d23ce2bb23419bb24055c435a9abe5fe03efdaea1ba0720b831aab6/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a", size = 624482, upload-time = "2026-09-21T09:08:44.064Z" },
{ url = "https://files.pythonhosted.org/packages/91/46/9540f8123eaec86df989f26d7704cad0448d9fb7271763a4f8f9fe23617d/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e5871d4a7f788a9f64fb06f5793389d29d171e5c32d37c3ebfd520b017cc7694", size = 653724, upload-time = "2026-09-21T09:08:45.257Z" },
{ url = "https://files.pythonhosted.org/packages/25/9d/b9deb52f1e4528ffe44338c5ebab95c32ed79203082da661de50288e130a/watchfiles-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:81c989d7267bfb7b676cd32c83bec97914e396870983cbf493e29415037bff5e", size = 270863, upload-time = "2026-09-21T09:08:46.518Z" },
{ url = "https://files.pythonhosted.org/packages/59/59/8b1881afccbafda06248be4185e28034dd73c80c85e4da24f2cb0c1321eb/watchfiles-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d754e049006e81a9be49dc78ba7802081cee426e968cbc63ee19e164fa2d0e39", size = 286879, upload-time = "2026-09-21T09:08:47.874Z" },
{ url = "https://files.pythonhosted.org/packages/46/dd/41c12ae9745d5d72f709333287679d71cbba68ad7678fed872207bba94f4/watchfiles-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5b631db08fd3032db57ec6a92d1777cd60f88803287e4e941a4858c53aab1e0", size = 276924, upload-time = "2026-09-21T09:08:48.976Z" },
{ url = "https://files.pythonhosted.org/packages/cd/eb/289fec71eba51d6c3ef3a28c81f7213f4e860084ffae5abc866933c564f0/watchfiles-1.3.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:45726c6b5a8d67c8087eb2933c4398948546d04206ef40f629dc4881d65d23d1", size = 396932, upload-time = "2026-09-21T09:08:50.217Z" },
{ url = "https://files.pythonhosted.org/packages/92/cc/a3c3f557992a20a5bb24607f0da44d39cf6d6ba3f8f6ed7cb31f6c73371a/watchfiles-1.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6398d250994baff581147ccba846223076fe6492ca93c0d1b3588fcf5355eb5c", size = 394852, upload-time = "2026-09-21T09:08:51.412Z" },
{ url = "https://files.pythonhosted.org/packages/55/65/d45763a5e1108a65af85a217af2930ebf1081c73fd8169d3f75e586992bb/watchfiles-1.3.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:591de392458bca26f6130fea4e667c06b657e873ad334ea06fad5a79f5086cef", size = 451789, upload-time = "2026-09-21T09:08:53.144Z" },
{ url = "https://files.pythonhosted.org/packages/02/96/165c6e540bf765ca1115062017adf861d0816525f02464ba71313fe7cf92/watchfiles-1.3.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ec46a1ff4c1d81405a5fc645abdbff5e4e9388f7c9a6cdb392316a7c29b884f", size = 456919, upload-time = "2026-09-21T09:08:54.372Z" },
]
[[package]] [[package]]
name = "wcwidth" name = "wcwidth"
version = "0.7.0" version = "0.7.0"
@@ -3806,297 +3553,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" }, { url = "https://files.pythonhosted.org/packages/d5/d2/cc4dc1271e464942db7ee278baae2daa99ee77cb2af744025c04da585a3e/websocket_client-1.9.2-py3-none-any.whl", hash = "sha256:e1a673830a9c7bfa47b1cd3d5e4178f4c9651d80a4eab02c9c23a1c3ec6250ce", size = 95786, upload-time = "2026-08-31T14:08:39.899Z" },
] ]
[[package]]
name = "websockets"
version = "16.1.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.11'",
]
sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/e7/d1671fb984f9dd844e1da5288070c7c23c9eaba3082d3871aae19c3ab8b9/websockets-16.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:49ae99bdfcae803a885c926bf14f886196e84925395bb3f568fef5c0f0979d7d", size = 179570, upload-time = "2026-07-17T22:48:24.032Z" },
{ url = "https://files.pythonhosted.org/packages/99/f5/70df723bf571f5e0b1b845e0a4ff1c966eeb84f667599fc251caa37d15a3/websockets-16.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5bfd1ac19b1b9986a9c95a82d5e23a391ebb09e12c34d7be6094b86efcc35731", size = 177252, upload-time = "2026-07-17T22:48:25.775Z" },
{ url = "https://files.pythonhosted.org/packages/90/72/2f14b2e167170b8bf1c8bb7f9b0d78000f470d41a2085a91f33e3917b6c9/websockets-16.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9246a0d063cfcbcc85f2359dd6876d681213f4790832272aa16641b4ed5d64d4", size = 177530, upload-time = "2026-07-17T22:48:27.337Z" },
{ url = "https://files.pythonhosted.org/packages/f3/18/a17e2f0cde02dc10154c808deed7e1d8528afff93612f70d3f0a5b19b011/websockets-16.1.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1214e673c404684b9bf7154f5cf43b45025b1a6160fac3a9e438e9c1a97e22cb", size = 186038, upload-time = "2026-07-17T22:48:28.756Z" },
{ url = "https://files.pythonhosted.org/packages/d5/b0/41de283899cf5929d637b72a508cdbc9aa40dc0f317c6b77613fd1000488/websockets-16.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90001d893bc368e302ef168d82130b4e4fdd27b85fa094682df9b667c2d48838", size = 187278, upload-time = "2026-07-17T22:48:30.328Z" },
{ url = "https://files.pythonhosted.org/packages/50/61/874aab5257e027f9f61b5004cec65e592babca7942b1bc09f38e72b7f1fd/websockets-16.1.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:130937b167a52af203c8d58e78d67705874e82759862e3b9671a452fec4abc87", size = 189936, upload-time = "2026-07-17T22:48:31.896Z" },
{ url = "https://files.pythonhosted.org/packages/a6/1a/42173913ac5519607220849ed417c864d77384e4119f06dbba964a50f096/websockets-16.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c9f23004a3d40e89c01a7955d186a6cc83418d93b749701944ce2de3e95a1f3", size = 187796, upload-time = "2026-07-17T22:48:33.344Z" },
{ url = "https://files.pythonhosted.org/packages/1b/f4/37c1840bd89b529479aec41470b97b7c683b107ca90b6399ac5afb99dedf/websockets-16.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f55f0b01956a094c8587146d9558c91937e78789c333860ffaf35931a6e5dbc4", size = 186481, upload-time = "2026-07-17T22:48:34.843Z" },
{ url = "https://files.pythonhosted.org/packages/9e/70/652d9b964adcfbeb056f42e0ca6bece34d108fe75534e74df20643cae199/websockets-16.1.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6aaface73b9c71974c6497366d8b9628357f6c9749e09c4ea3610176c63f2ae3", size = 184351, upload-time = "2026-07-17T22:48:36.307Z" },
{ url = "https://files.pythonhosted.org/packages/13/f1/af3850e5d48d482921985be72ebcb169c6180b3a77b57bd612deebcee23b/websockets-16.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc0fad4933f427acd5b1cec210f3ea6dce7089e1724e4b9ec6ef47c6c04d1b3b", size = 186791, upload-time = "2026-07-17T22:48:37.762Z" },
{ url = "https://files.pythonhosted.org/packages/1d/40/1a4e3ed4969ec378dcad337e5f1472c5e292cb3e733bc392f0dc2e230abd/websockets-16.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2769a0344a09e9ccf5b3cce538bc75a51b53eff3275d3896310c8552049195d", size = 185413, upload-time = "2026-07-17T22:48:39.127Z" },
{ url = "https://files.pythonhosted.org/packages/aa/3e/4e3fa1afe8f1a6a780434cd9ba8eb422632b044eff3dd73f6af67523c147/websockets-16.1.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f70541f3104339f59f830522d94ebadb1bf47426287381623443d8bb1cdbf33d", size = 187178, upload-time = "2026-07-17T22:48:40.676Z" },
{ url = "https://files.pythonhosted.org/packages/71/ab/dd742766aa5dda7f349be0de49e4d565b84cf6f7f7fa02e07692f0f2bdd9/websockets-16.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:dc385593a42e31cd6fb60c19f0ecb015b386603818fc2c6c274fb42bd2bb4165", size = 185051, upload-time = "2026-07-17T22:48:42.098Z" },
{ url = "https://files.pythonhosted.org/packages/ae/f5/76438c6560f416f1c0a7f587679fb97cc6e99ed336011d43ce2002dd27c1/websockets-16.1.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:387e8e4aa5df2f90b198fa3cad3478822a89cf905b6a6d6c97dc3664689640cc", size = 185846, upload-time = "2026-07-17T22:48:43.472Z" },
{ url = "https://files.pythonhosted.org/packages/62/12/5c0320f2127823d27b2d56d611d31b0b284ad4edcb41364d66bf4c92b537/websockets-16.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fd46fff7eb62c24804d234f0051c7a8ea81285ad63e0337d3dcf33ca82aee58a", size = 186066, upload-time = "2026-07-17T22:48:44.884Z" },
{ url = "https://files.pythonhosted.org/packages/a2/97/875986b857b955c3f9dd192cb8a1af81254dfb2ea22cc9590f0a1e020b8b/websockets-16.1.1-cp310-cp310-win32.whl", hash = "sha256:7883388947767080f094950b342b30d35a2a06b849cd967c422fa0db72b40ea9", size = 179940, upload-time = "2026-07-17T22:48:46.481Z" },
{ url = "https://files.pythonhosted.org/packages/54/82/1013a5fe7ddae8e102bc3b4b39db81d8d28fd02100a324ce6ede8cd832b1/websockets-16.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:d57685547e0060cc6fd90ee6a28405d6bd395e525545f13c8d7cd99c78afd79f", size = 180239, upload-time = "2026-07-17T22:48:48.043Z" },
{ url = "https://files.pythonhosted.org/packages/2b/03/47debfe28e9d6d354be5d777b67fd44c359b9eb299a5d103500bd7cc3e37/websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c", size = 179566, upload-time = "2026-07-17T22:48:49.596Z" },
{ url = "https://files.pythonhosted.org/packages/72/93/31efa1ed78c17e5cfc229fd449e3966e1b9cc15753204cd585cc8dd01f4a/websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a", size = 177250, upload-time = "2026-07-17T22:48:50.942Z" },
{ url = "https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22", size = 177528, upload-time = "2026-07-17T22:48:52.59Z" },
{ url = "https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2", size = 186859, upload-time = "2026-07-17T22:48:53.915Z" },
{ url = "https://files.pythonhosted.org/packages/de/09/87df740f7430ce564bd52402e9c9458d4d0459cc7d2ee29e530c8204851b/websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01", size = 188095, upload-time = "2026-07-17T22:48:55.384Z" },
{ url = "https://files.pythonhosted.org/packages/d2/12/3d2703af7cc095f3c81904c92208cc1ae79affbc67376944b50ee9301f73/websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0", size = 191385, upload-time = "2026-07-17T22:48:56.742Z" },
{ url = "https://files.pythonhosted.org/packages/1d/69/986aa0234a964a00f5149cfc46e136e96c8faad1c783474550f40d31aef4/websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29", size = 188653, upload-time = "2026-07-17T22:48:58.134Z" },
{ url = "https://files.pythonhosted.org/packages/35/6b/10f9d03e3970a69ba67bd3b46b87a929b586d0300fadbfe14f57c1f85490/websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512", size = 187426, upload-time = "2026-07-17T22:48:59.515Z" },
{ url = "https://files.pythonhosted.org/packages/56/db/bb3aad62bf63d8bb3f0634b2eabffcfb3677a34bd19492110ff6869cf703/websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3", size = 184882, upload-time = "2026-07-17T22:49:00.916Z" },
{ url = "https://files.pythonhosted.org/packages/6c/4c/c09a2ea9bfbeccce52fdc383e5f28af4bc8843338aabac28c81489af6120/websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57", size = 187584, upload-time = "2026-07-17T22:49:02.283Z" },
{ url = "https://files.pythonhosted.org/packages/c7/8b/31bb4eb4d9eaacf1fdd39d115772a8aeaedfc19b5dc262e57ffbc8a9d42c/websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3", size = 186174, upload-time = "2026-07-17T22:49:03.973Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e4/dc02d725610a1ad49e193ef91a548194d71bdc6cdf27da83067dd1f73995/websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648", size = 187986, upload-time = "2026-07-17T22:49:05.553Z" },
{ url = "https://files.pythonhosted.org/packages/e0/73/30ed84c8bfd14c73d4af29d5ed9323c3073b48e0b7b23b67070f4e7fd59b/websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d", size = 185565, upload-time = "2026-07-17T22:49:06.959Z" },
{ url = "https://files.pythonhosted.org/packages/7d/d3/4be8d4959f51e31b4f8fc0ece12b45bd3b6c0d15ea23b9990d9c11fc805f/websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be", size = 186598, upload-time = "2026-07-17T22:49:08.293Z" },
{ url = "https://files.pythonhosted.org/packages/26/fa/abb38597a52d84ed9cfacadc7a0c6f2db282c0ab23cdf72b58a666a21227/websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81", size = 186834, upload-time = "2026-07-17T22:49:09.766Z" },
{ url = "https://files.pythonhosted.org/packages/59/80/1119ad08a228b90c4eb77fbe48df7836731a605f5f881ba701ca826a4a65/websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57", size = 179940, upload-time = "2026-07-17T22:49:11.196Z" },
{ url = "https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a", size = 180239, upload-time = "2026-07-17T22:49:12.519Z" },
{ url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" },
{ url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" },
{ url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" },
{ url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" },
{ url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" },
{ url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" },
{ url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" },
{ url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" },
{ url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" },
{ url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" },
{ url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" },
{ url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" },
{ url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" },
{ url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" },
{ url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" },
{ url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" },
{ url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" },
{ url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" },
{ url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" },
{ url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" },
{ url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" },
{ url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" },
{ url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" },
{ url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" },
{ url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" },
{ url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" },
{ url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" },
{ url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" },
{ url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" },
{ url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" },
{ url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" },
{ url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" },
{ url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" },
{ url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" },
{ url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" },
{ url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" },
{ url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" },
{ url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" },
{ url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" },
{ url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" },
{ url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" },
{ url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" },
{ url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" },
{ url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" },
{ url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" },
{ url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" },
{ url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" },
{ url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" },
{ url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" },
{ url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" },
{ url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" },
{ url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" },
{ url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" },
{ url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" },
{ url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" },
{ url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" },
{ url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" },
{ url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" },
{ url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" },
{ url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" },
{ url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" },
{ url = "https://files.pythonhosted.org/packages/e1/ed/71fea6e141590cafc40b14dc5943b0845606bee87bdb52a21b6a73eb4311/websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869", size = 177185, upload-time = "2026-07-17T22:50:56.665Z" },
{ url = "https://files.pythonhosted.org/packages/01/ec/00e7eeca200facf9266a83e4cbbf1bed0e67fba1d4d45031d3e5b3d81b5c/websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9", size = 177459, upload-time = "2026-07-17T22:50:58.197Z" },
{ url = "https://files.pythonhosted.org/packages/75/fd/5774c4b33f7c0d8f0c51809c8b3a93456c48e3543579262cfa64eb5f522e/websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e", size = 178294, upload-time = "2026-07-17T22:50:59.641Z" },
{ url = "https://files.pythonhosted.org/packages/37/c3/48e2c03d2bd79bb45948841c592d24156312dd5f58cdf8f549febe652fb6/websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d", size = 179190, upload-time = "2026-07-17T22:51:01.129Z" },
{ url = "https://files.pythonhosted.org/packages/2d/3f/73e511ecf2496ceac57dd4ed8388efe2bcf0769338a2dbf242c8366ae87e/websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5", size = 180330, upload-time = "2026-07-17T22:51:02.603Z" },
{ url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" },
]
[[package]]
name = "websockets"
version = "17.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform == 'emscripten'",
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'",
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
"python_full_version == '3.11.*' and sys_platform == 'win32'",
"python_full_version == '3.11.*' and sys_platform == 'emscripten'",
"python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
sdist = { url = "https://files.pythonhosted.org/packages/30/4b/4692be6d5bf739ed049b3813901c42091f8c054df89d5c089343823dea0d/websockets-17.3.tar.gz", hash = "sha256:1c7dd93a1c9f89297471493674b1fc1d9467cafd45374f2172213046bdb3a329", size = 188878, upload-time = "2026-10-19T16:58:05.813Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/b8/282f114a67886fccde02a77ff296501ad7fea25f4d696b2b1b337cbd27da/websockets-17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:45cc1ccb12ced6d242335159e8221733c8696da5ad0b6ceef6debb0f5ce89110", size = 218223, upload-time = "2026-10-19T16:54:25.998Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ba/8043c303aa60af053e092c1d667165f46d6729217ce2af795a23a3cd700a/websockets-17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46ae05aa1aecd47d4d7de0f05ce2d1d2eb418f8de614964d754cc3b9a409c400", size = 215908, upload-time = "2026-10-19T16:54:27.785Z" },
{ url = "https://files.pythonhosted.org/packages/21/3d/5d0eaa9b29db6c999a2cf749605141796cff21a5e235b2488bd39dcdcac9/websockets-17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:43f270a5f21e12db08c73d83849a877c02ab7b7d0fb6e2718b5101d9c810212f", size = 216181, upload-time = "2026-10-19T16:54:29.267Z" },
{ url = "https://files.pythonhosted.org/packages/43/fb/33080a7b2d8427b343056eab32ba78235a4bfbbdc79b5593688c7b7c62ef/websockets-17.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d875224bac1a05a20acade268a7a0e3498e7f8cf890acb7c2b23bacd2bd3659", size = 225246, upload-time = "2026-10-19T16:54:30.807Z" },
{ url = "https://files.pythonhosted.org/packages/61/8c/d4220455cee00adb553c602ba4f1f6a47cd882d86dd9e5124679c0c95c75/websockets-17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ad409b7ea102339041281117cf2dadfe71edbb653dc48037cc55d516fb7f448", size = 225517, upload-time = "2026-10-19T16:54:32.182Z" },
{ url = "https://files.pythonhosted.org/packages/75/a3/0ff8e4f0195884ae446844185e81bf7619673d816424b8a58bb5812dd4b8/websockets-17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d43c311f3519fb1057b6114dc24e9060bc3fb6aedf4aa11160484154b66b63", size = 226751, upload-time = "2026-10-19T16:54:33.52Z" },
{ url = "https://files.pythonhosted.org/packages/06/cf/82c88e3fbb6a75a62b2997932664bccfe68256661c69144bd48020160cf1/websockets-17.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9eacb382f400bb5865e7e6d24bbe709a84510291e707f169dd4a24ee4a5ac6a", size = 230063, upload-time = "2026-10-19T16:54:34.855Z" },
{ url = "https://files.pythonhosted.org/packages/4a/e4/9131d3b73504902a83d10b1e1244319377ca72755351998ac9c408ba6ddb/websockets-17.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f09f145b89ea292cf6c25fd7c8c0d90207fd6a9e54c7828499669996a1e5bdcc", size = 227310, upload-time = "2026-10-19T16:54:36.248Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b2/2208506afb45d181b0d6ccf377d9d79985fbcbf871af02ef4f7f3d405f5b/websockets-17.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:49fb5b4960ebe1bc557b435da8687deef1302a08f030a33c5606fa6fb11b773b", size = 226087, upload-time = "2026-10-19T16:54:37.583Z" },
{ url = "https://files.pythonhosted.org/packages/39/53/da73e3809f1d4e8c3346ca89a4bed7995de5708f2cb14c9359172a85f5a5/websockets-17.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:13cffaedde6e06183860fc92d88fe1d190abda73837fe4c14f5d4803c472f5dd", size = 223360, upload-time = "2026-10-19T16:54:38.91Z" },
{ url = "https://files.pythonhosted.org/packages/9a/7b/eaf828fd414401672e615570b9f2bfd84342b49c86ffb2ae735e4aada32f/websockets-17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ef2950896673a9809e8e5ab857525924fbcacfc98b858b0509ccf50d341f80f4", size = 226240, upload-time = "2026-10-19T16:54:40.294Z" },
{ url = "https://files.pythonhosted.org/packages/79/bd/4b6aa6e761c8d89aa6defdd83e40a6b2d9aa21bd01520c8e8470fe8d8681/websockets-17.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2d08ca4bf5a9cdb6fd81b86b51bd5cfb10527d3a1199888f2b02cc6c6a3d5f80", size = 224833, upload-time = "2026-10-19T16:54:41.939Z" },
{ url = "https://files.pythonhosted.org/packages/37/a8/0a6aea08cad7db2c1aa53dbd3ece3ebc09e420e8301cb02f2b812a728a5b/websockets-17.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5e0c0ed37472c4729f3001066510a656486f08f149dda1ebfd3dfe4450c9756e", size = 225599, upload-time = "2026-10-19T16:54:43.54Z" },
{ url = "https://files.pythonhosted.org/packages/1d/82/3998ea56beb079f43fc39dd5935ef8dc150f915bf2de8bd6ddcd50c0fae6/websockets-17.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:62059d739af7fc035ed6e63c5390118a60a5e9aa5a5541cd0486a83e741842c7", size = 226643, upload-time = "2026-10-19T16:54:44.914Z" },
{ url = "https://files.pythonhosted.org/packages/c1/a5/6568add8d0a2f37c5780c6de62b5b8ee191ca9d2d0b8ff2123b8277c41b7/websockets-17.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:bc6f87d03e3691154673aaa8f08991723d6e3394c83ef2875c7a9049eac9595c", size = 224223, upload-time = "2026-10-19T16:54:46.429Z" },
{ url = "https://files.pythonhosted.org/packages/72/79/b11bc05b0f64d8b14901443a4b01ea80d039cc68b260250b4cb488070aca/websockets-17.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3d78d93846eddc990551c1c31914d90d71957ca3af468969826f7ed0d7d1d51c", size = 225255, upload-time = "2026-10-19T16:54:47.82Z" },
{ url = "https://files.pythonhosted.org/packages/71/f9/9a8902074db7044b64a05feff349e4f44144a961310d3419bab612f00dd1/websockets-17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2a377c7132b1e69be7a50d7434d7f26f69e93c70166ebb6d8f8cabf802ac4506", size = 225494, upload-time = "2026-10-19T16:54:49.171Z" },
{ url = "https://files.pythonhosted.org/packages/99/ba/7b873060ce6764207a24c10e81294739abffe2a90dc67c7f7db5042c596c/websockets-17.3-cp311-cp311-win32.whl", hash = "sha256:29b01520c39ef2f23c88b002f11e6ef4165675f6c92f5f3578fd587d69b5a5fc", size = 218720, upload-time = "2026-10-19T16:54:50.501Z" },
{ url = "https://files.pythonhosted.org/packages/28/11/7469f3f28513ec0d43f10b1c88bf970b0b6f0aa8190c42731954297e824c/websockets-17.3-cp311-cp311-win_amd64.whl", hash = "sha256:d338813a440bf6d2f0df55140d3f3080284410b1ebfe9df50e3847bcdb1a17bb", size = 219017, upload-time = "2026-10-19T16:54:51.843Z" },
{ url = "https://files.pythonhosted.org/packages/bc/fd/4724610baabbba94a44c5d1cf1b1a33ff77031e94a51addb0387c7f56b10/websockets-17.3-cp311-cp311-win_arm64.whl", hash = "sha256:6a8248b1ba8861e48e4c7ced3895a1264d2a3b617de72ec94172ce6e64bf16c7", size = 218950, upload-time = "2026-10-19T16:54:53.37Z" },
{ url = "https://files.pythonhosted.org/packages/0d/29/902ffe19c0d35603102f31490dea342f083bc77d8f8bcdc7d8b16f5810f9/websockets-17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:41665c7ea01d3ae497a0c61fd22d3424230d18782a5fceb79f04db9ccb2042c9", size = 218250, upload-time = "2026-10-19T16:54:54.718Z" },
{ url = "https://files.pythonhosted.org/packages/f0/66/f56a2637b1aac263205b0e3cd7b2ff2e2f8b404b8118c991f4d122fd381f/websockets-17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8f7469408740cf06a79b01605f2ae0b2acc8839c03fcbbf05da49baee12d3302", size = 215927, upload-time = "2026-10-19T16:54:56.124Z" },
{ url = "https://files.pythonhosted.org/packages/22/7d/a27f25451c4cc62141992b51005688481210f3d3e63de3dc66f9e882ba59/websockets-17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d68da281ac208b6236ffa8beef23fe7c7c956d8bf09b421d447232fd94441b35", size = 216196, upload-time = "2026-10-19T16:54:57.538Z" },
{ url = "https://files.pythonhosted.org/packages/9f/77/40bbc1a5c016cf9f598dea21d1250a93245c995eafaec6401d0e4eb4e647/websockets-17.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:078d1d41f45ee34eed5a4f3bbe7c588caecbdd6e12dfca73e8c1b0e803773793", size = 225516, upload-time = "2026-10-19T16:54:59.104Z" },
{ url = "https://files.pythonhosted.org/packages/3d/9c/91889c8c62fb7d16cd58e3074473a259303d6229bf0ce496aec87e66f3dc/websockets-17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7f391838e8adcf1f2fe71561d9e5a9f74ea7ffa5052eb2db329aac677383136", size = 225795, upload-time = "2026-10-19T16:55:00.438Z" },
{ url = "https://files.pythonhosted.org/packages/b8/29/2e5e2158e9920f806cd45f3cbc81cf5f3eef2b9e5a74b9ef049e2a3578e3/websockets-17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c15a165673bb3fb57facbf040edf5c0556d20ccd2e0ccc5f23ebb69fb715fdc1", size = 227034, upload-time = "2026-10-19T16:55:02.065Z" },
{ url = "https://files.pythonhosted.org/packages/b6/af/fbab5c4098b26026b877bfc139b1ea0d7c6eaf7a86b84544933b4c445f82/websockets-17.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28d1935c16782a1e3650c9baa79069b016fff739e292e4cb14bbb830d6930491", size = 229863, upload-time = "2026-10-19T16:55:03.415Z" },
{ url = "https://files.pythonhosted.org/packages/ac/72/d6e460a991607794072fe3fdfcb3221a0342af61586f5bd7ea40bddcfd21/websockets-17.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c534c9524a2acd0b97b7022074bec3784ac273c683cf9535087e437672ea8531", size = 227669, upload-time = "2026-10-19T16:55:04.763Z" },
{ url = "https://files.pythonhosted.org/packages/ec/8f/1c7da1cfb632e141e019cf2e0bbbd4e6dc8c198c8cc29544e4acf1a89f56/websockets-17.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b459d29c8b82a420a98fa0ce8e9cf787ce0062d98cc90604ef9574fb3cc26ff8", size = 226429, upload-time = "2026-10-19T16:55:06.112Z" },
{ url = "https://files.pythonhosted.org/packages/3e/63/c48ef8bbfbd982f2cfae1cfcf9e63f5bfbb8468fd37ca700507efc0b0007/websockets-17.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53209dffd91b0aefed9ea630fa6ad5c2944d6210ba3a8a3a8664dc1575289b14", size = 223653, upload-time = "2026-10-19T16:55:07.473Z" },
{ url = "https://files.pythonhosted.org/packages/cb/37/7bedaad48ae8e89eaa4f3942c11be0c41de77ce2118a475f3ba05e9b5a0f/websockets-17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9662a9fe457820c60365b3209e03cbd8e4a817195d55de65a80fa680b4bea136", size = 226465, upload-time = "2026-10-19T16:55:08.791Z" },
{ url = "https://files.pythonhosted.org/packages/33/cf/ab9a8a0de4769ca1dce392021b6a922e21b4d3687fe9e1ab4db585a088f8/websockets-17.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5670a762c7ae388bf8aa04199fd436180a4d28318dca9c1eae7b38604676dbfd", size = 225071, upload-time = "2026-10-19T16:55:10.127Z" },
{ url = "https://files.pythonhosted.org/packages/89/96/64d8460cec95f873c61c6d13a09b435915150640525ae56d5871400aec09/websockets-17.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ed59cb4ee258674a6ed9b8515e42079db02cb3be94f133fdd3f200183a40c49d", size = 225841, upload-time = "2026-10-19T16:55:11.519Z" },
{ url = "https://files.pythonhosted.org/packages/a6/98/2b83211ee3087db4ae66533782664c0e283cf4a08cbb14036018d0803e09/websockets-17.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0f78bb2e82fe88912c9d7b25c98e57a9b08655579af89f164fd4c0657a075d62", size = 226945, upload-time = "2026-10-19T16:55:12.85Z" },
{ url = "https://files.pythonhosted.org/packages/9f/fe/6b2a37d422d7f4dfc0c92c95a14649d9bd662243ed8b0774523ed2703604/websockets-17.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:71250e010eede33c1d638e2c7a98288520d67f782fd5eceecaeca3129233a09a", size = 224502, upload-time = "2026-10-19T16:55:14.48Z" },
{ url = "https://files.pythonhosted.org/packages/0c/c5/364015c9311c89ea6085d54c70d654c5d27ba05014d44ef9fd9daa09462b/websockets-17.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:729989759feb81ff6493879a0d60a0bd7d2eee41001bbec0381a080318439382", size = 225483, upload-time = "2026-10-19T16:55:15.899Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b3/75d0cae8398816ed34a16a78252bfc8651f2f883cc5a568e1198395f15df/websockets-17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:90d8c4fa5ba293cf92de351fbc292cdaadb1a1d58d031a1a97ff9b8fb6250ec7", size = 225761, upload-time = "2026-10-19T16:55:17.28Z" },
{ url = "https://files.pythonhosted.org/packages/f0/af/9a7c4b2e87f47a2a6a066acdc2157e9b7cb6222a224b95a432f54ae786ce/websockets-17.3-cp312-cp312-win32.whl", hash = "sha256:0824e6ecee838cc28e6481062574c7fde49f9a2cedfacd36f0e91cb4b37fd6fe", size = 218722, upload-time = "2026-10-19T16:55:18.658Z" },
{ url = "https://files.pythonhosted.org/packages/f7/e8/e52953a8280a1e51cb9307218443f68c8f4dc4b9952db6021d5c12d03c0f/websockets-17.3-cp312-cp312-win_amd64.whl", hash = "sha256:6644eecd99c4e8cdf72e646e17b391bb70b054f44becde37dcdc48cbec7e1527", size = 219024, upload-time = "2026-10-19T16:55:19.987Z" },
{ url = "https://files.pythonhosted.org/packages/85/a4/005eb7c1f5c1a249c1231a6ed83a54ada0b6b8ffab33a608ca20e3034ea0/websockets-17.3-cp312-cp312-win_arm64.whl", hash = "sha256:4a9133dbaedc6f1af358127275676171d9a1bb3e61f47ee62b2ff9acb78af3d0", size = 218950, upload-time = "2026-10-19T16:55:22.161Z" },
{ url = "https://files.pythonhosted.org/packages/ee/1d/c1f4a1c45c5ca2c35ae9fd7cac0e491f8a014c9a86933a5da7b04ff825cf/websockets-17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:52a2ca6f401fb49a8d5d9e0e48ade5f58f1bdda33132907b0e4e3b702628fa4e", size = 218243, upload-time = "2026-10-19T16:55:23.851Z" },
{ url = "https://files.pythonhosted.org/packages/4e/e8/f1b47d0ced4964ae748931a54347fd27b06b62e8ea7837a9de3aee0c5999/websockets-17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80578e91764bfca297ddca322c00a3136d993b829e9839e7fa378a19c1fcc25d", size = 215927, upload-time = "2026-10-19T16:55:25.317Z" },
{ url = "https://files.pythonhosted.org/packages/16/df/ab0ed0445d8d81a133c8fed760f3b4f743a2f563804fdbecb2c32fe05d94/websockets-17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fe7a168d805c8a7bf1c2de55b7ab2bc958586bfd88d0e87235a92cac66b590a7", size = 216185, upload-time = "2026-10-19T16:55:26.678Z" },
{ url = "https://files.pythonhosted.org/packages/9f/51/b30fd6c5a33dac76a78083be125e3f9804f3d735da494ff03a46834f31f4/websockets-17.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:54fcd24944e6d127d198c28dc363967b7131ff0c97833962c2ef2048018de02d", size = 225574, upload-time = "2026-10-19T16:55:28.097Z" },
{ url = "https://files.pythonhosted.org/packages/1c/8a/8ede659919c0805d1fcc79c2ed16588d3419f1687c5117ba46f0217750ec/websockets-17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de2c12555bf05546f3ee75733f2c81de70aab7f530e86d0d2162b0ddcb3c39ae", size = 225854, upload-time = "2026-10-19T16:55:29.491Z" },
{ url = "https://files.pythonhosted.org/packages/a6/6a/e75d10e3896edc61320062890f71cdc0767dc4a54e2a26a943b703042d13/websockets-17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c95d25b5382ee0e6191ecafdeabefdc1f7bff8dfee29239b9b79e760ef3b04bd", size = 227091, upload-time = "2026-10-19T16:55:31.146Z" },
{ url = "https://files.pythonhosted.org/packages/dc/d3/658f1e153566e5edae02812122d9f020c30785d8b53c4bda61b8e976f77f/websockets-17.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8f244dfee05ea29b8064701880865eb1684f3a704a5665bd10b64694444dbbd4", size = 228526, upload-time = "2026-10-19T16:55:32.524Z" },
{ url = "https://files.pythonhosted.org/packages/28/00/002f95a02d721d8c6ec2676c5aee904a823838a9c205fc0b31eedd882a7b/websockets-17.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e99440ef446c7833690c1754e23b0188d440f3cb89812b0764c697a7880cb33", size = 227718, upload-time = "2026-10-19T16:55:34.085Z" },
{ url = "https://files.pythonhosted.org/packages/fa/9c/d36ec9499a62ec4e2a4eb570f93c6fcedd80ad5685e0f9d6866e8cfbbb84/websockets-17.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:26838e2d95d7004c52e8c7b6b51676c872093a3b55a497824f899116a15a8a55", size = 226472, upload-time = "2026-10-19T16:55:35.484Z" },
{ url = "https://files.pythonhosted.org/packages/36/5d/3a6d74e409f24ae4e8661a9d74d6645f71b3e91425a527c0e3f2e78bb15b/websockets-17.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:508775efa11d26aec186d10154359f52276e2baf9ca201b52356330883276dfd", size = 223710, upload-time = "2026-10-19T16:55:36.853Z" },
{ url = "https://files.pythonhosted.org/packages/e1/90/e08eb5d69b73cf5babf80922a8c964019b48339ce5c694a5d0c0f083c5a8/websockets-17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2789d203babb6ceb2770aadc2394c1f99263ec37fba57a93064f9a8ad899e8ff", size = 226532, upload-time = "2026-10-19T16:55:38.191Z" },
{ url = "https://files.pythonhosted.org/packages/c2/45/b1916efa1dad5af33beafe5c536810e1c80856b7e913f671c35031c57745/websockets-17.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cfef7f7aa434a1e632e0ebda14b39f4f1d7975e0fce5c77ea7b43156fabf9bd8", size = 225129, upload-time = "2026-10-19T16:55:39.635Z" },
{ url = "https://files.pythonhosted.org/packages/04/c1/d427abb3f11e92f102f70f8e73addb767fc62c614565c0ee3f5657fa3551/websockets-17.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:39f532fa0d9026de4b9315a175998646112776baad54d55348435c11b05d034e", size = 225900, upload-time = "2026-10-19T16:55:41.033Z" },
{ url = "https://files.pythonhosted.org/packages/07/2b/462fab8e4b09eaa4eac2f40fcff8ee30f851aa40fb518efb0177262f6590/websockets-17.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:85d6daa3a7ca404570ae130babbeb347ef9d3faca5300fb13cfa4bb48c3d30d1", size = 227007, upload-time = "2026-10-19T16:55:42.314Z" },
{ url = "https://files.pythonhosted.org/packages/71/36/7404a4376021a0cc7ae9fcbdb1196c0c8aa48df4fd370c185f4f5b677bff/websockets-17.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ce39283ef08b0f28478e920d4d89a1fc169176e568e8a6a821332673145522eb", size = 224565, upload-time = "2026-10-19T16:55:43.93Z" },
{ url = "https://files.pythonhosted.org/packages/c9/5f/b8ada5787d11e6edbf58e2d680235fb3b2fb3298991cc310566f4d6c2fbf/websockets-17.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:23046098ebf844753cfd211033624a8247520e5bf2194c24cb1a8c222917acff", size = 225516, upload-time = "2026-10-19T16:55:45.398Z" },
{ url = "https://files.pythonhosted.org/packages/23/1c/0533f760237ac6604efd0bf98c18ea4c1381f2cadd8c8d0b05faecb07a80/websockets-17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:724224d4c457207271239114bf43afafc3eca4a1b4661ccdd428d11869a03bbd", size = 225797, upload-time = "2026-10-19T16:55:46.989Z" },
{ url = "https://files.pythonhosted.org/packages/97/b4/0e580e8f96197130ebdb32b727e9c891594c930be30d8a9de9aa0bbb7e2c/websockets-17.3-cp313-cp313-win32.whl", hash = "sha256:5af7070d80d278832cbca9c3c3dd44b30a26dcfa3ae1e0f6f19ff34edc27b689", size = 218712, upload-time = "2026-10-19T16:55:48.379Z" },
{ url = "https://files.pythonhosted.org/packages/7d/6c/db5c907d1cbea84444bc759ff63db97fc586988d11a34d5565e8b50080fa/websockets-17.3-cp313-cp313-win_amd64.whl", hash = "sha256:66027e401c70d0cf55021b91b8a7ec2cd5810e20602f945b4fe6ae8b37ed232b", size = 219026, upload-time = "2026-10-19T16:55:50.688Z" },
{ url = "https://files.pythonhosted.org/packages/f1/d3/30f04bec5b9cf0c00c98c038494b210ece923a0b05b6df38a162b759e5ed/websockets-17.3-cp313-cp313-win_arm64.whl", hash = "sha256:6b14914e67044724c31a9360c7b89e9d070117c630746eefdc1620d967545acd", size = 218958, upload-time = "2026-10-19T16:55:52.085Z" },
{ url = "https://files.pythonhosted.org/packages/27/ad/f65e365bbf38d61164b0c2f64776fc7714bfa59ac51e492b038a35f4ab1b/websockets-17.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:bc8fd230ef013951900c1fda100eebe4a4b1b407dde2a44186995042dda2c628", size = 218288, upload-time = "2026-10-19T16:55:53.387Z" },
{ url = "https://files.pythonhosted.org/packages/48/1c/33271eba53b4927a1b91d99d838a6d7b888e6f708425262c911450d73696/websockets-17.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cdb8e4b0f6a41b857c74c03116e8ffbef59ef8e533e934b532843178990d01d5", size = 215977, upload-time = "2026-10-19T16:55:55.063Z" },
{ url = "https://files.pythonhosted.org/packages/3d/f3/6d982c60f6c7c95e5faed53d39263df273ecfda904c7d6920c37eee58dd1/websockets-17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:018fab26140ce221c3853454a608e4e7004f4f10641cb97819dcd5b917bb2e99", size = 216193, upload-time = "2026-10-19T16:55:56.424Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c6/4ff6b8d2a4b32719d4658bbd791ee9d19939a47a69b961c961fd9dc3a64c/websockets-17.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dd0f3037d501178a761fd2814370aca547f79e99a200a61a1f6e7b79ec3d78b9", size = 225573, upload-time = "2026-10-19T16:55:58.252Z" },
{ url = "https://files.pythonhosted.org/packages/eb/8f/46e4030947f5590cb61832901f44a3edb07ef8d1e27f6c06b42c5f1c4d33/websockets-17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8dbb3862aa649de95660b6337d97101977f160b5b354d07b82fe309874d2c8bc", size = 225927, upload-time = "2026-10-19T16:55:59.561Z" },
{ url = "https://files.pythonhosted.org/packages/f0/1b/3db30fdf4fbefa4ac463060d3f87e2d444f960aa1234028468c09b88bffc/websockets-17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af37a1e6b6bd506b597d5fb4cf111fb527d1b2223608a5d441d4738827cfd166", size = 227169, upload-time = "2026-10-19T16:56:01.029Z" },
{ url = "https://files.pythonhosted.org/packages/02/ed/849799cfc0e3e359b75ca1d50605376da52dc9ef0b2a53931e3f5991f96e/websockets-17.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:740bba4223fa5216d4d6c5dcd29e697f37229b8a61b69e1a50527b0ea993ab2c", size = 228542, upload-time = "2026-10-19T16:56:02.377Z" },
{ url = "https://files.pythonhosted.org/packages/19/53/dcc397e20ce792fffd9a8f79b6333275748ee7a23f807e576166a0b16edd/websockets-17.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4170556762c6fd2913d99dfdd8dbb4c927584899df7facb1034627e355263c45", size = 227772, upload-time = "2026-10-19T16:56:03.768Z" },
{ url = "https://files.pythonhosted.org/packages/c9/36/e5935782f71e9e22247b2a2e562da05e18d8945c49f2692ca1bdaceec178/websockets-17.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d355b22270be8062360b9df2ee0b10277590789a1059257916fb9e256a0cff80", size = 226518, upload-time = "2026-10-19T16:56:05.332Z" },
{ url = "https://files.pythonhosted.org/packages/27/76/bd5978a1e403fc646230cf35cf7c06b40b3a232d440881b9e89ede229b18/websockets-17.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4aa85b34c21cf97b3470793d0bf0763d32bfd1e67b83067a0a3ba8dc3474dacf", size = 223766, upload-time = "2026-10-19T16:56:06.703Z" },
{ url = "https://files.pythonhosted.org/packages/da/87/c66eb39bd851d5c14d9eb75aa64d03cfcf2ded77927ca067b5917291d19d/websockets-17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a0e2e7c2c1a4f25db7b6596738d810c10732e77f852247300a1bc49e364d6dc9", size = 226644, upload-time = "2026-10-19T16:56:08.198Z" },
{ url = "https://files.pythonhosted.org/packages/fe/ae/b9a31d5497fa0b73629a097b173a26123da2ed656b562114644bc623414b/websockets-17.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7fa69212ea60ea24e2d8aff23fe102b1f87f1b27a320f92d2e7b3468997a496a", size = 225107, upload-time = "2026-10-19T16:56:09.627Z" },
{ url = "https://files.pythonhosted.org/packages/61/0c/b773a102f84b60b2052b2cfde773d2f3cd3e8eaa93a145f54450460549cf/websockets-17.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:80a6e37e235780f189558ab804f3cbc01c18f5c578995739b49613d544d34ebf", size = 225891, upload-time = "2026-10-19T16:56:10.973Z" },
{ url = "https://files.pythonhosted.org/packages/b2/fa/e7e538575dd4912dea9ea4c9ae86519aa1420969d9cb5cf02fd10fd20ba7/websockets-17.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:628efe13630ac3397e7d1278b6be6f22f37a86d638a324334b4cd43c1daf9928", size = 227064, upload-time = "2026-10-19T16:56:12.295Z" },
{ url = "https://files.pythonhosted.org/packages/28/87/bf2b928c156deefd0c50285a48fc985e2ca85d284e7d9981cc5b79b0e696/websockets-17.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:443fa28652682d2a3ec3eca49af1b87cb2523561257683e62e03a98967b22138", size = 224618, upload-time = "2026-10-19T16:56:13.73Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/d59817ba3535f787c1cf8990a01b3d2896a7d9d8f3b5d55bed22fce595a6/websockets-17.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6705af9d1005127302c04d9f1533ba56e289336c465923ef878430e99295a0b9", size = 225571, upload-time = "2026-10-19T16:56:15.199Z" },
{ url = "https://files.pythonhosted.org/packages/a6/96/8210379dac889e16c75099aefffa200dc55dc8cb030010f309a7eb9c75c1/websockets-17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9bde5d82d7ac4859e0cb3a35e148f537a8d4e56aa43f42582b5f263982f095ae", size = 225869, upload-time = "2026-10-19T16:56:16.569Z" },
{ url = "https://files.pythonhosted.org/packages/97/0b/d4741c977fec9e6482beffece572d544d42d230f56fc3ef504f96521063a/websockets-17.3-cp314-cp314-win32.whl", hash = "sha256:be7be57097a65a8fab3d0f0a10d12931deb626a7d8082eb732b1ae41f3a202e3", size = 218232, upload-time = "2026-10-19T16:56:18.028Z" },
{ url = "https://files.pythonhosted.org/packages/33/69/2f9ce899776d0c522f7f44080cad4228a1c14ec13b0125a2efc12ebe41c9/websockets-17.3-cp314-cp314-win_amd64.whl", hash = "sha256:2b06213c8e1ce93b18d0476d7a5e647436c8f23d099888c9065e930e80f4bed5", size = 218582, upload-time = "2026-10-19T16:56:19.399Z" },
{ url = "https://files.pythonhosted.org/packages/4e/e9/11b885666d72add674440a3435c059bea325616959cec8be42605948714c/websockets-17.3-cp314-cp314-win_arm64.whl", hash = "sha256:6b7b540bb321f7f0a7b8f5d107f941bb361e3a154c0b594dae0394997b91b743", size = 218465, upload-time = "2026-10-19T16:56:21.005Z" },
{ url = "https://files.pythonhosted.org/packages/c4/0e/0c0f40ab8eefe9d5a6c78b4e267d8e37d70d3db8dd195574e0ab867e4f81/websockets-17.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:c9d06edfd974e918b40e0662a7e76dac13125529ee5ec9fbe42dc0d6faa209c3", size = 218614, upload-time = "2026-10-19T16:56:22.457Z" },
{ url = "https://files.pythonhosted.org/packages/1f/93/d22b3d63aa439bc01a9477880070d05e0b257b0548294786a33be3e842ec/websockets-17.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a0bcf66289b9d017c175562c35c5304b5cc3ed56aded994ae30b0a9654ba405", size = 216151, upload-time = "2026-10-19T16:56:23.95Z" },
{ url = "https://files.pythonhosted.org/packages/b9/92/f60a9672cdeb8cb4f7156e6e83c76d59fdc586737a97ac77129e9fc188a2/websockets-17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f3ed747b7651668131029c720fbc32c7ee2d129bdad200e6c8f4037b20f7913a", size = 216354, upload-time = "2026-10-19T16:56:25.251Z" },
{ url = "https://files.pythonhosted.org/packages/9d/da/2be8c5f3c6612d4523f95b074e2352bc11bd090cf121d32bc11af1d52e70/websockets-17.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5868a02c4f6d698ddd19b4c9742748c03dec045289915806845189d96bc3d314", size = 225937, upload-time = "2026-10-19T16:56:26.726Z" },
{ url = "https://files.pythonhosted.org/packages/85/1c/cb84aec871840acd66dfa84520bf7b8e48ce70bdb3f4baa4efd097fbe6b1/websockets-17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa87ea2ad64e59fe5622c1dc5716106b6d6e7e4b26b848cb5f5710a75c292ef5", size = 226215, upload-time = "2026-10-19T16:56:28.1Z" },
{ url = "https://files.pythonhosted.org/packages/9b/7b/cfb65557accec2eda2c80288e13eca6f1c797c579375906eb73f4053cfd1/websockets-17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65e937576141ac50fac32fa9736dbb3e0e4a7769f91be004011a09216c6ab05f", size = 227389, upload-time = "2026-10-19T16:56:29.518Z" },
{ url = "https://files.pythonhosted.org/packages/70/a3/7e08ec4b23e376ed228831aa44b2f2a3f2e7025cc66dda4b2e53dcc2619e/websockets-17.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:35381b575f70a08d4241e73c0cde4672bb9b2a9aae8767ca6b7fb61305a052c8", size = 229534, upload-time = "2026-10-19T16:56:31.072Z" },
{ url = "https://files.pythonhosted.org/packages/4b/7d/8a813e3e53956f876cfaad4f6358625bbd94168aa8046c44e2f3561f12af/websockets-17.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5142798871a248b422f353e0684d51dfa119de3403a6fa0904b7cb806d9790ce", size = 227959, upload-time = "2026-10-19T16:56:32.656Z" },
{ url = "https://files.pythonhosted.org/packages/41/23/5f3dab6ab44f2f30042db83a658a57129d5e3c0425c798b3c3e727bc060a/websockets-17.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e164c4a6868d11988c7f2386e3278ccff21c665cd7d08279b62709dd37a3167c", size = 226691, upload-time = "2026-10-19T16:56:34.101Z" },
{ url = "https://files.pythonhosted.org/packages/5b/49/99a3fb5a2e94d677fbd0d014a6f9a56a3d2b8e8007b7175727bc4fc4ae94/websockets-17.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10b24e836ee0233ca078e1244f6ba40343a22127a9a736f46a7e7b18de2cc711", size = 224225, upload-time = "2026-10-19T16:56:35.452Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e5/f503a4d757858d083c90ea85dd1fd636ae9eb63c9552c75a4c38514ee7ed/websockets-17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:56a76556ab04ab54a675acce2cac8b4e74ea2cec558a7c9ed37e0367b04b5d50", size = 226984, upload-time = "2026-10-19T16:56:36.887Z" },
{ url = "https://files.pythonhosted.org/packages/4a/a6/8586844ad4b21988837624e1f4b331bc71919c85a6eb1ee3c8bd29893f27/websockets-17.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:33221e49be79d7bfe6d12bc52ef9458b256ab74fa8ac5660acfa3065de1c7870", size = 225436, upload-time = "2026-10-19T16:56:38.213Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b9/f419506494d6ffd205a9c3258f56ff5aeae6976de26361057de74f4ab659/websockets-17.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2fa79b9a48ae59744316c71a34cb9f76f30c93e117338522c5135cb7eac10351", size = 226325, upload-time = "2026-10-19T16:56:39.606Z" },
{ url = "https://files.pythonhosted.org/packages/28/c5/55a58faa67149e84cb98b33f9d8fda95242677ea07b78f3ecaf2e77877d6/websockets-17.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f87f02afe05c1c246012e3de100a77cfbabd17db709fe93cf074271ab239b312", size = 227342, upload-time = "2026-10-19T16:56:41.041Z" },
{ url = "https://files.pythonhosted.org/packages/35/e0/fbe561dff24b6ef1b2034877f37b56eb9b81b3c103e2a8b03819bf2b3b6d/websockets-17.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8135a549e27e9941a70e2d2156676ef2ffeff4b06e9d564dbaaae963482afdef", size = 225036, upload-time = "2026-10-19T16:56:42.433Z" },
{ url = "https://files.pythonhosted.org/packages/0d/37/d80753064f8370a24d3bec74807c3928d7b0ebef5381956e2a0108b365c2/websockets-17.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ace8dd1efa5f44c482d091aa38322e20e3ffe5adcdba6b67ad9f4e7da58f3f68", size = 225807, upload-time = "2026-10-19T16:56:43.774Z" },
{ url = "https://files.pythonhosted.org/packages/f1/9d/a11d3168522a2959c4a5661728744d62b9eee344abdba5f9fbc1d359742f/websockets-17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2129c9a5fc05a230ce088745a790af073e582b584badc7b05db067ee326f56d7", size = 226241, upload-time = "2026-10-19T16:56:45.088Z" },
{ url = "https://files.pythonhosted.org/packages/06/28/16107b3b83fd2b5024bfecf8d000140f555204a0191202f9c4c981137810/websockets-17.3-cp314-cp314t-win32.whl", hash = "sha256:af7c474f0d8f4d2a196370e85033873c7b063a0b5da9a4f143aa023e3bd25407", size = 218365, upload-time = "2026-10-19T16:56:46.41Z" },
{ url = "https://files.pythonhosted.org/packages/77/dd/8b00e1dd552788d8c7762d7ef61de8f2f238d7357b1a02d4c4650b112196/websockets-17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:0c63ad657695e8f1a48624754cb6b464118ac50d4c1d477c26461c98390f0a62", size = 218706, upload-time = "2026-10-19T16:56:47.754Z" },
{ url = "https://files.pythonhosted.org/packages/39/eb/5d90d57a5cbe614b2f13433aaf1c0b068c804589379061ba06812a185a89/websockets-17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:3c4f631dc8d00e470cde3ec0a35d4b58a817bea592411956f1cf4099dbb306b3", size = 218622, upload-time = "2026-10-19T16:56:49.07Z" },
{ url = "https://files.pythonhosted.org/packages/6e/41/1efe5ecae1f32495dc75641704f959bd2d8e56cdc825f3acc5e010a51960/websockets-17.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f300f28dff74ff4148c21e6971e4de6ca0c34e0086a3d95c017c5174ebf85ca5", size = 218211, upload-time = "2026-10-19T16:56:50.423Z" },
{ url = "https://files.pythonhosted.org/packages/01/ec/4edb653533241203db3f11522e122338d4fda50a41ce2a542029086f8eda/websockets-17.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:32029441af953e980b9940f042b337fe4ad9a97fa8b17a4ed878ec67b5b89ef4", size = 215944, upload-time = "2026-10-19T16:56:51.766Z" },
{ url = "https://files.pythonhosted.org/packages/d5/b9/ec450b9650925dd5cf27c1f36d0464786fe3ac702ed656879c4c0dd5c4df/websockets-17.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09f007b2150eff015b2109f006e2ac2012d376f41486defa88ae78a776b00cff", size = 216164, upload-time = "2026-10-19T16:56:53.125Z" },
{ url = "https://files.pythonhosted.org/packages/15/1e/63f7fbff9f0540a0331f1a62424c9ff24faa6ba8aec264f55e9e31017eea/websockets-17.3-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bdc658c9474864893e0b6b94f1390bf6a912952df1870dd93bb7d1a902b5628", size = 225608, upload-time = "2026-10-19T16:56:54.442Z" },
{ url = "https://files.pythonhosted.org/packages/0c/1b/f6679ad61cb8c110033a1b30d561d5c1052983e202157251ff1778f27932/websockets-17.3-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:15decc0fe293d9e3f924bd483f3d76f3d0ff26845112a470e3f8842fec2c82a1", size = 226042, upload-time = "2026-10-19T16:56:55.727Z" },
{ url = "https://files.pythonhosted.org/packages/28/c6/ed993aaef8046cbbb939ae367ee3dd5383dbd055f0500a5b8aed101168b6/websockets-17.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:997617dd7856c5dad742bf6893b872319156dd43f47587541a59e4a852d52bbc", size = 227203, upload-time = "2026-10-19T16:56:57.076Z" },
{ url = "https://files.pythonhosted.org/packages/aa/96/7057dc71affe6c8252df8648f5a225642228a6fdd8d046a6b9aa1fb0e9eb/websockets-17.3-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a004c1fe5a72c496f28c6c1d8c2dc5cfe3278d99478e18f8b838c70a912b366f", size = 228900, upload-time = "2026-10-19T16:56:58.633Z" },
{ url = "https://files.pythonhosted.org/packages/43/e3/8b3268bb7e72b465e55172b86d55f58aa277fb6d9bac92aee615345874df/websockets-17.3-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35fe35d20baa661cef4a12ee7bc42eaf2376d43ef9816ab29dd47ca957f7a73a", size = 227684, upload-time = "2026-10-19T16:56:59.975Z" },
{ url = "https://files.pythonhosted.org/packages/59/15/fa31c0a12240b1225c3712ff080d2f7ede4f695ca90e59fdb58e8affabba/websockets-17.3-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8b3bede7d583bc9f47d8693a29984882e59077db1a5238ad977fc70ae8169741", size = 226580, upload-time = "2026-10-19T16:57:01.371Z" },
{ url = "https://files.pythonhosted.org/packages/22/b5/75d911c206a3035e2ecfad107878e62c06e574b2437fa2cfb4ecef436fa3/websockets-17.3-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dafbb58a149d1ebd5767b3f064f911c5ee0897608005e51c3904b0ada2a8d99c", size = 223967, upload-time = "2026-10-19T16:57:02.839Z" },
{ url = "https://files.pythonhosted.org/packages/0e/f8/f98aa36f611acd5770819f6690125c20d55f221fd6db8d00d284b5f78e89/websockets-17.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80326bc571164bbbb6bead2e35d0c943b23b24fde629032197c58151529d24d8", size = 226689, upload-time = "2026-10-19T16:57:04.458Z" },
{ url = "https://files.pythonhosted.org/packages/1a/36/d52f5dff7597de6fbfc26c3d126abd668137915719aebf97abcc3be77eca/websockets-17.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:7d53fc4b90ef6e11de639dc18744d9be8cbd70ccb5ce80027aaffe7b1ab0215a", size = 225353, upload-time = "2026-10-19T16:57:05.857Z" },
{ url = "https://files.pythonhosted.org/packages/d4/10/dcd70ad9f9cd20e8af43284ac250af610e8bf9022da25f5b409153d033b4/websockets-17.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:fdd00937f15f5f93c8e53d8060a8c4c010431ab595a9b5b50ddd569a04603944", size = 225879, upload-time = "2026-10-19T16:57:07.249Z" },
{ url = "https://files.pythonhosted.org/packages/70/ab/f26c6bfa99d38ca3d1bf23145306d97c5d0c75e133a8d837373e4b3d4629/websockets-17.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1dae5cd6c92d4440b15f20aa2987baa73c47ea14baa0fec8536a542e82e0ab33", size = 227031, upload-time = "2026-10-19T16:57:08.817Z" },
{ url = "https://files.pythonhosted.org/packages/d7/3f/c8fd74cdb526cc65295b7468f2f84ba16678646d8174288822f99f510274/websockets-17.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b225c656efa2dedecc81651d2290170af25887b7de4f38c5efc7c5ae2b9442ab", size = 224842, upload-time = "2026-10-19T16:57:10.298Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c5/b3a07ce1c54fc0f5441642f5352662b90d7e658c966730eaf6d78a5f20bc/websockets-17.3-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:bd645b5d2f810a181c44f4a71135e7c365b44f4be9ed3d1892635be13a857553", size = 225744, upload-time = "2026-10-19T16:57:11.615Z" },
{ url = "https://files.pythonhosted.org/packages/66/37/8321a8a6ab0215d4df0238e49ace5f539364cf337bb122b85c788e8c93ec/websockets-17.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d2605a3b2af8d66f8b75312d223642c065ec773446c04566d9030ad5e2db13c9", size = 226006, upload-time = "2026-10-19T16:57:13.049Z" },
{ url = "https://files.pythonhosted.org/packages/f8/04/8c457a7e6a260b673bf4e6f2f422eb4af5f11c48505748359706f24f1bef/websockets-17.3-cp315-cp315-win32.whl", hash = "sha256:82e60c00cfb8493a5ba27cdf9b3695fdd153f666f73e11f18968f78ead1d8705", size = 218196, upload-time = "2026-10-19T16:57:14.41Z" },
{ url = "https://files.pythonhosted.org/packages/a0/7c/c2df129d9e344b99bb388bcac54fbbdfeec29e6c716c12dc9a3398757940/websockets-17.3-cp315-cp315-win_amd64.whl", hash = "sha256:4d5f2492cb2192aab2d42e57f2caa2159b4fd20fde06f859ec13354f524b176c", size = 218490, upload-time = "2026-10-19T16:57:16.234Z" },
{ url = "https://files.pythonhosted.org/packages/e6/4f/721b778c85559b2949e46246838e31a346dd815b3cdd8460fe517235675a/websockets-17.3-cp315-cp315-win_arm64.whl", hash = "sha256:55494475206113dbaf731dc8c5ffd5e1094175b753b7bbaad09fe3d7df61165c", size = 218403, upload-time = "2026-10-19T16:57:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/b3/28/241d9ebb4dad79346b5a6abf9f14d7685b12ca358d250eea4920cdb1caf3/websockets-17.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:23cb6f6d64e3a389bb8ba150f947f9a6c8c0aca383e814ca3c5cffab738d5646", size = 218523, upload-time = "2026-10-19T16:57:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b0/bb/559fe539992102c66cbca38b488af6c7b184ab7736d819afc0f72268a2b4/websockets-17.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f27e7e8a85512b6a4b490d5756edde8e6772883a5800e1476172e44eda3f7d7c", size = 216103, upload-time = "2026-10-19T16:57:20.412Z" },
{ url = "https://files.pythonhosted.org/packages/34/ee/e8ade4f13a3d0da68636fe44bb8ac1a3e36fc57070512788320dead42067/websockets-17.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:6342f79d6f50e680c8aa59e0615d745e867e51ffd59c77093be60b7c433067af", size = 216316, upload-time = "2026-10-19T16:57:21.761Z" },
{ url = "https://files.pythonhosted.org/packages/4d/06/4812b2a7a5e6571d9be425e76cf54ee69aaa15720caa02307785aa5b19a7/websockets-17.3-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fc23e72bd7f8a8e540fed2244524d0a9aeb6ae3325f3d56f04a7a3debe5b04c2", size = 226467, upload-time = "2026-10-19T16:57:23.077Z" },
{ url = "https://files.pythonhosted.org/packages/d3/e8/adfc05d7f9b6b0cce6cc554426c50b7058609119b6b84a39171fe5ed4e50/websockets-17.3-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:722ab747ff5dc28070b1a8c020dba6466f13f2ab56bc251ad301fefa2454aa1e", size = 226736, upload-time = "2026-10-19T16:57:24.419Z" },
{ url = "https://files.pythonhosted.org/packages/96/b2/d5b93dd933552554507edae9e3a4890269a8ea8b46019450ea9c8a30e7cc/websockets-17.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d20d6ea5aabbeba2e054d33e405192ef75dc12947bbd00ec97d23b14969d325", size = 228065, upload-time = "2026-10-19T16:57:25.908Z" },
{ url = "https://files.pythonhosted.org/packages/10/37/642f0f1cf1c4814147a4f0586f796a0b38e42b69e5a296bfc270fb660051/websockets-17.3-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:90cc9973c51811918247a78d9ab2f54871296e4e37e69a98aba4d3c9acedbf62", size = 229692, upload-time = "2026-10-19T16:57:27.63Z" },
{ url = "https://files.pythonhosted.org/packages/5b/33/eac81cf62ac394b61db6a27ccbe8fb9ae382016ef73ede1edf9242a6a43e/websockets-17.3-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63b87c93e28957e6dc2bb92d31727a863c9bf4a4daa080f0995fdcb0242c9166", size = 228503, upload-time = "2026-10-19T16:57:29.031Z" },
{ url = "https://files.pythonhosted.org/packages/0d/84/e057788a9bd056f4f7d3a06b59efaaf724bbc6f2d1e1d316f8a93c951abc/websockets-17.3-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f7431e71072962d3ed1768038200aa4f1e76d9c2d7bedd554d57a55faaf6644", size = 227379, upload-time = "2026-10-19T16:57:30.571Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a7/cca1f9f45b893bb5b7545f2b234f910e07fcd2f8065724ac1a62a0961be2/websockets-17.3-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:534ed583fd4323de45d196535a444cebd1af2105e6e4e0cdcbf8cea266a8747a", size = 224719, upload-time = "2026-10-19T16:57:31.895Z" },
{ url = "https://files.pythonhosted.org/packages/1f/a0/5d07451027150c802ce4c9999e86994a3f0de5f4dbe3d1cce46995999269/websockets-17.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8879b6a4040ee925c638548238ab7fe612cf5fa8752fe847346da0da8be81c3f", size = 227564, upload-time = "2026-10-19T16:57:33.236Z" },
{ url = "https://files.pythonhosted.org/packages/62/90/835f37e13fac0c615a700caae0d39deff1de2d2b9b341836a786f9b359e0/websockets-17.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58e5d1e935a90878a935c7d2737a25c71cf4f1c13b6e6bef454ab722c4f338bb", size = 226218, upload-time = "2026-10-19T16:57:34.876Z" },
{ url = "https://files.pythonhosted.org/packages/16/8a/718639939b947fe715501e8073350fd3f8a42f8b5eba3c0c2389c5836513/websockets-17.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:0a657b51e4a5f4ac577c11e706ff9a884ea5aaf3e2666bae283a251c63e66acb", size = 226712, upload-time = "2026-10-19T16:57:36.248Z" },
{ url = "https://files.pythonhosted.org/packages/e8/45/6bef8572b9b0dee51369b69776af1e4c7cc3a807f199dc3c165bbcf1199a/websockets-17.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:411aa0fb234f42a1444dea1e177890ddfc577b36ffdfafec25cb0d2c818e1af8", size = 227819, upload-time = "2026-10-19T16:57:37.586Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3f/3b726d0b46a132e641d2d8c0243ea8a7d39464dbaf28c7a206e51fbae681/websockets-17.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:623d4b44f64efdf235b1e8dce266057073d93cde6197e7d7684f20c297f67f30", size = 225573, upload-time = "2026-10-19T16:57:39.285Z" },
{ url = "https://files.pythonhosted.org/packages/90/96/cd972a12e1412d9b42c42ef42efbd81aa396ca9408d982d524012f011a8d/websockets-17.3-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5d0c871efc696ef5eb8514df64b86ab28736db753ebb42a2101f3b562f4849c0", size = 226430, upload-time = "2026-10-19T16:57:40.915Z" },
{ url = "https://files.pythonhosted.org/packages/7d/2d/e11524771588188a39f6da234779d4942496b96c5a10b92f3df30db48608/websockets-17.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1db2604e6aedb7a2cb2f6e0575d6c9a852ccac8239703a930ac83b63309177a5", size = 226728, upload-time = "2026-10-19T16:57:42.245Z" },
{ url = "https://files.pythonhosted.org/packages/17/9b/63722aed973f53ea579e1ce0c4563a2b8b536629e556feff26e4c6a2a60d/websockets-17.3-cp315-cp315t-win32.whl", hash = "sha256:07246d4f0293ef0b87eff3d7dd88c1f03dd9f678fa39f6de87b2af8504e7e0de", size = 218325, upload-time = "2026-10-19T16:57:43.585Z" },
{ url = "https://files.pythonhosted.org/packages/34/07/5e0c92e225546f1789c957c0383852797be7c899b07207e9b6eb4a93473f/websockets-17.3-cp315-cp315t-win_amd64.whl", hash = "sha256:856bbb24f2ee6436af7609267e73309e4bb0a0f9982f79f1c2e01019279c6671", size = 218641, upload-time = "2026-10-19T16:57:44.918Z" },
{ url = "https://files.pythonhosted.org/packages/d4/e4/339663de65c44ab3eb5209890e86eed26e1a131d6afab3df6f1c3e5b4d8c/websockets-17.3-cp315-cp315t-win_arm64.whl", hash = "sha256:a22c4956a4d858200dba83f0691bb692d77b0eeddfca80157820d20b85dea4f1", size = 218564, upload-time = "2026-10-19T16:57:46.332Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f1/b462b1518779adf0b6fce305beee07357a79cf0724bbe61113ed6521882e/websockets-17.3-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:34c78052277053bcb09810ac8e9ba4029f9e3e277833fc18ec1c928a6246ac91", size = 215839, upload-time = "2026-10-19T16:57:47.742Z" },
{ url = "https://files.pythonhosted.org/packages/a9/59/7dcea408892967516cc00d491ee7bba48577059c6eb0811f436b720bcd98/websockets-17.3-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:ef07e0b6b879f699a16fc7a513b974246fdeaef44908a021130cf4afe6a1c89c", size = 216114, upload-time = "2026-10-19T16:57:49.111Z" },
{ url = "https://files.pythonhosted.org/packages/c6/b7/2d447e461f1160dc8dcdd8b143e2adba54d402e5f828eeb1f8469970c937/websockets-17.3-pp311-pypy311_pp80-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99c5637b74e2805ee4de9e2a43c8b45f678e50abb1c2da9880f0372f6498ba2f", size = 217065, upload-time = "2026-10-19T16:57:50.424Z" },
{ url = "https://files.pythonhosted.org/packages/01/51/35aa5edd12b23e0001b85517ad447342c07a29fcee62009b0859825b77a7/websockets-17.3-pp311-pypy311_pp80-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c460aeb12c2bd1c204944d9e9199595bf9e505eb542018d304bf9e63cb60cad", size = 216952, upload-time = "2026-10-19T16:57:51.72Z" },
{ url = "https://files.pythonhosted.org/packages/6c/db/537306b92236d761b99dc56a7316c0ad46f2854cd0756585b3a9d3fe5639/websockets-17.3-pp311-pypy311_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e1c174b366565e101f903f9d1cc96d5508f805759dd37d012743f1c73ec0d7b", size = 217844, upload-time = "2026-10-19T16:57:53.144Z" },
{ url = "https://files.pythonhosted.org/packages/e0/c2/494bed87536f3822872e57b07ac9afa744af88ff64dda664b9caa32b1a72/websockets-17.3-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:c4b3f2c7d84f303da873b634d0beef83b5e1137294c3de4e23102841dac0cf4a", size = 306274, upload-time = "2026-10-19T16:57:54.525Z" },
{ url = "https://files.pythonhosted.org/packages/5a/1a/5582103e0362f86c4725051ded89e585ceaf750f26beace530b7e42c81c0/websockets-17.3-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:0e65260a1b1d7b81651f47343d836de70b88b02358ae3658efa6382104ec37b1", size = 215872, upload-time = "2026-10-19T16:57:55.95Z" },
{ url = "https://files.pythonhosted.org/packages/ef/a7/f69d65ed3ff9ae73d428a918300c23a992e4d50683090b8dc33cc3341d5a/websockets-17.3-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:94c02b0702656f083c098bdbd93bce22a7aa7a2b34ba56d2472efb67dab7cf58", size = 216136, upload-time = "2026-10-19T16:57:57.251Z" },
{ url = "https://files.pythonhosted.org/packages/69/76/c64fda4ac18a3aeb32e66acec481011a766add480b6362fad80e2c3c1963/websockets-17.3-pp312-pypy312_pp80-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ade76f8c84ade325bbed96806357fffd353fcc29a5166d6dc6d39381f6d9f76c", size = 217080, upload-time = "2026-10-19T16:57:58.593Z" },
{ url = "https://files.pythonhosted.org/packages/6b/6d/085a3f0e6152bbe79917973f3d33cd008437de6c04675e2f15253b921f21/websockets-17.3-pp312-pypy312_pp80-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a215749c9a88ef2842e880215193e56ae60b856773c99702b078236bc6d4adb", size = 216966, upload-time = "2026-10-19T16:57:59.924Z" },
{ url = "https://files.pythonhosted.org/packages/ed/b8/7eb904583ea2090b329f50487e6f8dc58eb558712b9f3c16f289f0be668e/websockets-17.3-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7b5d23ef144b32645d97bd99f382c4394c443395f45fe1576786838d26e308", size = 217828, upload-time = "2026-10-19T16:58:01.822Z" },
{ url = "https://files.pythonhosted.org/packages/79/36/f54b4a1cd9456ca0a65a76d77d181621433793aca633dfeed214748ff4f0/websockets-17.3-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:13eace1b315345ebac5b27821f5dcce24e5cf9976ab18f79e00a59bdd67e5d19", size = 306301, upload-time = "2026-10-19T16:58:03.161Z" },
{ url = "https://files.pythonhosted.org/packages/d7/b0/3f01fcb3406662f6deb1e3e72b34dd0979e993991c0b7435a9e12bc14f02/websockets-17.3-py3-none-any.whl", hash = "sha256:dc2b495a92b3971966bf102e73589f950db3ec1ca6713265355ce7771d97b199", size = 212375, upload-time = "2026-10-19T16:58:04.483Z" },
]
[[package]] [[package]]
name = "werkzeug" name = "werkzeug"
version = "3.1.8" version = "3.1.8"