Compare commits
54
Commits
e38b610456
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
407cc22911
|
||
|
|
5fc79197f6
|
||
|
|
73b279d7fe
|
||
|
|
1844b973e9
|
||
|
|
6cee1135a8
|
||
|
|
4f2cf08b27
|
||
|
|
9bb02463da
|
||
|
|
0b34e66d81
|
||
|
|
d180eb3c90
|
||
|
|
5c70c38523
|
||
|
|
026d592483
|
||
|
|
87e6e38291
|
||
|
|
723da9aa38
|
||
|
|
421106bf71
|
||
|
|
f42649b888
|
||
|
|
f75a1b0338
|
||
|
|
1c6d9202fc
|
||
|
|
bc3d8158ec
|
||
|
|
6e7a68d509
|
||
|
|
2c4dac628e
|
||
|
|
cf51b187d2
|
||
|
|
b5716d6dcd
|
||
|
|
ad7726d978
|
||
|
|
89fb745e74
|
||
|
|
2f8fdb755b
|
||
|
|
54ffe89445
|
||
|
|
377bc91bcb
|
||
|
|
60d730a683
|
||
|
|
1daf2757b8
|
||
|
|
a110575e0a
|
||
|
|
bfaef2e402
|
||
|
|
3d040dd377
|
||
|
|
a6ea3719b9
|
||
|
|
13c09adff3
|
||
|
|
de5ee8ae7a
|
||
|
|
8daa91d6c5
|
||
|
|
acd08e216a
|
||
|
|
b403311ce1
|
||
|
|
1ca4643774
|
||
|
|
27d3c08a72
|
||
|
|
919e611895
|
||
|
|
107fe8b69b
|
||
|
|
15ab9a14ac
|
||
|
|
b837abdba0
|
||
|
|
4fd683ff2c
|
||
|
|
8509be548e
|
||
|
|
382ba01f3e
|
||
|
|
6a5a224d3b
|
||
|
|
fdf84f9524
|
||
|
|
e8b018ec49
|
||
|
|
b74d20cbe0
|
||
|
|
59b568ff41
|
||
|
|
979be91417
|
||
|
|
28d2a7ad24
|
@@ -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
|
||||
@@ -178,3 +178,4 @@ pyrightconfig.json
|
||||
tags
|
||||
media/
|
||||
settingsLocal.py
|
||||
deploy/docker/polylan-submitter.env
|
||||
|
||||
+75
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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 "$@"
|
||||
@@ -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=
|
||||
Executable
+19
@@ -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"
|
||||
@@ -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
|
||||
Executable
+125
@@ -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
|
||||
@@ -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
|
||||
@@ -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}).",
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
@@ -54,8 +54,8 @@ def current_state(request: HttpRequest, event_path: str):
|
||||
def my_stats(request: HttpRequest, event_path: str):
|
||||
ranked = list(
|
||||
ranked_players(request.event)
|
||||
.values("id", "points", "rank")
|
||||
.order_by("-points", "id")
|
||||
.values("id", "clock_points", "rank")
|
||||
.order_by("-clock_points", "id")
|
||||
)
|
||||
total_players = len(ranked)
|
||||
my_index = next(
|
||||
@@ -71,9 +71,9 @@ def my_stats(request: HttpRequest, event_path: str):
|
||||
)
|
||||
|
||||
return {
|
||||
"points": mine["points"] if mine else 0.0,
|
||||
"points": mine["clock_points"] if mine else 0.0,
|
||||
"rank": mine["rank"] if mine else total_players,
|
||||
"total_players": total_players,
|
||||
"points_above": above["points"] if above else None,
|
||||
"points_below": below["points"] if below else None,
|
||||
"points_above": above["clock_points"] if above else None,
|
||||
"points_below": below["clock_points"] if below else None,
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ 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.event = await self._get_enabled_event(
|
||||
self.event_path, self.scope.get("user")
|
||||
)
|
||||
if self.event is None:
|
||||
await self.close()
|
||||
return
|
||||
@@ -62,8 +64,8 @@ class ClockConsumer(AsyncWebsocketConsumer):
|
||||
)
|
||||
|
||||
@database_sync_to_async
|
||||
def _get_enabled_event(self, event_path):
|
||||
event = Event.objects.filter(path=event_path).first()
|
||||
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():
|
||||
|
||||
@@ -6,7 +6,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
||||
@@ -58,7 +58,9 @@ class ClockTimer(models.Model):
|
||||
|
||||
|
||||
class Press(models.Model):
|
||||
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="clock_presses")
|
||||
event = models.ForeignKey(
|
||||
Event, on_delete=models.CASCADE, related_name="clock_presses"
|
||||
)
|
||||
player = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="clock_presses"
|
||||
)
|
||||
|
||||
@@ -22,7 +22,8 @@ def _press_points_expression():
|
||||
|
||||
|
||||
def ranked_players(event: Event):
|
||||
"""All users annotated with their live total points for this event and
|
||||
"""All users annotated with their live total `clock_points` (not `points`,
|
||||
that one is the market balance field on CustomUser) for this event and
|
||||
DB-computed RANK(). Points only ever come from presses made in `event` -
|
||||
a player's history in another event never leaks into this leaderboard."""
|
||||
from accounts.models import CustomUser
|
||||
@@ -32,6 +33,6 @@ def ranked_players(event: Event):
|
||||
Value(0.0),
|
||||
output_field=FloatField(),
|
||||
)
|
||||
return CustomUser.objects.annotate(points=points).annotate(
|
||||
rank=Window(expression=Rank(), order_by=F("points").desc())
|
||||
return CustomUser.objects.annotate(clock_points=points).annotate(
|
||||
rank=Window(expression=Rank(), order_by=F("clock_points").desc())
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ acts.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
@@ -30,13 +31,20 @@ HEARTBEAT_INTERVAL_SECONDS = 1
|
||||
# One chain ticks every event with the game enabled - this lock keeps a
|
||||
# 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.
|
||||
# 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():
|
||||
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"
|
||||
@@ -46,7 +54,8 @@ def clock_heartbeat_task():
|
||||
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.
|
||||
cache.delete(HEARTBEAT_LOCK_KEY)
|
||||
if cache.get(HEARTBEAT_LOCK_KEY) == chain_id:
|
||||
cache.delete(HEARTBEAT_LOCK_KEY)
|
||||
return
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
@@ -67,13 +76,14 @@ def clock_heartbeat_task():
|
||||
},
|
||||
)
|
||||
|
||||
cache.set(HEARTBEAT_LOCK_KEY, 1, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS)
|
||||
cache.set(HEARTBEAT_LOCK_KEY, chain_id, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS)
|
||||
clock_heartbeat_task.using(
|
||||
run_after=timezone.now() + timedelta(seconds=HEARTBEAT_INTERVAL_SECONDS)
|
||||
).enqueue()
|
||||
).enqueue(chain_id)
|
||||
|
||||
|
||||
def start_clock_heartbeat():
|
||||
"""Enqueues the heartbeat chain, unless one is already running."""
|
||||
if cache.add(HEARTBEAT_LOCK_KEY, 1, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS):
|
||||
clock_heartbeat_task.enqueue()
|
||||
chain_id = uuid4().hex
|
||||
if cache.add(HEARTBEAT_LOCK_KEY, chain_id, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS):
|
||||
clock_heartbeat_task.enqueue(chain_id)
|
||||
|
||||
@@ -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))
|
||||
@@ -5,7 +5,9 @@ from .models import Event
|
||||
|
||||
@admin.register(Event)
|
||||
class EventAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "path", "created_at"]
|
||||
list_display = ["name", "path", "is_available", "created_at"]
|
||||
list_editable = ["is_available"]
|
||||
list_filter = ["is_available"]
|
||||
search_fields = ["name", "path"]
|
||||
prepopulated_fields = {"path": ("name",)}
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
|
||||
@@ -22,6 +22,9 @@ class EventContextMiddleware:
|
||||
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, path=event_path)
|
||||
request.event = get_object_or_404(
|
||||
Event.objects.available_to(getattr(request, "user", None)),
|
||||
path=event_path,
|
||||
)
|
||||
current_event.set(request.event)
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.5 on 2026-10-15 22:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("events", "0002_seed_polylan_43"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="event",
|
||||
name="is_available",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
@@ -14,9 +14,21 @@ class BaseModel(models.Model):
|
||||
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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.test import TestCase
|
||||
|
||||
@@ -32,3 +34,41 @@ class EventScopedManagerTests(TestCase):
|
||||
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
|
||||
)
|
||||
|
||||
@@ -5,9 +5,10 @@ from django_object_actions import (
|
||||
takes_instance_or_queryset,
|
||||
)
|
||||
|
||||
from submissions.tasks import sync_event_game_scopes_task
|
||||
from submissions.tasks import schedule_scope_sync, stop_scope_sync
|
||||
|
||||
from .models import EventGame, Game
|
||||
from .models import EventGame, Game, PlanningSlot
|
||||
from .tasks import refresh_steam_games_task
|
||||
|
||||
|
||||
class EventGameInline(admin.TabularInline):
|
||||
@@ -19,8 +20,30 @@ class EventGameInline(admin.TabularInline):
|
||||
class GameAdmin(admin.ModelAdmin):
|
||||
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)
|
||||
@@ -28,13 +51,35 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
list_display = ["game", "event", "enabled", "has_apibot_token"]
|
||||
list_filter = ["event", "enabled"]
|
||||
search_fields = ["game__name", "event__name"]
|
||||
actions = ["sync_scopes"]
|
||||
change_actions = ["sync_scopes"]
|
||||
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",
|
||||
@@ -44,11 +89,12 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
tokened = queryset.exclude(apibot_token__isnull=True).exclude(apibot_token="")
|
||||
skipped = queryset.count() - tokened.count()
|
||||
|
||||
for event_game in tokened:
|
||||
sync_event_game_scopes_task.enqueue(event_game.id)
|
||||
started = sum(schedule_scope_sync(event_game.id) for event_game in tokened)
|
||||
|
||||
self.message_user(
|
||||
request, f"Queued scope sync for {tokened.count()} EventGame(s)."
|
||||
request,
|
||||
f"Started scope sync for {started} EventGame(s)"
|
||||
f" ({tokened.count() - started} already running).",
|
||||
)
|
||||
if skipped:
|
||||
self.message_user(
|
||||
@@ -56,3 +102,20 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
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"]
|
||||
|
||||
@@ -3,7 +3,9 @@ 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()
|
||||
@@ -17,3 +19,19 @@ def list_games(request):
|
||||
@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())
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import json
|
||||
import sys
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
from events.models import Event
|
||||
from games.services.planning import (
|
||||
create_missing_steam_games,
|
||||
import_planning,
|
||||
slot_game_names,
|
||||
)
|
||||
from games.tasks import refresh_steam_games_task
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Import an event planning from the legacy PolyLAN planning JSON "
|
||||
"(slots missing from the file are deleted)"
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("event_path", help="Path of the event, e.g. polylan-43")
|
||||
parser.add_argument("json_file", help="Planning JSON file, '-' for stdin")
|
||||
parser.add_argument(
|
||||
"--no-steam",
|
||||
action="store_true",
|
||||
help="Don't look up unknown games on the Steam store",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
try:
|
||||
event = Event.objects.get(path=options["event_path"])
|
||||
except Event.DoesNotExist:
|
||||
raise CommandError(f"No event with path {options['event_path']!r}")
|
||||
|
||||
if options["json_file"] == "-":
|
||||
data = json.load(sys.stdin)
|
||||
else:
|
||||
with open(options["json_file"], encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
entries = data["list"] if isinstance(data, dict) else data
|
||||
with transaction.atomic():
|
||||
created, updated, deleted = import_planning(event, entries)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Planning of {event}: {created} created, {updated} updated, {deleted} deleted"
|
||||
)
|
||||
)
|
||||
|
||||
if options["no_steam"]:
|
||||
return
|
||||
|
||||
games = create_missing_steam_games(event, slot_game_names(entries))
|
||||
for game in games:
|
||||
self.stdout.write(f"+ {game.name} (Steam app {game.steam_app_id})")
|
||||
if games:
|
||||
# Prices, blurb, ... for the games page
|
||||
refresh_steam_games_task.enqueue(game_ids=[game.id for game in games])
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"{len(games)} game(s) found on Steam and added to {event}")
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from games.tasks import schedule_steam_refresh
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Start the periodic Steam store refresh loop (no-op if already running)"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if schedule_steam_refresh():
|
||||
self.stdout.write(self.style.SUCCESS("Steam store refresh scheduled"))
|
||||
else:
|
||||
self.stdout.write("Steam store refresh already scheduled")
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.5 on 2026-10-15 22:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0008_game_custom_path"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="description",
|
||||
field=models.TextField(blank=True, default=""),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 6.0.5 on 2026-10-15 22:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0009_game_description"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="steam_currency",
|
||||
field=models.CharField(blank=True, default="", max_length=3),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="steam_discount_percent",
|
||||
field=models.PositiveSmallIntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="steam_header_image",
|
||||
field=models.URLField(blank=True, default="", max_length=500),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="steam_info_updated_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="steam_is_free",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="steam_price_final",
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="steam_price_initial",
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="steam_short_description",
|
||||
field=models.TextField(blank=True, default=""),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.5 on 2026-10-16 00:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0010_game_steam_store_info"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="library_image",
|
||||
field=models.URLField(blank=True, default="", max_length=500),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 6.0.5 on 2026-10-16 03:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("events", "0003_event_is_available"),
|
||||
("games", "0011_game_library_image"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="PlanningSlot",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("source_pk", models.PositiveIntegerField()),
|
||||
("title", models.CharField(max_length=255)),
|
||||
("begin", models.DateTimeField()),
|
||||
("end", models.DateTimeField()),
|
||||
("column", models.PositiveSmallIntegerField(default=0)),
|
||||
("color", models.CharField(blank=True, default="", max_length=7)),
|
||||
("is_multi_anims", models.BooleanField(default=False)),
|
||||
("game_names", models.JSONField(blank=True, default=list)),
|
||||
(
|
||||
"event",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="planning_slots",
|
||||
to="events.event",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["begin", "column"],
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("event", "source_pk"), name="unique_event_planning_slot"
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -5,7 +5,7 @@ from events.models import Event
|
||||
|
||||
class GameQuerySet(models.QuerySet):
|
||||
def enabled_for_event(self, event):
|
||||
return self.exclude(event_games__event=event, event_games__enabled=False)
|
||||
return self.filter(event_games__event=event, event_games__enabled=True)
|
||||
|
||||
|
||||
class Game(models.Model):
|
||||
@@ -14,6 +14,20 @@ class Game(models.Model):
|
||||
path = models.CharField(max_length=100)
|
||||
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)
|
||||
@@ -28,6 +42,19 @@ class Game(models.Model):
|
||||
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(
|
||||
@@ -62,3 +89,37 @@ class EventGame(models.Model):
|
||||
|
||||
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})"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from ninja import Schema
|
||||
@@ -10,3 +11,88 @@ class GameOut(Schema):
|
||||
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)
|
||||
@@ -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)
|
||||
@@ -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"})
|
||||
@@ -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()
|
||||
@@ -15,8 +15,8 @@ class MarketOptionInline(admin.TabularInline):
|
||||
|
||||
@admin.register(Market)
|
||||
class MarketAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
list_display = ["title", "status", "end_date", "created_by", "created_at"]
|
||||
list_filter = ["status", "created_at"]
|
||||
list_display = ["title", "status", "event", "end_date", "created_by", "created_at"]
|
||||
list_filter = ["status", "created_at", "event"]
|
||||
search_fields = ["uuid", "title"]
|
||||
readonly_fields = [
|
||||
"uuid",
|
||||
@@ -27,7 +27,7 @@ class MarketAdmin(DjangoObjectActions, 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"]}),
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,6 +31,7 @@ PREFIX = "/events/{event_path}/market"
|
||||
|
||||
|
||||
@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.for_event.exclude(status=Market.Status.DRAFT)
|
||||
@@ -33,6 +44,7 @@ def list_markets(request, event_path: str):
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/user/bets", response=List[UserBetSchema])
|
||||
@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:
|
||||
@@ -45,7 +57,49 @@ def list_user_bets(request, event_path: str):
|
||||
)
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/user/points", response=UserPointsSchema)
|
||||
@decorate_view(require_game_enabled(path="market"))
|
||||
def get_user_points(request, event_path: str):
|
||||
"""Get the current user's points balance for this event."""
|
||||
if not request.user.is_authenticated:
|
||||
raise HttpError(401, "Authentication required")
|
||||
|
||||
return {"points": get_event_points(request.user, request.event)}
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/{{market_uuid}}/history", response=List[BetHistoryPointSchema])
|
||||
@decorate_view(require_game_enabled(path="market"))
|
||||
def bet_history(request, event_path: str, market_uuid: str):
|
||||
"""Points added to each option over time (anonymous, one entry per bet placed/increased)."""
|
||||
market = get_object_or_404(
|
||||
Market.objects.exclude(status=Market.Status.DRAFT),
|
||||
uuid=market_uuid,
|
||||
event=request.event,
|
||||
)
|
||||
|
||||
# A user can only bet on one option per market, so their option is unique
|
||||
option_by_user = dict(
|
||||
UserBet.objects.filter(option__market=market).values_list(
|
||||
"user_id", "option__uuid"
|
||||
)
|
||||
)
|
||||
changes = UserPointChange.objects.filter(
|
||||
market=market, reason=UserPointChange.Reason.BET_PLACED
|
||||
).order_by("created_at")
|
||||
|
||||
return [
|
||||
{
|
||||
"option_uuid": str(option_by_user[change.user_id]),
|
||||
"created_at": change.created_at,
|
||||
"amount": -change.amount,
|
||||
}
|
||||
for change in changes
|
||||
if change.user_id in option_by_user
|
||||
]
|
||||
|
||||
|
||||
@router.post(f"{PREFIX}/{{market_uuid}}/actions/close")
|
||||
@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:
|
||||
@@ -58,6 +112,7 @@ def close_market(request, event_path: str, market_uuid: str):
|
||||
|
||||
|
||||
@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
|
||||
):
|
||||
@@ -76,9 +131,7 @@ def resolve_market(
|
||||
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)
|
||||
@@ -92,7 +145,6 @@ def resolve_market(
|
||||
total_winning = sum(bet.amount for bet in winning_bets)
|
||||
|
||||
point_changes = []
|
||||
users_to_update = []
|
||||
|
||||
with transaction.atomic():
|
||||
# Award payouts to winners with multiplier
|
||||
@@ -101,11 +153,9 @@ def resolve_market(
|
||||
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,
|
||||
@@ -116,17 +166,13 @@ def resolve_market(
|
||||
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)
|
||||
|
||||
@@ -134,6 +180,7 @@ def resolve_market(
|
||||
|
||||
|
||||
@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
|
||||
):
|
||||
@@ -167,8 +214,8 @@ def create_bet(
|
||||
# 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(
|
||||
@@ -177,9 +224,7 @@ def create_bet(
|
||||
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,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),
|
||||
]
|
||||
@@ -1,10 +1,28 @@
|
||||
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):
|
||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,9 +3,11 @@ 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
|
||||
@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")
|
||||
|
||||
@@ -239,7 +239,9 @@ def get_leaderboard(request: HttpRequest, event_path: str):
|
||||
return data
|
||||
|
||||
|
||||
@router.post(f"{PREFIX}/submit", response={200: NoitaSubmissionOut, 400: dict})
|
||||
@router.post(
|
||||
f"{PREFIX}/submit", response={200: NoitaSubmissionOut, 400: dict, 500: dict}
|
||||
)
|
||||
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||
def submit_log_file(
|
||||
request: HttpRequest, event_path: str, file: UploadedFile = 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'],
|
||||
});
|
||||
|
||||
@@ -95,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")}),
|
||||
(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -73,7 +78,9 @@ def get_submission(request, event_path: str, submission_id: str):
|
||||
)
|
||||
|
||||
|
||||
@router.post(f"{PREFIX}/submissions", response=SubmissionOut)
|
||||
@router.post(
|
||||
f"{PREFIX}/submissions", response={200: SubmissionOut, 400: dict, 500: dict}
|
||||
)
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def create_submission(
|
||||
request, event_path: str, data: SubmissionIn, files: List[UploadedFile] = File(...)
|
||||
@@ -120,13 +127,17 @@ 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
|
||||
@@ -146,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,
|
||||
@@ -156,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 {},
|
||||
)
|
||||
|
||||
@@ -211,7 +222,10 @@ def create_submission(
|
||||
return 500, {"detail": f"Error creating submission: {str(e)}"}
|
||||
|
||||
|
||||
@router.put(f"{PREFIX}/responses/{{response_id}}/validate", response=PuzzleResponseOut)
|
||||
@router.put(
|
||||
f"{PREFIX}/responses/{{response_id}}/validate",
|
||||
response={200: PuzzleResponseOut, 403: dict},
|
||||
)
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def validate_response(request, event_path: str, response_id: int, data: ValidationIn):
|
||||
"""Manually validate a puzzle response"""
|
||||
@@ -252,7 +266,8 @@ def validate_response(request, event_path: str, response_id: int, data: Validati
|
||||
|
||||
|
||||
@router.put(
|
||||
f"{PREFIX}/responses/{{response_id}}/validate/auto", response=PuzzleResponseOut
|
||||
f"{PREFIX}/responses/{{response_id}}/validate/auto",
|
||||
response={200: PuzzleResponseOut, 403: dict},
|
||||
)
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def validate_auto(request, event_path: str, response_id: int):
|
||||
@@ -271,7 +286,10 @@ def validate_auto(request, event_path: str, response_id: int):
|
||||
return response
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/responses/needs-validation", response=List[PuzzleResponseOut])
|
||||
@router.get(
|
||||
f"{PREFIX}/responses/needs-validation",
|
||||
response={200: List[PuzzleResponseOut], 403: dict},
|
||||
)
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def list_responses_needing_validation(request, event_path: str):
|
||||
"""Get all responses that need manual validation"""
|
||||
@@ -289,7 +307,28 @@ def list_responses_needing_validation(request, event_path: str):
|
||||
)
|
||||
|
||||
|
||||
@router.post(f"{PREFIX}/submissions/{{submission_id}}/validate", response=SubmissionOut)
|
||||
@router.get(
|
||||
f"{PREFIX}/responses/{{response_id}}/files",
|
||||
response={200: List[SubmissionFileOut], 403: dict},
|
||||
)
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def list_response_files(request, event_path: str, response_id: int):
|
||||
"""Get the solution files of any player's response (admin only)"""
|
||||
|
||||
if not request.user.is_authenticated or not request.user.is_staff:
|
||||
return 403, {"detail": "Admin access required"}
|
||||
|
||||
response = get_object_or_404(
|
||||
PuzzleResponse, id=response_id, submission__event=request.event
|
||||
)
|
||||
|
||||
return response.files.all()
|
||||
|
||||
|
||||
@router.post(
|
||||
f"{PREFIX}/submissions/{{submission_id}}/validate",
|
||||
response={200: SubmissionOut, 403: dict},
|
||||
)
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def validate_submission(request, event_path: str, submission_id: str):
|
||||
"""Mark entire submission as validated"""
|
||||
@@ -318,7 +357,9 @@ def validate_submission(request, event_path: str, submission_id: str):
|
||||
return submission
|
||||
|
||||
|
||||
@router.delete(f"{PREFIX}/submissions/{{submission_id}}")
|
||||
@router.delete(
|
||||
f"{PREFIX}/submissions/{{submission_id}}", response={200: dict, 403: dict}
|
||||
)
|
||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||
def delete_submission(request, event_path: str, submission_id: str):
|
||||
"""Delete a submission (admin only)"""
|
||||
@@ -358,3 +399,41 @@ def get_stats(request, event_path: str):
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -400,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(
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@tanstack/query-persist-client-core": "^5.104.3",
|
||||
"@tanstack/query-sync-storage-persister": "^5.104.3",
|
||||
"@tanstack/vue-query": "^5.104.3",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vueuse/core": "^14.0.0",
|
||||
"dayjs": "^1.11.20",
|
||||
|
||||
@@ -7,8 +7,8 @@ from .models import PixelPlacement, PlaceSettings
|
||||
|
||||
@admin.register(PlaceSettings)
|
||||
class PlaceSettingsAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
list_display = ("event", "width", "height")
|
||||
list_editable = ("width", "height")
|
||||
list_display = ("event", "width", "height", "placement_ends_at")
|
||||
list_editable = ("width", "height", "placement_ends_at")
|
||||
list_filter = ("event",)
|
||||
change_actions = ("resync_canvas",)
|
||||
|
||||
@@ -19,7 +19,8 @@ class PlaceSettingsAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
def resync_canvas(self, request, obj):
|
||||
count = rebuild_canvas_from_history(obj.event, obj.width, obj.height)
|
||||
self.message_user(
|
||||
request, f"Canvas resynced from {count} placement(s) at {obj.width}x{obj.height}."
|
||||
request,
|
||||
f"Canvas resynced from {count} placement(s) at {obj.width}x{obj.height}.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import csv
|
||||
import struct
|
||||
|
||||
from django.db.models import Count
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from ninja import Router
|
||||
from ninja.decorators import decorate_view
|
||||
@@ -8,8 +10,17 @@ from ninja.security import django_auth_is_staff
|
||||
from games.decorators import require_game_enabled
|
||||
|
||||
from .canvas import read_canvas
|
||||
from .export import build_final_png
|
||||
from .gif import build_evolution_gif
|
||||
from .models import PlaceSettings
|
||||
from .heatmap import (
|
||||
DecayMode,
|
||||
build_heatmap_decay_gif,
|
||||
build_heatmap_evolution_gif,
|
||||
build_heatmap_png,
|
||||
)
|
||||
from .models import PixelPlacement, PlaceSettings
|
||||
from .schemas import PlaceStatsOut
|
||||
from .stats import build_place_stats
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -25,15 +36,116 @@ HEADER_FORMAT = "<HH"
|
||||
@decorate_view(require_game_enabled(path="place"))
|
||||
def canvas(request: HttpRequest, event_path: str):
|
||||
"""The one-time full-grid load. Every placement after that arrives as a
|
||||
single websocket message, never a re-fetch."""
|
||||
single websocket message, never a re-fetch. Once placement is over the
|
||||
board is shown wiped (Redis keeps the real final board, see tasks.py)."""
|
||||
place_settings = PlaceSettings.current(request.event)
|
||||
header = struct.pack(HEADER_FORMAT, place_settings.width, place_settings.height)
|
||||
pixels = read_canvas(event_path, place_settings.width, place_settings.height)
|
||||
if place_settings.placement_ended:
|
||||
pixels = bytes(place_settings.width * place_settings.height)
|
||||
else:
|
||||
pixels = read_canvas(event_path, place_settings.width, place_settings.height)
|
||||
return HttpResponse(header + pixels, content_type="application/octet-stream")
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/evolution.gif", auth=django_auth_is_staff)
|
||||
@decorate_view(require_game_enabled(path="place"))
|
||||
def evolution_gif(request: HttpRequest, event_path: str):
|
||||
"""Admin-only: replays the full placement history as an animated GIF."""
|
||||
return HttpResponse(build_evolution_gif(request.event), content_type="image/gif")
|
||||
def evolution_gif(request: HttpRequest, event_path: str, players: str = ""):
|
||||
"""Admin-only: replays the full placement history as an animated GIF.
|
||||
|
||||
`players` is an optional comma-separated list of usernames to only
|
||||
replay those players' placements (combined)."""
|
||||
usernames = [name.strip() for name in players.split(",") if name.strip()]
|
||||
return HttpResponse(
|
||||
build_evolution_gif(request.event, usernames), content_type="image/gif"
|
||||
)
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/final.png", auth=django_auth_is_staff)
|
||||
@decorate_view(require_game_enabled(path="place"))
|
||||
def final_png(request: HttpRequest, event_path: str):
|
||||
"""Admin-only: the real final board as a PNG, even once placement is
|
||||
over and everyone else sees it wiped."""
|
||||
return HttpResponse(build_final_png(request.event), content_type="image/png")
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/heatmap.png", auth=django_auth_is_staff)
|
||||
@decorate_view(require_game_enabled(path="place"))
|
||||
def heatmap_png(request: HttpRequest, event_path: str, players: str = ""):
|
||||
"""Admin-only: how many times each pixel was placed, as a heatmap PNG.
|
||||
|
||||
`players` is an optional comma-separated list of usernames to only
|
||||
count those players' placements (combined)."""
|
||||
usernames = [name.strip() for name in players.split(",") if name.strip()]
|
||||
return HttpResponse(
|
||||
build_heatmap_png(request.event, usernames), content_type="image/png"
|
||||
)
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/heatmap-evolution.gif", auth=django_auth_is_staff)
|
||||
@decorate_view(require_game_enabled(path="place"))
|
||||
def heatmap_evolution_gif(request: HttpRequest, event_path: str, players: str = ""):
|
||||
"""Admin-only: the heatmap building up over time, as an animated GIF.
|
||||
|
||||
`players` is an optional comma-separated list of usernames to only
|
||||
count those players' placements (combined)."""
|
||||
usernames = [name.strip() for name in players.split(",") if name.strip()]
|
||||
return HttpResponse(
|
||||
build_heatmap_evolution_gif(request.event, usernames),
|
||||
content_type="image/gif",
|
||||
)
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/heatmap-decay.gif", auth=django_auth_is_staff)
|
||||
@decorate_view(require_game_enabled(path="place"))
|
||||
def heatmap_decay_gif(
|
||||
request: HttpRequest,
|
||||
event_path: str,
|
||||
players: str = "",
|
||||
half_life: float | None = None,
|
||||
mode: DecayMode = "placements",
|
||||
):
|
||||
"""Admin-only: the heatmap over time, but heat decays so pixels nobody
|
||||
touches anymore fade back to white.
|
||||
|
||||
`players` is an optional comma-separated list of usernames to only
|
||||
count those players' placements (combined). `mode` is "placements"
|
||||
(decay per placement, `half_life` in placements, defaults to 10 frames'
|
||||
worth) or "time" (decay over time, `half_life` in minutes, defaults
|
||||
to 60)."""
|
||||
usernames = [name.strip() for name in players.split(",") if name.strip()]
|
||||
return HttpResponse(
|
||||
build_heatmap_decay_gif(request.event, usernames, half_life, mode),
|
||||
content_type="image/gif",
|
||||
)
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/stats", auth=django_auth_is_staff, response=PlaceStatsOut)
|
||||
@decorate_view(require_game_enabled(path="place"))
|
||||
def stats(request: HttpRequest, event_path: str):
|
||||
"""Admin-only: aggregated stats about the game - totals, coverage, top
|
||||
players, most contested pixels, color usage and activity per hour."""
|
||||
return build_place_stats(request.event)
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/placements.csv", auth=django_auth_is_staff)
|
||||
@decorate_view(require_game_enabled(path="place"))
|
||||
def placements_csv(request: HttpRequest, event_path: str):
|
||||
"""Admin-only: pixel placement count per player, most active first."""
|
||||
rows = (
|
||||
PixelPlacement.objects.filter(event=request.event)
|
||||
.values("player__username")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count", "player__username")
|
||||
)
|
||||
# text/plain so browsers render it inline - most force-download text/csv
|
||||
response = HttpResponse(content_type="text/plain; charset=utf-8")
|
||||
writer = csv.writer(response)
|
||||
writer.writerow(["username", "placements"])
|
||||
for row in rows:
|
||||
writer.writerow(
|
||||
[
|
||||
row["player__username"],
|
||||
row["count"],
|
||||
]
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -22,7 +22,7 @@ _client = redis.Redis.from_url(settings.PLACE_REDIS_URL)
|
||||
|
||||
|
||||
def _canvas_key(event_path: str) -> str:
|
||||
return f"place:canvas:{event_path}"
|
||||
return f"place:{event_path}:canvas"
|
||||
|
||||
|
||||
def read_canvas(event_path: str, width: int, height: int) -> bytes:
|
||||
@@ -49,9 +49,11 @@ def rebuild_canvas_from_history(event, width: int, height: int) -> int:
|
||||
"""
|
||||
grid = bytearray(width * height)
|
||||
count = 0
|
||||
placements = PixelPlacement.objects.filter(event=event).order_by(
|
||||
"placed_at"
|
||||
).values_list("x", "y", "color_index")
|
||||
placements = (
|
||||
PixelPlacement.objects.filter(event=event)
|
||||
.order_by("placed_at")
|
||||
.values_list("x", "y", "color_index")
|
||||
)
|
||||
for x, y, color_index in placements.iterator():
|
||||
if x >= width or y >= height:
|
||||
continue
|
||||
|
||||
@@ -13,10 +13,16 @@ from .models import PALETTE, PLACE_COOLDOWN_SECONDS, PixelPlacement, PlaceSettin
|
||||
from .wire import encode, to_epoch_ms
|
||||
|
||||
|
||||
def _epoch_ms_or_none(dt):
|
||||
return None if dt is None else to_epoch_ms(dt)
|
||||
|
||||
|
||||
class PlaceConsumer(AsyncWebsocketConsumer):
|
||||
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.event = await self._get_enabled_event(
|
||||
self.event_path, self.scope.get("user")
|
||||
)
|
||||
if self.event is None:
|
||||
await self.close()
|
||||
return
|
||||
@@ -43,6 +49,10 @@ class PlaceConsumer(AsyncWebsocketConsumer):
|
||||
await self.send(text_data=encode("r", "a"))
|
||||
return
|
||||
|
||||
if place_settings.placement_ended:
|
||||
await self.send(text_data=encode("r", "d"))
|
||||
return
|
||||
|
||||
parts = text_data.split("|")
|
||||
if len(parts) != 4:
|
||||
await self.send(text_data=encode("r", "i"))
|
||||
@@ -65,7 +75,9 @@ class PlaceConsumer(AsyncWebsocketConsumer):
|
||||
await self.send(text_data=encode("r", "i"))
|
||||
return
|
||||
|
||||
result = await self._record_placement(user, x, y, color_index, place_settings.width)
|
||||
result = await self._record_placement(
|
||||
user, x, y, color_index, place_settings.width
|
||||
)
|
||||
|
||||
if result["rejected"]:
|
||||
await self.send(text_data=encode("r", "c", result["next"]))
|
||||
@@ -78,8 +90,8 @@ class PlaceConsumer(AsyncWebsocketConsumer):
|
||||
)
|
||||
|
||||
@database_sync_to_async
|
||||
def _get_enabled_event(self, event_path):
|
||||
event = Event.objects.filter(path=event_path).first()
|
||||
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="place").exists():
|
||||
@@ -110,7 +122,9 @@ class PlaceConsumer(AsyncWebsocketConsumer):
|
||||
|
||||
return {
|
||||
"rejected": False,
|
||||
"next": to_epoch_ms(placement.placed_at + timedelta(seconds=PLACE_COOLDOWN_SECONDS)),
|
||||
"next": to_epoch_ms(
|
||||
placement.placed_at + timedelta(seconds=PLACE_COOLDOWN_SECONDS)
|
||||
),
|
||||
}
|
||||
|
||||
@database_sync_to_async
|
||||
@@ -136,6 +150,7 @@ class PlaceConsumer(AsyncWebsocketConsumer):
|
||||
next_placement_at,
|
||||
place_settings.width,
|
||||
place_settings.height,
|
||||
_epoch_ms_or_none(place_settings.placement_ends_at),
|
||||
)
|
||||
|
||||
@database_sync_to_async
|
||||
@@ -146,4 +161,14 @@ class PlaceConsumer(AsyncWebsocketConsumer):
|
||||
await self.send(text_data=encode("p", event["x"], event["y"], event["c"]))
|
||||
|
||||
async def settings_changed(self, event):
|
||||
await self.send(text_data=encode("e", event["width"], event["height"]))
|
||||
await self.send(
|
||||
text_data=encode(
|
||||
"e",
|
||||
event["width"],
|
||||
event["height"],
|
||||
event["ends_at"],
|
||||
)
|
||||
)
|
||||
|
||||
async def wipe(self, event):
|
||||
await self.send(text_data=encode("w"))
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Exports one event's final board as a PNG.
|
||||
|
||||
Reads the live Redis canvas rather than replaying the history like gif.py -
|
||||
once placement is over the canvas endpoint serves a wiped board, but Redis
|
||||
still holds the real final one (see tasks.py).
|
||||
"""
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from events.models import Event
|
||||
|
||||
from .canvas import read_canvas
|
||||
from .gif import _render_frame
|
||||
from .models import PlaceSettings
|
||||
|
||||
|
||||
def build_final_png(event: Event) -> bytes:
|
||||
"""Renders `event`'s current board as a PNG, upscaled like gif.py.
|
||||
|
||||
Redis only pads up to the last written pixel, so a short canvas is
|
||||
padded with white (and a longer one, e.g. from before a shrink, cut)
|
||||
to the current grid dimensions."""
|
||||
place_settings = PlaceSettings.current(event)
|
||||
width, height = place_settings.width, place_settings.height
|
||||
size = width * height
|
||||
|
||||
canvas = read_canvas(event.path, width, height)[:size].ljust(size, b"\0")
|
||||
|
||||
buffer = BytesIO()
|
||||
_render_frame(bytearray(canvas), width, height).save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
@@ -25,20 +25,25 @@ _PALETTE_RGB = [
|
||||
]
|
||||
|
||||
|
||||
def build_evolution_gif(event: Event) -> bytes:
|
||||
def build_evolution_gif(event: Event, usernames: list[str] | None = None) -> bytes:
|
||||
"""Renders `event`'s full placement history as an animated GIF, upscaled
|
||||
for visibility. Returns GIF bytes, even if there are zero placements.
|
||||
|
||||
If `usernames` is given, only those players' placements are replayed
|
||||
(combined on the same board).
|
||||
|
||||
Always replays at the *current* grid dimensions - a placement made
|
||||
before a resize that now falls outside them is skipped rather than
|
||||
raising."""
|
||||
place_settings = PlaceSettings.current(event)
|
||||
width, height = place_settings.width, place_settings.height
|
||||
|
||||
placements = PixelPlacement.objects.filter(event=event).order_by(
|
||||
"placed_at"
|
||||
).values_list("x", "y", "color_index")
|
||||
placements = list(placements)
|
||||
placements = PixelPlacement.objects.filter(event=event)
|
||||
if usernames:
|
||||
placements = placements.filter(player__username__in=usernames)
|
||||
placements = list(
|
||||
placements.order_by("placed_at").values_list("x", "y", "color_index")
|
||||
)
|
||||
|
||||
grid = bytearray(width * height)
|
||||
frames: list[Image.Image] = []
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Renders one event's pixel placement history as a heatmap.
|
||||
|
||||
Same source as gif.py (the durable PixelPlacement history), but instead of
|
||||
replaying colors, counts how many times each pixel was placed and maps that
|
||||
count to a white -> red gradient, so an admin can see
|
||||
where the action was - either as a single PNG of the final counts, as an
|
||||
animated GIF of the counts building up over time, or as an animated GIF
|
||||
where the heat decays so only recent activity stays red.
|
||||
"""
|
||||
|
||||
import math
|
||||
from io import BytesIO
|
||||
from typing import Literal
|
||||
|
||||
from django.db.models import Count
|
||||
from PIL import Image
|
||||
|
||||
from events.models import Event
|
||||
|
||||
from .gif import FINAL_FRAME_HOLD_MS, FRAME_DURATION_MS, MAX_FRAMES
|
||||
from .models import PixelPlacement, PlaceSettings
|
||||
|
||||
SCALE = 8
|
||||
|
||||
# Gradient stops, from "never placed" to "most placed"
|
||||
_GRADIENT = [
|
||||
(255, 255, 255),
|
||||
(193, 1, 2),
|
||||
]
|
||||
|
||||
|
||||
def build_heatmap_png(event: Event, usernames: list[str] | None = None) -> bytes:
|
||||
"""Renders `event`'s placement counts per pixel as a PNG heatmap,
|
||||
upscaled for visibility. Returns PNG bytes, even if there are zero
|
||||
placements (all white).
|
||||
|
||||
If `usernames` is given, only those players' placements are counted
|
||||
(combined on the same board).
|
||||
|
||||
Counts are log-scaled, otherwise a few heavily fought-over pixels make
|
||||
everything else look white. Placements outside the *current* grid
|
||||
dimensions are skipped, like in gif.py."""
|
||||
place_settings = PlaceSettings.current(event)
|
||||
width, height = place_settings.width, place_settings.height
|
||||
|
||||
placements = _placements(event, usernames)
|
||||
counts = placements.values("x", "y").annotate(count=Count("id")).order_by()
|
||||
|
||||
grid = [0] * (width * height)
|
||||
for row in counts:
|
||||
if row["x"] >= width or row["y"] >= height:
|
||||
continue
|
||||
grid[row["y"] * width + row["x"]] = row["count"]
|
||||
|
||||
colors = _color_table(max(grid, default=0))
|
||||
buffer = BytesIO()
|
||||
_render_frame(grid, width, height, colors).save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def build_heatmap_evolution_gif(
|
||||
event: Event, usernames: list[str] | None = None
|
||||
) -> bytes:
|
||||
"""Same as build_heatmap_png, but as an animated GIF where the counts
|
||||
accumulate placement by placement (sampled down to MAX_FRAMES, like
|
||||
gif.py). Returns GIF bytes, even if there are zero placements.
|
||||
|
||||
Every frame uses the *final* max count for its color scale, so a pixel's
|
||||
color only ever heats up - otherwise early frames would look as hot as
|
||||
the end."""
|
||||
place_settings = PlaceSettings.current(event)
|
||||
width, height = place_settings.width, place_settings.height
|
||||
|
||||
placements = [
|
||||
(x, y)
|
||||
for x, y in _placements(event, usernames)
|
||||
.order_by("placed_at")
|
||||
.values_list("x", "y")
|
||||
if x < width and y < height
|
||||
]
|
||||
|
||||
final_grid = [0] * (width * height)
|
||||
for x, y in placements:
|
||||
final_grid[y * width + x] += 1
|
||||
colors = _color_table(max(final_grid, default=0))
|
||||
|
||||
grid = [0] * (width * height)
|
||||
frames: list[Image.Image] = []
|
||||
|
||||
step = max(1, len(placements) // MAX_FRAMES)
|
||||
for i, (x, y) in enumerate(placements):
|
||||
grid[y * width + x] += 1
|
||||
if i % step == 0 or i == len(placements) - 1:
|
||||
frames.append(_render_frame(grid, width, height, colors))
|
||||
|
||||
if not frames:
|
||||
frames.append(_render_frame(grid, width, height, colors))
|
||||
|
||||
return _save_gif(frames)
|
||||
|
||||
|
||||
DecayMode = Literal["placements", "time"]
|
||||
|
||||
DEFAULT_HALF_LIFE_MINUTES = 60
|
||||
|
||||
|
||||
def build_heatmap_decay_gif(
|
||||
event: Event,
|
||||
usernames: list[str] | None = None,
|
||||
half_life: float | None = None,
|
||||
mode: DecayMode = "placements",
|
||||
) -> bytes:
|
||||
"""Like build_heatmap_evolution_gif, but every pixel's heat decays
|
||||
exponentially, so a pixel nobody touches anymore fades back to white
|
||||
and the GIF shows where the activity is *right now*.
|
||||
|
||||
`mode` picks what drives the decay (and the frame spacing):
|
||||
- "placements": `half_life` is in placements (after that many more
|
||||
placements anywhere on the board, a pixel's heat is halved), defaults
|
||||
to 10 frames' worth. Frames are one every N placements, so quiet
|
||||
periods take almost no frames and nothing fades during them.
|
||||
- "time": `half_life` is in minutes, defaults to an hour. Frames are
|
||||
evenly spaced in time between the first and last placement, so quiet
|
||||
periods actually show the board fading out.
|
||||
|
||||
Every frame shares the same color scale (the hottest pixel across the
|
||||
whole replay), so a quiet period actually looks quiet."""
|
||||
place_settings = PlaceSettings.current(event)
|
||||
width, height = place_settings.width, place_settings.height
|
||||
|
||||
rows = [
|
||||
(placed_at, x, y)
|
||||
for placed_at, x, y in _placements(event, usernames)
|
||||
.order_by("placed_at")
|
||||
.values_list("placed_at", "x", "y")
|
||||
if x < width and y < height
|
||||
]
|
||||
|
||||
# Both modes boil down to a "clock" per placement, the clock values to
|
||||
# snapshot a frame at, and a half-life in that clock's unit.
|
||||
if mode == "time":
|
||||
clocks = [placed_at.timestamp() for placed_at, _, _ in rows]
|
||||
frame_clocks = _evenly_spaced(clocks, MAX_FRAMES)
|
||||
half_life_clock = (half_life or DEFAULT_HALF_LIFE_MINUTES) * 60
|
||||
else:
|
||||
clocks = list(range(len(rows)))
|
||||
step = max(1, len(rows) // MAX_FRAMES)
|
||||
frame_clocks = [i for i in clocks if i % step == 0 or i == len(rows) - 1]
|
||||
half_life_clock = half_life or step * 10
|
||||
decay = 0.5 ** (1 / max(1e-9, half_life_clock))
|
||||
|
||||
# Decay is applied lazily: each pixel stores its heat as of the clock it
|
||||
# was last touched at, so a placement is O(1) instead of decaying the
|
||||
# whole grid every time.
|
||||
heat = [0.0] * (width * height)
|
||||
touched_at = [0.0] * (width * height)
|
||||
snapshots: list[list[float]] = []
|
||||
placement_index = 0
|
||||
for frame_clock in frame_clocks:
|
||||
while placement_index < len(rows) and clocks[placement_index] <= frame_clock:
|
||||
_, x, y = rows[placement_index]
|
||||
clock = clocks[placement_index]
|
||||
index = y * width + x
|
||||
heat[index] = heat[index] * decay ** (clock - touched_at[index]) + 1
|
||||
touched_at[index] = clock
|
||||
placement_index += 1
|
||||
snapshots.append(
|
||||
[h * decay ** (frame_clock - t) for h, t in zip(heat, touched_at)]
|
||||
)
|
||||
|
||||
if not snapshots:
|
||||
snapshots.append(heat)
|
||||
|
||||
max_log = math.log1p(max(max(snapshot) for snapshot in snapshots)) or 1
|
||||
colors = [_gradient(i / 255) for i in range(256)]
|
||||
frames = []
|
||||
for snapshot in snapshots:
|
||||
image = Image.new("RGB", (width, height))
|
||||
image.putdata([colors[round(math.log1p(h) / max_log * 255)] for h in snapshot])
|
||||
frames.append(image.resize((width * SCALE, height * SCALE), Image.NEAREST))
|
||||
|
||||
return _save_gif(frames)
|
||||
|
||||
|
||||
def _evenly_spaced(clocks: list[float], count: int) -> list[float]:
|
||||
"""`count` clock values evenly spread from the first to the last one
|
||||
(just the one if they're all the same, none if `clocks` is empty)."""
|
||||
if not clocks:
|
||||
return []
|
||||
first, last = clocks[0], clocks[-1]
|
||||
if first == last:
|
||||
return [first]
|
||||
return [first + (last - first) * i / (count - 1) for i in range(count)]
|
||||
|
||||
|
||||
def _save_gif(frames: list[Image.Image]) -> bytes:
|
||||
buffer = BytesIO()
|
||||
durations = [FRAME_DURATION_MS] * len(frames)
|
||||
durations[-1] = FINAL_FRAME_HOLD_MS
|
||||
frames[0].save(
|
||||
buffer,
|
||||
format="GIF",
|
||||
save_all=True,
|
||||
append_images=frames[1:],
|
||||
duration=durations,
|
||||
loop=0,
|
||||
disposal=2,
|
||||
)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _placements(event: Event, usernames: list[str] | None):
|
||||
placements = PixelPlacement.objects.filter(event=event)
|
||||
if usernames:
|
||||
placements = placements.filter(player__username__in=usernames)
|
||||
return placements
|
||||
|
||||
|
||||
def _color_table(max_count: int) -> list[tuple[int, int, int]]:
|
||||
"""Color for every count in [0, max_count], log-scaled."""
|
||||
max_log = math.log1p(max_count) or 1
|
||||
return [_gradient(math.log1p(count) / max_log) for count in range(max_count + 1)]
|
||||
|
||||
|
||||
def _render_frame(
|
||||
grid: list[int], width: int, height: int, colors: list[tuple[int, int, int]]
|
||||
) -> Image.Image:
|
||||
image = Image.new("RGB", (width, height))
|
||||
image.putdata([colors[count] for count in grid])
|
||||
return image.resize((width * SCALE, height * SCALE), Image.NEAREST)
|
||||
|
||||
|
||||
def _gradient(t: float) -> tuple[int, int, int]:
|
||||
"""Linearly interpolates `_GRADIENT` at `t` in [0, 1]."""
|
||||
position = t * (len(_GRADIENT) - 1)
|
||||
index = min(int(position), len(_GRADIENT) - 2)
|
||||
frac = position - index
|
||||
start, end = _GRADIENT[index], _GRADIENT[index + 1]
|
||||
r, g, b = (round(a + (z - a) * frac) for a, z in zip(start, end))
|
||||
return r, g, b
|
||||
@@ -6,7 +6,6 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.5 on 2026-10-25 09:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("place", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="placesettings",
|
||||
name="placement_enabled",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 6.0.5 on 2026-10-25 09:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("place", "0002_placesettings_placement_enabled"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="placesettings",
|
||||
name="placement_enabled",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="placesettings",
|
||||
name="placement_ends_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -47,10 +48,22 @@ class PlaceSettings(models.Model):
|
||||
)
|
||||
width = models.PositiveSmallIntegerField(default=DEFAULT_GRID_WIDTH)
|
||||
height = models.PositiveSmallIntegerField(default=DEFAULT_GRID_HEIGHT)
|
||||
# Past this, the game is over without taking it down: every placement
|
||||
# is rejected and the board is shown wiped (see tasks.py). Only visual -
|
||||
# the PixelPlacement history and the Redis canvas are kept as-is, so
|
||||
# admin stats/heatmaps still see the final board. Empty = no end.
|
||||
placement_ends_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"PlaceSettings({self.event}, size={self.width}x{self.height})"
|
||||
|
||||
@property
|
||||
def placement_ended(self) -> bool:
|
||||
return (
|
||||
self.placement_ends_at is not None
|
||||
and self.placement_ends_at <= timezone.now()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def current(cls, event: Event) -> "PlaceSettings":
|
||||
obj, _ = cls.objects.get_or_create(event=event)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from datetime import datetime
|
||||
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class PlayerCountOut(Schema):
|
||||
username: str
|
||||
placements: int
|
||||
|
||||
|
||||
class PixelCountOut(Schema):
|
||||
x: int
|
||||
y: int
|
||||
placements: int
|
||||
players: int
|
||||
|
||||
|
||||
class ColorCountOut(Schema):
|
||||
color_index: int
|
||||
color: str
|
||||
placements: int
|
||||
# How many pixels of the live board currently have this color
|
||||
on_canvas: int
|
||||
|
||||
|
||||
class HourCountOut(Schema):
|
||||
hour: datetime
|
||||
placements: int
|
||||
players: int
|
||||
|
||||
|
||||
class PlaceStatsOut(Schema):
|
||||
width: int
|
||||
height: int
|
||||
total_placements: int
|
||||
total_players: int
|
||||
first_placement_at: datetime | None
|
||||
last_placement_at: datetime | None
|
||||
# Distinct pixels placed at least once (within the current grid)
|
||||
pixels_touched: int
|
||||
coverage: float
|
||||
# Placements that landed on an already placed pixel
|
||||
overwrites: int
|
||||
avg_placements_per_player: float
|
||||
top_players: list[PlayerCountOut]
|
||||
most_contested_pixels: list[PixelCountOut]
|
||||
colors: list[ColorCountOut]
|
||||
per_hour: list[HourCountOut]
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Reacts to an admin editing a PlaceSettings row (grid size) by:
|
||||
"""Reacts to an admin editing a PlaceSettings row (grid size, end time) by:
|
||||
|
||||
1. Rebuilding that event's Redis canvas from history at the (possibly new)
|
||||
dimensions - a resize invalidates the old Redis bytes (wrong length,
|
||||
wrong meaning), so this keeps Redis self-healing instead of relying on
|
||||
the admin remembering to click "Resync canvas from DB" after every
|
||||
resize.
|
||||
2. Pushing the new size to that event's already-connected clients. Unlike
|
||||
2. Pushing the new settings to that event's already-connected clients. Unlike
|
||||
the clock app, place has no periodic heartbeat broadcast to piggyback
|
||||
this onto, so it's broadcast explicitly here instead.
|
||||
3. Queueing the wipe for the end time, if any (see tasks.py).
|
||||
"""
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
@@ -18,11 +19,14 @@ from django.dispatch import receiver
|
||||
from .broadcast import place_group
|
||||
from .canvas import rebuild_canvas_from_history
|
||||
from .models import PlaceSettings
|
||||
from .tasks import schedule_wipe
|
||||
from .wire import to_epoch_ms
|
||||
|
||||
|
||||
@receiver(post_save, sender=PlaceSettings)
|
||||
def on_settings_saved(sender, instance, **kwargs):
|
||||
rebuild_canvas_from_history(instance.event, instance.width, instance.height)
|
||||
schedule_wipe(instance)
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
if channel_layer is None:
|
||||
@@ -33,5 +37,10 @@ def on_settings_saved(sender, instance, **kwargs):
|
||||
"type": "settings_changed",
|
||||
"width": instance.width,
|
||||
"height": instance.height,
|
||||
"ends_at": (
|
||||
None
|
||||
if instance.placement_ends_at is None
|
||||
else to_epoch_ms(instance.placement_ends_at)
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Aggregated stats about one event's place game, for the admin stats
|
||||
endpoint. Everything comes from the durable PixelPlacement history, except
|
||||
the colors currently on the board, which are read off the live Redis canvas.
|
||||
"""
|
||||
|
||||
from django.db.models import Count, Max, Min
|
||||
from django.db.models.functions import TruncHour
|
||||
|
||||
from events.models import Event
|
||||
|
||||
from .canvas import read_canvas
|
||||
from .models import PALETTE, PixelPlacement, PlaceSettings
|
||||
|
||||
TOP_N = 10
|
||||
|
||||
|
||||
def build_place_stats(event: Event) -> dict:
|
||||
place_settings = PlaceSettings.current(event)
|
||||
width, height = place_settings.width, place_settings.height
|
||||
|
||||
placements = PixelPlacement.objects.filter(event=event)
|
||||
totals = placements.aggregate(
|
||||
total=Count("id"),
|
||||
players=Count("player", distinct=True),
|
||||
first=Min("placed_at"),
|
||||
last=Max("placed_at"),
|
||||
)
|
||||
total, players = totals["total"], totals["players"]
|
||||
|
||||
# Same as heatmap.py, placements outside the current grid are skipped
|
||||
in_grid = placements.filter(x__lt=width, y__lt=height)
|
||||
pixels_touched = in_grid.values("x", "y").distinct().count()
|
||||
|
||||
top_players = (
|
||||
placements.values("player__username")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count", "player__username")[:TOP_N]
|
||||
)
|
||||
contested = (
|
||||
in_grid.values("x", "y")
|
||||
.annotate(count=Count("id"), players=Count("player", distinct=True))
|
||||
.order_by("-count", "-players", "y", "x")[:TOP_N]
|
||||
)
|
||||
color_counts = dict(
|
||||
placements.values_list("color_index")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by()
|
||||
)
|
||||
# Redis only pads up to the last written pixel, and keeps old bytes
|
||||
# after a shrink, so normalize to the current grid size
|
||||
size = width * height
|
||||
canvas = read_canvas(event.path, width, height)[:size].ljust(size, b"\0")
|
||||
per_hour = (
|
||||
placements.annotate(hour=TruncHour("placed_at"))
|
||||
.values("hour")
|
||||
.annotate(count=Count("id"), players=Count("player", distinct=True))
|
||||
.order_by("hour")
|
||||
)
|
||||
|
||||
return {
|
||||
"width": width,
|
||||
"height": height,
|
||||
"total_placements": total,
|
||||
"total_players": players,
|
||||
"first_placement_at": totals["first"],
|
||||
"last_placement_at": totals["last"],
|
||||
"pixels_touched": pixels_touched,
|
||||
"coverage": pixels_touched / size if size else 0.0,
|
||||
"overwrites": in_grid.count() - pixels_touched,
|
||||
"avg_placements_per_player": total / players if players else 0.0,
|
||||
"top_players": [
|
||||
{"username": row["player__username"], "placements": row["count"]}
|
||||
for row in top_players
|
||||
],
|
||||
"most_contested_pixels": [
|
||||
{
|
||||
"x": row["x"],
|
||||
"y": row["y"],
|
||||
"placements": row["count"],
|
||||
"players": row["players"],
|
||||
}
|
||||
for row in contested
|
||||
],
|
||||
"colors": [
|
||||
{
|
||||
"color_index": index,
|
||||
"color": color,
|
||||
"placements": color_counts.get(index, 0),
|
||||
"on_canvas": canvas.count(index),
|
||||
}
|
||||
for index, color in enumerate(PALETTE)
|
||||
],
|
||||
"per_hour": [
|
||||
{"hour": row["hour"], "placements": row["count"], "players": row["players"]}
|
||||
for row in per_hour
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Ends the game at PlaceSettings.placement_ends_at: a task queued to run at
|
||||
that time broadcasts the wipe to everyone connected, so they all see the
|
||||
board cleared at the same moment (no client-side timer to drift).
|
||||
|
||||
The wipe is only visual. Nothing is deleted, the Redis canvas still holds the
|
||||
final board (admin stats, heatmaps, resync keep working), it's just that the
|
||||
canvas endpoint serves a blank board once placement is over (see api.py).
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.core.cache import cache
|
||||
from django.tasks import task
|
||||
from django.utils import timezone
|
||||
|
||||
from .broadcast import place_group
|
||||
from .models import PlaceSettings
|
||||
from .wire import to_epoch_ms
|
||||
|
||||
# Kept a bit past the end time, so re-saving the settings with the same end
|
||||
# time (or with only the size changed) doesn't queue the same wipe twice.
|
||||
WIPE_GUARD_MARGIN = timedelta(hours=1)
|
||||
|
||||
|
||||
def _wipe_guard_key(settings_id: int, ends_at_ms: int) -> str:
|
||||
return f"place:wipe:{settings_id}:{ends_at_ms}"
|
||||
|
||||
|
||||
def schedule_wipe(place_settings: PlaceSettings) -> bool:
|
||||
"""Queue the wipe for this end time, unless it's already queued or the end
|
||||
time is already past. Safe to call on every save."""
|
||||
ends_at = place_settings.placement_ends_at
|
||||
if ends_at is None or ends_at <= timezone.now():
|
||||
return False
|
||||
|
||||
ends_at_ms = to_epoch_ms(ends_at)
|
||||
timeout = int((ends_at - timezone.now() + WIPE_GUARD_MARGIN).total_seconds())
|
||||
if not cache.add(_wipe_guard_key(place_settings.id, ends_at_ms), True, timeout):
|
||||
return False
|
||||
|
||||
wipe_canvas_task.using(run_after=ends_at).enqueue(place_settings.id, ends_at_ms)
|
||||
return True
|
||||
|
||||
|
||||
@task
|
||||
def wipe_canvas_task(settings_id: int, ends_at_ms: int):
|
||||
"""Broadcast the wipe wave, if `ends_at_ms` is still the event's end time -
|
||||
an admin moving or clearing it leaves the old job queued, it just stops
|
||||
here."""
|
||||
place_settings = (
|
||||
PlaceSettings.objects.filter(id=settings_id).select_related("event").first()
|
||||
)
|
||||
if place_settings is None or place_settings.placement_ends_at is None:
|
||||
return
|
||||
if to_epoch_ms(place_settings.placement_ends_at) != ends_at_ms:
|
||||
return
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
if channel_layer is None:
|
||||
return
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
place_group(place_settings.event.path), {"type": "wipe"}
|
||||
)
|
||||
@@ -0,0 +1,210 @@
|
||||
from datetime import timedelta
|
||||
from io import BytesIO
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
from PIL import Image
|
||||
|
||||
from accounts.models import CustomUser
|
||||
from events.models import Event
|
||||
from games.models import EventGame, Game
|
||||
|
||||
from .models import PixelPlacement, PlaceSettings
|
||||
from .tasks import schedule_wipe, wipe_canvas_task
|
||||
from .wire import to_epoch_ms
|
||||
|
||||
URL = "/api/events/event/place/stats"
|
||||
CANVAS_URL = "/api/events/event/place/canvas"
|
||||
FINAL_URL = "/api/events/event/place/final.png"
|
||||
LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
|
||||
|
||||
|
||||
@mock.patch("place.stats.read_canvas")
|
||||
class PlaceStatsTests(TestCase):
|
||||
def setUp(self):
|
||||
self.event = Event.objects.create(name="Event", path="event")
|
||||
place = Game.objects.create(name="Place", path="place", custom_path=True)
|
||||
EventGame.objects.create(event=self.event, game=place)
|
||||
PlaceSettings.objects.create(event=self.event, width=4, height=2)
|
||||
self.alice = CustomUser.objects.create_user(username="alice", password="x")
|
||||
self.bob = CustomUser.objects.create_user(username="bob", password="x")
|
||||
self.admin = CustomUser.objects.create_user(
|
||||
username="admin", password="x", is_staff=True
|
||||
)
|
||||
|
||||
def place(self, player, x, y, color_index):
|
||||
PixelPlacement.objects.create(
|
||||
event=self.event, player=player, x=x, y=y, color_index=color_index
|
||||
)
|
||||
|
||||
def test_requires_staff(self, _):
|
||||
self.client.force_login(self.alice)
|
||||
|
||||
response = self.client.get(URL)
|
||||
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_empty_game(self, read_canvas):
|
||||
read_canvas.return_value = b""
|
||||
self.client.force_login(self.admin)
|
||||
|
||||
data = self.client.get(URL).json()
|
||||
|
||||
self.assertEqual(data["total_placements"], 0)
|
||||
self.assertEqual(data["total_players"], 0)
|
||||
self.assertEqual(data["coverage"], 0.0)
|
||||
self.assertEqual(data["avg_placements_per_player"], 0.0)
|
||||
self.assertEqual(data["colors"][0]["on_canvas"], 8)
|
||||
self.assertEqual(data["per_hour"], [])
|
||||
|
||||
def test_stats(self, read_canvas):
|
||||
self.place(self.alice, 0, 0, 5)
|
||||
self.place(self.bob, 0, 0, 3)
|
||||
self.place(self.alice, 0, 0, 5)
|
||||
self.place(self.alice, 1, 0, 3)
|
||||
# Outside the current grid, e.g. from before a shrink
|
||||
self.place(self.bob, 9, 9, 3)
|
||||
# Short canvas, Redis only padded up to the last written pixel
|
||||
read_canvas.return_value = bytes([5, 3])
|
||||
self.client.force_login(self.admin)
|
||||
|
||||
data = self.client.get(URL).json()
|
||||
|
||||
self.assertEqual(data["total_placements"], 5)
|
||||
self.assertEqual(data["total_players"], 2)
|
||||
self.assertEqual(data["pixels_touched"], 2)
|
||||
self.assertEqual(data["coverage"], 0.25)
|
||||
self.assertEqual(data["overwrites"], 2)
|
||||
self.assertEqual(data["avg_placements_per_player"], 2.5)
|
||||
self.assertEqual(
|
||||
data["top_players"],
|
||||
[
|
||||
{"username": "alice", "placements": 3},
|
||||
{"username": "bob", "placements": 2},
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
data["most_contested_pixels"][0],
|
||||
{"x": 0, "y": 0, "placements": 3, "players": 2},
|
||||
)
|
||||
colors = {row["color_index"]: row for row in data["colors"]}
|
||||
self.assertEqual(colors[5]["placements"], 2)
|
||||
self.assertEqual(colors[3]["placements"], 3)
|
||||
self.assertEqual(colors[5]["on_canvas"], 1)
|
||||
self.assertEqual(colors[0]["on_canvas"], 6)
|
||||
self.assertEqual(sum(row["placements"] for row in data["per_hour"]), 5)
|
||||
|
||||
|
||||
@mock.patch("place.export.read_canvas")
|
||||
class FinalPngTests(TestCase):
|
||||
def setUp(self):
|
||||
self.event = Event.objects.create(name="Event", path="event")
|
||||
place = Game.objects.create(name="Place", path="place", custom_path=True)
|
||||
EventGame.objects.create(event=self.event, game=place)
|
||||
PlaceSettings.objects.create(
|
||||
event=self.event,
|
||||
width=2,
|
||||
height=1,
|
||||
# Ended, the real board is still exported
|
||||
placement_ends_at=timezone.now() - timedelta(hours=1),
|
||||
)
|
||||
self.alice = CustomUser.objects.create_user(username="alice", password="x")
|
||||
self.admin = CustomUser.objects.create_user(
|
||||
username="admin", password="x", is_staff=True
|
||||
)
|
||||
|
||||
def test_requires_staff(self, _):
|
||||
self.client.force_login(self.alice)
|
||||
|
||||
response = self.client.get(FINAL_URL)
|
||||
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_final_png(self, read_canvas):
|
||||
# Short canvas, Redis only padded up to the last written pixel
|
||||
read_canvas.return_value = bytes([5])
|
||||
self.client.force_login(self.admin)
|
||||
|
||||
response = self.client.get(FINAL_URL)
|
||||
|
||||
self.assertEqual(response["Content-Type"], "image/png")
|
||||
image = Image.open(BytesIO(response.content))
|
||||
self.assertEqual(image.size, (16, 8))
|
||||
self.assertEqual(image.getpixel((0, 0)), (229, 0, 0))
|
||||
self.assertEqual(image.getpixel((15, 0)), (255, 255, 255))
|
||||
|
||||
|
||||
@override_settings(CACHES=LOCMEM_CACHE)
|
||||
@mock.patch("place.signals.rebuild_canvas_from_history")
|
||||
class PlacementEndTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.event = Event.objects.create(name="Event", path="event")
|
||||
place = Game.objects.create(name="Place", path="place", custom_path=True)
|
||||
EventGame.objects.create(event=self.event, game=place)
|
||||
self.alice = CustomUser.objects.create_user(username="alice", password="x")
|
||||
|
||||
def settings(self, ends_at):
|
||||
with mock.patch("place.signals.schedule_wipe"):
|
||||
return PlaceSettings.objects.create(
|
||||
event=self.event, width=2, height=1, placement_ends_at=ends_at
|
||||
)
|
||||
|
||||
@mock.patch("place.api.read_canvas")
|
||||
def test_canvas_is_blank_once_ended(self, read_canvas, _):
|
||||
read_canvas.return_value = bytes([5, 3])
|
||||
place_settings = self.settings(timezone.now() + timedelta(hours=1))
|
||||
self.client.force_login(self.alice)
|
||||
|
||||
self.assertEqual(self.client.get(CANVAS_URL).content[4:], bytes([5, 3]))
|
||||
|
||||
place_settings.placement_ends_at = timezone.now() - timedelta(seconds=1)
|
||||
with mock.patch("place.signals.schedule_wipe"):
|
||||
place_settings.save()
|
||||
|
||||
self.assertEqual(self.client.get(CANVAS_URL).content[4:], bytes(2))
|
||||
|
||||
@mock.patch("place.tasks.wipe_canvas_task")
|
||||
def test_wipe_is_scheduled_once_per_end_time(self, task, _):
|
||||
ends_at = timezone.now() + timedelta(hours=1)
|
||||
place_settings = self.settings(ends_at)
|
||||
|
||||
self.assertTrue(schedule_wipe(place_settings))
|
||||
self.assertFalse(schedule_wipe(place_settings))
|
||||
|
||||
task.using.assert_called_once_with(run_after=ends_at)
|
||||
task.using.return_value.enqueue.assert_called_once_with(
|
||||
place_settings.id, to_epoch_ms(ends_at)
|
||||
)
|
||||
|
||||
@mock.patch("place.tasks.wipe_canvas_task")
|
||||
def test_no_wipe_without_future_end(self, task, _):
|
||||
self.assertFalse(schedule_wipe(self.settings(None)))
|
||||
past = PlaceSettings(placement_ends_at=timezone.now() - timedelta(seconds=1))
|
||||
self.assertFalse(schedule_wipe(past))
|
||||
|
||||
task.using.assert_not_called()
|
||||
|
||||
@mock.patch("place.tasks.get_channel_layer")
|
||||
def test_task_broadcasts_wipe(self, get_channel_layer, _):
|
||||
ends_at = timezone.now()
|
||||
place_settings = self.settings(ends_at)
|
||||
layer = get_channel_layer.return_value
|
||||
layer.group_send = mock.AsyncMock()
|
||||
|
||||
wipe_canvas_task.call(place_settings.id, to_epoch_ms(ends_at))
|
||||
|
||||
layer.group_send.assert_awaited_once_with("place_room_event", {"type": "wipe"})
|
||||
|
||||
@mock.patch("place.tasks.get_channel_layer")
|
||||
def test_task_skips_moved_end_time(self, get_channel_layer, _):
|
||||
ends_at = timezone.now()
|
||||
place_settings = self.settings(ends_at + timedelta(hours=1))
|
||||
layer = get_channel_layer.return_value
|
||||
layer.group_send = mock.AsyncMock()
|
||||
|
||||
wipe_canvas_task.call(place_settings.id, to_epoch_ms(ends_at))
|
||||
|
||||
layer.group_send.assert_not_awaited()
|
||||
@@ -6,8 +6,10 @@ either). Each message is a short string: a one-letter type tag followed by
|
||||
'|'-separated fields, in a fixed order the client already knows from this
|
||||
table:
|
||||
|
||||
s|<next>|<width>|<height> initial personal state, once per connection
|
||||
(next = epoch ms cooldown ends, or empty if free now)
|
||||
s|<next>|<width>|<height>|<end>
|
||||
initial personal state, once per connection
|
||||
(next = epoch ms cooldown ends, or empty if free now;
|
||||
end = epoch ms placement ends, or empty if never)
|
||||
p|<x>|<y>|<c> a pixel was placed - broadcast to everyone,
|
||||
including the placer (no separate ack needed)
|
||||
k|<next> your own placement was accepted; carries your
|
||||
@@ -15,10 +17,16 @@ table:
|
||||
r|c|<next> rejected: still on cooldown
|
||||
r|a rejected: not logged in
|
||||
r|i rejected: invalid x/y/color
|
||||
e|<width>|<height> an admin resized the grid for this event -
|
||||
broadcast to everyone. A client that sees this
|
||||
should re-fetch /api/.../place/canvas rather than
|
||||
try to remap its local pixels.
|
||||
r|d rejected: placement is over (past <end>)
|
||||
e|<width>|<height>|<end>
|
||||
an admin edited this event's settings (grid size
|
||||
and/or end time) - broadcast to everyone.
|
||||
If the size changed, a client should re-fetch
|
||||
/api/.../place/canvas rather than try to remap its
|
||||
local pixels.
|
||||
w placement just ended - broadcast to everyone at
|
||||
<end> (see tasks.py). Clients play the wipe wave
|
||||
and blank their board.
|
||||
|
||||
Client -> server has exactly one message shape, the same as the broadcast:
|
||||
p|<x>|<y>|<c>
|
||||
|
||||
Generated
+74
@@ -11,6 +11,15 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.16
|
||||
version: 4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4))
|
||||
'@tanstack/query-persist-client-core':
|
||||
specifier: ^5.104.3
|
||||
version: 5.104.3
|
||||
'@tanstack/query-sync-storage-persister':
|
||||
specifier: ^5.104.3
|
||||
version: 5.104.3
|
||||
'@tanstack/vue-query':
|
||||
specifier: ^5.104.3
|
||||
version: 5.104.3(vue@3.5.22(typescript@5.9.3))
|
||||
'@tanstack/vue-table':
|
||||
specifier: ^8.21.3
|
||||
version: 8.21.3(vue@3.5.22(typescript@5.9.3))
|
||||
@@ -515,10 +524,32 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7
|
||||
|
||||
'@tanstack/match-sorter-utils@9.2.6':
|
||||
resolution: {integrity: sha512-DIpUSOhPfQ7u/qfUi/Wgm0wBO56MyBYXGvnlI0NcauDRPqdBUlAZZjW2Dxm92m+k1UWVhKw8YJ4f47+75Aujyw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@tanstack/query-core@5.104.3':
|
||||
resolution: {integrity: sha512-DZY9TuJAOgU3UyHUhXb4CcTYEHNZszlwkPg4VViIwTxOKzLprPUU+rbaPZQd3srqkGOtpaDdKpW972UV/LKlcg==}
|
||||
|
||||
'@tanstack/query-persist-client-core@5.104.3':
|
||||
resolution: {integrity: sha512-6nhc/QbMpkKeuAM81SFSdcsJ9B8dm3fGHPODbau2cRSDyqcXiSl7sXQM7POVktch/YdgIGYPIQyZOQQaRarHMA==}
|
||||
|
||||
'@tanstack/query-sync-storage-persister@5.104.3':
|
||||
resolution: {integrity: sha512-sNM9X7QZdTHBlzHuNxuHQjECn3/zncuMgw5pesOFXu8XQ01UXBGASdMKZSRaxfNOhigRjm5souHhfmByN97NEg==}
|
||||
|
||||
'@tanstack/table-core@8.21.3':
|
||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/vue-query@5.104.3':
|
||||
resolution: {integrity: sha512-bdVlhyEFPllLfHioHx8kwtCAQ1YJQrvYczlsNzyiYFsp6n5yz9isNXiy6MIu+QGmG+RyghVdnp1UmiNDqLS22g==}
|
||||
peerDependencies:
|
||||
'@vue/composition-api': ^1.1.2
|
||||
vue: ^2.6.0 || ^3.3.0
|
||||
peerDependenciesMeta:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
'@tanstack/vue-table@8.21.3':
|
||||
resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -961,6 +992,9 @@ packages:
|
||||
regenerator-runtime@0.13.11:
|
||||
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||
|
||||
remove-accents@0.5.0:
|
||||
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
|
||||
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
@@ -1072,6 +1106,17 @@ packages:
|
||||
vscode-uri@3.1.0:
|
||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@vue/composition-api': ^1.0.0-rc.1
|
||||
vue: ^3.0.0-0 || ^2.6.0
|
||||
peerDependenciesMeta:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-i18n@11.4.12:
|
||||
resolution: {integrity: sha512-yvArvvo3ErLKFNlQrZUGmRkxL/ctM5xFYtE9FxTKiE/PalWP1bnTopBgbSRGLluYEyYSNl1Pt8l2J7zVavbXww==}
|
||||
engines: {node: '>= 22'}
|
||||
@@ -1445,8 +1490,31 @@ snapshots:
|
||||
tailwindcss: 4.1.16
|
||||
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4)
|
||||
|
||||
'@tanstack/match-sorter-utils@9.2.6':
|
||||
dependencies:
|
||||
remove-accents: 0.5.0
|
||||
|
||||
'@tanstack/query-core@5.104.3': {}
|
||||
|
||||
'@tanstack/query-persist-client-core@5.104.3':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.104.3
|
||||
|
||||
'@tanstack/query-sync-storage-persister@5.104.3':
|
||||
dependencies:
|
||||
'@tanstack/query-core': 5.104.3
|
||||
'@tanstack/query-persist-client-core': 5.104.3
|
||||
|
||||
'@tanstack/table-core@8.21.3': {}
|
||||
|
||||
'@tanstack/vue-query@5.104.3(vue@3.5.22(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@tanstack/match-sorter-utils': 9.2.6
|
||||
'@tanstack/query-core': 5.104.3
|
||||
'@vue/devtools-api': 6.6.4
|
||||
vue: 3.5.22(typescript@5.9.3)
|
||||
vue-demi: 0.14.10(vue@3.5.22(typescript@5.9.3))
|
||||
|
||||
'@tanstack/vue-table@8.21.3(vue@3.5.22(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@tanstack/table-core': 8.21.3
|
||||
@@ -1863,6 +1931,8 @@ snapshots:
|
||||
|
||||
regenerator-runtime@0.13.11: {}
|
||||
|
||||
remove-accents@0.5.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
@@ -1962,6 +2032,10 @@ snapshots:
|
||||
|
||||
vscode-uri@3.1.0: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.22(typescript@5.9.3)):
|
||||
dependencies:
|
||||
vue: 3.5.22(typescript@5.9.3)
|
||||
|
||||
vue-i18n@11.4.12(vue@3.5.22(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@intlify/core-base': 11.4.12
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from ninja import NinjaAPI, Schema
|
||||
from django.conf import settings
|
||||
from ninja import NinjaAPI
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpRequest
|
||||
from opus_magnum.api import router as opus_magnum_router
|
||||
@@ -54,31 +53,18 @@ api.add_router("", games_event_router, tags=["games"])
|
||||
api.add_router("", submissions_router, tags=["submissions"])
|
||||
api.add_router("", clock_router, tags=["clock"])
|
||||
api.add_router("", place_router, tags=["place"])
|
||||
|
||||
if settings.MARKET_ENABLED:
|
||||
api.add_router("", market_router, tags=["market"])
|
||||
api.add_router("", market_router, tags=["market"])
|
||||
|
||||
|
||||
# Health check endpoint
|
||||
@api.get("/health")
|
||||
@api.get("/health", response=dict)
|
||||
def health_check(request):
|
||||
"""Health check endpoint"""
|
||||
return {"status": "healthy", "service": "polylan-submitter-api"}
|
||||
|
||||
|
||||
class ConfigOut(Schema):
|
||||
market_enabled: bool
|
||||
|
||||
|
||||
# Site-wide config the SPA needs but that isn't tied to any one event
|
||||
# (e.g. whether to render the market card at all on the home page).
|
||||
@api.get("/config", response=ConfigOut)
|
||||
def get_config(request):
|
||||
return {"market_enabled": settings.MARKET_ENABLED}
|
||||
|
||||
|
||||
# Cache management endpoint
|
||||
@api.post("/cache/clear")
|
||||
@api.post("/cache/clear", response={200: dict, 403: dict})
|
||||
def clear_cache(request: HttpRequest, event_path: str | None = None):
|
||||
"""Clear API caches (admin only). Pass event_path to clear just that event's keys."""
|
||||
if not request.user.is_authenticated or not request.user.is_staff:
|
||||
|
||||
@@ -17,9 +17,11 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings")
|
||||
|
||||
django_asgi_app = get_asgi_application()
|
||||
|
||||
from submissions.routing import websocket_urlpatterns as submissions_websocket_urlpatterns
|
||||
from clock.routing import websocket_urlpatterns as clock_websocket_urlpatterns
|
||||
from place.routing import websocket_urlpatterns as place_websocket_urlpatterns
|
||||
from submissions.routing import ( # noqa: E402
|
||||
websocket_urlpatterns as submissions_websocket_urlpatterns,
|
||||
)
|
||||
from clock.routing import websocket_urlpatterns as clock_websocket_urlpatterns # noqa: E402
|
||||
from place.routing import websocket_urlpatterns as place_websocket_urlpatterns # noqa: E402
|
||||
|
||||
websocket_urlpatterns = [
|
||||
*submissions_websocket_urlpatterns,
|
||||
|
||||
@@ -13,30 +13,90 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Optional: point this at a KEY=value file (see
|
||||
# deploy/docker/polylan-submitter.env.dist for the template - unused in the
|
||||
# Docker deploy itself, which passes env vars directly via `docker run
|
||||
# --env-file` instead) to fill in the env vars read throughout this file.
|
||||
# override=False so an already-exported env var always wins over the file -
|
||||
# this only fills gaps, same as every os.environ.get() below already
|
||||
# treating a merely-blank value as "use my own default" rather than "".
|
||||
if os.environ.get("SETTINGS_ENV_FILE_LOCATION"):
|
||||
load_dotenv(os.environ["SETTINGS_ENV_FILE_LOCATION"], override=False)
|
||||
|
||||
os.environ.setdefault("AWS_REQUEST_CHECKSUM_CALCULATION", "when_required")
|
||||
os.environ.setdefault("AWS_RESPONSE_CHECKSUM_VALIDATION", "when_required")
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||
#
|
||||
# All of the settings below that read from os.environ fall back to
|
||||
# quick-start/dev-friendly defaults when the env var is unset, EXCEPT
|
||||
# DJANGO_DEBUG, which defaults to False (secure by default per Django's own
|
||||
# deployment checklist) - export DJANGO_DEBUG=true for local development.
|
||||
#
|
||||
# These all use `os.environ.get(X) or default` rather than
|
||||
# `os.environ.get(X, default)`: an env var that's *defined but blank* (e.g.
|
||||
# systemd's EnvironmentFile represents "not configured" as `KEY=`, not by
|
||||
# omitting the line) must fall through to the default too, not become "".
|
||||
# That distinction matters a lot here - `int(os.environ.get("REDIS_PORT",
|
||||
# "6379"))` would still crash on an empty string.
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = "django-insecure-m0ivj_3gpbf281jl1$&n5pdo!5le(bp4z31u(1&4s=n#!tpy=n"
|
||||
SECRET_KEY = (
|
||||
os.environ.get("DJANGO_SECRET_KEY")
|
||||
or "django-insecure-m0ivj_3gpbf281jl1$&n5pdo!5le(bp4z31u(1&4s=n#!tpy=n"
|
||||
)
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true"
|
||||
|
||||
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
|
||||
ALLOWED_HOSTS = [
|
||||
host
|
||||
for host in (os.environ.get("DJANGO_ALLOWED_HOSTS") or "127.0.0.1,localhost").split(
|
||||
","
|
||||
)
|
||||
if host
|
||||
]
|
||||
|
||||
# TLS is terminated by the reverse proxy in front of the container, so daphne
|
||||
# only ever sees plain http. Without these, Django compares the browser's
|
||||
# `Origin: https://...` against `http://...` and rejects every POST (admin
|
||||
# login included) with "CSRF verification failed - Origin checking failed".
|
||||
# Full origins with scheme, e.g. "https://submitter.polylan.ch".
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
origin
|
||||
for origin in (os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS") or "").split(",")
|
||||
if origin
|
||||
]
|
||||
|
||||
# Trust the proxy's X-Forwarded-Proto so request.is_secure() /
|
||||
# build_absolute_uri() report https. Only safe if the proxy overwrites that
|
||||
# header (client-supplied values must not pass through) - opt-in for that
|
||||
# reason.
|
||||
if os.environ.get("DJANGO_BEHIND_TLS_PROXY", "false").lower() == "true":
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"daphne",
|
||||
"django.contrib.admin",
|
||||
"admin_interface",
|
||||
"colorfield",
|
||||
# "palette" provides the admin/base.html override (the command-palette
|
||||
# UI) and must precede "palette.apps.PaletteAdminConfig", which stands
|
||||
# in for "django.contrib.admin" (same app name, custom AdminSite) to
|
||||
# wire the palette into the admin.
|
||||
"palette.apps.PaletteConfig",
|
||||
"palette.apps.PaletteAdminConfig",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
@@ -58,6 +118,12 @@ INSTALLED_APPS = [
|
||||
"place",
|
||||
]
|
||||
|
||||
if DEBUG:
|
||||
# Was unconditionally added by settingsLocal.py before it was retired in
|
||||
# favor of env-var-driven settings; kept dev-only since it pulls in
|
||||
# shell_plus and similar dev tooling that has no place in production.
|
||||
INSTALLED_APPS += ["django_extensions"]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
@@ -95,13 +161,28 @@ ASGI_APPLICATION = "polylan_submitter.asgi.application"
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||
#
|
||||
# Falls back to sqlite (the quick-start default) when POSTGRES_DB isn't set,
|
||||
# so a fresh checkout with no env configured still works out of the box.
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
if os.environ.get("POSTGRES_DB"):
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": os.environ["POSTGRES_DB"],
|
||||
"USER": os.environ.get("POSTGRES_USER") or "postgres",
|
||||
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""),
|
||||
"HOST": os.environ.get("POSTGRES_HOST") or "localhost",
|
||||
"PORT": os.environ.get("POSTGRES_PORT") or "5432",
|
||||
}
|
||||
}
|
||||
else:
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
@@ -155,16 +236,34 @@ AUTH_USER_MODEL = "accounts.CustomUser"
|
||||
# Simple CAS Configuration
|
||||
CAS_SERVER_URL = "https://polylan.ch/cas/"
|
||||
|
||||
# Enables POST /testing/login/ (see testing_auth_views.py), a
|
||||
# username/password login for local-only accounts that authenticate through
|
||||
# ModelBackend instead of SimpleCASBackend. This exists so load testing (see
|
||||
# loadtest/locustfile.py) never sends traffic to the real CAS server at
|
||||
# polylan.ch. Leave this off everywhere except a dedicated load-test
|
||||
# environment - the "Create load test users" admin action on CustomUser
|
||||
# (see accounts/admin.py) also hides itself without it, as a second layer
|
||||
# of the same guard rail.
|
||||
LOAD_TEST_LOGIN_ENABLED = (
|
||||
os.environ.get("LOAD_TEST_LOGIN_ENABLED", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# Steam API Configuration
|
||||
STEAM_API_KEY = os.environ.get("STEAM_API_KEY", None) # Set via environment variable
|
||||
STEAM_API_KEY = os.environ.get("STEAM_API_KEY") or None # Set via environment variable
|
||||
|
||||
# Steam store info (price, discount, blurb) for the event's Steam games, refreshed
|
||||
# in the background every STEAM_STORE_REFRESH_MINUTES (see games.tasks)
|
||||
STEAM_STORE_REFRESH_MINUTES = int(os.environ.get("STEAM_STORE_REFRESH_MINUTES") or 360)
|
||||
# Store region the prices are fetched for (ISO country code, drives the currency)
|
||||
STEAM_STORE_COUNTRY = os.environ.get("STEAM_STORE_COUNTRY") or "ch"
|
||||
|
||||
# Base URL of the legacy PolyLAN site, used to sync AAndT matches/members
|
||||
# (see submissions.services.aandt_sync)
|
||||
AANDT_API_BASE_URL = os.environ.get("AANDT_API_BASE_URL", "https://polylan.ch")
|
||||
AANDT_API_BASE_URL = os.environ.get("AANDT_API_BASE_URL") or "https://polylan.ch"
|
||||
|
||||
# Base URL of this site, used to build submission links pushed back to the
|
||||
# legacy AAndT match messages (see submissions.services.aandt_sync)
|
||||
SUBMITTER_BASE_URL = os.environ.get("SUBMITTER_BASE_URL", "http://localhost:7777")
|
||||
SUBMITTER_BASE_URL = os.environ.get("SUBMITTER_BASE_URL") or "http://localhost:7777"
|
||||
|
||||
# File Upload Settings
|
||||
MEDIA_URL = "/media/"
|
||||
@@ -174,11 +273,16 @@ MEDIA_ROOT = BASE_DIR / "media"
|
||||
FILE_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB
|
||||
|
||||
# Redis host/port shared by the cache, channel layer, RQ, and place's canvas
|
||||
# below - each still uses its own DB number, just on a common host.
|
||||
REDIS_HOST = os.environ.get("REDIS_HOST") or "127.0.0.1"
|
||||
REDIS_PORT = int(os.environ.get("REDIS_PORT") or "6379")
|
||||
|
||||
# Caching Configuration
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": "redis://127.0.0.1:6379/11",
|
||||
"LOCATION": f"redis://{REDIS_HOST}:{REDIS_PORT}/11",
|
||||
"TIMEOUT": 300, # 5 minutes default
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
@@ -191,7 +295,7 @@ CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [("127.0.0.1", 6379)],
|
||||
"hosts": [(REDIS_HOST, REDIS_PORT)],
|
||||
"serializer_format": "cjson",
|
||||
},
|
||||
},
|
||||
@@ -199,7 +303,9 @@ CHANNEL_LAYERS = {
|
||||
|
||||
# Redis for place's live pixel canvas (SETRANGE on the hot path - not a fit
|
||||
# for django-redis's cache API, so it gets its own raw redis-py client).
|
||||
PLACE_REDIS_URL = os.environ.get("PLACE_REDIS_URL", "redis://127.0.0.1:6379/15")
|
||||
PLACE_REDIS_URL = (
|
||||
os.environ.get("PLACE_REDIS_URL") or f"redis://{REDIS_HOST}:{REDIS_PORT}/15"
|
||||
)
|
||||
|
||||
# Django Tasks Configuration (with RQ backend)
|
||||
TASKS = {
|
||||
@@ -210,8 +316,8 @@ TASKS = {
|
||||
|
||||
RQ_QUEUES = {
|
||||
"default": {
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 6379,
|
||||
"HOST": REDIS_HOST,
|
||||
"PORT": REDIS_PORT,
|
||||
"DB": 12,
|
||||
"DEFAULT_RESULT_TTL": 500,
|
||||
}
|
||||
@@ -227,9 +333,6 @@ ALLOWED_SUBMISSION_TYPES = [
|
||||
"video/webm",
|
||||
]
|
||||
|
||||
# Market app settings
|
||||
MARKET_ENABLED = os.environ.get("MARKET_ENABLED", "true").lower() == "true"
|
||||
|
||||
# Authentication backends
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
@@ -243,7 +346,8 @@ LOGIN_REDIRECT_URL = "/"
|
||||
LOGOUT_REDIRECT_URL = "/"
|
||||
DJANGO_VITE = {
|
||||
"default": {
|
||||
"dev_mode": False,
|
||||
"dev_mode": DEBUG,
|
||||
"dev_server_port": 5273,
|
||||
}
|
||||
}
|
||||
STATIC_URL = "/static/"
|
||||
@@ -253,26 +357,51 @@ STATICFILES_DIRS = [
|
||||
os.path.join(BASE_DIR, "static_source/vite"),
|
||||
]
|
||||
|
||||
# Submission/media uploads always go to S3 - there's no local fallback for
|
||||
# that, so these must be set (via env) wherever the app runs, dev included.
|
||||
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
|
||||
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
|
||||
AWS_S3_ENDPOINT_URL = (
|
||||
os.environ.get("AWS_S3_ENDPOINT_URL") or "https://storage.polylan.ch"
|
||||
)
|
||||
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_STORAGE_BUCKET_NAME") or "dev-submissions"
|
||||
|
||||
_s3_storage_options = {
|
||||
"bucket_name": AWS_STORAGE_BUCKET_NAME,
|
||||
"endpoint_url": AWS_S3_ENDPOINT_URL,
|
||||
"access_key": AWS_ACCESS_KEY_ID,
|
||||
"secret_key": AWS_SECRET_ACCESS_KEY,
|
||||
}
|
||||
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "storages.backends.s3.S3Storage",
|
||||
"OPTIONS": _s3_storage_options,
|
||||
},
|
||||
# Static files are always collected to local disk and served by
|
||||
# WhiteNoise (see WhiteNoiseMiddleware in MIDDLEWARE above, which is
|
||||
# likewise unconditional) - S3 is only for user-uploaded content
|
||||
# ("default", above), never for the app's own CSS/JS/images.
|
||||
"staticfiles": {
|
||||
"BACKEND": "storages.backends.s3.S3Storage",
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
SENTRY_ENVIRONMENT = "production"
|
||||
from polylan_submitter.settingsLocal import * # noqa
|
||||
|
||||
SENTRY_ENVIRONMENT = os.environ.get("SENTRY_ENVIRONMENT") or (
|
||||
"development" if DEBUG else "production"
|
||||
)
|
||||
SENTRY_DSN = (
|
||||
os.environ.get("SENTRY_DSN")
|
||||
or "https://cc62a4ce3f3470890b43accf02cc6d8c@sentry2.polylan.ch/12"
|
||||
)
|
||||
|
||||
import sentry_sdk # noqa
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn="https://cc62a4ce3f3470890b43accf02cc6d8c@sentry2.polylan.ch/12",
|
||||
dsn=SENTRY_DSN,
|
||||
# Set traces_sample_rate to 1.0 to capture 100%
|
||||
# of transactions for tracing.
|
||||
traces_sample_rate=1.0,
|
||||
traces_sample_rate=0.1,
|
||||
environment=SENTRY_ENVIRONMENT,
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ from django.contrib.auth.decorators import login_required
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
|
||||
from testing_auth_views import TestLoginView
|
||||
from events.models import Event
|
||||
from games.decorators import require_game_enabled
|
||||
from market.views import market_home
|
||||
@@ -35,7 +36,9 @@ OPUS_MAGNUM_APP_ID = 558990
|
||||
|
||||
@login_required
|
||||
def home(request: HttpRequest):
|
||||
event = Event.objects.order_by("-created_at").first()
|
||||
# Staff land on an available event too; hidden ones are only a fallback.
|
||||
events = Event.objects.available_to(request.user)
|
||||
event = events.order_by("-is_available", "-created_at").first()
|
||||
if event is None:
|
||||
raise Http404("No event configured")
|
||||
return redirect("event.home", event_path=event.path)
|
||||
@@ -47,6 +50,18 @@ def event_home(request: HttpRequest, event_path: str):
|
||||
return render(request, "app.html")
|
||||
|
||||
|
||||
@login_required
|
||||
def games_home(request: HttpRequest, event_path: str):
|
||||
get_object_or_404(Event, path=event_path)
|
||||
return render(request, "app.html")
|
||||
|
||||
|
||||
@login_required
|
||||
def planning_home(request: HttpRequest, event_path: str):
|
||||
get_object_or_404(Event, path=event_path)
|
||||
return render(request, "app.html")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_game_enabled(OPUS_MAGNUM_APP_ID)
|
||||
def opus_magnum_home(request: HttpRequest, event_path: str):
|
||||
@@ -92,6 +107,8 @@ urlpatterns = [
|
||||
path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"),
|
||||
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
|
||||
path("api/", api.urls),
|
||||
path("<slug:event_path>/games", games_home, name="games.home"),
|
||||
path("<slug:event_path>/planning", planning_home, name="planning.home"),
|
||||
path("<slug:event_path>/market", market_home, name="market.home"),
|
||||
path("<slug:event_path>/opus-magnum", opus_magnum_home, name="opus-magnum.home"),
|
||||
path("<slug:event_path>/noita", noita_home, name="noita.home"),
|
||||
@@ -112,6 +129,14 @@ urlpatterns = [
|
||||
path("", home, name="home"),
|
||||
]
|
||||
|
||||
# Load-test-only username/password login (see testing_auth_views.py) - the
|
||||
# URL itself doesn't exist unless explicitly enabled, so it 404s rather than
|
||||
# 403s in every environment that hasn't opted in.
|
||||
if settings.LOAD_TEST_LOGIN_ENABLED:
|
||||
urlpatterns.append(
|
||||
path("testing/login/", TestLoginView.as_view(), name="testing.login")
|
||||
)
|
||||
|
||||
# Serve media files in development
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue";
|
||||
import { computed } from "vue";
|
||||
import { RouterView, useRoute } from "vue-router";
|
||||
import AppHeader from "@/layout/AppHeader.vue";
|
||||
import AppFooter from "@/layout/AppFooter.vue";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
|
||||
const userStore = useUserStore();
|
||||
const route = useRoute();
|
||||
// Embed routes (e.g. place/embed) are meant to be dropped into an <iframe>
|
||||
// as-is - the header/footer chrome would just eat into the fixed space a
|
||||
// stream overlay or OBS browser source gives it.
|
||||
const isEmbed = computed(() => route.meta.embed === true);
|
||||
|
||||
onMounted(() => {
|
||||
userStore.loadUserData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useTitle } from "@vueuse/core";
|
||||
import { useClockSocket } from "@/composables/useClockSocket";
|
||||
import { useLiveBadge } from "@/composables/useLiveBadge";
|
||||
|
||||
interface Props {
|
||||
eventPath: string;
|
||||
@@ -25,6 +26,7 @@ const {
|
||||
cooldownDurationSeconds,
|
||||
press,
|
||||
} = useClockSocket(props.eventPath);
|
||||
useLiveBadge("clock", connected);
|
||||
|
||||
const remainingDisplay = computed(() => {
|
||||
const totalSeconds = Math.floor(remainingMs.value / 1000);
|
||||
@@ -38,10 +40,6 @@ const onCooldown = computed(() => cooldownRemainingMs.value > 0);
|
||||
|
||||
<template>
|
||||
<div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-8">
|
||||
<span class="badge" :class="connected ? 'badge-success' : 'badge-error'">
|
||||
{{ connected ? t("clock.connected") : t("clock.reconnecting") }}
|
||||
</span>
|
||||
|
||||
<div class="font-bold text-7xl tabular-nums">
|
||||
{{ remainingDisplay }}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useStorage, useTitle } from "@vueuse/core";
|
||||
import { gamesApiListEventSteamGames } from "./api";
|
||||
import type { SteamGameOut } from "./api/types.gen";
|
||||
import SteamLibraryCard from "./components/SteamLibraryCard.vue";
|
||||
import SteamStoreCard from "./components/SteamStoreCard.vue";
|
||||
|
||||
interface Props {
|
||||
eventPath: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const { t } = useI18n();
|
||||
useTitle(computed(() => t("games.title")));
|
||||
|
||||
const games = ref<SteamGameOut[]>([]);
|
||||
const loading = ref(true);
|
||||
// Library (vertical covers) by default, the store cards show the prices
|
||||
const showPrices = useStorage("games.showPrices", true);
|
||||
|
||||
onMounted(async () => {
|
||||
const response = await gamesApiListEventSteamGames({
|
||||
path: { event_path: props.eventPath },
|
||||
});
|
||||
if (response.data) {
|
||||
games.value = response.data;
|
||||
}
|
||||
loading.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
|
||||
<div class="w-full max-w-7xl">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-5xl font-bold mb-4">{{ t("games.title") }}</h1>
|
||||
<p class="text-xl text-base-content/70">{{ t("games.subtitle") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mb-6">
|
||||
<label class="label cursor-pointer gap-2">
|
||||
<span>{{ t("games.showPrices") }}</span>
|
||||
<input v-model="showPrices" type="checkbox" class="toggle toggle-primary" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex justify-center py-20">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="!games.length" class="text-center text-base-content/50 py-20">
|
||||
{{ t("games.empty") }}
|
||||
</p>
|
||||
|
||||
<div v-else-if="showPrices" class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<SteamStoreCard v-for="game in games" :key="game.id" :game="game" />
|
||||
</div>
|
||||
|
||||
<div v-else
|
||||
class="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] sm:grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-4 sm:gap-6">
|
||||
<SteamLibraryCard v-for="game in games" :key="game.id" :game="game" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { ref, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useTitle } from "@vueuse/core";
|
||||
import { gamesApiListEventGames, polylanSubmitterApiGetConfig } from "./api";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import { eventGamesQuery, eventPlanningQuery } from "@/queries";
|
||||
import type { GamesApiListEventGamesResponse } from "./api/types.gen";
|
||||
|
||||
interface Props {
|
||||
@@ -16,10 +17,11 @@ const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
useTitle(computed(() => t("home.title")));
|
||||
|
||||
const games = ref<GamesApiListEventGamesResponse | undefined>();
|
||||
const loading = ref(true);
|
||||
const { data: games, isPending: loading } = useQuery(eventGamesQuery(props.eventPath));
|
||||
const hasSteamGames = computed(() => games.value?.some((game) => game.steam_app_id) ?? false);
|
||||
const { data: planning } = useQuery(eventPlanningQuery(props.eventPath));
|
||||
const hasPlanning = computed(() => !!planning.value?.length);
|
||||
const imageErrors = ref<Set<number>>(new Set());
|
||||
const marketEnabled = ref(false);
|
||||
|
||||
const getHeaderImage = (game: GamesApiListEventGamesResponse[number]) => {
|
||||
if (game.image) {
|
||||
@@ -35,33 +37,16 @@ const onImageError = (gameId: number) => {
|
||||
imageErrors.value.add(gameId);
|
||||
};
|
||||
|
||||
const goToMarket = () => {
|
||||
router.push(`/${props.eventPath}/market`);
|
||||
};
|
||||
|
||||
const goToGame = (game: GamesApiListEventGamesResponse[number]) => {
|
||||
const path = game.custom_path ? `/${game.path}` : `/submissions/${game.path}`;
|
||||
router.push(`/${props.eventPath}${path}`);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
const [gamesResponse, configResponse] = await Promise.all([
|
||||
gamesApiListEventGames({ path: { event_path: props.eventPath } }),
|
||||
polylanSubmitterApiGetConfig(),
|
||||
]);
|
||||
if (gamesResponse.data) {
|
||||
games.value = gamesResponse.data;
|
||||
}
|
||||
if (configResponse.data) {
|
||||
marketEnabled.value = configResponse.data.market_enabled;
|
||||
}
|
||||
loading.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 bg-base-300 flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-6xl">
|
||||
<div class="w-full max-w-7xl">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-12">
|
||||
<h1 class="text-5xl font-bold mb-4">{{ t("home.title") }}</h1>
|
||||
@@ -77,25 +62,6 @@ onMounted(async () => {
|
||||
|
||||
<!-- Cards Grid -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<!-- Market Card -->
|
||||
<div v-if="marketEnabled" @click="goToMarket"
|
||||
class="card card-xl bg-base-200 shadow-xl hover:shadow-2xl transition-all cursor-pointer transform hover:-translate-y-2 hover:scale-[1.05] hover:bg-base-100 overflow-hidden">
|
||||
<figure class="relative h-60 bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center">
|
||||
<i class="mdi mdi-chart-box text-6xl text-white opacity-80"></i>
|
||||
<div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors"></div>
|
||||
</figure>
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl">{{ t("home.marketCard.title") }}</h2>
|
||||
<p class="text-base-content/70">{{ t("home.marketCard.description") }}</p>
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button class="btn btn-primary">
|
||||
<i class="mdi mdi-arrow-right mr-2"></i>
|
||||
{{ t("home.marketCard.cta") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Game Cards -->
|
||||
<div v-for="game in games" :key="game.id"
|
||||
@click="goToGame(game)"
|
||||
@@ -111,6 +77,7 @@ onMounted(async () => {
|
||||
</figure>
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl">{{ game.name }}</h2>
|
||||
<p v-if="game.description" class="text-sm text-base-content/70 line-clamp-2" :title="game.description">{{ game.description }}</p>
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button class="btn btn-primary">
|
||||
<i class="mdi mdi-arrow-right mr-2"></i>
|
||||
@@ -121,6 +88,18 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Links to the planning and the Steam games page -->
|
||||
<div v-if="!loading && (hasPlanning || hasSteamGames)" class="flex flex-wrap justify-center gap-4 mt-12">
|
||||
<RouterLink v-if="hasPlanning" :to="{ name: 'planning', params: { eventPath } }" class="btn btn-outline">
|
||||
<i class="mdi mdi-calendar-clock mr-2"></i>
|
||||
{{ t("home.planning") }}
|
||||
</RouterLink>
|
||||
<RouterLink v-if="hasSteamGames" :to="{ name: 'games', params: { eventPath } }" class="btn btn-outline">
|
||||
<i class="mdi mdi-steam mr-2"></i>
|
||||
{{ t("home.getTheGames") }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center mt-12 text-base-content/50">
|
||||
<p>{{ t("home.footer") }}</p>
|
||||
|
||||
@@ -19,7 +19,7 @@ const { t } = useI18n();
|
||||
useTitle(computed(() => t("market.pageTitle")));
|
||||
|
||||
const marketStore = useMarketStore();
|
||||
const { markets, userInfo, isLoading } = storeToRefs(marketStore);
|
||||
const { markets, userInfo, userPoints, isLoading } = storeToRefs(marketStore);
|
||||
|
||||
const reloadPage = async () => {
|
||||
await marketStore.refreshPage(props.eventPath);
|
||||
@@ -56,7 +56,7 @@ onMounted(() => {
|
||||
{{ t("market.myBets") }}
|
||||
</h3>
|
||||
<div class="text-lg font-semibold">
|
||||
<span class="text-primary">{{ userInfo.points }}</span>
|
||||
<span class="text-primary">{{ userPoints }}</span>
|
||||
<span class="text-base-content/60 ml-1">{{ t("common.pts") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,10 +9,11 @@ import Results from "@/components/Results.vue";
|
||||
import Winners from "@/components/Winners.vue";
|
||||
import PuzzleResults from "@/components/PuzzleResults.vue";
|
||||
import TopUsersLeaderboard from "@/components/TopUsersLeaderboard.vue";
|
||||
import { polylanSubmitterApiGetUserInfo, opusMagnumApiGetCollection } from "@/api";
|
||||
import { opusMagnumApiGetCollection } from "@/api";
|
||||
import { apiService, errorHelpers } from "@/services/apiService";
|
||||
import { usePuzzlesStore } from "@/stores/puzzles";
|
||||
import { useSubmissionsStore } from "@/stores/submissions";
|
||||
import { queryClient, userQuery } from "@/queries";
|
||||
import { provideEventPath } from "@/composables/useEventPath";
|
||||
import type { PuzzleResponse, UserInfo, SteamCollection } from "@/types";
|
||||
import { useCountdown } from "@vueuse/core";
|
||||
@@ -74,13 +75,7 @@ async function initialize() {
|
||||
|
||||
// Load user info
|
||||
console.log("Loading user info...");
|
||||
const userResponse = await polylanSubmitterApiGetUserInfo();
|
||||
if (userResponse.data) {
|
||||
userInfo.value = userResponse.data as UserInfo;
|
||||
console.log("User info loaded:", userResponse.data);
|
||||
} else if (userResponse.error) {
|
||||
console.warn("User info error:", userResponse.error);
|
||||
}
|
||||
userInfo.value = (await queryClient.fetchQuery(userQuery()).catch(() => null)) as UserInfo | null;
|
||||
|
||||
// Load collection data
|
||||
console.log("Loading collection...");
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useTitle } from "@vueuse/core";
|
||||
import { usePlaceSocket } from "@/composables/usePlaceSocket";
|
||||
import { useLiveBadge } from "@/composables/useLiveBadge";
|
||||
import { PALETTE } from "@/palette";
|
||||
import { playWipeWave } from "@/placeWipe";
|
||||
|
||||
interface Props {
|
||||
eventPath: string;
|
||||
@@ -39,20 +41,39 @@ function drawPixel(x: number, y: number, colorIndex: number) {
|
||||
pixelCtx.fillRect(x, y, 1, 1);
|
||||
}
|
||||
|
||||
let cancelWipe: (() => void) | null = null;
|
||||
|
||||
function wipe() {
|
||||
if (!pixelCtx) return;
|
||||
cancelWipe?.();
|
||||
cancelWipe = playWipeWave(pixelCtx, gridWidth.value, gridHeight.value);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => cancelWipe?.());
|
||||
|
||||
const {
|
||||
connected,
|
||||
cooldownRemainingMs,
|
||||
placementEndMs,
|
||||
placementEnded,
|
||||
cooldownDurationSeconds,
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
place,
|
||||
} = usePlaceSocket(props.eventPath, drawPixel);
|
||||
} = usePlaceSocket(props.eventPath, drawPixel, undefined, wipe);
|
||||
useLiveBadge("place", connected);
|
||||
|
||||
const overlayWidth = computed(() => gridWidth.value * PX_PER_CELL);
|
||||
const overlayHeight = computed(() => gridHeight.value * PX_PER_CELL);
|
||||
|
||||
const cooldownSecondsDisplay = computed(() => (cooldownRemainingMs.value / 1000).toFixed(1));
|
||||
const onCooldown = computed(() => cooldownRemainingMs.value > 0);
|
||||
const canPlace = computed(() => !placementEnded.value && !onCooldown.value);
|
||||
const placementEndDisplay = computed(() =>
|
||||
placementEndMs.value !== null
|
||||
? new Date(placementEndMs.value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
: null,
|
||||
);
|
||||
|
||||
// The overlay canvas never touches the pixel colors - it only draws grid
|
||||
// lines plus a preview of the hovered cell, so it can freely redraw on
|
||||
@@ -84,7 +105,7 @@ function drawOverlay() {
|
||||
}
|
||||
}
|
||||
|
||||
if (hoveredCell.value && !onCooldown.value) {
|
||||
if (hoveredCell.value && canPlace.value) {
|
||||
const { x, y } = hoveredCell.value;
|
||||
gridCtx.fillStyle = `${PALETTE[selectedColorIndex.value]}80`;
|
||||
gridCtx.fillRect(x * PX_PER_CELL, y * PX_PER_CELL, PX_PER_CELL, PX_PER_CELL);
|
||||
@@ -94,7 +115,7 @@ function drawOverlay() {
|
||||
}
|
||||
}
|
||||
|
||||
watch([selectedColorIndex, onCooldown, showGrid, gridWidth, gridHeight], drawOverlay);
|
||||
watch([selectedColorIndex, canPlace, showGrid, gridWidth, gridHeight], drawOverlay);
|
||||
|
||||
onMounted(() => {
|
||||
pixelCtx = pixelCanvasRef.value?.getContext("2d") ?? null;
|
||||
@@ -123,7 +144,7 @@ function handleMouseLeave() {
|
||||
}
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (onCooldown.value) return;
|
||||
if (!canPlace.value) return;
|
||||
const { x, y } = cellFromEvent(event);
|
||||
place(x, y, selectedColorIndex.value);
|
||||
}
|
||||
@@ -131,24 +152,36 @@ function handleClick(event: MouseEvent) {
|
||||
|
||||
<template>
|
||||
<div class="flex-1 bg-base-200 flex flex-col items-center px-4 py-8 gap-6">
|
||||
<span class="badge" :class="connected ? 'badge-success' : 'badge-error'">
|
||||
{{ connected ? t("place.connected") : t("place.reconnecting") }}
|
||||
</span>
|
||||
|
||||
<div class="relative w-full max-w-[512px]" :style="{ aspectRatio: `${gridWidth} / ${gridHeight}` }">
|
||||
<!-- Sized to fill the width, but capped so the full height (minus the
|
||||
navbar and the controls below) still fits in the viewport. -->
|
||||
<div class="relative" :style="{
|
||||
aspectRatio: `${gridWidth} / ${gridHeight}`,
|
||||
width: `min(100%, calc((100dvh - 16rem) * ${gridWidth} / ${gridHeight}))`,
|
||||
}">
|
||||
<canvas ref="pixelCanvasRef" :width="gridWidth" :height="gridHeight"
|
||||
class="absolute inset-0 w-full h-full border border-base-300 rounded-box shadow-lg" :style="{
|
||||
imageRendering: 'pixelated',
|
||||
cursor: onCooldown ? 'not-allowed' : 'crosshair',
|
||||
cursor: canPlace ? 'crosshair' : 'not-allowed',
|
||||
}" @click="handleClick" @mousemove="handleMouseMove" @mouseleave="handleMouseLeave" />
|
||||
<canvas ref="gridCanvasRef" :width="overlayWidth" :height="overlayHeight"
|
||||
class="absolute inset-0 w-full h-full rounded-box pointer-events-none" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Timer shares the toggle row so showing it doesn't push the page past
|
||||
the viewport height the grid is sized against. -->
|
||||
<div class="flex items-center gap-4 h-8">
|
||||
<button class="btn btn-ghost btn-sm" @click="showGrid = !showGrid">
|
||||
{{ showGrid ? t("place.hideGrid") : t("place.showGrid") }}
|
||||
</button>
|
||||
<span v-if="placementEnded" class="badge badge-warning">{{ t("place.placementEnded") }}</span>
|
||||
<template v-else-if="onCooldown">
|
||||
<span class="font-bold text-xl tabular-nums">{{ cooldownSecondsDisplay }}s</span>
|
||||
<progress class="progress progress-primary w-48"
|
||||
:value="cooldownDurationSeconds - cooldownRemainingMs / 1000" :max="cooldownDurationSeconds" />
|
||||
</template>
|
||||
<span v-if="placementEndDisplay && !placementEnded" class="text-sm opacity-70">
|
||||
{{ t("place.endsAt", { time: placementEndDisplay }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-2 max-w-md">
|
||||
@@ -156,13 +189,5 @@ function handleClick(event: MouseEvent) {
|
||||
:class="selectedColorIndex === index ? 'border-primary scale-110' : 'border-base-300'"
|
||||
:style="{ backgroundColor: color }" @click="selectedColorIndex = index" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span v-if="onCooldown" class="font-bold text-2xl tabular-nums">
|
||||
{{ cooldownSecondsDisplay }}s
|
||||
</span>
|
||||
<progress v-if="onCooldown" class="progress progress-primary w-48"
|
||||
:value="cooldownDurationSeconds - cooldownRemainingMs / 1000" :max="cooldownDurationSeconds" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { usePlaceSocket } from "@/composables/usePlaceSocket";
|
||||
import { PALETTE } from "@/palette";
|
||||
import { playWipeWave } from "@/placeWipe";
|
||||
|
||||
interface Props {
|
||||
eventPath: string;
|
||||
@@ -22,7 +23,22 @@ function drawPixel(x: number, y: number, colorIndex: number) {
|
||||
ctx.fillRect(x, y, 1, 1);
|
||||
}
|
||||
|
||||
const { connected, gridWidth, gridHeight } = usePlaceSocket(props.eventPath, drawPixel);
|
||||
let cancelWipe: (() => void) | null = null;
|
||||
|
||||
function wipe() {
|
||||
if (!ctx) return;
|
||||
cancelWipe?.();
|
||||
cancelWipe = playWipeWave(ctx, gridWidth.value, gridHeight.value);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => cancelWipe?.());
|
||||
|
||||
const { connected, gridWidth, gridHeight } = usePlaceSocket(
|
||||
props.eventPath,
|
||||
drawPixel,
|
||||
undefined,
|
||||
wipe,
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
ctx = canvasRef.value?.getContext("2d") ?? null;
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useTemplateRef } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useElementSize, useTitle } from "@vueuse/core";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import { eventPlanningQuery } from "@/queries";
|
||||
import type { PlanningSlotOut } from "./api/types.gen";
|
||||
import PlanningCover from "./components/PlanningCover.vue";
|
||||
import PlanningMultiSlot from "./components/PlanningMultiSlot.vue";
|
||||
|
||||
interface Props {
|
||||
eventPath: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const { t } = useI18n();
|
||||
useTitle(computed(() => t("planning.title")));
|
||||
|
||||
const { data: slots, isPending: loading } = useQuery(eventPlanningQuery(props.eventPath));
|
||||
|
||||
const HOUR = 3600e3;
|
||||
// Hour height per px of column width: a 2h slot ends up close to the 2:3 cover ratio
|
||||
const HOUR_RATIO = 104 / 150;
|
||||
const BREAK_PX = 34;
|
||||
const DAY_PX = 32;
|
||||
const AXIS_PX = 56;
|
||||
// Columns shrink to fit the page, the planning only scrolls sideways below the min
|
||||
const MIN_COL_PX = 80;
|
||||
const MAX_COL_PX = 150;
|
||||
const GAP_PX = 10;
|
||||
// The multi-anims column is the big one, its covers 1.75x the size of the others
|
||||
const WIDE_RATIO = 1.75;
|
||||
// Room on the left for the day names, they stick out of the time axis
|
||||
const DAY_LABEL_ROOM_PX = 120;
|
||||
// Empty runs at least this long (nights) get squashed into a thin break
|
||||
const MIN_BREAK_HOURS = 3;
|
||||
|
||||
const fmtTime = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
|
||||
const fmtDay = new Intl.DateTimeFormat(undefined, { weekday: "long", day: "numeric", month: "short" });
|
||||
|
||||
interface Segment {
|
||||
from: number;
|
||||
to: number;
|
||||
y: number;
|
||||
h: number;
|
||||
kind: "hour" | "idle" | "break" | "day";
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface Placed {
|
||||
slot: PlanningSlotOut;
|
||||
top: number;
|
||||
height: number;
|
||||
when: string;
|
||||
}
|
||||
|
||||
const scroller = useTemplateRef<HTMLElement>("scroller");
|
||||
const { width: available } = useElementSize(scroller);
|
||||
|
||||
const layout = computed(() => {
|
||||
const list = (slots.value ?? []).map((slot) => ({
|
||||
slot,
|
||||
b: Date.parse(slot.begin),
|
||||
e: Date.parse(slot.end),
|
||||
}));
|
||||
if (!list.length) return null;
|
||||
|
||||
// Unused columns are dropped, the multi-anims ones are wider
|
||||
const columnIds = [...new Set(list.map(({ slot }) => slot.column))].sort((a, b) => a - b);
|
||||
const isWide = (id: number) => list.some(({ slot }) => slot.column === id && slot.is_multi_anims);
|
||||
const wideCount = columnIds.filter(isWide).length;
|
||||
const fit =
|
||||
(available.value - DAY_LABEL_ROOM_PX - AXIS_PX - (columnIds.length - 1) * GAP_PX) /
|
||||
(columnIds.length + (WIDE_RATIO - 1) * wideCount);
|
||||
const colPx = available.value ? Math.min(MAX_COL_PX, Math.max(MIN_COL_PX, fit)) : MAX_COL_PX;
|
||||
const hourPx = colPx * HOUR_RATIO;
|
||||
|
||||
// Multi-anims run the whole event, only the other slots decide what's busy
|
||||
const singles = list.filter(({ slot }) => !slot.is_multi_anims);
|
||||
const busy = (t: number) => singles.some(({ b, e }) => b < t + HOUR && e > t);
|
||||
|
||||
const start = Math.floor(Math.min(...list.map(({ b }) => b)) / HOUR) * HOUR;
|
||||
const end = Math.ceil(Math.max(...list.map(({ e }) => e)) / HOUR) * HOUR;
|
||||
|
||||
const segments: Segment[] = [];
|
||||
let y = 0;
|
||||
let lastDay = "";
|
||||
for (let t = start; t < end;) {
|
||||
if (busy(t)) {
|
||||
// Each day gets its own header row, takes no time
|
||||
const day = fmtDay.format(t);
|
||||
if (day !== lastDay) {
|
||||
segments.push({ from: t, to: t, y, h: DAY_PX, kind: "day", label: day });
|
||||
y += DAY_PX;
|
||||
lastDay = day;
|
||||
}
|
||||
segments.push({ from: t, to: t + HOUR, y, h: hourPx, kind: "hour" });
|
||||
y += hourPx;
|
||||
t += HOUR;
|
||||
continue;
|
||||
}
|
||||
let u = t;
|
||||
while (u < end && !busy(u)) u += HOUR;
|
||||
const hours = (u - t) / HOUR;
|
||||
const isBreak = hours >= MIN_BREAK_HOURS;
|
||||
const h = isBreak ? BREAK_PX : hourPx * hours;
|
||||
segments.push({ from: t, to: u, y, h, kind: isBreak ? "break" : "idle" });
|
||||
y += h;
|
||||
t = u;
|
||||
}
|
||||
|
||||
// Day headers have no duration, they never contain a time
|
||||
const timed = segments.filter((s) => s.kind !== "day");
|
||||
const yOf = (t: number) => {
|
||||
const s = timed.find((s) => t >= s.from && t < s.to) ?? timed[timed.length - 1]!;
|
||||
return s.y + s.h * Math.min(1, (t - s.from) / (s.to - s.from));
|
||||
};
|
||||
|
||||
const days = segments
|
||||
.filter((s) => s.kind === "day")
|
||||
.map((s) => ({ y: s.y, label: s.label! }));
|
||||
|
||||
// No tick right under a day header, it would sit on its line
|
||||
const dayEnds = new Set(days.map((d) => d.y + DAY_PX));
|
||||
const ticks = timed
|
||||
.filter((s) => s.kind !== "break")
|
||||
.flatMap((s) => {
|
||||
const out = [];
|
||||
for (let t = s.from; t < s.to; t += HOUR) out.push({ y: yOf(t), label: fmtTime.format(t) });
|
||||
return out;
|
||||
})
|
||||
.filter((tick) => !dayEnds.has(tick.y));
|
||||
|
||||
const breaks = segments
|
||||
.filter((s) => s.kind === "break")
|
||||
.map((s) => ({ y: s.y, h: s.h, label: `${fmtTime.format(s.from)} – ${fmtTime.format(s.to)}` }));
|
||||
|
||||
let left = AXIS_PX;
|
||||
const columns = columnIds.map((id) => {
|
||||
const placed = list
|
||||
.filter(({ slot }) => slot.column === id)
|
||||
.map<Placed>(({ slot, b, e }) => ({
|
||||
slot,
|
||||
top: yOf(b) + 3,
|
||||
height: yOf(e) - yOf(b) - 6,
|
||||
when: `${fmtTime.format(b)} – ${fmtTime.format(e)}`,
|
||||
}));
|
||||
const width = isWide(id) ? WIDE_RATIO * colPx : colPx;
|
||||
const column = { left, width, placed };
|
||||
left += width + GAP_PX;
|
||||
return column;
|
||||
});
|
||||
|
||||
return {
|
||||
height: y,
|
||||
width: left - GAP_PX,
|
||||
days,
|
||||
ticks,
|
||||
breaks,
|
||||
columns,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 bg-base-300 flex justify-center px-4 py-12">
|
||||
<!-- min-w-0: lets the planning scroll sideways instead of overflowing the page -->
|
||||
<div class="w-full max-w-7xl min-w-0">
|
||||
<div class="text-center mb-12">
|
||||
<h1 class="text-5xl font-bold mb-4">{{ t("planning.title") }}</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-20">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="!layout" class="text-center text-base-content/50 py-20">
|
||||
{{ t("planning.empty") }}
|
||||
</p>
|
||||
|
||||
<!-- pr/pt/pb: room for the Fils rouges glow, the scroll box would clip it -->
|
||||
<div v-else ref="scroller" class="overflow-x-auto pr-10 pt-4 pb-10">
|
||||
<div class="relative" :style="{
|
||||
width: `${layout.width}px`,
|
||||
height: `${layout.height}px`,
|
||||
marginLeft: `max(${DAY_LABEL_ROOM_PX}px, (100% - ${layout.width}px) / 2)`,
|
||||
}">
|
||||
<!-- Time axis: a header row per day, hour ticks on the side -->
|
||||
<!-- Above the cards: slots running past midnight go over the header row -->
|
||||
<div v-for="day in layout.days" :key="`day-${day.y}`"
|
||||
class="absolute left-0 right-0 z-20 pointer-events-none border-b border-base-content/20 flex items-end text-sm font-semibold uppercase tracking-wide"
|
||||
:style="{ top: `${day.y}px`, height: `${DAY_PX}px` }">
|
||||
<!-- Ends at the time axis and grows to the left, never over the cards -->
|
||||
<span class="absolute bottom-0 whitespace-nowrap bg-base-300 rounded-t px-2 py-0.5"
|
||||
:style="{ right: `calc(100% - ${AXIS_PX}px)` }">{{ day.label }}</span>
|
||||
</div>
|
||||
<template v-for="tick in layout.ticks" :key="tick.y">
|
||||
<span class="absolute -translate-y-1/2 text-xs text-base-content/40 text-right pr-2"
|
||||
:style="{ top: `${tick.y}px`, width: `${AXIS_PX}px` }">
|
||||
{{ tick.label }}
|
||||
</span>
|
||||
<div class="absolute right-0 border-t border-dashed border-base-content/5"
|
||||
:style="{ top: `${tick.y}px`, left: `${AXIS_PX}px` }"></div>
|
||||
</template>
|
||||
|
||||
<!-- Nights -->
|
||||
<div v-for="b in layout.breaks" :key="`break-${b.y}`"
|
||||
class="planning-break absolute left-0 right-0 rounded flex items-center justify-center text-xs text-base-content/40"
|
||||
:style="{ top: `${b.y}px`, height: `${b.h}px` }">
|
||||
{{ b.label }}
|
||||
</div>
|
||||
|
||||
<!-- Slots -->
|
||||
<div v-for="column in layout.columns" :key="column.left" class="absolute top-0 h-full"
|
||||
:style="{ left: `${column.left}px`, width: `${column.width}px` }">
|
||||
<template v-for="{ slot, top, height, when } in column.placed" :key="slot.id">
|
||||
<!-- Multi-anims: one long shiny card, its games follow the scroll -->
|
||||
<PlanningMultiSlot v-if="slot.is_multi_anims" :entry="slot" class="absolute inset-x-0"
|
||||
:style="{ top: `${top}px`, height: `${height}px` }" />
|
||||
|
||||
<component :is="slot.games[0]?.store_url ? 'a' : 'div'" v-else
|
||||
:href="slot.games[0]?.store_url || undefined" target="_blank" rel="noopener noreferrer"
|
||||
class="group absolute inset-x-0 block rounded-md overflow-hidden bg-base-200 hover:bg-base-100
|
||||
shadow-xl hover:shadow-2xl transition-all transform hover:-translate-y-2 hover:scale-[1.05] hover:z-10"
|
||||
:style="{ top: `${top}px`, height: `${height}px` }">
|
||||
<PlanningCover :game="slot.games[0] ?? { name: slot.title }" :color="slot.color" :when="when" />
|
||||
<span class="absolute top-1.5 left-1.5 text-xs px-1.5 rounded bg-black/70 text-white opacity-0
|
||||
group-hover:opacity-100 transition-opacity">
|
||||
{{ when }}
|
||||
</span>
|
||||
</component>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.planning-break {
|
||||
background: repeating-linear-gradient(45deg, color-mix(in srgb, currentColor 6%, transparent) 0 6px, transparent 6px 12px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from "vue";
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useTitle } from "@vueuse/core";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { useQueryClient } from "@tanstack/vue-query";
|
||||
import { useUser } from "@/composables/useUser";
|
||||
import { eventGamesQuery } from "@/queries";
|
||||
import { usePageHeaderStore } from "@/stores/pageHeader";
|
||||
import { gamesApiListEventGames, submissionsApiListAvailableScopes, submissionsApiCreateSubmission, submissionsApiListUserSubmissions, submissionsApiCreateImageCrop, submissionsApiDeleteImageCrop, submissionsApiProcessFileOcr, submissionsApiProcessCropOcr } from "@/api";
|
||||
import { submissionsApiListAvailableScopes, submissionsApiCreateSubmission, submissionsApiListUserSubmissions, submissionsApiCreateImageCrop, submissionsApiDeleteImageCrop, submissionsApiProcessFileOcr, submissionsApiProcessCropOcr } from "@/api";
|
||||
import type { SubmissionOut } from "@/api";
|
||||
import { useSubmissionsWebSocket } from "@/composables/useSubmissionsWebSocket";
|
||||
import { provideEventPath } from "@/composables/useEventPath";
|
||||
|
||||
@@ -31,8 +33,8 @@ const gameSteamId = ref("");
|
||||
|
||||
useTitle(computed(() => t("submissions.pageTitle", { game: gameName.value, scope: scopeName.value })));
|
||||
|
||||
const userStore = useUserStore();
|
||||
const { userInfo } = storeToRefs(userStore);
|
||||
const queryClient = useQueryClient();
|
||||
const userInfo = useUser();
|
||||
|
||||
const uploadedFiles = ref<File[]>([]);
|
||||
const isDragover = ref(false);
|
||||
@@ -41,11 +43,12 @@ const notes = ref("");
|
||||
const selectedFile = ref<File | null>(null);
|
||||
const filePreviewUrl = ref<string>("");
|
||||
const isModalOpen = ref(false);
|
||||
const userSubmissions = ref<any[]>([]);
|
||||
const userSubmissions = ref<SubmissionOut[]>([]);
|
||||
const isLoadingSubmissions = ref(false);
|
||||
const selectedSubmissionFile = ref<any>(null);
|
||||
const isSubmissionFileModalOpen = ref(false);
|
||||
const copyFeedback = ref<string>("");
|
||||
// Feedback shown on the copy button that was clicked, keyed by "file" or crop id
|
||||
const copyFeedback = ref<{ key: string; message: string } | null>(null);
|
||||
const isDrawingCrop = ref(false);
|
||||
const cropStartX = ref(0);
|
||||
const cropStartY = ref(0);
|
||||
@@ -139,23 +142,23 @@ const closeSubmissionFileModal = () => {
|
||||
selectedSubmissionFile.value = null;
|
||||
};
|
||||
|
||||
const copyOCRText = async () => {
|
||||
if (selectedSubmissionFile.value?.ocr_raw_data) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(selectedSubmissionFile.value.ocr_raw_data);
|
||||
copyFeedback.value = t("common.copied");
|
||||
setTimeout(() => {
|
||||
copyFeedback.value = "";
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
copyFeedback.value = t("submissions.copyFailed");
|
||||
setTimeout(() => {
|
||||
copyFeedback.value = "";
|
||||
}, 2000);
|
||||
}
|
||||
const copyOCRText = async (text: string | undefined, key: string) => {
|
||||
if (!text) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copyFeedback.value = { key, message: t("common.copied") };
|
||||
} catch (err) {
|
||||
copyFeedback.value = { key, message: t("submissions.copyFailed") };
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (copyFeedback.value?.key === key) copyFeedback.value = null;
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const copyLabel = (key: string) =>
|
||||
copyFeedback.value?.key === key ? copyFeedback.value.message : t("common.copy");
|
||||
|
||||
const getCanvasCoordinates = (canvas: HTMLCanvasElement, clientX: number, clientY: number) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
@@ -244,6 +247,12 @@ const redrawCropCanvas = () => {
|
||||
img.src = selectedSubmissionFile.value?.file_url;
|
||||
};
|
||||
|
||||
// Crops can also arrive over the websocket (e.g. drawn by another admin)
|
||||
watch(
|
||||
() => selectedSubmissionFile.value?.crops?.length,
|
||||
() => redrawCropCanvas(),
|
||||
);
|
||||
|
||||
const submitCrop = async () => {
|
||||
if (!currentCrop.value || !selectedSubmissionFile.value) return;
|
||||
|
||||
@@ -260,10 +269,12 @@ const submitCrop = async () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Add the new crop to the list immediately
|
||||
// Add the new crop to the list immediately, unless the websocket beat us to it
|
||||
if (newCrop) {
|
||||
selectedSubmissionFile.value.crops = selectedSubmissionFile.value.crops || [];
|
||||
selectedSubmissionFile.value.crops.push(newCrop);
|
||||
if (!selectedSubmissionFile.value.crops.some((c: any) => c.id === newCrop.id)) {
|
||||
selectedSubmissionFile.value.crops.push(newCrop);
|
||||
}
|
||||
}
|
||||
|
||||
currentCrop.value = null;
|
||||
@@ -404,6 +415,26 @@ const removeFile = (index: number) => {
|
||||
uploadedFiles.value.splice(index, 1);
|
||||
};
|
||||
|
||||
// Thumbnails of the selected images, revoked once the file is removed/submitted
|
||||
const thumbnailUrls = new Map<File, string>();
|
||||
const thumbnails = computed(() =>
|
||||
uploadedFiles.value.map((file) => {
|
||||
if (!file.type.startsWith("image/")) return null;
|
||||
if (!thumbnailUrls.has(file)) thumbnailUrls.set(file, URL.createObjectURL(file));
|
||||
return thumbnailUrls.get(file)!;
|
||||
}),
|
||||
);
|
||||
|
||||
const revokeThumbnails = (keep: File[] = []) => {
|
||||
thumbnailUrls.forEach((url, file) => {
|
||||
if (keep.includes(file)) return;
|
||||
URL.revokeObjectURL(url);
|
||||
thumbnailUrls.delete(file);
|
||||
});
|
||||
};
|
||||
|
||||
watch(uploadedFiles, (files) => revokeThumbnails(files), { deep: true });
|
||||
|
||||
const submitSubmission = async () => {
|
||||
if (uploadedFiles.value.length === 0) return;
|
||||
|
||||
@@ -474,7 +505,17 @@ const loadUserSubmissions = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const { isConnected: wsConnected } = useSubmissionsWebSocket(userSubmissions);
|
||||
// Match over (or user moved off it): back to the picker, which auto-enters
|
||||
// the next match when there's only one, so players follow along without a refresh.
|
||||
useSubmissionsWebSocket({
|
||||
userSubmissions,
|
||||
scopeId: props.scopeId,
|
||||
onScopeUpdate: (scope) => {
|
||||
if (scope.id !== props.scopeId || (scope.is_active && scope.assigned)) return;
|
||||
|
||||
router.replace({ name: "submissions-select", params: { eventPath: props.eventPath, gamePath: props.gamePath } });
|
||||
},
|
||||
});
|
||||
const isUploadAreaHovered = ref(false);
|
||||
|
||||
const handlePasteOnUploadArea = (event: ClipboardEvent) => {
|
||||
@@ -503,17 +544,16 @@ const handlePasteOnUploadArea = (event: ClipboardEvent) => {
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
userStore.loadUserData();
|
||||
loadUserSubmissions();
|
||||
|
||||
const [gamesResponse, scopesResponse] = await Promise.all([
|
||||
gamesApiListEventGames({ path: { event_path: props.eventPath } }),
|
||||
const [games, scopesResponse] = await Promise.all([
|
||||
queryClient.ensureQueryData(eventGamesQuery(props.eventPath)),
|
||||
submissionsApiListAvailableScopes({
|
||||
path: { event_path: props.eventPath, game_path: props.gamePath },
|
||||
}),
|
||||
]);
|
||||
|
||||
const game = gamesResponse.data?.find((g) => g.path === props.gamePath);
|
||||
const game = games.find((g) => g.path === props.gamePath);
|
||||
if (game) {
|
||||
gameName.value = game.name;
|
||||
gameImage.value = game.image ?? "";
|
||||
@@ -528,6 +568,11 @@ onMounted(async () => {
|
||||
return;
|
||||
}
|
||||
scopeName.value = scope.name;
|
||||
// The picker auto-redirects back here when there's only one scope, so
|
||||
// going "back" to it would loop - head to the event home instead.
|
||||
if (scopesResponse.data?.length === 1) {
|
||||
pageHeaderStore.setBackRoute({ name: "home", params: { eventPath: props.eventPath } });
|
||||
}
|
||||
pageHeaderStore.setTitle(t("submissions.pageTitle", { game: gameName.value, scope: scopeName.value }));
|
||||
|
||||
// Add paste listener (only processes if hovering upload area)
|
||||
@@ -536,6 +581,7 @@ onMounted(async () => {
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('paste', handlePasteOnUploadArea);
|
||||
revokeThumbnails();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -543,14 +589,6 @@ onUnmounted(() => {
|
||||
<div class="flex-1 bg-base-200">
|
||||
<!-- Main Content -->
|
||||
<div class="container min-w-3/4 mx-auto px-4 py-8">
|
||||
<!-- WebSocket Status Indicator -->
|
||||
<div class="flex justify-end mb-4">
|
||||
<div :class="['badge', wsConnected ? 'badge-success' : 'badge-warning']">
|
||||
<i :class="['mdi mr-1', wsConnected ? 'mdi-wifi' : 'mdi-wifi-off']"></i>
|
||||
{{ wsConnected ? t("submissions.live") : t("submissions.offline") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Right Column: Upload -->
|
||||
<div class="lg:col-span-1 flex flex-col gap-8 order-1 lg:order-2">
|
||||
@@ -612,17 +650,24 @@ onUnmounted(() => {
|
||||
<!-- Uploaded Files List -->
|
||||
<div v-if="uploadedFiles.length > 0" class="mb-6">
|
||||
<p class="font-semibold mb-3">{{ t("common.selectedFiles") }}</p>
|
||||
<div class="space-y-2">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div v-for="(file, index) in uploadedFiles" :key="index"
|
||||
class="flex items-center gap-3 bg-base-200 p-3 rounded-lg cursor-pointer hover:bg-base-300 transition-colors group"
|
||||
class="relative bg-base-200 rounded-lg overflow-hidden cursor-pointer hover:bg-base-300 transition-colors group"
|
||||
@click="openFilePreview(file)">
|
||||
<i :class="['mdi text-primary', getFileIcon(file)]"></i>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium truncate group-hover:text-primary">{{ file.name }}</p>
|
||||
<p class="text-xs text-base-content/70">{{ formatFileSize(file.size) }}</p>
|
||||
<div class="aspect-video bg-base-300 flex items-center justify-center">
|
||||
<img v-if="thumbnails[index]" :src="thumbnails[index]" :alt="file.name"
|
||||
class="w-full h-full object-contain" />
|
||||
<i v-else :class="['mdi text-5xl text-primary', getFileIcon(file)]"></i>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-2">
|
||||
<i :class="['mdi text-primary', getFileIcon(file)]"></i>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate group-hover:text-primary" :title="file.name">{{ file.name }}</p>
|
||||
<p class="text-xs text-base-content/70">{{ formatFileSize(file.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button @click.stop="removeFile(index)"
|
||||
class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100">
|
||||
class="btn btn-circle btn-xs absolute top-2 right-2 sm:opacity-0 sm:group-hover:opacity-100">
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -722,7 +767,7 @@ onUnmounted(() => {
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-6">
|
||||
<i class="mdi mdi-history text-primary mr-2"></i>
|
||||
{{ t("submissions.yourSubmissions") }}
|
||||
{{ userInfo.isStaff ? t("submissions.allSubmissions") : t("submissions.yourSubmissions") }}
|
||||
</h2>
|
||||
|
||||
<div v-if="isLoadingSubmissions" class="flex justify-center py-8">
|
||||
@@ -739,6 +784,7 @@ onUnmounted(() => {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("common.date") }}</th>
|
||||
<th v-if="userInfo.isStaff">{{ t("common.user") }}</th>
|
||||
<th>{{ t("common.files") }}</th>
|
||||
<th>{{ t("submissions.fileStatus") }}</th>
|
||||
<th>{{ t("submissions.ocrStatus") }}</th>
|
||||
@@ -748,6 +794,7 @@ onUnmounted(() => {
|
||||
<tbody>
|
||||
<tr v-for="submission in userSubmissions" :key="submission.id" class="hover">
|
||||
<td class="text-sm">{{ formatDate(submission.created_at) }}</td>
|
||||
<td v-if="userInfo.isStaff" class="text-sm">{{ submission.username ?? "-" }}</td>
|
||||
<td class="text-sm">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div v-for="file in submission.files" :key="file.id" class="flex items-center gap-2">
|
||||
@@ -900,9 +947,10 @@ onUnmounted(() => {
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-xs font-semibold text-base-content/70">{{ t("submissions.extractedText") }}</p>
|
||||
<button @click="copyOCRText" class="btn btn-xs btn-ghost gap-1">
|
||||
<button @click="copyOCRText(selectedSubmissionFile.ocr_raw_data, 'file')"
|
||||
class="btn btn-xs btn-ghost gap-1">
|
||||
<i class="mdi mdi-content-copy"></i>
|
||||
{{ copyFeedback || t("common.copy") }}
|
||||
{{ copyLabel("file") }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
@@ -939,7 +987,14 @@ onUnmounted(() => {
|
||||
<div v-for="crop in selectedSubmissionFile.crops" :key="crop.id" class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-medium">{{ crop.label || t("submissions.cropLabelFallback", { x: crop.x1, y: crop.y1 }) }}</p>
|
||||
<span :class="getOCRStatusColor(crop.ocr_status)">{{ crop.ocr_status }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<button v-if="crop.ocr_status === 'processed' && crop.ocr_raw_data"
|
||||
@click="copyOCRText(crop.ocr_raw_data, crop.id)" class="btn btn-xs btn-ghost gap-1">
|
||||
<i class="mdi mdi-content-copy"></i>
|
||||
{{ copyLabel(crop.id) }}
|
||||
</button>
|
||||
<span :class="getOCRStatusColor(crop.ocr_status)">{{ crop.ocr_status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="crop.ocr_status === 'processed' && crop.ocr_raw_data"
|
||||
class="bg-base-100 p-2 rounded text-xs whitespace-pre-wrap break-words font-mono max-h-32 overflow-y-auto">
|
||||
|
||||
@@ -3,7 +3,12 @@ import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useTitle } from "@vueuse/core";
|
||||
import { gamesApiListEventGames, submissionsApiListAvailableScopes } from "@/api";
|
||||
import { submissionsApiListAvailableScopes } from "@/api";
|
||||
import { useQueryClient } from "@tanstack/vue-query";
|
||||
import { eventGamesQuery, userQuery } from "@/queries";
|
||||
import { useUser } from "@/composables/useUser";
|
||||
import { useSubmissionsWebSocket } from "@/composables/useSubmissionsWebSocket";
|
||||
import ScopeSyncModal from "@/components/ScopeSyncModal.vue";
|
||||
import type { ScopeOut } from "@/api/types.gen";
|
||||
|
||||
interface Props {
|
||||
@@ -14,13 +19,18 @@ interface Props {
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useI18n();
|
||||
const userInfo = useUser();
|
||||
useTitle(computed(() => t("submissionsSelect.title")));
|
||||
|
||||
const gameName = ref("");
|
||||
const scopes = ref<ScopeOut[]>([]);
|
||||
const loading = ref(true);
|
||||
const notAssigned = ref(false);
|
||||
const notFound = ref(false);
|
||||
|
||||
const isSyncModalOpen = ref(false);
|
||||
|
||||
const scopeRoute = (scope: ScopeOut) => ({
|
||||
name: "submissions",
|
||||
@@ -31,40 +41,67 @@ const goToScope = (scope: ScopeOut) => {
|
||||
router.push(scopeRoute(scope));
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
const [gamesResponse, scopesResponse] = await Promise.all([
|
||||
gamesApiListEventGames({ path: { event_path: props.eventPath } }),
|
||||
const loadScopes = async () => {
|
||||
const [games, scopesResponse, user] = await Promise.all([
|
||||
queryClient.ensureQueryData(eventGamesQuery(props.eventPath)),
|
||||
submissionsApiListAvailableScopes({
|
||||
path: { event_path: props.eventPath, game_path: props.gamePath },
|
||||
}),
|
||||
queryClient.ensureQueryData(userQuery()),
|
||||
]);
|
||||
|
||||
gameName.value = gamesResponse.data?.find((game) => game.path === props.gamePath)?.name ?? "";
|
||||
// Not a game of this event (or one with its own page, not submissions):
|
||||
// the backend just serves the app for any game path, this is our 404.
|
||||
const game = games.find((game) => game.path === props.gamePath && !game.custom_path);
|
||||
if (!game || scopesResponse.error) {
|
||||
notFound.value = true;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
gameName.value = game.name;
|
||||
|
||||
const availableScopes = scopesResponse.data ?? [];
|
||||
|
||||
// Mirrors the server-side behavior this replaced: skip the picker
|
||||
// straight to the only option, or bounce out if there's nothing to pick.
|
||||
if (availableScopes.length === 1) {
|
||||
// Superusers always land here, it's where the scope sync button lives.
|
||||
if (availableScopes.length === 1 && !user.is_superuser) {
|
||||
router.replace(scopeRoute(availableScopes[0]));
|
||||
return;
|
||||
}
|
||||
if (availableScopes.length === 0) {
|
||||
notAssigned.value = true;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
scopes.value = availableScopes;
|
||||
notAssigned.value = availableScopes.length === 0;
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
// A match opened/closed or players got (re)assigned: reload, so waiting
|
||||
// players get auto-entered into their next match without a refresh.
|
||||
useSubmissionsWebSocket({ onScopeUpdate: () => loadScopes() });
|
||||
|
||||
onMounted(loadScopes);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 bg-base-200 flex items-center justify-center p-4">
|
||||
<div class="flex-1 bg-base-200 flex flex-col items-center justify-center gap-4 p-4">
|
||||
<div v-if="userInfo.isSuperuser && !loading && !notFound" class="w-full max-w-md flex justify-end">
|
||||
<button @click="isSyncModalOpen = true" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-sync"></i>
|
||||
{{ t("submissionsSelect.syncScopes") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-20">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
<div v-else-if="notFound" class="text-center max-w-md">
|
||||
<i class="mdi mdi-alert-circle-outline text-6xl text-primary"></i>
|
||||
<h2 class="text-2xl font-semibold mt-4">{{ t("errorPage.notFound.title") }}</h2>
|
||||
<p class="text-base-content/70 mt-2">{{ t("errorPage.notFound.message") }}</p>
|
||||
<RouterLink :to="{ name: 'home', params: { eventPath } }" class="btn btn-primary mt-6">
|
||||
<i class="mdi mdi-home mr-2"></i>
|
||||
{{ t("errorPage.goHome") }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div v-else-if="notAssigned" class="text-center max-w-md">
|
||||
<i class="mdi mdi-account-alert-outline text-6xl text-warning"></i>
|
||||
<h2 class="text-2xl font-semibold mt-4">{{ t("submissionsSelect.notAssignedTitle") }}</h2>
|
||||
@@ -86,5 +123,8 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScopeSyncModal v-if="userInfo.isSuperuser && !notFound" v-model:open="isSyncModalOpen" :event-path="eventPath"
|
||||
:game-path="gamePath" :game-name="gameName" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
||||
|
||||
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
|
||||
import { client } from './client.gen';
|
||||
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetConfigData, PolylanSubmitterApiGetConfigResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen';
|
||||
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListEventPlanningData, GamesApiListEventPlanningResponses, GamesApiListEventSteamGamesData, GamesApiListEventSteamGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionErrors, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionErrors, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponseFilesData, OpusMagnumApiListResponseFilesErrors, OpusMagnumApiListResponseFilesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationErrors, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoErrors, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseErrors, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionErrors, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PlaceApiFinalPngData, PlaceApiFinalPngResponses, PlaceApiHeatmapDecayGifData, PlaceApiHeatmapDecayGifResponses, PlaceApiHeatmapEvolutionGifData, PlaceApiHeatmapEvolutionGifResponses, PlaceApiHeatmapPngData, PlaceApiHeatmapPngResponses, PlaceApiPlacementsCsvData, PlaceApiPlacementsCsvResponses, PlaceApiStatsData, PlaceApiStatsResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheErrors, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropErrors, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropErrors, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionErrors, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameErrors, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrErrors, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrErrors, SubmissionsApiProcessFileOcrResponses, SubmissionsApiStopSyncScopesData, SubmissionsApiStopSyncScopesErrors, SubmissionsApiStopSyncScopesResponses, SubmissionsApiSyncScopesData, SubmissionsApiSyncScopesErrors, SubmissionsApiSyncScopesResponses, SubmissionsApiSyncScopesStatusData, SubmissionsApiSyncScopesStatusErrors, SubmissionsApiSyncScopesStatusResponses } from './types.gen';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
|
||||
/**
|
||||
@@ -25,17 +25,12 @@ export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends
|
||||
*/
|
||||
export const polylanSubmitterApiHealthCheck = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiHealthCheckData, ThrowOnError>) => (options?.client ?? client).get<PolylanSubmitterApiHealthCheckResponses, unknown, ThrowOnError>({ url: '/api/health', ...options });
|
||||
|
||||
/**
|
||||
* Get Config
|
||||
*/
|
||||
export const polylanSubmitterApiGetConfig = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiGetConfigData, ThrowOnError>) => (options?.client ?? client).get<PolylanSubmitterApiGetConfigResponses, unknown, ThrowOnError>({ url: '/api/config', ...options });
|
||||
|
||||
/**
|
||||
* Clear Cache
|
||||
*
|
||||
* Clear API caches (admin only). Pass event_path to clear just that event's keys.
|
||||
*/
|
||||
export const polylanSubmitterApiClearCache = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiClearCacheData, ThrowOnError>) => (options?.client ?? client).post<PolylanSubmitterApiClearCacheResponses, unknown, ThrowOnError>({ url: '/api/cache/clear', ...options });
|
||||
export const polylanSubmitterApiClearCache = <ThrowOnError extends boolean = false>(options?: Options<PolylanSubmitterApiClearCacheData, ThrowOnError>) => (options?.client ?? client).post<PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiClearCacheErrors, ThrowOnError>({ url: '/api/cache/clear', ...options });
|
||||
|
||||
/**
|
||||
* Get User Info
|
||||
@@ -75,7 +70,7 @@ export const opusMagnumApiListSubmissions = <ThrowOnError extends boolean = fals
|
||||
*
|
||||
* Create a new submission with multiple puzzle responses
|
||||
*/
|
||||
export const opusMagnumApiCreateSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiCreateSubmissionData, ThrowOnError>) => (options.client ?? client).post<OpusMagnumApiCreateSubmissionResponses, unknown, ThrowOnError>({
|
||||
export const opusMagnumApiCreateSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiCreateSubmissionData, ThrowOnError>) => (options.client ?? client).post<OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiCreateSubmissionErrors, ThrowOnError>({
|
||||
...formDataBodySerializer,
|
||||
url: '/api/events/{event_path}/opus-magnum/submissions',
|
||||
...options,
|
||||
@@ -90,7 +85,7 @@ export const opusMagnumApiCreateSubmission = <ThrowOnError extends boolean = fal
|
||||
*
|
||||
* Delete a submission (admin only)
|
||||
*/
|
||||
export const opusMagnumApiDeleteSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiDeleteSubmissionData, ThrowOnError>) => (options.client ?? client).delete<OpusMagnumApiDeleteSubmissionResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/submissions/{submission_id}', ...options });
|
||||
export const opusMagnumApiDeleteSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiDeleteSubmissionData, ThrowOnError>) => (options.client ?? client).delete<OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiDeleteSubmissionErrors, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/submissions/{submission_id}', ...options });
|
||||
|
||||
/**
|
||||
* Get Submission
|
||||
@@ -104,7 +99,7 @@ export const opusMagnumApiGetSubmission = <ThrowOnError extends boolean = false>
|
||||
*
|
||||
* Manually validate a puzzle response
|
||||
*/
|
||||
export const opusMagnumApiValidateResponse = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateResponseData, ThrowOnError>) => (options.client ?? client).put<OpusMagnumApiValidateResponseResponses, unknown, ThrowOnError>({
|
||||
export const opusMagnumApiValidateResponse = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateResponseData, ThrowOnError>) => (options.client ?? client).put<OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateResponseErrors, ThrowOnError>({
|
||||
url: '/api/events/{event_path}/opus-magnum/responses/{response_id}/validate',
|
||||
...options,
|
||||
headers: {
|
||||
@@ -118,21 +113,28 @@ export const opusMagnumApiValidateResponse = <ThrowOnError extends boolean = fal
|
||||
*
|
||||
* Try to auto validate a puzzle response
|
||||
*/
|
||||
export const opusMagnumApiValidateAuto = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateAutoData, ThrowOnError>) => (options.client ?? client).put<OpusMagnumApiValidateAutoResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/responses/{response_id}/validate/auto', ...options });
|
||||
export const opusMagnumApiValidateAuto = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateAutoData, ThrowOnError>) => (options.client ?? client).put<OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateAutoErrors, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/responses/{response_id}/validate/auto', ...options });
|
||||
|
||||
/**
|
||||
* List Responses Needing Validation
|
||||
*
|
||||
* Get all responses that need manual validation
|
||||
*/
|
||||
export const opusMagnumApiListResponsesNeedingValidation = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiListResponsesNeedingValidationData, ThrowOnError>) => (options.client ?? client).get<OpusMagnumApiListResponsesNeedingValidationResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/responses/needs-validation', ...options });
|
||||
export const opusMagnumApiListResponsesNeedingValidation = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiListResponsesNeedingValidationData, ThrowOnError>) => (options.client ?? client).get<OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListResponsesNeedingValidationErrors, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/responses/needs-validation', ...options });
|
||||
|
||||
/**
|
||||
* List Response Files
|
||||
*
|
||||
* Get the solution files of any player's response (admin only)
|
||||
*/
|
||||
export const opusMagnumApiListResponseFiles = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiListResponseFilesData, ThrowOnError>) => (options.client ?? client).get<OpusMagnumApiListResponseFilesResponses, OpusMagnumApiListResponseFilesErrors, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/responses/{response_id}/files', ...options });
|
||||
|
||||
/**
|
||||
* Validate Submission
|
||||
*
|
||||
* Mark entire submission as validated
|
||||
*/
|
||||
export const opusMagnumApiValidateSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateSubmissionData, ThrowOnError>) => (options.client ?? client).post<OpusMagnumApiValidateSubmissionResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/submissions/{submission_id}/validate', ...options });
|
||||
export const opusMagnumApiValidateSubmission = <ThrowOnError extends boolean = false>(options: Options<OpusMagnumApiValidateSubmissionData, ThrowOnError>) => (options.client ?? client).post<OpusMagnumApiValidateSubmissionResponses, OpusMagnumApiValidateSubmissionErrors, ThrowOnError>({ url: '/api/events/{event_path}/opus-magnum/submissions/{submission_id}/validate', ...options });
|
||||
|
||||
/**
|
||||
* Get Stats
|
||||
@@ -201,6 +203,20 @@ export const noitaApiSubmitLogFile = <ThrowOnError extends boolean = false>(opti
|
||||
*/
|
||||
export const gamesApiListEventGames = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventGamesData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventGamesResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/games', ...options });
|
||||
|
||||
/**
|
||||
* List Event Steam Games
|
||||
*
|
||||
* The event's Steam games with their store info (price, discount, blurb).
|
||||
*/
|
||||
export const gamesApiListEventSteamGames = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventSteamGamesData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventSteamGamesResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/games/steam', ...options });
|
||||
|
||||
/**
|
||||
* List Event Planning
|
||||
*
|
||||
* The event planning, with the covers of the games we know.
|
||||
*/
|
||||
export const gamesApiListEventPlanning = <ThrowOnError extends boolean = false>(options: Options<GamesApiListEventPlanningData, ThrowOnError>) => (options.client ?? client).get<GamesApiListEventPlanningResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/planning', ...options });
|
||||
|
||||
/**
|
||||
* List My Scopes
|
||||
*/
|
||||
@@ -217,6 +233,38 @@ export const submissionsApiListMyScopes = <ThrowOnError extends boolean = false>
|
||||
*/
|
||||
export const submissionsApiListAvailableScopes = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListAvailableScopesData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListAvailableScopesResponses, SubmissionsApiListAvailableScopesErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/{game_path}/available-scopes', ...options });
|
||||
|
||||
/**
|
||||
* Stop Sync Scopes
|
||||
*
|
||||
* Stop the AAndT scope sync loop of a (non custom path) game, superusers
|
||||
* only. Cancels its scheduled job, scopes are left as they are.
|
||||
*/
|
||||
export const submissionsApiStopSyncScopes = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiStopSyncScopesData, ThrowOnError>) => (options.client ?? client).delete<SubmissionsApiStopSyncScopesResponses, SubmissionsApiStopSyncScopesErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/{game_path}/sync-scopes', ...options });
|
||||
|
||||
/**
|
||||
* Sync Scopes Status
|
||||
*
|
||||
* State of the AAndT scope sync loop of a (non custom path) game,
|
||||
* superusers only.
|
||||
*/
|
||||
export const submissionsApiSyncScopesStatus = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiSyncScopesStatusData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiSyncScopesStatusResponses, SubmissionsApiSyncScopesStatusErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/{game_path}/sync-scopes', ...options });
|
||||
|
||||
/**
|
||||
* Sync Scopes
|
||||
*
|
||||
* Start the AAndT scope sync loop for a (non custom path) game, superusers
|
||||
* only. Stores `apibot_token` first when given; without one and none stored
|
||||
* yet, answers 400 with `token_required` so the frontend asks for it.
|
||||
*/
|
||||
export const submissionsApiSyncScopes = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiSyncScopesData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiSyncScopesResponses, SubmissionsApiSyncScopesErrors, ThrowOnError>({
|
||||
url: '/api/events/{event_path}/submissions/{game_path}/sync-scopes',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Create Submission
|
||||
*/
|
||||
@@ -238,17 +286,17 @@ export const submissionsApiListUserSubmissions = <ThrowOnError extends boolean =
|
||||
/**
|
||||
* List Submissions By Game
|
||||
*/
|
||||
export const submissionsApiListSubmissionsByGame = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListSubmissionsByGameData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListSubmissionsByGameResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/submissions/submission/by/game', ...options });
|
||||
export const submissionsApiListSubmissionsByGame = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiListSubmissionsByGameData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListSubmissionsByGameErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/submission/by/game', ...options });
|
||||
|
||||
/**
|
||||
* Get Submission
|
||||
*/
|
||||
export const submissionsApiGetSubmission = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiGetSubmissionData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiGetSubmissionResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/submissions/submission/{submission_id}', ...options });
|
||||
export const submissionsApiGetSubmission = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiGetSubmissionData, ThrowOnError>) => (options.client ?? client).get<SubmissionsApiGetSubmissionResponses, SubmissionsApiGetSubmissionErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/submission/{submission_id}', ...options });
|
||||
|
||||
/**
|
||||
* Create Image Crop
|
||||
*/
|
||||
export const submissionsApiCreateImageCrop = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiCreateImageCropData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiCreateImageCropResponses, unknown, ThrowOnError>({
|
||||
export const submissionsApiCreateImageCrop = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiCreateImageCropData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateImageCropErrors, ThrowOnError>({
|
||||
url: '/api/events/{event_path}/submissions/crops/s/{submission_file_id}',
|
||||
...options,
|
||||
headers: {
|
||||
@@ -260,17 +308,17 @@ export const submissionsApiCreateImageCrop = <ThrowOnError extends boolean = fal
|
||||
/**
|
||||
* Delete Image Crop
|
||||
*/
|
||||
export const submissionsApiDeleteImageCrop = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiDeleteImageCropData, ThrowOnError>) => (options.client ?? client).delete<SubmissionsApiDeleteImageCropResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/submissions/crops/{crop_id}', ...options });
|
||||
export const submissionsApiDeleteImageCrop = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiDeleteImageCropData, ThrowOnError>) => (options.client ?? client).delete<SubmissionsApiDeleteImageCropResponses, SubmissionsApiDeleteImageCropErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/crops/{crop_id}', ...options });
|
||||
|
||||
/**
|
||||
* Process File Ocr
|
||||
*/
|
||||
export const submissionsApiProcessFileOcr = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiProcessFileOcrData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiProcessFileOcrResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/submissions/files/{file_id}/process-ocr', ...options });
|
||||
export const submissionsApiProcessFileOcr = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiProcessFileOcrData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiProcessFileOcrResponses, SubmissionsApiProcessFileOcrErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/files/{file_id}/process-ocr', ...options });
|
||||
|
||||
/**
|
||||
* Process Crop Ocr
|
||||
*/
|
||||
export const submissionsApiProcessCropOcr = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiProcessCropOcrData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiProcessCropOcrResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/submissions/crops/{crop_id}/process-ocr', ...options });
|
||||
export const submissionsApiProcessCropOcr = <ThrowOnError extends boolean = false>(options: Options<SubmissionsApiProcessCropOcrData, ThrowOnError>) => (options.client ?? client).post<SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessCropOcrErrors, ThrowOnError>({ url: '/api/events/{event_path}/submissions/crops/{crop_id}/process-ocr', ...options });
|
||||
|
||||
/**
|
||||
* Current State
|
||||
@@ -286,7 +334,8 @@ export const clockApiMyStats = <ThrowOnError extends boolean = false>(options: O
|
||||
* Canvas
|
||||
*
|
||||
* The one-time full-grid load. Every placement after that arrives as a
|
||||
* single websocket message, never a re-fetch.
|
||||
* single websocket message, never a re-fetch. Once placement is over the
|
||||
* board is shown wiped (Redis keeps the real final board, see tasks.py).
|
||||
*/
|
||||
export const placeApiCanvas = <ThrowOnError extends boolean = false>(options: Options<PlaceApiCanvasData, ThrowOnError>) => (options.client ?? client).get<PlaceApiCanvasResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/place/canvas', ...options });
|
||||
|
||||
@@ -294,6 +343,9 @@ export const placeApiCanvas = <ThrowOnError extends boolean = false>(options: Op
|
||||
* Evolution Gif
|
||||
*
|
||||
* Admin-only: replays the full placement history as an animated GIF.
|
||||
*
|
||||
* `players` is an optional comma-separated list of usernames to only
|
||||
* replay those players' placements (combined).
|
||||
*/
|
||||
export const placeApiEvolutionGif = <ThrowOnError extends boolean = false>(options: Options<PlaceApiEvolutionGifData, ThrowOnError>) => (options.client ?? client).get<PlaceApiEvolutionGifResponses, unknown, ThrowOnError>({
|
||||
security: [{
|
||||
@@ -305,6 +357,111 @@ export const placeApiEvolutionGif = <ThrowOnError extends boolean = false>(optio
|
||||
...options
|
||||
});
|
||||
|
||||
/**
|
||||
* Final Png
|
||||
*
|
||||
* Admin-only: the real final board as a PNG, even once placement is
|
||||
* over and everyone else sees it wiped.
|
||||
*/
|
||||
export const placeApiFinalPng = <ThrowOnError extends boolean = false>(options: Options<PlaceApiFinalPngData, ThrowOnError>) => (options.client ?? client).get<PlaceApiFinalPngResponses, unknown, ThrowOnError>({
|
||||
security: [{
|
||||
in: 'cookie',
|
||||
name: 'sessionid',
|
||||
type: 'apiKey'
|
||||
}],
|
||||
url: '/api/events/{event_path}/place/final.png',
|
||||
...options
|
||||
});
|
||||
|
||||
/**
|
||||
* Heatmap Png
|
||||
*
|
||||
* Admin-only: how many times each pixel was placed, as a heatmap PNG.
|
||||
*
|
||||
* `players` is an optional comma-separated list of usernames to only
|
||||
* count those players' placements (combined).
|
||||
*/
|
||||
export const placeApiHeatmapPng = <ThrowOnError extends boolean = false>(options: Options<PlaceApiHeatmapPngData, ThrowOnError>) => (options.client ?? client).get<PlaceApiHeatmapPngResponses, unknown, ThrowOnError>({
|
||||
security: [{
|
||||
in: 'cookie',
|
||||
name: 'sessionid',
|
||||
type: 'apiKey'
|
||||
}],
|
||||
url: '/api/events/{event_path}/place/heatmap.png',
|
||||
...options
|
||||
});
|
||||
|
||||
/**
|
||||
* Heatmap Evolution Gif
|
||||
*
|
||||
* Admin-only: the heatmap building up over time, as an animated GIF.
|
||||
*
|
||||
* `players` is an optional comma-separated list of usernames to only
|
||||
* count those players' placements (combined).
|
||||
*/
|
||||
export const placeApiHeatmapEvolutionGif = <ThrowOnError extends boolean = false>(options: Options<PlaceApiHeatmapEvolutionGifData, ThrowOnError>) => (options.client ?? client).get<PlaceApiHeatmapEvolutionGifResponses, unknown, ThrowOnError>({
|
||||
security: [{
|
||||
in: 'cookie',
|
||||
name: 'sessionid',
|
||||
type: 'apiKey'
|
||||
}],
|
||||
url: '/api/events/{event_path}/place/heatmap-evolution.gif',
|
||||
...options
|
||||
});
|
||||
|
||||
/**
|
||||
* Heatmap Decay Gif
|
||||
*
|
||||
* Admin-only: the heatmap over time, but heat decays so pixels nobody
|
||||
* touches anymore fade back to white.
|
||||
*
|
||||
* `players` is an optional comma-separated list of usernames to only
|
||||
* count those players' placements (combined). `mode` is "placements"
|
||||
* (decay per placement, `half_life` in placements, defaults to 10 frames'
|
||||
* worth) or "time" (decay over time, `half_life` in minutes, defaults
|
||||
* to 60).
|
||||
*/
|
||||
export const placeApiHeatmapDecayGif = <ThrowOnError extends boolean = false>(options: Options<PlaceApiHeatmapDecayGifData, ThrowOnError>) => (options.client ?? client).get<PlaceApiHeatmapDecayGifResponses, unknown, ThrowOnError>({
|
||||
security: [{
|
||||
in: 'cookie',
|
||||
name: 'sessionid',
|
||||
type: 'apiKey'
|
||||
}],
|
||||
url: '/api/events/{event_path}/place/heatmap-decay.gif',
|
||||
...options
|
||||
});
|
||||
|
||||
/**
|
||||
* Stats
|
||||
*
|
||||
* Admin-only: aggregated stats about the game - totals, coverage, top
|
||||
* players, most contested pixels, color usage and activity per hour.
|
||||
*/
|
||||
export const placeApiStats = <ThrowOnError extends boolean = false>(options: Options<PlaceApiStatsData, ThrowOnError>) => (options.client ?? client).get<PlaceApiStatsResponses, unknown, ThrowOnError>({
|
||||
security: [{
|
||||
in: 'cookie',
|
||||
name: 'sessionid',
|
||||
type: 'apiKey'
|
||||
}],
|
||||
url: '/api/events/{event_path}/place/stats',
|
||||
...options
|
||||
});
|
||||
|
||||
/**
|
||||
* Placements Csv
|
||||
*
|
||||
* Admin-only: pixel placement count per player, most active first.
|
||||
*/
|
||||
export const placeApiPlacementsCsv = <ThrowOnError extends boolean = false>(options: Options<PlaceApiPlacementsCsvData, ThrowOnError>) => (options.client ?? client).get<PlaceApiPlacementsCsvResponses, unknown, ThrowOnError>({
|
||||
security: [{
|
||||
in: 'cookie',
|
||||
name: 'sessionid',
|
||||
type: 'apiKey'
|
||||
}],
|
||||
url: '/api/events/{event_path}/place/placements.csv',
|
||||
...options
|
||||
});
|
||||
|
||||
/**
|
||||
* List Markets
|
||||
*
|
||||
@@ -319,6 +476,20 @@ export const marketApiListMarkets = <ThrowOnError extends boolean = false>(optio
|
||||
*/
|
||||
export const marketApiListUserBets = <ThrowOnError extends boolean = false>(options: Options<MarketApiListUserBetsData, ThrowOnError>) => (options.client ?? client).get<MarketApiListUserBetsResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/market/user/bets', ...options });
|
||||
|
||||
/**
|
||||
* Get User Points
|
||||
*
|
||||
* Get the current user's points balance for this event.
|
||||
*/
|
||||
export const marketApiGetUserPoints = <ThrowOnError extends boolean = false>(options: Options<MarketApiGetUserPointsData, ThrowOnError>) => (options.client ?? client).get<MarketApiGetUserPointsResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/market/user/points', ...options });
|
||||
|
||||
/**
|
||||
* Bet History
|
||||
*
|
||||
* Points added to each option over time (anonymous, one entry per bet placed/increased).
|
||||
*/
|
||||
export const marketApiBetHistory = <ThrowOnError extends boolean = false>(options: Options<MarketApiBetHistoryData, ThrowOnError>) => (options.client ?? client).get<MarketApiBetHistoryResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/market/{market_uuid}/history', ...options });
|
||||
|
||||
/**
|
||||
* Close Market
|
||||
*
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user