Compare commits

...
82 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
Legrems 59b568ff41 chore: dockerfile (testing) 2026-09-18 23:00:38 +02:00
Legrems 979be91417 chore: deploy scripts + env 2026-09-17 17:28:06 +02:00
Legrems 28d2a7ad24 fix: only explicit games are considered enabled 2026-09-17 11:30:58 +02:00
Legrems e38b610456 feat: add embed for place game 2026-09-17 01:51:38 +02:00
Legrems 9fc9e19d68 fix: useEvent for clock/place games 2026-09-17 01:37:31 +02:00
Legrems 6866b45f66 feat: clock + place games 2026-09-17 01:33:54 +02:00
Legrems d104480188 feat: switch to spa 2026-09-17 00:56:17 +02:00
Legrems 15590e5162 feat: "spa", but more like single entry-point 2026-09-16 23:25:16 +02:00
Legrems 13b14abb12 chore: add i18n 2026-09-16 22:41:59 +02:00
Legrems f68c4e50e8 feat: use django-objects-actions 2026-09-16 19:07:40 +02:00
Legrems 4392122cfc fix: polylan sync 2026-09-16 18:42:37 +02:00
Legrems 9ec9a54649 refactor: add polylan-aandt generated client 2026-09-16 15:36:51 +02:00
Legrems fabf06e6d4 feat(submissions): scoped submissions per user/matchs, sync from polylan 2026-09-16 13:12:22 +02:00
Legrems 85e67599af fix: multi-events 2026-09-16 00:21:08 +02:00
Legrems 68de07645a feat: add custom 404/500 error pages 2026-09-16 00:07:35 +02:00
Legrems 8b9c254fd2 feat: add events app + "multi-tenants" aka multi-events 2026-09-16 00:03:26 +02:00
Legrems b5c2cdc037 chore: assets fixes 2026-09-15 21:55:16 +02:00
Legrems d534519de6 chore: uv.lock update 2026-09-15 16:56:51 +02:00
Legrems d7bdb85fb0 feat(websocket): frontend websocket 2026-05-25 13:01:22 +02:00
Legrems b64161b371 feat(websocket): submissions backend 2026-05-25 02:45:17 +02:00
Legrems c02a0aa985 add missing market api call 2026-05-25 02:40:53 +02:00
Legrems 202e04a554 feat(submissions): admin call to process ocr data 2026-05-25 02:39:51 +02:00
Legrems 0fc1fb255f feat(submissions): crop management 2026-05-25 02:34:23 +02:00
Legrems a769d1a0b3 fix: display ocr data submissions 2026-05-25 02:00:34 +02:00
Legrems 11d6fc423a fix(submissions): fix upload + bucket path 2026-05-25 01:50:27 +02:00
Legrems be81d40a90 feat(game): quick admin action for enable/disable 2026-05-25 01:23:23 +02:00
Legrems 9f9cb0a483 feat(submissions): base submissions frontend + api 2026-05-25 01:20:47 +02:00
Legrems 6a58a0289e feat(games): support non-steam games 2026-05-25 00:54:38 +02:00
Legrems c2602dbc02 feat: add django-channel and daphne 2026-05-25 00:42:34 +02:00
Legrems f543cdcd5c chore: django 6 migration + lib upgrade 2026-05-24 23:39:34 +02:00
Legrems f2fe87c12b feat: submissions app + django-storages + whitenoise 2026-05-24 23:37:22 +02:00
323 changed files with 27761 additions and 1808 deletions
+23
View File
@@ -0,0 +1,23 @@
.git
.gitignore
.venv
venv
**/__pycache__
**/*.pyc
*.egg-info
.ruff_cache
.pytest_cache
ctagsfile
images
main.py
gifs_opus_magnum
polylan_submitter/node_modules
polylan_submitter/static
polylan_submitter/static_source/vite
polylan_submitter/media
polylan_submitter/db.sqlite3
polylan_submitter/.vscode
polylan_submitter/settingsLocal.py
deploy/docker/polylan-submitter.env
+1
View File
@@ -178,3 +178,4 @@ pyrightconfig.json
tags
media/
settingsLocal.py
deploy/docker/polylan-submitter.env
+75
View File
@@ -0,0 +1,75 @@
# Single OCI image bundling the Vue/Vite frontend, the Django/Channels web
# process (daphne) and the RQ worker, managed together by supervisord - see
# deploy/docker/README.md for why (single-container Proxmox deploy) and how
# to run it. Postgres, Redis and S3 stay external (see
# deploy/docker/polylan-submitter.env.dist for the full env var list) - this
# image carries no infra, just the app.
#
# Build (from repo root, requires BuildKit/buildx - RUN --mount=type=cache
# below is rejected by the legacy builder):
# docker buildx build --load -t polylan-submitter .
# ---- frontend build -------------------------------------------------------
FROM node:22-alpine AS frontend-builder
WORKDIR /app
COPY polylan_submitter/package.json polylan_submitter/pnpm-lock.yaml ./
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate \
&& pnpm install --frozen-lockfile
COPY polylan_submitter/vite.config.ts polylan_submitter/tsconfig*.json ./
COPY polylan_submitter/src ./src
COPY polylan_submitter/public ./public
RUN pnpm build
# ---- python dependencies ---------------------------------------------------
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS python-builder
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
WORKDIR /app
COPY pyproject.toml uv.lock ./
# --no-install-project: this pyproject has no [build-system]/package to
# install, it only pins the app's dependencies - so just build the venv.
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --no-install-project
# ---- runtime ----------------------------------------------------------------
FROM python:3.13-slim AS runtime
# tesseract-ocr: shelled out to by pytesseract (submissions/tasks.py,
# opus_magnum/utils.py) for OCR'ing Opus Magnum submission GIFs.
# libgl1/libglib2.0-0: runtime shared libs opencv-python needs even though
# it ships as a wheel.
# libpq5: psycopg (plain, not psycopg[binary]/[c]) loads libpq via ctypes.
# supervisor/tini: process supervision - see deploy/docker/supervisord.conf.
RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr \
libgl1 \
libglib2.0-0 \
libpq5 \
supervisor \
tini \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --system app && useradd --system --gid app --create-home app
WORKDIR /app
COPY --from=python-builder --chown=app:app /app/.venv /app/.venv
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
DJANGO_SETTINGS_MODULE=polylan_submitter.settings
COPY --chown=app:app polylan_submitter/ /app/polylan_submitter/
COPY --from=frontend-builder --chown=app:app /app/static_source/vite /app/polylan_submitter/static_source/vite
COPY --chown=app:app deploy/docker/entrypoint.sh /app/entrypoint.sh
COPY --chown=app:app deploy/docker/supervisord.conf /app/supervisord.conf
RUN rm -rf /app/polylan_submitter/node_modules /app/polylan_submitter/settingsLocal.py \
&& mkdir -p /app/polylan_submitter/static /app/polylan_submitter/media \
&& chown app:app /app/polylan_submitter/static /app/polylan_submitter/media \
&& chmod +x /app/entrypoint.sh
USER app
WORKDIR /app/polylan_submitter
EXPOSE 8000
ENTRYPOINT ["tini", "--", "/app/entrypoint.sh"]
CMD ["supervisord", "-c", "/app/supervisord.conf", "-n"]
+73
View File
@@ -0,0 +1,73 @@
# Container image
`Dockerfile` (repo root) builds a single OCI image with the Vite frontend
already built in, and a `.venv` with all Python deps. At runtime it uses
`supervisord` (`deploy/docker/supervisord.conf`) to run two processes in the
one container:
- `web` - one `daphne` process serving the Django/Channels app on `:8000`
- `worker` - two `rqworker` processes for background jobs (submission OCR,
AAndT sync, etc.)
Postgres, Redis and S3-compatible storage are **not** bundled (see
`deploy/docker/polylan-submitter.env.dist` for the full list of env vars:
`POSTGRES_*`, `REDIS_HOST`/`REDIS_PORT`, `AWS_*`, `DJANGO_SECRET_KEY`, etc.).
Point those at wherever those services already live (other containers/LXCs,
or existing infra).
## Build
Requires BuildKit/buildx (`RUN --mount=type=cache` in the Dockerfile is
rejected by the legacy builder):
```sh
docker buildx build --load -t polylan-submitter .
```
## Run
```sh
docker run -d \
--name polylan-submitter \
--env-file deploy/docker/polylan-submitter.env \
-p 8000:8000 \
polylan-submitter
```
Migrations run automatically on container start (set `MIGRATE_ON_START=false`
to skip). `collectstatic` does not run automatically - it pushes to the
shared S3 bucket, so run it once per deploy instead of racing multiple
replicas on every boot:
```sh
docker run --rm --env-file deploy/docker/polylan-submitter.env \
polylan-submitter python manage.py collectstatic --noinput
```
## Scaling
There's one `daphne` process per container - it binds the only port the
container exposes, so running more of them would need a reverse proxy
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
Recent Proxmox VE versions can create an LXC container directly from an OCI
image (`pct create <vmid> <image-ref> ...` with an OCI-format image
reference, currently a tech-preview feature - check `pct create --help` and
the Proxmox VE docs for your installed version, since the exact flags have
been changing across releases). Practically that means:
1. Push the built image to a registry Proxmox can reach (a local registry,
GHCR, Docker Hub, etc.) - `docker tag`/`docker push`.
2. Create the container on the Proxmox host from that image reference,
passing the same env vars as above.
3. Map/forward port 8000 as needed, and put whatever TLS-terminating
reverse proxy you already run in front of it.
If OCI-to-LXC isn't available on your Proxmox version, the same image runs
fine in a plain VM (or an LXC) with any container runtime (Docker/Podman)
installed instead.
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Container entrypoint: runs migrations (unless disabled), then execs
# whatever CMD supplied (supervisord by default - see the Dockerfile).
#
# collectstatic is intentionally NOT run here: it pushes to the shared S3
# bucket (see STORAGES in settings.py), so running it on every container
# start would race across replicas for no benefit - run it once per deploy
# instead:
# docker run --rm --env-file <env file> polylan-submitter \
# python manage.py collectstatic --noinput
set -euo pipefail
if [ "${MIGRATE_ON_START:-true}" = "true" ]; then
echo "Running migrations..."
python manage.py migrate --noinput
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 "$@"
+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=
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env sh
# One-off build+push to the Gitea container registry for testing.
# Usage: deploy/docker/push.sh [tag] (run from repo root, default tag: test)
set -eu
REGISTRY=git.legrems.mom
OWNER=polylan
IMAGE=polylan-submitter
TAG="${1:-test}"
cd "$(dirname "$0")/../.."
REF="$REGISTRY/$OWNER/$IMAGE:$TAG"
# buildx (BuildKit) is required: the Dockerfile uses RUN --mount=type=cache
# for the uv dep cache, which the legacy builder rejects.
docker buildx build --load -t "$REF" .
docker push "$REF"
echo "Pushed $REF"
+43
View File
@@ -0,0 +1,43 @@
; 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
; root here). This container runs ONE daphne process on TCP: scale by
; running more container replicas 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
; own log rotation for them) so `docker logs` / Proxmox's log viewer picks
; them up - no log files inside the container to manage.
[supervisord]
nodaemon=true
pidfile=/tmp/supervisord.pid
logfile=/dev/null
logfile_maxbytes=0
[program:web]
command=/app/.venv/bin/daphne -b 0.0.0.0 -p 8000 polylan_submitter.asgi:application
directory=/app/polylan_submitter
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopsignal=TERM
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:worker]
command=/app/.venv/bin/python manage.py rqworker default --job-class django_tasks_rq.Job --with-scheduler
directory=/app/polylan_submitter
process_name=%(program_name)s-%(process_num)02d
numprocs=2
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopsignal=TERM
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
+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
+12
View File
@@ -12,6 +12,18 @@ dist
dist-ssr
*.local
# Vite build output (generated locally via `pnpm build`, served by whitenoise)
static_source/vite/assets
static_source/vite/manifest.json
static_source/vite/icon.png
tsconfig.tsbuildinfo
# vue-tsc emits a compiled copy of vite.config.ts here (tsconfig.node.json,
# composite project ref) — Vite's config lookup prefers .js over .ts, so a
# committed copy silently shadows every edit to the real config.
/vite.config.js
/vite.config.d.ts
# Editor directories and files
.vscode/*
!.vscode/extensions.json
+44 -1
View File
@@ -1,10 +1,20 @@
from django.conf import settings
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django_object_actions import DjangoObjectActions, action
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)
class CustomUserAdmin(UserAdmin):
class CustomUserAdmin(DjangoObjectActions, UserAdmin):
"""Admin interface for CustomUser."""
# Add custom fields to the user admin
@@ -29,6 +39,8 @@ class CustomUserAdmin(UserAdmin):
# Make CAS fields readonly in admin
readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes")
changelist_actions = ("create_load_test_users",)
def get_cas_groups_display(self, obj):
"""Display CAS groups in admin list."""
return obj.get_cas_groups_display()
@@ -37,3 +49,34 @@ class CustomUserAdmin(UserAdmin):
def has_delete_permission(self, request, obj=None):
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):
default_auto_field = "django.db.models.BigAutoField"
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),
]
+22 -16
View File
@@ -22,10 +22,12 @@ from opus_magnum.models import PuzzleResponse, SteamCollectionItem, SteamCollect
router = Router()
PREFIX = "/events/{event_path}/results"
@router.get("results", response=RankingSchema)
def results(request: HttpRequest) -> dict:
cache_key = "api:results:results"
@router.get(f"{PREFIX}/results", response=RankingSchema)
def results(request: HttpRequest, event_path: str) -> dict:
cache_key = f"api:{event_path}:results:results"
cached_data = cache.get(cache_key)
if cached_data is not None:
@@ -35,7 +37,9 @@ def results(request: HttpRequest) -> dict:
responses_by_puzzleid = defaultdict(list)
for response in list(
PuzzleResponse.objects.filter(needs_manual_validation=False)
PuzzleResponse.objects.filter(
needs_manual_validation=False, submission__event=request.event
)
.filter_user_best_response()
.prefetch_related("submission__user")
):
@@ -51,25 +55,27 @@ def results(request: HttpRequest) -> dict:
data = {
"users": list(CustomUser.objects.filter(pk__in=responses_by_userid.keys())),
"puzzles": list(SteamCollectionItem.objects.all()),
"puzzles": list(
SteamCollectionItem.objects.filter(collection__event=request.event)
),
"responses_by_userid": dict(responses_by_userid),
"ranking_by_puzzle": ranking,
}
cache.set("api:results:results", data, 300)
cache.set(cache_key, data, 300)
return data
@router.get("top-submissions", response=TournamentSubmissionsOut)
def top_submissions(request: HttpRequest, limit: int = 5) -> dict:
@router.get(f"{PREFIX}/top-submissions", response=TournamentSubmissionsOut)
def top_submissions(request: HttpRequest, event_path: str, limit: int = 5) -> dict:
"""Get tournament top submissions for each puzzle. Only available when tournament is closed."""
cache_key = f"api:results:top_submissions:{limit}"
cache_key = f"api:{event_path}:results:top_submissions:{limit}"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
collection = get_object_or_404(SteamCollection, is_active=True)
collection = get_object_or_404(SteamCollection, is_active=True, event=request.event)
# Only allow access when tournament is closed
if collection.accepting_submissions:
@@ -141,20 +147,20 @@ def top_submissions(request: HttpRequest, limit: int = 5) -> dict:
)
data = {"submissions": submissions_list}
cache.set(f"api:results:top_submissions:{limit}", data, 300)
cache.set(cache_key, data, 300)
return data
@router.get("puzzle-results", response=TournamentPuzzleResultsOut)
def puzzle_results(request: HttpRequest, limit: int = 5) -> dict:
@router.get(f"{PREFIX}/puzzle-results", response=TournamentPuzzleResultsOut)
def puzzle_results(request: HttpRequest, event_path: str, limit: int = 5) -> dict:
"""Get tournament results organized by puzzle with coefficients. Only available when tournament is closed."""
cache_key = f"api:results:puzzle_results:{limit}"
cache_key = f"api:{event_path}:results:puzzle_results:{limit}"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
collection = get_object_or_404(SteamCollection, is_active=True)
collection = get_object_or_404(SteamCollection, is_active=True, event=request.event)
# Only allow access when tournament is closed
if collection.accepting_submissions:
@@ -237,5 +243,5 @@ def puzzle_results(request: HttpRequest, limit: int = 5) -> dict:
)
data = {"results": results_list}
cache.set(f"api:results:puzzle_results:{limit}", data, 300)
cache.set(cache_key, data, 300)
return data
View File
+23
View File
@@ -0,0 +1,23 @@
from django.contrib import admin
from .models import ClockTimer, Press
@admin.register(ClockTimer)
class ClockTimerAdmin(admin.ModelAdmin):
list_display = ("event", "end_time", "initial_duration")
list_editable = ("initial_duration",)
list_filter = ("event",)
@admin.register(Press)
class PressAdmin(admin.ModelAdmin):
list_display = (
"id",
"event",
"player",
"pressed_at",
"remaining_before_press",
"seconds_added",
)
list_filter = ("event", "player")
+79
View File
@@ -0,0 +1,79 @@
from datetime import timedelta
from django.http import HttpRequest
from django.utils import timezone
from ninja import Router
from ninja.decorators import decorate_view
from games.decorators import require_game_enabled
from .models import PRESS_BONUS_SECONDS, ClockTimer, Press
from .schemas import ClockStateOut, PlayerStatsOut
from .scoring import ranked_players
router = Router()
PREFIX = "/events/{event_path}/clock"
@router.get(f"{PREFIX}/current", response=ClockStateOut)
@decorate_view(require_game_enabled(path="clock"))
def current_state(request: HttpRequest, event_path: str):
timer = ClockTimer.current(request.event)
timer.reset_if_expired()
now = timezone.now()
next_press_at = None
cooldown_seconds = None
if request.user.is_authenticated:
last_press = (
Press.objects.filter(player=request.user, event=request.event)
.order_by("-pressed_at")
.first()
)
if last_press is not None:
candidate = last_press.pressed_at + timedelta(
seconds=last_press.cooldown_seconds
)
if candidate > now:
next_press_at = candidate
cooldown_seconds = last_press.cooldown_seconds
return {
"server_time": now,
"end_time": timer.end_time,
"initial_duration": timer.initial_duration,
"press_bonus_seconds": PRESS_BONUS_SECONDS,
"next_press_at": next_press_at,
"cooldown_seconds": cooldown_seconds,
}
@router.get(f"{PREFIX}/me", response=PlayerStatsOut)
@decorate_view(require_game_enabled(path="clock"))
def my_stats(request: HttpRequest, event_path: str):
ranked = list(
ranked_players(request.event)
.values("id", "clock_points", "rank")
.order_by("-clock_points", "id")
)
total_players = len(ranked)
my_index = next(
(i for i, row in enumerate(ranked) if row["id"] == request.user.id), None
)
mine = ranked[my_index] if my_index is not None else None
above = ranked[my_index - 1] if my_index is not None and my_index > 0 else None
below = (
ranked[my_index + 1]
if my_index is not None and my_index + 1 < total_players
else None
)
return {
"points": mine["clock_points"] if mine else 0.0,
"rank": mine["rank"] if mine else total_players,
"total_players": total_players,
"points_above": above["clock_points"] if above else None,
"points_below": below["clock_points"] if below else None,
}
+9
View File
@@ -0,0 +1,9 @@
from django.apps import AppConfig
class ClockConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "clock"
def ready(self):
from . import signals # noqa: F401
+3
View File
@@ -0,0 +1,3 @@
def clock_group(event_path: str) -> str:
"""Channel group for one event's clock room - every event ticks independently."""
return f"clock_room_{event_path}"
+154
View File
@@ -0,0 +1,154 @@
from datetime import timedelta
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncWebsocketConsumer
from django.db import transaction
from django.utils import timezone
from events.models import Event
from games.models import Game
from .broadcast import clock_group
from .models import PRESS_BONUS_SECONDS, ClockTimer, Press, random_cooldown_seconds
from .wire import encode, to_epoch_ms
class ClockConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.event_path = self.scope["url_route"]["kwargs"]["event_path"]
self.event = await self._get_enabled_event(
self.event_path, self.scope.get("user")
)
if self.event is None:
await self.close()
return
self.group_name = clock_group(self.event_path)
await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept()
await self.send(text_data=await self._initial_state(self.scope.get("user")))
async def disconnect(self, close_code):
if getattr(self, "group_name", None):
await self.channel_layer.group_discard(self.group_name, self.channel_name)
async def receive(self, text_data=None, bytes_data=None):
if text_data == "press":
await self._handle_press()
async def _handle_press(self):
user = self.scope.get("user")
if user is None or not user.is_authenticated:
await self.send(text_data=encode("r", "a"))
return
result = await self._record_press(user)
if result["rejected"]:
await self.send(text_data=encode("r", "c", result["next"], result["now"]))
return
# The cooldown outcome is only useful to the player who just pressed -
# broadcasting it to every connection would be pure waste.
await self.send(text_data=encode("k", result["next"], result["now"]))
# Everyone else only needs to know the shared timer moved.
await self.channel_layer.group_send(
self.group_name,
{
"type": "tick",
"end": result["end"],
"now": result["now"],
"pressed": True,
},
)
@database_sync_to_async
def _get_enabled_event(self, event_path, user):
event = Event.objects.available_to(user).filter(path=event_path).first()
if event is None:
return None
if not Game.objects.enabled_for_event(event).filter(path="clock").exists():
return None
return event
@database_sync_to_async
def _record_press(self, user):
with transaction.atomic():
timer = ClockTimer.objects.select_for_update().get(event=self.event)
now = timezone.now()
timer.reset_if_expired(now)
last_press = (
Press.objects.filter(player=user, event=self.event)
.order_by("-pressed_at")
.first()
)
if last_press is not None:
next_allowed = last_press.pressed_at + timedelta(
seconds=last_press.cooldown_seconds
)
if next_allowed > now:
return {
"rejected": True,
"next": to_epoch_ms(next_allowed),
"now": to_epoch_ms(now),
}
remaining = (timer.end_time - now).total_seconds()
cooldown = random_cooldown_seconds()
press = Press.objects.create(
event=self.event,
player=user,
remaining_before_press=remaining,
seconds_added=PRESS_BONUS_SECONDS,
cooldown_seconds=cooldown,
)
timer.end_time = timer.end_time + timedelta(seconds=PRESS_BONUS_SECONDS)
timer.save(update_fields=["end_time"])
return {
"rejected": False,
"end": to_epoch_ms(timer.end_time),
"now": to_epoch_ms(now),
"next": to_epoch_ms(press.pressed_at + timedelta(seconds=cooldown)),
}
@database_sync_to_async
def _initial_state(self, user):
timer = ClockTimer.current(self.event)
timer.reset_if_expired()
now = timezone.now()
next_press_at = None
if user is not None and user.is_authenticated:
last_press = (
Press.objects.filter(player=user, event=self.event)
.order_by("-pressed_at")
.first()
)
if last_press is not None:
candidate = last_press.pressed_at + timedelta(
seconds=last_press.cooldown_seconds
)
if candidate > now:
next_press_at = to_epoch_ms(candidate)
return encode(
"s",
to_epoch_ms(timer.end_time),
to_epoch_ms(now),
timer.initial_duration,
PRESS_BONUS_SECONDS,
next_press_at,
)
async def tick(self, event):
await self.send(
text_data=encode(
"t",
event["end"],
event["now"],
"1" if event.get("pressed") else "",
)
)
@@ -0,0 +1,78 @@
# Generated by Django 6.0.5 on 2026-09-16 23:11
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("events", "0002_seed_polylan_43"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ClockTimer",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("initial_duration", models.PositiveIntegerField(default=60)),
("end_time", models.DateTimeField()),
(
"event",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="clock_timer",
to="events.event",
),
),
],
),
migrations.CreateModel(
name="Press",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("pressed_at", models.DateTimeField(auto_now_add=True)),
("remaining_before_press", models.FloatField()),
("seconds_added", models.PositiveIntegerField(default=5)),
("cooldown_seconds", models.FloatField()),
(
"event",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="clock_presses",
to="events.event",
),
),
(
"player",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="clock_presses",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-pressed_at"],
},
),
]
+81
View File
@@ -0,0 +1,81 @@
import random
from datetime import timedelta
from django.conf import settings
from django.db import models
from django.utils import timezone
from events.models import Event
from events.scoping import EventScopedManager
DEFAULT_TIMER_DURATION = 60
PRESS_BONUS_SECONDS = 5
PRESS_COOLDOWN_MIN_SECONDS = PRESS_BONUS_SECONDS
PRESS_COOLDOWN_MAX_SECONDS = PRESS_BONUS_SECONDS * 2
def random_cooldown_seconds() -> float:
return random.uniform(PRESS_COOLDOWN_MIN_SECONDS, PRESS_COOLDOWN_MAX_SECONDS)
class ClockTimer(models.Model):
"""The shared countdown for one event. One row per event, reset in place.
Unlike the enable/disable toggle for every other game (EventGame.enabled,
checked before a client can even reach this app), there's no separate
is_enabled flag here - the registry is the single source of truth.
"""
event = models.OneToOneField(
Event, on_delete=models.CASCADE, related_name="clock_timer"
)
initial_duration = models.PositiveIntegerField(default=DEFAULT_TIMER_DURATION)
end_time = models.DateTimeField()
def __str__(self):
return f"ClockTimer({self.event}, end_time={self.end_time.isoformat()})"
@classmethod
def current(cls, event: Event) -> "ClockTimer":
"""Fetch the event's singleton timer, creating it on first-ever run."""
obj, _ = cls.objects.get_or_create(
event=event,
defaults={
"end_time": timezone.now() + timedelta(seconds=DEFAULT_TIMER_DURATION)
},
)
return obj
def reset_if_expired(self, now=None) -> bool:
"""Resets end_time back to a fresh window if it has passed. Returns True if reset."""
now = now or timezone.now()
if self.end_time > now:
return False
self.end_time = now + timedelta(seconds=self.initial_duration)
self.save(update_fields=["end_time"])
return True
class Press(models.Model):
event = models.ForeignKey(
Event, on_delete=models.CASCADE, related_name="clock_presses"
)
player = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="clock_presses"
)
pressed_at = models.DateTimeField(auto_now_add=True)
remaining_before_press = models.FloatField()
seconds_added = models.PositiveIntegerField(default=PRESS_BONUS_SECONDS)
# Randomized 1x-2x PRESS_BONUS_SECONDS at press time and stored, since it's no
# longer a fixed constant that can be recomputed later.
cooldown_seconds = models.FloatField()
objects = models.Manager()
for_event = EventScopedManager()
class Meta:
ordering = ["-pressed_at"]
def __str__(self):
return f"{self.player} pressed at {self.remaining_before_press:.1f}s left ({self.event})"
+7
View File
@@ -0,0 +1,7 @@
from django.urls import re_path
from .consumers import ClockConsumer
websocket_urlpatterns = [
re_path(r"^ws/clock/(?P<event_path>[\w-]+)/$", ClockConsumer.as_asgi()),
]
+20
View File
@@ -0,0 +1,20 @@
from datetime import datetime
from ninja import Schema
class ClockStateOut(Schema):
server_time: datetime
end_time: datetime
initial_duration: int
press_bonus_seconds: int
next_press_at: datetime | None
cooldown_seconds: float | None
class PlayerStatsOut(Schema):
points: float
rank: int
total_players: int
points_above: float | None
points_below: float | None
+38
View File
@@ -0,0 +1,38 @@
from django.db.models import ExpressionWrapper, F, FloatField, Q, Sum, Value, Window
from django.db.models.functions import Coalesce, Power, Rank
from events.models import Event
# points = 1000 * (1 / (remaining_seconds + 0.5)) ** 2, computed per press and
# summed per player. Never stored - always derived live from Press rows.
POINTS_BASE = 1000.0
POINTS_OFFSET = 0.5
POINTS_EXPONENT = -2.0
def _press_points_expression():
base = ExpressionWrapper(
F("clock_presses__remaining_before_press") + Value(POINTS_OFFSET),
output_field=FloatField(),
)
return ExpressionWrapper(
Value(POINTS_BASE) * Power(base, Value(POINTS_EXPONENT)),
output_field=FloatField(),
)
def ranked_players(event: Event):
"""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` -
a player's history in another event never leaks into this leaderboard."""
from accounts.models import CustomUser
points = Coalesce(
Sum(_press_points_expression(), filter=Q(clock_presses__event=event)),
Value(0.0),
output_field=FloatField(),
)
return CustomUser.objects.annotate(clock_points=points).annotate(
rank=Window(expression=Rank(), order_by=F("clock_points").desc())
)
+12
View File
@@ -0,0 +1,12 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from games.models import EventGame
from .tasks import start_clock_heartbeat
@receiver(post_save, sender=EventGame)
def on_event_game_saved(sender, instance, **kwargs):
if instance.game.path == "clock" and instance.enabled:
start_clock_heartbeat()
+89
View File
@@ -0,0 +1,89 @@
"""Self-rescheduling heartbeat, replacing pplace's standalone `watch_timer`
management command with the project's existing self-rescheduling django-tasks
pattern (see submissions.tasks.sync_event_game_scopes_task).
Resets any event's timer once it expires - even if nobody presses or
reconnects to notice - and gives already-connected clients a periodic tick.
Note this is a safety net, not the only way the timer gets reset: every press
and every new connection (see consumers.py) also calls reset_if_expired(), so
the game works correctly even if this chain is never started or dies -
connected-but-idle clients just won't see the countdown restart until someone
acts.
"""
from datetime import timedelta
from uuid import uuid4
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.core.cache import cache
from django.db import transaction
from django.tasks import task
from django.utils import timezone
from games.models import EventGame
from .broadcast import clock_group
from .models import ClockTimer
from .wire import to_epoch_ms
HEARTBEAT_INTERVAL_SECONDS = 1
# 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
# EventGame saves close together). Refreshed every tick, wide enough that a
# single slow tick doesn't let a second chain slip in. The lock holds the
# 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_TIMEOUT_SECONDS = HEARTBEAT_INTERVAL_SECONDS * 5
@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(
EventGame.objects.filter(game__path="clock", enabled=True).select_related(
"event"
)
)
if not event_games:
# Nothing left to tick - let the chain die. start_clock_heartbeat()
# starts a fresh one next time an event enables the game.
if cache.get(HEARTBEAT_LOCK_KEY) == chain_id:
cache.delete(HEARTBEAT_LOCK_KEY)
return
channel_layer = get_channel_layer()
for event_game in event_games:
event = event_game.event
ClockTimer.current(event)
with transaction.atomic():
timer = ClockTimer.objects.select_for_update().get(event=event)
timer.reset_if_expired()
async_to_sync(channel_layer.group_send)(
clock_group(event.path),
{
"type": "tick",
"end": to_epoch_ms(timer.end_time),
"now": to_epoch_ms(timezone.now()),
"pressed": False,
},
)
cache.set(HEARTBEAT_LOCK_KEY, chain_id, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS)
clock_heartbeat_task.using(
run_after=timezone.now() + timedelta(seconds=HEARTBEAT_INTERVAL_SECONDS)
).enqueue(chain_id)
def start_clock_heartbeat():
"""Enqueues the heartbeat chain, unless one is already running."""
chain_id = uuid4().hex
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))
+33
View File
@@ -0,0 +1,33 @@
"""Compact wire format for the clock websocket.
JSON's braces/quotes/keys are pure overhead for messages this small and this
frequent (a heartbeat every second, to every connection). Each message is
instead a short string: a one-letter type tag followed by '|'-separated
fields, in a fixed order the client already knows from this table (missing/
null fields are written as an empty segment):
s|<end>|<now>|<duration>|<bonus>|<next> initial state, once per connection
t|<end>|<now>|<pressed> shared timer update ("pressed" is "1" or "")
k|<next>|<now> your press was accepted
r|c|<next>|<now> your press was rejected: on cooldown
r|a your press was rejected: not logged in
Client -> server has exactly one message, sent as the bare literal "press"
(no fields, so no delimiter needed).
Whether the game is enabled at all for this event (EventGame.enabled) is
checked once, at connect time - a connection is refused outright rather than
accepted-but-disabled, so there's no "disabled" case in this wire format.
"""
DELIMITER = "|"
def encode(*parts) -> str:
return DELIMITER.join("" if p is None else str(p) for p in parts)
def to_epoch_ms(dt) -> int:
"""Epoch-ms integer: far cheaper than an ISO-8601 string and needs no
client-side parsing (the frontend already does its clock math in epoch ms)."""
return int(dt.timestamp() * 1000)
+13
View File
@@ -0,0 +1,13 @@
from django.contrib import admin
from .models import Event
@admin.register(Event)
class EventAdmin(admin.ModelAdmin):
list_display = ["name", "path", "is_available", "created_at"]
list_editable = ["is_available"]
list_filter = ["is_available"]
search_fields = ["name", "path"]
prepopulated_fields = {"path": ("name",)}
readonly_fields = ["created_at", "updated_at"]
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class EventsConfig(AppConfig):
name = "events"
+30
View File
@@ -0,0 +1,30 @@
from django.shortcuts import get_object_or_404
from .models import Event
from .scoping import current_event
class EventContextMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
try:
return self.get_response(request)
finally:
# Not current_event.reset(token): under ASGI, process_view and this
# finally block can run in different contextvars.Context copies
# (sync_to_async(thread_sensitive=True) copies the context), and
# Token.reset() requires the exact Context it was created in.
# set(None) has no such constraint.
current_event.set(None)
def process_view(self, request, view_func, view_args, view_kwargs):
event_path = view_kwargs.get("event_path")
if event_path:
request.event = get_object_or_404(
Event.objects.available_to(getattr(request, "user", None)),
path=event_path,
)
current_event.set(request.event)
return None
@@ -0,0 +1,34 @@
# Generated by Django 6.0.5 on 2026-09-15 21:08
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Event",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=255)),
("path", models.SlugField(max_length=100, unique=True)),
],
options={
"abstract": False,
},
),
]
@@ -0,0 +1,24 @@
from django.db import migrations
EVENT_NAME = "PolyLAN 43"
EVENT_PATH = "polylan-43"
def seed_event(apps, schema_editor):
Event = apps.get_model("events", "Event")
Event.objects.get_or_create(path=EVENT_PATH, defaults={"name": EVENT_NAME})
def unseed_event(apps, schema_editor):
Event = apps.get_model("events", "Event")
Event.objects.filter(path=EVENT_PATH).delete()
class Migration(migrations.Migration):
dependencies = [
("events", "0001_initial"),
]
operations = [
migrations.RunPython(seed_event, unseed_event),
]
@@ -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),
),
]
+34
View File
@@ -0,0 +1,34 @@
from django.db import models
import uuid
class BaseModel(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
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):
name = models.CharField(max_length=255)
path = models.SlugField(max_length=100, unique=True)
is_available = models.BooleanField(default=True)
objects = EventQuerySet.as_manager()
def __str__(self) -> str:
return self.name
+21
View File
@@ -0,0 +1,21 @@
import contextvars
from django.core.exceptions import ImproperlyConfigured
from django.db import models
current_event = contextvars.ContextVar("current_event", default=None)
class EventScopedQuerySet(models.QuerySet):
def for_current_event(self):
event = current_event.get()
if event is None:
raise ImproperlyConfigured(
f"{self.model.__name__}.for_event was used outside an event-scoped request"
)
return self.filter(event=event)
class EventScopedManager(models.Manager.from_queryset(EventScopedQuerySet)):
def get_queryset(self):
return super().get_queryset().for_current_event()
+74
View File
@@ -0,0 +1,74 @@
from datetime import timedelta
from django.core.exceptions import ImproperlyConfigured
from django.test import TestCase
from games.models import Game
from submissions.models import Submission
from .models import Event
from .scoping import current_event
class EventScopedManagerTests(TestCase):
def setUp(self):
self.event_a = Event.objects.create(name="Event A", path="event-a")
self.event_b = Event.objects.create(name="Event B", path="event-b")
self.game = Game.objects.create(name="Test Game", path="test-game")
self.submission_a = Submission.objects.create(
event=self.event_a, game=self.game
)
self.submission_b = Submission.objects.create(
event=self.event_b, game=self.game
)
def test_for_event_raises_outside_event_context(self):
with self.assertRaises(ImproperlyConfigured):
list(Submission.for_event.all())
def test_for_event_filters_to_current_event(self):
token = current_event.set(self.event_a)
try:
results = list(Submission.for_event.all())
finally:
current_event.reset(token)
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
)
+1
View File
@@ -0,0 +1 @@
# Create your views here.
+115 -5
View File
@@ -1,11 +1,121 @@
from django.contrib import admin
from django.contrib import admin, messages
from django_object_actions import (
DjangoObjectActions,
action,
takes_instance_or_queryset,
)
from .models import Game
from submissions.tasks import schedule_scope_sync, stop_scope_sync
from .models import EventGame, Game, PlanningSlot
from .tasks import refresh_steam_games_task
class EventGameInline(admin.TabularInline):
model = EventGame
extra = 0
@admin.register(Game)
class GameAdmin(admin.ModelAdmin):
list_display = ["name", "steam_app_id", "enabled", "updated_at"]
list_filter = ["enabled"]
list_display = ["name", "steam_app_id", "updated_at"]
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]
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)
class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
list_display = ["game", "event", "enabled", "has_apibot_token"]
list_filter = ["event", "enabled"]
search_fields = ["game__name", "event__name"]
actions = ["enable", "disable", "sync_scopes", "stop_scope_sync"]
change_actions = ["sync_scopes", "stop_scope_sync"]
@admin.display(description="AAndT token", boolean=True)
def has_apibot_token(self, obj):
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(
label="Sync scopes",
description="Sync scopes from AAndT for selected EventGames",
)
@takes_instance_or_queryset
def sync_scopes(self, request, queryset):
tokened = queryset.exclude(apibot_token__isnull=True).exclude(apibot_token="")
skipped = queryset.count() - tokened.count()
started = sum(schedule_scope_sync(event_game.id) for event_game in tokened)
self.message_user(
request,
f"Started scope sync for {started} EventGame(s)"
f" ({tokened.count() - started} already running).",
)
if skipped:
self.message_user(
request,
f"Skipped {skipped} EventGame(s) with no AAndT token configured.",
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"]
+26 -2
View File
@@ -3,11 +3,35 @@ from typing import List
from ninja import Router
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()
event_router = Router()
@router.get("", response=List[GameOut])
def list_games(request):
return Game.objects.filter(enabled=True)
return Game.objects.all()
@event_router.get("/events/{event_path}/games", response=List[GameOut])
def list_event_games(request, event_path: str):
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())
+19 -6
View File
@@ -1,20 +1,33 @@
from functools import wraps
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404
from events.models import Event
from .models import Game
def require_game_enabled(steam_app_id: int):
def require_game_enabled(steam_app_id: int | None = None, *, path: str | None = None):
"""Gate a view (or, via @decorate_view, an API operation) behind an
EventGame being enabled for the current event. Steam games are looked up
by steam_app_id (the original, positional form); custom non-Steam games
(no steam_app_id) are looked up by their Game.path instead, e.g.
require_game_enabled(path="clock")."""
def decorator(view_func):
@wraps(view_func)
def wrapper(request, *args, **kwargs):
try:
game = Game.objects.get(steam_app_id=steam_app_id)
except Game.DoesNotExist:
raise PermissionDenied
if not game.enabled:
event = get_object_or_404(Event, path=kwargs.get("event_path"))
games = Game.objects.enabled_for_event(event)
games = (
games.filter(steam_app_id=steam_app_id)
if steam_app_id is not None
else games.filter(path=path)
)
if not games.exists():
raise PermissionDenied
return view_func(request, *args, **kwargs)
return wrapper
@@ -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,22 @@
# Generated by Django 6.0.5 on 2026-05-24 22:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0003_game_path"),
]
operations = [
migrations.AddField(
model_name="game",
name="image",
field=models.URLField(blank=True, null=True),
),
migrations.AlterField(
model_name="game",
name="steam_app_id",
field=models.PositiveIntegerField(blank=True, null=True),
),
]
@@ -0,0 +1,52 @@
# Generated by Django 6.0.5 on 2026-09-15 21:11
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("events", "0002_seed_polylan_43"),
("games", "0004_game_image_alter_game_steam_app_id"),
]
operations = [
migrations.CreateModel(
name="EventGame",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("enabled", models.BooleanField(default=True)),
(
"event",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="event_games",
to="events.event",
),
),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="event_games",
to="games.game",
),
),
],
options={
"constraints": [
models.UniqueConstraint(
fields=("event", "game"), name="unique_event_game"
)
],
},
),
]
@@ -0,0 +1,16 @@
# Generated by Django 6.0.5 on 2026-09-15 21:51
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0005_eventgame"),
]
operations = [
migrations.RemoveField(
model_name="game",
name="enabled",
),
]
@@ -0,0 +1,23 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0006_remove_game_enabled"),
]
operations = [
migrations.AddField(
model_name="eventgame",
name="apibot_token",
field=models.CharField(
blank=True,
help_text=(
"Bearer token for the legacy PolyLAN AAndT API (from the AAndT's "
"'apibot' admin page), used to auto-sync Scopes and their assigned users."
),
max_length=512,
null=True,
),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 6.0.5 on 2026-09-16 00:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0007_eventgame_apibot_token"),
]
operations = [
migrations.AddField(
model_name="game",
name="custom_path",
field=models.BooleanField(default=False),
),
]
@@ -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"
)
],
},
),
]
+110 -2
View File
@@ -1,17 +1,125 @@
from django.db import models
from events.models import Event
class GameQuerySet(models.QuerySet):
def enabled_for_event(self, event):
return self.filter(event_games__event=event, event_games__enabled=True)
class Game(models.Model):
steam_app_id = models.PositiveIntegerField(unique=True)
steam_app_id = models.PositiveIntegerField(null=True, blank=True)
name = models.CharField(max_length=255)
path = models.CharField(max_length=100)
enabled = models.BooleanField(default=True)
custom_path = models.BooleanField(default=False)
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)
updated_at = models.DateTimeField(auto_now=True)
objects = models.Manager.from_queryset(GameQuerySet)()
class Meta:
ordering = ["name"]
def __str__(self) -> str:
if self.steam_app_id:
return f"{self.name} ({self.steam_app_id})"
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):
event = models.ForeignKey(
Event,
on_delete=models.CASCADE,
related_name="event_games",
)
game = models.ForeignKey(
Game,
on_delete=models.CASCADE,
related_name="event_games",
)
enabled = models.BooleanField(default=True)
apibot_token = models.CharField(
max_length=512,
blank=True,
null=True,
help_text=(
"Bearer token for the legacy PolyLAN AAndT API (from the AAndT's "
"'apibot' admin page), used to auto-sync Scopes and their assigned users."
),
)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["event", "game"],
name="unique_event_game",
),
]
def __str__(self) -> str:
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})"
+92 -1
View File
@@ -1,7 +1,98 @@
from datetime import datetime
from typing import Optional
from ninja import Schema
class GameOut(Schema):
steam_app_id: int
id: int
steam_app_id: Optional[int] = None
name: str
path: str
custom_path: bool
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()
+16 -8
View File
@@ -1,4 +1,9 @@
from django.contrib import admin
from django_object_actions import (
DjangoObjectActions,
action,
takes_instance_or_queryset,
)
from market.models import Market, MarketOption, UserBet, UserPointChange
@@ -9,9 +14,9 @@ class MarketOptionInline(admin.TabularInline):
@admin.register(Market)
class MarketAdmin(admin.ModelAdmin):
list_display = ["title", "status", "end_date", "created_by", "created_at"]
list_filter = ["status", "created_at"]
class MarketAdmin(DjangoObjectActions, admin.ModelAdmin):
list_display = ["title", "status", "event", "end_date", "created_by", "created_at"]
list_filter = ["status", "created_at", "event"]
search_fields = ["uuid", "title"]
readonly_fields = [
"uuid",
@@ -22,7 +27,7 @@ class MarketAdmin(admin.ModelAdmin):
]
inlines = [MarketOptionInline]
fieldsets = (
("Info", {"fields": ["uuid", "title", "description"]}),
("Info", {"fields": ["uuid", "title", "description", "event"]}),
("Configuration", {"fields": ["end_date", "multiplier"]}),
("Status", {"fields": ["status", "winning_option"]}),
("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}),
@@ -39,22 +44,25 @@ class MarketAdmin(admin.ModelAdmin):
obj.created_by = request.user
super().save_model(request, obj, form, change)
@admin.action(description="Publish selected draft markets")
actions = ["publish_markets", "close_markets"]
change_actions = ["publish_markets", "close_markets"]
@action(label="Publish", description="Publish selected draft markets")
@takes_instance_or_queryset
def publish_markets(self, request, queryset):
updated = queryset.filter(status=Market.Status.DRAFT).update(
status=Market.Status.OPEN
)
self.message_user(request, f"Published {updated} market(s).")
@admin.action(description="Close selected markets")
@action(label="Close", description="Close selected markets")
@takes_instance_or_queryset
def close_markets(self, request, queryset):
updated = queryset.filter(status=Market.Status.OPEN).update(
status=Market.Status.CLOSED
)
self.message_user(request, f"Closed {updated} market(s).")
actions = ["publish_markets", "close_markets"]
@admin.register(MarketOption)
class MarketOptionAdmin(admin.ModelAdmin):
+84 -33
View File
@@ -1,27 +1,40 @@
from typing import List
from ninja import Router
from ninja.decorators import decorate_view
from ninja.errors import HttpError
from django.shortcuts import get_object_or_404
from django.db.models import Sum, Prefetch
from django.db.models.functions import Coalesce
from django.db import transaction
from market.models import Market, MarketOption, UserBet, UserPointChange
from games.decorators import require_game_enabled
from market.models import (
Market,
MarketOption,
UserBet,
UserPointChange,
get_event_points,
)
from market.schemas import (
BetHistoryPointSchema,
MarketListSchema,
ResolveMarketSchema,
UserBetCreateSchema,
UserBetSchema,
UserPointsSchema,
)
router = Router(tags=["market"])
PREFIX = "/events/{event_path}/market"
@router.get("/", response=List[MarketListSchema])
def list_markets(request):
@router.get(f"{PREFIX}/", response=List[MarketListSchema])
@decorate_view(require_game_enabled(path="market"))
def list_markets(request, event_path: str):
"""List all markets (excludes draft markets)."""
markets = Market.objects.exclude(status=Market.Status.DRAFT)
markets = Market.for_event.exclude(status=Market.Status.DRAFT)
# Prefetch options with total_bets annotation sorted by total_bets desc, then text asc
options_queryset = MarketOption.objects.annotate(
total_bets=Coalesce(Sum("user_bets__amount"), 0)
@@ -30,38 +43,84 @@ def list_markets(request):
return markets.prefetch_related(Prefetch("options", queryset=options_queryset))
@router.get("/user/bets", response=List[UserBetSchema])
def list_user_bets(request):
@router.get(f"{PREFIX}/user/bets", response=List[UserBetSchema])
@decorate_view(require_game_enabled(path="market"))
def list_user_bets(request, event_path: str):
"""List all bets placed by the current user."""
if not request.user.is_authenticated:
raise HttpError(401, "Authentication required")
return (
UserBet.objects.filter(user=request.user)
UserBet.objects.filter(user=request.user, option__market__event=request.event)
.select_related("option__market")
.prefetch_related("option__market__options")
)
@router.post("/{market_uuid}/actions/close")
def close_market(request, market_uuid: 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")
@decorate_view(require_game_enabled(path="market"))
def close_market(request, event_path: str, market_uuid: str):
"""Close a market. Admin only."""
if not request.user.is_staff:
raise HttpError(403, "Permission denied")
market = get_object_or_404(Market, uuid=market_uuid)
market = get_object_or_404(Market, uuid=market_uuid, event=request.event)
market.status = Market.Status.CLOSED
market.save(update_fields=["status", "updated_at"])
return {"status": Market.Status.CLOSED}
@router.post("/{market_uuid}/actions/resolve", response=MarketListSchema)
def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema):
@router.post(f"{PREFIX}/{{market_uuid}}/actions/resolve", response=MarketListSchema)
@decorate_view(require_game_enabled(path="market"))
def resolve_market(
request, event_path: str, market_uuid: str, payload: ResolveMarketSchema
):
"""Resolve a market with a winning option. Admin only."""
if not request.user.is_staff:
raise HttpError(403, "Permission denied")
market = get_object_or_404(Market, uuid=market_uuid)
market = get_object_or_404(Market, uuid=market_uuid, event=request.event)
winning_option = get_object_or_404(MarketOption, uuid=payload.winning_option_uuid)
if winning_option.market_id != market.id:
@@ -72,9 +131,7 @@ def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema):
market.save(update_fields=["winning_option", "status", "updated_at"])
# Calculate and distribute winnings
all_bets = list(
UserBet.objects.filter(option__market=market).select_related("user")
)
all_bets = list(UserBet.objects.filter(option__market=market))
# Calculate total pot
total_pot = sum(bet.amount for bet in all_bets)
@@ -88,7 +145,6 @@ def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema):
total_winning = sum(bet.amount for bet in winning_bets)
point_changes = []
users_to_update = []
with transaction.atomic():
# Award payouts to winners with multiplier
@@ -97,11 +153,9 @@ def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema):
payout = round(
bet.amount / total_winning * total_pot * market.multiplier
)
bet.user.points += payout
users_to_update.append(bet.user)
point_changes.append(
UserPointChange(
user=bet.user,
user_id=bet.user_id,
market=market,
amount=payout,
reason=UserPointChange.Reason.BET_WON,
@@ -112,30 +166,29 @@ def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema):
for bet in losing_bets:
point_changes.append(
UserPointChange(
user=bet.user,
user_id=bet.user_id,
market=market,
amount=-bet.amount,
reason=UserPointChange.Reason.BET_LOST,
)
)
# Bulk update users
for user in users_to_update:
user.save(update_fields=["points"])
# Bulk create point changes
UserPointChange.objects.bulk_create(point_changes)
return market
@router.post("/{market_uuid}/bets", response=UserBetSchema)
def create_bet(request, market_uuid: str, payload: UserBetCreateSchema):
@router.post(f"{PREFIX}/{{market_uuid}}/bets", response=UserBetSchema)
@decorate_view(require_game_enabled(path="market"))
def create_bet(
request, event_path: str, market_uuid: str, payload: UserBetCreateSchema
):
"""Place a bet on a market option."""
if not request.user.is_authenticated:
raise HttpError(401, "Authentication required")
market = get_object_or_404(Market, uuid=market_uuid)
market = get_object_or_404(Market, uuid=market_uuid, event=request.event)
option = get_object_or_404(MarketOption, uuid=payload.option_uuid)
if option.market_id != market.id:
@@ -161,8 +214,8 @@ def create_bet(request, market_uuid: str, payload: UserBetCreateSchema):
# Calculate delta (amount to deduct from user's points)
delta = payload.amount - (existing_bet.amount if existing_bet else 0)
# Check if user has enough points
if request.user.points < delta:
# Check if user has enough points for this event
if get_event_points(request.user, request.event) < delta:
raise HttpError(400, "Insufficient points for this bet")
user_bet, created = UserBet.objects.update_or_create(
@@ -171,9 +224,7 @@ def create_bet(request, market_uuid: str, payload: UserBetCreateSchema):
defaults={"amount": payload.amount},
)
# Deduct points and record the change
request.user.points -= delta
request.user.save(update_fields=["points"])
# Record the change (the event balance is derived from these)
UserPointChange.objects.create(
user=request.user,
market=market,
@@ -0,0 +1,24 @@
# Generated by Django 6.0.5 on 2026-09-15 21:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("events", "0002_seed_polylan_43"),
("market", "0006_alter_market_status"),
]
operations = [
migrations.AddField(
model_name="market",
name="event",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="markets",
to="events.event",
),
),
]
@@ -0,0 +1,25 @@
from django.db import migrations
EVENT_PATH = "polylan-43"
def backfill_event(apps, schema_editor):
Event = apps.get_model("events", "Event")
Market = apps.get_model("market", "Market")
event = Event.objects.get(path=EVENT_PATH)
Market.objects.filter(event__isnull=True).update(event=event)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("market", "0007_market_event"),
("events", "0002_seed_polylan_43"),
]
operations = [
migrations.RunPython(backfill_event, noop),
]
@@ -0,0 +1,20 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("market", "0008_backfill_event"),
]
operations = [
migrations.AlterField(
model_name="market",
name="event",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="markets",
to="events.event",
),
),
]
@@ -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),
]
+30
View File
@@ -1,5 +1,26 @@
import uuid
from django.db import models
from django.db.models import Sum
from events.models import Event
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):
@@ -18,6 +39,12 @@ class Market(BaseModel):
CLOSED = "closed", "Closed"
RESOLVED = "resolved", "Resolved"
event = models.ForeignKey(
Event,
on_delete=models.CASCADE,
related_name="markets",
)
title = models.CharField(max_length=255)
description = models.TextField(blank=True)
status = models.CharField(
@@ -34,6 +61,9 @@ class Market(BaseModel):
related_name="market_won",
)
objects = models.Manager()
for_event = EventScopedManager()
class Meta:
ordering = ["-created_at"]
indexes = [
+10
View File
@@ -35,6 +35,16 @@ class ResolveMarketSchema(Schema):
winning_option_uuid: str
class UserPointsSchema(Schema):
points: int
class BetHistoryPointSchema(Schema):
option_uuid: str
created_at: datetime
amount: int
class UserBetCreateSchema(Schema):
option_uuid: str
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)
+8 -3
View File
@@ -1,8 +1,13 @@
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from django.shortcuts import get_object_or_404, render
from django.http import HttpRequest
from events.models import Event
from games.decorators import require_game_enabled
@login_required
def market_home(request: HttpRequest):
return render(request, "market.html", {})
@require_game_enabled(path="market")
def market_home(request: HttpRequest, event_path: str):
get_object_or_404(Event, path=event_path)
return render(request, "app.html")
+9 -1
View File
@@ -1,11 +1,16 @@
from django.contrib import admin
from django_object_actions import (
DjangoObjectActions,
action,
takes_instance_or_queryset,
)
from noita.services.objectives import parse_objectives_and_store
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
@admin.register(LogfileSubmission)
class LogfileSubmissionAdmin(admin.ModelAdmin):
class LogfileSubmissionAdmin(DjangoObjectActions, admin.ModelAdmin):
list_display = (
"id",
"user",
@@ -26,7 +31,10 @@ class LogfileSubmissionAdmin(admin.ModelAdmin):
)
actions = ["validate_submission"]
change_actions = ["validate_submission"]
@action(label="Validate", description="Validate selected submissions")
@takes_instance_or_queryset
def validate_submission(self, request, queryset):
for logfile in queryset:
parse_objectives_and_store(logfile)
+25 -17
View File
@@ -25,11 +25,13 @@ from .schemas import NoitaSubmissionOut
router = Router()
NOITA_APP_ID = 881100
PREFIX = "/events/{event_path}/noita"
@router.get("results", response=ResultsOut)
@router.get(f"{PREFIX}/results", response=ResultsOut)
@decorate_view(require_game_enabled(NOITA_APP_ID))
def get_results(request: HttpRequest):
cache_key = f"api:noita:results:{request.user.id}"
def get_results(request: HttpRequest, event_path: str):
cache_key = f"api:{event_path}:noita:results:{request.user.id}"
cached_data = cache.get(cache_key)
if cached_data is not None:
@@ -42,7 +44,7 @@ def get_results(request: HttpRequest):
"""
# Group objectives by objectiv_id and count occurrences
user_objectives = (
Objectiv.objects.filter(user=request.user)
Objectiv.objects.filter(user=request.user, submission__event=request.event)
.values("objectiv_id")
.annotate(count=Count("id"))
)
@@ -118,7 +120,7 @@ def get_results(request: HttpRequest):
total_score += points
# Count deaths for the user
deaths_count = DeathCounter.objects.filter(user=request.user).count()
deaths_count = DeathCounter.for_event.filter(user=request.user).count()
data = {
"total_score": total_score,
@@ -126,19 +128,19 @@ def get_results(request: HttpRequest):
"objectives": list(with_points.values()),
}
cache.set(f"api:noita:results:{request.user.id}", data, 300)
cache.set(cache_key, data, 300)
return data
@router.get("leaderboard", response=LeaderboardOut)
@router.get(f"{PREFIX}/leaderboard", response=LeaderboardOut)
@decorate_view(require_game_enabled(NOITA_APP_ID))
def get_leaderboard(request: HttpRequest):
def get_leaderboard(request: HttpRequest, event_path: str):
"""
Get the global leaderboard for all users ranked by total score.
Uses Window functions to rank users by their total score in descending order.
"""
cache_key = "api:noita:leaderboard"
cache_key = f"api:{event_path}:noita:leaderboard"
cached_data = cache.get(cache_key)
if cached_data is not None:
@@ -150,7 +152,8 @@ def get_leaderboard(request: HttpRequest):
# Get all objectives with calculated points (grouped by objectiv_id and user)
all_objectives = (
Objectiv.objects.values("user", "objectiv_id")
Objectiv.objects.filter(submission__event=request.event)
.values("user", "objectiv_id")
.annotate(count=Count("id"))
.annotate(
# Fetch points from ObjectivPoint using Subquery
@@ -201,12 +204,12 @@ def get_leaderboard(request: HttpRequest):
for user_id, total_score in user_totals_dict.items():
user = User.objects.get(id=user_id)
objectives_count = (
Objectiv.objects.filter(user_id=user_id)
Objectiv.objects.filter(user_id=user_id, submission__event=request.event)
.values("objectiv_id")
.distinct()
.count()
)
deaths_count = DeathCounter.objects.filter(user_id=user_id).count()
deaths_count = DeathCounter.for_event.filter(user_id=user_id).count()
users_with_scores.append(
{
"user_id": user_id,
@@ -232,13 +235,17 @@ def get_leaderboard(request: HttpRequest):
]
data = {"leaderboard": leaderboard}
cache.set("api:noita:leaderboard", data, 300)
cache.set(cache_key, data, 300)
return data
@router.post("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))
def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
def submit_log_file(
request: HttpRequest, event_path: str, file: UploadedFile = File(...)
):
"""
Submit a Noita run file (log file, screenshot, or video).
@@ -267,6 +274,7 @@ def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
try:
# Create submission
submission = LogfileSubmission.objects.create(
event=request.event,
user=request.user if request.user.is_authenticated else None,
content_type=file.content_type,
file_size=file.size,
@@ -284,8 +292,8 @@ def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
# Invalidate caches on successful submission
if submission.user:
cache.delete(f"api:noita:results:{submission.user.id}")
cache.delete("api:noita:leaderboard")
cache.delete(f"api:{event_path}:noita:results:{submission.user.id}")
cache.delete(f"api:{event_path}:noita:leaderboard")
return {
"id": str(submission.id),
@@ -0,0 +1,34 @@
# Generated by Django 6.0.5 on 2026-09-15 21:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("events", "0002_seed_polylan_43"),
("noita", "0012_deathcounter_unique_death_per_seen_at"),
]
operations = [
migrations.AddField(
model_name="deathcounter",
name="event",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="death_counters",
to="events.event",
),
),
migrations.AddField(
model_name="logfilesubmission",
name="event",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="logfile_submissions",
to="events.event",
),
),
]
@@ -0,0 +1,27 @@
from django.db import migrations
EVENT_PATH = "polylan-43"
def backfill_event(apps, schema_editor):
Event = apps.get_model("events", "Event")
LogfileSubmission = apps.get_model("noita", "LogfileSubmission")
DeathCounter = apps.get_model("noita", "DeathCounter")
event = Event.objects.get(path=EVENT_PATH)
LogfileSubmission.objects.filter(event__isnull=True).update(event=event)
DeathCounter.objects.filter(event__isnull=True).update(event=event)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("noita", "0013_deathcounter_event_logfilesubmission_event"),
("events", "0002_seed_polylan_43"),
]
operations = [
migrations.RunPython(backfill_event, noop),
]
@@ -0,0 +1,29 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("noita", "0014_backfill_event"),
]
operations = [
migrations.AlterField(
model_name="deathcounter",
name="event",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="death_counters",
to="events.event",
),
),
migrations.AlterField(
model_name="logfilesubmission",
name="event",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="logfile_submissions",
to="events.event",
),
),
]
+20
View File
@@ -4,6 +4,9 @@ from django.utils import timezone
import uuid
from events.models import Event
from events.scoping import EventScopedManager
User = get_user_model()
@@ -21,6 +24,12 @@ class LogfileSubmission(models.Model):
# Identification
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
event = models.ForeignKey(
Event,
on_delete=models.CASCADE,
related_name="logfile_submissions",
)
content_type = models.CharField(max_length=100, help_text="MIME type of the file")
file_size = models.PositiveIntegerField(help_text="File size in bytes")
file = models.FileField(
@@ -44,6 +53,9 @@ class LogfileSubmission(models.Model):
processed = models.BooleanField(default=False)
objects = models.Manager()
for_event = EventScopedManager()
class Objectiv(models.Model):
objectiv_id = models.CharField(max_length=64)
@@ -71,10 +83,18 @@ class ObjectivPoint(models.Model):
class DeathCounter(models.Model):
event = models.ForeignKey(
Event,
on_delete=models.CASCADE,
related_name="death_counters",
)
user = models.ForeignKey(User, on_delete=models.CASCADE)
seed = models.CharField(max_length=32)
seen_at = models.DateTimeField()
objects = models.Manager()
for_event = EventScopedManager()
class Meta:
constraints = [
models.UniqueConstraint(
@@ -31,7 +31,11 @@ def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
continue
if idx == "DEATH":
deaths.append(DeathCounter(user=logfile.user, seed=seed, seen_at=ts))
deaths.append(
DeathCounter(
event=logfile.event, user=logfile.user, seed=seed, seen_at=ts
)
)
continue
objectives.append(
+1
View File
@@ -3,4 +3,5 @@ import { defineConfig } from '@hey-api/openapi-ts';
export default defineConfig({
input: `http://localhost:7777/api/openapi.json`,
output: 'src/api/',
plugins: ['@hey-api/typescript', '@hey-api/sdk', '@tanstack/vue-query'],
});
+25 -11
View File
@@ -1,6 +1,11 @@
from django.contrib import admin
from django.utils.html import format_html
from django.utils import timezone
from django_object_actions import (
DjangoObjectActions,
action,
takes_instance_or_queryset,
)
from opus_magnum.models import (
SteamAPIKey,
SteamCollection,
@@ -90,7 +95,16 @@ class SteamCollectionAdmin(admin.ModelAdmin):
fieldsets = (
(
"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")}),
(
@@ -185,7 +199,7 @@ class PuzzleResponseInline(admin.TabularInline):
@admin.register(Submission)
class SubmissionAdmin(admin.ModelAdmin):
class SubmissionAdmin(DjangoObjectActions, admin.ModelAdmin):
list_display = [
"id",
"user",
@@ -238,9 +252,11 @@ class SubmissionAdmin(admin.ModelAdmin):
)
actions = ["mark_as_validated"]
change_actions = ["mark_as_validated"]
@action(label="Validate", description="Mark selected submissions as validated")
@takes_instance_or_queryset
def mark_as_validated(self, request, queryset):
"""Mark selected submissions as validated"""
updated = 0
for submission in queryset:
if not submission.is_validated:
@@ -254,11 +270,9 @@ class SubmissionAdmin(admin.ModelAdmin):
self.message_user(request, f"{updated} submissions marked as validated.")
mark_as_validated.short_description = "Mark selected submissions as validated"
@admin.register(PuzzleResponse)
class PuzzleResponseAdmin(admin.ModelAdmin):
class PuzzleResponseAdmin(DjangoObjectActions, admin.ModelAdmin):
list_display = [
"puzzle_name",
"submission",
@@ -314,22 +328,22 @@ class PuzzleResponseAdmin(admin.ModelAdmin):
)
actions = ["mark_for_validation", "clear_validation_flag"]
change_actions = ["mark_for_validation", "clear_validation_flag"]
@action(label="Mark for validation", description="Mark as needing validation")
@takes_instance_or_queryset
def mark_for_validation(self, request, queryset):
"""Mark selected responses as needing validation"""
updated = queryset.update(needs_manual_validation=True)
self.message_user(request, f"{updated} responses marked for validation.")
@action(label="Clear validation flag", description="Clear validation flag")
@takes_instance_or_queryset
def clear_validation_flag(self, request, queryset):
"""Clear validation flag for selected responses"""
updated = queryset.update(needs_manual_validation=False)
self.message_user(
request, f"{updated} responses cleared from validation queue."
)
mark_for_validation.short_description = "Mark as needing validation"
clear_validation_flag.short_description = "Clear validation flag"
@admin.register(SubmissionFile)
class SubmissionFileAdmin(admin.ModelAdmin):
+147 -50
View File
@@ -1,3 +1,5 @@
import csv
from ninja import Router, File
from ninja.files import UploadedFile
from ninja.pagination import paginate
@@ -5,6 +7,8 @@ from ninja.errors import HttpError
from django.db import transaction
from django.core.files.base import ContentFile
from django.core.cache import cache
from django.http import HttpRequest, HttpResponse
from ninja.security import django_auth_is_staff
from django.utils import timezone
from django.shortcuts import get_object_or_404
from typing import List
@@ -25,6 +29,7 @@ from .schemas import (
SubmissionOut,
PuzzleResponseOut,
ValidationIn,
SubmissionFileOut,
SteamCollectionItemOut,
SteamCollectionOut,
)
@@ -32,50 +37,53 @@ from .schemas import (
router = Router()
OPUS_MAGNUM_APP_ID = 558990
PREFIX = "/events/{event_path}/opus-magnum"
@router.get("/puzzles", response=List[SteamCollectionItemOut])
@router.get(f"{PREFIX}/puzzles", response=List[SteamCollectionItemOut])
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def list_puzzles(request):
def list_puzzles(request, event_path: str):
"""Get list of available puzzles"""
return SteamCollectionItem.objects.select_related("collection").filter(
collection__is_active=True
collection__is_active=True, collection__event=request.event
)
@router.get("/collection", response=SteamCollectionOut)
@router.get(f"{PREFIX}/collection", response=SteamCollectionOut)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def get_collection(request):
def get_collection(request, event_path: str):
"""Get the active collection details"""
collection = get_object_or_404(SteamCollection, is_active=True)
return collection
return get_object_or_404(SteamCollection.for_event, is_active=True)
@router.get("/submissions", response=List[SubmissionOut])
@router.get(f"{PREFIX}/submissions", response=List[SubmissionOut])
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
@paginate
def list_submissions(request):
def list_submissions(request, event_path: str):
"""Get paginated list of submissions"""
return Submission.objects.prefetch_related(
return Submission.for_event.prefetch_related(
"responses__files", "responses__puzzle"
).filter(user=request.user)
@router.get("/submissions/{submission_id}", response=SubmissionOut)
@router.get(f"{PREFIX}/submissions/{{submission_id}}", response=SubmissionOut)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def get_submission(request, submission_id: str):
def get_submission(request, event_path: str, submission_id: str):
"""Get detailed submission by ID"""
return get_object_or_404(
Submission.objects.prefetch_related(
Submission.for_event.prefetch_related(
"responses__files", "responses__puzzle"
).filter(user=request.user),
id=submission_id,
)
@router.post("/submissions", response=SubmissionOut)
@router.post(
f"{PREFIX}/submissions", response={200: SubmissionOut, 400: dict, 500: dict}
)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def create_submission(
request, data: SubmissionIn, files: List[UploadedFile] = File(...)
request, event_path: str, data: SubmissionIn, files: List[UploadedFile] = File(...)
):
"""Create a new submission with multiple puzzle responses"""
@@ -92,7 +100,9 @@ def create_submission(
if data.responses:
for puzzle in data.responses:
if not get_object_or_404(
SteamCollectionItem, id=puzzle.puzzle_id
SteamCollectionItem,
id=puzzle.puzzle_id,
collection__event=request.event,
).collection.accepting_submissions:
raise HttpError(
403, "This tournament is no longer accepting submissions"
@@ -117,19 +127,26 @@ def create_submission(
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
submission = Submission.objects.create(
event=request.event,
user=request.user if request.user.is_authenticated else None,
notes=data.notes,
manual_validation_requested=data.manual_validation_requested
or auto_request_validation,
manual_validation_requested=needs_validation or auto_request_validation,
)
file_index = 0
for response_data in data.responses:
# Get the puzzle
puzzle = get_object_or_404(
SteamCollectionItem, id=response_data.puzzle_id
SteamCollectionItem,
id=response_data.puzzle_id,
collection__event=request.event,
)
# Create the puzzle response
@@ -140,7 +157,7 @@ def create_submission(
cost=response_data.cost,
cycles=response_data.cycles,
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_cycles=response_data.ocr_confidence_cycles,
ocr_confidence_area=response_data.ocr_confidence_area,
@@ -150,7 +167,7 @@ def create_submission(
"validated_cycles": response_data.cycles,
"validated_area": response_data.area,
}
if not data.manual_validation_requested
if not needs_validation
else {},
)
@@ -192,12 +209,12 @@ def create_submission(
response.mark_for_validation("Incomplete OCR data")
# Reload with relations for response
submission = Submission.objects.prefetch_related(
submission = Submission.for_event.prefetch_related(
"responses__files", "responses__puzzle"
).get(id=submission.id)
# Invalidate results cache on successful submission
cache.delete("api:results:results")
cache.delete(f"api:{event_path}:results:results")
return submission
@@ -205,18 +222,25 @@ def create_submission(
return 500, {"detail": f"Error creating submission: {str(e)}"}
@router.put("/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))
def validate_response(request, response_id: int, data: ValidationIn):
def validate_response(request, event_path: str, response_id: int, data: ValidationIn):
"""Manually validate a puzzle response"""
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)
response = get_object_or_404(
PuzzleResponse, id=response_id, submission__event=request.event
)
if data.puzzle is not None:
puzzle = get_object_or_404(SteamCollectionItem, id=data.puzzle)
puzzle = get_object_or_404(
SteamCollectionItem, id=data.puzzle, collection__event=request.event
)
response.puzzle = puzzle
# Update validated values
@@ -236,20 +260,25 @@ def validate_response(request, response_id: int, data: ValidationIn):
response.save()
# Invalidate results cache when a response is validated
cache.delete("api:results:results")
cache.delete(f"api:{event_path}:results:results")
return response
@router.put("/responses/{response_id}/validate/auto", response=PuzzleResponseOut)
@router.put(
f"{PREFIX}/responses/{{response_id}}/validate/auto",
response={200: PuzzleResponseOut, 403: dict},
)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def validate_auto(request, response_id: int):
def validate_auto(request, event_path: str, response_id: int):
"""Try to auto validate a puzzle response"""
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)
response = get_object_or_404(
PuzzleResponse, id=response_id, submission__event=request.event
)
for file in response.files.all():
verify_and_validate_ocr_date_for_submission(file)
@@ -257,9 +286,12 @@ def validate_auto(request, response_id: int):
return response
@router.get("/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))
def list_responses_needing_validation(request):
def list_responses_needing_validation(request, event_path: str):
"""Get all responses that need manual validation"""
if not request.user.is_authenticated or not request.user.is_staff:
@@ -267,21 +299,44 @@ def list_responses_needing_validation(request):
return (
PuzzleResponse.objects.filter(needs_manual_validation=True)
.filter(puzzle__collection__is_active=True)
.filter(
puzzle__collection__is_active=True, puzzle__collection__event=request.event
)
.select_related("puzzle", "submission")
.prefetch_related("files")
)
@router.post("/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 validate_submission(request, submission_id: str):
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))
def validate_submission(request, event_path: str, submission_id: str):
"""Mark entire submission as validated"""
if not request.user.is_authenticated or not request.user.is_staff:
return 403, {"detail": "Admin access required"}
submission = get_object_or_404(Submission, id=submission_id)
submission = get_object_or_404(Submission.for_event, id=submission_id)
submission.is_validated = True
submission.validated_by = request.user
@@ -292,44 +347,48 @@ def validate_submission(request, submission_id: str):
submission.responses.update(needs_manual_validation=False)
# Reload with relations
submission = Submission.objects.prefetch_related(
submission = Submission.for_event.prefetch_related(
"responses__files", "responses__puzzle"
).get(id=submission.id)
# Invalidate results cache when submission is validated
cache.delete("api:results:results")
cache.delete(f"api:{event_path}:results:results")
return submission
@router.delete("/submissions/{submission_id}")
@router.delete(
f"{PREFIX}/submissions/{{submission_id}}", response={200: dict, 403: dict}
)
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def delete_submission(request, submission_id: str):
def delete_submission(request, event_path: str, submission_id: str):
"""Delete a submission (admin only)"""
if not request.user.is_authenticated or not request.user.is_staff:
return 403, {"detail": "Admin access required"}
submission = get_object_or_404(Submission, id=submission_id)
submission = get_object_or_404(Submission.for_event, id=submission_id)
submission.delete()
# Invalidate results cache when submission is deleted
cache.delete("api:results:results")
cache.delete(f"api:{event_path}:results:results")
return {"detail": "Submission deleted successfully"}
@router.get("/stats")
@router.get(f"{PREFIX}/stats")
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
def get_stats(request):
def get_stats(request, event_path: str):
"""Get submission statistics"""
total_submissions = Submission.objects.count()
total_responses = PuzzleResponse.objects.count()
needs_validation = PuzzleResponse.objects.filter(
needs_manual_validation=True
total_submissions = Submission.for_event.count()
total_responses = PuzzleResponse.objects.filter(
submission__event=request.event
).count()
validated_submissions = Submission.objects.filter(is_validated=True).count()
needs_validation = PuzzleResponse.objects.filter(
needs_manual_validation=True, submission__event=request.event
).count()
validated_submissions = Submission.for_event.filter(is_validated=True).count()
return {
"total_submissions": total_submissions,
@@ -340,3 +399,41 @@ def get_stats(request):
if total_responses
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 opus_magnum.utils import create_or_update_collection
from events.models import Event
from opus_magnum.models import SteamAPIKey, SteamCollection
@@ -12,6 +13,12 @@ class Command(BaseCommand):
def add_arguments(self, parser):
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(
"--force",
action="store_true",
@@ -22,6 +29,11 @@ class Command(BaseCommand):
url = options["url"]
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}")
api_key = SteamAPIKey.objects.filter(is_active=True).first()
@@ -53,7 +65,7 @@ class Command(BaseCommand):
return
# Fetch and create/update collection
collection, created = create_or_update_collection(url)
collection, created = create_or_update_collection(url, event)
if created:
self.stdout.write(
@@ -3,45 +3,31 @@
from django.db import migrations
TABLE_PAIRS = [
("submissions_steamapikey", "opus_magnum_steamapikey"),
("submissions_steamcollection", "opus_magnum_steamcollection"),
("submissions_steamcollectionitem", "opus_magnum_steamcollectionitem"),
("submissions_submission", "opus_magnum_submission"),
("submissions_puzzleresponse", "opus_magnum_puzzleresponse"),
("submissions_submissionfile", "opus_magnum_submissionfile"),
]
def migrate_data_forward(apps, schema_editor):
"""Copy data from submissions_* tables to opus_magnum_* tables"""
# Use raw SQL to copy data while preserving all fields
"""Copy data from submissions_* tables to opus_magnum_* tables.
The source tables only exist on databases that predate the submissions ->
opus_magnum rename; a database built fresh from this migration history
never has them, so skip pairs whose source table is absent instead of
failing.
"""
existing_tables = set(schema_editor.connection.introspection.table_names())
with schema_editor.connection.cursor() as cursor:
# Copy SteamAPIKey
cursor.execute("""
INSERT INTO opus_magnum_steamapikey
SELECT * FROM submissions_steamapikey
""")
# Copy SteamCollection
cursor.execute("""
INSERT INTO opus_magnum_steamcollection
SELECT * FROM submissions_steamcollection
""")
# Copy SteamCollectionItem
cursor.execute("""
INSERT INTO opus_magnum_steamcollectionitem
SELECT * FROM submissions_steamcollectionitem
""")
# Copy Submission
cursor.execute("""
INSERT INTO opus_magnum_submission
SELECT * FROM submissions_submission
""")
# Copy PuzzleResponse
cursor.execute("""
INSERT INTO opus_magnum_puzzleresponse
SELECT * FROM submissions_puzzleresponse
""")
# Copy SubmissionFile
cursor.execute("""
INSERT INTO opus_magnum_submissionfile
SELECT * FROM submissions_submissionfile
""")
for source, dest in TABLE_PAIRS:
if source not in existing_tables:
continue
cursor.execute(f"INSERT INTO {dest} SELECT * FROM {source}")
def migrate_data_backward(apps, schema_editor):
@@ -0,0 +1,34 @@
# Generated by Django 6.0.5 on 2026-09-15 21:09
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("events", "0002_seed_polylan_43"),
("opus_magnum", "0015_migrate_data_from_submissions"),
]
operations = [
migrations.AddField(
model_name="steamcollection",
name="event",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="steam_collections",
to="events.event",
),
),
migrations.AddField(
model_name="submission",
name="event",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="opus_magnum_submissions",
to="events.event",
),
),
]
@@ -0,0 +1,27 @@
from django.db import migrations
EVENT_PATH = "polylan-43"
def backfill_event(apps, schema_editor):
Event = apps.get_model("events", "Event")
SteamCollection = apps.get_model("opus_magnum", "SteamCollection")
Submission = apps.get_model("opus_magnum", "Submission")
event = Event.objects.get(path=EVENT_PATH)
SteamCollection.objects.filter(event__isnull=True).update(event=event)
Submission.objects.filter(event__isnull=True).update(event=event)
def noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("opus_magnum", "0016_steamcollection_event_submission_event"),
("events", "0002_seed_polylan_43"),
]
operations = [
migrations.RunPython(backfill_event, noop),
]
@@ -0,0 +1,29 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("opus_magnum", "0017_backfill_event"),
]
operations = [
migrations.AlterField(
model_name="steamcollection",
name="event",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="steam_collections",
to="events.event",
),
),
migrations.AlterField(
model_name="submission",
name="event",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="opus_magnum_submissions",
to="events.event",
),
),
]
@@ -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
),
),
]
+24 -3
View File
@@ -8,6 +8,9 @@ from django.db.models.expressions import Window
from django.db.models.functions import Cast, Rank, RowNumber
from django.db.models.query import F
from events.models import Event
from events.scoping import EventScopedManager
User = get_user_model()
@@ -108,6 +111,12 @@ class SteamAPIKey(models.Model):
class SteamCollection(models.Model):
"""Model representing a Steam Workshop collection"""
event = models.ForeignKey(
Event,
on_delete=models.CASCADE,
related_name="steam_collections",
)
# Basic collection info
steam_id = models.CharField(
max_length=50, unique=True, help_text="Steam collection ID from URL"
@@ -164,6 +173,9 @@ class SteamCollection(models.Model):
blank=True, help_text="Last error encountered when fetching data"
)
objects = models.Manager()
for_event = EventScopedManager()
class Meta:
ordering = ["-created_at"]
verbose_name = "Steam Collection"
@@ -257,6 +269,12 @@ class Submission(models.Model):
# Identification
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
event = models.ForeignKey(
Event,
on_delete=models.CASCADE,
related_name="opus_magnum_submissions",
)
# User information (optional for anonymous submissions)
user = models.ForeignKey(
User,
@@ -299,6 +317,9 @@ class Submission(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = models.Manager()
for_event = EventScopedManager()
class Meta:
ordering = ["-created_at"]
verbose_name = "Submission"
@@ -379,9 +400,9 @@ class PuzzleResponse(models.Model):
puzzle_name = models.CharField(
max_length=255, help_text="Puzzle name as detected by OCR"
)
cost = models.IntegerField(blank=True, help_text="Cost value from OCR")
cycles = models.IntegerField(blank=True, help_text="Cycles value from OCR")
area = models.IntegerField(blank=True, help_text="Area value from OCR")
cost = models.IntegerField(null=True, blank=True, help_text="Cost value from OCR")
cycles = models.IntegerField(null=True, blank=True, help_text="Cycles value from OCR")
area = models.IntegerField(null=True, blank=True, help_text="Area value from OCR")
# Validation flags
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 requests
from events.models import Event
from opus_magnum.models import SteamCollection, SteamCollectionItem, SubmissionFile
from datetime import datetime
from django.utils import timezone
@@ -373,12 +374,15 @@ def fetch_steam_collection(url: str) -> Dict:
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
Args:
url: Steam Workshop collection URL
event: Event the collection belongs to
Returns:
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(
steam_id=data["steam_id"],
defaults={
"event": event,
"url": data["url"],
"title": data["title"],
"description": data["description"],

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