Compare commits
110
Commits
2ee8cd6be3
...
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
|
||
|
|
d104480188
|
||
|
|
15590e5162
|
||
|
|
13b14abb12
|
||
|
|
f68c4e50e8
|
||
|
|
4392122cfc
|
||
|
|
9ec9a54649
|
||
|
|
fabf06e6d4
|
||
|
|
85e67599af
|
||
|
|
68de07645a
|
||
|
|
8b9c254fd2
|
||
|
|
b5c2cdc037
|
||
|
|
d534519de6
|
||
|
|
d7bdb85fb0
|
||
|
|
b64161b371
|
||
|
|
c02a0aa985
|
||
|
|
202e04a554
|
||
|
|
0fc1fb255f
|
||
|
|
a769d1a0b3
|
||
|
|
11d6fc423a
|
||
|
|
be81d40a90
|
||
|
|
9f9cb0a483
|
||
|
|
6a58a0289e
|
||
|
|
c2602dbc02
|
||
|
|
f543cdcd5c
|
||
|
|
f2fe87c12b
|
||
|
|
3e91ef77b6
|
||
|
|
215d68a0e1
|
||
|
|
47812ffd09
|
||
|
|
a5fe8aacaf
|
||
|
|
b437210eb3
|
||
|
|
5584e54b58
|
||
|
|
35ea54ecea
|
||
|
|
821e453bc0
|
||
|
|
9fd0122a67
|
||
|
|
e557fe2cda
|
||
|
|
79e7cef3ba
|
||
|
|
a264336bd8
|
||
|
|
42e3571fab
|
||
|
|
43b314bb20
|
||
|
|
f1afb2096f
|
||
|
|
62a81e57ad
|
||
|
|
303b9e1c8a
|
||
|
|
f7c7eba4da
|
||
|
|
ce30539808
|
||
|
|
544112b204
|
||
|
|
9f94fb3974
|
||
|
|
25072e8eb4
|
||
|
|
9e5ab8539a
|
||
|
|
4ba6a48246
|
||
|
|
92dddca964
|
||
|
|
bf21a5eae6
|
||
|
|
89faa7cd40
|
||
|
|
a800c7fff7
|
@@ -0,0 +1,23 @@
|
||||
.git
|
||||
.gitignore
|
||||
.venv
|
||||
venv
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
*.egg-info
|
||||
.ruff_cache
|
||||
.pytest_cache
|
||||
ctagsfile
|
||||
images
|
||||
main.py
|
||||
gifs_opus_magnum
|
||||
|
||||
polylan_submitter/node_modules
|
||||
polylan_submitter/static
|
||||
polylan_submitter/static_source/vite
|
||||
polylan_submitter/media
|
||||
polylan_submitter/db.sqlite3
|
||||
polylan_submitter/.vscode
|
||||
polylan_submitter/settingsLocal.py
|
||||
|
||||
deploy/docker/polylan-submitter.env
|
||||
@@ -178,3 +178,4 @@ pyrightconfig.json
|
||||
tags
|
||||
media/
|
||||
settingsLocal.py
|
||||
deploy/docker/polylan-submitter.env
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
# Single OCI image bundling the Vue/Vite frontend, the Django/Channels web
|
||||
# process (daphne) and the RQ worker, managed together by supervisord - see
|
||||
# deploy/docker/README.md for why (single-container Proxmox deploy) and how
|
||||
# to run it. Postgres, Redis and S3 stay external (see
|
||||
# deploy/docker/polylan-submitter.env.dist for the full env var list) - this
|
||||
# image carries no infra, just the app.
|
||||
#
|
||||
# Build (from repo root, requires BuildKit/buildx - RUN --mount=type=cache
|
||||
# below is rejected by the legacy builder):
|
||||
# docker buildx build --load -t polylan-submitter .
|
||||
|
||||
# ---- frontend build -------------------------------------------------------
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
WORKDIR /app
|
||||
COPY polylan_submitter/package.json polylan_submitter/pnpm-lock.yaml ./
|
||||
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate \
|
||||
&& pnpm install --frozen-lockfile
|
||||
COPY polylan_submitter/vite.config.ts polylan_submitter/tsconfig*.json ./
|
||||
COPY polylan_submitter/src ./src
|
||||
COPY polylan_submitter/public ./public
|
||||
RUN pnpm build
|
||||
|
||||
# ---- python dependencies ---------------------------------------------------
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS python-builder
|
||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock ./
|
||||
# --no-install-project: this pyproject has no [build-system]/package to
|
||||
# install, it only pins the app's dependencies - so just build the venv.
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev --no-install-project
|
||||
|
||||
# ---- runtime ----------------------------------------------------------------
|
||||
FROM python:3.13-slim AS runtime
|
||||
|
||||
# tesseract-ocr: shelled out to by pytesseract (submissions/tasks.py,
|
||||
# opus_magnum/utils.py) for OCR'ing Opus Magnum submission GIFs.
|
||||
# libgl1/libglib2.0-0: runtime shared libs opencv-python needs even though
|
||||
# it ships as a wheel.
|
||||
# libpq5: psycopg (plain, not psycopg[binary]/[c]) loads libpq via ctypes.
|
||||
# supervisor/tini: process supervision - see deploy/docker/supervisord.conf.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tesseract-ocr \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
libpq5 \
|
||||
supervisor \
|
||||
tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd --system app && useradd --system --gid app --create-home app
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=python-builder --chown=app:app /app/.venv /app/.venv
|
||||
ENV PATH="/app/.venv/bin:$PATH" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
DJANGO_SETTINGS_MODULE=polylan_submitter.settings
|
||||
|
||||
COPY --chown=app:app polylan_submitter/ /app/polylan_submitter/
|
||||
COPY --from=frontend-builder --chown=app:app /app/static_source/vite /app/polylan_submitter/static_source/vite
|
||||
COPY --chown=app:app deploy/docker/entrypoint.sh /app/entrypoint.sh
|
||||
COPY --chown=app:app deploy/docker/supervisord.conf /app/supervisord.conf
|
||||
|
||||
RUN rm -rf /app/polylan_submitter/node_modules /app/polylan_submitter/settingsLocal.py \
|
||||
&& mkdir -p /app/polylan_submitter/static /app/polylan_submitter/media \
|
||||
&& chown app:app /app/polylan_submitter/static /app/polylan_submitter/media \
|
||||
&& chmod +x /app/entrypoint.sh
|
||||
|
||||
USER app
|
||||
WORKDIR /app/polylan_submitter
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["tini", "--", "/app/entrypoint.sh"]
|
||||
CMD ["supervisord", "-c", "/app/supervisord.conf", "-n"]
|
||||
@@ -0,0 +1,73 @@
|
||||
# Container image
|
||||
|
||||
`Dockerfile` (repo root) builds a single OCI image with the Vite frontend
|
||||
already built in, and a `.venv` with all Python deps. At runtime it uses
|
||||
`supervisord` (`deploy/docker/supervisord.conf`) to run two processes in the
|
||||
one container:
|
||||
|
||||
- `web` - one `daphne` process serving the Django/Channels app on `:8000`
|
||||
- `worker` - two `rqworker` processes for background jobs (submission OCR,
|
||||
AAndT sync, etc.)
|
||||
|
||||
Postgres, Redis and S3-compatible storage are **not** bundled (see
|
||||
`deploy/docker/polylan-submitter.env.dist` for the full list of env vars:
|
||||
`POSTGRES_*`, `REDIS_HOST`/`REDIS_PORT`, `AWS_*`, `DJANGO_SECRET_KEY`, etc.).
|
||||
Point those at wherever those services already live (other containers/LXCs,
|
||||
or existing infra).
|
||||
|
||||
## Build
|
||||
|
||||
Requires BuildKit/buildx (`RUN --mount=type=cache` in the Dockerfile is
|
||||
rejected by the legacy builder):
|
||||
|
||||
```sh
|
||||
docker buildx build --load -t polylan-submitter .
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
docker run -d \
|
||||
--name polylan-submitter \
|
||||
--env-file deploy/docker/polylan-submitter.env \
|
||||
-p 8000:8000 \
|
||||
polylan-submitter
|
||||
```
|
||||
|
||||
Migrations run automatically on container start (set `MIGRATE_ON_START=false`
|
||||
to skip). `collectstatic` does not run automatically - it pushes to the
|
||||
shared S3 bucket, so run it once per deploy instead of racing multiple
|
||||
replicas on every boot:
|
||||
|
||||
```sh
|
||||
docker run --rm --env-file deploy/docker/polylan-submitter.env \
|
||||
polylan-submitter python manage.py collectstatic --noinput
|
||||
```
|
||||
|
||||
## Scaling
|
||||
|
||||
There's one `daphne` process per container - it binds the only port the
|
||||
container exposes, so running more of them would need a reverse proxy
|
||||
inside the container too. Scale by running multiple container replicas
|
||||
behind an external reverse proxy/load balancer instead (each replica also
|
||||
runs its own pair of `rqworker` processes, so worker capacity scales the
|
||||
same way).
|
||||
|
||||
## Deploying to Proxmox
|
||||
|
||||
Recent Proxmox VE versions can create an LXC container directly from an OCI
|
||||
image (`pct create <vmid> <image-ref> ...` with an OCI-format image
|
||||
reference, currently a tech-preview feature - check `pct create --help` and
|
||||
the Proxmox VE docs for your installed version, since the exact flags have
|
||||
been changing across releases). Practically that means:
|
||||
|
||||
1. Push the built image to a registry Proxmox can reach (a local registry,
|
||||
GHCR, Docker Hub, etc.) - `docker tag`/`docker push`.
|
||||
2. Create the container on the Proxmox host from that image reference,
|
||||
passing the same env vars as above.
|
||||
3. Map/forward port 8000 as needed, and put whatever TLS-terminating
|
||||
reverse proxy you already run in front of it.
|
||||
|
||||
If OCI-to-LXC isn't available on your Proxmox version, the same image runs
|
||||
fine in a plain VM (or an LXC) with any container runtime (Docker/Podman)
|
||||
installed instead.
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Container entrypoint: runs migrations (unless disabled), then execs
|
||||
# whatever CMD supplied (supervisord by default - see the Dockerfile).
|
||||
#
|
||||
# collectstatic is intentionally NOT run here: it pushes to the shared S3
|
||||
# bucket (see STORAGES in settings.py), so running it on every container
|
||||
# start would race across replicas for no benefit - run it once per deploy
|
||||
# instead:
|
||||
# docker run --rm --env-file <env file> polylan-submitter \
|
||||
# python manage.py collectstatic --noinput
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${MIGRATE_ON_START:-true}" = "true" ]; then
|
||||
echo "Running migrations..."
|
||||
python manage.py migrate --noinput
|
||||
fi
|
||||
|
||||
# Kick off the periodic Steam store refresh (games.tasks). No-op when a refresh
|
||||
# is already queued, so every replica can safely run it on start.
|
||||
python manage.py schedule_steam_refresh || echo "Could not schedule Steam store refresh"
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,44 @@
|
||||
# Template for the --env-file passed to `docker run` (see
|
||||
# deploy/docker/README.md). Copy this to deploy/docker/polylan-submitter.env,
|
||||
# fill in real values, and keep it out of git (already in .gitignore) and
|
||||
# world-readable perms (chmod 600) since it holds secrets.
|
||||
#
|
||||
# Docker reads this file itself (KEY=VALUE per line) before the container's
|
||||
# Python even starts - no python-dotenv, no $VAR/${VAR} expansion, no shell
|
||||
# involved. settings.py just reads the values from os.environ.
|
||||
#
|
||||
# Rotating a secret means editing this file and restarting the container
|
||||
# (`docker restart polylan-submitter` or replacing it, depending on how
|
||||
# you're running it) - values are read fresh on every process start.
|
||||
#
|
||||
# See settings.py for what each of these does and its dev-mode default.
|
||||
|
||||
DJANGO_SECRET_KEY=
|
||||
DJANGO_DEBUG=false
|
||||
DJANGO_ALLOWED_HOSTS=submitter.polylan.ch
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS=https://polylan-submitter.legrems.xyz
|
||||
DJANGO_BEHIND_TLS_PROXY=true
|
||||
|
||||
POSTGRES_DB=
|
||||
POSTGRES_USER=
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_HOST=
|
||||
POSTGRES_PORT=
|
||||
|
||||
REDIS_HOST=
|
||||
REDIS_PORT=
|
||||
PLACE_REDIS_URL=
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_S3_ENDPOINT_URL=
|
||||
AWS_STORAGE_BUCKET_NAME=
|
||||
|
||||
SENTRY_ENVIRONMENT=production
|
||||
SENTRY_DSN=
|
||||
|
||||
STEAM_API_KEY=
|
||||
STEAM_STORE_REFRESH_MINUTES=360
|
||||
STEAM_STORE_COUNTRY=ch
|
||||
AANDT_API_BASE_URL=
|
||||
SUBMITTER_BASE_URL=
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env sh
|
||||
# One-off build+push to the Gitea container registry for testing.
|
||||
# Usage: deploy/docker/push.sh [tag] (run from repo root, default tag: test)
|
||||
set -eu
|
||||
|
||||
REGISTRY=git.legrems.mom
|
||||
OWNER=polylan
|
||||
IMAGE=polylan-submitter
|
||||
TAG="${1:-test}"
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
REF="$REGISTRY/$OWNER/$IMAGE:$TAG"
|
||||
# buildx (BuildKit) is required: the Dockerfile uses RUN --mount=type=cache
|
||||
# for the uv dep cache, which the legacy builder rejects.
|
||||
docker buildx build --load -t "$REF" .
|
||||
docker push "$REF"
|
||||
|
||||
echo "Pushed $REF"
|
||||
@@ -0,0 +1,43 @@
|
||||
; Runs inside the single app container as the non-root "app" user (see
|
||||
; Dockerfile's USER app - no [supervisord] user= needed, we never start as
|
||||
; root here). This container runs ONE daphne process on TCP: scale by
|
||||
; running more container replicas behind an external reverse proxy/load
|
||||
; balancer instead of more processes per container.
|
||||
;
|
||||
; Program logs go to stdout/stderr (logfile_maxbytes=0 disables supervisor's
|
||||
; own log rotation for them) so `docker logs` / Proxmox's log viewer picks
|
||||
; them up - no log files inside the container to manage.
|
||||
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
pidfile=/tmp/supervisord.pid
|
||||
logfile=/dev/null
|
||||
logfile_maxbytes=0
|
||||
|
||||
[program:web]
|
||||
command=/app/.venv/bin/daphne -b 0.0.0.0 -p 8000 polylan_submitter.asgi:application
|
||||
directory=/app/polylan_submitter
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
stopsignal=TERM
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:worker]
|
||||
command=/app/.venv/bin/python manage.py rqworker default --job-class django_tasks_rq.Job --with-scheduler
|
||||
directory=/app/polylan_submitter
|
||||
process_name=%(program_name)s-%(process_num)02d
|
||||
numprocs=2
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
stopsignal=TERM
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
# Zero-downtime prod deploy: pull, sync deps, build front, migrate, then
|
||||
# gracefully reload gunicorn (see polylan-submitter.service, installed and
|
||||
# restarted instead when it changed) and restart the RQ worker (see
|
||||
# polylan-submitter-worker.conf).
|
||||
#
|
||||
# Run from anywhere as the user owning the checkout, needs sudo for
|
||||
# systemctl/supervisorctl (no-op when already root):
|
||||
# ./deploy/systemd/deploy.sh
|
||||
#
|
||||
# Migrations run while the old code still serves requests, so keep them
|
||||
# backward compatible (nullable/default columns, drop/rename in a later deploy).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE="${SERVICE:-polylan-submitter}"
|
||||
WORKER="${WORKER:-polylan-submitter-worker}"
|
||||
SUPERVISOR_CONF_DIR="${SUPERVISOR_CONF_DIR:-/etc/supervisor/conf.d}"
|
||||
WEB_URL="${WEB_URL:-https://polylan-submitter.legrems.xyz/}"
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
DEPLOY_DIR="$REPO_DIR/deploy/systemd"
|
||||
APP_DIR="$REPO_DIR/polylan_submitter"
|
||||
PYTHON="$REPO_DIR/.venv/bin/python"
|
||||
# Same as the unit's Environment=, so migrate/collectstatic see the prod settings.
|
||||
# Relative to $APP_DIR, where manage.py runs.
|
||||
export SETTINGS_ENV_FILE_LOCATION="${SETTINGS_ENV_FILE_LOCATION:-polylan_submitter/.env}"
|
||||
|
||||
# gunicorn's master doesn't reload itself on HUP, so a server upgrade needs a
|
||||
# full restart instead.
|
||||
server_versions() {
|
||||
"$PYTHON" - <<'EOF' 2>/dev/null || true
|
||||
import importlib.metadata as m
|
||||
for pkg in ("gunicorn", "uvicorn", "uvicorn-worker"):
|
||||
try:
|
||||
print(pkg, m.version(pkg))
|
||||
except m.PackageNotFoundError:
|
||||
print(pkg, "missing")
|
||||
EOF
|
||||
}
|
||||
|
||||
# wait_for <description> <command...>: retry for ~30s, fail the deploy if it
|
||||
# never succeeds.
|
||||
wait_for() {
|
||||
local what="$1"
|
||||
shift
|
||||
for _ in $(seq 1 30); do
|
||||
if "$@" >/dev/null 2>&1; then
|
||||
echo " $what: ok"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo " $what: FAILED" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
web_responds() {
|
||||
# 000 = nothing listening, 5xx = Caddy up but gunicorn down (502) or broken.
|
||||
local code
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' "$WEB_URL")"
|
||||
[ "$code" != "000" ] && [ "$code" -lt 500 ]
|
||||
}
|
||||
|
||||
worker_running() {
|
||||
sudo supervisorctl status "$WORKER" | grep -q RUNNING
|
||||
}
|
||||
|
||||
cd "$REPO_DIR"
|
||||
|
||||
echo "==> git pull"
|
||||
git pull --ff-only
|
||||
|
||||
before="$(server_versions)"
|
||||
|
||||
echo "==> uv sync"
|
||||
uv sync --frozen --no-dev
|
||||
|
||||
after="$(server_versions)"
|
||||
|
||||
echo "==> frontend build"
|
||||
cd "$APP_DIR"
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
echo "==> migrate"
|
||||
"$PYTHON" manage.py migrate --noinput
|
||||
|
||||
echo "==> collectstatic"
|
||||
"$PYTHON" manage.py collectstatic --noinput
|
||||
|
||||
echo "==> $SERVICE (systemd)"
|
||||
unit_changed=false
|
||||
if ! cmp -s "$DEPLOY_DIR/$SERVICE.service" "/etc/systemd/system/$SERVICE.service"; then
|
||||
sudo install -m 644 "$DEPLOY_DIR/$SERVICE.service" "/etc/systemd/system/$SERVICE.service"
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable "$SERVICE"
|
||||
unit_changed=true
|
||||
fi
|
||||
|
||||
# HUP re-forks workers with the master's original args, so a new ExecStart
|
||||
# (unit change) or a new gunicorn/uvicorn needs a full restart.
|
||||
if [ "$unit_changed" = true ]; then
|
||||
echo "==> unit file changed, full restart of $SERVICE"
|
||||
sudo systemctl restart "$SERVICE"
|
||||
elif [ "$before" != "$after" ]; then
|
||||
echo "==> server packages changed, full restart of $SERVICE"
|
||||
sudo systemctl restart "$SERVICE"
|
||||
else
|
||||
echo "==> graceful reload of $SERVICE"
|
||||
sudo systemctl reload "$SERVICE"
|
||||
fi
|
||||
wait_for "$SERVICE active" systemctl is-active --quiet "$SERVICE"
|
||||
wait_for "$SERVICE responds on $WEB_URL" web_responds
|
||||
|
||||
echo "==> $WORKER (supervisor)"
|
||||
sudo install -m 644 "$DEPLOY_DIR/$WORKER.conf" "$SUPERVISOR_CONF_DIR/$WORKER.conf"
|
||||
# reread+update picks up config changes (and starts it on first install),
|
||||
# restart loads the new code - TERM is a warm shutdown, the running job ends first.
|
||||
sudo supervisorctl reread
|
||||
sudo supervisorctl update "$WORKER"
|
||||
sudo supervisorctl restart "$WORKER"
|
||||
wait_for "$WORKER running" worker_running
|
||||
|
||||
echo "==> done"
|
||||
@@ -0,0 +1,24 @@
|
||||
; Prod RQ worker (submission OCR, AAndT sync, Steam refresh...), run by the
|
||||
; host's supervisord next to the polylan-submitter systemd unit (web).
|
||||
;
|
||||
; Installed to /etc/supervisor/conf.d/ by deploy/systemd/deploy.sh, which also
|
||||
; restarts it on every deploy so it picks up the new code.
|
||||
;
|
||||
; Logs: supervisorctl tail -f polylan-submitter-worker
|
||||
|
||||
[program:polylan-submitter-worker]
|
||||
command=/home/caddy/polylan-submitter/.venv/bin/python manage.py rqworker default --job-class django_tasks_rq.Job --with-scheduler
|
||||
directory=/home/caddy/polylan-submitter/polylan_submitter
|
||||
; Relative to directory, same as the systemd unit.
|
||||
environment=SETTINGS_ENV_FILE_LOCATION="polylan_submitter/.env",PYTHONUNBUFFERED="1"
|
||||
user=root
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=5
|
||||
; rqworker does a warm shutdown on TERM: it finishes the current job (OCR can
|
||||
; take a while) before exiting, only SIGKILLed after stopwaitsecs.
|
||||
stopsignal=TERM
|
||||
stopwaitsecs=120
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
redirect_stderr=true
|
||||
@@ -0,0 +1,39 @@
|
||||
# Prod web process: gunicorn master + uvicorn ASGI workers, behind Caddy.
|
||||
#
|
||||
# `systemctl reload polylan-submitter` sends HUP to the gunicorn master: it
|
||||
# keeps the :7777 socket open, boots new workers on the new code, then
|
||||
# gracefully stops the old ones - no downtime. See deploy/systemd/deploy.sh.
|
||||
#
|
||||
# Installed to /etc/systemd/system/ by deploy.sh: when this file changed it
|
||||
# does daemon-reload + a full restart (HUP keeps the old ExecStart args).
|
||||
|
||||
[Unit]
|
||||
Description=PolyLAN submitter (gunicorn + uvicorn workers)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=always
|
||||
RestartSec=1
|
||||
User=root
|
||||
WorkingDirectory=/home/caddy/polylan-submitter/polylan_submitter
|
||||
# Relative to WorkingDirectory, loaded by settings.py via python-dotenv.
|
||||
Environment="SETTINGS_ENV_FILE_LOCATION=polylan_submitter/.env"
|
||||
|
||||
# No --preload: with it, HUP re-forks from the already imported (old) code.
|
||||
# --forwarded-allow-ips replaces daphne's --proxy-headers: trust
|
||||
# X-Forwarded-* only from Caddy (local here).
|
||||
ExecStart=/home/caddy/polylan-submitter/.venv/bin/gunicorn polylan_submitter.asgi:application \
|
||||
--worker-class uvicorn_worker.UvicornWorker \
|
||||
--workers 2 \
|
||||
--bind 0.0.0.0:7777 \
|
||||
--graceful-timeout 20 \
|
||||
--forwarded-allow-ips 127.0.0.1
|
||||
ExecReload=/bin/kill -s HUP $MAINPID
|
||||
|
||||
# SIGTERM to the master only, it then drains its workers (graceful-timeout).
|
||||
KillMode=mixed
|
||||
TimeoutStopSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -12,6 +12,18 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Vite build output (generated locally via `pnpm build`, served by whitenoise)
|
||||
static_source/vite/assets
|
||||
static_source/vite/manifest.json
|
||||
static_source/vite/icon.png
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
# vue-tsc emits a compiled copy of vite.config.ts here (tsconfig.node.json,
|
||||
# composite project ref) — Vite's config lookup prefers .js over .ts, so a
|
||||
# committed copy silently shadows every edit to the real config.
|
||||
/vite.config.js
|
||||
/vite.config.d.ts
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django_object_actions import DjangoObjectActions, action
|
||||
|
||||
from .models import CustomUser
|
||||
|
||||
# Must match loadtest/locustfile.py's own defaults (LOAD_TEST_USER_COUNT,
|
||||
# LOAD_TEST_USER_PREFIX, LOAD_TEST_PASSWORD) - see that file for what these
|
||||
# accounts are for and how they're used.
|
||||
LOAD_TEST_USER_COUNT = 500
|
||||
LOAD_TEST_USER_PREFIX = "loadtest"
|
||||
LOAD_TEST_PASSWORD = "loadtest-only-password"
|
||||
|
||||
|
||||
@admin.register(CustomUser)
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
class CustomUserAdmin(DjangoObjectActions, UserAdmin):
|
||||
"""Admin interface for CustomUser."""
|
||||
|
||||
# Add custom fields to the user admin
|
||||
@@ -29,8 +39,44 @@ class CustomUserAdmin(UserAdmin):
|
||||
# Make CAS fields readonly in admin
|
||||
readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes")
|
||||
|
||||
changelist_actions = ("create_load_test_users",)
|
||||
|
||||
def get_cas_groups_display(self, obj):
|
||||
"""Display CAS groups in admin list."""
|
||||
return obj.get_cas_groups_display()
|
||||
|
||||
get_cas_groups_display.short_description = "CAS Groups"
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def get_changelist_actions(self, request):
|
||||
# Second layer of the same guard rail as the login endpoint itself
|
||||
# (see testing_auth_views.py): the button doesn't even show up
|
||||
# unless this deployment opted into load testing.
|
||||
if not settings.LOAD_TEST_LOGIN_ENABLED:
|
||||
return []
|
||||
return super().get_changelist_actions(request)
|
||||
|
||||
@action(
|
||||
label="Create load test users",
|
||||
description=(
|
||||
"Create/reset the local loadtestN accounts used by "
|
||||
"loadtest/locustfile.py - authenticates via ModelBackend, "
|
||||
"never the real CAS server"
|
||||
),
|
||||
)
|
||||
def create_load_test_users(self, request, _queryset):
|
||||
created = 0
|
||||
for i in range(LOAD_TEST_USER_COUNT):
|
||||
username = f"{LOAD_TEST_USER_PREFIX}{i}"
|
||||
user, was_created = CustomUser.objects.get_or_create(username=username)
|
||||
user.set_password(LOAD_TEST_PASSWORD)
|
||||
user.save()
|
||||
created += was_created
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f"{created} created, {LOAD_TEST_USER_COUNT - created} already existed "
|
||||
f"(password reset on all {LOAD_TEST_USER_COUNT}).",
|
||||
)
|
||||
|
||||
@@ -4,3 +4,23 @@ from django.apps import AppConfig
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "accounts"
|
||||
|
||||
def ready(self):
|
||||
# django-admin-interface's Theme.logo/favicon are plain FileFields,
|
||||
# so without this they'd resolve through STORAGES["default"] (S3 -
|
||||
# see settings.py) like every submission/media upload. The theme's
|
||||
# logo/favicon (see accounts/fixtures/admin_interface_theme.json)
|
||||
# are actually a static asset (static/favicon.ico, built from
|
||||
# public/favicon.ico by Vite - see vite.config.ts), so point those
|
||||
# two fields at STATIC_ROOT/STATIC_URL instead, served by WhiteNoise
|
||||
# like any other static file.
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import FileSystemStorage
|
||||
|
||||
from admin_interface.models import Theme
|
||||
|
||||
static_storage = FileSystemStorage(
|
||||
location=settings.STATIC_ROOT, base_url=settings.STATIC_URL
|
||||
)
|
||||
Theme._meta.get_field("logo").storage = static_storage
|
||||
Theme._meta.get_field("favicon").storage = static_storage
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
[
|
||||
{
|
||||
"model": "admin_interface.theme",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"name": "Django Purple (by Legrems)",
|
||||
"active": true,
|
||||
"title": "Super administrateur de poney ",
|
||||
"title_color": "#E5B8FF",
|
||||
"title_visible": true,
|
||||
"logo": "favicon.ico",
|
||||
"logo_color": "#831a96",
|
||||
"logo_max_width": 400,
|
||||
"logo_max_height": 50,
|
||||
"logo_visible": true,
|
||||
"favicon": "favicon.ico",
|
||||
"env_name": "",
|
||||
"env_color": "#831a96",
|
||||
"env_visible_in_header": true,
|
||||
"env_visible_in_favicon": true,
|
||||
"language_chooser_active": true,
|
||||
"language_chooser_control": "default-select",
|
||||
"language_chooser_display": "code",
|
||||
"css_header_background_color": "#2d0840",
|
||||
"css_header_text_color": "#831a96",
|
||||
"css_header_link_color": "#FFFFFF",
|
||||
"css_header_link_hover_color": "#a82bb8",
|
||||
"css_module_background_color": "#6b0f7d",
|
||||
"css_module_background_selected_color": "#831a96",
|
||||
"css_module_text_color": "#FFFFFF",
|
||||
"css_module_link_color": "#FFFFFF",
|
||||
"css_module_link_selected_color": "#FFFFFF",
|
||||
"css_module_link_hover_color": "#a82bb8",
|
||||
"css_module_rounded_corners": true,
|
||||
"css_generic_link_color": "#2d0840",
|
||||
"css_generic_link_hover_color": "#5a0d6b",
|
||||
"css_save_button_background_color": "#2d0840",
|
||||
"css_save_button_background_hover_color": "#5a0d6b",
|
||||
"css_save_button_text_color": "#FFFFFF",
|
||||
"css_delete_button_background_color": "#a82bb8",
|
||||
"css_delete_button_background_hover_color": "#831a96",
|
||||
"css_delete_button_text_color": "#FFFFFF",
|
||||
"related_modal_active": true,
|
||||
"related_modal_background_color": "#000000",
|
||||
"related_modal_background_opacity": "0.3",
|
||||
"related_modal_rounded_corners": true,
|
||||
"related_modal_close_button_visible": true,
|
||||
"list_filter_highlight": true,
|
||||
"list_filter_dropdown": true,
|
||||
"list_filter_sticky": true,
|
||||
"list_filter_removal_links": false,
|
||||
"foldable_apps": true,
|
||||
"show_fieldsets_as_tabs": false,
|
||||
"show_inlines_as_tabs": false,
|
||||
"recent_actions_visible": true,
|
||||
"form_submit_sticky": false,
|
||||
"form_pagination_sticky": false
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-23 18:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="customuser",
|
||||
name="points",
|
||||
field=models.IntegerField(default=1000),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -14,6 +14,9 @@ class CustomUser(AbstractUser):
|
||||
# Additional fields that might come from CAS
|
||||
cas_attributes = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Market points balance
|
||||
points = models.IntegerField(default=1000)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} ({self.cas_user_id})"
|
||||
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
from collections import defaultdict
|
||||
from django.http import HttpRequest
|
||||
from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
from django.core.cache import cache
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from accounts.models import CustomUser
|
||||
from animations.schemas import RankingSchema
|
||||
from submissions.models import PuzzleResponse, SteamCollectionItem
|
||||
from animations.schemas import (
|
||||
RankingSchema,
|
||||
TournamentSubmissionsOut,
|
||||
TournamentPuzzleResultsOut,
|
||||
PuzzleSubmissionsOut,
|
||||
PuzzleResultsOut,
|
||||
PuzzleSubmissionWithRankOut,
|
||||
PuzzlePointsFactorOut,
|
||||
WinnerResponseOut,
|
||||
WinnerFileOut,
|
||||
)
|
||||
from opus_magnum.models import PuzzleResponse, SteamCollectionItem, SteamCollection
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
PREFIX = "/events/{event_path}/results"
|
||||
|
||||
@router.get("results", response=RankingSchema)
|
||||
def results(request: HttpRequest) -> dict:
|
||||
cache_key = "api:results:results"
|
||||
|
||||
@router.get(f"{PREFIX}/results", response=RankingSchema)
|
||||
def results(request: HttpRequest, event_path: str) -> dict:
|
||||
cache_key = f"api:{event_path}:results:results"
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if cached_data is not None:
|
||||
@@ -23,7 +37,9 @@ def results(request: HttpRequest) -> dict:
|
||||
responses_by_puzzleid = defaultdict(list)
|
||||
|
||||
for response in list(
|
||||
PuzzleResponse.objects.filter(needs_manual_validation=False)
|
||||
PuzzleResponse.objects.filter(
|
||||
needs_manual_validation=False, submission__event=request.event
|
||||
)
|
||||
.filter_user_best_response()
|
||||
.prefetch_related("submission__user")
|
||||
):
|
||||
@@ -39,10 +55,193 @@ def results(request: HttpRequest) -> dict:
|
||||
|
||||
data = {
|
||||
"users": list(CustomUser.objects.filter(pk__in=responses_by_userid.keys())),
|
||||
"puzzles": list(SteamCollectionItem.objects.all()),
|
||||
"puzzles": list(
|
||||
SteamCollectionItem.objects.filter(collection__event=request.event)
|
||||
),
|
||||
"responses_by_userid": dict(responses_by_userid),
|
||||
"ranking_by_puzzle": ranking,
|
||||
}
|
||||
|
||||
cache.set("api:results:results", data, 300)
|
||||
cache.set(cache_key, data, 300)
|
||||
return data
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/top-submissions", response=TournamentSubmissionsOut)
|
||||
def top_submissions(request: HttpRequest, event_path: str, limit: int = 5) -> dict:
|
||||
"""Get tournament top submissions for each puzzle. Only available when tournament is closed."""
|
||||
cache_key = f"api:{event_path}:results:top_submissions:{limit}"
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if cached_data is not None:
|
||||
return cached_data
|
||||
|
||||
collection = get_object_or_404(SteamCollection, is_active=True, event=request.event)
|
||||
|
||||
# Only allow access when tournament is closed
|
||||
if collection.accepting_submissions:
|
||||
raise HttpError(403, "Tournament is still accepting submissions")
|
||||
|
||||
# Get all puzzles
|
||||
puzzles = SteamCollectionItem.objects.filter(collection=collection).order_by(
|
||||
"order_index"
|
||||
)
|
||||
|
||||
# Build response
|
||||
submissions_list = []
|
||||
for puzzle in puzzles:
|
||||
# Get the top N responses for this puzzle (ranked by points, highest first)
|
||||
top_responses = (
|
||||
PuzzleResponse.objects.filter(puzzle=puzzle, needs_manual_validation=False)
|
||||
.filter_user_best_response()
|
||||
.annotate_rank_points()
|
||||
.order_by("-rank_points")[:limit]
|
||||
)
|
||||
|
||||
# Build submission list for this puzzle
|
||||
puzzle_submissions = []
|
||||
for response in top_responses:
|
||||
# Get submission files
|
||||
files = response.files.all()
|
||||
response_files = [
|
||||
WinnerFileOut(
|
||||
file_url=file.file_url or "",
|
||||
original_filename=file.original_filename,
|
||||
)
|
||||
for file in files
|
||||
]
|
||||
|
||||
# Calculate total coefficient
|
||||
total_coef = None
|
||||
if (
|
||||
puzzle.points_factor
|
||||
and response.final_cost is not None
|
||||
and response.final_cycles is not None
|
||||
and response.final_area is not None
|
||||
):
|
||||
total_coef = (
|
||||
puzzle.points_factor.cost * response.final_cost
|
||||
+ puzzle.points_factor.cycles * response.final_cycles
|
||||
+ puzzle.points_factor.area * response.final_area
|
||||
)
|
||||
|
||||
submission_data = WinnerResponseOut(
|
||||
user_id=response.submission.user.id if response.submission.user else 0,
|
||||
username=response.submission.user.username
|
||||
if response.submission.user
|
||||
else "Anonymous",
|
||||
final_cost=response.final_cost,
|
||||
final_cycles=response.final_cycles,
|
||||
final_area=response.final_area,
|
||||
rank_points=response.rank_points,
|
||||
total_coef=total_coef,
|
||||
files=response_files,
|
||||
)
|
||||
puzzle_submissions.append(submission_data)
|
||||
|
||||
submissions_list.append(
|
||||
PuzzleSubmissionsOut(
|
||||
puzzle_id=puzzle.id,
|
||||
puzzle_title=puzzle.title,
|
||||
submissions=puzzle_submissions,
|
||||
)
|
||||
)
|
||||
|
||||
data = {"submissions": submissions_list}
|
||||
cache.set(cache_key, data, 300)
|
||||
return data
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/puzzle-results", response=TournamentPuzzleResultsOut)
|
||||
def puzzle_results(request: HttpRequest, event_path: str, limit: int = 5) -> dict:
|
||||
"""Get tournament results organized by puzzle with coefficients. Only available when tournament is closed."""
|
||||
cache_key = f"api:{event_path}:results:puzzle_results:{limit}"
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if cached_data is not None:
|
||||
return cached_data
|
||||
|
||||
collection = get_object_or_404(SteamCollection, is_active=True, event=request.event)
|
||||
|
||||
# Only allow access when tournament is closed
|
||||
if collection.accepting_submissions:
|
||||
raise HttpError(403, "Tournament is still accepting submissions")
|
||||
|
||||
# Get all puzzles
|
||||
puzzles = SteamCollectionItem.objects.filter(collection=collection).order_by(
|
||||
"order_index"
|
||||
)
|
||||
|
||||
# Build response
|
||||
results_list = []
|
||||
for puzzle in puzzles:
|
||||
# Get the top N responses for this puzzle (ranked by points)
|
||||
top_responses = (
|
||||
PuzzleResponse.objects.filter(puzzle=puzzle, needs_manual_validation=False)
|
||||
.filter_user_best_response()
|
||||
.annotate_rank_points()
|
||||
.order_by("-rank_points")[:limit]
|
||||
)
|
||||
|
||||
# Build submission list for this puzzle with rank
|
||||
puzzle_submissions = []
|
||||
for rank, response in enumerate(top_responses, 1):
|
||||
# Get submission files
|
||||
files = response.files.all()
|
||||
response_files = [
|
||||
WinnerFileOut(
|
||||
file_url=file.file_url or "",
|
||||
original_filename=file.original_filename,
|
||||
)
|
||||
for file in files
|
||||
]
|
||||
|
||||
# Calculate total coefficient
|
||||
total_coef = None
|
||||
if (
|
||||
puzzle.points_factor
|
||||
and response.final_cost is not None
|
||||
and response.final_cycles is not None
|
||||
and response.final_area is not None
|
||||
):
|
||||
total_coef = (
|
||||
puzzle.points_factor.cost * response.final_cost
|
||||
+ puzzle.points_factor.cycles * response.final_cycles
|
||||
+ puzzle.points_factor.area * response.final_area
|
||||
)
|
||||
|
||||
submission_data = PuzzleSubmissionWithRankOut(
|
||||
rank=rank,
|
||||
user_id=response.submission.user.id if response.submission.user else 0,
|
||||
username=response.submission.user.username
|
||||
if response.submission.user
|
||||
else "Anonymous",
|
||||
final_cost=response.final_cost,
|
||||
final_cycles=response.final_cycles,
|
||||
final_area=response.final_area,
|
||||
rank_points=response.rank_points,
|
||||
total_coef=total_coef,
|
||||
files=response_files,
|
||||
)
|
||||
puzzle_submissions.append(submission_data)
|
||||
|
||||
# Get points factor if available
|
||||
points_factor = None
|
||||
if puzzle.points_factor:
|
||||
points_factor = PuzzlePointsFactorOut(
|
||||
cost=puzzle.points_factor.cost,
|
||||
cycles=puzzle.points_factor.cycles,
|
||||
area=puzzle.points_factor.area,
|
||||
)
|
||||
|
||||
results_list.append(
|
||||
PuzzleResultsOut(
|
||||
puzzle_id=puzzle.id,
|
||||
puzzle_title=puzzle.title,
|
||||
points_factor=points_factor,
|
||||
submissions=puzzle_submissions,
|
||||
)
|
||||
)
|
||||
|
||||
data = {"results": results_list}
|
||||
cache.set(cache_key, data, 300)
|
||||
return data
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from ninja import ModelSchema, Schema
|
||||
from typing import List, Optional
|
||||
|
||||
from submissions.models import PuzzleResponse
|
||||
from submissions.schemas import SteamCollectionItemOut
|
||||
from opus_magnum.models import PuzzleResponse
|
||||
from opus_magnum.schemas import SteamCollectionItemOut
|
||||
|
||||
|
||||
class PuzzleResponseRankingOut(ModelSchema):
|
||||
@@ -41,3 +42,74 @@ class RankingSchema(Schema):
|
||||
puzzles: list[SteamCollectionItemOut]
|
||||
responses_by_userid: dict[int, list[PuzzleResponseRankingOut]]
|
||||
ranking_by_puzzle: dict[int, list[PuzzleResponseRankingOut]]
|
||||
|
||||
|
||||
class WinnerFileOut(Schema):
|
||||
"""Schema for winner submission file"""
|
||||
|
||||
file_url: str
|
||||
original_filename: str
|
||||
|
||||
|
||||
class WinnerResponseOut(Schema):
|
||||
"""Schema for winner response with files"""
|
||||
|
||||
user_id: int
|
||||
username: str
|
||||
final_cost: Optional[int]
|
||||
final_cycles: Optional[int]
|
||||
final_area: Optional[int]
|
||||
rank_points: Optional[int]
|
||||
total_coef: Optional[int]
|
||||
files: List[WinnerFileOut]
|
||||
|
||||
|
||||
class PuzzleSubmissionsOut(Schema):
|
||||
"""Schema for puzzle with all top submissions"""
|
||||
|
||||
puzzle_id: int
|
||||
puzzle_title: str
|
||||
submissions: List[WinnerResponseOut]
|
||||
|
||||
|
||||
class TournamentSubmissionsOut(Schema):
|
||||
"""Schema for tournament top submissions results"""
|
||||
|
||||
submissions: List[PuzzleSubmissionsOut]
|
||||
|
||||
|
||||
class PuzzlePointsFactorOut(Schema):
|
||||
"""Schema for puzzle points factor"""
|
||||
|
||||
cost: int
|
||||
cycles: int
|
||||
area: int
|
||||
|
||||
|
||||
class PuzzleSubmissionWithRankOut(Schema):
|
||||
"""Schema for puzzle submission with rank"""
|
||||
|
||||
rank: int
|
||||
user_id: int
|
||||
username: str
|
||||
final_cost: Optional[int]
|
||||
final_cycles: Optional[int]
|
||||
final_area: Optional[int]
|
||||
rank_points: Optional[int]
|
||||
total_coef: Optional[int]
|
||||
files: List[WinnerFileOut]
|
||||
|
||||
|
||||
class PuzzleResultsOut(Schema):
|
||||
"""Schema for puzzle-specific results with coefficients"""
|
||||
|
||||
puzzle_id: int
|
||||
puzzle_title: str
|
||||
points_factor: Optional[PuzzlePointsFactorOut]
|
||||
submissions: List[PuzzleSubmissionWithRankOut]
|
||||
|
||||
|
||||
class TournamentPuzzleResultsOut(Schema):
|
||||
"""Schema for tournament puzzle-specific results"""
|
||||
|
||||
results: List[PuzzleResultsOut]
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Event
|
||||
|
||||
|
||||
@admin.register(Event)
|
||||
class EventAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "path", "is_available", "created_at"]
|
||||
list_editable = ["is_available"]
|
||||
list_filter = ["is_available"]
|
||||
search_fields = ["name", "path"]
|
||||
prepopulated_fields = {"path": ("name",)}
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class EventsConfig(AppConfig):
|
||||
name = "events"
|
||||
@@ -0,0 +1,30 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from .models import Event
|
||||
from .scoping import current_event
|
||||
|
||||
|
||||
class EventContextMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
try:
|
||||
return self.get_response(request)
|
||||
finally:
|
||||
# Not current_event.reset(token): under ASGI, process_view and this
|
||||
# finally block can run in different contextvars.Context copies
|
||||
# (sync_to_async(thread_sensitive=True) copies the context), and
|
||||
# Token.reset() requires the exact Context it was created in.
|
||||
# set(None) has no such constraint.
|
||||
current_event.set(None)
|
||||
|
||||
def process_view(self, request, view_func, view_args, view_kwargs):
|
||||
event_path = view_kwargs.get("event_path")
|
||||
if event_path:
|
||||
request.event = get_object_or_404(
|
||||
Event.objects.available_to(getattr(request, "user", None)),
|
||||
path=event_path,
|
||||
)
|
||||
current_event.set(request.event)
|
||||
return None
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 6.0.5 on 2026-09-15 21:08
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Event",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("path", models.SlugField(max_length=100, unique=True)),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
from django.db import migrations
|
||||
|
||||
EVENT_NAME = "PolyLAN 43"
|
||||
EVENT_PATH = "polylan-43"
|
||||
|
||||
|
||||
def seed_event(apps, schema_editor):
|
||||
Event = apps.get_model("events", "Event")
|
||||
Event.objects.get_or_create(path=EVENT_PATH, defaults={"name": EVENT_NAME})
|
||||
|
||||
|
||||
def unseed_event(apps, schema_editor):
|
||||
Event = apps.get_model("events", "Event")
|
||||
Event.objects.filter(path=EVENT_PATH).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("events", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed_event, unseed_event),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.5 on 2026-10-15 22:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("events", "0002_seed_polylan_43"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="event",
|
||||
name="is_available",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
from django.db import models
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class EventQuerySet(models.QuerySet):
|
||||
def available_to(self, user):
|
||||
"""Unavailable events are hidden from everyone but staff, so they can
|
||||
still be prepared before being opened up."""
|
||||
if user is not None and user.is_staff:
|
||||
return self
|
||||
return self.filter(is_available=True)
|
||||
|
||||
|
||||
class Event(BaseModel):
|
||||
name = models.CharField(max_length=255)
|
||||
path = models.SlugField(max_length=100, unique=True)
|
||||
is_available = models.BooleanField(default=True)
|
||||
|
||||
objects = EventQuerySet.as_manager()
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
@@ -0,0 +1,21 @@
|
||||
import contextvars
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import models
|
||||
|
||||
current_event = contextvars.ContextVar("current_event", default=None)
|
||||
|
||||
|
||||
class EventScopedQuerySet(models.QuerySet):
|
||||
def for_current_event(self):
|
||||
event = current_event.get()
|
||||
if event is None:
|
||||
raise ImproperlyConfigured(
|
||||
f"{self.model.__name__}.for_event was used outside an event-scoped request"
|
||||
)
|
||||
return self.filter(event=event)
|
||||
|
||||
|
||||
class EventScopedManager(models.Manager.from_queryset(EventScopedQuerySet)):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().for_current_event()
|
||||
@@ -0,0 +1,74 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.test import TestCase
|
||||
|
||||
from games.models import Game
|
||||
from submissions.models import Submission
|
||||
|
||||
from .models import Event
|
||||
from .scoping import current_event
|
||||
|
||||
|
||||
class EventScopedManagerTests(TestCase):
|
||||
def setUp(self):
|
||||
self.event_a = Event.objects.create(name="Event A", path="event-a")
|
||||
self.event_b = Event.objects.create(name="Event B", path="event-b")
|
||||
self.game = Game.objects.create(name="Test Game", path="test-game")
|
||||
self.submission_a = Submission.objects.create(
|
||||
event=self.event_a, game=self.game
|
||||
)
|
||||
self.submission_b = Submission.objects.create(
|
||||
event=self.event_b, game=self.game
|
||||
)
|
||||
|
||||
def test_for_event_raises_outside_event_context(self):
|
||||
with self.assertRaises(ImproperlyConfigured):
|
||||
list(Submission.for_event.all())
|
||||
|
||||
def test_for_event_filters_to_current_event(self):
|
||||
token = current_event.set(self.event_a)
|
||||
try:
|
||||
results = list(Submission.for_event.all())
|
||||
finally:
|
||||
current_event.reset(token)
|
||||
|
||||
self.assertEqual(results, [self.submission_a])
|
||||
|
||||
|
||||
class EventAvailabilityTests(TestCase):
|
||||
def setUp(self):
|
||||
from accounts.models import CustomUser
|
||||
|
||||
self.hidden = Event.objects.create(
|
||||
name="Hidden", path="hidden", is_available=False
|
||||
)
|
||||
self.user = CustomUser.objects.create_user(username="user", password="x")
|
||||
self.staff = CustomUser.objects.create_user(
|
||||
username="staff", password="x", is_staff=True
|
||||
)
|
||||
|
||||
def test_unavailable_event_is_404_for_regular_user(self):
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(f"/{self.hidden.path}/")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_unavailable_event_is_visible_to_staff(self):
|
||||
self.client.force_login(self.staff)
|
||||
response = self.client.get(f"/{self.hidden.path}/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_available_to_filters_for_regular_user_only(self):
|
||||
self.assertNotIn(self.hidden, Event.objects.available_to(self.user))
|
||||
self.assertIn(self.hidden, Event.objects.available_to(self.staff))
|
||||
|
||||
def test_home_prefers_available_event_for_staff(self):
|
||||
available = Event.objects.create(name="Open", path="open")
|
||||
Event.objects.filter(pk=self.hidden.pk).update(
|
||||
created_at=available.created_at + timedelta(days=1)
|
||||
)
|
||||
self.client.force_login(self.staff)
|
||||
response = self.client.get("/")
|
||||
self.assertRedirects(
|
||||
response, f"/{available.path}/", fetch_redirect_response=False
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
# Create your views here.
|
||||
@@ -0,0 +1,121 @@
|
||||
from django.contrib import admin, messages
|
||||
from django_object_actions import (
|
||||
DjangoObjectActions,
|
||||
action,
|
||||
takes_instance_or_queryset,
|
||||
)
|
||||
|
||||
from submissions.tasks import schedule_scope_sync, stop_scope_sync
|
||||
|
||||
from .models import EventGame, Game, PlanningSlot
|
||||
from .tasks import refresh_steam_games_task
|
||||
|
||||
|
||||
class EventGameInline(admin.TabularInline):
|
||||
model = EventGame
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(Game)
|
||||
class GameAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "steam_app_id", "updated_at"]
|
||||
search_fields = ["name", "steam_app_id"]
|
||||
readonly_fields = [
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"steam_price_initial",
|
||||
"steam_price_final",
|
||||
"steam_discount_percent",
|
||||
"steam_currency",
|
||||
"steam_is_free",
|
||||
"steam_short_description",
|
||||
"steam_header_image",
|
||||
"steam_info_updated_at",
|
||||
]
|
||||
inlines = [EventGameInline]
|
||||
actions = ["refresh_steam_info"]
|
||||
|
||||
@admin.action(description="Refresh Steam store info")
|
||||
def refresh_steam_info(self, request, queryset):
|
||||
game_ids = list(
|
||||
queryset.filter(steam_app_id__isnull=False).values_list("id", flat=True)
|
||||
)
|
||||
refresh_steam_games_task.enqueue(game_ids=game_ids)
|
||||
self.message_user(
|
||||
request, f"Queued Steam store refresh for {len(game_ids)} game(s)."
|
||||
)
|
||||
|
||||
|
||||
@admin.register(EventGame)
|
||||
class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
list_display = ["game", "event", "enabled", "has_apibot_token"]
|
||||
list_filter = ["event", "enabled"]
|
||||
search_fields = ["game__name", "event__name"]
|
||||
actions = ["enable", "disable", "sync_scopes", "stop_scope_sync"]
|
||||
change_actions = ["sync_scopes", "stop_scope_sync"]
|
||||
|
||||
@admin.display(description="AAndT token", boolean=True)
|
||||
def has_apibot_token(self, obj):
|
||||
return bool(obj.apibot_token)
|
||||
|
||||
def _set_enabled(self, request, queryset, enabled: bool):
|
||||
# save() per row (not update()) so post_save fires, e.g. the clock heartbeat
|
||||
changed = 0
|
||||
for event_game in queryset.select_related("game").filter(enabled=not enabled):
|
||||
event_game.enabled = enabled
|
||||
event_game.save(update_fields=["enabled"])
|
||||
changed += 1
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f"{'Enabled' if enabled else 'Disabled'} {changed} EventGame(s)"
|
||||
f" ({queryset.count() - changed} already {'enabled' if enabled else 'disabled'}).",
|
||||
)
|
||||
|
||||
@admin.action(description="Enable selected EventGames")
|
||||
def enable(self, request, queryset):
|
||||
self._set_enabled(request, queryset, enabled=True)
|
||||
|
||||
@admin.action(description="Disable selected EventGames")
|
||||
def disable(self, request, queryset):
|
||||
self._set_enabled(request, queryset, enabled=False)
|
||||
|
||||
@action(
|
||||
label="Sync scopes",
|
||||
description="Sync scopes from AAndT for selected EventGames",
|
||||
)
|
||||
@takes_instance_or_queryset
|
||||
def sync_scopes(self, request, queryset):
|
||||
tokened = queryset.exclude(apibot_token__isnull=True).exclude(apibot_token="")
|
||||
skipped = queryset.count() - tokened.count()
|
||||
|
||||
started = sum(schedule_scope_sync(event_game.id) for event_game in tokened)
|
||||
|
||||
self.message_user(
|
||||
request,
|
||||
f"Started scope sync for {started} EventGame(s)"
|
||||
f" ({tokened.count() - started} already running).",
|
||||
)
|
||||
if skipped:
|
||||
self.message_user(
|
||||
request,
|
||||
f"Skipped {skipped} EventGame(s) with no AAndT token configured.",
|
||||
level=messages.WARNING,
|
||||
)
|
||||
|
||||
@action(
|
||||
label="Stop scope sync",
|
||||
description="Stop the scope sync loop of selected EventGames",
|
||||
)
|
||||
@takes_instance_or_queryset
|
||||
def stop_scope_sync(self, request, queryset):
|
||||
stopped = sum(stop_scope_sync(event_game.id) for event_game in queryset)
|
||||
|
||||
self.message_user(request, f"Stopped scope sync for {stopped} EventGame(s).")
|
||||
|
||||
|
||||
@admin.register(PlanningSlot)
|
||||
class PlanningSlotAdmin(admin.ModelAdmin):
|
||||
list_display = ["title", "event", "begin", "end", "column", "is_multi_anims"]
|
||||
list_filter = ["event", "is_multi_anims"]
|
||||
search_fields = ["title"]
|
||||
@@ -0,0 +1,37 @@
|
||||
from typing import List
|
||||
|
||||
from ninja import Router
|
||||
|
||||
from .models import Game
|
||||
from .schemas import GameOut, PlanningSlotOut, SteamGameOut
|
||||
from .services.planning import planning_with_games
|
||||
from .tasks import schedule_steam_refresh
|
||||
|
||||
router = Router()
|
||||
event_router = Router()
|
||||
|
||||
|
||||
@router.get("", response=List[GameOut])
|
||||
def list_games(request):
|
||||
return Game.objects.all()
|
||||
|
||||
|
||||
@event_router.get("/events/{event_path}/games", response=List[GameOut])
|
||||
def list_event_games(request, event_path: str):
|
||||
return Game.objects.enabled_for_event(request.event)
|
||||
|
||||
|
||||
@event_router.get("/events/{event_path}/games/steam", response=List[SteamGameOut])
|
||||
def list_event_steam_games(request, event_path: str):
|
||||
"""The event's Steam games with their store info (price, discount, blurb)."""
|
||||
# Safety net in case the periodic refresh loop isn't running (no-op otherwise).
|
||||
schedule_steam_refresh()
|
||||
return Game.objects.enabled_for_event(request.event).filter(
|
||||
steam_app_id__isnull=False
|
||||
)
|
||||
|
||||
|
||||
@event_router.get("/events/{event_path}/planning", response=List[PlanningSlotOut])
|
||||
def list_event_planning(request, event_path: str):
|
||||
"""The event planning, with the covers of the games we know."""
|
||||
return planning_with_games(request.event.planning_slots.all())
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class GamesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "games"
|
||||
@@ -0,0 +1,35 @@
|
||||
from functools import wraps
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from events.models import Event
|
||||
from .models import Game
|
||||
|
||||
|
||||
def require_game_enabled(steam_app_id: int | None = None, *, path: str | None = None):
|
||||
"""Gate a view (or, via @decorate_view, an API operation) behind an
|
||||
EventGame being enabled for the current event. Steam games are looked up
|
||||
by steam_app_id (the original, positional form); custom non-Steam games
|
||||
(no steam_app_id) are looked up by their Game.path instead, e.g.
|
||||
require_game_enabled(path="clock")."""
|
||||
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(request, *args, **kwargs):
|
||||
event = get_object_or_404(Event, path=kwargs.get("event_path"))
|
||||
games = Game.objects.enabled_for_event(event)
|
||||
games = (
|
||||
games.filter(steam_app_id=steam_app_id)
|
||||
if steam_app_id is not None
|
||||
else games.filter(path=path)
|
||||
)
|
||||
|
||||
if not games.exists():
|
||||
raise PermissionDenied
|
||||
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@@ -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,32 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Game",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("steam_app_id", models.PositiveIntegerField(unique=True)),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("enabled", models.BooleanField(default=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
from django.db import migrations
|
||||
|
||||
NOITA_APP_ID = 881100
|
||||
OPUS_MAGNUM_APP_ID = 558990
|
||||
|
||||
|
||||
def seed_games(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game.objects.get_or_create(
|
||||
steam_app_id=NOITA_APP_ID,
|
||||
defaults={"name": "Noita", "enabled": True},
|
||||
)
|
||||
Game.objects.get_or_create(
|
||||
steam_app_id=OPUS_MAGNUM_APP_ID,
|
||||
defaults={"name": "Opus Magnum", "enabled": True},
|
||||
)
|
||||
|
||||
|
||||
def unseed_games(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game.objects.filter(steam_app_id__in=[NOITA_APP_ID, OPUS_MAGNUM_APP_ID]).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed_games, reverse_code=unseed_games),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
NOITA_APP_ID = 881100
|
||||
OPUS_MAGNUM_APP_ID = 558990
|
||||
|
||||
PATHS = {
|
||||
NOITA_APP_ID: "/noita",
|
||||
OPUS_MAGNUM_APP_ID: "/opus-magnum",
|
||||
}
|
||||
|
||||
|
||||
def set_paths(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
for app_id, path in PATHS.items():
|
||||
Game.objects.filter(steam_app_id=app_id).update(path=path)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0002_seed_noita_and_opus_magnum"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="path",
|
||||
field=models.CharField(default="", max_length=100),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.RunPython(set_paths, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 6.0.5 on 2026-05-24 22:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0003_game_path"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="image",
|
||||
field=models.URLField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="game",
|
||||
name="steam_app_id",
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 6.0.5 on 2026-09-15 21:11
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("events", "0002_seed_polylan_43"),
|
||||
("games", "0004_game_image_alter_game_steam_app_id"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="EventGame",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("enabled", models.BooleanField(default=True)),
|
||||
(
|
||||
"event",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="event_games",
|
||||
to="events.event",
|
||||
),
|
||||
),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="event_games",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("event", "game"), name="unique_event_game"
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
# Generated by Django 6.0.5 on 2026-09-15 21:51
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0005_eventgame"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="game",
|
||||
name="enabled",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0006_remove_game_enabled"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="eventgame",
|
||||
name="apibot_token",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text=(
|
||||
"Bearer token for the legacy PolyLAN AAndT API (from the AAndT's "
|
||||
"'apibot' admin page), used to auto-sync Scopes and their assigned users."
|
||||
),
|
||||
max_length=512,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.5 on 2026-09-16 00:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0007_eventgame_apibot_token"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="custom_path",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.5 on 2026-10-15 22:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0008_game_custom_path"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="description",
|
||||
field=models.TextField(blank=True, default=""),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 6.0.5 on 2026-10-15 22:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0009_game_description"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="steam_currency",
|
||||
field=models.CharField(blank=True, default="", max_length=3),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="steam_discount_percent",
|
||||
field=models.PositiveSmallIntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="steam_header_image",
|
||||
field=models.URLField(blank=True, default="", max_length=500),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="steam_info_updated_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="steam_is_free",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="steam_price_final",
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="steam_price_initial",
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="steam_short_description",
|
||||
field=models.TextField(blank=True, default=""),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.5 on 2026-10-16 00:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0010_game_steam_store_info"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="library_image",
|
||||
field=models.URLField(blank=True, default="", max_length=500),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 6.0.5 on 2026-10-16 03:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("events", "0003_event_is_available"),
|
||||
("games", "0011_game_library_image"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="PlanningSlot",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("source_pk", models.PositiveIntegerField()),
|
||||
("title", models.CharField(max_length=255)),
|
||||
("begin", models.DateTimeField()),
|
||||
("end", models.DateTimeField()),
|
||||
("column", models.PositiveSmallIntegerField(default=0)),
|
||||
("color", models.CharField(blank=True, default="", max_length=7)),
|
||||
("is_multi_anims", models.BooleanField(default=False)),
|
||||
("game_names", models.JSONField(blank=True, default=list)),
|
||||
(
|
||||
"event",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="planning_slots",
|
||||
to="events.event",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["begin", "column"],
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("event", "source_pk"), name="unique_event_planning_slot"
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,125 @@
|
||||
from django.db import models
|
||||
|
||||
from events.models import Event
|
||||
|
||||
|
||||
class GameQuerySet(models.QuerySet):
|
||||
def enabled_for_event(self, event):
|
||||
return self.filter(event_games__event=event, event_games__enabled=True)
|
||||
|
||||
|
||||
class Game(models.Model):
|
||||
steam_app_id = models.PositiveIntegerField(null=True, blank=True)
|
||||
name = models.CharField(max_length=255)
|
||||
path = models.CharField(max_length=100)
|
||||
custom_path = models.BooleanField(default=False)
|
||||
image = models.URLField(null=True, blank=True)
|
||||
description = models.TextField(blank=True, default="")
|
||||
# Vertical (600x900) cover for games/showcase, empty = Steam's library capsule
|
||||
library_image = models.URLField(max_length=500, blank=True, default="")
|
||||
|
||||
# Steam store info, refreshed in the background (see games.tasks).
|
||||
# Prices are in cents, in steam_currency.
|
||||
steam_price_initial = models.PositiveIntegerField(null=True, blank=True)
|
||||
steam_price_final = models.PositiveIntegerField(null=True, blank=True)
|
||||
steam_discount_percent = models.PositiveSmallIntegerField(default=0)
|
||||
steam_currency = models.CharField(max_length=3, blank=True, default="")
|
||||
steam_is_free = models.BooleanField(default=False)
|
||||
steam_short_description = models.TextField(blank=True, default="")
|
||||
steam_header_image = models.URLField(max_length=500, blank=True, default="")
|
||||
steam_info_updated_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = models.Manager.from_queryset(GameQuerySet)()
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.steam_app_id:
|
||||
return f"{self.name} ({self.steam_app_id})"
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def store_url(self) -> str:
|
||||
if not self.steam_app_id:
|
||||
return ""
|
||||
return f"https://store.steampowered.com/app/{self.steam_app_id}"
|
||||
|
||||
@property
|
||||
def library_image_url(self) -> str:
|
||||
"""Vertical cover, like in the Steam library. Empty if there's none."""
|
||||
if self.library_image or not self.steam_app_id:
|
||||
return self.library_image
|
||||
return f"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/{self.steam_app_id}/library_600x900.jpg"
|
||||
|
||||
|
||||
class EventGame(models.Model):
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="event_games",
|
||||
)
|
||||
game = models.ForeignKey(
|
||||
Game,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="event_games",
|
||||
)
|
||||
enabled = models.BooleanField(default=True)
|
||||
|
||||
apibot_token = models.CharField(
|
||||
max_length=512,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=(
|
||||
"Bearer token for the legacy PolyLAN AAndT API (from the AAndT's "
|
||||
"'apibot' admin page), used to auto-sync Scopes and their assigned users."
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["event", "game"],
|
||||
name="unique_event_game",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.game.name} @ {self.event.name} ({'enabled' if self.enabled else 'disabled'})"
|
||||
|
||||
|
||||
class PlanningSlot(models.Model):
|
||||
"""One slot of the event planning, imported from the legacy PolyLAN planning
|
||||
(see the import_planning command)."""
|
||||
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="planning_slots",
|
||||
)
|
||||
# pk of the slot in the legacy planning, to update it on re-import
|
||||
source_pk = models.PositiveIntegerField()
|
||||
title = models.CharField(max_length=255)
|
||||
begin = models.DateTimeField()
|
||||
end = models.DateTimeField()
|
||||
column = models.PositiveSmallIntegerField(default=0)
|
||||
color = models.CharField(max_length=7, blank=True, default="")
|
||||
# Multi-anims (e.g. "Fils rouges") run several games over the whole event
|
||||
is_multi_anims = models.BooleanField(default=False)
|
||||
# Names of the games run in this slot (the legacy "aandts")
|
||||
game_names = models.JSONField(default=list, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["begin", "column"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["event", "source_pk"],
|
||||
name="unique_event_planning_slot",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.title} @ {self.event.name} ({self.begin:%a %H:%M})"
|
||||
@@ -0,0 +1,98 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class GameOut(Schema):
|
||||
id: int
|
||||
steam_app_id: Optional[int] = None
|
||||
name: str
|
||||
path: str
|
||||
custom_path: bool
|
||||
image: Optional[str] = None
|
||||
description: str
|
||||
|
||||
@staticmethod
|
||||
def resolve_description(obj) -> str:
|
||||
# The admin one wins, Steam games fall back to their store blurb
|
||||
return obj.description or obj.steam_short_description
|
||||
|
||||
|
||||
class SteamGameOut(Schema):
|
||||
id: int
|
||||
name: str
|
||||
steam_app_id: int
|
||||
store_url: str
|
||||
header_image: str
|
||||
library_image: str
|
||||
short_description: str
|
||||
is_free: bool
|
||||
currency: str
|
||||
price_initial: Optional[int] = None
|
||||
price_final: Optional[int] = None
|
||||
discount_percent: int
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
@staticmethod
|
||||
def resolve_store_url(obj) -> str:
|
||||
return obj.store_url
|
||||
|
||||
@staticmethod
|
||||
def resolve_header_image(obj) -> str:
|
||||
return (
|
||||
obj.steam_header_image
|
||||
or f"https://cdn.akamai.steamstatic.com/steam/apps/{obj.steam_app_id}/header.jpg"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def resolve_library_image(obj) -> str:
|
||||
return obj.library_image_url
|
||||
|
||||
@staticmethod
|
||||
def resolve_short_description(obj) -> str:
|
||||
return obj.steam_short_description
|
||||
|
||||
@staticmethod
|
||||
def resolve_is_free(obj) -> bool:
|
||||
return obj.steam_is_free
|
||||
|
||||
@staticmethod
|
||||
def resolve_currency(obj) -> str:
|
||||
return obj.steam_currency
|
||||
|
||||
@staticmethod
|
||||
def resolve_price_initial(obj) -> Optional[int]:
|
||||
return obj.steam_price_initial
|
||||
|
||||
@staticmethod
|
||||
def resolve_price_final(obj) -> Optional[int]:
|
||||
return obj.steam_price_final
|
||||
|
||||
@staticmethod
|
||||
def resolve_discount_percent(obj) -> int:
|
||||
return obj.steam_discount_percent
|
||||
|
||||
@staticmethod
|
||||
def resolve_updated_at(obj) -> Optional[datetime]:
|
||||
return obj.steam_info_updated_at
|
||||
|
||||
|
||||
class PlanningGameOut(Schema):
|
||||
name: str
|
||||
# Empty when the name didn't match any of our games
|
||||
library_image: str = ""
|
||||
store_url: str = ""
|
||||
steam_app_id: Optional[int] = None
|
||||
|
||||
|
||||
class PlanningSlotOut(Schema):
|
||||
id: int
|
||||
title: str
|
||||
begin: datetime
|
||||
end: datetime
|
||||
column: int
|
||||
color: str
|
||||
is_multi_anims: bool
|
||||
# Single slot: the slot's game. Multi-anims: each of their games.
|
||||
games: list[PlanningGameOut]
|
||||
@@ -0,0 +1,172 @@
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
import httpx
|
||||
from django.utils.text import slugify
|
||||
|
||||
from ..models import EventGame, Game, PlanningSlot
|
||||
from .steam_store import search_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# "Cosmoteer: Starship Architect & Commander" is still "Cosmoteer"
|
||||
SUBTITLE_SEPARATOR = re.compile(r"\s*(?::|\s-\s|\s–\s|\s—\s)")
|
||||
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
"""'Towerfall: Ascension' and 'TowerFall Ascension' are the same game:
|
||||
no accents, no case, letters and digits only."""
|
||||
# Before NFKD, which turns ™ into "TM"
|
||||
name = re.sub(r"[™®©]", "", name)
|
||||
name = unicodedata.normalize("NFKD", name)
|
||||
name = "".join(c for c in name if not unicodedata.combining(c))
|
||||
return re.sub(r"[^0-9a-z]", "", name.casefold())
|
||||
|
||||
|
||||
def _steam_match(name: str) -> dict | None:
|
||||
"""The Steam store app named name (or name + a subtitle), if there's one.
|
||||
Strict on purpose: "Jass" or "Crochet" must not end up as some random game."""
|
||||
wanted = normalize_name(name)
|
||||
if not wanted:
|
||||
return None
|
||||
# Steam's search chokes on punctuation ("Puyo Puyo — Tetris" finds nothing)
|
||||
term = " ".join(re.sub(r"[^\w\s]", " ", name).split())
|
||||
for item in search_store(term):
|
||||
steam_name = item.get("name") or ""
|
||||
if wanted in (
|
||||
normalize_name(steam_name),
|
||||
normalize_name(SUBTITLE_SEPARATOR.split(steam_name, maxsplit=1)[0]),
|
||||
):
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def _unique_path(name: str) -> str:
|
||||
base = slugify(name)[:90] or "game"
|
||||
path, n = base, 2
|
||||
while Game.objects.filter(path=path).exists():
|
||||
path, n = f"{base}-{n}", n + 1
|
||||
return path
|
||||
|
||||
|
||||
def create_missing_steam_games(event, names: list[str]) -> list[Game]:
|
||||
"""For each name without a Game, look it up on the Steam store and create
|
||||
the Game + its EventGame when found. Returns the created games."""
|
||||
known = {normalize_name(game.name) for game in Game.objects.all()}
|
||||
created = []
|
||||
for name in dict.fromkeys(names): # unique, keeps order
|
||||
if normalize_name(name) in known:
|
||||
continue
|
||||
try:
|
||||
item = _steam_match(name)
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
logger.warning("Steam store search failed for %r: %s", name, exc)
|
||||
continue
|
||||
if item is None:
|
||||
continue
|
||||
|
||||
if Game.objects.filter(steam_app_id=item["id"]).exists():
|
||||
# Already there under another name, the planning won't match it
|
||||
logger.warning(
|
||||
"%r is Steam app %s, already a Game under another name",
|
||||
name,
|
||||
item["id"],
|
||||
)
|
||||
continue
|
||||
|
||||
# Named like in the planning, so the slot matches it
|
||||
game = Game.objects.create(
|
||||
name=name, steam_app_id=item["id"], path=_unique_path(name)
|
||||
)
|
||||
EventGame.objects.get_or_create(event=event, game=game)
|
||||
known.add(normalize_name(name))
|
||||
created.append(game)
|
||||
return created
|
||||
|
||||
|
||||
def slot_game_names(entries: list[dict]) -> list[str]:
|
||||
"""Names to look for in a planning: titles of single slots, game names of
|
||||
multi-anims (their title, like "Fils rouges", isn't a game)."""
|
||||
names = []
|
||||
for entry in entries:
|
||||
if not entry.get("is_multi_anims"):
|
||||
names.append(entry["title"])
|
||||
names += [a["name"] for a in entry.get("aandts") or []]
|
||||
return names
|
||||
|
||||
|
||||
def _game_out(name: str, game: Game | None) -> dict:
|
||||
if game is None:
|
||||
return {"name": name}
|
||||
return {
|
||||
"name": game.name,
|
||||
"library_image": game.library_image_url,
|
||||
"store_url": game.store_url,
|
||||
"steam_app_id": game.steam_app_id,
|
||||
}
|
||||
|
||||
|
||||
def planning_with_games(slots) -> list[dict]:
|
||||
"""Slots as PlanningSlotOut dicts, with their games matched by name to ours."""
|
||||
games_by_name = {normalize_name(game.name): game for game in Game.objects.all()}
|
||||
|
||||
def match(name: str) -> Game | None:
|
||||
return games_by_name.get(normalize_name(name))
|
||||
|
||||
out = []
|
||||
for slot in slots:
|
||||
if slot.is_multi_anims:
|
||||
games = [_game_out(name, match(name)) for name in slot.game_names]
|
||||
else:
|
||||
# The title is the usual match, the legacy game names are a fallback
|
||||
# ("Golf it!" slot running the "Golf It!" game)
|
||||
game = next(
|
||||
filter(None, map(match, [slot.title, *slot.game_names])), None
|
||||
)
|
||||
games = [_game_out(slot.title, game)]
|
||||
|
||||
out.append(
|
||||
{
|
||||
"id": slot.id,
|
||||
"title": slot.title,
|
||||
"begin": slot.begin,
|
||||
"end": slot.end,
|
||||
"column": slot.column,
|
||||
"color": slot.color,
|
||||
"is_multi_anims": slot.is_multi_anims,
|
||||
"games": games,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def import_planning(event, entries: list[dict]) -> tuple[int, int, int]:
|
||||
"""Sync the event planning with the legacy planning entries (its "list").
|
||||
Returns (created, updated, deleted)."""
|
||||
created = updated = 0
|
||||
for entry in entries:
|
||||
_, was_created = PlanningSlot.objects.update_or_create(
|
||||
event=event,
|
||||
source_pk=entry["pk"],
|
||||
defaults={
|
||||
"title": entry["title"],
|
||||
"begin": entry["begin"],
|
||||
"end": entry["end"],
|
||||
"column": entry.get("colonneS") or 0,
|
||||
"color": entry.get("color_code") or "",
|
||||
"is_multi_anims": bool(entry.get("is_multi_anims")),
|
||||
"game_names": [a["name"] for a in entry.get("aandts") or []],
|
||||
},
|
||||
)
|
||||
if was_created:
|
||||
created += 1
|
||||
else:
|
||||
updated += 1
|
||||
|
||||
deleted, _ = (
|
||||
PlanningSlot.objects.filter(event=event)
|
||||
.exclude(source_pk__in=[entry["pk"] for entry in entries])
|
||||
.delete()
|
||||
)
|
||||
return created, updated, deleted
|
||||
@@ -0,0 +1,91 @@
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from ..models import Game
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Public store endpoint, no API key needed. Rate limited to ~200 calls / 5 min,
|
||||
# which is plenty for a handful of games every few hours.
|
||||
APP_DETAILS_URL = "https://store.steampowered.com/api/appdetails"
|
||||
STORE_SEARCH_URL = "https://store.steampowered.com/api/storesearch/"
|
||||
|
||||
|
||||
class SteamStoreError(Exception):
|
||||
"""Raised when the Steam store didn't return usable details for an app."""
|
||||
|
||||
|
||||
def fetch_app_details(steam_app_id: int) -> dict:
|
||||
response = httpx.get(
|
||||
APP_DETAILS_URL,
|
||||
params={
|
||||
"appids": steam_app_id,
|
||||
"cc": settings.STEAM_STORE_COUNTRY,
|
||||
"l": "en",
|
||||
"filters": "basic,price_overview",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
entry = (response.json() or {}).get(str(steam_app_id)) or {}
|
||||
if not entry.get("success"):
|
||||
raise SteamStoreError(f"No store details for app {steam_app_id}")
|
||||
|
||||
return entry["data"]
|
||||
|
||||
|
||||
def search_store(term: str) -> list[dict]:
|
||||
"""Store search results for term, as [{"id": app id, "name": ...}, ...]."""
|
||||
response = httpx.get(
|
||||
STORE_SEARCH_URL,
|
||||
params={"term": term, "cc": settings.STEAM_STORE_COUNTRY, "l": "english"},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return (response.json() or {}).get("items") or []
|
||||
|
||||
|
||||
def refresh_steam_game(game: Game) -> None:
|
||||
"""Pull the current store info for a Steam game into its steam_* fields."""
|
||||
data = fetch_app_details(game.steam_app_id)
|
||||
price = data.get("price_overview") or {}
|
||||
|
||||
game.steam_is_free = bool(data.get("is_free"))
|
||||
game.steam_price_initial = price.get("initial")
|
||||
game.steam_price_final = price.get("final")
|
||||
game.steam_discount_percent = price.get("discount_percent") or 0
|
||||
game.steam_currency = price.get("currency") or ""
|
||||
game.steam_short_description = data.get("short_description") or ""
|
||||
game.steam_header_image = data.get("header_image") or ""
|
||||
game.steam_info_updated_at = timezone.now()
|
||||
game.save(
|
||||
update_fields=[
|
||||
"steam_is_free",
|
||||
"steam_price_initial",
|
||||
"steam_price_final",
|
||||
"steam_discount_percent",
|
||||
"steam_currency",
|
||||
"steam_short_description",
|
||||
"steam_header_image",
|
||||
"steam_info_updated_at",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def refresh_steam_games(game_ids: list[int] | None = None) -> None:
|
||||
"""Refresh every Steam game (or just game_ids). A failing game keeps its
|
||||
previous info and doesn't stop the others."""
|
||||
games = Game.objects.filter(steam_app_id__isnull=False)
|
||||
if game_ids is not None:
|
||||
games = games.filter(id__in=game_ids)
|
||||
|
||||
for game in games:
|
||||
try:
|
||||
refresh_steam_game(game)
|
||||
except (httpx.HTTPError, ValueError, SteamStoreError) as exc:
|
||||
logger.warning("Steam store refresh failed for %s: %s", game, exc)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,100 @@
|
||||
from django.contrib import admin
|
||||
from django_object_actions import (
|
||||
DjangoObjectActions,
|
||||
action,
|
||||
takes_instance_or_queryset,
|
||||
)
|
||||
from market.models import Market, MarketOption, UserBet, UserPointChange
|
||||
|
||||
|
||||
class MarketOptionInline(admin.TabularInline):
|
||||
model = MarketOption
|
||||
extra = 1
|
||||
fields = ["text"]
|
||||
|
||||
|
||||
@admin.register(Market)
|
||||
class MarketAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
list_display = ["title", "status", "event", "end_date", "created_by", "created_at"]
|
||||
list_filter = ["status", "created_at", "event"]
|
||||
search_fields = ["uuid", "title"]
|
||||
readonly_fields = [
|
||||
"uuid",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"winning_option",
|
||||
]
|
||||
inlines = [MarketOptionInline]
|
||||
fieldsets = (
|
||||
("Info", {"fields": ["uuid", "title", "description", "event"]}),
|
||||
("Configuration", {"fields": ["end_date", "multiplier"]}),
|
||||
("Status", {"fields": ["status", "winning_option"]}),
|
||||
("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}),
|
||||
)
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
# Prevent any changes to resolved markets
|
||||
if obj and obj.status == Market.Status.RESOLVED:
|
||||
return False
|
||||
return super().has_change_permission(request, obj)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not change: # Creating new market
|
||||
obj.created_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
actions = ["publish_markets", "close_markets"]
|
||||
change_actions = ["publish_markets", "close_markets"]
|
||||
|
||||
@action(label="Publish", description="Publish selected draft markets")
|
||||
@takes_instance_or_queryset
|
||||
def publish_markets(self, request, queryset):
|
||||
updated = queryset.filter(status=Market.Status.DRAFT).update(
|
||||
status=Market.Status.OPEN
|
||||
)
|
||||
self.message_user(request, f"Published {updated} market(s).")
|
||||
|
||||
@action(label="Close", description="Close selected markets")
|
||||
@takes_instance_or_queryset
|
||||
def close_markets(self, request, queryset):
|
||||
updated = queryset.filter(status=Market.Status.OPEN).update(
|
||||
status=Market.Status.CLOSED
|
||||
)
|
||||
self.message_user(request, f"Closed {updated} market(s).")
|
||||
|
||||
|
||||
@admin.register(MarketOption)
|
||||
class MarketOptionAdmin(admin.ModelAdmin):
|
||||
list_display = ["text", "market"]
|
||||
list_filter = ["market"]
|
||||
search_fields = ["uuid", "text", "market__title"]
|
||||
readonly_fields = ["uuid"]
|
||||
|
||||
|
||||
@admin.register(UserBet)
|
||||
class UserBetAdmin(admin.ModelAdmin):
|
||||
list_display = ["user", "option", "amount", "created_at"]
|
||||
list_filter = ["user", "created_at", "option__market"]
|
||||
search_fields = ["uuid", "user__username", "option__text"]
|
||||
readonly_fields = ["uuid", "user", "option", "amount", "created_at", "updated_at"]
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(UserPointChange)
|
||||
class UserPointChangeAdmin(admin.ModelAdmin):
|
||||
list_display = ["user", "market", "amount", "reason", "created_at"]
|
||||
list_filter = ["user", "reason", "created_at", "market"]
|
||||
search_fields = ["uuid", "user__username", "market__title"]
|
||||
readonly_fields = ["uuid", "created_at", "updated_at"]
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
@@ -0,0 +1,235 @@
|
||||
from typing import List
|
||||
from ninja import Router
|
||||
from ninja.decorators import decorate_view
|
||||
from ninja.errors import HttpError
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Sum, Prefetch
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db import transaction
|
||||
|
||||
from games.decorators import require_game_enabled
|
||||
from market.models import (
|
||||
Market,
|
||||
MarketOption,
|
||||
UserBet,
|
||||
UserPointChange,
|
||||
get_event_points,
|
||||
)
|
||||
from market.schemas import (
|
||||
BetHistoryPointSchema,
|
||||
MarketListSchema,
|
||||
ResolveMarketSchema,
|
||||
UserBetCreateSchema,
|
||||
UserBetSchema,
|
||||
UserPointsSchema,
|
||||
)
|
||||
|
||||
|
||||
router = Router(tags=["market"])
|
||||
|
||||
PREFIX = "/events/{event_path}/market"
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/", response=List[MarketListSchema])
|
||||
@decorate_view(require_game_enabled(path="market"))
|
||||
def list_markets(request, event_path: str):
|
||||
"""List all markets (excludes draft markets)."""
|
||||
markets = Market.for_event.exclude(status=Market.Status.DRAFT)
|
||||
# Prefetch options with total_bets annotation sorted by total_bets desc, then text asc
|
||||
options_queryset = MarketOption.objects.annotate(
|
||||
total_bets=Coalesce(Sum("user_bets__amount"), 0)
|
||||
).order_by("-total_bets", "text")
|
||||
|
||||
return markets.prefetch_related(Prefetch("options", queryset=options_queryset))
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/user/bets", response=List[UserBetSchema])
|
||||
@decorate_view(require_game_enabled(path="market"))
|
||||
def list_user_bets(request, event_path: str):
|
||||
"""List all bets placed by the current user."""
|
||||
if not request.user.is_authenticated:
|
||||
raise HttpError(401, "Authentication required")
|
||||
|
||||
return (
|
||||
UserBet.objects.filter(user=request.user, option__market__event=request.event)
|
||||
.select_related("option__market")
|
||||
.prefetch_related("option__market__options")
|
||||
)
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/user/points", response=UserPointsSchema)
|
||||
@decorate_view(require_game_enabled(path="market"))
|
||||
def get_user_points(request, event_path: str):
|
||||
"""Get the current user's points balance for this event."""
|
||||
if not request.user.is_authenticated:
|
||||
raise HttpError(401, "Authentication required")
|
||||
|
||||
return {"points": get_event_points(request.user, request.event)}
|
||||
|
||||
|
||||
@router.get(f"{PREFIX}/{{market_uuid}}/history", response=List[BetHistoryPointSchema])
|
||||
@decorate_view(require_game_enabled(path="market"))
|
||||
def bet_history(request, event_path: str, market_uuid: str):
|
||||
"""Points added to each option over time (anonymous, one entry per bet placed/increased)."""
|
||||
market = get_object_or_404(
|
||||
Market.objects.exclude(status=Market.Status.DRAFT),
|
||||
uuid=market_uuid,
|
||||
event=request.event,
|
||||
)
|
||||
|
||||
# A user can only bet on one option per market, so their option is unique
|
||||
option_by_user = dict(
|
||||
UserBet.objects.filter(option__market=market).values_list(
|
||||
"user_id", "option__uuid"
|
||||
)
|
||||
)
|
||||
changes = UserPointChange.objects.filter(
|
||||
market=market, reason=UserPointChange.Reason.BET_PLACED
|
||||
).order_by("created_at")
|
||||
|
||||
return [
|
||||
{
|
||||
"option_uuid": str(option_by_user[change.user_id]),
|
||||
"created_at": change.created_at,
|
||||
"amount": -change.amount,
|
||||
}
|
||||
for change in changes
|
||||
if change.user_id in option_by_user
|
||||
]
|
||||
|
||||
|
||||
@router.post(f"{PREFIX}/{{market_uuid}}/actions/close")
|
||||
@decorate_view(require_game_enabled(path="market"))
|
||||
def close_market(request, event_path: str, market_uuid: str):
|
||||
"""Close a market. Admin only."""
|
||||
if not request.user.is_staff:
|
||||
raise HttpError(403, "Permission denied")
|
||||
|
||||
market = get_object_or_404(Market, uuid=market_uuid, event=request.event)
|
||||
market.status = Market.Status.CLOSED
|
||||
market.save(update_fields=["status", "updated_at"])
|
||||
return {"status": Market.Status.CLOSED}
|
||||
|
||||
|
||||
@router.post(f"{PREFIX}/{{market_uuid}}/actions/resolve", response=MarketListSchema)
|
||||
@decorate_view(require_game_enabled(path="market"))
|
||||
def resolve_market(
|
||||
request, event_path: str, market_uuid: str, payload: ResolveMarketSchema
|
||||
):
|
||||
"""Resolve a market with a winning option. Admin only."""
|
||||
if not request.user.is_staff:
|
||||
raise HttpError(403, "Permission denied")
|
||||
|
||||
market = get_object_or_404(Market, uuid=market_uuid, event=request.event)
|
||||
winning_option = get_object_or_404(MarketOption, uuid=payload.winning_option_uuid)
|
||||
|
||||
if winning_option.market_id != market.id:
|
||||
raise HttpError(400, "Option does not belong to this market")
|
||||
|
||||
market.winning_option = winning_option
|
||||
market.status = Market.Status.RESOLVED
|
||||
market.save(update_fields=["winning_option", "status", "updated_at"])
|
||||
|
||||
# Calculate and distribute winnings
|
||||
all_bets = list(UserBet.objects.filter(option__market=market))
|
||||
|
||||
# Calculate total pot
|
||||
total_pot = sum(bet.amount for bet in all_bets)
|
||||
if total_pot == 0:
|
||||
return market
|
||||
|
||||
# Separate winning and losing bets
|
||||
winning_bets = [bet for bet in all_bets if bet.option_id == winning_option.id]
|
||||
losing_bets = [bet for bet in all_bets if bet.option_id != winning_option.id]
|
||||
|
||||
total_winning = sum(bet.amount for bet in winning_bets)
|
||||
|
||||
point_changes = []
|
||||
|
||||
with transaction.atomic():
|
||||
# Award payouts to winners with multiplier
|
||||
if total_winning > 0:
|
||||
for bet in winning_bets:
|
||||
payout = round(
|
||||
bet.amount / total_winning * total_pot * market.multiplier
|
||||
)
|
||||
point_changes.append(
|
||||
UserPointChange(
|
||||
user_id=bet.user_id,
|
||||
market=market,
|
||||
amount=payout,
|
||||
reason=UserPointChange.Reason.BET_WON,
|
||||
)
|
||||
)
|
||||
|
||||
# Record losing bets (points already deducted)
|
||||
for bet in losing_bets:
|
||||
point_changes.append(
|
||||
UserPointChange(
|
||||
user_id=bet.user_id,
|
||||
market=market,
|
||||
amount=-bet.amount,
|
||||
reason=UserPointChange.Reason.BET_LOST,
|
||||
)
|
||||
)
|
||||
|
||||
# Bulk create point changes
|
||||
UserPointChange.objects.bulk_create(point_changes)
|
||||
|
||||
return market
|
||||
|
||||
|
||||
@router.post(f"{PREFIX}/{{market_uuid}}/bets", response=UserBetSchema)
|
||||
@decorate_view(require_game_enabled(path="market"))
|
||||
def create_bet(
|
||||
request, event_path: str, market_uuid: str, payload: UserBetCreateSchema
|
||||
):
|
||||
"""Place a bet on a market option."""
|
||||
if not request.user.is_authenticated:
|
||||
raise HttpError(401, "Authentication required")
|
||||
|
||||
market = get_object_or_404(Market, uuid=market_uuid, event=request.event)
|
||||
option = get_object_or_404(MarketOption, uuid=payload.option_uuid)
|
||||
|
||||
if option.market_id != market.id:
|
||||
raise HttpError(400, "Option does not belong to this market")
|
||||
|
||||
if market.status != Market.Status.OPEN:
|
||||
raise HttpError(400, "Market is not open for betting")
|
||||
|
||||
# Check if user already has a bet on a different option in this market
|
||||
existing_bet_on_market = (
|
||||
UserBet.objects.filter(user=request.user, option__market=market)
|
||||
.exclude(option=option)
|
||||
.first()
|
||||
)
|
||||
if existing_bet_on_market:
|
||||
raise HttpError(400, "You can only bet on one option per market")
|
||||
|
||||
# Check if user already has a bet on this option
|
||||
existing_bet = UserBet.objects.filter(user=request.user, option=option).first()
|
||||
if existing_bet and payload.amount < existing_bet.amount:
|
||||
raise HttpError(400, "Cannot decrease bet amount. You can only increase it.")
|
||||
|
||||
# Calculate delta (amount to deduct from user's points)
|
||||
delta = payload.amount - (existing_bet.amount if existing_bet else 0)
|
||||
|
||||
# Check if user has enough points for this event
|
||||
if get_event_points(request.user, request.event) < delta:
|
||||
raise HttpError(400, "Insufficient points for this bet")
|
||||
|
||||
user_bet, created = UserBet.objects.update_or_create(
|
||||
user=request.user,
|
||||
option=option,
|
||||
defaults={"amount": payload.amount},
|
||||
)
|
||||
|
||||
# Record the change (the event balance is derived from these)
|
||||
UserPointChange.objects.create(
|
||||
user=request.user,
|
||||
market=market,
|
||||
amount=-delta,
|
||||
reason=UserPointChange.Reason.BET_PLACED,
|
||||
)
|
||||
|
||||
return user_bet
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MarketConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "market"
|
||||
@@ -0,0 +1,187 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-23 15:45
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Market",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("title", models.CharField(max_length=255)),
|
||||
("description", models.TextField(blank=True)),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[("yes_no", "Yes/No"), ("multiple", "Multiple Choice")],
|
||||
default="yes_no",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("open", "Open"),
|
||||
("closed", "Closed"),
|
||||
("resolved", "Resolved"),
|
||||
],
|
||||
default="open",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
("end_date", models.DateTimeField()),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="MarketOption",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("text", models.CharField(max_length=255)),
|
||||
("position", models.PositiveIntegerField(default=0)),
|
||||
(
|
||||
"market",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="options",
|
||||
to="market.market",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["position"],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="market",
|
||||
name="winning_option",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="market_won",
|
||||
to="market.marketoption",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="UserBet",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("amount", models.PositiveIntegerField()),
|
||||
(
|
||||
"option",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="user_bets",
|
||||
to="market.marketoption",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="bets",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="marketoption",
|
||||
index=models.Index(
|
||||
fields=["market", "position"], name="market_mark_market__8679ce_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="marketoption",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("market", "position"), name="unique_market_option_position"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="market",
|
||||
index=models.Index(
|
||||
fields=["status", "-created_at"], name="market_mark_status_1ef6c3_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="market",
|
||||
index=models.Index(
|
||||
fields=["end_date"], name="market_mark_end_dat_26bec0_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="userbet",
|
||||
index=models.Index(
|
||||
fields=["user", "option"], name="market_user_user_id_5e43d9_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="userbet",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("user", "option"), name="unique_user_bet_per_option"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-23 18:11
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("market", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UserPointChange",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("amount", models.IntegerField()),
|
||||
(
|
||||
"reason",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("bet_placed", "Bet Placed"),
|
||||
("bet_won", "Bet Won"),
|
||||
("bet_lost", "Bet Lost"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"market",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="point_changes",
|
||||
to="market.market",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="point_changes",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created_at"],
|
||||
"indexes": [
|
||||
models.Index(
|
||||
fields=["user", "-created_at"],
|
||||
name="market_user_user_id_631ba9_idx",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-23 18:19
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("market", "0002_userpointchange"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="market",
|
||||
name="type",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-23 18:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("market", "0003_remove_market_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="marketoption",
|
||||
options={"ordering": ["text"]},
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name="marketoption",
|
||||
name="unique_market_option_position",
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name="marketoption",
|
||||
name="market_mark_market__8679ce_idx",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="marketoption",
|
||||
name="position",
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="marketoption",
|
||||
index=models.Index(
|
||||
fields=["market"], name="market_mark_market__67f63b_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="marketoption",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("market", "text"), name="unique_market_option_text"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-23 18:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("market", "0004_alter_marketoption_options_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="market",
|
||||
name="multiplier",
|
||||
field=models.FloatField(default=1.0),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.2.7 on 2026-05-23 18:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("market", "0005_market_multiplier"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="market",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("draft", "Draft"),
|
||||
("open", "Open"),
|
||||
("closed", "Closed"),
|
||||
("resolved", "Resolved"),
|
||||
],
|
||||
default="draft",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 6.0.5 on 2026-09-15 21:10
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("events", "0002_seed_polylan_43"),
|
||||
("market", "0006_alter_market_status"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="market",
|
||||
name="event",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="markets",
|
||||
to="events.event",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
from django.db import migrations
|
||||
|
||||
EVENT_PATH = "polylan-43"
|
||||
|
||||
|
||||
def backfill_event(apps, schema_editor):
|
||||
Event = apps.get_model("events", "Event")
|
||||
Market = apps.get_model("market", "Market")
|
||||
event = Event.objects.get(path=EVENT_PATH)
|
||||
Market.objects.filter(event__isnull=True).update(event=event)
|
||||
|
||||
|
||||
def noop(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("market", "0007_market_event"),
|
||||
("events", "0002_seed_polylan_43"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(backfill_event, noop),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("market", "0008_backfill_event"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="market",
|
||||
name="event",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="markets",
|
||||
to="events.event",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
from django.db import migrations
|
||||
|
||||
MARKET_PATH = "market"
|
||||
|
||||
|
||||
def seed_market_game(apps, schema_editor):
|
||||
"""The market used to be toggled globally (MARKET_ENABLED); it's now a
|
||||
custom Game like clock/place, enabled per event through EventGame. Enable
|
||||
it for every existing event so nothing disappears on deploy."""
|
||||
Event = apps.get_model("events", "Event")
|
||||
Game = apps.get_model("games", "Game")
|
||||
EventGame = apps.get_model("games", "EventGame")
|
||||
|
||||
game, _ = Game.objects.get_or_create(
|
||||
path=MARKET_PATH,
|
||||
custom_path=True,
|
||||
defaults={
|
||||
"name": "Market",
|
||||
"description": "Place your bets and compete",
|
||||
},
|
||||
)
|
||||
for event in Event.objects.all():
|
||||
EventGame.objects.get_or_create(event=event, game=game)
|
||||
|
||||
|
||||
def unseed_market_game(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game.objects.filter(path=MARKET_PATH, custom_path=True).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("events", "0003_event_is_available"),
|
||||
("games", "0009_game_description"),
|
||||
("market", "0009_event_required"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed_market_game, reverse_code=unseed_market_game),
|
||||
]
|
||||
@@ -0,0 +1,144 @@
|
||||
import uuid
|
||||
from django.db import models
|
||||
from django.db.models import Sum
|
||||
|
||||
from events.models import Event
|
||||
from events.scoping import EventScopedManager
|
||||
|
||||
|
||||
# Each event's market starts every user with this balance
|
||||
STARTING_POINTS = 1000
|
||||
|
||||
|
||||
def get_event_points(user, event) -> int:
|
||||
"""User's market balance for an event: starting points + the event's point changes.
|
||||
|
||||
BET_LOST rows are informational (the stake was already deducted by BET_PLACED).
|
||||
"""
|
||||
total = (
|
||||
UserPointChange.objects.filter(user=user, market__event=event)
|
||||
.exclude(reason=UserPointChange.Reason.BET_LOST)
|
||||
.aggregate(total=Sum("amount"))["total"]
|
||||
)
|
||||
return STARTING_POINTS + (total or 0)
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class Market(BaseModel):
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = "draft", "Draft"
|
||||
OPEN = "open", "Open"
|
||||
CLOSED = "closed", "Closed"
|
||||
RESOLVED = "resolved", "Resolved"
|
||||
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="markets",
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
status = models.CharField(
|
||||
max_length=10, choices=Status.choices, default=Status.DRAFT
|
||||
)
|
||||
end_date = models.DateTimeField()
|
||||
multiplier = models.FloatField(default=1.0)
|
||||
created_by = models.ForeignKey("accounts.CustomUser", on_delete=models.PROTECT)
|
||||
winning_option = models.ForeignKey(
|
||||
"MarketOption",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="market_won",
|
||||
)
|
||||
|
||||
objects = models.Manager()
|
||||
for_event = EventScopedManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["status", "-created_at"]),
|
||||
models.Index(fields=["end_date"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class MarketOption(BaseModel):
|
||||
market = models.ForeignKey(Market, on_delete=models.CASCADE, related_name="options")
|
||||
text = models.CharField(max_length=255)
|
||||
|
||||
class Meta:
|
||||
ordering = ["text"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["market", "text"],
|
||||
name="unique_market_option_text",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["market"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.market.title} - {self.text}"
|
||||
|
||||
|
||||
class UserBet(BaseModel):
|
||||
user = models.ForeignKey(
|
||||
"accounts.CustomUser", on_delete=models.CASCADE, related_name="bets"
|
||||
)
|
||||
option = models.ForeignKey(
|
||||
MarketOption, on_delete=models.CASCADE, related_name="user_bets"
|
||||
)
|
||||
amount = models.PositiveIntegerField()
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "option"],
|
||||
name="unique_user_bet_per_option",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["user", "option"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} bet {self.amount} on {self.option.text}"
|
||||
|
||||
|
||||
class UserPointChange(BaseModel):
|
||||
class Reason(models.TextChoices):
|
||||
BET_PLACED = "bet_placed", "Bet Placed"
|
||||
BET_WON = "bet_won", "Bet Won"
|
||||
BET_LOST = "bet_lost", "Bet Lost"
|
||||
|
||||
user = models.ForeignKey(
|
||||
"accounts.CustomUser", on_delete=models.CASCADE, related_name="point_changes"
|
||||
)
|
||||
market = models.ForeignKey(
|
||||
Market, on_delete=models.CASCADE, related_name="point_changes"
|
||||
)
|
||||
amount = models.IntegerField()
|
||||
reason = models.CharField(max_length=20, choices=Reason.choices)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["user", "-created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} {self.reason}: {self.amount} pts on {self.market.title}"
|
||||
@@ -0,0 +1,69 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Any
|
||||
from uuid import UUID
|
||||
from ninja import Schema
|
||||
from pydantic import field_serializer, model_validator
|
||||
|
||||
|
||||
class MarketOptionSchema(Schema):
|
||||
uuid: UUID
|
||||
text: str
|
||||
total_bets: int = 0
|
||||
|
||||
@field_serializer("uuid")
|
||||
def serialize_uuid(self, value: UUID) -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
class MarketListSchema(Schema):
|
||||
uuid: UUID
|
||||
title: str
|
||||
description: str
|
||||
status: str
|
||||
end_date: datetime
|
||||
multiplier: float = 1.0
|
||||
created_at: datetime
|
||||
options: List[MarketOptionSchema]
|
||||
winning_option: Optional[MarketOptionSchema] = None
|
||||
|
||||
@field_serializer("uuid")
|
||||
def serialize_uuid(self, value: UUID) -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
class ResolveMarketSchema(Schema):
|
||||
winning_option_uuid: str
|
||||
|
||||
|
||||
class UserPointsSchema(Schema):
|
||||
points: int
|
||||
|
||||
|
||||
class BetHistoryPointSchema(Schema):
|
||||
option_uuid: str
|
||||
created_at: datetime
|
||||
amount: int
|
||||
|
||||
|
||||
class UserBetCreateSchema(Schema):
|
||||
option_uuid: str
|
||||
amount: int
|
||||
|
||||
|
||||
class UserBetSchema(Schema):
|
||||
uuid: UUID
|
||||
amount: int
|
||||
created_at: datetime
|
||||
option: MarketOptionSchema
|
||||
market: Optional[MarketListSchema] = None
|
||||
|
||||
@field_serializer("uuid")
|
||||
def serialize_uuid(self, value: UUID) -> str:
|
||||
return str(value)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def resolve_market_from_option(cls, data: Any) -> Any:
|
||||
if hasattr(data, "option") and hasattr(data.option, "market"):
|
||||
data.market = data.option.market
|
||||
return data
|
||||
@@ -0,0 +1,23 @@
|
||||
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)
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.http import HttpRequest
|
||||
|
||||
from events.models import Event
|
||||
from games.decorators import require_game_enabled
|
||||
|
||||
|
||||
@login_required
|
||||
@require_game_enabled(path="market")
|
||||
def market_home(request: HttpRequest, event_path: str):
|
||||
get_object_or_404(Event, path=event_path)
|
||||
return render(request, "app.html")
|
||||
@@ -1,11 +1,16 @@
|
||||
from django.contrib import admin
|
||||
from django_object_actions import (
|
||||
DjangoObjectActions,
|
||||
action,
|
||||
takes_instance_or_queryset,
|
||||
)
|
||||
|
||||
from noita.services.objectives import parse_objectives_and_store
|
||||
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
|
||||
|
||||
|
||||
@admin.register(LogfileSubmission)
|
||||
class LogfileSubmissionAdmin(admin.ModelAdmin):
|
||||
class LogfileSubmissionAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||
list_display = (
|
||||
"id",
|
||||
"user",
|
||||
@@ -26,7 +31,10 @@ class LogfileSubmissionAdmin(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
actions = ["validate_submission"]
|
||||
change_actions = ["validate_submission"]
|
||||
|
||||
@action(label="Validate", description="Validate selected submissions")
|
||||
@takes_instance_or_queryset
|
||||
def validate_submission(self, request, queryset):
|
||||
for logfile in queryset:
|
||||
parse_objectives_and_store(logfile)
|
||||
|
||||
@@ -12,20 +12,26 @@ from django.db.models import (
|
||||
)
|
||||
from ninja import Router, File
|
||||
from ninja.files import UploadedFile
|
||||
from ninja.decorators import decorate_view
|
||||
|
||||
from noita.schemas import ResultsOut, LeaderboardOut
|
||||
from noita.services.objectives import parse_objectives_and_store
|
||||
from games.decorators import require_game_enabled
|
||||
|
||||
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
|
||||
from .schemas import NoitaSubmissionOut
|
||||
|
||||
|
||||
router = Router()
|
||||
NOITA_APP_ID = 881100
|
||||
|
||||
PREFIX = "/events/{event_path}/noita"
|
||||
|
||||
|
||||
@router.get("results", response=ResultsOut)
|
||||
def get_results(request: HttpRequest):
|
||||
cache_key = f"api:noita:results:{request.user.id}"
|
||||
@router.get(f"{PREFIX}/results", response=ResultsOut)
|
||||
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||
def get_results(request: HttpRequest, event_path: str):
|
||||
cache_key = f"api:{event_path}:noita:results:{request.user.id}"
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if cached_data is not None:
|
||||
@@ -38,7 +44,7 @@ def get_results(request: HttpRequest):
|
||||
"""
|
||||
# Group objectives by objectiv_id and count occurrences
|
||||
user_objectives = (
|
||||
Objectiv.objects.filter(user=request.user)
|
||||
Objectiv.objects.filter(user=request.user, submission__event=request.event)
|
||||
.values("objectiv_id")
|
||||
.annotate(count=Count("id"))
|
||||
)
|
||||
@@ -87,15 +93,25 @@ def get_results(request: HttpRequest):
|
||||
)
|
||||
|
||||
# Build response with all objectives and compute total score
|
||||
objectives_with_points = []
|
||||
total_score = 0
|
||||
with_points = {}
|
||||
for obj in ObjectivPoint.objects.all():
|
||||
with_points[obj.objectiv_id] = {
|
||||
"objectiv_id": obj.objectiv_id,
|
||||
"display_string": obj.display_string,
|
||||
"count": 0,
|
||||
"max_count": obj.max_count,
|
||||
"points_per_objectiv": obj.point,
|
||||
"total_points": 0,
|
||||
"first_seen_at": None,
|
||||
"seed": None,
|
||||
}
|
||||
|
||||
for obj in user_objectives.order_by("-total_points"):
|
||||
points = obj["total_points"] or 0
|
||||
objectives_with_points.append(
|
||||
with_points[obj["objectiv_id"]].update(
|
||||
{
|
||||
"objectiv_id": obj["objectiv_id"],
|
||||
"count": obj["count"],
|
||||
"points_per_objectiv": obj["points_per_objectiv"] or 0,
|
||||
"total_points": points,
|
||||
"first_seen_at": obj["first_seen_at"],
|
||||
"seed": obj["seed"],
|
||||
@@ -104,26 +120,27 @@ def get_results(request: HttpRequest):
|
||||
total_score += points
|
||||
|
||||
# Count deaths for the user
|
||||
deaths_count = DeathCounter.objects.filter(user=request.user).count()
|
||||
deaths_count = DeathCounter.for_event.filter(user=request.user).count()
|
||||
|
||||
data = {
|
||||
"total_score": total_score,
|
||||
"deaths_count": deaths_count,
|
||||
"objectives": objectives_with_points,
|
||||
"objectives": list(with_points.values()),
|
||||
}
|
||||
|
||||
cache.set(f"api:noita:results:{request.user.id}", data, 300)
|
||||
cache.set(cache_key, data, 300)
|
||||
return data
|
||||
|
||||
|
||||
@router.get("leaderboard", response=LeaderboardOut)
|
||||
def get_leaderboard(request: HttpRequest):
|
||||
@router.get(f"{PREFIX}/leaderboard", response=LeaderboardOut)
|
||||
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||
def get_leaderboard(request: HttpRequest, event_path: str):
|
||||
"""
|
||||
Get the global leaderboard for all users ranked by total score.
|
||||
|
||||
Uses Window functions to rank users by their total score in descending order.
|
||||
"""
|
||||
cache_key = "api:noita:leaderboard"
|
||||
cache_key = f"api:{event_path}:noita:leaderboard"
|
||||
cached_data = cache.get(cache_key)
|
||||
|
||||
if cached_data is not None:
|
||||
@@ -135,7 +152,8 @@ def get_leaderboard(request: HttpRequest):
|
||||
|
||||
# Get all objectives with calculated points (grouped by objectiv_id and user)
|
||||
all_objectives = (
|
||||
Objectiv.objects.values("user", "objectiv_id")
|
||||
Objectiv.objects.filter(submission__event=request.event)
|
||||
.values("user", "objectiv_id")
|
||||
.annotate(count=Count("id"))
|
||||
.annotate(
|
||||
# Fetch points from ObjectivPoint using Subquery
|
||||
@@ -186,12 +204,12 @@ def get_leaderboard(request: HttpRequest):
|
||||
for user_id, total_score in user_totals_dict.items():
|
||||
user = User.objects.get(id=user_id)
|
||||
objectives_count = (
|
||||
Objectiv.objects.filter(user_id=user_id)
|
||||
Objectiv.objects.filter(user_id=user_id, submission__event=request.event)
|
||||
.values("objectiv_id")
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
deaths_count = DeathCounter.objects.filter(user_id=user_id).count()
|
||||
deaths_count = DeathCounter.for_event.filter(user_id=user_id).count()
|
||||
users_with_scores.append(
|
||||
{
|
||||
"user_id": user_id,
|
||||
@@ -217,12 +235,17 @@ def get_leaderboard(request: HttpRequest):
|
||||
]
|
||||
|
||||
data = {"leaderboard": leaderboard}
|
||||
cache.set("api:noita:leaderboard", data, 300)
|
||||
cache.set(cache_key, data, 300)
|
||||
return data
|
||||
|
||||
|
||||
@router.post("submit", response={200: NoitaSubmissionOut, 400: dict})
|
||||
def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
|
||||
@router.post(
|
||||
f"{PREFIX}/submit", response={200: NoitaSubmissionOut, 400: dict, 500: dict}
|
||||
)
|
||||
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||
def submit_log_file(
|
||||
request: HttpRequest, event_path: str, file: UploadedFile = File(...)
|
||||
):
|
||||
"""
|
||||
Submit a Noita run file (log file, screenshot, or video).
|
||||
|
||||
@@ -251,6 +274,7 @@ def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
|
||||
try:
|
||||
# Create submission
|
||||
submission = LogfileSubmission.objects.create(
|
||||
event=request.event,
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
content_type=file.content_type,
|
||||
file_size=file.size,
|
||||
@@ -268,8 +292,8 @@ def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
|
||||
|
||||
# Invalidate caches on successful submission
|
||||
if submission.user:
|
||||
cache.delete(f"api:noita:results:{submission.user.id}")
|
||||
cache.delete("api:noita:leaderboard")
|
||||
cache.delete(f"api:{event_path}:noita:results:{submission.user.id}")
|
||||
cache.delete(f"api:{event_path}:noita:leaderboard")
|
||||
|
||||
return {
|
||||
"id": str(submission.id),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user