Compare commits
57
Commits
d104480188
..
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
|
||
|
|
e38b610456
|
||
|
|
9fc9e19d68
|
||
|
|
6866b45f66
|
@@ -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
|
tags
|
||||||
media/
|
media/
|
||||||
settingsLocal.py
|
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 import admin
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from django_object_actions import DjangoObjectActions, action
|
||||||
|
|
||||||
from .models import CustomUser
|
from .models import CustomUser
|
||||||
|
|
||||||
|
# Must match loadtest/locustfile.py's own defaults (LOAD_TEST_USER_COUNT,
|
||||||
|
# LOAD_TEST_USER_PREFIX, LOAD_TEST_PASSWORD) - see that file for what these
|
||||||
|
# accounts are for and how they're used.
|
||||||
|
LOAD_TEST_USER_COUNT = 500
|
||||||
|
LOAD_TEST_USER_PREFIX = "loadtest"
|
||||||
|
LOAD_TEST_PASSWORD = "loadtest-only-password"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CustomUser)
|
@admin.register(CustomUser)
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(DjangoObjectActions, UserAdmin):
|
||||||
"""Admin interface for CustomUser."""
|
"""Admin interface for CustomUser."""
|
||||||
|
|
||||||
# Add custom fields to the user admin
|
# Add custom fields to the user admin
|
||||||
@@ -29,6 +39,8 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
# Make CAS fields readonly in admin
|
# Make CAS fields readonly in admin
|
||||||
readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes")
|
readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes")
|
||||||
|
|
||||||
|
changelist_actions = ("create_load_test_users",)
|
||||||
|
|
||||||
def get_cas_groups_display(self, obj):
|
def get_cas_groups_display(self, obj):
|
||||||
"""Display CAS groups in admin list."""
|
"""Display CAS groups in admin list."""
|
||||||
return obj.get_cas_groups_display()
|
return obj.get_cas_groups_display()
|
||||||
@@ -37,3 +49,34 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
|
|
||||||
def has_delete_permission(self, request, obj=None):
|
def has_delete_permission(self, request, obj=None):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def get_changelist_actions(self, request):
|
||||||
|
# Second layer of the same guard rail as the login endpoint itself
|
||||||
|
# (see testing_auth_views.py): the button doesn't even show up
|
||||||
|
# unless this deployment opted into load testing.
|
||||||
|
if not settings.LOAD_TEST_LOGIN_ENABLED:
|
||||||
|
return []
|
||||||
|
return super().get_changelist_actions(request)
|
||||||
|
|
||||||
|
@action(
|
||||||
|
label="Create load test users",
|
||||||
|
description=(
|
||||||
|
"Create/reset the local loadtestN accounts used by "
|
||||||
|
"loadtest/locustfile.py - authenticates via ModelBackend, "
|
||||||
|
"never the real CAS server"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def create_load_test_users(self, request, _queryset):
|
||||||
|
created = 0
|
||||||
|
for i in range(LOAD_TEST_USER_COUNT):
|
||||||
|
username = f"{LOAD_TEST_USER_PREFIX}{i}"
|
||||||
|
user, was_created = CustomUser.objects.get_or_create(username=username)
|
||||||
|
user.set_password(LOAD_TEST_PASSWORD)
|
||||||
|
user.save()
|
||||||
|
created += was_created
|
||||||
|
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"{created} created, {LOAD_TEST_USER_COUNT - created} already existed "
|
||||||
|
f"(password reset on all {LOAD_TEST_USER_COUNT}).",
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,3 +4,23 @@ from django.apps import AppConfig
|
|||||||
class AccountsConfig(AppConfig):
|
class AccountsConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "accounts"
|
name = "accounts"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
# django-admin-interface's Theme.logo/favicon are plain FileFields,
|
||||||
|
# so without this they'd resolve through STORAGES["default"] (S3 -
|
||||||
|
# see settings.py) like every submission/media upload. The theme's
|
||||||
|
# logo/favicon (see accounts/fixtures/admin_interface_theme.json)
|
||||||
|
# are actually a static asset (static/favicon.ico, built from
|
||||||
|
# public/favicon.ico by Vite - see vite.config.ts), so point those
|
||||||
|
# two fields at STATIC_ROOT/STATIC_URL instead, served by WhiteNoise
|
||||||
|
# like any other static file.
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.files.storage import FileSystemStorage
|
||||||
|
|
||||||
|
from admin_interface.models import Theme
|
||||||
|
|
||||||
|
static_storage = FileSystemStorage(
|
||||||
|
location=settings.STATIC_ROOT, base_url=settings.STATIC_URL
|
||||||
|
)
|
||||||
|
Theme._meta.get_field("logo").storage = static_storage
|
||||||
|
Theme._meta.get_field("favicon").storage = static_storage
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "admin_interface.theme",
|
||||||
|
"pk": 2,
|
||||||
|
"fields": {
|
||||||
|
"name": "Django Purple (by Legrems)",
|
||||||
|
"active": true,
|
||||||
|
"title": "Super administrateur de poney ",
|
||||||
|
"title_color": "#E5B8FF",
|
||||||
|
"title_visible": true,
|
||||||
|
"logo": "favicon.ico",
|
||||||
|
"logo_color": "#831a96",
|
||||||
|
"logo_max_width": 400,
|
||||||
|
"logo_max_height": 50,
|
||||||
|
"logo_visible": true,
|
||||||
|
"favicon": "favicon.ico",
|
||||||
|
"env_name": "",
|
||||||
|
"env_color": "#831a96",
|
||||||
|
"env_visible_in_header": true,
|
||||||
|
"env_visible_in_favicon": true,
|
||||||
|
"language_chooser_active": true,
|
||||||
|
"language_chooser_control": "default-select",
|
||||||
|
"language_chooser_display": "code",
|
||||||
|
"css_header_background_color": "#2d0840",
|
||||||
|
"css_header_text_color": "#831a96",
|
||||||
|
"css_header_link_color": "#FFFFFF",
|
||||||
|
"css_header_link_hover_color": "#a82bb8",
|
||||||
|
"css_module_background_color": "#6b0f7d",
|
||||||
|
"css_module_background_selected_color": "#831a96",
|
||||||
|
"css_module_text_color": "#FFFFFF",
|
||||||
|
"css_module_link_color": "#FFFFFF",
|
||||||
|
"css_module_link_selected_color": "#FFFFFF",
|
||||||
|
"css_module_link_hover_color": "#a82bb8",
|
||||||
|
"css_module_rounded_corners": true,
|
||||||
|
"css_generic_link_color": "#2d0840",
|
||||||
|
"css_generic_link_hover_color": "#5a0d6b",
|
||||||
|
"css_save_button_background_color": "#2d0840",
|
||||||
|
"css_save_button_background_hover_color": "#5a0d6b",
|
||||||
|
"css_save_button_text_color": "#FFFFFF",
|
||||||
|
"css_delete_button_background_color": "#a82bb8",
|
||||||
|
"css_delete_button_background_hover_color": "#831a96",
|
||||||
|
"css_delete_button_text_color": "#FFFFFF",
|
||||||
|
"related_modal_active": true,
|
||||||
|
"related_modal_background_color": "#000000",
|
||||||
|
"related_modal_background_opacity": "0.3",
|
||||||
|
"related_modal_rounded_corners": true,
|
||||||
|
"related_modal_close_button_visible": true,
|
||||||
|
"list_filter_highlight": true,
|
||||||
|
"list_filter_dropdown": true,
|
||||||
|
"list_filter_sticky": true,
|
||||||
|
"list_filter_removal_links": false,
|
||||||
|
"foldable_apps": true,
|
||||||
|
"show_fieldsets_as_tabs": false,
|
||||||
|
"show_inlines_as_tabs": false,
|
||||||
|
"recent_actions_visible": true,
|
||||||
|
"form_submit_sticky": false,
|
||||||
|
"form_pagination_sticky": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from django.core.management import call_command
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def load_admin_theme(apps, schema_editor):
|
||||||
|
# Seeds the default django-admin-interface theme (see
|
||||||
|
# accounts/fixtures/admin_interface_theme.json). Runs once, the first
|
||||||
|
# time this migration is applied, so it won't clobber a theme later
|
||||||
|
# edited by hand through the admin.
|
||||||
|
call_command("loaddata", "admin_interface_theme.json")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0002_customuser_points"),
|
||||||
|
("admin_interface", "0032_alter_theme_defaults"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(load_admin_theme, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import ClockTimer, Press
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ClockTimer)
|
||||||
|
class ClockTimerAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("event", "end_time", "initial_duration")
|
||||||
|
list_editable = ("initial_duration",)
|
||||||
|
list_filter = ("event",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Press)
|
||||||
|
class PressAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"event",
|
||||||
|
"player",
|
||||||
|
"pressed_at",
|
||||||
|
"remaining_before_press",
|
||||||
|
"seconds_added",
|
||||||
|
)
|
||||||
|
list_filter = ("event", "player")
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.utils import timezone
|
||||||
|
from ninja import Router
|
||||||
|
from ninja.decorators import decorate_view
|
||||||
|
|
||||||
|
from games.decorators import require_game_enabled
|
||||||
|
|
||||||
|
from .models import PRESS_BONUS_SECONDS, ClockTimer, Press
|
||||||
|
from .schemas import ClockStateOut, PlayerStatsOut
|
||||||
|
from .scoring import ranked_players
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
PREFIX = "/events/{event_path}/clock"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{PREFIX}/current", response=ClockStateOut)
|
||||||
|
@decorate_view(require_game_enabled(path="clock"))
|
||||||
|
def current_state(request: HttpRequest, event_path: str):
|
||||||
|
timer = ClockTimer.current(request.event)
|
||||||
|
timer.reset_if_expired()
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
next_press_at = None
|
||||||
|
cooldown_seconds = None
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
last_press = (
|
||||||
|
Press.objects.filter(player=request.user, event=request.event)
|
||||||
|
.order_by("-pressed_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if last_press is not None:
|
||||||
|
candidate = last_press.pressed_at + timedelta(
|
||||||
|
seconds=last_press.cooldown_seconds
|
||||||
|
)
|
||||||
|
if candidate > now:
|
||||||
|
next_press_at = candidate
|
||||||
|
cooldown_seconds = last_press.cooldown_seconds
|
||||||
|
|
||||||
|
return {
|
||||||
|
"server_time": now,
|
||||||
|
"end_time": timer.end_time,
|
||||||
|
"initial_duration": timer.initial_duration,
|
||||||
|
"press_bonus_seconds": PRESS_BONUS_SECONDS,
|
||||||
|
"next_press_at": next_press_at,
|
||||||
|
"cooldown_seconds": cooldown_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{PREFIX}/me", response=PlayerStatsOut)
|
||||||
|
@decorate_view(require_game_enabled(path="clock"))
|
||||||
|
def my_stats(request: HttpRequest, event_path: str):
|
||||||
|
ranked = list(
|
||||||
|
ranked_players(request.event)
|
||||||
|
.values("id", "clock_points", "rank")
|
||||||
|
.order_by("-clock_points", "id")
|
||||||
|
)
|
||||||
|
total_players = len(ranked)
|
||||||
|
my_index = next(
|
||||||
|
(i for i, row in enumerate(ranked) if row["id"] == request.user.id), None
|
||||||
|
)
|
||||||
|
|
||||||
|
mine = ranked[my_index] if my_index is not None else None
|
||||||
|
above = ranked[my_index - 1] if my_index is not None and my_index > 0 else None
|
||||||
|
below = (
|
||||||
|
ranked[my_index + 1]
|
||||||
|
if my_index is not None and my_index + 1 < total_players
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"points": mine["clock_points"] if mine else 0.0,
|
||||||
|
"rank": mine["rank"] if mine else total_players,
|
||||||
|
"total_players": total_players,
|
||||||
|
"points_above": above["clock_points"] if above else None,
|
||||||
|
"points_below": below["clock_points"] if below else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ClockConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "clock"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
from . import signals # noqa: F401
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
def clock_group(event_path: str) -> str:
|
||||||
|
"""Channel group for one event's clock room - every event ticks independently."""
|
||||||
|
return f"clock_room_{event_path}"
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from channels.db import database_sync_to_async
|
||||||
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
from .broadcast import clock_group
|
||||||
|
from .models import PRESS_BONUS_SECONDS, ClockTimer, Press, random_cooldown_seconds
|
||||||
|
from .wire import encode, to_epoch_ms
|
||||||
|
|
||||||
|
|
||||||
|
class ClockConsumer(AsyncWebsocketConsumer):
|
||||||
|
async def connect(self):
|
||||||
|
self.event_path = self.scope["url_route"]["kwargs"]["event_path"]
|
||||||
|
self.event = await self._get_enabled_event(
|
||||||
|
self.event_path, self.scope.get("user")
|
||||||
|
)
|
||||||
|
if self.event is None:
|
||||||
|
await self.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.group_name = clock_group(self.event_path)
|
||||||
|
await self.channel_layer.group_add(self.group_name, self.channel_name)
|
||||||
|
await self.accept()
|
||||||
|
await self.send(text_data=await self._initial_state(self.scope.get("user")))
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
if getattr(self, "group_name", None):
|
||||||
|
await self.channel_layer.group_discard(self.group_name, self.channel_name)
|
||||||
|
|
||||||
|
async def receive(self, text_data=None, bytes_data=None):
|
||||||
|
if text_data == "press":
|
||||||
|
await self._handle_press()
|
||||||
|
|
||||||
|
async def _handle_press(self):
|
||||||
|
user = self.scope.get("user")
|
||||||
|
if user is None or not user.is_authenticated:
|
||||||
|
await self.send(text_data=encode("r", "a"))
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await self._record_press(user)
|
||||||
|
|
||||||
|
if result["rejected"]:
|
||||||
|
await self.send(text_data=encode("r", "c", result["next"], result["now"]))
|
||||||
|
return
|
||||||
|
|
||||||
|
# The cooldown outcome is only useful to the player who just pressed -
|
||||||
|
# broadcasting it to every connection would be pure waste.
|
||||||
|
await self.send(text_data=encode("k", result["next"], result["now"]))
|
||||||
|
|
||||||
|
# Everyone else only needs to know the shared timer moved.
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
self.group_name,
|
||||||
|
{
|
||||||
|
"type": "tick",
|
||||||
|
"end": result["end"],
|
||||||
|
"now": result["now"],
|
||||||
|
"pressed": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _get_enabled_event(self, event_path, user):
|
||||||
|
event = Event.objects.available_to(user).filter(path=event_path).first()
|
||||||
|
if event is None:
|
||||||
|
return None
|
||||||
|
if not Game.objects.enabled_for_event(event).filter(path="clock").exists():
|
||||||
|
return None
|
||||||
|
return event
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _record_press(self, user):
|
||||||
|
with transaction.atomic():
|
||||||
|
timer = ClockTimer.objects.select_for_update().get(event=self.event)
|
||||||
|
now = timezone.now()
|
||||||
|
timer.reset_if_expired(now)
|
||||||
|
|
||||||
|
last_press = (
|
||||||
|
Press.objects.filter(player=user, event=self.event)
|
||||||
|
.order_by("-pressed_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if last_press is not None:
|
||||||
|
next_allowed = last_press.pressed_at + timedelta(
|
||||||
|
seconds=last_press.cooldown_seconds
|
||||||
|
)
|
||||||
|
if next_allowed > now:
|
||||||
|
return {
|
||||||
|
"rejected": True,
|
||||||
|
"next": to_epoch_ms(next_allowed),
|
||||||
|
"now": to_epoch_ms(now),
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining = (timer.end_time - now).total_seconds()
|
||||||
|
cooldown = random_cooldown_seconds()
|
||||||
|
press = Press.objects.create(
|
||||||
|
event=self.event,
|
||||||
|
player=user,
|
||||||
|
remaining_before_press=remaining,
|
||||||
|
seconds_added=PRESS_BONUS_SECONDS,
|
||||||
|
cooldown_seconds=cooldown,
|
||||||
|
)
|
||||||
|
timer.end_time = timer.end_time + timedelta(seconds=PRESS_BONUS_SECONDS)
|
||||||
|
timer.save(update_fields=["end_time"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rejected": False,
|
||||||
|
"end": to_epoch_ms(timer.end_time),
|
||||||
|
"now": to_epoch_ms(now),
|
||||||
|
"next": to_epoch_ms(press.pressed_at + timedelta(seconds=cooldown)),
|
||||||
|
}
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _initial_state(self, user):
|
||||||
|
timer = ClockTimer.current(self.event)
|
||||||
|
timer.reset_if_expired()
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
next_press_at = None
|
||||||
|
if user is not None and user.is_authenticated:
|
||||||
|
last_press = (
|
||||||
|
Press.objects.filter(player=user, event=self.event)
|
||||||
|
.order_by("-pressed_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if last_press is not None:
|
||||||
|
candidate = last_press.pressed_at + timedelta(
|
||||||
|
seconds=last_press.cooldown_seconds
|
||||||
|
)
|
||||||
|
if candidate > now:
|
||||||
|
next_press_at = to_epoch_ms(candidate)
|
||||||
|
|
||||||
|
return encode(
|
||||||
|
"s",
|
||||||
|
to_epoch_ms(timer.end_time),
|
||||||
|
to_epoch_ms(now),
|
||||||
|
timer.initial_duration,
|
||||||
|
PRESS_BONUS_SECONDS,
|
||||||
|
next_press_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def tick(self, event):
|
||||||
|
await self.send(
|
||||||
|
text_data=encode(
|
||||||
|
"t",
|
||||||
|
event["end"],
|
||||||
|
event["now"],
|
||||||
|
"1" if event.get("pressed") else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-09-16 23:11
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("events", "0002_seed_polylan_43"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ClockTimer",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("initial_duration", models.PositiveIntegerField(default=60)),
|
||||||
|
("end_time", models.DateTimeField()),
|
||||||
|
(
|
||||||
|
"event",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="clock_timer",
|
||||||
|
to="events.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Press",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("pressed_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("remaining_before_press", models.FloatField()),
|
||||||
|
("seconds_added", models.PositiveIntegerField(default=5)),
|
||||||
|
("cooldown_seconds", models.FloatField()),
|
||||||
|
(
|
||||||
|
"event",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="clock_presses",
|
||||||
|
to="events.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"player",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="clock_presses",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-pressed_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import random
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
from events.scoping import EventScopedManager
|
||||||
|
|
||||||
|
DEFAULT_TIMER_DURATION = 60
|
||||||
|
PRESS_BONUS_SECONDS = 5
|
||||||
|
PRESS_COOLDOWN_MIN_SECONDS = PRESS_BONUS_SECONDS
|
||||||
|
PRESS_COOLDOWN_MAX_SECONDS = PRESS_BONUS_SECONDS * 2
|
||||||
|
|
||||||
|
|
||||||
|
def random_cooldown_seconds() -> float:
|
||||||
|
return random.uniform(PRESS_COOLDOWN_MIN_SECONDS, PRESS_COOLDOWN_MAX_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
class ClockTimer(models.Model):
|
||||||
|
"""The shared countdown for one event. One row per event, reset in place.
|
||||||
|
|
||||||
|
Unlike the enable/disable toggle for every other game (EventGame.enabled,
|
||||||
|
checked before a client can even reach this app), there's no separate
|
||||||
|
is_enabled flag here - the registry is the single source of truth.
|
||||||
|
"""
|
||||||
|
|
||||||
|
event = models.OneToOneField(
|
||||||
|
Event, on_delete=models.CASCADE, related_name="clock_timer"
|
||||||
|
)
|
||||||
|
initial_duration = models.PositiveIntegerField(default=DEFAULT_TIMER_DURATION)
|
||||||
|
end_time = models.DateTimeField()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"ClockTimer({self.event}, end_time={self.end_time.isoformat()})"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def current(cls, event: Event) -> "ClockTimer":
|
||||||
|
"""Fetch the event's singleton timer, creating it on first-ever run."""
|
||||||
|
obj, _ = cls.objects.get_or_create(
|
||||||
|
event=event,
|
||||||
|
defaults={
|
||||||
|
"end_time": timezone.now() + timedelta(seconds=DEFAULT_TIMER_DURATION)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def reset_if_expired(self, now=None) -> bool:
|
||||||
|
"""Resets end_time back to a fresh window if it has passed. Returns True if reset."""
|
||||||
|
now = now or timezone.now()
|
||||||
|
if self.end_time > now:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.end_time = now + timedelta(seconds=self.initial_duration)
|
||||||
|
self.save(update_fields=["end_time"])
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class Press(models.Model):
|
||||||
|
event = models.ForeignKey(
|
||||||
|
Event, on_delete=models.CASCADE, related_name="clock_presses"
|
||||||
|
)
|
||||||
|
player = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="clock_presses"
|
||||||
|
)
|
||||||
|
pressed_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
remaining_before_press = models.FloatField()
|
||||||
|
seconds_added = models.PositiveIntegerField(default=PRESS_BONUS_SECONDS)
|
||||||
|
# Randomized 1x-2x PRESS_BONUS_SECONDS at press time and stored, since it's no
|
||||||
|
# longer a fixed constant that can be recomputed later.
|
||||||
|
cooldown_seconds = models.FloatField()
|
||||||
|
|
||||||
|
objects = models.Manager()
|
||||||
|
for_event = EventScopedManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-pressed_at"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.player} pressed at {self.remaining_before_press:.1f}s left ({self.event})"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import re_path
|
||||||
|
|
||||||
|
from .consumers import ClockConsumer
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
re_path(r"^ws/clock/(?P<event_path>[\w-]+)/$", ClockConsumer.as_asgi()),
|
||||||
|
]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from ninja import Schema
|
||||||
|
|
||||||
|
|
||||||
|
class ClockStateOut(Schema):
|
||||||
|
server_time: datetime
|
||||||
|
end_time: datetime
|
||||||
|
initial_duration: int
|
||||||
|
press_bonus_seconds: int
|
||||||
|
next_press_at: datetime | None
|
||||||
|
cooldown_seconds: float | None
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerStatsOut(Schema):
|
||||||
|
points: float
|
||||||
|
rank: int
|
||||||
|
total_players: int
|
||||||
|
points_above: float | None
|
||||||
|
points_below: float | None
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from django.db.models import ExpressionWrapper, F, FloatField, Q, Sum, Value, Window
|
||||||
|
from django.db.models.functions import Coalesce, Power, Rank
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
|
||||||
|
# points = 1000 * (1 / (remaining_seconds + 0.5)) ** 2, computed per press and
|
||||||
|
# summed per player. Never stored - always derived live from Press rows.
|
||||||
|
POINTS_BASE = 1000.0
|
||||||
|
POINTS_OFFSET = 0.5
|
||||||
|
POINTS_EXPONENT = -2.0
|
||||||
|
|
||||||
|
|
||||||
|
def _press_points_expression():
|
||||||
|
base = ExpressionWrapper(
|
||||||
|
F("clock_presses__remaining_before_press") + Value(POINTS_OFFSET),
|
||||||
|
output_field=FloatField(),
|
||||||
|
)
|
||||||
|
return ExpressionWrapper(
|
||||||
|
Value(POINTS_BASE) * Power(base, Value(POINTS_EXPONENT)),
|
||||||
|
output_field=FloatField(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ranked_players(event: Event):
|
||||||
|
"""All users annotated with their live total `clock_points` (not `points`,
|
||||||
|
that one is the market balance field on CustomUser) for this event and
|
||||||
|
DB-computed RANK(). Points only ever come from presses made in `event` -
|
||||||
|
a player's history in another event never leaks into this leaderboard."""
|
||||||
|
from accounts.models import CustomUser
|
||||||
|
|
||||||
|
points = Coalesce(
|
||||||
|
Sum(_press_points_expression(), filter=Q(clock_presses__event=event)),
|
||||||
|
Value(0.0),
|
||||||
|
output_field=FloatField(),
|
||||||
|
)
|
||||||
|
return CustomUser.objects.annotate(clock_points=points).annotate(
|
||||||
|
rank=Window(expression=Rank(), order_by=F("clock_points").desc())
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from games.models import EventGame
|
||||||
|
|
||||||
|
from .tasks import start_clock_heartbeat
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=EventGame)
|
||||||
|
def on_event_game_saved(sender, instance, **kwargs):
|
||||||
|
if instance.game.path == "clock" and instance.enabled:
|
||||||
|
start_clock_heartbeat()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Self-rescheduling heartbeat, replacing pplace's standalone `watch_timer`
|
||||||
|
management command with the project's existing self-rescheduling django-tasks
|
||||||
|
pattern (see submissions.tasks.sync_event_game_scopes_task).
|
||||||
|
|
||||||
|
Resets any event's timer once it expires - even if nobody presses or
|
||||||
|
reconnects to notice - and gives already-connected clients a periodic tick.
|
||||||
|
Note this is a safety net, not the only way the timer gets reset: every press
|
||||||
|
and every new connection (see consumers.py) also calls reset_if_expired(), so
|
||||||
|
the game works correctly even if this chain is never started or dies -
|
||||||
|
connected-but-idle clients just won't see the countdown restart until someone
|
||||||
|
acts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.db import transaction
|
||||||
|
from django.tasks import task
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from games.models import EventGame
|
||||||
|
|
||||||
|
from .broadcast import clock_group
|
||||||
|
from .models import ClockTimer
|
||||||
|
from .wire import to_epoch_ms
|
||||||
|
|
||||||
|
HEARTBEAT_INTERVAL_SECONDS = 1
|
||||||
|
# One chain ticks every event with the game enabled - this lock keeps a
|
||||||
|
# second chain from starting underneath an already-running one (e.g. two
|
||||||
|
# EventGame saves close together). Refreshed every tick, wide enough that a
|
||||||
|
# single slow tick doesn't let a second chain slip in. The lock holds the
|
||||||
|
# chain id: if it expires anyway (worker down/restarted for a few seconds) and
|
||||||
|
# a new chain starts, the old one stops at its next tick (instead of both
|
||||||
|
# ticking forever).
|
||||||
|
HEARTBEAT_LOCK_KEY = "clock:heartbeat:lock"
|
||||||
|
HEARTBEAT_LOCK_TIMEOUT_SECONDS = HEARTBEAT_INTERVAL_SECONDS * 5
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def clock_heartbeat_task(chain_id: str):
|
||||||
|
if cache.get(HEARTBEAT_LOCK_KEY) != chain_id:
|
||||||
|
# Another chain owns the lock (or it expired), stop this one
|
||||||
|
return
|
||||||
|
|
||||||
|
event_games = list(
|
||||||
|
EventGame.objects.filter(game__path="clock", enabled=True).select_related(
|
||||||
|
"event"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not event_games:
|
||||||
|
# Nothing left to tick - let the chain die. start_clock_heartbeat()
|
||||||
|
# starts a fresh one next time an event enables the game.
|
||||||
|
if cache.get(HEARTBEAT_LOCK_KEY) == chain_id:
|
||||||
|
cache.delete(HEARTBEAT_LOCK_KEY)
|
||||||
|
return
|
||||||
|
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
for event_game in event_games:
|
||||||
|
event = event_game.event
|
||||||
|
ClockTimer.current(event)
|
||||||
|
with transaction.atomic():
|
||||||
|
timer = ClockTimer.objects.select_for_update().get(event=event)
|
||||||
|
timer.reset_if_expired()
|
||||||
|
|
||||||
|
async_to_sync(channel_layer.group_send)(
|
||||||
|
clock_group(event.path),
|
||||||
|
{
|
||||||
|
"type": "tick",
|
||||||
|
"end": to_epoch_ms(timer.end_time),
|
||||||
|
"now": to_epoch_ms(timezone.now()),
|
||||||
|
"pressed": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
cache.set(HEARTBEAT_LOCK_KEY, chain_id, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS)
|
||||||
|
clock_heartbeat_task.using(
|
||||||
|
run_after=timezone.now() + timedelta(seconds=HEARTBEAT_INTERVAL_SECONDS)
|
||||||
|
).enqueue(chain_id)
|
||||||
|
|
||||||
|
|
||||||
|
def start_clock_heartbeat():
|
||||||
|
"""Enqueues the heartbeat chain, unless one is already running."""
|
||||||
|
chain_id = uuid4().hex
|
||||||
|
if cache.add(HEARTBEAT_LOCK_KEY, chain_id, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS):
|
||||||
|
clock_heartbeat_task.enqueue(chain_id)
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Compact wire format for the clock websocket.
|
||||||
|
|
||||||
|
JSON's braces/quotes/keys are pure overhead for messages this small and this
|
||||||
|
frequent (a heartbeat every second, to every connection). Each message is
|
||||||
|
instead a short string: a one-letter type tag followed by '|'-separated
|
||||||
|
fields, in a fixed order the client already knows from this table (missing/
|
||||||
|
null fields are written as an empty segment):
|
||||||
|
|
||||||
|
s|<end>|<now>|<duration>|<bonus>|<next> initial state, once per connection
|
||||||
|
t|<end>|<now>|<pressed> shared timer update ("pressed" is "1" or "")
|
||||||
|
k|<next>|<now> your press was accepted
|
||||||
|
r|c|<next>|<now> your press was rejected: on cooldown
|
||||||
|
r|a your press was rejected: not logged in
|
||||||
|
|
||||||
|
Client -> server has exactly one message, sent as the bare literal "press"
|
||||||
|
(no fields, so no delimiter needed).
|
||||||
|
|
||||||
|
Whether the game is enabled at all for this event (EventGame.enabled) is
|
||||||
|
checked once, at connect time - a connection is refused outright rather than
|
||||||
|
accepted-but-disabled, so there's no "disabled" case in this wire format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DELIMITER = "|"
|
||||||
|
|
||||||
|
|
||||||
|
def encode(*parts) -> str:
|
||||||
|
return DELIMITER.join("" if p is None else str(p) for p in parts)
|
||||||
|
|
||||||
|
|
||||||
|
def to_epoch_ms(dt) -> int:
|
||||||
|
"""Epoch-ms integer: far cheaper than an ISO-8601 string and needs no
|
||||||
|
client-side parsing (the frontend already does its clock math in epoch ms)."""
|
||||||
|
return int(dt.timestamp() * 1000)
|
||||||
@@ -5,7 +5,9 @@ from .models import Event
|
|||||||
|
|
||||||
@admin.register(Event)
|
@admin.register(Event)
|
||||||
class EventAdmin(admin.ModelAdmin):
|
class EventAdmin(admin.ModelAdmin):
|
||||||
list_display = ["name", "path", "created_at"]
|
list_display = ["name", "path", "is_available", "created_at"]
|
||||||
|
list_editable = ["is_available"]
|
||||||
|
list_filter = ["is_available"]
|
||||||
search_fields = ["name", "path"]
|
search_fields = ["name", "path"]
|
||||||
prepopulated_fields = {"path": ("name",)}
|
prepopulated_fields = {"path": ("name",)}
|
||||||
readonly_fields = ["created_at", "updated_at"]
|
readonly_fields = ["created_at", "updated_at"]
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ class EventContextMiddleware:
|
|||||||
def process_view(self, request, view_func, view_args, view_kwargs):
|
def process_view(self, request, view_func, view_args, view_kwargs):
|
||||||
event_path = view_kwargs.get("event_path")
|
event_path = view_kwargs.get("event_path")
|
||||||
if event_path:
|
if event_path:
|
||||||
request.event = get_object_or_404(Event, path=event_path)
|
request.event = get_object_or_404(
|
||||||
|
Event.objects.available_to(getattr(request, "user", None)),
|
||||||
|
path=event_path,
|
||||||
|
)
|
||||||
current_event.set(request.event)
|
current_event.set(request.event)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-10-15 22:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("events", "0002_seed_polylan_43"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="event",
|
||||||
|
name="is_available",
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -14,9 +14,21 @@ class BaseModel(models.Model):
|
|||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class EventQuerySet(models.QuerySet):
|
||||||
|
def available_to(self, user):
|
||||||
|
"""Unavailable events are hidden from everyone but staff, so they can
|
||||||
|
still be prepared before being opened up."""
|
||||||
|
if user is not None and user.is_staff:
|
||||||
|
return self
|
||||||
|
return self.filter(is_available=True)
|
||||||
|
|
||||||
|
|
||||||
class Event(BaseModel):
|
class Event(BaseModel):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
path = models.SlugField(max_length=100, unique=True)
|
path = models.SlugField(max_length=100, unique=True)
|
||||||
|
is_available = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
objects = EventQuerySet.as_manager()
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.name
|
return self.name
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
@@ -32,3 +34,41 @@ class EventScopedManagerTests(TestCase):
|
|||||||
current_event.reset(token)
|
current_event.reset(token)
|
||||||
|
|
||||||
self.assertEqual(results, [self.submission_a])
|
self.assertEqual(results, [self.submission_a])
|
||||||
|
|
||||||
|
|
||||||
|
class EventAvailabilityTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from accounts.models import CustomUser
|
||||||
|
|
||||||
|
self.hidden = Event.objects.create(
|
||||||
|
name="Hidden", path="hidden", is_available=False
|
||||||
|
)
|
||||||
|
self.user = CustomUser.objects.create_user(username="user", password="x")
|
||||||
|
self.staff = CustomUser.objects.create_user(
|
||||||
|
username="staff", password="x", is_staff=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unavailable_event_is_404_for_regular_user(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(f"/{self.hidden.path}/")
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_unavailable_event_is_visible_to_staff(self):
|
||||||
|
self.client.force_login(self.staff)
|
||||||
|
response = self.client.get(f"/{self.hidden.path}/")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_available_to_filters_for_regular_user_only(self):
|
||||||
|
self.assertNotIn(self.hidden, Event.objects.available_to(self.user))
|
||||||
|
self.assertIn(self.hidden, Event.objects.available_to(self.staff))
|
||||||
|
|
||||||
|
def test_home_prefers_available_event_for_staff(self):
|
||||||
|
available = Event.objects.create(name="Open", path="open")
|
||||||
|
Event.objects.filter(pk=self.hidden.pk).update(
|
||||||
|
created_at=available.created_at + timedelta(days=1)
|
||||||
|
)
|
||||||
|
self.client.force_login(self.staff)
|
||||||
|
response = self.client.get("/")
|
||||||
|
self.assertRedirects(
|
||||||
|
response, f"/{available.path}/", fetch_redirect_response=False
|
||||||
|
)
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ from django_object_actions import (
|
|||||||
takes_instance_or_queryset,
|
takes_instance_or_queryset,
|
||||||
)
|
)
|
||||||
|
|
||||||
from submissions.tasks import sync_event_game_scopes_task
|
from submissions.tasks import schedule_scope_sync, stop_scope_sync
|
||||||
|
|
||||||
from .models import EventGame, Game
|
from .models import EventGame, Game, PlanningSlot
|
||||||
|
from .tasks import refresh_steam_games_task
|
||||||
|
|
||||||
|
|
||||||
class EventGameInline(admin.TabularInline):
|
class EventGameInline(admin.TabularInline):
|
||||||
@@ -19,8 +20,30 @@ class EventGameInline(admin.TabularInline):
|
|||||||
class GameAdmin(admin.ModelAdmin):
|
class GameAdmin(admin.ModelAdmin):
|
||||||
list_display = ["name", "steam_app_id", "updated_at"]
|
list_display = ["name", "steam_app_id", "updated_at"]
|
||||||
search_fields = ["name", "steam_app_id"]
|
search_fields = ["name", "steam_app_id"]
|
||||||
readonly_fields = ["created_at", "updated_at"]
|
readonly_fields = [
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"steam_price_initial",
|
||||||
|
"steam_price_final",
|
||||||
|
"steam_discount_percent",
|
||||||
|
"steam_currency",
|
||||||
|
"steam_is_free",
|
||||||
|
"steam_short_description",
|
||||||
|
"steam_header_image",
|
||||||
|
"steam_info_updated_at",
|
||||||
|
]
|
||||||
inlines = [EventGameInline]
|
inlines = [EventGameInline]
|
||||||
|
actions = ["refresh_steam_info"]
|
||||||
|
|
||||||
|
@admin.action(description="Refresh Steam store info")
|
||||||
|
def refresh_steam_info(self, request, queryset):
|
||||||
|
game_ids = list(
|
||||||
|
queryset.filter(steam_app_id__isnull=False).values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
refresh_steam_games_task.enqueue(game_ids=game_ids)
|
||||||
|
self.message_user(
|
||||||
|
request, f"Queued Steam store refresh for {len(game_ids)} game(s)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(EventGame)
|
@admin.register(EventGame)
|
||||||
@@ -28,13 +51,35 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
|
|||||||
list_display = ["game", "event", "enabled", "has_apibot_token"]
|
list_display = ["game", "event", "enabled", "has_apibot_token"]
|
||||||
list_filter = ["event", "enabled"]
|
list_filter = ["event", "enabled"]
|
||||||
search_fields = ["game__name", "event__name"]
|
search_fields = ["game__name", "event__name"]
|
||||||
actions = ["sync_scopes"]
|
actions = ["enable", "disable", "sync_scopes", "stop_scope_sync"]
|
||||||
change_actions = ["sync_scopes"]
|
change_actions = ["sync_scopes", "stop_scope_sync"]
|
||||||
|
|
||||||
@admin.display(description="AAndT token", boolean=True)
|
@admin.display(description="AAndT token", boolean=True)
|
||||||
def has_apibot_token(self, obj):
|
def has_apibot_token(self, obj):
|
||||||
return bool(obj.apibot_token)
|
return bool(obj.apibot_token)
|
||||||
|
|
||||||
|
def _set_enabled(self, request, queryset, enabled: bool):
|
||||||
|
# save() per row (not update()) so post_save fires, e.g. the clock heartbeat
|
||||||
|
changed = 0
|
||||||
|
for event_game in queryset.select_related("game").filter(enabled=not enabled):
|
||||||
|
event_game.enabled = enabled
|
||||||
|
event_game.save(update_fields=["enabled"])
|
||||||
|
changed += 1
|
||||||
|
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"{'Enabled' if enabled else 'Disabled'} {changed} EventGame(s)"
|
||||||
|
f" ({queryset.count() - changed} already {'enabled' if enabled else 'disabled'}).",
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.action(description="Enable selected EventGames")
|
||||||
|
def enable(self, request, queryset):
|
||||||
|
self._set_enabled(request, queryset, enabled=True)
|
||||||
|
|
||||||
|
@admin.action(description="Disable selected EventGames")
|
||||||
|
def disable(self, request, queryset):
|
||||||
|
self._set_enabled(request, queryset, enabled=False)
|
||||||
|
|
||||||
@action(
|
@action(
|
||||||
label="Sync scopes",
|
label="Sync scopes",
|
||||||
description="Sync scopes from AAndT for selected EventGames",
|
description="Sync scopes from AAndT for selected EventGames",
|
||||||
@@ -44,11 +89,12 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
|
|||||||
tokened = queryset.exclude(apibot_token__isnull=True).exclude(apibot_token="")
|
tokened = queryset.exclude(apibot_token__isnull=True).exclude(apibot_token="")
|
||||||
skipped = queryset.count() - tokened.count()
|
skipped = queryset.count() - tokened.count()
|
||||||
|
|
||||||
for event_game in tokened:
|
started = sum(schedule_scope_sync(event_game.id) for event_game in tokened)
|
||||||
sync_event_game_scopes_task.enqueue(event_game.id)
|
|
||||||
|
|
||||||
self.message_user(
|
self.message_user(
|
||||||
request, f"Queued scope sync for {tokened.count()} EventGame(s)."
|
request,
|
||||||
|
f"Started scope sync for {started} EventGame(s)"
|
||||||
|
f" ({tokened.count() - started} already running).",
|
||||||
)
|
)
|
||||||
if skipped:
|
if skipped:
|
||||||
self.message_user(
|
self.message_user(
|
||||||
@@ -56,3 +102,20 @@ class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
|
|||||||
f"Skipped {skipped} EventGame(s) with no AAndT token configured.",
|
f"Skipped {skipped} EventGame(s) with no AAndT token configured.",
|
||||||
level=messages.WARNING,
|
level=messages.WARNING,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@action(
|
||||||
|
label="Stop scope sync",
|
||||||
|
description="Stop the scope sync loop of selected EventGames",
|
||||||
|
)
|
||||||
|
@takes_instance_or_queryset
|
||||||
|
def stop_scope_sync(self, request, queryset):
|
||||||
|
stopped = sum(stop_scope_sync(event_game.id) for event_game in queryset)
|
||||||
|
|
||||||
|
self.message_user(request, f"Stopped scope sync for {stopped} EventGame(s).")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PlanningSlot)
|
||||||
|
class PlanningSlotAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["title", "event", "begin", "end", "column", "is_multi_anims"]
|
||||||
|
list_filter = ["event", "is_multi_anims"]
|
||||||
|
search_fields = ["title"]
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ from typing import List
|
|||||||
from ninja import Router
|
from ninja import Router
|
||||||
|
|
||||||
from .models import Game
|
from .models import Game
|
||||||
from .schemas import GameOut
|
from .schemas import GameOut, PlanningSlotOut, SteamGameOut
|
||||||
|
from .services.planning import planning_with_games
|
||||||
|
from .tasks import schedule_steam_refresh
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
event_router = Router()
|
event_router = Router()
|
||||||
@@ -17,3 +19,19 @@ def list_games(request):
|
|||||||
@event_router.get("/events/{event_path}/games", response=List[GameOut])
|
@event_router.get("/events/{event_path}/games", response=List[GameOut])
|
||||||
def list_event_games(request, event_path: str):
|
def list_event_games(request, event_path: str):
|
||||||
return Game.objects.enabled_for_event(request.event)
|
return Game.objects.enabled_for_event(request.event)
|
||||||
|
|
||||||
|
|
||||||
|
@event_router.get("/events/{event_path}/games/steam", response=List[SteamGameOut])
|
||||||
|
def list_event_steam_games(request, event_path: str):
|
||||||
|
"""The event's Steam games with their store info (price, discount, blurb)."""
|
||||||
|
# Safety net in case the periodic refresh loop isn't running (no-op otherwise).
|
||||||
|
schedule_steam_refresh()
|
||||||
|
return Game.objects.enabled_for_event(request.event).filter(
|
||||||
|
steam_app_id__isnull=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@event_router.get("/events/{event_path}/planning", response=List[PlanningSlotOut])
|
||||||
|
def list_event_planning(request, event_path: str):
|
||||||
|
"""The event planning, with the covers of the games we know."""
|
||||||
|
return planning_with_games(request.event.planning_slots.all())
|
||||||
|
|||||||
@@ -7,17 +7,25 @@ from events.models import Event
|
|||||||
from .models import Game
|
from .models import Game
|
||||||
|
|
||||||
|
|
||||||
def require_game_enabled(steam_app_id: int):
|
def require_game_enabled(steam_app_id: int | None = None, *, path: str | None = None):
|
||||||
|
"""Gate a view (or, via @decorate_view, an API operation) behind an
|
||||||
|
EventGame being enabled for the current event. Steam games are looked up
|
||||||
|
by steam_app_id (the original, positional form); custom non-Steam games
|
||||||
|
(no steam_app_id) are looked up by their Game.path instead, e.g.
|
||||||
|
require_game_enabled(path="clock")."""
|
||||||
|
|
||||||
def decorator(view_func):
|
def decorator(view_func):
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
def wrapper(request, *args, **kwargs):
|
def wrapper(request, *args, **kwargs):
|
||||||
event = get_object_or_404(Event, path=kwargs.get("event_path"))
|
event = get_object_or_404(Event, path=kwargs.get("event_path"))
|
||||||
|
games = Game.objects.enabled_for_event(event)
|
||||||
|
games = (
|
||||||
|
games.filter(steam_app_id=steam_app_id)
|
||||||
|
if steam_app_id is not None
|
||||||
|
else games.filter(path=path)
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
if not games.exists():
|
||||||
not Game.objects.enabled_for_event(event)
|
|
||||||
.filter(steam_app_id=steam_app_id)
|
|
||||||
.exists()
|
|
||||||
):
|
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
|
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
|
|||||||
@@ -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):
|
class GameQuerySet(models.QuerySet):
|
||||||
def enabled_for_event(self, event):
|
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):
|
class Game(models.Model):
|
||||||
@@ -14,6 +14,20 @@ class Game(models.Model):
|
|||||||
path = models.CharField(max_length=100)
|
path = models.CharField(max_length=100)
|
||||||
custom_path = models.BooleanField(default=False)
|
custom_path = models.BooleanField(default=False)
|
||||||
image = models.URLField(null=True, blank=True)
|
image = models.URLField(null=True, blank=True)
|
||||||
|
description = models.TextField(blank=True, default="")
|
||||||
|
# Vertical (600x900) cover for games/showcase, empty = Steam's library capsule
|
||||||
|
library_image = models.URLField(max_length=500, blank=True, default="")
|
||||||
|
|
||||||
|
# Steam store info, refreshed in the background (see games.tasks).
|
||||||
|
# Prices are in cents, in steam_currency.
|
||||||
|
steam_price_initial = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
steam_price_final = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
steam_discount_percent = models.PositiveSmallIntegerField(default=0)
|
||||||
|
steam_currency = models.CharField(max_length=3, blank=True, default="")
|
||||||
|
steam_is_free = models.BooleanField(default=False)
|
||||||
|
steam_short_description = models.TextField(blank=True, default="")
|
||||||
|
steam_header_image = models.URLField(max_length=500, blank=True, default="")
|
||||||
|
steam_info_updated_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
@@ -28,6 +42,19 @@ class Game(models.Model):
|
|||||||
return f"{self.name} ({self.steam_app_id})"
|
return f"{self.name} ({self.steam_app_id})"
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def store_url(self) -> str:
|
||||||
|
if not self.steam_app_id:
|
||||||
|
return ""
|
||||||
|
return f"https://store.steampowered.com/app/{self.steam_app_id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def library_image_url(self) -> str:
|
||||||
|
"""Vertical cover, like in the Steam library. Empty if there's none."""
|
||||||
|
if self.library_image or not self.steam_app_id:
|
||||||
|
return self.library_image
|
||||||
|
return f"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/{self.steam_app_id}/library_600x900.jpg"
|
||||||
|
|
||||||
|
|
||||||
class EventGame(models.Model):
|
class EventGame(models.Model):
|
||||||
event = models.ForeignKey(
|
event = models.ForeignKey(
|
||||||
@@ -62,3 +89,37 @@ class EventGame(models.Model):
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.game.name} @ {self.event.name} ({'enabled' if self.enabled else 'disabled'})"
|
return f"{self.game.name} @ {self.event.name} ({'enabled' if self.enabled else 'disabled'})"
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningSlot(models.Model):
|
||||||
|
"""One slot of the event planning, imported from the legacy PolyLAN planning
|
||||||
|
(see the import_planning command)."""
|
||||||
|
|
||||||
|
event = models.ForeignKey(
|
||||||
|
Event,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="planning_slots",
|
||||||
|
)
|
||||||
|
# pk of the slot in the legacy planning, to update it on re-import
|
||||||
|
source_pk = models.PositiveIntegerField()
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
begin = models.DateTimeField()
|
||||||
|
end = models.DateTimeField()
|
||||||
|
column = models.PositiveSmallIntegerField(default=0)
|
||||||
|
color = models.CharField(max_length=7, blank=True, default="")
|
||||||
|
# Multi-anims (e.g. "Fils rouges") run several games over the whole event
|
||||||
|
is_multi_anims = models.BooleanField(default=False)
|
||||||
|
# Names of the games run in this slot (the legacy "aandts")
|
||||||
|
game_names = models.JSONField(default=list, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["begin", "column"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["event", "source_pk"],
|
||||||
|
name="unique_event_planning_slot",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.title} @ {self.event.name} ({self.begin:%a %H:%M})"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from ninja import Schema
|
from ninja import Schema
|
||||||
@@ -10,3 +11,88 @@ class GameOut(Schema):
|
|||||||
path: str
|
path: str
|
||||||
custom_path: bool
|
custom_path: bool
|
||||||
image: Optional[str] = None
|
image: Optional[str] = None
|
||||||
|
description: str
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_description(obj) -> str:
|
||||||
|
# The admin one wins, Steam games fall back to their store blurb
|
||||||
|
return obj.description or obj.steam_short_description
|
||||||
|
|
||||||
|
|
||||||
|
class SteamGameOut(Schema):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
steam_app_id: int
|
||||||
|
store_url: str
|
||||||
|
header_image: str
|
||||||
|
library_image: str
|
||||||
|
short_description: str
|
||||||
|
is_free: bool
|
||||||
|
currency: str
|
||||||
|
price_initial: Optional[int] = None
|
||||||
|
price_final: Optional[int] = None
|
||||||
|
discount_percent: int
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_store_url(obj) -> str:
|
||||||
|
return obj.store_url
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_header_image(obj) -> str:
|
||||||
|
return (
|
||||||
|
obj.steam_header_image
|
||||||
|
or f"https://cdn.akamai.steamstatic.com/steam/apps/{obj.steam_app_id}/header.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_library_image(obj) -> str:
|
||||||
|
return obj.library_image_url
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_short_description(obj) -> str:
|
||||||
|
return obj.steam_short_description
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_is_free(obj) -> bool:
|
||||||
|
return obj.steam_is_free
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_currency(obj) -> str:
|
||||||
|
return obj.steam_currency
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_price_initial(obj) -> Optional[int]:
|
||||||
|
return obj.steam_price_initial
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_price_final(obj) -> Optional[int]:
|
||||||
|
return obj.steam_price_final
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_discount_percent(obj) -> int:
|
||||||
|
return obj.steam_discount_percent
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_updated_at(obj) -> Optional[datetime]:
|
||||||
|
return obj.steam_info_updated_at
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningGameOut(Schema):
|
||||||
|
name: str
|
||||||
|
# Empty when the name didn't match any of our games
|
||||||
|
library_image: str = ""
|
||||||
|
store_url: str = ""
|
||||||
|
steam_app_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningSlotOut(Schema):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
begin: datetime
|
||||||
|
end: datetime
|
||||||
|
column: int
|
||||||
|
color: str
|
||||||
|
is_multi_anims: bool
|
||||||
|
# Single slot: the slot's game. Multi-anims: each of their games.
|
||||||
|
games: list[PlanningGameOut]
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
from ..models import EventGame, Game, PlanningSlot
|
||||||
|
from .steam_store import search_store
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# "Cosmoteer: Starship Architect & Commander" is still "Cosmoteer"
|
||||||
|
SUBTITLE_SEPARATOR = re.compile(r"\s*(?::|\s-\s|\s–\s|\s—\s)")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_name(name: str) -> str:
|
||||||
|
"""'Towerfall: Ascension' and 'TowerFall Ascension' are the same game:
|
||||||
|
no accents, no case, letters and digits only."""
|
||||||
|
# Before NFKD, which turns ™ into "TM"
|
||||||
|
name = re.sub(r"[™®©]", "", name)
|
||||||
|
name = unicodedata.normalize("NFKD", name)
|
||||||
|
name = "".join(c for c in name if not unicodedata.combining(c))
|
||||||
|
return re.sub(r"[^0-9a-z]", "", name.casefold())
|
||||||
|
|
||||||
|
|
||||||
|
def _steam_match(name: str) -> dict | None:
|
||||||
|
"""The Steam store app named name (or name + a subtitle), if there's one.
|
||||||
|
Strict on purpose: "Jass" or "Crochet" must not end up as some random game."""
|
||||||
|
wanted = normalize_name(name)
|
||||||
|
if not wanted:
|
||||||
|
return None
|
||||||
|
# Steam's search chokes on punctuation ("Puyo Puyo — Tetris" finds nothing)
|
||||||
|
term = " ".join(re.sub(r"[^\w\s]", " ", name).split())
|
||||||
|
for item in search_store(term):
|
||||||
|
steam_name = item.get("name") or ""
|
||||||
|
if wanted in (
|
||||||
|
normalize_name(steam_name),
|
||||||
|
normalize_name(SUBTITLE_SEPARATOR.split(steam_name, maxsplit=1)[0]),
|
||||||
|
):
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_path(name: str) -> str:
|
||||||
|
base = slugify(name)[:90] or "game"
|
||||||
|
path, n = base, 2
|
||||||
|
while Game.objects.filter(path=path).exists():
|
||||||
|
path, n = f"{base}-{n}", n + 1
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def create_missing_steam_games(event, names: list[str]) -> list[Game]:
|
||||||
|
"""For each name without a Game, look it up on the Steam store and create
|
||||||
|
the Game + its EventGame when found. Returns the created games."""
|
||||||
|
known = {normalize_name(game.name) for game in Game.objects.all()}
|
||||||
|
created = []
|
||||||
|
for name in dict.fromkeys(names): # unique, keeps order
|
||||||
|
if normalize_name(name) in known:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
item = _steam_match(name)
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
logger.warning("Steam store search failed for %r: %s", name, exc)
|
||||||
|
continue
|
||||||
|
if item is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if Game.objects.filter(steam_app_id=item["id"]).exists():
|
||||||
|
# Already there under another name, the planning won't match it
|
||||||
|
logger.warning(
|
||||||
|
"%r is Steam app %s, already a Game under another name",
|
||||||
|
name,
|
||||||
|
item["id"],
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Named like in the planning, so the slot matches it
|
||||||
|
game = Game.objects.create(
|
||||||
|
name=name, steam_app_id=item["id"], path=_unique_path(name)
|
||||||
|
)
|
||||||
|
EventGame.objects.get_or_create(event=event, game=game)
|
||||||
|
known.add(normalize_name(name))
|
||||||
|
created.append(game)
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def slot_game_names(entries: list[dict]) -> list[str]:
|
||||||
|
"""Names to look for in a planning: titles of single slots, game names of
|
||||||
|
multi-anims (their title, like "Fils rouges", isn't a game)."""
|
||||||
|
names = []
|
||||||
|
for entry in entries:
|
||||||
|
if not entry.get("is_multi_anims"):
|
||||||
|
names.append(entry["title"])
|
||||||
|
names += [a["name"] for a in entry.get("aandts") or []]
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _game_out(name: str, game: Game | None) -> dict:
|
||||||
|
if game is None:
|
||||||
|
return {"name": name}
|
||||||
|
return {
|
||||||
|
"name": game.name,
|
||||||
|
"library_image": game.library_image_url,
|
||||||
|
"store_url": game.store_url,
|
||||||
|
"steam_app_id": game.steam_app_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def planning_with_games(slots) -> list[dict]:
|
||||||
|
"""Slots as PlanningSlotOut dicts, with their games matched by name to ours."""
|
||||||
|
games_by_name = {normalize_name(game.name): game for game in Game.objects.all()}
|
||||||
|
|
||||||
|
def match(name: str) -> Game | None:
|
||||||
|
return games_by_name.get(normalize_name(name))
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for slot in slots:
|
||||||
|
if slot.is_multi_anims:
|
||||||
|
games = [_game_out(name, match(name)) for name in slot.game_names]
|
||||||
|
else:
|
||||||
|
# The title is the usual match, the legacy game names are a fallback
|
||||||
|
# ("Golf it!" slot running the "Golf It!" game)
|
||||||
|
game = next(
|
||||||
|
filter(None, map(match, [slot.title, *slot.game_names])), None
|
||||||
|
)
|
||||||
|
games = [_game_out(slot.title, game)]
|
||||||
|
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": slot.id,
|
||||||
|
"title": slot.title,
|
||||||
|
"begin": slot.begin,
|
||||||
|
"end": slot.end,
|
||||||
|
"column": slot.column,
|
||||||
|
"color": slot.color,
|
||||||
|
"is_multi_anims": slot.is_multi_anims,
|
||||||
|
"games": games,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def import_planning(event, entries: list[dict]) -> tuple[int, int, int]:
|
||||||
|
"""Sync the event planning with the legacy planning entries (its "list").
|
||||||
|
Returns (created, updated, deleted)."""
|
||||||
|
created = updated = 0
|
||||||
|
for entry in entries:
|
||||||
|
_, was_created = PlanningSlot.objects.update_or_create(
|
||||||
|
event=event,
|
||||||
|
source_pk=entry["pk"],
|
||||||
|
defaults={
|
||||||
|
"title": entry["title"],
|
||||||
|
"begin": entry["begin"],
|
||||||
|
"end": entry["end"],
|
||||||
|
"column": entry.get("colonneS") or 0,
|
||||||
|
"color": entry.get("color_code") or "",
|
||||||
|
"is_multi_anims": bool(entry.get("is_multi_anims")),
|
||||||
|
"game_names": [a["name"] for a in entry.get("aandts") or []],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if was_created:
|
||||||
|
created += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
deleted, _ = (
|
||||||
|
PlanningSlot.objects.filter(event=event)
|
||||||
|
.exclude(source_pk__in=[entry["pk"] for entry in entries])
|
||||||
|
.delete()
|
||||||
|
)
|
||||||
|
return created, updated, deleted
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from ..models import Game
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Public store endpoint, no API key needed. Rate limited to ~200 calls / 5 min,
|
||||||
|
# which is plenty for a handful of games every few hours.
|
||||||
|
APP_DETAILS_URL = "https://store.steampowered.com/api/appdetails"
|
||||||
|
STORE_SEARCH_URL = "https://store.steampowered.com/api/storesearch/"
|
||||||
|
|
||||||
|
|
||||||
|
class SteamStoreError(Exception):
|
||||||
|
"""Raised when the Steam store didn't return usable details for an app."""
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_app_details(steam_app_id: int) -> dict:
|
||||||
|
response = httpx.get(
|
||||||
|
APP_DETAILS_URL,
|
||||||
|
params={
|
||||||
|
"appids": steam_app_id,
|
||||||
|
"cc": settings.STEAM_STORE_COUNTRY,
|
||||||
|
"l": "en",
|
||||||
|
"filters": "basic,price_overview",
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
entry = (response.json() or {}).get(str(steam_app_id)) or {}
|
||||||
|
if not entry.get("success"):
|
||||||
|
raise SteamStoreError(f"No store details for app {steam_app_id}")
|
||||||
|
|
||||||
|
return entry["data"]
|
||||||
|
|
||||||
|
|
||||||
|
def search_store(term: str) -> list[dict]:
|
||||||
|
"""Store search results for term, as [{"id": app id, "name": ...}, ...]."""
|
||||||
|
response = httpx.get(
|
||||||
|
STORE_SEARCH_URL,
|
||||||
|
params={"term": term, "cc": settings.STEAM_STORE_COUNTRY, "l": "english"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return (response.json() or {}).get("items") or []
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_steam_game(game: Game) -> None:
|
||||||
|
"""Pull the current store info for a Steam game into its steam_* fields."""
|
||||||
|
data = fetch_app_details(game.steam_app_id)
|
||||||
|
price = data.get("price_overview") or {}
|
||||||
|
|
||||||
|
game.steam_is_free = bool(data.get("is_free"))
|
||||||
|
game.steam_price_initial = price.get("initial")
|
||||||
|
game.steam_price_final = price.get("final")
|
||||||
|
game.steam_discount_percent = price.get("discount_percent") or 0
|
||||||
|
game.steam_currency = price.get("currency") or ""
|
||||||
|
game.steam_short_description = data.get("short_description") or ""
|
||||||
|
game.steam_header_image = data.get("header_image") or ""
|
||||||
|
game.steam_info_updated_at = timezone.now()
|
||||||
|
game.save(
|
||||||
|
update_fields=[
|
||||||
|
"steam_is_free",
|
||||||
|
"steam_price_initial",
|
||||||
|
"steam_price_final",
|
||||||
|
"steam_discount_percent",
|
||||||
|
"steam_currency",
|
||||||
|
"steam_short_description",
|
||||||
|
"steam_header_image",
|
||||||
|
"steam_info_updated_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_steam_games(game_ids: list[int] | None = None) -> None:
|
||||||
|
"""Refresh every Steam game (or just game_ids). A failing game keeps its
|
||||||
|
previous info and doesn't stop the others."""
|
||||||
|
games = Game.objects.filter(steam_app_id__isnull=False)
|
||||||
|
if game_ids is not None:
|
||||||
|
games = games.filter(id__in=game_ids)
|
||||||
|
|
||||||
|
for game in games:
|
||||||
|
try:
|
||||||
|
refresh_steam_game(game)
|
||||||
|
except (httpx.HTTPError, ValueError, SteamStoreError) as exc:
|
||||||
|
logger.warning("Steam store refresh failed for %s: %s", game, exc)
|
||||||
@@ -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)
|
@admin.register(Market)
|
||||||
class MarketAdmin(DjangoObjectActions, admin.ModelAdmin):
|
class MarketAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||||
list_display = ["title", "status", "end_date", "created_by", "created_at"]
|
list_display = ["title", "status", "event", "end_date", "created_by", "created_at"]
|
||||||
list_filter = ["status", "created_at"]
|
list_filter = ["status", "created_at", "event"]
|
||||||
search_fields = ["uuid", "title"]
|
search_fields = ["uuid", "title"]
|
||||||
readonly_fields = [
|
readonly_fields = [
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -27,7 +27,7 @@ class MarketAdmin(DjangoObjectActions, admin.ModelAdmin):
|
|||||||
]
|
]
|
||||||
inlines = [MarketOptionInline]
|
inlines = [MarketOptionInline]
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
("Info", {"fields": ["uuid", "title", "description"]}),
|
("Info", {"fields": ["uuid", "title", "description", "event"]}),
|
||||||
("Configuration", {"fields": ["end_date", "multiplier"]}),
|
("Configuration", {"fields": ["end_date", "multiplier"]}),
|
||||||
("Status", {"fields": ["status", "winning_option"]}),
|
("Status", {"fields": ["status", "winning_option"]}),
|
||||||
("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}),
|
("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}),
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
from typing import List
|
from typing import List
|
||||||
from ninja import Router
|
from ninja import Router
|
||||||
|
from ninja.decorators import decorate_view
|
||||||
from ninja.errors import HttpError
|
from ninja.errors import HttpError
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.db.models import Sum, Prefetch
|
from django.db.models import Sum, Prefetch
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from market.models import Market, MarketOption, UserBet, UserPointChange
|
from games.decorators import require_game_enabled
|
||||||
|
from market.models import (
|
||||||
|
Market,
|
||||||
|
MarketOption,
|
||||||
|
UserBet,
|
||||||
|
UserPointChange,
|
||||||
|
get_event_points,
|
||||||
|
)
|
||||||
from market.schemas import (
|
from market.schemas import (
|
||||||
|
BetHistoryPointSchema,
|
||||||
MarketListSchema,
|
MarketListSchema,
|
||||||
ResolveMarketSchema,
|
ResolveMarketSchema,
|
||||||
UserBetCreateSchema,
|
UserBetCreateSchema,
|
||||||
UserBetSchema,
|
UserBetSchema,
|
||||||
|
UserPointsSchema,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -21,6 +31,7 @@ PREFIX = "/events/{event_path}/market"
|
|||||||
|
|
||||||
|
|
||||||
@router.get(f"{PREFIX}/", response=List[MarketListSchema])
|
@router.get(f"{PREFIX}/", response=List[MarketListSchema])
|
||||||
|
@decorate_view(require_game_enabled(path="market"))
|
||||||
def list_markets(request, event_path: str):
|
def list_markets(request, event_path: str):
|
||||||
"""List all markets (excludes draft markets)."""
|
"""List all markets (excludes draft markets)."""
|
||||||
markets = Market.for_event.exclude(status=Market.Status.DRAFT)
|
markets = Market.for_event.exclude(status=Market.Status.DRAFT)
|
||||||
@@ -33,6 +44,7 @@ def list_markets(request, event_path: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.get(f"{PREFIX}/user/bets", response=List[UserBetSchema])
|
@router.get(f"{PREFIX}/user/bets", response=List[UserBetSchema])
|
||||||
|
@decorate_view(require_game_enabled(path="market"))
|
||||||
def list_user_bets(request, event_path: str):
|
def list_user_bets(request, event_path: str):
|
||||||
"""List all bets placed by the current user."""
|
"""List all bets placed by the current user."""
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
@@ -45,7 +57,49 @@ def list_user_bets(request, event_path: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{PREFIX}/user/points", response=UserPointsSchema)
|
||||||
|
@decorate_view(require_game_enabled(path="market"))
|
||||||
|
def get_user_points(request, event_path: str):
|
||||||
|
"""Get the current user's points balance for this event."""
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
raise HttpError(401, "Authentication required")
|
||||||
|
|
||||||
|
return {"points": get_event_points(request.user, request.event)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{PREFIX}/{{market_uuid}}/history", response=List[BetHistoryPointSchema])
|
||||||
|
@decorate_view(require_game_enabled(path="market"))
|
||||||
|
def bet_history(request, event_path: str, market_uuid: str):
|
||||||
|
"""Points added to each option over time (anonymous, one entry per bet placed/increased)."""
|
||||||
|
market = get_object_or_404(
|
||||||
|
Market.objects.exclude(status=Market.Status.DRAFT),
|
||||||
|
uuid=market_uuid,
|
||||||
|
event=request.event,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A user can only bet on one option per market, so their option is unique
|
||||||
|
option_by_user = dict(
|
||||||
|
UserBet.objects.filter(option__market=market).values_list(
|
||||||
|
"user_id", "option__uuid"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
changes = UserPointChange.objects.filter(
|
||||||
|
market=market, reason=UserPointChange.Reason.BET_PLACED
|
||||||
|
).order_by("created_at")
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"option_uuid": str(option_by_user[change.user_id]),
|
||||||
|
"created_at": change.created_at,
|
||||||
|
"amount": -change.amount,
|
||||||
|
}
|
||||||
|
for change in changes
|
||||||
|
if change.user_id in option_by_user
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post(f"{PREFIX}/{{market_uuid}}/actions/close")
|
@router.post(f"{PREFIX}/{{market_uuid}}/actions/close")
|
||||||
|
@decorate_view(require_game_enabled(path="market"))
|
||||||
def close_market(request, event_path: str, market_uuid: str):
|
def close_market(request, event_path: str, market_uuid: str):
|
||||||
"""Close a market. Admin only."""
|
"""Close a market. Admin only."""
|
||||||
if not request.user.is_staff:
|
if not request.user.is_staff:
|
||||||
@@ -58,6 +112,7 @@ def close_market(request, event_path: str, market_uuid: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.post(f"{PREFIX}/{{market_uuid}}/actions/resolve", response=MarketListSchema)
|
@router.post(f"{PREFIX}/{{market_uuid}}/actions/resolve", response=MarketListSchema)
|
||||||
|
@decorate_view(require_game_enabled(path="market"))
|
||||||
def resolve_market(
|
def resolve_market(
|
||||||
request, event_path: str, market_uuid: str, payload: ResolveMarketSchema
|
request, event_path: str, market_uuid: str, payload: ResolveMarketSchema
|
||||||
):
|
):
|
||||||
@@ -76,9 +131,7 @@ def resolve_market(
|
|||||||
market.save(update_fields=["winning_option", "status", "updated_at"])
|
market.save(update_fields=["winning_option", "status", "updated_at"])
|
||||||
|
|
||||||
# Calculate and distribute winnings
|
# Calculate and distribute winnings
|
||||||
all_bets = list(
|
all_bets = list(UserBet.objects.filter(option__market=market))
|
||||||
UserBet.objects.filter(option__market=market).select_related("user")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate total pot
|
# Calculate total pot
|
||||||
total_pot = sum(bet.amount for bet in all_bets)
|
total_pot = sum(bet.amount for bet in all_bets)
|
||||||
@@ -92,7 +145,6 @@ def resolve_market(
|
|||||||
total_winning = sum(bet.amount for bet in winning_bets)
|
total_winning = sum(bet.amount for bet in winning_bets)
|
||||||
|
|
||||||
point_changes = []
|
point_changes = []
|
||||||
users_to_update = []
|
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# Award payouts to winners with multiplier
|
# Award payouts to winners with multiplier
|
||||||
@@ -101,11 +153,9 @@ def resolve_market(
|
|||||||
payout = round(
|
payout = round(
|
||||||
bet.amount / total_winning * total_pot * market.multiplier
|
bet.amount / total_winning * total_pot * market.multiplier
|
||||||
)
|
)
|
||||||
bet.user.points += payout
|
|
||||||
users_to_update.append(bet.user)
|
|
||||||
point_changes.append(
|
point_changes.append(
|
||||||
UserPointChange(
|
UserPointChange(
|
||||||
user=bet.user,
|
user_id=bet.user_id,
|
||||||
market=market,
|
market=market,
|
||||||
amount=payout,
|
amount=payout,
|
||||||
reason=UserPointChange.Reason.BET_WON,
|
reason=UserPointChange.Reason.BET_WON,
|
||||||
@@ -116,17 +166,13 @@ def resolve_market(
|
|||||||
for bet in losing_bets:
|
for bet in losing_bets:
|
||||||
point_changes.append(
|
point_changes.append(
|
||||||
UserPointChange(
|
UserPointChange(
|
||||||
user=bet.user,
|
user_id=bet.user_id,
|
||||||
market=market,
|
market=market,
|
||||||
amount=-bet.amount,
|
amount=-bet.amount,
|
||||||
reason=UserPointChange.Reason.BET_LOST,
|
reason=UserPointChange.Reason.BET_LOST,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Bulk update users
|
|
||||||
for user in users_to_update:
|
|
||||||
user.save(update_fields=["points"])
|
|
||||||
|
|
||||||
# Bulk create point changes
|
# Bulk create point changes
|
||||||
UserPointChange.objects.bulk_create(point_changes)
|
UserPointChange.objects.bulk_create(point_changes)
|
||||||
|
|
||||||
@@ -134,6 +180,7 @@ def resolve_market(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(f"{PREFIX}/{{market_uuid}}/bets", response=UserBetSchema)
|
@router.post(f"{PREFIX}/{{market_uuid}}/bets", response=UserBetSchema)
|
||||||
|
@decorate_view(require_game_enabled(path="market"))
|
||||||
def create_bet(
|
def create_bet(
|
||||||
request, event_path: str, market_uuid: str, payload: UserBetCreateSchema
|
request, event_path: str, market_uuid: str, payload: UserBetCreateSchema
|
||||||
):
|
):
|
||||||
@@ -167,8 +214,8 @@ def create_bet(
|
|||||||
# Calculate delta (amount to deduct from user's points)
|
# Calculate delta (amount to deduct from user's points)
|
||||||
delta = payload.amount - (existing_bet.amount if existing_bet else 0)
|
delta = payload.amount - (existing_bet.amount if existing_bet else 0)
|
||||||
|
|
||||||
# Check if user has enough points
|
# Check if user has enough points for this event
|
||||||
if request.user.points < delta:
|
if get_event_points(request.user, request.event) < delta:
|
||||||
raise HttpError(400, "Insufficient points for this bet")
|
raise HttpError(400, "Insufficient points for this bet")
|
||||||
|
|
||||||
user_bet, created = UserBet.objects.update_or_create(
|
user_bet, created = UserBet.objects.update_or_create(
|
||||||
@@ -177,9 +224,7 @@ def create_bet(
|
|||||||
defaults={"amount": payload.amount},
|
defaults={"amount": payload.amount},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Deduct points and record the change
|
# Record the change (the event balance is derived from these)
|
||||||
request.user.points -= delta
|
|
||||||
request.user.save(update_fields=["points"])
|
|
||||||
UserPointChange.objects.create(
|
UserPointChange.objects.create(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
market=market,
|
market=market,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
MARKET_PATH = "market"
|
||||||
|
|
||||||
|
|
||||||
|
def seed_market_game(apps, schema_editor):
|
||||||
|
"""The market used to be toggled globally (MARKET_ENABLED); it's now a
|
||||||
|
custom Game like clock/place, enabled per event through EventGame. Enable
|
||||||
|
it for every existing event so nothing disappears on deploy."""
|
||||||
|
Event = apps.get_model("events", "Event")
|
||||||
|
Game = apps.get_model("games", "Game")
|
||||||
|
EventGame = apps.get_model("games", "EventGame")
|
||||||
|
|
||||||
|
game, _ = Game.objects.get_or_create(
|
||||||
|
path=MARKET_PATH,
|
||||||
|
custom_path=True,
|
||||||
|
defaults={
|
||||||
|
"name": "Market",
|
||||||
|
"description": "Place your bets and compete",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for event in Event.objects.all():
|
||||||
|
EventGame.objects.get_or_create(event=event, game=game)
|
||||||
|
|
||||||
|
|
||||||
|
def unseed_market_game(apps, schema_editor):
|
||||||
|
Game = apps.get_model("games", "Game")
|
||||||
|
Game.objects.filter(path=MARKET_PATH, custom_path=True).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("events", "0003_event_is_available"),
|
||||||
|
("games", "0009_game_description"),
|
||||||
|
("market", "0009_event_required"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed_market_game, reverse_code=unseed_market_game),
|
||||||
|
]
|
||||||
@@ -1,10 +1,28 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Sum
|
||||||
|
|
||||||
from events.models import Event
|
from events.models import Event
|
||||||
from events.scoping import EventScopedManager
|
from events.scoping import EventScopedManager
|
||||||
|
|
||||||
|
|
||||||
|
# Each event's market starts every user with this balance
|
||||||
|
STARTING_POINTS = 1000
|
||||||
|
|
||||||
|
|
||||||
|
def get_event_points(user, event) -> int:
|
||||||
|
"""User's market balance for an event: starting points + the event's point changes.
|
||||||
|
|
||||||
|
BET_LOST rows are informational (the stake was already deducted by BET_PLACED).
|
||||||
|
"""
|
||||||
|
total = (
|
||||||
|
UserPointChange.objects.filter(user=user, market__event=event)
|
||||||
|
.exclude(reason=UserPointChange.Reason.BET_LOST)
|
||||||
|
.aggregate(total=Sum("amount"))["total"]
|
||||||
|
)
|
||||||
|
return STARTING_POINTS + (total or 0)
|
||||||
|
|
||||||
|
|
||||||
class BaseModel(models.Model):
|
class BaseModel(models.Model):
|
||||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ class ResolveMarketSchema(Schema):
|
|||||||
winning_option_uuid: str
|
winning_option_uuid: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserPointsSchema(Schema):
|
||||||
|
points: int
|
||||||
|
|
||||||
|
|
||||||
|
class BetHistoryPointSchema(Schema):
|
||||||
|
option_uuid: str
|
||||||
|
created_at: datetime
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
|
||||||
class UserBetCreateSchema(Schema):
|
class UserBetCreateSchema(Schema):
|
||||||
option_uuid: str
|
option_uuid: str
|
||||||
amount: int
|
amount: int
|
||||||
|
|||||||
@@ -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 django.http import HttpRequest
|
||||||
|
|
||||||
from events.models import Event
|
from events.models import Event
|
||||||
|
from games.decorators import require_game_enabled
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@require_game_enabled(path="market")
|
||||||
def market_home(request: HttpRequest, event_path: str):
|
def market_home(request: HttpRequest, event_path: str):
|
||||||
get_object_or_404(Event, path=event_path)
|
get_object_or_404(Event, path=event_path)
|
||||||
return render(request, "app.html")
|
return render(request, "app.html")
|
||||||
|
|||||||
@@ -239,7 +239,9 @@ def get_leaderboard(request: HttpRequest, event_path: str):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@router.post(f"{PREFIX}/submit", response={200: NoitaSubmissionOut, 400: dict})
|
@router.post(
|
||||||
|
f"{PREFIX}/submit", response={200: NoitaSubmissionOut, 400: dict, 500: dict}
|
||||||
|
)
|
||||||
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||||
def submit_log_file(
|
def submit_log_file(
|
||||||
request: HttpRequest, event_path: str, file: UploadedFile = File(...)
|
request: HttpRequest, event_path: str, file: UploadedFile = File(...)
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ import { defineConfig } from '@hey-api/openapi-ts';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
input: `http://localhost:7777/api/openapi.json`,
|
input: `http://localhost:7777/api/openapi.json`,
|
||||||
output: 'src/api/',
|
output: 'src/api/',
|
||||||
|
plugins: ['@hey-api/typescript', '@hey-api/sdk', '@tanstack/vue-query'],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,7 +95,16 @@ class SteamCollectionAdmin(admin.ModelAdmin):
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
"Basic Information",
|
"Basic Information",
|
||||||
{"fields": ("steam_id", "url", "title", "description", "is_active")},
|
{
|
||||||
|
"fields": (
|
||||||
|
"event",
|
||||||
|
"steam_id",
|
||||||
|
"url",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"is_active",
|
||||||
|
)
|
||||||
|
},
|
||||||
),
|
),
|
||||||
("Author Information", {"fields": ("author_name", "author_steam_id")}),
|
("Author Information", {"fields": ("author_name", "author_steam_id")}),
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import csv
|
||||||
|
|
||||||
from ninja import Router, File
|
from ninja import Router, File
|
||||||
from ninja.files import UploadedFile
|
from ninja.files import UploadedFile
|
||||||
from ninja.pagination import paginate
|
from ninja.pagination import paginate
|
||||||
@@ -5,6 +7,8 @@ from ninja.errors import HttpError
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from ninja.security import django_auth_is_staff
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from typing import List
|
from typing import List
|
||||||
@@ -25,6 +29,7 @@ from .schemas import (
|
|||||||
SubmissionOut,
|
SubmissionOut,
|
||||||
PuzzleResponseOut,
|
PuzzleResponseOut,
|
||||||
ValidationIn,
|
ValidationIn,
|
||||||
|
SubmissionFileOut,
|
||||||
SteamCollectionItemOut,
|
SteamCollectionItemOut,
|
||||||
SteamCollectionOut,
|
SteamCollectionOut,
|
||||||
)
|
)
|
||||||
@@ -73,7 +78,9 @@ def get_submission(request, event_path: str, submission_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(f"{PREFIX}/submissions", response=SubmissionOut)
|
@router.post(
|
||||||
|
f"{PREFIX}/submissions", response={200: SubmissionOut, 400: dict, 500: dict}
|
||||||
|
)
|
||||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def create_submission(
|
def create_submission(
|
||||||
request, event_path: str, data: SubmissionIn, files: List[UploadedFile] = File(...)
|
request, event_path: str, data: SubmissionIn, files: List[UploadedFile] = File(...)
|
||||||
@@ -120,13 +127,17 @@ def create_submission(
|
|||||||
for response_data in data.responses
|
for response_data in data.responses
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Non-admin submissions always go through manual validation
|
||||||
|
needs_validation = (
|
||||||
|
data.manual_validation_requested or not request.user.is_staff
|
||||||
|
)
|
||||||
|
|
||||||
# Create the submission
|
# Create the submission
|
||||||
submission = Submission.objects.create(
|
submission = Submission.objects.create(
|
||||||
event=request.event,
|
event=request.event,
|
||||||
user=request.user if request.user.is_authenticated else None,
|
user=request.user if request.user.is_authenticated else None,
|
||||||
notes=data.notes,
|
notes=data.notes,
|
||||||
manual_validation_requested=data.manual_validation_requested
|
manual_validation_requested=needs_validation or auto_request_validation,
|
||||||
or auto_request_validation,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
file_index = 0
|
file_index = 0
|
||||||
@@ -146,7 +157,7 @@ def create_submission(
|
|||||||
cost=response_data.cost,
|
cost=response_data.cost,
|
||||||
cycles=response_data.cycles,
|
cycles=response_data.cycles,
|
||||||
area=response_data.area,
|
area=response_data.area,
|
||||||
needs_manual_validation=data.manual_validation_requested,
|
needs_manual_validation=needs_validation,
|
||||||
ocr_confidence_cost=response_data.ocr_confidence_cost,
|
ocr_confidence_cost=response_data.ocr_confidence_cost,
|
||||||
ocr_confidence_cycles=response_data.ocr_confidence_cycles,
|
ocr_confidence_cycles=response_data.ocr_confidence_cycles,
|
||||||
ocr_confidence_area=response_data.ocr_confidence_area,
|
ocr_confidence_area=response_data.ocr_confidence_area,
|
||||||
@@ -156,7 +167,7 @@ def create_submission(
|
|||||||
"validated_cycles": response_data.cycles,
|
"validated_cycles": response_data.cycles,
|
||||||
"validated_area": response_data.area,
|
"validated_area": response_data.area,
|
||||||
}
|
}
|
||||||
if not data.manual_validation_requested
|
if not needs_validation
|
||||||
else {},
|
else {},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -211,7 +222,10 @@ def create_submission(
|
|||||||
return 500, {"detail": f"Error creating submission: {str(e)}"}
|
return 500, {"detail": f"Error creating submission: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
@router.put(f"{PREFIX}/responses/{{response_id}}/validate", response=PuzzleResponseOut)
|
@router.put(
|
||||||
|
f"{PREFIX}/responses/{{response_id}}/validate",
|
||||||
|
response={200: PuzzleResponseOut, 403: dict},
|
||||||
|
)
|
||||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def validate_response(request, event_path: str, response_id: int, data: ValidationIn):
|
def validate_response(request, event_path: str, response_id: int, data: ValidationIn):
|
||||||
"""Manually validate a puzzle response"""
|
"""Manually validate a puzzle response"""
|
||||||
@@ -252,7 +266,8 @@ def validate_response(request, event_path: str, response_id: int, data: Validati
|
|||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
f"{PREFIX}/responses/{{response_id}}/validate/auto", response=PuzzleResponseOut
|
f"{PREFIX}/responses/{{response_id}}/validate/auto",
|
||||||
|
response={200: PuzzleResponseOut, 403: dict},
|
||||||
)
|
)
|
||||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def validate_auto(request, event_path: str, response_id: int):
|
def validate_auto(request, event_path: str, response_id: int):
|
||||||
@@ -271,7 +286,10 @@ def validate_auto(request, event_path: str, response_id: int):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get(f"{PREFIX}/responses/needs-validation", response=List[PuzzleResponseOut])
|
@router.get(
|
||||||
|
f"{PREFIX}/responses/needs-validation",
|
||||||
|
response={200: List[PuzzleResponseOut], 403: dict},
|
||||||
|
)
|
||||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def list_responses_needing_validation(request, event_path: str):
|
def list_responses_needing_validation(request, event_path: str):
|
||||||
"""Get all responses that need manual validation"""
|
"""Get all responses that need manual validation"""
|
||||||
@@ -289,7 +307,28 @@ def list_responses_needing_validation(request, event_path: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(f"{PREFIX}/submissions/{{submission_id}}/validate", response=SubmissionOut)
|
@router.get(
|
||||||
|
f"{PREFIX}/responses/{{response_id}}/files",
|
||||||
|
response={200: List[SubmissionFileOut], 403: dict},
|
||||||
|
)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
|
def list_response_files(request, event_path: str, response_id: int):
|
||||||
|
"""Get the solution files of any player's response (admin only)"""
|
||||||
|
|
||||||
|
if not request.user.is_authenticated or not request.user.is_staff:
|
||||||
|
return 403, {"detail": "Admin access required"}
|
||||||
|
|
||||||
|
response = get_object_or_404(
|
||||||
|
PuzzleResponse, id=response_id, submission__event=request.event
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.files.all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
f"{PREFIX}/submissions/{{submission_id}}/validate",
|
||||||
|
response={200: SubmissionOut, 403: dict},
|
||||||
|
)
|
||||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def validate_submission(request, event_path: str, submission_id: str):
|
def validate_submission(request, event_path: str, submission_id: str):
|
||||||
"""Mark entire submission as validated"""
|
"""Mark entire submission as validated"""
|
||||||
@@ -318,7 +357,9 @@ def validate_submission(request, event_path: str, submission_id: str):
|
|||||||
return submission
|
return submission
|
||||||
|
|
||||||
|
|
||||||
@router.delete(f"{PREFIX}/submissions/{{submission_id}}")
|
@router.delete(
|
||||||
|
f"{PREFIX}/submissions/{{submission_id}}", response={200: dict, 403: dict}
|
||||||
|
)
|
||||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def delete_submission(request, event_path: str, submission_id: str):
|
def delete_submission(request, event_path: str, submission_id: str):
|
||||||
"""Delete a submission (admin only)"""
|
"""Delete a submission (admin only)"""
|
||||||
@@ -358,3 +399,41 @@ def get_stats(request, event_path: str):
|
|||||||
if total_responses
|
if total_responses
|
||||||
else 0,
|
else 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{PREFIX}/points.csv", auth=django_auth_is_staff)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
|
def points_csv(request: HttpRequest, event_path: str):
|
||||||
|
"""Admin-only: points per player for each puzzle of the active collection,
|
||||||
|
one column per puzzle (1 puzzle = 1 round), `;` separated."""
|
||||||
|
puzzles = list(
|
||||||
|
SteamCollectionItem.objects.filter(
|
||||||
|
collection__is_active=True, collection__event=request.event
|
||||||
|
).order_by("order_index")
|
||||||
|
)
|
||||||
|
|
||||||
|
points = {}
|
||||||
|
for response in (
|
||||||
|
PuzzleResponse.objects.filter(
|
||||||
|
needs_manual_validation=False,
|
||||||
|
submission__event=request.event,
|
||||||
|
submission__user__isnull=False,
|
||||||
|
puzzle__in=puzzles,
|
||||||
|
)
|
||||||
|
.filter_user_best_response()
|
||||||
|
.select_related("submission__user")
|
||||||
|
):
|
||||||
|
user_points = points.setdefault(response.submission.user.username, {})
|
||||||
|
user_points[response.puzzle_id] = response.rank_points or 0
|
||||||
|
|
||||||
|
# text/plain so browsers render it inline - most force-download text/csv
|
||||||
|
response = HttpResponse(content_type="text/plain; charset=utf-8")
|
||||||
|
writer = csv.writer(response, delimiter=";")
|
||||||
|
writer.writerow(["username", *(puzzle.title for puzzle in puzzles)])
|
||||||
|
for username, user_points in sorted(
|
||||||
|
points.items(), key=lambda item: (-sum(item[1].values()), item[0])
|
||||||
|
):
|
||||||
|
writer.writerow(
|
||||||
|
[username, *(user_points.get(puzzle.id, 0) for puzzle in puzzles)]
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Django management command to fetch Steam Workshop collections
|
|||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from opus_magnum.utils import create_or_update_collection
|
from opus_magnum.utils import create_or_update_collection
|
||||||
|
from events.models import Event
|
||||||
from opus_magnum.models import SteamAPIKey, SteamCollection
|
from opus_magnum.models import SteamAPIKey, SteamCollection
|
||||||
|
|
||||||
|
|
||||||
@@ -12,6 +13,12 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument("url", type=str, help="Steam Workshop collection URL")
|
parser.add_argument("url", type=str, help="Steam Workshop collection URL")
|
||||||
|
parser.add_argument(
|
||||||
|
"--event",
|
||||||
|
type=str,
|
||||||
|
required=True,
|
||||||
|
help="Path of the event the collection belongs to",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--force",
|
"--force",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -22,6 +29,11 @@ class Command(BaseCommand):
|
|||||||
url = options["url"]
|
url = options["url"]
|
||||||
force = options["force"]
|
force = options["force"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = Event.objects.get(path=options["event"])
|
||||||
|
except Event.DoesNotExist:
|
||||||
|
raise CommandError(f"No event with path '{options['event']}'")
|
||||||
|
|
||||||
self.stdout.write(f"Fetching Steam collection from: {url}")
|
self.stdout.write(f"Fetching Steam collection from: {url}")
|
||||||
|
|
||||||
api_key = SteamAPIKey.objects.filter(is_active=True).first()
|
api_key = SteamAPIKey.objects.filter(is_active=True).first()
|
||||||
@@ -53,7 +65,7 @@ class Command(BaseCommand):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Fetch and create/update collection
|
# Fetch and create/update collection
|
||||||
collection, created = create_or_update_collection(url)
|
collection, created = create_or_update_collection(url, event)
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 0015 copied rows with their ids via raw INSERT ... SELECT *, which doesn't
|
||||||
|
# bump postgres sequences -> new inserts collide on the pkey. Resync them.
|
||||||
|
|
||||||
|
from django.core.management.color import no_style
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
MODEL_NAMES = [
|
||||||
|
"SteamAPIKey",
|
||||||
|
"SteamCollection",
|
||||||
|
"SteamCollectionItem",
|
||||||
|
"Submission",
|
||||||
|
"PuzzleResponse",
|
||||||
|
"SubmissionFile",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def reset_sequences(apps, schema_editor):
|
||||||
|
connection = schema_editor.connection
|
||||||
|
models = [apps.get_model("opus_magnum", name) for name in MODEL_NAMES]
|
||||||
|
statements = connection.ops.sequence_reset_sql(no_style(), models)
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
for sql in statements:
|
||||||
|
cursor.execute(sql)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("opus_magnum", "0018_event_required"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(reset_sequences, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-10-23 21:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("opus_magnum", "0019_reset_sequences"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="puzzleresponse",
|
||||||
|
name="area",
|
||||||
|
field=models.IntegerField(
|
||||||
|
blank=True, help_text="Area value from OCR", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="puzzleresponse",
|
||||||
|
name="cost",
|
||||||
|
field=models.IntegerField(
|
||||||
|
blank=True, help_text="Cost value from OCR", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="puzzleresponse",
|
||||||
|
name="cycles",
|
||||||
|
field=models.IntegerField(
|
||||||
|
blank=True, help_text="Cycles value from OCR", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -400,9 +400,9 @@ class PuzzleResponse(models.Model):
|
|||||||
puzzle_name = models.CharField(
|
puzzle_name = models.CharField(
|
||||||
max_length=255, help_text="Puzzle name as detected by OCR"
|
max_length=255, help_text="Puzzle name as detected by OCR"
|
||||||
)
|
)
|
||||||
cost = models.IntegerField(blank=True, help_text="Cost value from OCR")
|
cost = models.IntegerField(null=True, blank=True, help_text="Cost value from OCR")
|
||||||
cycles = models.IntegerField(blank=True, help_text="Cycles value from OCR")
|
cycles = models.IntegerField(null=True, blank=True, help_text="Cycles value from OCR")
|
||||||
area = models.IntegerField(blank=True, help_text="Area value from OCR")
|
area = models.IntegerField(null=True, blank=True, help_text="Area value from OCR")
|
||||||
|
|
||||||
# Validation flags
|
# Validation flags
|
||||||
needs_manual_validation = models.BooleanField(
|
needs_manual_validation = models.BooleanField(
|
||||||
|
|||||||
@@ -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 re
|
||||||
import requests
|
import requests
|
||||||
|
from events.models import Event
|
||||||
from opus_magnum.models import SteamCollection, SteamCollectionItem, SubmissionFile
|
from opus_magnum.models import SteamCollection, SteamCollectionItem, SubmissionFile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -373,12 +374,15 @@ def fetch_steam_collection(url: str) -> Dict:
|
|||||||
return fetcher.fetch_collection_data(url)
|
return fetcher.fetch_collection_data(url)
|
||||||
|
|
||||||
|
|
||||||
def create_or_update_collection(url: str) -> Tuple[SteamCollection, bool]:
|
def create_or_update_collection(
|
||||||
|
url: str, event: Event
|
||||||
|
) -> Tuple[SteamCollection, bool]:
|
||||||
"""
|
"""
|
||||||
Create or update a Steam collection in the database
|
Create or update a Steam collection in the database
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: Steam Workshop collection URL
|
url: Steam Workshop collection URL
|
||||||
|
event: Event the collection belongs to
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (SteamCollection instance, created_flag)
|
Tuple of (SteamCollection instance, created_flag)
|
||||||
@@ -395,6 +399,7 @@ def create_or_update_collection(url: str) -> Tuple[SteamCollection, bool]:
|
|||||||
collection, created = SteamCollection.objects.update_or_create(
|
collection, created = SteamCollection.objects.update_or_create(
|
||||||
steam_id=data["steam_id"],
|
steam_id=data["steam_id"],
|
||||||
defaults={
|
defaults={
|
||||||
|
"event": event,
|
||||||
"url": data["url"],
|
"url": data["url"],
|
||||||
"title": data["title"],
|
"title": data["title"],
|
||||||
"description": data["description"],
|
"description": data["description"],
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
|
"@tanstack/query-persist-client-core": "^5.104.3",
|
||||||
|
"@tanstack/query-sync-storage-persister": "^5.104.3",
|
||||||
|
"@tanstack/vue-query": "^5.104.3",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vueuse/core": "^14.0.0",
|
"@vueuse/core": "^14.0.0",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django_object_actions import DjangoObjectActions, action
|
||||||
|
|
||||||
|
from .canvas import rebuild_canvas_from_history
|
||||||
|
from .models import PixelPlacement, PlaceSettings
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PlaceSettings)
|
||||||
|
class PlaceSettingsAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||||
|
list_display = ("event", "width", "height", "placement_ends_at")
|
||||||
|
list_editable = ("width", "height", "placement_ends_at")
|
||||||
|
list_filter = ("event",)
|
||||||
|
change_actions = ("resync_canvas",)
|
||||||
|
|
||||||
|
@action(
|
||||||
|
label="Resync canvas from DB",
|
||||||
|
description="Rebuild the live Redis canvas by replaying the full PixelPlacement history",
|
||||||
|
)
|
||||||
|
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}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PixelPlacement)
|
||||||
|
class PixelPlacementAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("id", "event", "player", "x", "y", "color_index", "placed_at")
|
||||||
|
list_filter = ("event", "player")
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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
|
||||||
|
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 .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()
|
||||||
|
|
||||||
|
PREFIX = "/events/{event_path}/place"
|
||||||
|
|
||||||
|
# 4-byte little-endian header (uint16 width, uint16 height) prefixed to the
|
||||||
|
# canvas payload - the grid can be resized by an admin, so the frontend
|
||||||
|
# can't hardcode dimensions and needs to read them off the payload itself.
|
||||||
|
HEADER_FORMAT = "<HH"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{PREFIX}/canvas")
|
||||||
|
@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. 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)
|
||||||
|
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, 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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "place"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
from . import signals # noqa: F401
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
def place_group(event_path: str) -> str:
|
||||||
|
"""Channel group for one event's place room - every event has its own board."""
|
||||||
|
return f"place_room_{event_path}"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Redis-backed live canvas state, one canvas per event.
|
||||||
|
|
||||||
|
Redis is read/written on the hot path (every placement); the DB
|
||||||
|
(PixelPlacement) is the durable history and is never queried on the hot
|
||||||
|
path. Each event's canvas is its own string key, one byte per pixel
|
||||||
|
(row-major), holding the palette index - SETRANGE self-pads with zero
|
||||||
|
bytes, so there's no separate "ensure canvas exists" bootstrap step.
|
||||||
|
|
||||||
|
The grid's width/height aren't fixed - they live on PlaceSettings (see
|
||||||
|
models.py) and an admin can change them per event - so every function here
|
||||||
|
takes them as arguments rather than reading a constant. Callers that already
|
||||||
|
have a PlaceSettings instance in hand (consumers, the resync admin action)
|
||||||
|
just pass its width/height through.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import redis
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from .models import PixelPlacement
|
||||||
|
|
||||||
|
_client = redis.Redis.from_url(settings.PLACE_REDIS_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def _canvas_key(event_path: str) -> str:
|
||||||
|
return f"place:{event_path}:canvas"
|
||||||
|
|
||||||
|
|
||||||
|
def read_canvas(event_path: str, width: int, height: int) -> bytes:
|
||||||
|
data = _client.get(_canvas_key(event_path))
|
||||||
|
if data is None:
|
||||||
|
return bytes(width * height)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def write_pixel(event_path: str, x: int, y: int, color_index: int, width: int) -> None:
|
||||||
|
_client.setrange(_canvas_key(event_path), y * width + x, bytes([color_index]))
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_canvas_from_history(event, width: int, height: int) -> int:
|
||||||
|
"""Replays the durable PixelPlacement history for `event`, in placement
|
||||||
|
order, into a fresh canvas sized width x height and overwrites the live
|
||||||
|
Redis copy with it in one atomic SET. Used to repair Redis after it's
|
||||||
|
lost/diverged from the DB (a flush, a restart without persistence, a
|
||||||
|
resize - the old Redis bytes don't match the new dimensions - or a write
|
||||||
|
that raced another one to the same pixel and landed out of order).
|
||||||
|
Placements that fall outside the current dimensions (from a placement
|
||||||
|
made before a shrink) are skipped rather than raising. Returns the number
|
||||||
|
of placements replayed.
|
||||||
|
"""
|
||||||
|
grid = bytearray(width * height)
|
||||||
|
count = 0
|
||||||
|
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
|
||||||
|
grid[y * width + x] = color_index
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
_client.set(_canvas_key(event.path), bytes(grid))
|
||||||
|
return count
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from channels.db import database_sync_to_async
|
||||||
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
from .broadcast import place_group
|
||||||
|
from .canvas import write_pixel
|
||||||
|
from .models import PALETTE, PLACE_COOLDOWN_SECONDS, PixelPlacement, PlaceSettings
|
||||||
|
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.scope.get("user")
|
||||||
|
)
|
||||||
|
if self.event is None:
|
||||||
|
await self.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.group_name = place_group(self.event_path)
|
||||||
|
await self.channel_layer.group_add(self.group_name, self.channel_name)
|
||||||
|
await self.accept()
|
||||||
|
await self.send(text_data=await self._initial_state(self.scope.get("user")))
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
if getattr(self, "group_name", None):
|
||||||
|
await self.channel_layer.group_discard(self.group_name, self.channel_name)
|
||||||
|
|
||||||
|
async def receive(self, text_data=None, bytes_data=None):
|
||||||
|
if text_data is None or not text_data.startswith("p|"):
|
||||||
|
return
|
||||||
|
await self._handle_place(text_data)
|
||||||
|
|
||||||
|
async def _handle_place(self, text_data):
|
||||||
|
place_settings = await self._get_settings()
|
||||||
|
|
||||||
|
user = self.scope.get("user")
|
||||||
|
if user is None or not user.is_authenticated:
|
||||||
|
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"))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
x, y, color_index = int(parts[1]), int(parts[2]), int(parts[3])
|
||||||
|
except ValueError:
|
||||||
|
await self.send(text_data=encode("r", "i"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# An unvalidated x/y would let a client make Redis compute a
|
||||||
|
# huge/negative SETRANGE offset - this check is required, not just
|
||||||
|
# a UX nicety.
|
||||||
|
if not (
|
||||||
|
0 <= x < place_settings.width
|
||||||
|
and 0 <= y < place_settings.height
|
||||||
|
and 0 <= color_index < len(PALETTE)
|
||||||
|
):
|
||||||
|
await self.send(text_data=encode("r", "i"))
|
||||||
|
return
|
||||||
|
|
||||||
|
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"]))
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.send(text_data=encode("k", result["next"]))
|
||||||
|
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
self.group_name, {"type": "pixel", "x": x, "y": y, "c": color_index}
|
||||||
|
)
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _get_enabled_event(self, event_path, user):
|
||||||
|
event = Event.objects.available_to(user).filter(path=event_path).first()
|
||||||
|
if event is None:
|
||||||
|
return None
|
||||||
|
if not Game.objects.enabled_for_event(event).filter(path="place").exists():
|
||||||
|
return None
|
||||||
|
return event
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _record_placement(self, user, x, y, color_index, width):
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
last_placement = (
|
||||||
|
PixelPlacement.objects.filter(player=user, event=self.event)
|
||||||
|
.order_by("-placed_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if last_placement is not None:
|
||||||
|
next_allowed = last_placement.placed_at + timedelta(
|
||||||
|
seconds=PLACE_COOLDOWN_SECONDS
|
||||||
|
)
|
||||||
|
if next_allowed > now:
|
||||||
|
return {"rejected": True, "next": to_epoch_ms(next_allowed)}
|
||||||
|
|
||||||
|
# Durable history first, then hot Redis state.
|
||||||
|
placement = PixelPlacement.objects.create(
|
||||||
|
event=self.event, player=user, x=x, y=y, color_index=color_index
|
||||||
|
)
|
||||||
|
write_pixel(self.event_path, x, y, color_index, width)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rejected": False,
|
||||||
|
"next": to_epoch_ms(
|
||||||
|
placement.placed_at + timedelta(seconds=PLACE_COOLDOWN_SECONDS)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _initial_state(self, user):
|
||||||
|
place_settings = PlaceSettings.current(self.event)
|
||||||
|
|
||||||
|
next_placement_at = None
|
||||||
|
if user is not None and user.is_authenticated:
|
||||||
|
last_placement = (
|
||||||
|
PixelPlacement.objects.filter(player=user, event=self.event)
|
||||||
|
.order_by("-placed_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if last_placement is not None:
|
||||||
|
candidate = last_placement.placed_at + timedelta(
|
||||||
|
seconds=PLACE_COOLDOWN_SECONDS
|
||||||
|
)
|
||||||
|
if candidate > timezone.now():
|
||||||
|
next_placement_at = to_epoch_ms(candidate)
|
||||||
|
|
||||||
|
return encode(
|
||||||
|
"s",
|
||||||
|
next_placement_at,
|
||||||
|
place_settings.width,
|
||||||
|
place_settings.height,
|
||||||
|
_epoch_ms_or_none(place_settings.placement_ends_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _get_settings(self):
|
||||||
|
return PlaceSettings.current(self.event)
|
||||||
|
|
||||||
|
async def pixel(self, event):
|
||||||
|
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"],
|
||||||
|
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()
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Replays one event's pixel placement history into an animated GIF.
|
||||||
|
|
||||||
|
Unlike canvas.py (which only ever reflects the current state), this reads
|
||||||
|
the durable PixelPlacement history and reconstructs every intermediate
|
||||||
|
state of the grid, one frame per placement (or sampled down if there are
|
||||||
|
more placements than MAX_FRAMES), so an admin can watch that event's board
|
||||||
|
evolve.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
|
||||||
|
from .models import PALETTE, PixelPlacement, PlaceSettings
|
||||||
|
|
||||||
|
SCALE = 8
|
||||||
|
MAX_FRAMES = 300
|
||||||
|
FRAME_DURATION_MS = 80
|
||||||
|
FINAL_FRAME_HOLD_MS = 3000
|
||||||
|
|
||||||
|
_PALETTE_RGB = [
|
||||||
|
tuple(int(hex_color[i : i + 2], 16) for i in (1, 3, 5)) for hex_color in PALETTE
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
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] = []
|
||||||
|
|
||||||
|
step = max(1, len(placements) // MAX_FRAMES)
|
||||||
|
for i, (x, y, color_index) in enumerate(placements):
|
||||||
|
if x >= width or y >= height:
|
||||||
|
continue
|
||||||
|
grid[y * width + x] = color_index
|
||||||
|
if i % step == 0 or i == len(placements) - 1:
|
||||||
|
frames.append(_render_frame(grid, width, height))
|
||||||
|
|
||||||
|
if not frames:
|
||||||
|
frames.append(_render_frame(grid, width, height))
|
||||||
|
|
||||||
|
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 _render_frame(grid: bytearray, width: int, height: int) -> Image.Image:
|
||||||
|
image = Image.new("RGB", (width, height))
|
||||||
|
image.putdata([_PALETTE_RGB[c] for c in grid])
|
||||||
|
return image.resize((width * SCALE, height * SCALE), Image.NEAREST)
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-09-16 23:11
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("events", "0002_seed_polylan_43"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PixelPlacement",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("x", models.PositiveSmallIntegerField()),
|
||||||
|
("y", models.PositiveSmallIntegerField()),
|
||||||
|
("color_index", models.PositiveSmallIntegerField()),
|
||||||
|
("placed_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
(
|
||||||
|
"event",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="pixel_placements",
|
||||||
|
to="events.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"player",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="pixel_placements",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-placed_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PlaceSettings",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("width", models.PositiveSmallIntegerField(default=64)),
|
||||||
|
("height", models.PositiveSmallIntegerField(default=64)),
|
||||||
|
(
|
||||||
|
"event",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="place_settings",
|
||||||
|
to="events.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
from events.scoping import EventScopedManager
|
||||||
|
|
||||||
|
DEFAULT_GRID_WIDTH = 64
|
||||||
|
DEFAULT_GRID_HEIGHT = 64
|
||||||
|
PLACE_COOLDOWN_SECONDS = 30
|
||||||
|
|
||||||
|
# 16-entry palette, classic r/place-style colors. The index into this list is
|
||||||
|
# what's sent over the wire (see wire.py) - frontend/src/palette.ts must
|
||||||
|
# mirror this list exactly, in the same order.
|
||||||
|
PALETTE = [
|
||||||
|
"#FFFFFF", # 0 white
|
||||||
|
"#E4E4E4", # 1 light grey
|
||||||
|
"#888888", # 2 grey
|
||||||
|
"#222222", # 3 black
|
||||||
|
"#FFA7D1", # 4 pink
|
||||||
|
"#E50000", # 5 red
|
||||||
|
"#E59500", # 6 orange
|
||||||
|
"#A06A42", # 7 brown
|
||||||
|
"#E5D900", # 8 yellow
|
||||||
|
"#94E044", # 9 light green
|
||||||
|
"#02BE01", # 10 green
|
||||||
|
"#00D3DD", # 11 cyan
|
||||||
|
"#0083C7", # 12 blue
|
||||||
|
"#0000EA", # 13 dark blue
|
||||||
|
"#CF6EE4", # 14 purple
|
||||||
|
"#820080", # 15 dark purple
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceSettings(models.Model):
|
||||||
|
"""One row per event, same event-scoped-singleton pattern as
|
||||||
|
clock.models.ClockTimer. width/height are the live grid dimensions - the
|
||||||
|
canonical source of truth is here, not a fixed constant, so an admin can
|
||||||
|
resize the board per event (see signals.py for how that propagates).
|
||||||
|
|
||||||
|
Unlike pplace's original PlaceSettings, there's no is_enabled here - the
|
||||||
|
games registry (EventGame.enabled) is the single on/off switch, checked
|
||||||
|
before a client can reach this app at all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
event = models.OneToOneField(
|
||||||
|
Event, on_delete=models.CASCADE, related_name="place_settings"
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
class PixelPlacement(models.Model):
|
||||||
|
event = models.ForeignKey(
|
||||||
|
Event, on_delete=models.CASCADE, related_name="pixel_placements"
|
||||||
|
)
|
||||||
|
player = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="pixel_placements",
|
||||||
|
)
|
||||||
|
x = models.PositiveSmallIntegerField()
|
||||||
|
y = models.PositiveSmallIntegerField()
|
||||||
|
color_index = models.PositiveSmallIntegerField()
|
||||||
|
placed_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
objects = models.Manager()
|
||||||
|
for_event = EventScopedManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-placed_at"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.player} placed ({self.x}, {self.y})=#{self.color_index} ({self.event})"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import re_path
|
||||||
|
|
||||||
|
from .consumers import PlaceConsumer
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
re_path(r"^ws/place/(?P<event_path>[\w-]+)/$", PlaceConsumer.as_asgi()),
|
||||||
|
]
|
||||||
@@ -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]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""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 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
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
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:
|
||||||
|
return
|
||||||
|
async_to_sync(channel_layer.group_send)(
|
||||||
|
place_group(instance.event.path),
|
||||||
|
{
|
||||||
|
"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()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Compact wire format for the place websocket.
|
||||||
|
|
||||||
|
Same convention as clock/wire.py (kept separate on purpose - the two games
|
||||||
|
don't share code, mirroring how the clock and place apps don't share code
|
||||||
|
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>|<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
|
||||||
|
new cooldown end
|
||||||
|
r|c|<next> rejected: still on cooldown
|
||||||
|
r|a rejected: not logged in
|
||||||
|
r|i rejected: invalid x/y/color
|
||||||
|
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>
|
||||||
|
|
||||||
|
Whether the game is enabled at all for this event (EventGame.enabled) is
|
||||||
|
checked once, at connect time - a connection is refused outright rather than
|
||||||
|
accepted-but-disabled, so there's no "disabled" case in this wire format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DELIMITER = "|"
|
||||||
|
|
||||||
|
|
||||||
|
def encode(*parts) -> str:
|
||||||
|
return DELIMITER.join("" if p is None else str(p) for p in parts)
|
||||||
|
|
||||||
|
|
||||||
|
def to_epoch_ms(dt) -> int:
|
||||||
|
return int(dt.timestamp() * 1000)
|
||||||
Generated
+74
@@ -11,6 +11,15 @@ importers:
|
|||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.1.16
|
specifier: ^4.1.16
|
||||||
version: 4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4))
|
version: 4.1.16(vite@7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4))
|
||||||
|
'@tanstack/query-persist-client-core':
|
||||||
|
specifier: ^5.104.3
|
||||||
|
version: 5.104.3
|
||||||
|
'@tanstack/query-sync-storage-persister':
|
||||||
|
specifier: ^5.104.3
|
||||||
|
version: 5.104.3
|
||||||
|
'@tanstack/vue-query':
|
||||||
|
specifier: ^5.104.3
|
||||||
|
version: 5.104.3(vue@3.5.22(typescript@5.9.3))
|
||||||
'@tanstack/vue-table':
|
'@tanstack/vue-table':
|
||||||
specifier: ^8.21.3
|
specifier: ^8.21.3
|
||||||
version: 8.21.3(vue@3.5.22(typescript@5.9.3))
|
version: 8.21.3(vue@3.5.22(typescript@5.9.3))
|
||||||
@@ -515,10 +524,32 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vite: ^5.2.0 || ^6 || ^7
|
vite: ^5.2.0 || ^6 || ^7
|
||||||
|
|
||||||
|
'@tanstack/match-sorter-utils@9.2.6':
|
||||||
|
resolution: {integrity: sha512-DIpUSOhPfQ7u/qfUi/Wgm0wBO56MyBYXGvnlI0NcauDRPqdBUlAZZjW2Dxm92m+k1UWVhKw8YJ4f47+75Aujyw==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
'@tanstack/query-core@5.104.3':
|
||||||
|
resolution: {integrity: sha512-DZY9TuJAOgU3UyHUhXb4CcTYEHNZszlwkPg4VViIwTxOKzLprPUU+rbaPZQd3srqkGOtpaDdKpW972UV/LKlcg==}
|
||||||
|
|
||||||
|
'@tanstack/query-persist-client-core@5.104.3':
|
||||||
|
resolution: {integrity: sha512-6nhc/QbMpkKeuAM81SFSdcsJ9B8dm3fGHPODbau2cRSDyqcXiSl7sXQM7POVktch/YdgIGYPIQyZOQQaRarHMA==}
|
||||||
|
|
||||||
|
'@tanstack/query-sync-storage-persister@5.104.3':
|
||||||
|
resolution: {integrity: sha512-sNM9X7QZdTHBlzHuNxuHQjECn3/zncuMgw5pesOFXu8XQ01UXBGASdMKZSRaxfNOhigRjm5souHhfmByN97NEg==}
|
||||||
|
|
||||||
'@tanstack/table-core@8.21.3':
|
'@tanstack/table-core@8.21.3':
|
||||||
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
'@tanstack/vue-query@5.104.3':
|
||||||
|
resolution: {integrity: sha512-bdVlhyEFPllLfHioHx8kwtCAQ1YJQrvYczlsNzyiYFsp6n5yz9isNXiy6MIu+QGmG+RyghVdnp1UmiNDqLS22g==}
|
||||||
|
peerDependencies:
|
||||||
|
'@vue/composition-api': ^1.1.2
|
||||||
|
vue: ^2.6.0 || ^3.3.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@vue/composition-api':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@tanstack/vue-table@8.21.3':
|
'@tanstack/vue-table@8.21.3':
|
||||||
resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==}
|
resolution: {integrity: sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -961,6 +992,9 @@ packages:
|
|||||||
regenerator-runtime@0.13.11:
|
regenerator-runtime@0.13.11:
|
||||||
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||||
|
|
||||||
|
remove-accents@0.5.0:
|
||||||
|
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
|
||||||
|
|
||||||
resolve-pkg-maps@1.0.0:
|
resolve-pkg-maps@1.0.0:
|
||||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||||
|
|
||||||
@@ -1072,6 +1106,17 @@ packages:
|
|||||||
vscode-uri@3.1.0:
|
vscode-uri@3.1.0:
|
||||||
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
|
||||||
|
|
||||||
|
vue-demi@0.14.10:
|
||||||
|
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@vue/composition-api': ^1.0.0-rc.1
|
||||||
|
vue: ^3.0.0-0 || ^2.6.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@vue/composition-api':
|
||||||
|
optional: true
|
||||||
|
|
||||||
vue-i18n@11.4.12:
|
vue-i18n@11.4.12:
|
||||||
resolution: {integrity: sha512-yvArvvo3ErLKFNlQrZUGmRkxL/ctM5xFYtE9FxTKiE/PalWP1bnTopBgbSRGLluYEyYSNl1Pt8l2J7zVavbXww==}
|
resolution: {integrity: sha512-yvArvvo3ErLKFNlQrZUGmRkxL/ctM5xFYtE9FxTKiE/PalWP1bnTopBgbSRGLluYEyYSNl1Pt8l2J7zVavbXww==}
|
||||||
engines: {node: '>= 22'}
|
engines: {node: '>= 22'}
|
||||||
@@ -1445,8 +1490,31 @@ snapshots:
|
|||||||
tailwindcss: 4.1.16
|
tailwindcss: 4.1.16
|
||||||
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4)
|
vite: 7.1.12(@types/node@24.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.4)
|
||||||
|
|
||||||
|
'@tanstack/match-sorter-utils@9.2.6':
|
||||||
|
dependencies:
|
||||||
|
remove-accents: 0.5.0
|
||||||
|
|
||||||
|
'@tanstack/query-core@5.104.3': {}
|
||||||
|
|
||||||
|
'@tanstack/query-persist-client-core@5.104.3':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/query-core': 5.104.3
|
||||||
|
|
||||||
|
'@tanstack/query-sync-storage-persister@5.104.3':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/query-core': 5.104.3
|
||||||
|
'@tanstack/query-persist-client-core': 5.104.3
|
||||||
|
|
||||||
'@tanstack/table-core@8.21.3': {}
|
'@tanstack/table-core@8.21.3': {}
|
||||||
|
|
||||||
|
'@tanstack/vue-query@5.104.3(vue@3.5.22(typescript@5.9.3))':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/match-sorter-utils': 9.2.6
|
||||||
|
'@tanstack/query-core': 5.104.3
|
||||||
|
'@vue/devtools-api': 6.6.4
|
||||||
|
vue: 3.5.22(typescript@5.9.3)
|
||||||
|
vue-demi: 0.14.10(vue@3.5.22(typescript@5.9.3))
|
||||||
|
|
||||||
'@tanstack/vue-table@8.21.3(vue@3.5.22(typescript@5.9.3))':
|
'@tanstack/vue-table@8.21.3(vue@3.5.22(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/table-core': 8.21.3
|
'@tanstack/table-core': 8.21.3
|
||||||
@@ -1863,6 +1931,8 @@ snapshots:
|
|||||||
|
|
||||||
regenerator-runtime@0.13.11: {}
|
regenerator-runtime@0.13.11: {}
|
||||||
|
|
||||||
|
remove-accents@0.5.0: {}
|
||||||
|
|
||||||
resolve-pkg-maps@1.0.0: {}
|
resolve-pkg-maps@1.0.0: {}
|
||||||
|
|
||||||
rfdc@1.4.1: {}
|
rfdc@1.4.1: {}
|
||||||
@@ -1962,6 +2032,10 @@ snapshots:
|
|||||||
|
|
||||||
vscode-uri@3.1.0: {}
|
vscode-uri@3.1.0: {}
|
||||||
|
|
||||||
|
vue-demi@0.14.10(vue@3.5.22(typescript@5.9.3)):
|
||||||
|
dependencies:
|
||||||
|
vue: 3.5.22(typescript@5.9.3)
|
||||||
|
|
||||||
vue-i18n@11.4.12(vue@3.5.22(typescript@5.9.3)):
|
vue-i18n@11.4.12(vue@3.5.22(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@intlify/core-base': 11.4.12
|
'@intlify/core-base': 11.4.12
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from ninja import NinjaAPI, Schema
|
from ninja import NinjaAPI
|
||||||
from django.conf import settings
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from opus_magnum.api import router as opus_magnum_router
|
from opus_magnum.api import router as opus_magnum_router
|
||||||
@@ -9,6 +8,8 @@ from noita.api import router as noita_router
|
|||||||
from games.api import router as games_router, event_router as games_event_router
|
from games.api import router as games_router, event_router as games_event_router
|
||||||
from market.api import router as market_router
|
from market.api import router as market_router
|
||||||
from submissions.api import router as submissions_router
|
from submissions.api import router as submissions_router
|
||||||
|
from clock.api import router as clock_router
|
||||||
|
from place.api import router as place_router
|
||||||
|
|
||||||
# Create the main API instance
|
# Create the main API instance
|
||||||
api = NinjaAPI(
|
api = NinjaAPI(
|
||||||
@@ -50,31 +51,20 @@ api.add_router("", results_router, tags=["results"])
|
|||||||
api.add_router("", noita_router, tags=["noita"])
|
api.add_router("", noita_router, tags=["noita"])
|
||||||
api.add_router("", games_event_router, tags=["games"])
|
api.add_router("", games_event_router, tags=["games"])
|
||||||
api.add_router("", submissions_router, tags=["submissions"])
|
api.add_router("", submissions_router, tags=["submissions"])
|
||||||
|
api.add_router("", clock_router, tags=["clock"])
|
||||||
if settings.MARKET_ENABLED:
|
api.add_router("", place_router, tags=["place"])
|
||||||
api.add_router("", market_router, tags=["market"])
|
api.add_router("", market_router, tags=["market"])
|
||||||
|
|
||||||
|
|
||||||
# Health check endpoint
|
# Health check endpoint
|
||||||
@api.get("/health")
|
@api.get("/health", response=dict)
|
||||||
def health_check(request):
|
def health_check(request):
|
||||||
"""Health check endpoint"""
|
"""Health check endpoint"""
|
||||||
return {"status": "healthy", "service": "polylan-submitter-api"}
|
return {"status": "healthy", "service": "polylan-submitter-api"}
|
||||||
|
|
||||||
|
|
||||||
class ConfigOut(Schema):
|
|
||||||
market_enabled: bool
|
|
||||||
|
|
||||||
|
|
||||||
# Site-wide config the SPA needs but that isn't tied to any one event
|
|
||||||
# (e.g. whether to render the market card at all on the home page).
|
|
||||||
@api.get("/config", response=ConfigOut)
|
|
||||||
def get_config(request):
|
|
||||||
return {"market_enabled": settings.MARKET_ENABLED}
|
|
||||||
|
|
||||||
|
|
||||||
# Cache management endpoint
|
# Cache management endpoint
|
||||||
@api.post("/cache/clear")
|
@api.post("/cache/clear", response={200: dict, 403: dict})
|
||||||
def clear_cache(request: HttpRequest, event_path: str | None = None):
|
def clear_cache(request: HttpRequest, event_path: str | None = None):
|
||||||
"""Clear API caches (admin only). Pass event_path to clear just that event's keys."""
|
"""Clear API caches (admin only). Pass event_path to clear just that event's keys."""
|
||||||
if not request.user.is_authenticated or not request.user.is_staff:
|
if not request.user.is_authenticated or not request.user.is_staff:
|
||||||
|
|||||||
@@ -17,7 +17,17 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings")
|
|||||||
|
|
||||||
django_asgi_app = get_asgi_application()
|
django_asgi_app = get_asgi_application()
|
||||||
|
|
||||||
from submissions.routing import websocket_urlpatterns
|
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,
|
||||||
|
*clock_websocket_urlpatterns,
|
||||||
|
*place_websocket_urlpatterns,
|
||||||
|
]
|
||||||
|
|
||||||
application = ProtocolTypeRouter(
|
application = ProtocolTypeRouter(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,30 +13,90 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
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_REQUEST_CHECKSUM_CALCULATION", "when_required")
|
||||||
os.environ.setdefault("AWS_RESPONSE_CHECKSUM_VALIDATION", "when_required")
|
os.environ.setdefault("AWS_RESPONSE_CHECKSUM_VALIDATION", "when_required")
|
||||||
|
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
# 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!
|
# 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!
|
# 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
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"daphne",
|
"daphne",
|
||||||
"django.contrib.admin",
|
"admin_interface",
|
||||||
|
"colorfield",
|
||||||
|
# "palette" provides the admin/base.html override (the command-palette
|
||||||
|
# UI) and must precede "palette.apps.PaletteAdminConfig", which stands
|
||||||
|
# in for "django.contrib.admin" (same app name, custom AdminSite) to
|
||||||
|
# wire the palette into the admin.
|
||||||
|
"palette.apps.PaletteConfig",
|
||||||
|
"palette.apps.PaletteAdminConfig",
|
||||||
"django.contrib.auth",
|
"django.contrib.auth",
|
||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
@@ -54,8 +114,16 @@ INSTALLED_APPS = [
|
|||||||
"games",
|
"games",
|
||||||
"market",
|
"market",
|
||||||
"submissions",
|
"submissions",
|
||||||
|
"clock",
|
||||||
|
"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 = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
@@ -93,7 +161,22 @@ ASGI_APPLICATION = "polylan_submitter.asgi.application"
|
|||||||
|
|
||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
# 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.
|
||||||
|
|
||||||
|
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 = {
|
DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
@@ -153,16 +236,34 @@ AUTH_USER_MODEL = "accounts.CustomUser"
|
|||||||
# Simple CAS Configuration
|
# Simple CAS Configuration
|
||||||
CAS_SERVER_URL = "https://polylan.ch/cas/"
|
CAS_SERVER_URL = "https://polylan.ch/cas/"
|
||||||
|
|
||||||
|
# Enables POST /testing/login/ (see testing_auth_views.py), a
|
||||||
|
# username/password login for local-only accounts that authenticate through
|
||||||
|
# ModelBackend instead of SimpleCASBackend. This exists so load testing (see
|
||||||
|
# loadtest/locustfile.py) never sends traffic to the real CAS server at
|
||||||
|
# polylan.ch. Leave this off everywhere except a dedicated load-test
|
||||||
|
# environment - the "Create load test users" admin action on CustomUser
|
||||||
|
# (see accounts/admin.py) also hides itself without it, as a second layer
|
||||||
|
# of the same guard rail.
|
||||||
|
LOAD_TEST_LOGIN_ENABLED = (
|
||||||
|
os.environ.get("LOAD_TEST_LOGIN_ENABLED", "false").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
# Steam API Configuration
|
# Steam API Configuration
|
||||||
STEAM_API_KEY = os.environ.get("STEAM_API_KEY", None) # Set via environment variable
|
STEAM_API_KEY = os.environ.get("STEAM_API_KEY") or None # Set via environment variable
|
||||||
|
|
||||||
|
# Steam store info (price, discount, blurb) for the event's Steam games, refreshed
|
||||||
|
# in the background every STEAM_STORE_REFRESH_MINUTES (see games.tasks)
|
||||||
|
STEAM_STORE_REFRESH_MINUTES = int(os.environ.get("STEAM_STORE_REFRESH_MINUTES") or 360)
|
||||||
|
# Store region the prices are fetched for (ISO country code, drives the currency)
|
||||||
|
STEAM_STORE_COUNTRY = os.environ.get("STEAM_STORE_COUNTRY") or "ch"
|
||||||
|
|
||||||
# Base URL of the legacy PolyLAN site, used to sync AAndT matches/members
|
# Base URL of the legacy PolyLAN site, used to sync AAndT matches/members
|
||||||
# (see submissions.services.aandt_sync)
|
# (see submissions.services.aandt_sync)
|
||||||
AANDT_API_BASE_URL = os.environ.get("AANDT_API_BASE_URL", "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
|
# Base URL of this site, used to build submission links pushed back to the
|
||||||
# legacy AAndT match messages (see submissions.services.aandt_sync)
|
# 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
|
# File Upload Settings
|
||||||
MEDIA_URL = "/media/"
|
MEDIA_URL = "/media/"
|
||||||
@@ -172,11 +273,16 @@ MEDIA_ROOT = BASE_DIR / "media"
|
|||||||
FILE_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB
|
FILE_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB
|
||||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB
|
DATA_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB
|
||||||
|
|
||||||
|
# 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
|
# Caching Configuration
|
||||||
CACHES = {
|
CACHES = {
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "django_redis.cache.RedisCache",
|
"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
|
"TIMEOUT": 300, # 5 minutes default
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||||
@@ -189,12 +295,18 @@ CHANNEL_LAYERS = {
|
|||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||||
"CONFIG": {
|
"CONFIG": {
|
||||||
"hosts": [("127.0.0.1", 6379)],
|
"hosts": [(REDIS_HOST, REDIS_PORT)],
|
||||||
"serializer_format": "cjson",
|
"serializer_format": "cjson",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 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") or f"redis://{REDIS_HOST}:{REDIS_PORT}/15"
|
||||||
|
)
|
||||||
|
|
||||||
# Django Tasks Configuration (with RQ backend)
|
# Django Tasks Configuration (with RQ backend)
|
||||||
TASKS = {
|
TASKS = {
|
||||||
"default": {
|
"default": {
|
||||||
@@ -204,8 +316,8 @@ TASKS = {
|
|||||||
|
|
||||||
RQ_QUEUES = {
|
RQ_QUEUES = {
|
||||||
"default": {
|
"default": {
|
||||||
"HOST": "127.0.0.1",
|
"HOST": REDIS_HOST,
|
||||||
"PORT": 6379,
|
"PORT": REDIS_PORT,
|
||||||
"DB": 12,
|
"DB": 12,
|
||||||
"DEFAULT_RESULT_TTL": 500,
|
"DEFAULT_RESULT_TTL": 500,
|
||||||
}
|
}
|
||||||
@@ -221,9 +333,6 @@ ALLOWED_SUBMISSION_TYPES = [
|
|||||||
"video/webm",
|
"video/webm",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Market app settings
|
|
||||||
MARKET_ENABLED = os.environ.get("MARKET_ENABLED", "true").lower() == "true"
|
|
||||||
|
|
||||||
# Authentication backends
|
# Authentication backends
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
"django.contrib.auth.backends.ModelBackend",
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
@@ -237,7 +346,8 @@ LOGIN_REDIRECT_URL = "/"
|
|||||||
LOGOUT_REDIRECT_URL = "/"
|
LOGOUT_REDIRECT_URL = "/"
|
||||||
DJANGO_VITE = {
|
DJANGO_VITE = {
|
||||||
"default": {
|
"default": {
|
||||||
"dev_mode": False,
|
"dev_mode": DEBUG,
|
||||||
|
"dev_server_port": 5273,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
@@ -247,26 +357,51 @@ STATICFILES_DIRS = [
|
|||||||
os.path.join(BASE_DIR, "static_source/vite"),
|
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 = {
|
STORAGES = {
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "storages.backends.s3.S3Storage",
|
"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": {
|
"staticfiles": {
|
||||||
"BACKEND": "storages.backends.s3.S3Storage",
|
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
SENTRY_ENVIRONMENT = "production"
|
SENTRY_ENVIRONMENT = os.environ.get("SENTRY_ENVIRONMENT") or (
|
||||||
from polylan_submitter.settingsLocal import * # noqa
|
"development" if DEBUG else "production"
|
||||||
|
)
|
||||||
|
SENTRY_DSN = (
|
||||||
|
os.environ.get("SENTRY_DSN")
|
||||||
|
or "https://cc62a4ce3f3470890b43accf02cc6d8c@sentry2.polylan.ch/12"
|
||||||
|
)
|
||||||
|
|
||||||
import sentry_sdk # noqa
|
import sentry_sdk # noqa
|
||||||
|
|
||||||
sentry_sdk.init(
|
sentry_sdk.init(
|
||||||
dsn="https://cc62a4ce3f3470890b43accf02cc6d8c@sentry2.polylan.ch/12",
|
dsn=SENTRY_DSN,
|
||||||
# Set traces_sample_rate to 1.0 to capture 100%
|
# Set traces_sample_rate to 1.0 to capture 100%
|
||||||
# of transactions for tracing.
|
# of transactions for tracing.
|
||||||
traces_sample_rate=1.0,
|
traces_sample_rate=0.1,
|
||||||
environment=SENTRY_ENVIRONMENT,
|
environment=SENTRY_ENVIRONMENT,
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user