Compare commits

...
51 Commits
Author SHA1 Message Date
Legrems 407cc22911 opus-stats-score 2026-10-25 14:20:49 +01:00
Legrems 5fc79197f6 feat: add final.png export 2026-10-25 14:07:57 +01:00
Legrems 73b279d7fe feat(place): final end time + destruction wave 2026-10-25 12:55:24 +01:00
Legrems 1844b973e9 feat: add admin stats 2026-10-25 10:48:54 +01:00
Legrems 6cee1135a8 feat: add option to disable pixel placement 2026-10-25 10:42:58 +01:00
Legrems 4f2cf08b27 feat: heatmap time decay also in addition of placements 2026-10-25 10:29:20 +01:00
Legrems 9bb02463da feat: heatmap + evo + decay 2026-10-25 10:25:29 +01:00
Legrems 0b34e66d81 show opus submissions for admin 2026-10-24 04:27:40 +02:00
Legrems d180eb3c90 deploy scripts 2026-10-24 04:15:31 +02:00
Legrems 5c70c38523 feat: systemd deployment 2026-10-24 04:04:38 +02:00
Legrems 026d592483 feat: evolution per player (optional) 2026-10-24 03:49:34 +02:00
Legrems 87e6e38291 add place csv export 2026-10-24 02:11:09 +02:00
Legrems 723da9aa38 repair legacy table in migration 2026-10-24 01:18:08 +02:00
Legrems 421106bf71 fix staleTime to 5m instead of infinity 2026-10-23 23:56:20 +02:00
Legrems f42649b888 fix submittion null score 2026-10-23 23:35:38 +02:00
Legrems f75a1b0338 reset opus-magnum sequence 2026-10-23 21:26:22 +02:00
Legrems 1c6d9202fc increase place size 2026-10-23 20:21:57 +02:00
Legrems bc3d8158ec fetch steam collection from event path 2026-10-23 18:53:28 +02:00
Legrems 6e7a68d509 proxy headers 2026-10-23 18:38:48 +02:00
Legrems 2c4dac628e feat(games): disable games inline 2026-10-23 18:06:59 +02:00
Legrems cf51b187d2 opus-magnum steam collection linked to event in admin 2026-10-23 18:03:33 +02:00
Legrems b5716d6dcd various display fixes 2026-10-16 13:25:56 +02:00
Legrems ad7726d978 try to match to steam game 2026-10-16 05:28:15 +02:00
Legrems 89fb745e74 wip: planning-slot test 2026-10-16 05:20:52 +02:00
Legrems 2f8fdb755b feat(games): show games in library mode also 2026-10-16 02:41:27 +02:00
Legrems 54ffe89445 fix(clock): colliding points annotation with CustomUser.points field 2026-10-16 02:30:06 +02:00
Legrems 377bc91bcb fix: small return fixes in api schema 2026-10-16 02:29:39 +02:00
Legrems 60d730a683 feat(submissions): add small file preview before submitting 2026-10-16 02:24:41 +02:00
Legrems 1daf2757b8 fix(submissions): broadcast crop delete + fix for crop admin 2026-10-16 02:19:18 +02:00
Legrems a110575e0a feat(submissions): show all submissions for admin 2026-10-16 02:11:04 +02:00
Legrems bfaef2e402 fix: move other (clock+place) live icon to top ehader too 2026-10-16 02:06:33 +02:00
Legrems 3d040dd377 fix(submissions): top-header icon for live connection (ws) 2026-10-16 02:03:54 +02:00
Legrems a6ea3719b9 fix(submissions): default to frontend search to prevent wrong 404 on hard refresh 2026-10-16 01:55:38 +02:00
Legrems 13c09adff3 feat(submissions): auto-replace player in scopes, cancel sync + info about the sync 2026-10-16 01:49:53 +02:00
Legrems de5ee8ae7a feat(submissions): sync scopes admin from token 2026-10-16 01:34:56 +02:00
Legrems 8daa91d6c5 fix(submissions): better reconnect for ws 2026-10-16 01:26:43 +02:00
Legrems acd08e216a fix: use vue-query; make less useless api call 2026-10-16 01:22:16 +02:00
Legrems b403311ce1 fix(games): card fixes; colors + width 2026-10-16 01:13:24 +02:00
Legrems 1ca4643774 fix(clock): dedup tasks lock bug removal 2026-10-16 01:08:50 +02:00
Legrems 27d3c08a72 feat(games): add steam pages with steam price/reduc 2026-10-16 01:03:46 +02:00
Legrems 919e611895 feat(market): enable/disable like any other games 2026-10-16 00:23:27 +02:00
Legrems 107fe8b69b chore: ruff 2026-10-16 00:18:52 +02:00
Legrems 15ab9a14ac feat(event): option to disable an event 2026-10-16 00:18:36 +02:00
Legrems b837abdba0 fix(submissions): having one scope only make the back return not work 2026-10-15 23:36:06 +02:00
Legrems 4fd683ff2c fix(market): locust file for testing 2026-10-15 23:31:32 +02:00
Legrems 8509be548e feat(market): graph for fun 2026-10-07 21:54:24 +02:00
Legrems 382ba01f3e fix(market): per-event bet loss+win 2026-10-07 21:44:33 +02:00
Legrems 6a5a224d3b fix: multiple fixes + event for market 2026-10-07 21:38:30 +02:00
Legrems fdf84f9524 chore: admin interface changes 2026-09-19 17:18:23 +02:00
Legrems e8b018ec49 chore: add locust files + testing setup 2026-09-19 16:24:19 +02:00
Legrems b74d20cbe0 chore: deployment move to docker only 2026-09-19 13:36:48 +02:00
147 changed files with 10918 additions and 855 deletions
+1 -1
View File
@@ -20,4 +20,4 @@ polylan_submitter/db.sqlite3
polylan_submitter/.vscode polylan_submitter/.vscode
polylan_submitter/settingsLocal.py polylan_submitter/settingsLocal.py
deploy/supervisor/polylan-submitter.env deploy/docker/polylan-submitter.env
+1 -1
View File
@@ -178,4 +178,4 @@ pyrightconfig.json
tags tags
media/ media/
settingsLocal.py settingsLocal.py
deploy/supervisor/polylan-submitter.env deploy/docker/polylan-submitter.env
+3 -3
View File
@@ -1,9 +1,9 @@
# Single OCI image bundling the Vue/Vite frontend, the Django/Channels web # Single OCI image bundling the Vue/Vite frontend, the Django/Channels web
# process (daphne) and the RQ worker, managed together by supervisord - see # process (daphne) and the RQ worker, managed together by supervisord - see
# deploy/docker/README.md for why (single-container Proxmox deploy) and how # deploy/docker/README.md for why (single-container Proxmox deploy) and how
# to run it. Postgres, Redis and S3 stay external, configured the same way # to run it. Postgres, Redis and S3 stay external (see
# as the bare-metal deploy in deploy/ (see deploy/supervisor/*.env.dist for # deploy/docker/polylan-submitter.env.dist for the full env var list) - this
# the full env var list) - this image carries no infra, just the app. # image carries no infra, just the app.
# #
# Build (from repo root, requires BuildKit/buildx - RUN --mount=type=cache # Build (from repo root, requires BuildKit/buildx - RUN --mount=type=cache
# below is rejected by the legacy builder): # below is rejected by the legacy builder):
-58
View File
@@ -1,58 +0,0 @@
#!/usr/bin/env bash
# Deploys polylan-submitter from /opt/polylan-submitter: pulls latest master,
# syncs the venv, renders and installs the supervisor config, restarts the
# supervisor programs, installs the (static) HAProxy config, reloads
# HAProxy, then re-adds the backend servers over HAProxy's admin socket -
# a reload always resets HAProxy back to just the one static server in
# deploy/haproxy/haproxy.cfg, so the rest get added fresh every time.
#
# Run on the target host (needs sudo for cp/supervisorctl/systemctl/socat):
# deploy/deploy.sh
set -euo pipefail
APP_DIR=/opt/polylan-submitter
RUN_DIR="$APP_DIR/run"
SUPERVISOR_CONF_DIR=/etc/supervisor/conf.d
HAPROXY_CONF=/etc/haproxy/haproxy.cfg
HAPROXY_ADMIN_SOCK=/run/haproxy/admin.sock
HAPROXY_BACKEND=polylan_submitter_web
# Single source of truth for daphne process count: drives supervisor's
# numprocs, and how many backend servers get re-added to HAProxy below
# (server 00 is already static in haproxy.cfg, so only 01..N-1 are added).
NUM_WEB_WORKERS=5
cd "$APP_DIR"
git pull --ff-only
uv sync --frozen
sudo mkdir -p "$RUN_DIR"
sudo chown polylan:polylan "$RUN_DIR"
render_dir=$(mktemp -d)
trap 'rm -rf "$render_dir"' EXIT
sed "s/__NUM_WEB_WORKERS__/$NUM_WEB_WORKERS/" \
"$APP_DIR/deploy/supervisor/polylan-submitter.conf" > "$render_dir/polylan-submitter.conf"
sudo cp "$render_dir/polylan-submitter.conf" "$SUPERVISOR_CONF_DIR/polylan-submitter.conf"
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl restart polylan-submitter:
sudo mkdir -p "$(dirname "$HAPROXY_ADMIN_SOCK")"
sudo cp "$APP_DIR/deploy/haproxy/haproxy.cfg" "$HAPROXY_CONF"
sudo haproxy -c -f "$HAPROXY_CONF"
sudo systemctl reload haproxy
# The admin socket only processes one command per connection - sending
# several commands into one socat session silently drops everything after
# the first, so each command below gets its own connection.
for ((i = 1; i < NUM_WEB_WORKERS; i++)); do
printf -v socket_num "%02d" "$i"
server="${HAPROXY_BACKEND}/web-${socket_num}"
socket="unix@${RUN_DIR}/web-${socket_num}.sock"
echo "add server ${server} ${socket} check" | sudo socat - "UNIX-CONNECT:${HAPROXY_ADMIN_SOCK}"
echo "enable server ${server}" | sudo socat - "UNIX-CONNECT:${HAPROXY_ADMIN_SOCK}"
done
+13 -11
View File
@@ -9,12 +9,11 @@ one container:
- `worker` - two `rqworker` processes for background jobs (submission OCR, - `worker` - two `rqworker` processes for background jobs (submission OCR,
AAndT sync, etc.) AAndT sync, etc.)
Postgres, Redis and S3-compatible storage are **not** bundled - the image Postgres, Redis and S3-compatible storage are **not** bundled (see
expects them the same way the bare-metal deploy does (see `deploy/docker/polylan-submitter.env.dist` for the full list of env vars:
`deploy/supervisor/polylan-submitter.env.dist` for the full list of env `POSTGRES_*`, `REDIS_HOST`/`REDIS_PORT`, `AWS_*`, `DJANGO_SECRET_KEY`, etc.).
vars: `POSTGRES_*`, `REDIS_HOST`/`REDIS_PORT`, `AWS_*`, `DJANGO_SECRET_KEY`, Point those at wherever those services already live (other containers/LXCs,
etc.). Point those at wherever those services already live (other or existing infra).
containers/LXCs, or existing infra).
## Build ## Build
@@ -30,7 +29,7 @@ docker buildx build --load -t polylan-submitter .
```sh ```sh
docker run -d \ docker run -d \
--name polylan-submitter \ --name polylan-submitter \
--env-file deploy/supervisor/polylan-submitter.env \ --env-file deploy/docker/polylan-submitter.env \
-p 8000:8000 \ -p 8000:8000 \
polylan-submitter polylan-submitter
``` ```
@@ -41,15 +40,18 @@ shared S3 bucket, so run it once per deploy instead of racing multiple
replicas on every boot: replicas on every boot:
```sh ```sh
docker run --rm --env-file deploy/supervisor/polylan-submitter.env \ docker run --rm --env-file deploy/docker/polylan-submitter.env \
polylan-submitter python manage.py collectstatic --noinput polylan-submitter python manage.py collectstatic --noinput
``` ```
## Scaling ## Scaling
There's one `daphne` process per container (unlike the bare-metal deploy's There's one `daphne` process per container - it binds the only port the
`NUM_WEB_WORKERS` behind HAProxy) - scale by running multiple container container exposes, so running more of them would need a reverse proxy
replicas behind an external reverse proxy/load balancer instead. inside the container too. Scale by running multiple container replicas
behind an external reverse proxy/load balancer instead (each replica also
runs its own pair of `rqworker` processes, so worker capacity scales the
same way).
## Deploying to Proxmox ## Deploying to Proxmox
+4
View File
@@ -16,4 +16,8 @@ if [ "${MIGRATE_ON_START:-true}" = "true" ]; then
python manage.py migrate --noinput python manage.py migrate --noinput
fi fi
# Kick off the periodic Steam store refresh (games.tasks). No-op when a refresh
# is already queued, so every replica can safely run it on start.
python manage.py schedule_steam_refresh || echo "Could not schedule Steam store refresh"
exec "$@" exec "$@"
+44
View File
@@ -0,0 +1,44 @@
# Template for the --env-file passed to `docker run` (see
# deploy/docker/README.md). Copy this to deploy/docker/polylan-submitter.env,
# fill in real values, and keep it out of git (already in .gitignore) and
# world-readable perms (chmod 600) since it holds secrets.
#
# Docker reads this file itself (KEY=VALUE per line) before the container's
# Python even starts - no python-dotenv, no $VAR/${VAR} expansion, no shell
# involved. settings.py just reads the values from os.environ.
#
# Rotating a secret means editing this file and restarting the container
# (`docker restart polylan-submitter` or replacing it, depending on how
# you're running it) - values are read fresh on every process start.
#
# See settings.py for what each of these does and its dev-mode default.
DJANGO_SECRET_KEY=
DJANGO_DEBUG=false
DJANGO_ALLOWED_HOSTS=submitter.polylan.ch
DJANGO_CSRF_TRUSTED_ORIGINS=https://polylan-submitter.legrems.xyz
DJANGO_BEHIND_TLS_PROXY=true
POSTGRES_DB=
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_HOST=
POSTGRES_PORT=
REDIS_HOST=
REDIS_PORT=
PLACE_REDIS_URL=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_S3_ENDPOINT_URL=
AWS_STORAGE_BUCKET_NAME=
SENTRY_ENVIRONMENT=production
SENTRY_DSN=
STEAM_API_KEY=
STEAM_STORE_REFRESH_MINUTES=360
STEAM_STORE_COUNTRY=ch
AANDT_API_BASE_URL=
SUBMITTER_BASE_URL=
+3 -6
View File
@@ -1,10 +1,8 @@
; Runs inside the single app container as the non-root "app" user (see ; Runs inside the single app container as the non-root "app" user (see
; Dockerfile's USER app - no [supervisord] user= needed, we never start as ; Dockerfile's USER app - no [supervisord] user= needed, we never start as
; root here). Unlike deploy/supervisor/polylan-submitter.conf (multiple ; root here). This container runs ONE daphne process on TCP: scale by
; daphne processes behind HAProxy on one bare-metal host), this container ; running more container replicas behind an external reverse proxy/load
; runs ONE daphne process on TCP: scale by running more container replicas ; balancer instead of more processes per container.
; behind an external reverse proxy/load balancer instead of more processes
; per container.
; ;
; Program logs go to stdout/stderr (logfile_maxbytes=0 disables supervisor's ; Program logs go to stdout/stderr (logfile_maxbytes=0 disables supervisor's
; own log rotation for them) so `docker logs` / Proxmox's log viewer picks ; own log rotation for them) so `docker logs` / Proxmox's log viewer picks
@@ -29,7 +27,6 @@ stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
; RQ workers - same command as deploy/supervisor/polylan-submitter.conf.
[program:worker] [program:worker]
command=/app/.venv/bin/python manage.py rqworker default --job-class django_tasks_rq.Job --with-scheduler command=/app/.venv/bin/python manage.py rqworker default --job-class django_tasks_rq.Job --with-scheduler
directory=/app/polylan_submitter directory=/app/polylan_submitter
-47
View File
@@ -1,47 +0,0 @@
# HAProxy config for polylan-submitter. Plain HTTP only - TLS is terminated
# upstream of this box, so nothing here handles certs or :443.
#
# Unlike nginx/Caddy's per-site snippet directories, HAProxy's default
# packaging expects one single /etc/haproxy/haproxy.cfg for the whole
# process, so this file IS the full config - deploy.sh copies it wholesale
# rather than dropping a fragment into an "enabled" dir. If this box also
# runs other things through HAProxy, fold this frontend/backend into your
# existing haproxy.cfg by hand instead of letting deploy.sh clobber it.
#
# This is no longer a template: it's a plain, ready-to-use config with one
# statically defined backend server (web-00, the first daphne socket), so
# the backend is never empty right after a reload - traffic has somewhere to
# go immediately. deploy.sh adds the remaining NUM_WEB_WORKERS-1 servers over
# the admin socket below (`add server` + `enable server`) every time it
# reloads HAProxy, since a reload always resets the running process back to
# exactly what's in this file. Deploy sequence is always: cp this file ->
# reload -> re-add the rest of the servers.
#
# WebSocket (submissions/clock/place) needs no special handling here: modern
# HAProxy in `mode http` detects the 101 Switching Protocols response and then
# just relays bytes both ways for the rest of that connection - that only
# works because keep-alive is the default connection mode below (no
# `option http-server-close` / `httpclose`, either of which would break it).
# Static files are served in-process by WhiteNoise (see MIDDLEWARE in
# settings.py), so nothing else needs to be carved out here either.
global
log /dev/log local0
maxconn 2000
stats socket /run/haproxy/admin.sock mode 660 level admin
defaults
mode http
log global
option httplog
timeout connect 5s
timeout client 60s
timeout server 60s
frontend polylan_submitter_http
bind *:7777
default_backend polylan_submitter_web
backend polylan_submitter_web
balance roundrobin
server web-00 unix@/opt/polylan-submitter/run/web-00.sock check
-66
View File
@@ -1,66 +0,0 @@
# Supervisor config template for polylan-submitter.
#
# This is a template, not a ready-to-use file: __NUM_WEB_WORKERS__ is filled
# in by deploy.sh (the same number it also uses to re-add HAProxy backend
# servers after each reload - see deploy/haproxy/haproxy.cfg), which then
# installs the rendered result into /etc/supervisor/conf.d/ and
# reread/updates supervisor. Run deploy.sh rather than copying this file by
# hand.
#
# Assumes the app is checked out at /opt/polylan-submitter with a venv at
# /opt/polylan-submitter/.venv, and a "polylan" system user owning it
# (adduser --system --group polylan). Adjust to taste.
#
# App secrets/config (DJANGO_SECRET_KEY, POSTGRES_*, AWS_*, etc. - see
# settings.py for the full list, polylan-submitter.env.dist for the file
# template) are NOT listed in the `environment=` lines below - only
# SETTINGS_ENV_FILE_LOCATION is, pointing settings.py at the file so it can
# load_dotenv() it itself. That's the only var that needs to reach the
# process from here at all: it's just a path, not a secret, so unlike the
# ~19 real secrets it's safe to write directly into this tracked file, and
# there's no %(ENV_x)s crash risk since it's a literal string, not a
# pass-through. Rotating a secret just means editing the env file and
# running `supervisorctl restart polylan-submitter:` (which deploy.sh
# already does on every deploy) - dotenv is read fresh on every process
# start, no systemd/supervisord-wide restart involved.
[group:polylan-submitter]
programs=polylan-submitter-web,polylan-submitter-worker
; Daphne has no built-in multi-worker/--workers flag (single Twisted reactor
; per process), so "more workers" means more daphne processes. Each one binds
; its own unix socket under run/; HAProxy load-balances across all of them
; (see deploy/haproxy/haproxy.cfg). They share state fine since channel
; layers, sessions, cache, etc. all already go through Redis/Postgres rather
; than in-process memory. __NUM_WEB_WORKERS__ comes from deploy.sh's
; NUM_WEB_WORKERS - change it there, not here.
[program:polylan-submitter-web]
command=/opt/polylan-submitter/.venv/bin/daphne -u /opt/polylan-submitter/run/web-%(process_num)02d.sock polylan_submitter.asgi:application
directory=/opt/polylan-submitter/polylan_submitter
process_name=%(program_name)s-%(process_num)02d
user=polylan
numprocs=__NUM_WEB_WORKERS__
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopsignal=TERM
stdout_logfile=/var/log/supervisor/polylan-submitter-web-%(process_num)02d.log
stderr_logfile=/var/log/supervisor/polylan-submitter-web-%(process_num)02d.log
environment=DJANGO_SETTINGS_MODULE="polylan_submitter.settings",SETTINGS_ENV_FILE_LOCATION="/opt/polylan-submitter/deploy/supervisor/polylan-submitter.env"
; RQ workers can have multiple `--with-scheduler`, there is a redis-lock
[program:polylan-submitter-worker]
command=/opt/polylan-submitter/.venv/bin/python manage.py rqworker default --job-class django_tasks_rq.Job --with-scheduler
directory=/opt/polylan-submitter/polylan_submitter
process_name=%(program_name)s-%(process_num)02d
user=polylan
numprocs=2
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopsignal=TERM
stdout_logfile=/var/log/supervisor/polylan-submitter-worker-%(process_num)02d.log
stderr_logfile=/var/log/supervisor/polylan-submitter-worker-%(process_num)02d.log
environment=DJANGO_SETTINGS_MODULE="polylan_submitter.settings",SETTINGS_ENV_FILE_LOCATION="/opt/polylan-submitter/deploy/supervisor/polylan-submitter.env"
@@ -1,58 +0,0 @@
# Template for deploy/supervisor/polylan-submitter.env inside the app
# checkout (/opt/polylan-submitter/deploy/supervisor/polylan-submitter.env on
# the target host) - that path is in .gitignore, same treatment as
# settingsLocal.py, so `git pull` never touches or overwrites it once it's
# there.
#
# Copy this file to that path, fill in real values, then:
# sudo chown polylan:polylan /opt/polylan-submitter/deploy/supervisor/polylan-submitter.env
# sudo chmod 600 /opt/polylan-submitter/deploy/supervisor/polylan-submitter.env
# (owned by "polylan", not root: this gets read by settings.py itself, which
# runs as the "polylan" user - see `user=` in polylan-submitter.conf. 600
# still keeps every other user out.)
#
# settings.py loads this via python-dotenv (see SETTINGS_ENV_FILE_LOCATION
# in polylan-submitter.conf's `environment=`, and the load_dotenv() call
# near the top of settings.py), not a shell - there's no $VAR expansion or
# shell-special-character risk here like a bash `source` would have. The one
# thing dotenv DOES expand is ${VAR_NAME} (braced, referencing another var
# already defined above it in this same file) - if a real secret value ever
# happens to literally contain that exact pattern, quote it (single or
# double quotes both suppress dotenv's own expansion and get stripped).
# Plain values like the ones below need no quoting at all.
#
# load_dotenv() is called with override=False, so an already-exported env
# var always wins over what's in this file - it only fills gaps. It's also
# read fresh on every process start (not cached in some long-lived daemon),
# so rotating a secret just means editing this file and running
# `sudo supervisorctl restart polylan-submitter:` - which deploy.sh already
# does on every routine deploy anyway.
#
# See settings.py for what each of these does and its dev-mode default.
DJANGO_SECRET_KEY=
DJANGO_DEBUG=false
DJANGO_ALLOWED_HOSTS=submitter.polylan.ch
POSTGRES_DB=
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_HOST=
POSTGRES_PORT=
REDIS_HOST=
REDIS_PORT=
PLACE_REDIS_URL=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_S3_ENDPOINT_URL=
AWS_STORAGE_BUCKET_NAME=
SENTRY_ENVIRONMENT=production
SENTRY_DSN=
MARKET_ENABLED=true
STEAM_API_KEY=
AANDT_API_BASE_URL=
SUBMITTER_BASE_URL=
+125
View File
@@ -0,0 +1,125 @@
#!/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"
@@ -0,0 +1,24 @@
; 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
@@ -0,0 +1,39 @@
# 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
+44 -1
View File
@@ -1,10 +1,20 @@
from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
from django_object_actions import DjangoObjectActions, action
from .models import CustomUser from .models import CustomUser
# Must match loadtest/locustfile.py's own defaults (LOAD_TEST_USER_COUNT,
# LOAD_TEST_USER_PREFIX, LOAD_TEST_PASSWORD) - see that file for what these
# accounts are for and how they're used.
LOAD_TEST_USER_COUNT = 500
LOAD_TEST_USER_PREFIX = "loadtest"
LOAD_TEST_PASSWORD = "loadtest-only-password"
@admin.register(CustomUser) @admin.register(CustomUser)
class CustomUserAdmin(UserAdmin): class CustomUserAdmin(DjangoObjectActions, UserAdmin):
"""Admin interface for CustomUser.""" """Admin interface for CustomUser."""
# Add custom fields to the user admin # Add custom fields to the user admin
@@ -29,6 +39,8 @@ class CustomUserAdmin(UserAdmin):
# Make CAS fields readonly in admin # Make CAS fields readonly in admin
readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes") readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes")
changelist_actions = ("create_load_test_users",)
def get_cas_groups_display(self, obj): def get_cas_groups_display(self, obj):
"""Display CAS groups in admin list.""" """Display CAS groups in admin list."""
return obj.get_cas_groups_display() return obj.get_cas_groups_display()
@@ -37,3 +49,34 @@ class CustomUserAdmin(UserAdmin):
def has_delete_permission(self, request, obj=None): def has_delete_permission(self, request, obj=None):
return False return False
def get_changelist_actions(self, request):
# Second layer of the same guard rail as the login endpoint itself
# (see testing_auth_views.py): the button doesn't even show up
# unless this deployment opted into load testing.
if not settings.LOAD_TEST_LOGIN_ENABLED:
return []
return super().get_changelist_actions(request)
@action(
label="Create load test users",
description=(
"Create/reset the local loadtestN accounts used by "
"loadtest/locustfile.py - authenticates via ModelBackend, "
"never the real CAS server"
),
)
def create_load_test_users(self, request, _queryset):
created = 0
for i in range(LOAD_TEST_USER_COUNT):
username = f"{LOAD_TEST_USER_PREFIX}{i}"
user, was_created = CustomUser.objects.get_or_create(username=username)
user.set_password(LOAD_TEST_PASSWORD)
user.save()
created += was_created
self.message_user(
request,
f"{created} created, {LOAD_TEST_USER_COUNT - created} already existed "
f"(password reset on all {LOAD_TEST_USER_COUNT}).",
)
+20
View File
@@ -4,3 +4,23 @@ from django.apps import AppConfig
class AccountsConfig(AppConfig): class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "accounts" name = "accounts"
def ready(self):
# django-admin-interface's Theme.logo/favicon are plain FileFields,
# so without this they'd resolve through STORAGES["default"] (S3 -
# see settings.py) like every submission/media upload. The theme's
# logo/favicon (see accounts/fixtures/admin_interface_theme.json)
# are actually a static asset (static/favicon.ico, built from
# public/favicon.ico by Vite - see vite.config.ts), so point those
# two fields at STATIC_ROOT/STATIC_URL instead, served by WhiteNoise
# like any other static file.
from django.conf import settings
from django.core.files.storage import FileSystemStorage
from admin_interface.models import Theme
static_storage = FileSystemStorage(
location=settings.STATIC_ROOT, base_url=settings.STATIC_URL
)
Theme._meta.get_field("logo").storage = static_storage
Theme._meta.get_field("favicon").storage = static_storage
@@ -0,0 +1,60 @@
[
{
"model": "admin_interface.theme",
"pk": 2,
"fields": {
"name": "Django Purple (by Legrems)",
"active": true,
"title": "Super administrateur de poney ",
"title_color": "#E5B8FF",
"title_visible": true,
"logo": "favicon.ico",
"logo_color": "#831a96",
"logo_max_width": 400,
"logo_max_height": 50,
"logo_visible": true,
"favicon": "favicon.ico",
"env_name": "",
"env_color": "#831a96",
"env_visible_in_header": true,
"env_visible_in_favicon": true,
"language_chooser_active": true,
"language_chooser_control": "default-select",
"language_chooser_display": "code",
"css_header_background_color": "#2d0840",
"css_header_text_color": "#831a96",
"css_header_link_color": "#FFFFFF",
"css_header_link_hover_color": "#a82bb8",
"css_module_background_color": "#6b0f7d",
"css_module_background_selected_color": "#831a96",
"css_module_text_color": "#FFFFFF",
"css_module_link_color": "#FFFFFF",
"css_module_link_selected_color": "#FFFFFF",
"css_module_link_hover_color": "#a82bb8",
"css_module_rounded_corners": true,
"css_generic_link_color": "#2d0840",
"css_generic_link_hover_color": "#5a0d6b",
"css_save_button_background_color": "#2d0840",
"css_save_button_background_hover_color": "#5a0d6b",
"css_save_button_text_color": "#FFFFFF",
"css_delete_button_background_color": "#a82bb8",
"css_delete_button_background_hover_color": "#831a96",
"css_delete_button_text_color": "#FFFFFF",
"related_modal_active": true,
"related_modal_background_color": "#000000",
"related_modal_background_opacity": "0.3",
"related_modal_rounded_corners": true,
"related_modal_close_button_visible": true,
"list_filter_highlight": true,
"list_filter_dropdown": true,
"list_filter_sticky": true,
"list_filter_removal_links": false,
"foldable_apps": true,
"show_fieldsets_as_tabs": false,
"show_inlines_as_tabs": false,
"recent_actions_visible": true,
"form_submit_sticky": false,
"form_pagination_sticky": false
}
}
]
@@ -0,0 +1,21 @@
from django.core.management import call_command
from django.db import migrations
def load_admin_theme(apps, schema_editor):
# Seeds the default django-admin-interface theme (see
# accounts/fixtures/admin_interface_theme.json). Runs once, the first
# time this migration is applied, so it won't clobber a theme later
# edited by hand through the admin.
call_command("loaddata", "admin_interface_theme.json")
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_customuser_points"),
("admin_interface", "0032_alter_theme_defaults"),
]
operations = [
migrations.RunPython(load_admin_theme, migrations.RunPython.noop),
]
+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", "points", "rank") .values("id", "clock_points", "rank")
.order_by("-points", "id") .order_by("-clock_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["points"] if mine else 0.0, "points": mine["clock_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["points"] if above else None, "points_above": above["clock_points"] if above else None,
"points_below": below["points"] if below else None, "points_below": below["clock_points"] if below else None,
} }
+5 -3
View File
@@ -16,7 +16,9 @@ from .wire import encode, to_epoch_ms
class ClockConsumer(AsyncWebsocketConsumer): class ClockConsumer(AsyncWebsocketConsumer):
async def connect(self): async def connect(self):
self.event_path = self.scope["url_route"]["kwargs"]["event_path"] self.event_path = self.scope["url_route"]["kwargs"]["event_path"]
self.event = await self._get_enabled_event(self.event_path) self.event = await self._get_enabled_event(
self.event_path, self.scope.get("user")
)
if self.event is None: if self.event is None:
await self.close() await self.close()
return return
@@ -62,8 +64,8 @@ class ClockConsumer(AsyncWebsocketConsumer):
) )
@database_sync_to_async @database_sync_to_async
def _get_enabled_event(self, event_path): def _get_enabled_event(self, event_path, user):
event = Event.objects.filter(path=event_path).first() event = Event.objects.available_to(user).filter(path=event_path).first()
if event is None: if event is None:
return None return None
if not Game.objects.enabled_for_event(event).filter(path="clock").exists(): if not Game.objects.enabled_for_event(event).filter(path="clock").exists():
@@ -6,7 +6,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
+3 -1
View File
@@ -58,7 +58,9 @@ class ClockTimer(models.Model):
class Press(models.Model): class Press(models.Model):
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="clock_presses") event = models.ForeignKey(
Event, on_delete=models.CASCADE, related_name="clock_presses"
)
player = models.ForeignKey( player = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="clock_presses" settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="clock_presses"
) )
+4 -3
View File
@@ -22,7 +22,8 @@ def _press_points_expression():
def ranked_players(event: Event): def ranked_players(event: Event):
"""All users annotated with their live total points for this event and """All users annotated with their live total `clock_points` (not `points`,
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
@@ -32,6 +33,6 @@ def ranked_players(event: Event):
Value(0.0), Value(0.0),
output_field=FloatField(), output_field=FloatField(),
) )
return CustomUser.objects.annotate(points=points).annotate( return CustomUser.objects.annotate(clock_points=points).annotate(
rank=Window(expression=Rank(), order_by=F("points").desc()) rank=Window(expression=Rank(), order_by=F("clock_points").desc())
) )
+17 -7
View File
@@ -12,6 +12,7 @@ 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
@@ -30,13 +31,20 @@ 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. # single slow tick doesn't let a second chain slip in. The lock holds the
# 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(): def clock_heartbeat_task(chain_id: str):
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"
@@ -46,7 +54,8 @@ def clock_heartbeat_task():
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.
cache.delete(HEARTBEAT_LOCK_KEY) if cache.get(HEARTBEAT_LOCK_KEY) == chain_id:
cache.delete(HEARTBEAT_LOCK_KEY)
return return
channel_layer = get_channel_layer() channel_layer = get_channel_layer()
@@ -67,13 +76,14 @@ def clock_heartbeat_task():
}, },
) )
cache.set(HEARTBEAT_LOCK_KEY, 1, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS) cache.set(HEARTBEAT_LOCK_KEY, chain_id, 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() ).enqueue(chain_id)
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."""
if cache.add(HEARTBEAT_LOCK_KEY, 1, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS): chain_id = uuid4().hex
clock_heartbeat_task.enqueue() if cache.add(HEARTBEAT_LOCK_KEY, chain_id, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS):
clock_heartbeat_task.enqueue(chain_id)
+57
View File
@@ -0,0 +1,57 @@
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))
+3 -1
View File
@@ -5,7 +5,9 @@ from .models import Event
@admin.register(Event) @admin.register(Event)
class EventAdmin(admin.ModelAdmin): class EventAdmin(admin.ModelAdmin):
list_display = ["name", "path", "created_at"] list_display = ["name", "path", "is_available", "created_at"]
list_editable = ["is_available"]
list_filter = ["is_available"]
search_fields = ["name", "path"] search_fields = ["name", "path"]
prepopulated_fields = {"path": ("name",)} prepopulated_fields = {"path": ("name",)}
readonly_fields = ["created_at", "updated_at"] readonly_fields = ["created_at", "updated_at"]
+4 -1
View File
@@ -22,6 +22,9 @@ class EventContextMiddleware:
def process_view(self, request, view_func, view_args, view_kwargs): def process_view(self, request, view_func, view_args, view_kwargs):
event_path = view_kwargs.get("event_path") event_path = view_kwargs.get("event_path")
if event_path: if event_path:
request.event = get_object_or_404(Event, path=event_path) request.event = get_object_or_404(
Event.objects.available_to(getattr(request, "user", None)),
path=event_path,
)
current_event.set(request.event) current_event.set(request.event)
return None return None
@@ -0,0 +1,17 @@
# Generated by Django 6.0.5 on 2026-10-15 22:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("events", "0002_seed_polylan_43"),
]
operations = [
migrations.AddField(
model_name="event",
name="is_available",
field=models.BooleanField(default=True),
),
]
+12
View File
@@ -14,9 +14,21 @@ class BaseModel(models.Model):
abstract = True abstract = True
class EventQuerySet(models.QuerySet):
def available_to(self, user):
"""Unavailable events are hidden from everyone but staff, so they can
still be prepared before being opened up."""
if user is not None and user.is_staff:
return self
return self.filter(is_available=True)
class Event(BaseModel): class Event(BaseModel):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
path = models.SlugField(max_length=100, unique=True) path = models.SlugField(max_length=100, unique=True)
is_available = models.BooleanField(default=True)
objects = EventQuerySet.as_manager()
def __str__(self) -> str: def __str__(self) -> str:
return self.name return self.name
+40
View File
@@ -1,3 +1,5 @@
from datetime import timedelta
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.test import TestCase from django.test import TestCase
@@ -32,3 +34,41 @@ class EventScopedManagerTests(TestCase):
current_event.reset(token) current_event.reset(token)
self.assertEqual(results, [self.submission_a]) self.assertEqual(results, [self.submission_a])
class EventAvailabilityTests(TestCase):
def setUp(self):
from accounts.models import CustomUser
self.hidden = Event.objects.create(
name="Hidden", path="hidden", is_available=False
)
self.user = CustomUser.objects.create_user(username="user", password="x")
self.staff = CustomUser.objects.create_user(
username="staff", password="x", is_staff=True
)
def test_unavailable_event_is_404_for_regular_user(self):
self.client.force_login(self.user)
response = self.client.get(f"/{self.hidden.path}/")
self.assertEqual(response.status_code, 404)
def test_unavailable_event_is_visible_to_staff(self):
self.client.force_login(self.staff)
response = self.client.get(f"/{self.hidden.path}/")
self.assertEqual(response.status_code, 200)
def test_available_to_filters_for_regular_user_only(self):
self.assertNotIn(self.hidden, Event.objects.available_to(self.user))
self.assertIn(self.hidden, Event.objects.available_to(self.staff))
def test_home_prefers_available_event_for_staff(self):
available = Event.objects.create(name="Open", path="open")
Event.objects.filter(pk=self.hidden.pk).update(
created_at=available.created_at + timedelta(days=1)
)
self.client.force_login(self.staff)
response = self.client.get("/")
self.assertRedirects(
response, f"/{available.path}/", fetch_redirect_response=False
)
+71 -8
View File
@@ -5,9 +5,10 @@ from django_object_actions import (
takes_instance_or_queryset, takes_instance_or_queryset,
) )
from submissions.tasks import sync_event_game_scopes_task from submissions.tasks import schedule_scope_sync, stop_scope_sync
from .models import EventGame, Game from .models import EventGame, Game, PlanningSlot
from .tasks import refresh_steam_games_task
class EventGameInline(admin.TabularInline): class EventGameInline(admin.TabularInline):
@@ -19,8 +20,30 @@ class EventGameInline(admin.TabularInline):
class GameAdmin(admin.ModelAdmin): class GameAdmin(admin.ModelAdmin):
list_display = ["name", "steam_app_id", "updated_at"] list_display = ["name", "steam_app_id", "updated_at"]
search_fields = ["name", "steam_app_id"] search_fields = ["name", "steam_app_id"]
readonly_fields = ["created_at", "updated_at"] readonly_fields = [
"created_at",
"updated_at",
"steam_price_initial",
"steam_price_final",
"steam_discount_percent",
"steam_currency",
"steam_is_free",
"steam_short_description",
"steam_header_image",
"steam_info_updated_at",
]
inlines = [EventGameInline] inlines = [EventGameInline]
actions = ["refresh_steam_info"]
@admin.action(description="Refresh Steam store info")
def refresh_steam_info(self, request, queryset):
game_ids = list(
queryset.filter(steam_app_id__isnull=False).values_list("id", flat=True)
)
refresh_steam_games_task.enqueue(game_ids=game_ids)
self.message_user(
request, f"Queued Steam store refresh for {len(game_ids)} game(s)."
)
@admin.register(EventGame) @admin.register(EventGame)
@@ -28,13 +51,35 @@ 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 = ["sync_scopes"] actions = ["enable", "disable", "sync_scopes", "stop_scope_sync"]
change_actions = ["sync_scopes"] change_actions = ["sync_scopes", "stop_scope_sync"]
@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",
@@ -44,11 +89,12 @@ 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()
for event_game in tokened: started = sum(schedule_scope_sync(event_game.id) for event_game in tokened)
sync_event_game_scopes_task.enqueue(event_game.id)
self.message_user( self.message_user(
request, f"Queued scope sync for {tokened.count()} EventGame(s)." request,
f"Started scope sync for {started} EventGame(s)"
f" ({tokened.count() - started} already running).",
) )
if skipped: if skipped:
self.message_user( self.message_user(
@@ -56,3 +102,20 @@ 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"]
+19 -1
View File
@@ -3,7 +3,9 @@ from typing import List
from ninja import Router from ninja import Router
from .models import Game from .models import Game
from .schemas import GameOut from .schemas import GameOut, PlanningSlotOut, SteamGameOut
from .services.planning import planning_with_games
from .tasks import schedule_steam_refresh
router = Router() router = Router()
event_router = Router() event_router = Router()
@@ -17,3 +19,19 @@ def list_games(request):
@event_router.get("/events/{event_path}/games", response=List[GameOut]) @event_router.get("/events/{event_path}/games", response=List[GameOut])
def list_event_games(request, event_path: str): def list_event_games(request, event_path: str):
return Game.objects.enabled_for_event(request.event) return Game.objects.enabled_for_event(request.event)
@event_router.get("/events/{event_path}/games/steam", response=List[SteamGameOut])
def list_event_steam_games(request, event_path: str):
"""The event's Steam games with their store info (price, discount, blurb)."""
# Safety net in case the periodic refresh loop isn't running (no-op otherwise).
schedule_steam_refresh()
return Game.objects.enabled_for_event(request.event).filter(
steam_app_id__isnull=False
)
@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())
@@ -0,0 +1,64 @@
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}")
)
@@ -0,0 +1,13 @@
from django.core.management.base import BaseCommand
from games.tasks import schedule_steam_refresh
class Command(BaseCommand):
help = "Start the periodic Steam store refresh loop (no-op if already running)"
def handle(self, *args, **options):
if schedule_steam_refresh():
self.stdout.write(self.style.SUCCESS("Steam store refresh scheduled"))
else:
self.stdout.write("Steam store refresh already scheduled")
@@ -0,0 +1,17 @@
# Generated by Django 6.0.5 on 2026-10-15 22:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0008_game_custom_path"),
]
operations = [
migrations.AddField(
model_name="game",
name="description",
field=models.TextField(blank=True, default=""),
),
]
@@ -0,0 +1,52 @@
# Generated by Django 6.0.5 on 2026-10-15 22:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0009_game_description"),
]
operations = [
migrations.AddField(
model_name="game",
name="steam_currency",
field=models.CharField(blank=True, default="", max_length=3),
),
migrations.AddField(
model_name="game",
name="steam_discount_percent",
field=models.PositiveSmallIntegerField(default=0),
),
migrations.AddField(
model_name="game",
name="steam_header_image",
field=models.URLField(blank=True, default="", max_length=500),
),
migrations.AddField(
model_name="game",
name="steam_info_updated_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="game",
name="steam_is_free",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="game",
name="steam_price_final",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="game",
name="steam_price_initial",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="game",
name="steam_short_description",
field=models.TextField(blank=True, default=""),
),
]
@@ -0,0 +1,18 @@
# 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),
),
]
@@ -0,0 +1,53 @@
# 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"
)
],
},
),
]
+61
View File
@@ -14,6 +14,20 @@ class Game(models.Model):
path = models.CharField(max_length=100) path = models.CharField(max_length=100)
custom_path = models.BooleanField(default=False) custom_path = models.BooleanField(default=False)
image = models.URLField(null=True, blank=True) image = models.URLField(null=True, blank=True)
description = models.TextField(blank=True, default="")
# 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).
# Prices are in cents, in steam_currency.
steam_price_initial = models.PositiveIntegerField(null=True, blank=True)
steam_price_final = models.PositiveIntegerField(null=True, blank=True)
steam_discount_percent = models.PositiveSmallIntegerField(default=0)
steam_currency = models.CharField(max_length=3, blank=True, default="")
steam_is_free = models.BooleanField(default=False)
steam_short_description = models.TextField(blank=True, default="")
steam_header_image = models.URLField(max_length=500, blank=True, default="")
steam_info_updated_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -28,6 +42,19 @@ 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(
@@ -62,3 +89,37 @@ 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})"
+86
View File
@@ -1,3 +1,4 @@
from datetime import datetime
from typing import Optional from typing import Optional
from ninja import Schema from ninja import Schema
@@ -10,3 +11,88 @@ class GameOut(Schema):
path: str path: str
custom_path: bool custom_path: bool
image: Optional[str] = None image: Optional[str] = None
description: str
@staticmethod
def resolve_description(obj) -> str:
# The admin one wins, Steam games fall back to their store blurb
return obj.description or obj.steam_short_description
class SteamGameOut(Schema):
id: int
name: str
steam_app_id: int
store_url: str
header_image: str
library_image: str
short_description: str
is_free: bool
currency: str
price_initial: Optional[int] = None
price_final: Optional[int] = None
discount_percent: int
updated_at: Optional[datetime] = None
@staticmethod
def resolve_store_url(obj) -> str:
return obj.store_url
@staticmethod
def resolve_header_image(obj) -> str:
return (
obj.steam_header_image
or f"https://cdn.akamai.steamstatic.com/steam/apps/{obj.steam_app_id}/header.jpg"
)
@staticmethod
def resolve_library_image(obj) -> str:
return obj.library_image_url
@staticmethod
def resolve_short_description(obj) -> str:
return obj.steam_short_description
@staticmethod
def resolve_is_free(obj) -> bool:
return obj.steam_is_free
@staticmethod
def resolve_currency(obj) -> str:
return obj.steam_currency
@staticmethod
def resolve_price_initial(obj) -> Optional[int]:
return obj.steam_price_initial
@staticmethod
def resolve_price_final(obj) -> Optional[int]:
return obj.steam_price_final
@staticmethod
def resolve_discount_percent(obj) -> int:
return obj.steam_discount_percent
@staticmethod
def resolve_updated_at(obj) -> Optional[datetime]:
return obj.steam_info_updated_at
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]
@@ -0,0 +1,172 @@
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
@@ -0,0 +1,91 @@
import logging
import httpx
from django.conf import settings
from django.utils import timezone
from ..models import Game
logger = logging.getLogger(__name__)
# Public store endpoint, no API key needed. Rate limited to ~200 calls / 5 min,
# which is plenty for a handful of games every few hours.
APP_DETAILS_URL = "https://store.steampowered.com/api/appdetails"
STORE_SEARCH_URL = "https://store.steampowered.com/api/storesearch/"
class SteamStoreError(Exception):
"""Raised when the Steam store didn't return usable details for an app."""
def fetch_app_details(steam_app_id: int) -> dict:
response = httpx.get(
APP_DETAILS_URL,
params={
"appids": steam_app_id,
"cc": settings.STEAM_STORE_COUNTRY,
"l": "en",
"filters": "basic,price_overview",
},
timeout=10,
)
response.raise_for_status()
entry = (response.json() or {}).get(str(steam_app_id)) or {}
if not entry.get("success"):
raise SteamStoreError(f"No store details for app {steam_app_id}")
return entry["data"]
def 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:
"""Pull the current store info for a Steam game into its steam_* fields."""
data = fetch_app_details(game.steam_app_id)
price = data.get("price_overview") or {}
game.steam_is_free = bool(data.get("is_free"))
game.steam_price_initial = price.get("initial")
game.steam_price_final = price.get("final")
game.steam_discount_percent = price.get("discount_percent") or 0
game.steam_currency = price.get("currency") or ""
game.steam_short_description = data.get("short_description") or ""
game.steam_header_image = data.get("header_image") or ""
game.steam_info_updated_at = timezone.now()
game.save(
update_fields=[
"steam_is_free",
"steam_price_initial",
"steam_price_final",
"steam_discount_percent",
"steam_currency",
"steam_short_description",
"steam_header_image",
"steam_info_updated_at",
"updated_at",
]
)
def refresh_steam_games(game_ids: list[int] | None = None) -> None:
"""Refresh every Steam game (or just game_ids). A failing game keeps its
previous info and doesn't stop the others."""
games = Game.objects.filter(steam_app_id__isnull=False)
if game_ids is not None:
games = games.filter(id__in=game_ids)
for game in games:
try:
refresh_steam_game(game)
except (httpx.HTTPError, ValueError, SteamStoreError) as exc:
logger.warning("Steam store refresh failed for %s: %s", game, exc)
+61
View File
@@ -0,0 +1,61 @@
from datetime import timedelta
from uuid import uuid4
from django.conf import settings
from django.core.cache import cache
from django.tasks import task
from django.utils import timezone
from .services.steam_store import refresh_steam_games
# Set while a periodic refresh is queued, so there's only ever one loop running,
# whatever the number of workers/containers trying to start it. It expires on
# its own if the loop dies (worker killed mid-job, Redis queue flushed, ...),
# letting the next schedule_steam_refresh() start a new one. The guard holds the
# 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"
def steam_refresh_interval() -> timedelta:
return timedelta(minutes=settings.STEAM_STORE_REFRESH_MINUTES)
def _guard_timeout() -> int:
return int(steam_refresh_interval().total_seconds() * 2)
def schedule_steam_refresh() -> bool:
"""Start the periodic refresh loop right away, unless one is already queued.
Safe to call from anywhere, any number of times."""
loop_id = uuid4().hex
if not cache.add(STEAM_REFRESH_GUARD_KEY, loop_id, timeout=_guard_timeout()):
return False
refresh_steam_games_task.enqueue(loop_id=loop_id)
return True
@task
def refresh_steam_games_task(
game_ids: list[int] | None = None, loop_id: str | None = None
):
"""Refresh Steam store info, then re-enqueue itself every
STEAM_STORE_REFRESH_MINUTES (same self-rescheduling idea as
sync_event_game_scopes_task, no external scheduler needed).
Without loop_id it's 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
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:
refresh_steam_games(game_ids)
finally:
if loop_id is not None:
cache.set(STEAM_REFRESH_GUARD_KEY, loop_id, timeout=_guard_timeout())
refresh_steam_games_task.using(
run_after=timezone.now() + steam_refresh_interval()
).enqueue(loop_id=loop_id)
+172
View File
@@ -0,0 +1,172 @@
from unittest import mock
import httpx
from django.core.cache import cache
from django.test import TestCase, override_settings
from accounts.models import CustomUser
from events.models import Event
from .models import EventGame, Game
from .services.steam_store import refresh_steam_games
from .tasks import (
STEAM_REFRESH_GUARD_KEY,
refresh_steam_games_task,
schedule_steam_refresh,
)
LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
def steam_response(app_id, data, success=True):
return httpx.Response(
200,
json={str(app_id): {"success": success, "data": data}},
request=httpx.Request("GET", "https://store.steampowered.com"),
)
class RefreshSteamGamesTests(TestCase):
def setUp(self):
self.game = Game.objects.create(name="Noita", path="noita", steam_app_id=881100)
@mock.patch("games.services.steam_store.httpx.get")
def test_stores_price_and_discount(self, get):
get.return_value = steam_response(
881100,
{
"is_free": False,
"short_description": "Every pixel is simulated.",
"header_image": "https://example.com/header.jpg",
"price_overview": {
"currency": "CHF",
"initial": 1950,
"final": 780,
"discount_percent": 60,
},
},
)
refresh_steam_games()
self.game.refresh_from_db()
self.assertEqual(self.game.steam_price_initial, 1950)
self.assertEqual(self.game.steam_price_final, 780)
self.assertEqual(self.game.steam_discount_percent, 60)
self.assertEqual(self.game.steam_currency, "CHF")
self.assertEqual(self.game.steam_short_description, "Every pixel is simulated.")
self.assertIsNotNone(self.game.steam_info_updated_at)
@mock.patch("games.services.steam_store.httpx.get")
def test_free_game_has_no_price(self, get):
get.return_value = steam_response(881100, {"is_free": True})
refresh_steam_games()
self.game.refresh_from_db()
self.assertTrue(self.game.steam_is_free)
self.assertIsNone(self.game.steam_price_final)
@mock.patch("games.services.steam_store.httpx.get")
def test_failure_keeps_previous_info(self, get):
Game.objects.filter(pk=self.game.pk).update(steam_price_final=999)
get.side_effect = httpx.ConnectError("down")
refresh_steam_games()
self.game.refresh_from_db()
self.assertEqual(self.game.steam_price_final, 999)
@override_settings(CACHES=LOCMEM_CACHE)
class ScheduleSteamRefreshTests(TestCase):
def setUp(self):
cache.clear()
@mock.patch("games.tasks.refresh_steam_games_task")
def test_only_one_loop_is_scheduled(self, task):
self.assertTrue(schedule_steam_refresh())
self.assertFalse(schedule_steam_refresh())
task.enqueue.assert_called_once_with(loop_id=cache.get(STEAM_REFRESH_GUARD_KEY))
@mock.patch("games.tasks.refresh_steam_games")
def test_task_reschedules_itself_even_on_failure(self, refresh):
cache.set(STEAM_REFRESH_GUARD_KEY, "loop")
refresh.side_effect = RuntimeError("boom")
with mock.patch("games.tasks.refresh_steam_games_task") as patched:
with self.assertRaises(RuntimeError):
refresh_steam_games_task.call(loop_id="loop")
patched.using.return_value.enqueue.assert_called_once_with(loop_id="loop")
@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")
def test_one_off_run_does_not_reschedule(self, refresh):
with mock.patch("games.tasks.refresh_steam_games_task") as patched:
refresh_steam_games_task.call(game_ids=[1])
refresh.assert_called_once_with([1])
patched.using.assert_not_called()
@override_settings(CACHES=LOCMEM_CACHE)
class EventSteamGamesApiTests(TestCase):
def setUp(self):
self.event = Event.objects.create(name="Event", path="event")
noita = Game.objects.create(
name="Noita", path="noita", steam_app_id=881100, steam_price_final=780
)
clock = Game.objects.create(name="Clock", path="clock", custom_path=True)
EventGame.objects.create(event=self.event, game=noita)
EventGame.objects.create(event=self.event, game=clock)
user = CustomUser.objects.create_user(username="user", password="x")
self.client.force_login(user)
@mock.patch("games.api.schedule_steam_refresh")
def test_lists_only_steam_games_with_store_info(self, schedule):
response = self.client.get("/api/events/event/games/steam")
self.assertEqual(response.status_code, 200)
[game] = response.json()
self.assertEqual(game["name"], "Noita")
self.assertEqual(game["price_final"], 780)
self.assertEqual(game["store_url"], "https://store.steampowered.com/app/881100")
schedule.assert_called_once()
class EventGamesDescriptionTests(TestCase):
def setUp(self):
self.event = Event.objects.create(name="Event", path="event")
self.user = CustomUser.objects.create_user(username="user", password="x")
self.client.force_login(self.user)
def test_steam_description_is_fallback_for_admin_one(self):
steam = Game.objects.create(
name="Noita",
path="noita",
steam_app_id=881100,
steam_short_description="From Steam",
)
custom = Game.objects.create(
name="Opus",
path="opus",
steam_app_id=558990,
description="From admin",
steam_short_description="From Steam",
)
EventGame.objects.create(event=self.event, game=steam)
EventGame.objects.create(event=self.event, game=custom)
response = self.client.get("/api/events/event/games")
descriptions = {g["name"]: g["description"] for g in response.json()}
self.assertEqual(descriptions, {"Noita": "From Steam", "Opus": "From admin"})
+235
View File
@@ -0,0 +1,235 @@
"""Locust load test for the place game.
Simulates real players: log in, fetch the canvas, open the same websocket
the frontend uses, and place a pixel as soon as the per-user cooldown
allows - while a background greenlet keeps draining the broadcast stream so
each idle connection costs what a real one costs.
A placement that comes back rejected or errors out (timeout, dropped
connection, ...) has a configurable chance of triggering a "reload" -
tearing the socket down and redoing the page load (HTML + canvas fetch +
fresh websocket), the way a real, mildly frustrated player mashes refresh
instead of quietly waiting. That reconnect-storm behavior - many clients
reloading around the same time - is itself part of what's worth measuring
under load, not just steady-state placement throughput.
Authentication uses local Django accounts (ModelBackend), never the real
CAS server at polylan.ch - see testing_auth_views.py. This ONLY works
against a target where LOAD_TEST_LOGIN_ENABLED=true; never point it at
production.
Setup, on the target server:
export LOAD_TEST_LOGIN_ENABLED=true
Then, in the Django admin, open Accounts > Users and click "Create load
test users" (see accounts/admin.py) - it's only visible with the env
var above set. Creates/resets loadtest0..loadtest499.
Run:
uv run locust -f loadtest/locustfile.py --host http://localhost:7777
Env vars:
PLACE_EVENT_PATH event slug under test (default: "loadtest")
LOAD_TEST_USER_COUNT must match accounts/admin.py's LOAD_TEST_USER_COUNT (default: 500)
LOAD_TEST_USER_PREFIX must match accounts/admin.py's LOAD_TEST_USER_PREFIX (default: "loadtest")
LOAD_TEST_PASSWORD must match accounts/admin.py's LOAD_TEST_PASSWORD (default: "loadtest-only-password")
RELOAD_ON_FAILURE_PROBABILITY chance a failed placement reloads instead
of just waiting for the next cooldown
(default: 0.7)
"""
import os
import random
import struct
import time
import gevent
import websocket
from gevent.queue import Empty, Queue
from locust import HttpUser, between, events, task
EVENT_PATH = os.environ.get("PLACE_EVENT_PATH", "loadtest")
USER_COUNT = int(os.environ.get("LOAD_TEST_USER_COUNT", "500"))
USER_PREFIX = os.environ.get("LOAD_TEST_USER_PREFIX", "loadtest")
PASSWORD = os.environ.get("LOAD_TEST_PASSWORD", "loadtest-only-password")
RELOAD_ON_FAILURE_PROBABILITY = float(
os.environ.get("RELOAD_ON_FAILURE_PROBABILITY", "0.7")
)
# Mirrors place/models.py::PALETTE - kept as a plain constant here since
# loadtest runs standalone, without Django settings loaded.
PALETTE_SIZE = 16
CANVAS_HEADER = struct.Struct("<HH")
_next_account_index = 0
def _next_username() -> str:
"""Round-robins through the pool of pre-created accounts so N Locust
users spread evenly across USER_COUNT real accounts even when N differs
from USER_COUNT."""
global _next_account_index
username = f"{USER_PREFIX}{_next_account_index % USER_COUNT}"
_next_account_index += 1
return username
class PlaceUser(HttpUser):
wait_time = between(1, 3)
def on_start(self):
self.username = _next_username()
self._login()
self.width, self.height = self._fetch_canvas_size()
self._connect()
def on_stop(self):
self._teardown_ws()
def _login(self):
response = self.client.post(
"/testing/login/",
data={"username": self.username, "password": PASSWORD},
name="/testing/login/",
)
if response.status_code != 200:
raise RuntimeError(
f"login failed for {self.username}: {response.status_code} "
f"{response.text}. Is LOAD_TEST_LOGIN_ENABLED set on the "
"target, and did you run the 'Create load test users' admin "
"action (accounts/admin.py)?"
)
def _fetch_canvas_size(self) -> tuple[int, int]:
response = self.client.get(
f"/api/events/{EVENT_PATH}/place/canvas",
name="/api/events/[event]/place/canvas",
)
width, height = CANVAS_HEADER.unpack_from(response.content, 0)
return width, height
def _connect(self):
"""(Re)opens the websocket and a fresh reader greenlet, then syncs
next_placement_at to the server's own cooldown clock - every
connection starts with an 's|<next>|...' message (see
place/consumers.py::_initial_state) - rather than trusting local
bookkeeping. That sync is exactly what makes a page reload
meaningful to simulate: it re-anchors client state to the server's
truth instead of just reopening a blank socket."""
self.ws = self._open_ws()
self.next_placement_at = self._read_initial_next_placement_at()
self.reply_queue = Queue()
self._reader = gevent.spawn(self._reader_loop)
def _teardown_ws(self):
reader = getattr(self, "_reader", None)
if reader is not None:
reader.kill(block=False)
ws = getattr(self, "ws", None)
if ws is not None:
try:
ws.close()
except Exception:
pass
def _reload(self):
"""What a real player does after a placement fails: hit refresh.
Tears the socket down and redoes the page load over HTTP plus a
brand-new websocket - the same reconnect churn a real outage or
rejection wave would cause."""
self._teardown_ws()
self.client.get(f"/{EVENT_PATH}/place", name="/[event]/place (reload)")
self.width, self.height = self._fetch_canvas_size()
self._connect()
def _open_ws(self):
scheme = "wss" if self.client.base_url.startswith("https") else "ws"
host = self.client.base_url.split("://", 1)[1].rstrip("/")
url = f"{scheme}://{host}/ws/place/{EVENT_PATH}/"
cookie_header = "; ".join(
f"{name}={value}" for name, value in self.client.cookies.get_dict().items()
)
start = time.perf_counter()
exc = None
ws = None
try:
ws = websocket.create_connection(
url, header=[f"Cookie: {cookie_header}"], timeout=10
)
except Exception as e:
exc = e
events.request.fire(
request_type="WS",
name="ws_connect",
response_time=(time.perf_counter() - start) * 1000,
response_length=0,
exception=exc,
)
if exc is not None:
raise exc
return ws
def _read_initial_next_placement_at(self) -> float:
message = self.ws.recv()
next_ms = message.split("|")[1]
return int(next_ms) / 1000 if next_ms else 0.0
def _reader_loop(self):
"""The only greenlet that ever calls ws.recv() - place_pixel below
only ever writes, and reads back via reply_queue, so there's no race
between two greenlets reading the same socket. Broadcast pixels
from other players (and 'e|...' resize messages) are read and
dropped here, same as a real idle viewer."""
while True:
try:
message = self.ws.recv()
except Exception:
return
if not message:
return
if message[0] in ("k", "r"):
self.reply_queue.put(message)
@task
def place_pixel(self):
now = time.time()
if now < self.next_placement_at:
return # still on cooldown - a real client wouldn't send either
x = random.randrange(self.width)
y = random.randrange(self.height)
color = random.randrange(PALETTE_SIZE)
message = f"p|{x}|{y}|{color}"
start = time.perf_counter()
exc = None
reply = None
try:
self.ws.send(message)
reply = self.reply_queue.get(timeout=10)
except (Empty, Exception) as e:
exc = e
events.request.fire(
request_type="WS",
name="place_pixel",
response_time=(time.perf_counter() - start) * 1000,
response_length=len(reply) if reply else 0,
exception=exc,
)
accepted = reply is not None and reply.startswith("k|")
# Both an accepted placement (k|<next>) and a cooldown rejection
# (r|c|<next>) carry the server's authoritative cooldown end - sync
# to it either way, for the same reason _connect syncs off 's|...':
# it keeps us honest against clock drift and against several
# simulated users sharing one round-robined account, instead of
# hammering the server with attempts it will just keep rejecting.
if reply is not None and reply.startswith(("k|", "r|c|")):
next_ms = reply.rsplit("|", 1)[1]
self.next_placement_at = int(next_ms) / 1000
if not accepted and random.random() < RELOAD_ON_FAILURE_PROBABILITY:
self._reload()
@@ -0,0 +1,203 @@
"""Locust load test for the market (betting) page.
Simulates real bettors: log in, load the market page the way the frontend
does (user info, markets, own bets, own event points, then one bet-history
fetch per market since the charts are open by default), then keep placing
or increasing bets, refreshing the page, and occasionally reloading it.
Bets follow the same rules as the real UI: one option per market, the
amount sent is the new total (so an increase re-sends the same option with
a bigger amount), and a user never bets more than their event points. A
400 the server is expected to give under load ("Insufficient points",
"Market is not open for betting", ...) counts as a success - it's the
business rule doing its job, not the server failing.
Authentication uses local Django accounts (ModelBackend), never the real
CAS server at polylan.ch - see testing_auth_views.py. This ONLY works
against a target where LOAD_TEST_LOGIN_ENABLED=true; never point it at
production.
Setup, on the target server:
export LOAD_TEST_LOGIN_ENABLED=true
In the Django admin:
- Accounts > Users > "Create load test users" (see accounts/admin.py)
creates/resets loadtest0..loadtest499.
- Market > Markets: create a few markets with status "Open" (and a few
options each) on the event under test. Without open markets the users
only browse.
Run:
uv run locust -f loadtest/market_locustfile.py --host http://localhost:7777
Env vars:
MARKET_EVENT_PATH event slug under test (default: "loadtest")
LOAD_TEST_USER_COUNT must match accounts/admin.py's LOAD_TEST_USER_COUNT (default: 500)
LOAD_TEST_USER_PREFIX must match accounts/admin.py's LOAD_TEST_USER_PREFIX (default: "loadtest")
LOAD_TEST_PASSWORD must match accounts/admin.py's LOAD_TEST_PASSWORD (default: "loadtest-only-password")
MIN_BET_INCREMENT smallest amount added per bet (default: 10)
MAX_BET_INCREMENT largest amount added per bet (default: 50)
"""
import os
import random
from locust import HttpUser, between, task
EVENT_PATH = os.environ.get("MARKET_EVENT_PATH", "loadtest")
USER_COUNT = int(os.environ.get("LOAD_TEST_USER_COUNT", "500"))
USER_PREFIX = os.environ.get("LOAD_TEST_USER_PREFIX", "loadtest")
PASSWORD = os.environ.get("LOAD_TEST_PASSWORD", "loadtest-only-password")
MIN_BET_INCREMENT = int(os.environ.get("MIN_BET_INCREMENT", "10"))
MAX_BET_INCREMENT = int(os.environ.get("MAX_BET_INCREMENT", "50"))
API = f"/api/events/{EVENT_PATH}/market"
# 400s the market API returns on purpose - concurrent bettors racing for
# the same points, or a market closed mid-test. Anything else is a failure.
EXPECTED_BET_REJECTIONS = (
"Insufficient points",
"Market is not open",
"only bet on one option",
"Cannot decrease bet",
)
_next_account_index = 0
def _next_username() -> str:
"""Round-robins through the pool of pre-created accounts (same scheme as
locustfile.py - kept separate so running this file doesn't also spawn
PlaceUser)."""
global _next_account_index
username = f"{USER_PREFIX}{_next_account_index % USER_COUNT}"
_next_account_index += 1
return username
class MarketUser(HttpUser):
wait_time = between(2, 6)
def on_start(self):
self.username = _next_username()
self.markets: list[dict] = []
# market uuid -> {"option": option uuid, "amount": total}
self.bets: dict[str, dict] = {}
self.points = 0
self._login()
self._load_page()
def _login(self):
response = self.client.post(
"/testing/login/",
data={"username": self.username, "password": PASSWORD},
name="/testing/login/",
)
if response.status_code != 200:
raise RuntimeError(
f"login failed for {self.username}: {response.status_code} "
f"{response.text}. Is LOAD_TEST_LOGIN_ENABLED set on the "
"target, and did you run the 'Create load test users' admin "
"action (accounts/admin.py)?"
)
def _load_page(self):
"""Full page load: HTML, then what useMarketStore.initializeMarketPage
fetches, then one history fetch per card (charts are shown by
default)."""
self.client.get(f"/{EVENT_PATH}/market", name="/[event]/market")
self.client.get("/api/user", name="/api/user")
self._fetch_markets()
self._fetch_bets()
self._fetch_points()
for market in self.markets:
self._fetch_history(market)
def _fetch_markets(self) -> dict[str, int]:
"""Returns each market's total pot, to tell which charts refetch."""
response = self.client.get(f"{API}/", name="/api/events/[event]/market/")
if response.ok:
self.markets = response.json()
return {
market["uuid"]: sum(opt["total_bets"] for opt in market["options"])
for market in self.markets
}
def _fetch_bets(self):
response = self.client.get(
f"{API}/user/bets", name="/api/events/[event]/market/user/bets"
)
if response.ok:
self.bets = {
bet["market"]["uuid"]: {
"option": bet["option"]["uuid"],
"amount": bet["amount"],
}
for bet in response.json()
if bet.get("market")
}
def _fetch_points(self):
response = self.client.get(
f"{API}/user/points", name="/api/events/[event]/market/user/points"
)
if response.ok:
self.points = response.json()["points"]
def _fetch_history(self, market: dict):
self.client.get(
f"{API}/{market['uuid']}/history",
name="/api/events/[event]/market/[market]/history",
)
@task(5)
def place_bet(self):
open_markets = [
m for m in self.markets if m["status"] == "open" and m["options"]
]
if not open_markets or self.points < MIN_BET_INCREMENT:
return
market = random.choice(open_markets)
existing = self.bets.get(market["uuid"])
option = (
existing["option"] if existing else random.choice(market["options"])["uuid"]
)
increment = random.randint(
MIN_BET_INCREMENT, min(MAX_BET_INCREMENT, self.points)
)
amount = (existing["amount"] if existing else 0) + increment
with self.client.post(
f"{API}/{market['uuid']}/bets",
json={"option_uuid": option, "amount": amount},
name="/api/events/[event]/market/[market]/bets",
catch_response=True,
) as response:
if response.status_code == 400 and any(
reason in response.text for reason in EXPECTED_BET_REJECTIONS
):
response.success()
# MarketCard.placeBet reloads bets + points after every attempt
self._fetch_bets()
self._fetch_points()
@task(3)
def refresh(self):
"""useMarketStore.refreshPage, plus the history refetch the charts do
whenever a market's pot changed."""
previous_pots = {
market["uuid"]: sum(opt["total_bets"] for opt in market["options"])
for market in self.markets
}
self.client.get("/api/user", name="/api/user")
pots = self._fetch_markets()
self._fetch_bets()
self._fetch_points()
for market in self.markets:
if pots.get(market["uuid"]) != previous_pots.get(market["uuid"]):
self._fetch_history(market)
@task(1)
def reload(self):
self._load_page()
+3 -3
View File
@@ -15,8 +15,8 @@ class MarketOptionInline(admin.TabularInline):
@admin.register(Market) @admin.register(Market)
class MarketAdmin(DjangoObjectActions, admin.ModelAdmin): class MarketAdmin(DjangoObjectActions, admin.ModelAdmin):
list_display = ["title", "status", "end_date", "created_by", "created_at"] list_display = ["title", "status", "event", "end_date", "created_by", "created_at"]
list_filter = ["status", "created_at"] list_filter = ["status", "created_at", "event"]
search_fields = ["uuid", "title"] search_fields = ["uuid", "title"]
readonly_fields = [ readonly_fields = [
"uuid", "uuid",
@@ -27,7 +27,7 @@ class MarketAdmin(DjangoObjectActions, admin.ModelAdmin):
] ]
inlines = [MarketOptionInline] inlines = [MarketOptionInline]
fieldsets = ( fieldsets = (
("Info", {"fields": ["uuid", "title", "description"]}), ("Info", {"fields": ["uuid", "title", "description", "event"]}),
("Configuration", {"fields": ["end_date", "multiplier"]}), ("Configuration", {"fields": ["end_date", "multiplier"]}),
("Status", {"fields": ["status", "winning_option"]}), ("Status", {"fields": ["status", "winning_option"]}),
("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}), ("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}),
+63 -18
View File
@@ -1,17 +1,27 @@
from typing import List from typing import List
from ninja import Router from ninja import Router
from ninja.decorators import decorate_view
from ninja.errors import HttpError from ninja.errors import HttpError
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.db.models import Sum, Prefetch from django.db.models import Sum, Prefetch
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.db import transaction from django.db import transaction
from market.models import Market, MarketOption, UserBet, UserPointChange from games.decorators import require_game_enabled
from market.models import (
Market,
MarketOption,
UserBet,
UserPointChange,
get_event_points,
)
from market.schemas import ( from market.schemas import (
BetHistoryPointSchema,
MarketListSchema, MarketListSchema,
ResolveMarketSchema, ResolveMarketSchema,
UserBetCreateSchema, UserBetCreateSchema,
UserBetSchema, UserBetSchema,
UserPointsSchema,
) )
@@ -21,6 +31,7 @@ PREFIX = "/events/{event_path}/market"
@router.get(f"{PREFIX}/", response=List[MarketListSchema]) @router.get(f"{PREFIX}/", response=List[MarketListSchema])
@decorate_view(require_game_enabled(path="market"))
def list_markets(request, event_path: str): def list_markets(request, event_path: str):
"""List all markets (excludes draft markets).""" """List all markets (excludes draft markets)."""
markets = Market.for_event.exclude(status=Market.Status.DRAFT) markets = Market.for_event.exclude(status=Market.Status.DRAFT)
@@ -33,6 +44,7 @@ def list_markets(request, event_path: str):
@router.get(f"{PREFIX}/user/bets", response=List[UserBetSchema]) @router.get(f"{PREFIX}/user/bets", response=List[UserBetSchema])
@decorate_view(require_game_enabled(path="market"))
def list_user_bets(request, event_path: str): def list_user_bets(request, event_path: str):
"""List all bets placed by the current user.""" """List all bets placed by the current user."""
if not request.user.is_authenticated: if not request.user.is_authenticated:
@@ -45,7 +57,49 @@ def list_user_bets(request, event_path: str):
) )
@router.get(f"{PREFIX}/user/points", response=UserPointsSchema)
@decorate_view(require_game_enabled(path="market"))
def get_user_points(request, event_path: str):
"""Get the current user's points balance for this event."""
if not request.user.is_authenticated:
raise HttpError(401, "Authentication required")
return {"points": get_event_points(request.user, request.event)}
@router.get(f"{PREFIX}/{{market_uuid}}/history", response=List[BetHistoryPointSchema])
@decorate_view(require_game_enabled(path="market"))
def bet_history(request, event_path: str, market_uuid: str):
"""Points added to each option over time (anonymous, one entry per bet placed/increased)."""
market = get_object_or_404(
Market.objects.exclude(status=Market.Status.DRAFT),
uuid=market_uuid,
event=request.event,
)
# A user can only bet on one option per market, so their option is unique
option_by_user = dict(
UserBet.objects.filter(option__market=market).values_list(
"user_id", "option__uuid"
)
)
changes = UserPointChange.objects.filter(
market=market, reason=UserPointChange.Reason.BET_PLACED
).order_by("created_at")
return [
{
"option_uuid": str(option_by_user[change.user_id]),
"created_at": change.created_at,
"amount": -change.amount,
}
for change in changes
if change.user_id in option_by_user
]
@router.post(f"{PREFIX}/{{market_uuid}}/actions/close") @router.post(f"{PREFIX}/{{market_uuid}}/actions/close")
@decorate_view(require_game_enabled(path="market"))
def close_market(request, event_path: str, market_uuid: str): def close_market(request, event_path: str, market_uuid: str):
"""Close a market. Admin only.""" """Close a market. Admin only."""
if not request.user.is_staff: if not request.user.is_staff:
@@ -58,6 +112,7 @@ def close_market(request, event_path: str, market_uuid: str):
@router.post(f"{PREFIX}/{{market_uuid}}/actions/resolve", response=MarketListSchema) @router.post(f"{PREFIX}/{{market_uuid}}/actions/resolve", response=MarketListSchema)
@decorate_view(require_game_enabled(path="market"))
def resolve_market( def resolve_market(
request, event_path: str, market_uuid: str, payload: ResolveMarketSchema request, event_path: str, market_uuid: str, payload: ResolveMarketSchema
): ):
@@ -76,9 +131,7 @@ def resolve_market(
market.save(update_fields=["winning_option", "status", "updated_at"]) market.save(update_fields=["winning_option", "status", "updated_at"])
# Calculate and distribute winnings # Calculate and distribute winnings
all_bets = list( all_bets = list(UserBet.objects.filter(option__market=market))
UserBet.objects.filter(option__market=market).select_related("user")
)
# Calculate total pot # Calculate total pot
total_pot = sum(bet.amount for bet in all_bets) total_pot = sum(bet.amount for bet in all_bets)
@@ -92,7 +145,6 @@ def resolve_market(
total_winning = sum(bet.amount for bet in winning_bets) total_winning = sum(bet.amount for bet in winning_bets)
point_changes = [] point_changes = []
users_to_update = []
with transaction.atomic(): with transaction.atomic():
# Award payouts to winners with multiplier # Award payouts to winners with multiplier
@@ -101,11 +153,9 @@ def resolve_market(
payout = round( payout = round(
bet.amount / total_winning * total_pot * market.multiplier bet.amount / total_winning * total_pot * market.multiplier
) )
bet.user.points += payout
users_to_update.append(bet.user)
point_changes.append( point_changes.append(
UserPointChange( UserPointChange(
user=bet.user, user_id=bet.user_id,
market=market, market=market,
amount=payout, amount=payout,
reason=UserPointChange.Reason.BET_WON, reason=UserPointChange.Reason.BET_WON,
@@ -116,17 +166,13 @@ def resolve_market(
for bet in losing_bets: for bet in losing_bets:
point_changes.append( point_changes.append(
UserPointChange( UserPointChange(
user=bet.user, user_id=bet.user_id,
market=market, market=market,
amount=-bet.amount, amount=-bet.amount,
reason=UserPointChange.Reason.BET_LOST, reason=UserPointChange.Reason.BET_LOST,
) )
) )
# Bulk update users
for user in users_to_update:
user.save(update_fields=["points"])
# Bulk create point changes # Bulk create point changes
UserPointChange.objects.bulk_create(point_changes) UserPointChange.objects.bulk_create(point_changes)
@@ -134,6 +180,7 @@ def resolve_market(
@router.post(f"{PREFIX}/{{market_uuid}}/bets", response=UserBetSchema) @router.post(f"{PREFIX}/{{market_uuid}}/bets", response=UserBetSchema)
@decorate_view(require_game_enabled(path="market"))
def create_bet( def create_bet(
request, event_path: str, market_uuid: str, payload: UserBetCreateSchema request, event_path: str, market_uuid: str, payload: UserBetCreateSchema
): ):
@@ -167,8 +214,8 @@ def create_bet(
# Calculate delta (amount to deduct from user's points) # Calculate delta (amount to deduct from user's points)
delta = payload.amount - (existing_bet.amount if existing_bet else 0) delta = payload.amount - (existing_bet.amount if existing_bet else 0)
# Check if user has enough points # Check if user has enough points for this event
if request.user.points < delta: if get_event_points(request.user, request.event) < delta:
raise HttpError(400, "Insufficient points for this bet") raise HttpError(400, "Insufficient points for this bet")
user_bet, created = UserBet.objects.update_or_create( user_bet, created = UserBet.objects.update_or_create(
@@ -177,9 +224,7 @@ def create_bet(
defaults={"amount": payload.amount}, defaults={"amount": payload.amount},
) )
# Deduct points and record the change # Record the change (the event balance is derived from these)
request.user.points -= delta
request.user.save(update_fields=["points"])
UserPointChange.objects.create( UserPointChange.objects.create(
user=request.user, user=request.user,
market=market, market=market,
@@ -0,0 +1,40 @@
from django.db import migrations
MARKET_PATH = "market"
def seed_market_game(apps, schema_editor):
"""The market used to be toggled globally (MARKET_ENABLED); it's now a
custom Game like clock/place, enabled per event through EventGame. Enable
it for every existing event so nothing disappears on deploy."""
Event = apps.get_model("events", "Event")
Game = apps.get_model("games", "Game")
EventGame = apps.get_model("games", "EventGame")
game, _ = Game.objects.get_or_create(
path=MARKET_PATH,
custom_path=True,
defaults={
"name": "Market",
"description": "Place your bets and compete",
},
)
for event in Event.objects.all():
EventGame.objects.get_or_create(event=event, game=game)
def unseed_market_game(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game.objects.filter(path=MARKET_PATH, custom_path=True).delete()
class Migration(migrations.Migration):
dependencies = [
("events", "0003_event_is_available"),
("games", "0009_game_description"),
("market", "0009_event_required"),
]
operations = [
migrations.RunPython(seed_market_game, reverse_code=unseed_market_game),
]
+18
View File
@@ -1,10 +1,28 @@
import uuid import uuid
from django.db import models from django.db import models
from django.db.models import Sum
from events.models import Event from events.models import Event
from events.scoping import EventScopedManager from events.scoping import EventScopedManager
# Each event's market starts every user with this balance
STARTING_POINTS = 1000
def get_event_points(user, event) -> int:
"""User's market balance for an event: starting points + the event's point changes.
BET_LOST rows are informational (the stake was already deducted by BET_PLACED).
"""
total = (
UserPointChange.objects.filter(user=user, market__event=event)
.exclude(reason=UserPointChange.Reason.BET_LOST)
.aggregate(total=Sum("amount"))["total"]
)
return STARTING_POINTS + (total or 0)
class BaseModel(models.Model): class BaseModel(models.Model):
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
+10
View File
@@ -35,6 +35,16 @@ class ResolveMarketSchema(Schema):
winning_option_uuid: str winning_option_uuid: str
class UserPointsSchema(Schema):
points: int
class BetHistoryPointSchema(Schema):
option_uuid: str
created_at: datetime
amount: int
class UserBetCreateSchema(Schema): class UserBetCreateSchema(Schema):
option_uuid: str option_uuid: str
amount: int amount: int
+23 -1
View File
@@ -1 +1,23 @@
# Create your tests here. from django.test import TestCase
from accounts.models import CustomUser
from events.models import Event
from games.models import EventGame, Game
class MarketGameGatingTests(TestCase):
def setUp(self):
self.event = Event.objects.create(name="Event", path="event")
self.game = Game.objects.get(path="market", custom_path=True)
self.user = CustomUser.objects.create_user(username="user", password="x")
self.client.force_login(self.user)
def test_market_is_403_when_not_enabled_for_event(self):
response = self.client.get(f"/api/events/{self.event.path}/market/")
self.assertEqual(response.status_code, 403)
def test_market_is_reachable_when_enabled_for_event(self):
EventGame.objects.create(event=self.event, game=self.game)
response = self.client.get(f"/api/events/{self.event.path}/market/")
self.assertEqual(response.status_code, 200)
self.assertEqual(self.client.get(f"/{self.event.path}/market").status_code, 200)
+2
View File
@@ -3,9 +3,11 @@ from django.shortcuts import get_object_or_404, render
from django.http import HttpRequest from django.http import HttpRequest
from events.models import Event from events.models import Event
from games.decorators import require_game_enabled
@login_required @login_required
@require_game_enabled(path="market")
def market_home(request: HttpRequest, event_path: str): def market_home(request: HttpRequest, event_path: str):
get_object_or_404(Event, path=event_path) get_object_or_404(Event, path=event_path)
return render(request, "app.html") return render(request, "app.html")
+3 -1
View File
@@ -239,7 +239,9 @@ def get_leaderboard(request: HttpRequest, event_path: str):
return data return data
@router.post(f"{PREFIX}/submit", response={200: NoitaSubmissionOut, 400: dict}) @router.post(
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,4 +3,5 @@ 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'],
}); });
+10 -1
View File
@@ -95,7 +95,16 @@ 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")}),
( (
+89 -10
View File
@@ -1,3 +1,5 @@
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
@@ -5,6 +7,8 @@ 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
@@ -25,6 +29,7 @@ from .schemas import (
SubmissionOut, SubmissionOut,
PuzzleResponseOut, PuzzleResponseOut,
ValidationIn, ValidationIn,
SubmissionFileOut,
SteamCollectionItemOut, SteamCollectionItemOut,
SteamCollectionOut, SteamCollectionOut,
) )
@@ -73,7 +78,9 @@ def get_submission(request, event_path: str, submission_id: str):
) )
@router.post(f"{PREFIX}/submissions", response=SubmissionOut) @router.post(
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(...)
@@ -120,13 +127,17 @@ 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=data.manual_validation_requested manual_validation_requested=needs_validation or auto_request_validation,
or auto_request_validation,
) )
file_index = 0 file_index = 0
@@ -146,7 +157,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=data.manual_validation_requested, needs_manual_validation=needs_validation,
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,
@@ -156,7 +167,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 data.manual_validation_requested if not needs_validation
else {}, else {},
) )
@@ -211,7 +222,10 @@ def create_submission(
return 500, {"detail": f"Error creating submission: {str(e)}"} return 500, {"detail": f"Error creating submission: {str(e)}"}
@router.put(f"{PREFIX}/responses/{{response_id}}/validate", response=PuzzleResponseOut) @router.put(
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"""
@@ -252,7 +266,8 @@ def validate_response(request, event_path: str, response_id: int, data: Validati
@router.put( @router.put(
f"{PREFIX}/responses/{{response_id}}/validate/auto", response=PuzzleResponseOut f"{PREFIX}/responses/{{response_id}}/validate/auto",
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):
@@ -271,7 +286,10 @@ def validate_auto(request, event_path: str, response_id: int):
return response return response
@router.get(f"{PREFIX}/responses/needs-validation", response=List[PuzzleResponseOut]) @router.get(
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"""
@@ -289,7 +307,28 @@ def list_responses_needing_validation(request, event_path: str):
) )
@router.post(f"{PREFIX}/submissions/{{submission_id}}/validate", response=SubmissionOut) @router.get(
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"""
@@ -318,7 +357,9 @@ def validate_submission(request, event_path: str, submission_id: str):
return submission return submission
@router.delete(f"{PREFIX}/submissions/{{submission_id}}") @router.delete(
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)"""
@@ -358,3 +399,41 @@ 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,6 +4,7 @@ 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
@@ -12,6 +13,12 @@ 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",
@@ -22,6 +29,11 @@ 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()
@@ -53,7 +65,7 @@ class Command(BaseCommand):
return return
# Fetch and create/update collection # Fetch and create/update collection
collection, created = create_or_update_collection(url) collection, created = create_or_update_collection(url, event)
if created: if created:
self.stdout.write( self.stdout.write(
@@ -0,0 +1,33 @@
# 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),
]
@@ -0,0 +1,34 @@
# 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(blank=True, help_text="Cost value from OCR") cost = models.IntegerField(null=True, blank=True, help_text="Cost value from OCR")
cycles = models.IntegerField(blank=True, help_text="Cycles value from OCR") cycles = models.IntegerField(null=True, blank=True, help_text="Cycles value from OCR")
area = models.IntegerField(blank=True, help_text="Area value from OCR") area = models.IntegerField(null=True, blank=True, help_text="Area value from OCR")
# Validation flags # Validation flags
needs_manual_validation = models.BooleanField( needs_manual_validation = models.BooleanField(
+74 -1
View File
@@ -1 +1,74 @@
# Create your tests here. from django.test import TestCase
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",
],
)
+6 -1
View File
@@ -4,6 +4,7 @@ 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
@@ -373,12 +374,15 @@ def fetch_steam_collection(url: str) -> Dict:
return fetcher.fetch_collection_data(url) return fetcher.fetch_collection_data(url)
def create_or_update_collection(url: str) -> Tuple[SteamCollection, bool]: def create_or_update_collection(
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)
@@ -395,6 +399,7 @@ def create_or_update_collection(url: str) -> Tuple[SteamCollection, bool]:
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,6 +12,9 @@
}, },
"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",
+4 -3
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") list_display = ("event", "width", "height", "placement_ends_at")
list_editable = ("width", "height") list_editable = ("width", "height", "placement_ends_at")
list_filter = ("event",) list_filter = ("event",)
change_actions = ("resync_canvas",) change_actions = ("resync_canvas",)
@@ -19,7 +19,8 @@ class PlaceSettingsAdmin(DjangoObjectActions, admin.ModelAdmin):
def resync_canvas(self, request, obj): def resync_canvas(self, request, obj):
count = rebuild_canvas_from_history(obj.event, obj.width, obj.height) count = rebuild_canvas_from_history(obj.event, obj.width, obj.height)
self.message_user( self.message_user(
request, f"Canvas resynced from {count} placement(s) at {obj.width}x{obj.height}." request,
f"Canvas resynced from {count} placement(s) at {obj.width}x{obj.height}.",
) )
+118 -6
View File
@@ -1,5 +1,7 @@
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
@@ -8,8 +10,17 @@ 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 .models import PlaceSettings from .heatmap import (
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()
@@ -25,15 +36,116 @@ 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.""" single websocket message, never a re-fetch. Once placement is over the
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)
pixels = read_canvas(event_path, 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)
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): def evolution_gif(request: HttpRequest, event_path: str, players: 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
+6 -4
View File
@@ -22,7 +22,7 @@ _client = redis.Redis.from_url(settings.PLACE_REDIS_URL)
def _canvas_key(event_path: str) -> str: def _canvas_key(event_path: str) -> str:
return f"place:canvas:{event_path}" return f"place:{event_path}:canvas"
def read_canvas(event_path: str, width: int, height: int) -> bytes: def read_canvas(event_path: str, width: int, height: int) -> bytes:
@@ -49,9 +49,11 @@ def rebuild_canvas_from_history(event, width: int, height: int) -> int:
""" """
grid = bytearray(width * height) grid = bytearray(width * height)
count = 0 count = 0
placements = PixelPlacement.objects.filter(event=event).order_by( placements = (
"placed_at" PixelPlacement.objects.filter(event=event)
).values_list("x", "y", "color_index") .order_by("placed_at")
.values_list("x", "y", "color_index")
)
for x, y, color_index in placements.iterator(): for x, y, color_index in placements.iterator():
if x >= width or y >= height: if x >= width or y >= height:
continue continue
+31 -6
View File
@@ -13,10 +13,16 @@ 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"]
self.event = await self._get_enabled_event(self.event_path) self.event = await self._get_enabled_event(
self.event_path, self.scope.get("user")
)
if self.event is None: if self.event is None:
await self.close() await self.close()
return return
@@ -43,6 +49,10 @@ 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"))
@@ -65,7 +75,9 @@ class PlaceConsumer(AsyncWebsocketConsumer):
await self.send(text_data=encode("r", "i")) await self.send(text_data=encode("r", "i"))
return return
result = await self._record_placement(user, x, y, color_index, place_settings.width) result = await self._record_placement(
user, x, y, color_index, place_settings.width
)
if result["rejected"]: if result["rejected"]:
await self.send(text_data=encode("r", "c", result["next"])) await self.send(text_data=encode("r", "c", result["next"]))
@@ -78,8 +90,8 @@ class PlaceConsumer(AsyncWebsocketConsumer):
) )
@database_sync_to_async @database_sync_to_async
def _get_enabled_event(self, event_path): def _get_enabled_event(self, event_path, user):
event = Event.objects.filter(path=event_path).first() event = Event.objects.available_to(user).filter(path=event_path).first()
if event is None: if event is None:
return None return None
if not Game.objects.enabled_for_event(event).filter(path="place").exists(): if not Game.objects.enabled_for_event(event).filter(path="place").exists():
@@ -110,7 +122,9 @@ class PlaceConsumer(AsyncWebsocketConsumer):
return { return {
"rejected": False, "rejected": False,
"next": to_epoch_ms(placement.placed_at + timedelta(seconds=PLACE_COOLDOWN_SECONDS)), "next": to_epoch_ms(
placement.placed_at + timedelta(seconds=PLACE_COOLDOWN_SECONDS)
),
} }
@database_sync_to_async @database_sync_to_async
@@ -136,6 +150,7 @@ 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
@@ -146,4 +161,14 @@ 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(text_data=encode("e", event["width"], event["height"])) await self.send(
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
@@ -0,0 +1,31 @@
"""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()
+10 -5
View File
@@ -25,20 +25,25 @@ _PALETTE_RGB = [
] ]
def build_evolution_gif(event: Event) -> bytes: def build_evolution_gif(event: Event, usernames: list[str] | None = None) -> 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).order_by( placements = PixelPlacement.objects.filter(event=event)
"placed_at" if usernames:
).values_list("x", "y", "color_index") placements = placements.filter(player__username__in=usernames)
placements = list(placements) placements = list(
placements.order_by("placed_at").values_list("x", "y", "color_index")
)
grid = bytearray(width * height) grid = bytearray(width * height)
frames: list[Image.Image] = [] frames: list[Image.Image] = []
+240
View File
@@ -0,0 +1,240 @@
"""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
@@ -6,7 +6,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
@@ -0,0 +1,18 @@
# 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),
),
]
@@ -0,0 +1,21 @@
# 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,5 +1,6 @@
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
@@ -47,10 +48,22 @@ 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
@@ -0,0 +1,48 @@
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]
+11 -2
View File
@@ -1,13 +1,14 @@
"""Reacts to an admin editing a PlaceSettings row (grid size) by: """Reacts to an admin editing a PlaceSettings row (grid size, end time) 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 size to that event's already-connected clients. Unlike 2. Pushing the new settings 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
@@ -18,11 +19,14 @@ 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:
@@ -33,5 +37,10 @@ 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
@@ -0,0 +1,97 @@
"""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
@@ -0,0 +1,65 @@
"""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
@@ -0,0 +1,210 @@
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()
+14 -6
View File
@@ -6,8 +6,10 @@ 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> initial personal state, once per connection s|<next>|<width>|<height>|<end>
(next = epoch ms cooldown ends, or empty if free now) initial personal state, once per connection
(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
@@ -15,10 +17,16 @@ 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
e|<width>|<height> an admin resized the grid for this event - r|d rejected: placement is over (past <end>)
broadcast to everyone. A client that sees this e|<width>|<height>|<end>
should re-fetch /api/.../place/canvas rather than an admin edited this event's settings (grid size
try to remap its local pixels. and/or end time) - broadcast to everyone.
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,6 +11,15 @@ 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))
@@ -515,10 +524,32 @@ 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'}
@@ -961,6 +992,9 @@ 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==}
@@ -1072,6 +1106,17 @@ 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'}
@@ -1445,8 +1490,31 @@ 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
@@ -1863,6 +1931,8 @@ 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: {}
@@ -1962,6 +2032,10 @@ 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
+4 -18
View File
@@ -1,5 +1,4 @@
from ninja import NinjaAPI, Schema from ninja import NinjaAPI
from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.http import HttpRequest from django.http import HttpRequest
from opus_magnum.api import router as opus_magnum_router from opus_magnum.api import router as opus_magnum_router
@@ -54,31 +53,18 @@ api.add_router("", games_event_router, tags=["games"])
api.add_router("", submissions_router, tags=["submissions"]) api.add_router("", submissions_router, tags=["submissions"])
api.add_router("", clock_router, tags=["clock"]) api.add_router("", clock_router, tags=["clock"])
api.add_router("", place_router, tags=["place"]) api.add_router("", place_router, tags=["place"])
api.add_router("", market_router, tags=["market"])
if settings.MARKET_ENABLED:
api.add_router("", market_router, tags=["market"])
# Health check endpoint # Health check endpoint
@api.get("/health") @api.get("/health", response=dict)
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"}
class ConfigOut(Schema):
market_enabled: bool
# Site-wide config the SPA needs but that isn't tied to any one event
# (e.g. whether to render the market card at all on the home page).
@api.get("/config", response=ConfigOut)
def get_config(request):
return {"market_enabled": settings.MARKET_ENABLED}
# Cache management endpoint # Cache management endpoint
@api.post("/cache/clear") @api.post("/cache/clear", response={200: dict, 403: dict})
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:
+5 -3
View File
@@ -17,9 +17,11 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings")
django_asgi_app = get_asgi_application() django_asgi_app = get_asgi_application()
from submissions.routing import websocket_urlpatterns as submissions_websocket_urlpatterns from submissions.routing import ( # noqa: E402
from clock.routing import websocket_urlpatterns as clock_websocket_urlpatterns websocket_urlpatterns as submissions_websocket_urlpatterns,
from place.routing import websocket_urlpatterns as place_websocket_urlpatterns )
from clock.routing import websocket_urlpatterns as clock_websocket_urlpatterns # noqa: E402
from place.routing import websocket_urlpatterns as place_websocket_urlpatterns # noqa: E402
websocket_urlpatterns = [ websocket_urlpatterns = [
*submissions_websocket_urlpatterns, *submissions_websocket_urlpatterns,
+73 -23
View File
@@ -19,12 +19,12 @@ from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
# Optional: point this at a KEY=value file (see # Optional: point this at a KEY=value file (see
# deploy/supervisor/polylan-submitter.env.dist for the template, and # deploy/docker/polylan-submitter.env.dist for the template - unused in the
# deploy/supervisor/polylan-submitter.conf for how it's wired up in # Docker deploy itself, which passes env vars directly via `docker run
# production) to fill in the env vars read throughout this file. override # --env-file` instead) to fill in the env vars read throughout this file.
# =False so an already-exported env var always wins over the file - this # override=False so an already-exported env var always wins over the file -
# only fills gaps, same as every os.environ.get() below already treating a # this only fills gaps, same as every os.environ.get() below already
# merely-blank value as "use my own default" rather than "". # treating a merely-blank value as "use my own default" rather than "".
if os.environ.get("SETTINGS_ENV_FILE_LOCATION"): if os.environ.get("SETTINGS_ENV_FILE_LOCATION"):
load_dotenv(os.environ["SETTINGS_ENV_FILE_LOCATION"], override=False) load_dotenv(os.environ["SETTINGS_ENV_FILE_LOCATION"], override=False)
@@ -58,16 +58,45 @@ DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true"
ALLOWED_HOSTS = [ ALLOWED_HOSTS = [
host host
for host in (os.environ.get("DJANGO_ALLOWED_HOSTS") or "127.0.0.1,localhost").split(",") for host in (os.environ.get("DJANGO_ALLOWED_HOSTS") or "127.0.0.1,localhost").split(
","
)
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
INSTALLED_APPS = [ INSTALLED_APPS = [
"daphne", "daphne",
"django.contrib.admin", "admin_interface",
"colorfield",
# "palette" provides the admin/base.html override (the command-palette
# UI) and must precede "palette.apps.PaletteAdminConfig", which stands
# in for "django.contrib.admin" (same app name, custom AdminSite) to
# wire the palette into the admin.
"palette.apps.PaletteConfig",
"palette.apps.PaletteAdminConfig",
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.sessions",
@@ -207,9 +236,27 @@ AUTH_USER_MODEL = "accounts.CustomUser"
# Simple CAS Configuration # Simple CAS Configuration
CAS_SERVER_URL = "https://polylan.ch/cas/" CAS_SERVER_URL = "https://polylan.ch/cas/"
# Enables POST /testing/login/ (see testing_auth_views.py), a
# username/password login for local-only accounts that authenticate through
# ModelBackend instead of SimpleCASBackend. This exists so load testing (see
# loadtest/locustfile.py) never sends traffic to the real CAS server at
# polylan.ch. Leave this off everywhere except a dedicated load-test
# environment - the "Create load test users" admin action on CustomUser
# (see accounts/admin.py) also hides itself without it, as a second layer
# of the same guard rail.
LOAD_TEST_LOGIN_ENABLED = (
os.environ.get("LOAD_TEST_LOGIN_ENABLED", "false").lower() == "true"
)
# Steam API Configuration # Steam API Configuration
STEAM_API_KEY = os.environ.get("STEAM_API_KEY") or None # Set via environment variable STEAM_API_KEY = os.environ.get("STEAM_API_KEY") or None # Set via environment variable
# Steam store info (price, discount, blurb) for the event's Steam games, refreshed
# in the background every STEAM_STORE_REFRESH_MINUTES (see games.tasks)
STEAM_STORE_REFRESH_MINUTES = int(os.environ.get("STEAM_STORE_REFRESH_MINUTES") or 360)
# Store region the prices are fetched for (ISO country code, drives the currency)
STEAM_STORE_COUNTRY = os.environ.get("STEAM_STORE_COUNTRY") or "ch"
# Base URL of the legacy PolyLAN site, used to sync AAndT matches/members # Base URL of the legacy PolyLAN site, used to sync AAndT matches/members
# (see submissions.services.aandt_sync) # (see submissions.services.aandt_sync)
AANDT_API_BASE_URL = os.environ.get("AANDT_API_BASE_URL") or "https://polylan.ch" AANDT_API_BASE_URL = os.environ.get("AANDT_API_BASE_URL") or "https://polylan.ch"
@@ -256,7 +303,9 @@ CHANNEL_LAYERS = {
# Redis for place's live pixel canvas (SETRANGE on the hot path - not a fit # Redis for place's live pixel canvas (SETRANGE on the hot path - not a fit
# for django-redis's cache API, so it gets its own raw redis-py client). # for django-redis's cache API, so it gets its own raw redis-py client).
PLACE_REDIS_URL = os.environ.get("PLACE_REDIS_URL") or f"redis://{REDIS_HOST}:{REDIS_PORT}/15" PLACE_REDIS_URL = (
os.environ.get("PLACE_REDIS_URL") or f"redis://{REDIS_HOST}:{REDIS_PORT}/15"
)
# Django Tasks Configuration (with RQ backend) # Django Tasks Configuration (with RQ backend)
TASKS = { TASKS = {
@@ -284,9 +333,6 @@ ALLOWED_SUBMISSION_TYPES = [
"video/webm", "video/webm",
] ]
# Market app settings
MARKET_ENABLED = (os.environ.get("MARKET_ENABLED") or "true").lower() == "true"
# Authentication backends # Authentication backends
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
@@ -301,6 +347,7 @@ LOGOUT_REDIRECT_URL = "/"
DJANGO_VITE = { DJANGO_VITE = {
"default": { "default": {
"dev_mode": DEBUG, "dev_mode": DEBUG,
"dev_server_port": 5273,
} }
} }
STATIC_URL = "/static/" STATIC_URL = "/static/"
@@ -314,7 +361,9 @@ STATICFILES_DIRS = [
# that, so these must be set (via env) wherever the app runs, dev included. # that, so these must be set (via env) wherever the app runs, dev included.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "") AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "") AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL") or "https://storage.polylan.ch" AWS_S3_ENDPOINT_URL = (
os.environ.get("AWS_S3_ENDPOINT_URL") or "https://storage.polylan.ch"
)
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_STORAGE_BUCKET_NAME") or "dev-submissions" AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_STORAGE_BUCKET_NAME") or "dev-submissions"
_s3_storage_options = { _s3_storage_options = {
@@ -329,21 +378,22 @@ STORAGES = {
"BACKEND": "storages.backends.s3.S3Storage", "BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": _s3_storage_options, "OPTIONS": _s3_storage_options,
}, },
# Static files are collected to local disk and served by WhiteNoise in # Static files are always collected to local disk and served by
# DEBUG (no need to push every CSS/JS tweak to S3 while developing); # WhiteNoise (see WhiteNoiseMiddleware in MIDDLEWARE above, which is
# production collects straight to S3 like everything else here. # likewise unconditional) - S3 is only for user-uploaded content
# ("default", above), never for the app's own CSS/JS/images.
"staticfiles": { "staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage" "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
if DEBUG
else "storages.backends.s3.S3Storage",
**({} if DEBUG else {"OPTIONS": _s3_storage_options}),
}, },
} }
SENTRY_ENVIRONMENT = os.environ.get("SENTRY_ENVIRONMENT") or ("development" if DEBUG else "production") SENTRY_ENVIRONMENT = os.environ.get("SENTRY_ENVIRONMENT") or (
"development" if DEBUG else "production"
)
SENTRY_DSN = ( SENTRY_DSN = (
os.environ.get("SENTRY_DSN") or "https://cc62a4ce3f3470890b43accf02cc6d8c@sentry2.polylan.ch/12" os.environ.get("SENTRY_DSN")
or "https://cc62a4ce3f3470890b43accf02cc6d8c@sentry2.polylan.ch/12"
) )
import sentry_sdk # noqa import sentry_sdk # noqa
@@ -352,6 +402,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=1.0, traces_sample_rate=0.1,
environment=SENTRY_ENVIRONMENT, environment=SENTRY_ENVIRONMENT,
) )
+26 -1
View File
@@ -23,6 +23,7 @@ from django.contrib.auth.decorators import login_required
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
from testing_auth_views import TestLoginView
from events.models import Event from events.models import Event
from games.decorators import require_game_enabled from games.decorators import require_game_enabled
from market.views import market_home from market.views import market_home
@@ -35,7 +36,9 @@ OPUS_MAGNUM_APP_ID = 558990
@login_required @login_required
def home(request: HttpRequest): def home(request: HttpRequest):
event = Event.objects.order_by("-created_at").first() # Staff land on an available event too; hidden ones are only a fallback.
events = Event.objects.available_to(request.user)
event = events.order_by("-is_available", "-created_at").first()
if event is None: if event is None:
raise Http404("No event configured") raise Http404("No event configured")
return redirect("event.home", event_path=event.path) return redirect("event.home", event_path=event.path)
@@ -47,6 +50,18 @@ def event_home(request: HttpRequest, event_path: str):
return render(request, "app.html") return render(request, "app.html")
@login_required
def games_home(request: HttpRequest, event_path: str):
get_object_or_404(Event, path=event_path)
return render(request, "app.html")
@login_required
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):
@@ -92,6 +107,8 @@ urlpatterns = [
path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"), path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"),
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"), path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
path("api/", api.urls), path("api/", api.urls),
path("<slug:event_path>/games", games_home, name="games.home"),
path("<slug:event_path>/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"),
@@ -112,6 +129,14 @@ urlpatterns = [
path("", home, name="home"), path("", home, name="home"),
] ]
# Load-test-only username/password login (see testing_auth_views.py) - the
# URL itself doesn't exist unless explicitly enabled, so it 404s rather than
# 403s in every environment that hasn't opted in.
if settings.LOAD_TEST_LOGIN_ENABLED:
urlpatterns.append(
path("testing/login/", TestLoginView.as_view(), name="testing.login")
)
# Serve media files in development # Serve media files in development
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+1 -7
View File
@@ -1,20 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from "vue"; import { computed } 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>
+2 -4
View File
@@ -3,6 +3,7 @@ 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;
@@ -25,6 +26,7 @@ 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);
@@ -38,10 +40,6 @@ 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>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { useI18n } from "vue-i18n";
import { useStorage, useTitle } from "@vueuse/core";
import { gamesApiListEventSteamGames } from "./api";
import type { SteamGameOut } from "./api/types.gen";
import SteamLibraryCard from "./components/SteamLibraryCard.vue";
import SteamStoreCard from "./components/SteamStoreCard.vue";
interface Props {
eventPath: string;
}
const props = defineProps<Props>();
const { t } = useI18n();
useTitle(computed(() => t("games.title")));
const games = ref<SteamGameOut[]>([]);
const loading = ref(true);
// Library (vertical covers) by default, the store cards show the prices
const showPrices = useStorage("games.showPrices", true);
onMounted(async () => {
const response = await gamesApiListEventSteamGames({
path: { event_path: props.eventPath },
});
if (response.data) {
games.value = response.data;
}
loading.value = false;
});
</script>
<template>
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
<div class="w-full max-w-7xl">
<!-- Header -->
<div class="text-center mb-8">
<h1 class="text-5xl font-bold mb-4">{{ t("games.title") }}</h1>
<p class="text-xl text-base-content/70">{{ t("games.subtitle") }}</p>
</div>
<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 -->
<div v-if="loading" class="flex justify-center py-20">
<span class="loading loading-spinner loading-lg"></span>
</div>
<p v-else-if="!games.length" class="text-center text-base-content/50 py-20">
{{ t("games.empty") }}
</p>
<div v-else-if="showPrices" class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<SteamStoreCard v-for="game in games" :key="game.id" :game="game" />
</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>
</template>
+21 -42
View File
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from "vue"; import { ref, computed } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import { gamesApiListEventGames, polylanSubmitterApiGetConfig } from "./api"; import { useQuery } from "@tanstack/vue-query";
import { eventGamesQuery, eventPlanningQuery } from "@/queries";
import type { GamesApiListEventGamesResponse } from "./api/types.gen"; import type { GamesApiListEventGamesResponse } from "./api/types.gen";
interface Props { interface Props {
@@ -16,10 +17,11 @@ const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
useTitle(computed(() => t("home.title"))); useTitle(computed(() => t("home.title")));
const games = ref<GamesApiListEventGamesResponse | undefined>(); const { data: games, isPending: loading } = useQuery(eventGamesQuery(props.eventPath));
const loading = ref(true); 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 marketEnabled = ref(false);
const getHeaderImage = (game: GamesApiListEventGamesResponse[number]) => { const getHeaderImage = (game: GamesApiListEventGamesResponse[number]) => {
if (game.image) { if (game.image) {
@@ -35,33 +37,16 @@ const onImageError = (gameId: number) => {
imageErrors.value.add(gameId); imageErrors.value.add(gameId);
}; };
const goToMarket = () => {
router.push(`/${props.eventPath}/market`);
};
const goToGame = (game: GamesApiListEventGamesResponse[number]) => { const goToGame = (game: GamesApiListEventGamesResponse[number]) => {
const path = game.custom_path ? `/${game.path}` : `/submissions/${game.path}`; const path = game.custom_path ? `/${game.path}` : `/submissions/${game.path}`;
router.push(`/${props.eventPath}${path}`); router.push(`/${props.eventPath}${path}`);
}; };
onMounted(async () => {
const [gamesResponse, configResponse] = await Promise.all([
gamesApiListEventGames({ path: { event_path: props.eventPath } }),
polylanSubmitterApiGetConfig(),
]);
if (gamesResponse.data) {
games.value = gamesResponse.data;
}
if (configResponse.data) {
marketEnabled.value = configResponse.data.market_enabled;
}
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-6xl"> <div class="w-full max-w-7xl">
<!-- 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>
@@ -77,25 +62,6 @@ onMounted(async () => {
<!-- Cards Grid --> <!-- Cards Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8"> <div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Market Card -->
<div v-if="marketEnabled" @click="goToMarket"
class="card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden">
<figure class="relative h-60 bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center">
<i class="mdi mdi-chart-box text-6xl text-white opacity-80"></i>
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"></div>
</figure>
<div class="card-body">
<h2 class="card-title text-2xl">{{ t("home.marketCard.title") }}</h2>
<p class="text-base-content/70">{{ t("home.marketCard.description") }}</p>
<div class="card-actions justify-end mt-4">
<button class="btn btn-primary">
<i class="mdi mdi-arrow-right mr-2"></i>
{{ t("home.marketCard.cta") }}
</button>
</div>
</div>
</div>
<!-- Game Cards --> <!-- Game Cards -->
<div v-for="game in games" :key="game.id" <div v-for="game in games" :key="game.id"
@click="goToGame(game)" @click="goToGame(game)"
@@ -111,6 +77,7 @@ onMounted(async () => {
</figure> </figure>
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-2xl">{{ game.name }}</h2> <h2 class="card-title text-2xl">{{ game.name }}</h2>
<p v-if="game.description" class="text-sm text-base-content/70 line-clamp-2" :title="game.description">{{ game.description }}</p>
<div class="card-actions justify-end mt-4"> <div class="card-actions justify-end mt-4">
<button class="btn btn-primary"> <button class="btn btn-primary">
<i class="mdi mdi-arrow-right mr-2"></i> <i class="mdi mdi-arrow-right mr-2"></i>
@@ -121,6 +88,18 @@ onMounted(async () => {
</div> </div>
</div> </div>
<!-- Links to the planning and the Steam games page -->
<div v-if="!loading && (hasPlanning || hasSteamGames)" class="flex flex-wrap justify-center gap-4 mt-12">
<RouterLink v-if="hasPlanning" :to="{ name: 'planning', 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>
{{ t("home.getTheGames") }}
</RouterLink>
</div>
<!-- Footer --> <!-- Footer -->
<div class="text-center mt-12 text-base-content/50"> <div class="text-center mt-12 text-base-content/50">
<p>{{ t("home.footer") }}</p> <p>{{ t("home.footer") }}</p>
+2 -2
View File
@@ -19,7 +19,7 @@ const { t } = useI18n();
useTitle(computed(() => t("market.pageTitle"))); useTitle(computed(() => t("market.pageTitle")));
const marketStore = useMarketStore(); const marketStore = useMarketStore();
const { markets, userInfo, isLoading } = storeToRefs(marketStore); const { markets, userInfo, userPoints, isLoading } = storeToRefs(marketStore);
const reloadPage = async () => { const reloadPage = async () => {
await marketStore.refreshPage(props.eventPath); await marketStore.refreshPage(props.eventPath);
@@ -56,7 +56,7 @@ onMounted(() => {
{{ t("market.myBets") }} {{ t("market.myBets") }}
</h3> </h3>
<div class="text-lg font-semibold"> <div class="text-lg font-semibold">
<span class="text-primary">{{ userInfo.points }}</span> <span class="text-primary">{{ userPoints }}</span>
<span class="text-base-content/60 ml-1">{{ t("common.pts") }}</span> <span class="text-base-content/60 ml-1">{{ t("common.pts") }}</span>
</div> </div>
</div> </div>
+3 -8
View File
@@ -9,10 +9,11 @@ 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 { polylanSubmitterApiGetUserInfo, opusMagnumApiGetCollection } from "@/api"; import { 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";
@@ -74,13 +75,7 @@ async function initialize() {
// Load user info // Load user info
console.log("Loading user info..."); console.log("Loading user info...");
const userResponse = await polylanSubmitterApiGetUserInfo(); userInfo.value = (await queryClient.fetchQuery(userQuery()).catch(() => null)) as UserInfo | null;
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...");
+45 -20
View File
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue"; import { computed, onBeforeUnmount, 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;
@@ -39,20 +41,39 @@ 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); } = usePlaceSocket(props.eventPath, drawPixel, undefined, wipe);
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
@@ -84,7 +105,7 @@ function drawOverlay() {
} }
} }
if (hoveredCell.value && !onCooldown.value) { if (hoveredCell.value && canPlace.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);
@@ -94,7 +115,7 @@ function drawOverlay() {
} }
} }
watch([selectedColorIndex, onCooldown, showGrid, gridWidth, gridHeight], drawOverlay); watch([selectedColorIndex, canPlace, showGrid, gridWidth, gridHeight], drawOverlay);
onMounted(() => { onMounted(() => {
pixelCtx = pixelCanvasRef.value?.getContext("2d") ?? null; pixelCtx = pixelCanvasRef.value?.getContext("2d") ?? null;
@@ -123,7 +144,7 @@ function handleMouseLeave() {
} }
function handleClick(event: MouseEvent) { function handleClick(event: MouseEvent) {
if (onCooldown.value) return; if (!canPlace.value) return;
const { x, y } = cellFromEvent(event); const { x, y } = cellFromEvent(event);
place(x, y, selectedColorIndex.value); place(x, y, selectedColorIndex.value);
} }
@@ -131,24 +152,36 @@ 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">
<span class="badge" :class="connected ? 'badge-success' : 'badge-error'"> <!-- Sized to fill the width, but capped so the full height (minus the
{{ connected ? t("place.connected") : t("place.reconnecting") }} navbar and the controls below) still fits in the viewport. -->
</span> <div class="relative" :style="{
aspectRatio: `${gridWidth} / ${gridHeight}`,
<div class="relative w-full max-w-[512px]" :style="{ aspectRatio: `${gridWidth} / ${gridHeight}` }"> width: `min(100%, calc((100dvh - 16rem) * ${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: onCooldown ? 'not-allowed' : 'crosshair', cursor: canPlace ? 'crosshair' : 'not-allowed',
}" @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>
<div class="flex items-center gap-2"> <!-- Timer shares the toggle row so showing it doesn't push the page past
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">
@@ -156,13 +189,5 @@ 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>
+18 -2
View File
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { onBeforeUnmount, 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;
@@ -22,7 +23,22 @@ function drawPixel(x: number, y: number, colorIndex: number) {
ctx.fillRect(x, y, 1, 1); ctx.fillRect(x, y, 1, 1);
} }
const { connected, gridWidth, gridHeight } = usePlaceSocket(props.eventPath, drawPixel); let cancelWipe: (() => void) | null = null;
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
@@ -0,0 +1,245 @@
<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>
+103 -48
View File
@@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from "vue"; import { ref, onMounted, onUnmounted, computed, watch } 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 { useUserStore } from "@/stores/user"; import { useQueryClient } from "@tanstack/vue-query";
import { useUser } from "@/composables/useUser";
import { eventGamesQuery } from "@/queries";
import { usePageHeaderStore } from "@/stores/pageHeader"; import { usePageHeaderStore } from "@/stores/pageHeader";
import { gamesApiListEventGames, submissionsApiListAvailableScopes, submissionsApiCreateSubmission, submissionsApiListUserSubmissions, submissionsApiCreateImageCrop, submissionsApiDeleteImageCrop, submissionsApiProcessFileOcr, submissionsApiProcessCropOcr } from "@/api"; import { 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";
@@ -31,8 +33,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 userStore = useUserStore(); const queryClient = useQueryClient();
const { userInfo } = storeToRefs(userStore); const userInfo = useUser();
const uploadedFiles = ref<File[]>([]); const uploadedFiles = ref<File[]>([]);
const isDragover = ref(false); const isDragover = ref(false);
@@ -41,11 +43,12 @@ 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<any[]>([]); const userSubmissions = ref<SubmissionOut[]>([]);
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);
const copyFeedback = ref<string>(""); // Feedback shown on the copy button that was clicked, keyed by "file" or crop id
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);
@@ -139,23 +142,23 @@ const closeSubmissionFileModal = () => {
selectedSubmissionFile.value = null; selectedSubmissionFile.value = null;
}; };
const copyOCRText = async () => { const copyOCRText = async (text: string | undefined, key: string) => {
if (selectedSubmissionFile.value?.ocr_raw_data) { if (!text) return;
try {
await navigator.clipboard.writeText(selectedSubmissionFile.value.ocr_raw_data); try {
copyFeedback.value = t("common.copied"); await navigator.clipboard.writeText(text);
setTimeout(() => { copyFeedback.value = { key, message: t("common.copied") };
copyFeedback.value = ""; } catch (err) {
}, 2000); copyFeedback.value = { key, message: t("submissions.copyFailed") };
} catch (err) {
copyFeedback.value = t("submissions.copyFailed");
setTimeout(() => {
copyFeedback.value = "";
}, 2000);
}
} }
setTimeout(() => {
if (copyFeedback.value?.key === key) copyFeedback.value = null;
}, 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;
@@ -244,6 +247,12 @@ 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;
@@ -260,10 +269,12 @@ const submitCrop = async () => {
}, },
}); });
// Add the new crop to the list immediately // Add the new crop to the list immediately, unless the websocket beat us to it
if (newCrop) { if (newCrop) {
selectedSubmissionFile.value.crops = selectedSubmissionFile.value.crops || []; selectedSubmissionFile.value.crops = selectedSubmissionFile.value.crops || [];
selectedSubmissionFile.value.crops.push(newCrop); if (!selectedSubmissionFile.value.crops.some((c: any) => c.id === newCrop.id)) {
selectedSubmissionFile.value.crops.push(newCrop);
}
} }
currentCrop.value = null; currentCrop.value = null;
@@ -404,6 +415,26 @@ 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;
@@ -474,7 +505,17 @@ const loadUserSubmissions = async () => {
} }
}; };
const { isConnected: wsConnected } = useSubmissionsWebSocket(userSubmissions); // Match over (or user moved off it): back to the picker, which auto-enters
// 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) => {
@@ -503,17 +544,16 @@ const handlePasteOnUploadArea = (event: ClipboardEvent) => {
}; };
onMounted(async () => { onMounted(async () => {
userStore.loadUserData();
loadUserSubmissions(); loadUserSubmissions();
const [gamesResponse, scopesResponse] = await Promise.all([ const [games, scopesResponse] = await Promise.all([
gamesApiListEventGames({ path: { event_path: props.eventPath } }), queryClient.ensureQueryData(eventGamesQuery(props.eventPath)),
submissionsApiListAvailableScopes({ submissionsApiListAvailableScopes({
path: { event_path: props.eventPath, game_path: props.gamePath }, path: { event_path: props.eventPath, game_path: props.gamePath },
}), }),
]); ]);
const game = gamesResponse.data?.find((g) => g.path === props.gamePath); const game = games.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 ?? "";
@@ -528,6 +568,11 @@ onMounted(async () => {
return; return;
} }
scopeName.value = scope.name; scopeName.value = scope.name;
// The picker auto-redirects back here when there's only one scope, so
// going "back" to it would loop - head to the event home instead.
if (scopesResponse.data?.length === 1) {
pageHeaderStore.setBackRoute({ name: "home", params: { eventPath: props.eventPath } });
}
pageHeaderStore.setTitle(t("submissions.pageTitle", { game: gameName.value, scope: scopeName.value })); pageHeaderStore.setTitle(t("submissions.pageTitle", { game: gameName.value, scope: scopeName.value }));
// Add paste listener (only processes if hovering upload area) // Add paste listener (only processes if hovering upload area)
@@ -536,6 +581,7 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('paste', handlePasteOnUploadArea); window.removeEventListener('paste', handlePasteOnUploadArea);
revokeThumbnails();
}); });
</script> </script>
@@ -543,14 +589,6 @@ 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">
@@ -612,17 +650,24 @@ 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="space-y-2"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div v-for="(file, index) in uploadedFiles" :key="index" <div v-for="(file, index) in uploadedFiles" :key="index"
class="flex items-center gap-3 bg-base-200 p-3 rounded-lg cursor-pointer hover:bg-base-300 transition-colors group" class="relative bg-base-200 rounded-lg overflow-hidden cursor-pointer hover:bg-base-300 transition-colors group"
@click="openFilePreview(file)"> @click="openFilePreview(file)">
<i :class="['mdi text-primary', getFileIcon(file)]"></i> <div class="aspect-video bg-base-300 flex items-center justify-center">
<div class="flex-1 min-w-0"> <img v-if="thumbnails[index]" :src="thumbnails[index]" :alt="file.name"
<p class="font-medium truncate group-hover:text-primary">{{ file.name }}</p> class="w-full h-full object-contain" />
<p class="text-xs text-base-content/70">{{ formatFileSize(file.size) }}</p> <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>
<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="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-ghost btn-xs opacity-0 group-hover:opacity-100"> class="btn btn-circle btn-xs absolute top-2 right-2 sm:opacity-0 sm:group-hover:opacity-100">
<i class="mdi mdi-close"></i> <i class="mdi mdi-close"></i>
</button> </button>
</div> </div>
@@ -722,7 +767,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>
{{ t("submissions.yourSubmissions") }} {{ userInfo.isStaff ? t("submissions.allSubmissions") : 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">
@@ -739,6 +784,7 @@ 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>
@@ -748,6 +794,7 @@ 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">
@@ -900,9 +947,10 @@ 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" class="btn btn-xs btn-ghost gap-1"> <button @click="copyOCRText(selectedSubmissionFile.ocr_raw_data, 'file')"
class="btn btn-xs btn-ghost gap-1">
<i class="mdi mdi-content-copy"></i> <i class="mdi mdi-content-copy"></i>
{{ copyFeedback || t("common.copy") }} {{ copyLabel("file") }}
</button> </button>
</div> </div>
<div <div
@@ -939,7 +987,14 @@ 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>
<span :class="getOCRStatusColor(crop.ocr_status)">{{ crop.ocr_status }}</span> <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>
</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">
+54 -14
View File
@@ -3,7 +3,12 @@ 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 { gamesApiListEventGames, submissionsApiListAvailableScopes } from "@/api"; import { 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 {
@@ -14,13 +19,18 @@ 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",
@@ -31,40 +41,67 @@ const goToScope = (scope: ScopeOut) => {
router.push(scopeRoute(scope)); router.push(scopeRoute(scope));
}; };
onMounted(async () => { const loadScopes = async () => {
const [gamesResponse, scopesResponse] = await Promise.all([ const [games, scopesResponse, user] = await Promise.all([
gamesApiListEventGames({ path: { event_path: props.eventPath } }), queryClient.ensureQueryData(eventGamesQuery(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()),
]); ]);
gameName.value = gamesResponse.data?.find((game) => game.path === props.gamePath)?.name ?? ""; // Not a game of this event (or one with its own page, not submissions):
// 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.
if (availableScopes.length === 1) { // Superusers always land here, it's where the scope sync button lives.
if (availableScopes.length === 1 && !user.is_superuser) {
router.replace(scopeRoute(availableScopes[0])); router.replace(scopeRoute(availableScopes[0]));
return; return;
} }
if (availableScopes.length === 0) {
notAssigned.value = true;
loading.value = false;
return;
}
scopes.value = availableScopes; scopes.value = availableScopes;
notAssigned.value = availableScopes.length === 0;
loading.value = false; loading.value = false;
}); };
// A match opened/closed or players got (re)assigned: reload, so waiting
// players get auto-entered into their next match without a refresh.
useSubmissionsWebSocket({ onScopeUpdate: () => loadScopes() });
onMounted(loadScopes);
</script> </script>
<template> <template>
<div class="flex-1 bg-base-200 flex items-center justify-center p-4"> <div class="flex-1 bg-base-200 flex flex-col items-center justify-center gap-4 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>
@@ -86,5 +123,8 @@ onMounted(async () => {
</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>

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