Compare commits
82
Commits
3e91ef77b6
...
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
|
@@ -0,0 +1,23 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
**/__pycache__
|
||||||
|
**/*.pyc
|
||||||
|
*.egg-info
|
||||||
|
.ruff_cache
|
||||||
|
.pytest_cache
|
||||||
|
ctagsfile
|
||||||
|
images
|
||||||
|
main.py
|
||||||
|
gifs_opus_magnum
|
||||||
|
|
||||||
|
polylan_submitter/node_modules
|
||||||
|
polylan_submitter/static
|
||||||
|
polylan_submitter/static_source/vite
|
||||||
|
polylan_submitter/media
|
||||||
|
polylan_submitter/db.sqlite3
|
||||||
|
polylan_submitter/.vscode
|
||||||
|
polylan_submitter/settingsLocal.py
|
||||||
|
|
||||||
|
deploy/docker/polylan-submitter.env
|
||||||
@@ -178,3 +178,4 @@ pyrightconfig.json
|
|||||||
tags
|
tags
|
||||||
media/
|
media/
|
||||||
settingsLocal.py
|
settingsLocal.py
|
||||||
|
deploy/docker/polylan-submitter.env
|
||||||
|
|||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
# Single OCI image bundling the Vue/Vite frontend, the Django/Channels web
|
||||||
|
# process (daphne) and the RQ worker, managed together by supervisord - see
|
||||||
|
# deploy/docker/README.md for why (single-container Proxmox deploy) and how
|
||||||
|
# to run it. Postgres, Redis and S3 stay external (see
|
||||||
|
# deploy/docker/polylan-submitter.env.dist for the full env var list) - this
|
||||||
|
# image carries no infra, just the app.
|
||||||
|
#
|
||||||
|
# Build (from repo root, requires BuildKit/buildx - RUN --mount=type=cache
|
||||||
|
# below is rejected by the legacy builder):
|
||||||
|
# docker buildx build --load -t polylan-submitter .
|
||||||
|
|
||||||
|
# ---- frontend build -------------------------------------------------------
|
||||||
|
FROM node:22-alpine AS frontend-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY polylan_submitter/package.json polylan_submitter/pnpm-lock.yaml ./
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate \
|
||||||
|
&& pnpm install --frozen-lockfile
|
||||||
|
COPY polylan_submitter/vite.config.ts polylan_submitter/tsconfig*.json ./
|
||||||
|
COPY polylan_submitter/src ./src
|
||||||
|
COPY polylan_submitter/public ./public
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# ---- python dependencies ---------------------------------------------------
|
||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS python-builder
|
||||||
|
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
# --no-install-project: this pyproject has no [build-system]/package to
|
||||||
|
# install, it only pins the app's dependencies - so just build the venv.
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --frozen --no-dev --no-install-project
|
||||||
|
|
||||||
|
# ---- runtime ----------------------------------------------------------------
|
||||||
|
FROM python:3.13-slim AS runtime
|
||||||
|
|
||||||
|
# tesseract-ocr: shelled out to by pytesseract (submissions/tasks.py,
|
||||||
|
# opus_magnum/utils.py) for OCR'ing Opus Magnum submission GIFs.
|
||||||
|
# libgl1/libglib2.0-0: runtime shared libs opencv-python needs even though
|
||||||
|
# it ships as a wheel.
|
||||||
|
# libpq5: psycopg (plain, not psycopg[binary]/[c]) loads libpq via ctypes.
|
||||||
|
# supervisor/tini: process supervision - see deploy/docker/supervisord.conf.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
tesseract-ocr \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libpq5 \
|
||||||
|
supervisor \
|
||||||
|
tini \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN groupadd --system app && useradd --system --gid app --create-home app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=python-builder --chown=app:app /app/.venv /app/.venv
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH" \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
DJANGO_SETTINGS_MODULE=polylan_submitter.settings
|
||||||
|
|
||||||
|
COPY --chown=app:app polylan_submitter/ /app/polylan_submitter/
|
||||||
|
COPY --from=frontend-builder --chown=app:app /app/static_source/vite /app/polylan_submitter/static_source/vite
|
||||||
|
COPY --chown=app:app deploy/docker/entrypoint.sh /app/entrypoint.sh
|
||||||
|
COPY --chown=app:app deploy/docker/supervisord.conf /app/supervisord.conf
|
||||||
|
|
||||||
|
RUN rm -rf /app/polylan_submitter/node_modules /app/polylan_submitter/settingsLocal.py \
|
||||||
|
&& mkdir -p /app/polylan_submitter/static /app/polylan_submitter/media \
|
||||||
|
&& chown app:app /app/polylan_submitter/static /app/polylan_submitter/media \
|
||||||
|
&& chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
USER app
|
||||||
|
WORKDIR /app/polylan_submitter
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
ENTRYPOINT ["tini", "--", "/app/entrypoint.sh"]
|
||||||
|
CMD ["supervisord", "-c", "/app/supervisord.conf", "-n"]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Container image
|
||||||
|
|
||||||
|
`Dockerfile` (repo root) builds a single OCI image with the Vite frontend
|
||||||
|
already built in, and a `.venv` with all Python deps. At runtime it uses
|
||||||
|
`supervisord` (`deploy/docker/supervisord.conf`) to run two processes in the
|
||||||
|
one container:
|
||||||
|
|
||||||
|
- `web` - one `daphne` process serving the Django/Channels app on `:8000`
|
||||||
|
- `worker` - two `rqworker` processes for background jobs (submission OCR,
|
||||||
|
AAndT sync, etc.)
|
||||||
|
|
||||||
|
Postgres, Redis and S3-compatible storage are **not** bundled (see
|
||||||
|
`deploy/docker/polylan-submitter.env.dist` for the full list of env vars:
|
||||||
|
`POSTGRES_*`, `REDIS_HOST`/`REDIS_PORT`, `AWS_*`, `DJANGO_SECRET_KEY`, etc.).
|
||||||
|
Point those at wherever those services already live (other containers/LXCs,
|
||||||
|
or existing infra).
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Requires BuildKit/buildx (`RUN --mount=type=cache` in the Dockerfile is
|
||||||
|
rejected by the legacy builder):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker buildx build --load -t polylan-submitter .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -d \
|
||||||
|
--name polylan-submitter \
|
||||||
|
--env-file deploy/docker/polylan-submitter.env \
|
||||||
|
-p 8000:8000 \
|
||||||
|
polylan-submitter
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations run automatically on container start (set `MIGRATE_ON_START=false`
|
||||||
|
to skip). `collectstatic` does not run automatically - it pushes to the
|
||||||
|
shared S3 bucket, so run it once per deploy instead of racing multiple
|
||||||
|
replicas on every boot:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run --rm --env-file deploy/docker/polylan-submitter.env \
|
||||||
|
polylan-submitter python manage.py collectstatic --noinput
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scaling
|
||||||
|
|
||||||
|
There's one `daphne` process per container - it binds the only port the
|
||||||
|
container exposes, so running more of them would need a reverse proxy
|
||||||
|
inside the container too. Scale by running multiple container replicas
|
||||||
|
behind an external reverse proxy/load balancer instead (each replica also
|
||||||
|
runs its own pair of `rqworker` processes, so worker capacity scales the
|
||||||
|
same way).
|
||||||
|
|
||||||
|
## Deploying to Proxmox
|
||||||
|
|
||||||
|
Recent Proxmox VE versions can create an LXC container directly from an OCI
|
||||||
|
image (`pct create <vmid> <image-ref> ...` with an OCI-format image
|
||||||
|
reference, currently a tech-preview feature - check `pct create --help` and
|
||||||
|
the Proxmox VE docs for your installed version, since the exact flags have
|
||||||
|
been changing across releases). Practically that means:
|
||||||
|
|
||||||
|
1. Push the built image to a registry Proxmox can reach (a local registry,
|
||||||
|
GHCR, Docker Hub, etc.) - `docker tag`/`docker push`.
|
||||||
|
2. Create the container on the Proxmox host from that image reference,
|
||||||
|
passing the same env vars as above.
|
||||||
|
3. Map/forward port 8000 as needed, and put whatever TLS-terminating
|
||||||
|
reverse proxy you already run in front of it.
|
||||||
|
|
||||||
|
If OCI-to-LXC isn't available on your Proxmox version, the same image runs
|
||||||
|
fine in a plain VM (or an LXC) with any container runtime (Docker/Podman)
|
||||||
|
installed instead.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Container entrypoint: runs migrations (unless disabled), then execs
|
||||||
|
# whatever CMD supplied (supervisord by default - see the Dockerfile).
|
||||||
|
#
|
||||||
|
# collectstatic is intentionally NOT run here: it pushes to the shared S3
|
||||||
|
# bucket (see STORAGES in settings.py), so running it on every container
|
||||||
|
# start would race across replicas for no benefit - run it once per deploy
|
||||||
|
# instead:
|
||||||
|
# docker run --rm --env-file <env file> polylan-submitter \
|
||||||
|
# python manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "${MIGRATE_ON_START:-true}" = "true" ]; then
|
||||||
|
echo "Running migrations..."
|
||||||
|
python manage.py migrate --noinput
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Kick off the periodic Steam store refresh (games.tasks). No-op when a refresh
|
||||||
|
# is already queued, so every replica can safely run it on start.
|
||||||
|
python manage.py schedule_steam_refresh || echo "Could not schedule Steam store refresh"
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Template for the --env-file passed to `docker run` (see
|
||||||
|
# deploy/docker/README.md). Copy this to deploy/docker/polylan-submitter.env,
|
||||||
|
# fill in real values, and keep it out of git (already in .gitignore) and
|
||||||
|
# world-readable perms (chmod 600) since it holds secrets.
|
||||||
|
#
|
||||||
|
# Docker reads this file itself (KEY=VALUE per line) before the container's
|
||||||
|
# Python even starts - no python-dotenv, no $VAR/${VAR} expansion, no shell
|
||||||
|
# involved. settings.py just reads the values from os.environ.
|
||||||
|
#
|
||||||
|
# Rotating a secret means editing this file and restarting the container
|
||||||
|
# (`docker restart polylan-submitter` or replacing it, depending on how
|
||||||
|
# you're running it) - values are read fresh on every process start.
|
||||||
|
#
|
||||||
|
# See settings.py for what each of these does and its dev-mode default.
|
||||||
|
|
||||||
|
DJANGO_SECRET_KEY=
|
||||||
|
DJANGO_DEBUG=false
|
||||||
|
DJANGO_ALLOWED_HOSTS=submitter.polylan.ch
|
||||||
|
DJANGO_CSRF_TRUSTED_ORIGINS=https://polylan-submitter.legrems.xyz
|
||||||
|
DJANGO_BEHIND_TLS_PROXY=true
|
||||||
|
|
||||||
|
POSTGRES_DB=
|
||||||
|
POSTGRES_USER=
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
POSTGRES_HOST=
|
||||||
|
POSTGRES_PORT=
|
||||||
|
|
||||||
|
REDIS_HOST=
|
||||||
|
REDIS_PORT=
|
||||||
|
PLACE_REDIS_URL=
|
||||||
|
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_S3_ENDPOINT_URL=
|
||||||
|
AWS_STORAGE_BUCKET_NAME=
|
||||||
|
|
||||||
|
SENTRY_ENVIRONMENT=production
|
||||||
|
SENTRY_DSN=
|
||||||
|
|
||||||
|
STEAM_API_KEY=
|
||||||
|
STEAM_STORE_REFRESH_MINUTES=360
|
||||||
|
STEAM_STORE_COUNTRY=ch
|
||||||
|
AANDT_API_BASE_URL=
|
||||||
|
SUBMITTER_BASE_URL=
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# One-off build+push to the Gitea container registry for testing.
|
||||||
|
# Usage: deploy/docker/push.sh [tag] (run from repo root, default tag: test)
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
REGISTRY=git.legrems.mom
|
||||||
|
OWNER=polylan
|
||||||
|
IMAGE=polylan-submitter
|
||||||
|
TAG="${1:-test}"
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
REF="$REGISTRY/$OWNER/$IMAGE:$TAG"
|
||||||
|
# buildx (BuildKit) is required: the Dockerfile uses RUN --mount=type=cache
|
||||||
|
# for the uv dep cache, which the legacy builder rejects.
|
||||||
|
docker buildx build --load -t "$REF" .
|
||||||
|
docker push "$REF"
|
||||||
|
|
||||||
|
echo "Pushed $REF"
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
; Runs inside the single app container as the non-root "app" user (see
|
||||||
|
; Dockerfile's USER app - no [supervisord] user= needed, we never start as
|
||||||
|
; root here). This container runs ONE daphne process on TCP: scale by
|
||||||
|
; running more container replicas behind an external reverse proxy/load
|
||||||
|
; balancer instead of more processes per container.
|
||||||
|
;
|
||||||
|
; Program logs go to stdout/stderr (logfile_maxbytes=0 disables supervisor's
|
||||||
|
; own log rotation for them) so `docker logs` / Proxmox's log viewer picks
|
||||||
|
; them up - no log files inside the container to manage.
|
||||||
|
|
||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
pidfile=/tmp/supervisord.pid
|
||||||
|
logfile=/dev/null
|
||||||
|
logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:web]
|
||||||
|
command=/app/.venv/bin/daphne -b 0.0.0.0 -p 8000 polylan_submitter.asgi:application
|
||||||
|
directory=/app/polylan_submitter
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stopasgroup=true
|
||||||
|
killasgroup=true
|
||||||
|
stopsignal=TERM
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:worker]
|
||||||
|
command=/app/.venv/bin/python manage.py rqworker default --job-class django_tasks_rq.Job --with-scheduler
|
||||||
|
directory=/app/polylan_submitter
|
||||||
|
process_name=%(program_name)s-%(process_num)02d
|
||||||
|
numprocs=2
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stopasgroup=true
|
||||||
|
killasgroup=true
|
||||||
|
stopsignal=TERM
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
Executable
+125
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Zero-downtime prod deploy: pull, sync deps, build front, migrate, then
|
||||||
|
# gracefully reload gunicorn (see polylan-submitter.service, installed and
|
||||||
|
# restarted instead when it changed) and restart the RQ worker (see
|
||||||
|
# polylan-submitter-worker.conf).
|
||||||
|
#
|
||||||
|
# Run from anywhere as the user owning the checkout, needs sudo for
|
||||||
|
# systemctl/supervisorctl (no-op when already root):
|
||||||
|
# ./deploy/systemd/deploy.sh
|
||||||
|
#
|
||||||
|
# Migrations run while the old code still serves requests, so keep them
|
||||||
|
# backward compatible (nullable/default columns, drop/rename in a later deploy).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERVICE="${SERVICE:-polylan-submitter}"
|
||||||
|
WORKER="${WORKER:-polylan-submitter-worker}"
|
||||||
|
SUPERVISOR_CONF_DIR="${SUPERVISOR_CONF_DIR:-/etc/supervisor/conf.d}"
|
||||||
|
WEB_URL="${WEB_URL:-https://polylan-submitter.legrems.xyz/}"
|
||||||
|
|
||||||
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
DEPLOY_DIR="$REPO_DIR/deploy/systemd"
|
||||||
|
APP_DIR="$REPO_DIR/polylan_submitter"
|
||||||
|
PYTHON="$REPO_DIR/.venv/bin/python"
|
||||||
|
# Same as the unit's Environment=, so migrate/collectstatic see the prod settings.
|
||||||
|
# Relative to $APP_DIR, where manage.py runs.
|
||||||
|
export SETTINGS_ENV_FILE_LOCATION="${SETTINGS_ENV_FILE_LOCATION:-polylan_submitter/.env}"
|
||||||
|
|
||||||
|
# gunicorn's master doesn't reload itself on HUP, so a server upgrade needs a
|
||||||
|
# full restart instead.
|
||||||
|
server_versions() {
|
||||||
|
"$PYTHON" - <<'EOF' 2>/dev/null || true
|
||||||
|
import importlib.metadata as m
|
||||||
|
for pkg in ("gunicorn", "uvicorn", "uvicorn-worker"):
|
||||||
|
try:
|
||||||
|
print(pkg, m.version(pkg))
|
||||||
|
except m.PackageNotFoundError:
|
||||||
|
print(pkg, "missing")
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# wait_for <description> <command...>: retry for ~30s, fail the deploy if it
|
||||||
|
# never succeeds.
|
||||||
|
wait_for() {
|
||||||
|
local what="$1"
|
||||||
|
shift
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if "$@" >/dev/null 2>&1; then
|
||||||
|
echo " $what: ok"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo " $what: FAILED" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
web_responds() {
|
||||||
|
# 000 = nothing listening, 5xx = Caddy up but gunicorn down (502) or broken.
|
||||||
|
local code
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' "$WEB_URL")"
|
||||||
|
[ "$code" != "000" ] && [ "$code" -lt 500 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
worker_running() {
|
||||||
|
sudo supervisorctl status "$WORKER" | grep -q RUNNING
|
||||||
|
}
|
||||||
|
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
|
||||||
|
echo "==> git pull"
|
||||||
|
git pull --ff-only
|
||||||
|
|
||||||
|
before="$(server_versions)"
|
||||||
|
|
||||||
|
echo "==> uv sync"
|
||||||
|
uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
after="$(server_versions)"
|
||||||
|
|
||||||
|
echo "==> frontend build"
|
||||||
|
cd "$APP_DIR"
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
echo "==> migrate"
|
||||||
|
"$PYTHON" manage.py migrate --noinput
|
||||||
|
|
||||||
|
echo "==> collectstatic"
|
||||||
|
"$PYTHON" manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
echo "==> $SERVICE (systemd)"
|
||||||
|
unit_changed=false
|
||||||
|
if ! cmp -s "$DEPLOY_DIR/$SERVICE.service" "/etc/systemd/system/$SERVICE.service"; then
|
||||||
|
sudo install -m 644 "$DEPLOY_DIR/$SERVICE.service" "/etc/systemd/system/$SERVICE.service"
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable "$SERVICE"
|
||||||
|
unit_changed=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# HUP re-forks workers with the master's original args, so a new ExecStart
|
||||||
|
# (unit change) or a new gunicorn/uvicorn needs a full restart.
|
||||||
|
if [ "$unit_changed" = true ]; then
|
||||||
|
echo "==> unit file changed, full restart of $SERVICE"
|
||||||
|
sudo systemctl restart "$SERVICE"
|
||||||
|
elif [ "$before" != "$after" ]; then
|
||||||
|
echo "==> server packages changed, full restart of $SERVICE"
|
||||||
|
sudo systemctl restart "$SERVICE"
|
||||||
|
else
|
||||||
|
echo "==> graceful reload of $SERVICE"
|
||||||
|
sudo systemctl reload "$SERVICE"
|
||||||
|
fi
|
||||||
|
wait_for "$SERVICE active" systemctl is-active --quiet "$SERVICE"
|
||||||
|
wait_for "$SERVICE responds on $WEB_URL" web_responds
|
||||||
|
|
||||||
|
echo "==> $WORKER (supervisor)"
|
||||||
|
sudo install -m 644 "$DEPLOY_DIR/$WORKER.conf" "$SUPERVISOR_CONF_DIR/$WORKER.conf"
|
||||||
|
# reread+update picks up config changes (and starts it on first install),
|
||||||
|
# restart loads the new code - TERM is a warm shutdown, the running job ends first.
|
||||||
|
sudo supervisorctl reread
|
||||||
|
sudo supervisorctl update "$WORKER"
|
||||||
|
sudo supervisorctl restart "$WORKER"
|
||||||
|
wait_for "$WORKER running" worker_running
|
||||||
|
|
||||||
|
echo "==> done"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
; Prod RQ worker (submission OCR, AAndT sync, Steam refresh...), run by the
|
||||||
|
; host's supervisord next to the polylan-submitter systemd unit (web).
|
||||||
|
;
|
||||||
|
; Installed to /etc/supervisor/conf.d/ by deploy/systemd/deploy.sh, which also
|
||||||
|
; restarts it on every deploy so it picks up the new code.
|
||||||
|
;
|
||||||
|
; Logs: supervisorctl tail -f polylan-submitter-worker
|
||||||
|
|
||||||
|
[program:polylan-submitter-worker]
|
||||||
|
command=/home/caddy/polylan-submitter/.venv/bin/python manage.py rqworker default --job-class django_tasks_rq.Job --with-scheduler
|
||||||
|
directory=/home/caddy/polylan-submitter/polylan_submitter
|
||||||
|
; Relative to directory, same as the systemd unit.
|
||||||
|
environment=SETTINGS_ENV_FILE_LOCATION="polylan_submitter/.env",PYTHONUNBUFFERED="1"
|
||||||
|
user=root
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
startsecs=5
|
||||||
|
; rqworker does a warm shutdown on TERM: it finishes the current job (OCR can
|
||||||
|
; take a while) before exiting, only SIGKILLed after stopwaitsecs.
|
||||||
|
stopsignal=TERM
|
||||||
|
stopwaitsecs=120
|
||||||
|
stopasgroup=true
|
||||||
|
killasgroup=true
|
||||||
|
redirect_stderr=true
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Prod web process: gunicorn master + uvicorn ASGI workers, behind Caddy.
|
||||||
|
#
|
||||||
|
# `systemctl reload polylan-submitter` sends HUP to the gunicorn master: it
|
||||||
|
# keeps the :7777 socket open, boots new workers on the new code, then
|
||||||
|
# gracefully stops the old ones - no downtime. See deploy/systemd/deploy.sh.
|
||||||
|
#
|
||||||
|
# Installed to /etc/systemd/system/ by deploy.sh: when this file changed it
|
||||||
|
# does daemon-reload + a full restart (HUP keeps the old ExecStart args).
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=PolyLAN submitter (gunicorn + uvicorn workers)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
Restart=always
|
||||||
|
RestartSec=1
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/home/caddy/polylan-submitter/polylan_submitter
|
||||||
|
# Relative to WorkingDirectory, loaded by settings.py via python-dotenv.
|
||||||
|
Environment="SETTINGS_ENV_FILE_LOCATION=polylan_submitter/.env"
|
||||||
|
|
||||||
|
# No --preload: with it, HUP re-forks from the already imported (old) code.
|
||||||
|
# --forwarded-allow-ips replaces daphne's --proxy-headers: trust
|
||||||
|
# X-Forwarded-* only from Caddy (local here).
|
||||||
|
ExecStart=/home/caddy/polylan-submitter/.venv/bin/gunicorn polylan_submitter.asgi:application \
|
||||||
|
--worker-class uvicorn_worker.UvicornWorker \
|
||||||
|
--workers 2 \
|
||||||
|
--bind 0.0.0.0:7777 \
|
||||||
|
--graceful-timeout 20 \
|
||||||
|
--forwarded-allow-ips 127.0.0.1
|
||||||
|
ExecReload=/bin/kill -s HUP $MAINPID
|
||||||
|
|
||||||
|
# SIGTERM to the master only, it then drains its workers (graceful-timeout).
|
||||||
|
KillMode=mixed
|
||||||
|
TimeoutStopSec=30
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -12,6 +12,18 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.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
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
|
from django.conf import settings
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from django_object_actions import DjangoObjectActions, action
|
||||||
|
|
||||||
from .models import CustomUser
|
from .models import CustomUser
|
||||||
|
|
||||||
|
# Must match loadtest/locustfile.py's own defaults (LOAD_TEST_USER_COUNT,
|
||||||
|
# LOAD_TEST_USER_PREFIX, LOAD_TEST_PASSWORD) - see that file for what these
|
||||||
|
# accounts are for and how they're used.
|
||||||
|
LOAD_TEST_USER_COUNT = 500
|
||||||
|
LOAD_TEST_USER_PREFIX = "loadtest"
|
||||||
|
LOAD_TEST_PASSWORD = "loadtest-only-password"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CustomUser)
|
@admin.register(CustomUser)
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(DjangoObjectActions, UserAdmin):
|
||||||
"""Admin interface for CustomUser."""
|
"""Admin interface for CustomUser."""
|
||||||
|
|
||||||
# Add custom fields to the user admin
|
# Add custom fields to the user admin
|
||||||
@@ -29,6 +39,8 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
# Make CAS fields readonly in admin
|
# Make CAS fields readonly in admin
|
||||||
readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes")
|
readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes")
|
||||||
|
|
||||||
|
changelist_actions = ("create_load_test_users",)
|
||||||
|
|
||||||
def get_cas_groups_display(self, obj):
|
def get_cas_groups_display(self, obj):
|
||||||
"""Display CAS groups in admin list."""
|
"""Display CAS groups in admin list."""
|
||||||
return obj.get_cas_groups_display()
|
return obj.get_cas_groups_display()
|
||||||
@@ -37,3 +49,34 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
|
|
||||||
def has_delete_permission(self, request, obj=None):
|
def has_delete_permission(self, request, obj=None):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def get_changelist_actions(self, request):
|
||||||
|
# Second layer of the same guard rail as the login endpoint itself
|
||||||
|
# (see testing_auth_views.py): the button doesn't even show up
|
||||||
|
# unless this deployment opted into load testing.
|
||||||
|
if not settings.LOAD_TEST_LOGIN_ENABLED:
|
||||||
|
return []
|
||||||
|
return super().get_changelist_actions(request)
|
||||||
|
|
||||||
|
@action(
|
||||||
|
label="Create load test users",
|
||||||
|
description=(
|
||||||
|
"Create/reset the local loadtestN accounts used by "
|
||||||
|
"loadtest/locustfile.py - authenticates via ModelBackend, "
|
||||||
|
"never the real CAS server"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def create_load_test_users(self, request, _queryset):
|
||||||
|
created = 0
|
||||||
|
for i in range(LOAD_TEST_USER_COUNT):
|
||||||
|
username = f"{LOAD_TEST_USER_PREFIX}{i}"
|
||||||
|
user, was_created = CustomUser.objects.get_or_create(username=username)
|
||||||
|
user.set_password(LOAD_TEST_PASSWORD)
|
||||||
|
user.save()
|
||||||
|
created += was_created
|
||||||
|
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"{created} created, {LOAD_TEST_USER_COUNT - created} already existed "
|
||||||
|
f"(password reset on all {LOAD_TEST_USER_COUNT}).",
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,3 +4,23 @@ from django.apps import AppConfig
|
|||||||
class AccountsConfig(AppConfig):
|
class AccountsConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "accounts"
|
name = "accounts"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
# django-admin-interface's Theme.logo/favicon are plain FileFields,
|
||||||
|
# so without this they'd resolve through STORAGES["default"] (S3 -
|
||||||
|
# see settings.py) like every submission/media upload. The theme's
|
||||||
|
# logo/favicon (see accounts/fixtures/admin_interface_theme.json)
|
||||||
|
# are actually a static asset (static/favicon.ico, built from
|
||||||
|
# public/favicon.ico by Vite - see vite.config.ts), so point those
|
||||||
|
# two fields at STATIC_ROOT/STATIC_URL instead, served by WhiteNoise
|
||||||
|
# like any other static file.
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.files.storage import FileSystemStorage
|
||||||
|
|
||||||
|
from admin_interface.models import Theme
|
||||||
|
|
||||||
|
static_storage = FileSystemStorage(
|
||||||
|
location=settings.STATIC_ROOT, base_url=settings.STATIC_URL
|
||||||
|
)
|
||||||
|
Theme._meta.get_field("logo").storage = static_storage
|
||||||
|
Theme._meta.get_field("favicon").storage = static_storage
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "admin_interface.theme",
|
||||||
|
"pk": 2,
|
||||||
|
"fields": {
|
||||||
|
"name": "Django Purple (by Legrems)",
|
||||||
|
"active": true,
|
||||||
|
"title": "Super administrateur de poney ",
|
||||||
|
"title_color": "#E5B8FF",
|
||||||
|
"title_visible": true,
|
||||||
|
"logo": "favicon.ico",
|
||||||
|
"logo_color": "#831a96",
|
||||||
|
"logo_max_width": 400,
|
||||||
|
"logo_max_height": 50,
|
||||||
|
"logo_visible": true,
|
||||||
|
"favicon": "favicon.ico",
|
||||||
|
"env_name": "",
|
||||||
|
"env_color": "#831a96",
|
||||||
|
"env_visible_in_header": true,
|
||||||
|
"env_visible_in_favicon": true,
|
||||||
|
"language_chooser_active": true,
|
||||||
|
"language_chooser_control": "default-select",
|
||||||
|
"language_chooser_display": "code",
|
||||||
|
"css_header_background_color": "#2d0840",
|
||||||
|
"css_header_text_color": "#831a96",
|
||||||
|
"css_header_link_color": "#FFFFFF",
|
||||||
|
"css_header_link_hover_color": "#a82bb8",
|
||||||
|
"css_module_background_color": "#6b0f7d",
|
||||||
|
"css_module_background_selected_color": "#831a96",
|
||||||
|
"css_module_text_color": "#FFFFFF",
|
||||||
|
"css_module_link_color": "#FFFFFF",
|
||||||
|
"css_module_link_selected_color": "#FFFFFF",
|
||||||
|
"css_module_link_hover_color": "#a82bb8",
|
||||||
|
"css_module_rounded_corners": true,
|
||||||
|
"css_generic_link_color": "#2d0840",
|
||||||
|
"css_generic_link_hover_color": "#5a0d6b",
|
||||||
|
"css_save_button_background_color": "#2d0840",
|
||||||
|
"css_save_button_background_hover_color": "#5a0d6b",
|
||||||
|
"css_save_button_text_color": "#FFFFFF",
|
||||||
|
"css_delete_button_background_color": "#a82bb8",
|
||||||
|
"css_delete_button_background_hover_color": "#831a96",
|
||||||
|
"css_delete_button_text_color": "#FFFFFF",
|
||||||
|
"related_modal_active": true,
|
||||||
|
"related_modal_background_color": "#000000",
|
||||||
|
"related_modal_background_opacity": "0.3",
|
||||||
|
"related_modal_rounded_corners": true,
|
||||||
|
"related_modal_close_button_visible": true,
|
||||||
|
"list_filter_highlight": true,
|
||||||
|
"list_filter_dropdown": true,
|
||||||
|
"list_filter_sticky": true,
|
||||||
|
"list_filter_removal_links": false,
|
||||||
|
"foldable_apps": true,
|
||||||
|
"show_fieldsets_as_tabs": false,
|
||||||
|
"show_inlines_as_tabs": false,
|
||||||
|
"recent_actions_visible": true,
|
||||||
|
"form_submit_sticky": false,
|
||||||
|
"form_pagination_sticky": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from django.core.management import call_command
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def load_admin_theme(apps, schema_editor):
|
||||||
|
# Seeds the default django-admin-interface theme (see
|
||||||
|
# accounts/fixtures/admin_interface_theme.json). Runs once, the first
|
||||||
|
# time this migration is applied, so it won't clobber a theme later
|
||||||
|
# edited by hand through the admin.
|
||||||
|
call_command("loaddata", "admin_interface_theme.json")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0002_customuser_points"),
|
||||||
|
("admin_interface", "0032_alter_theme_defaults"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(load_admin_theme, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -22,10 +22,12 @@ from opus_magnum.models import PuzzleResponse, SteamCollectionItem, SteamCollect
|
|||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
|
PREFIX = "/events/{event_path}/results"
|
||||||
|
|
||||||
@router.get("results", response=RankingSchema)
|
|
||||||
def results(request: HttpRequest) -> dict:
|
@router.get(f"{PREFIX}/results", response=RankingSchema)
|
||||||
cache_key = "api:results:results"
|
def results(request: HttpRequest, event_path: str) -> dict:
|
||||||
|
cache_key = f"api:{event_path}:results:results"
|
||||||
cached_data = cache.get(cache_key)
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
if cached_data is not None:
|
if cached_data is not None:
|
||||||
@@ -35,7 +37,9 @@ def results(request: HttpRequest) -> dict:
|
|||||||
responses_by_puzzleid = defaultdict(list)
|
responses_by_puzzleid = defaultdict(list)
|
||||||
|
|
||||||
for response in 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()
|
.filter_user_best_response()
|
||||||
.prefetch_related("submission__user")
|
.prefetch_related("submission__user")
|
||||||
):
|
):
|
||||||
@@ -51,25 +55,27 @@ def results(request: HttpRequest) -> dict:
|
|||||||
|
|
||||||
data = {
|
data = {
|
||||||
"users": list(CustomUser.objects.filter(pk__in=responses_by_userid.keys())),
|
"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),
|
"responses_by_userid": dict(responses_by_userid),
|
||||||
"ranking_by_puzzle": ranking,
|
"ranking_by_puzzle": ranking,
|
||||||
}
|
}
|
||||||
|
|
||||||
cache.set("api:results:results", data, 300)
|
cache.set(cache_key, data, 300)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@router.get("top-submissions", response=TournamentSubmissionsOut)
|
@router.get(f"{PREFIX}/top-submissions", response=TournamentSubmissionsOut)
|
||||||
def top_submissions(request: HttpRequest, limit: int = 5) -> dict:
|
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."""
|
"""Get tournament top submissions for each puzzle. Only available when tournament is closed."""
|
||||||
cache_key = f"api:results:top_submissions:{limit}"
|
cache_key = f"api:{event_path}:results:top_submissions:{limit}"
|
||||||
cached_data = cache.get(cache_key)
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
if cached_data is not None:
|
if cached_data is not None:
|
||||||
return cached_data
|
return cached_data
|
||||||
|
|
||||||
collection = get_object_or_404(SteamCollection, is_active=True)
|
collection = get_object_or_404(SteamCollection, is_active=True, event=request.event)
|
||||||
|
|
||||||
# Only allow access when tournament is closed
|
# Only allow access when tournament is closed
|
||||||
if collection.accepting_submissions:
|
if collection.accepting_submissions:
|
||||||
@@ -141,20 +147,20 @@ def top_submissions(request: HttpRequest, limit: int = 5) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
data = {"submissions": submissions_list}
|
data = {"submissions": submissions_list}
|
||||||
cache.set(f"api:results:top_submissions:{limit}", data, 300)
|
cache.set(cache_key, data, 300)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@router.get("puzzle-results", response=TournamentPuzzleResultsOut)
|
@router.get(f"{PREFIX}/puzzle-results", response=TournamentPuzzleResultsOut)
|
||||||
def puzzle_results(request: HttpRequest, limit: int = 5) -> dict:
|
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."""
|
"""Get tournament results organized by puzzle with coefficients. Only available when tournament is closed."""
|
||||||
cache_key = f"api:results:puzzle_results:{limit}"
|
cache_key = f"api:{event_path}:results:puzzle_results:{limit}"
|
||||||
cached_data = cache.get(cache_key)
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
if cached_data is not None:
|
if cached_data is not None:
|
||||||
return cached_data
|
return cached_data
|
||||||
|
|
||||||
collection = get_object_or_404(SteamCollection, is_active=True)
|
collection = get_object_or_404(SteamCollection, is_active=True, event=request.event)
|
||||||
|
|
||||||
# Only allow access when tournament is closed
|
# Only allow access when tournament is closed
|
||||||
if collection.accepting_submissions:
|
if collection.accepting_submissions:
|
||||||
@@ -237,5 +243,5 @@ def puzzle_results(request: HttpRequest, limit: int = 5) -> dict:
|
|||||||
)
|
)
|
||||||
|
|
||||||
data = {"results": results_list}
|
data = {"results": results_list}
|
||||||
cache.set(f"api:results:puzzle_results:{limit}", data, 300)
|
cache.set(cache_key, data, 300)
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,11 +1,121 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin, messages
|
||||||
|
from django_object_actions import (
|
||||||
|
DjangoObjectActions,
|
||||||
|
action,
|
||||||
|
takes_instance_or_queryset,
|
||||||
|
)
|
||||||
|
|
||||||
from .models import Game
|
from submissions.tasks import schedule_scope_sync, stop_scope_sync
|
||||||
|
|
||||||
|
from .models import EventGame, Game, PlanningSlot
|
||||||
|
from .tasks import refresh_steam_games_task
|
||||||
|
|
||||||
|
|
||||||
|
class EventGameInline(admin.TabularInline):
|
||||||
|
model = EventGame
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Game)
|
@admin.register(Game)
|
||||||
class GameAdmin(admin.ModelAdmin):
|
class GameAdmin(admin.ModelAdmin):
|
||||||
list_display = ["name", "steam_app_id", "enabled", "updated_at"]
|
list_display = ["name", "steam_app_id", "updated_at"]
|
||||||
list_filter = ["enabled"]
|
|
||||||
search_fields = ["name", "steam_app_id"]
|
search_fields = ["name", "steam_app_id"]
|
||||||
readonly_fields = ["created_at", "updated_at"]
|
readonly_fields = [
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"steam_price_initial",
|
||||||
|
"steam_price_final",
|
||||||
|
"steam_discount_percent",
|
||||||
|
"steam_currency",
|
||||||
|
"steam_is_free",
|
||||||
|
"steam_short_description",
|
||||||
|
"steam_header_image",
|
||||||
|
"steam_info_updated_at",
|
||||||
|
]
|
||||||
|
inlines = [EventGameInline]
|
||||||
|
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"]
|
||||||
|
|||||||
@@ -3,11 +3,35 @@ from typing import List
|
|||||||
from ninja import Router
|
from ninja import Router
|
||||||
|
|
||||||
from .models import Game
|
from .models import Game
|
||||||
from .schemas import GameOut
|
from .schemas import GameOut, PlanningSlotOut, SteamGameOut
|
||||||
|
from .services.planning import planning_with_games
|
||||||
|
from .tasks import schedule_steam_refresh
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
event_router = Router()
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response=List[GameOut])
|
@router.get("", response=List[GameOut])
|
||||||
def list_games(request):
|
def list_games(request):
|
||||||
return Game.objects.filter(enabled=True)
|
return Game.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
@event_router.get("/events/{event_path}/games", response=List[GameOut])
|
||||||
|
def list_event_games(request, event_path: str):
|
||||||
|
return Game.objects.enabled_for_event(request.event)
|
||||||
|
|
||||||
|
|
||||||
|
@event_router.get("/events/{event_path}/games/steam", response=List[SteamGameOut])
|
||||||
|
def list_event_steam_games(request, event_path: str):
|
||||||
|
"""The event's Steam games with their store info (price, discount, blurb)."""
|
||||||
|
# Safety net in case the periodic refresh loop isn't running (no-op otherwise).
|
||||||
|
schedule_steam_refresh()
|
||||||
|
return Game.objects.enabled_for_event(request.event).filter(
|
||||||
|
steam_app_id__isnull=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@event_router.get("/events/{event_path}/planning", response=List[PlanningSlotOut])
|
||||||
|
def list_event_planning(request, event_path: str):
|
||||||
|
"""The event planning, with the covers of the games we know."""
|
||||||
|
return planning_with_games(request.event.planning_slots.all())
|
||||||
|
|||||||
@@ -1,20 +1,33 @@
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
from .models import Game
|
from .models import Game
|
||||||
|
|
||||||
|
|
||||||
def require_game_enabled(steam_app_id: int):
|
def require_game_enabled(steam_app_id: int | None = None, *, path: str | None = None):
|
||||||
|
"""Gate a view (or, via @decorate_view, an API operation) behind an
|
||||||
|
EventGame being enabled for the current event. Steam games are looked up
|
||||||
|
by steam_app_id (the original, positional form); custom non-Steam games
|
||||||
|
(no steam_app_id) are looked up by their Game.path instead, e.g.
|
||||||
|
require_game_enabled(path="clock")."""
|
||||||
|
|
||||||
def decorator(view_func):
|
def decorator(view_func):
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
def wrapper(request, *args, **kwargs):
|
def wrapper(request, *args, **kwargs):
|
||||||
try:
|
event = get_object_or_404(Event, path=kwargs.get("event_path"))
|
||||||
game = Game.objects.get(steam_app_id=steam_app_id)
|
games = Game.objects.enabled_for_event(event)
|
||||||
except Game.DoesNotExist:
|
games = (
|
||||||
raise PermissionDenied
|
games.filter(steam_app_id=steam_app_id)
|
||||||
if not game.enabled:
|
if steam_app_id is not None
|
||||||
|
else games.filter(path=path)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not games.exists():
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
|
|
||||||
return view_func(request, *args, **kwargs)
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
from games.services.planning import (
|
||||||
|
create_missing_steam_games,
|
||||||
|
import_planning,
|
||||||
|
slot_game_names,
|
||||||
|
)
|
||||||
|
from games.tasks import refresh_steam_games_task
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"Import an event planning from the legacy PolyLAN planning JSON "
|
||||||
|
"(slots missing from the file are deleted)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("event_path", help="Path of the event, e.g. polylan-43")
|
||||||
|
parser.add_argument("json_file", help="Planning JSON file, '-' for stdin")
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-steam",
|
||||||
|
action="store_true",
|
||||||
|
help="Don't look up unknown games on the Steam store",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
try:
|
||||||
|
event = Event.objects.get(path=options["event_path"])
|
||||||
|
except Event.DoesNotExist:
|
||||||
|
raise CommandError(f"No event with path {options['event_path']!r}")
|
||||||
|
|
||||||
|
if options["json_file"] == "-":
|
||||||
|
data = json.load(sys.stdin)
|
||||||
|
else:
|
||||||
|
with open(options["json_file"], encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
entries = data["list"] if isinstance(data, dict) else data
|
||||||
|
with transaction.atomic():
|
||||||
|
created, updated, deleted = import_planning(event, entries)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Planning of {event}: {created} created, {updated} updated, {deleted} deleted"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if options["no_steam"]:
|
||||||
|
return
|
||||||
|
|
||||||
|
games = create_missing_steam_games(event, slot_game_names(entries))
|
||||||
|
for game in games:
|
||||||
|
self.stdout.write(f"+ {game.name} (Steam app {game.steam_app_id})")
|
||||||
|
if games:
|
||||||
|
# Prices, blurb, ... for the games page
|
||||||
|
refresh_steam_games_task.enqueue(game_ids=[game.id for game in games])
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f"{len(games)} game(s) found on Steam and added to {event}")
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from games.tasks import schedule_steam_refresh
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Start the periodic Steam store refresh loop (no-op if already running)"
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if schedule_steam_refresh():
|
||||||
|
self.stdout.write(self.style.SUCCESS("Steam store refresh scheduled"))
|
||||||
|
else:
|
||||||
|
self.stdout.write("Steam store refresh already scheduled")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-05-24 22:51
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0003_game_path"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="image",
|
||||||
|
field=models.URLField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="game",
|
||||||
|
name="steam_app_id",
|
||||||
|
field=models.PositiveIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-09-15 21:11
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("events", "0002_seed_polylan_43"),
|
||||||
|
("games", "0004_game_image_alter_game_steam_app_id"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="EventGame",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("enabled", models.BooleanField(default=True)),
|
||||||
|
(
|
||||||
|
"event",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="event_games",
|
||||||
|
to="events.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"game",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="event_games",
|
||||||
|
to="games.game",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"constraints": [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=("event", "game"), name="unique_event_game"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-09-15 21:51
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0005_eventgame"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="game",
|
||||||
|
name="enabled",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0006_remove_game_enabled"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="eventgame",
|
||||||
|
name="apibot_token",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text=(
|
||||||
|
"Bearer token for the legacy PolyLAN AAndT API (from the AAndT's "
|
||||||
|
"'apibot' admin page), used to auto-sync Scopes and their assigned users."
|
||||||
|
),
|
||||||
|
max_length=512,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-09-16 00:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0007_eventgame_apibot_token"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="custom_path",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-10-15 22:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0008_game_custom_path"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="description",
|
||||||
|
field=models.TextField(blank=True, default=""),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-10-15 22:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0009_game_description"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="steam_currency",
|
||||||
|
field=models.CharField(blank=True, default="", max_length=3),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="steam_discount_percent",
|
||||||
|
field=models.PositiveSmallIntegerField(default=0),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="steam_header_image",
|
||||||
|
field=models.URLField(blank=True, default="", max_length=500),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="steam_info_updated_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="steam_is_free",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="steam_price_final",
|
||||||
|
field=models.PositiveIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="steam_price_initial",
|
||||||
|
field=models.PositiveIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="steam_short_description",
|
||||||
|
field=models.TextField(blank=True, default=""),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-10-16 00:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0010_game_steam_store_info"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="library_image",
|
||||||
|
field=models.URLField(blank=True, default="", max_length=500),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-10-16 03:14
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("events", "0003_event_is_available"),
|
||||||
|
("games", "0011_game_library_image"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PlanningSlot",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("source_pk", models.PositiveIntegerField()),
|
||||||
|
("title", models.CharField(max_length=255)),
|
||||||
|
("begin", models.DateTimeField()),
|
||||||
|
("end", models.DateTimeField()),
|
||||||
|
("column", models.PositiveSmallIntegerField(default=0)),
|
||||||
|
("color", models.CharField(blank=True, default="", max_length=7)),
|
||||||
|
("is_multi_anims", models.BooleanField(default=False)),
|
||||||
|
("game_names", models.JSONField(blank=True, default=list)),
|
||||||
|
(
|
||||||
|
"event",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="planning_slots",
|
||||||
|
to="events.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["begin", "column"],
|
||||||
|
"constraints": [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=("event", "source_pk"), name="unique_event_planning_slot"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,17 +1,125 @@
|
|||||||
from django.db import models
|
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):
|
class Game(models.Model):
|
||||||
steam_app_id = models.PositiveIntegerField(unique=True)
|
steam_app_id = models.PositiveIntegerField(null=True, blank=True)
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
path = models.CharField(max_length=100)
|
path = models.CharField(max_length=100)
|
||||||
enabled = models.BooleanField(default=True)
|
custom_path = models.BooleanField(default=False)
|
||||||
|
image = models.URLField(null=True, blank=True)
|
||||||
|
description = models.TextField(blank=True, default="")
|
||||||
|
# Vertical (600x900) cover for games/showcase, empty = Steam's library capsule
|
||||||
|
library_image = models.URLField(max_length=500, blank=True, default="")
|
||||||
|
|
||||||
|
# Steam store info, refreshed in the background (see games.tasks).
|
||||||
|
# Prices are in cents, in steam_currency.
|
||||||
|
steam_price_initial = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
steam_price_final = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
steam_discount_percent = models.PositiveSmallIntegerField(default=0)
|
||||||
|
steam_currency = models.CharField(max_length=3, blank=True, default="")
|
||||||
|
steam_is_free = models.BooleanField(default=False)
|
||||||
|
steam_short_description = models.TextField(blank=True, default="")
|
||||||
|
steam_header_image = models.URLField(max_length=500, blank=True, default="")
|
||||||
|
steam_info_updated_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
objects = models.Manager.from_queryset(GameQuerySet)()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.name} ({self.steam_app_id})"
|
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})"
|
||||||
|
|||||||
@@ -1,7 +1,98 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from ninja import Schema
|
from ninja import Schema
|
||||||
|
|
||||||
|
|
||||||
class GameOut(Schema):
|
class GameOut(Schema):
|
||||||
steam_app_id: int
|
id: int
|
||||||
|
steam_app_id: Optional[int] = None
|
||||||
name: str
|
name: str
|
||||||
path: 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()
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django_object_actions import (
|
||||||
|
DjangoObjectActions,
|
||||||
|
action,
|
||||||
|
takes_instance_or_queryset,
|
||||||
|
)
|
||||||
from market.models import Market, MarketOption, UserBet, UserPointChange
|
from market.models import Market, MarketOption, UserBet, UserPointChange
|
||||||
|
|
||||||
|
|
||||||
@@ -9,9 +14,9 @@ class MarketOptionInline(admin.TabularInline):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(Market)
|
@admin.register(Market)
|
||||||
class MarketAdmin(admin.ModelAdmin):
|
class MarketAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||||
list_display = ["title", "status", "end_date", "created_by", "created_at"]
|
list_display = ["title", "status", "event", "end_date", "created_by", "created_at"]
|
||||||
list_filter = ["status", "created_at"]
|
list_filter = ["status", "created_at", "event"]
|
||||||
search_fields = ["uuid", "title"]
|
search_fields = ["uuid", "title"]
|
||||||
readonly_fields = [
|
readonly_fields = [
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -22,7 +27,7 @@ class MarketAdmin(admin.ModelAdmin):
|
|||||||
]
|
]
|
||||||
inlines = [MarketOptionInline]
|
inlines = [MarketOptionInline]
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
("Info", {"fields": ["uuid", "title", "description"]}),
|
("Info", {"fields": ["uuid", "title", "description", "event"]}),
|
||||||
("Configuration", {"fields": ["end_date", "multiplier"]}),
|
("Configuration", {"fields": ["end_date", "multiplier"]}),
|
||||||
("Status", {"fields": ["status", "winning_option"]}),
|
("Status", {"fields": ["status", "winning_option"]}),
|
||||||
("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}),
|
("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}),
|
||||||
@@ -39,22 +44,25 @@ class MarketAdmin(admin.ModelAdmin):
|
|||||||
obj.created_by = request.user
|
obj.created_by = request.user
|
||||||
super().save_model(request, obj, form, change)
|
super().save_model(request, obj, form, change)
|
||||||
|
|
||||||
@admin.action(description="Publish selected draft markets")
|
actions = ["publish_markets", "close_markets"]
|
||||||
|
change_actions = ["publish_markets", "close_markets"]
|
||||||
|
|
||||||
|
@action(label="Publish", description="Publish selected draft markets")
|
||||||
|
@takes_instance_or_queryset
|
||||||
def publish_markets(self, request, queryset):
|
def publish_markets(self, request, queryset):
|
||||||
updated = queryset.filter(status=Market.Status.DRAFT).update(
|
updated = queryset.filter(status=Market.Status.DRAFT).update(
|
||||||
status=Market.Status.OPEN
|
status=Market.Status.OPEN
|
||||||
)
|
)
|
||||||
self.message_user(request, f"Published {updated} market(s).")
|
self.message_user(request, f"Published {updated} market(s).")
|
||||||
|
|
||||||
@admin.action(description="Close selected markets")
|
@action(label="Close", description="Close selected markets")
|
||||||
|
@takes_instance_or_queryset
|
||||||
def close_markets(self, request, queryset):
|
def close_markets(self, request, queryset):
|
||||||
updated = queryset.filter(status=Market.Status.OPEN).update(
|
updated = queryset.filter(status=Market.Status.OPEN).update(
|
||||||
status=Market.Status.CLOSED
|
status=Market.Status.CLOSED
|
||||||
)
|
)
|
||||||
self.message_user(request, f"Closed {updated} market(s).")
|
self.message_user(request, f"Closed {updated} market(s).")
|
||||||
|
|
||||||
actions = ["publish_markets", "close_markets"]
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(MarketOption)
|
@admin.register(MarketOption)
|
||||||
class MarketOptionAdmin(admin.ModelAdmin):
|
class MarketOptionAdmin(admin.ModelAdmin):
|
||||||
|
|||||||
@@ -1,27 +1,40 @@
|
|||||||
from typing import List
|
from typing import List
|
||||||
from ninja import Router
|
from ninja import Router
|
||||||
|
from ninja.decorators import decorate_view
|
||||||
from ninja.errors import HttpError
|
from ninja.errors import HttpError
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.db.models import Sum, Prefetch
|
from django.db.models import Sum, Prefetch
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from market.models import Market, MarketOption, UserBet, UserPointChange
|
from games.decorators import require_game_enabled
|
||||||
|
from market.models import (
|
||||||
|
Market,
|
||||||
|
MarketOption,
|
||||||
|
UserBet,
|
||||||
|
UserPointChange,
|
||||||
|
get_event_points,
|
||||||
|
)
|
||||||
from market.schemas import (
|
from market.schemas import (
|
||||||
|
BetHistoryPointSchema,
|
||||||
MarketListSchema,
|
MarketListSchema,
|
||||||
ResolveMarketSchema,
|
ResolveMarketSchema,
|
||||||
UserBetCreateSchema,
|
UserBetCreateSchema,
|
||||||
UserBetSchema,
|
UserBetSchema,
|
||||||
|
UserPointsSchema,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
router = Router(tags=["market"])
|
router = Router(tags=["market"])
|
||||||
|
|
||||||
|
PREFIX = "/events/{event_path}/market"
|
||||||
|
|
||||||
@router.get("/", response=List[MarketListSchema])
|
|
||||||
def list_markets(request):
|
@router.get(f"{PREFIX}/", response=List[MarketListSchema])
|
||||||
|
@decorate_view(require_game_enabled(path="market"))
|
||||||
|
def list_markets(request, event_path: str):
|
||||||
"""List all markets (excludes draft markets)."""
|
"""List all markets (excludes draft markets)."""
|
||||||
markets = Market.objects.exclude(status=Market.Status.DRAFT)
|
markets = Market.for_event.exclude(status=Market.Status.DRAFT)
|
||||||
# Prefetch options with total_bets annotation sorted by total_bets desc, then text asc
|
# Prefetch options with total_bets annotation sorted by total_bets desc, then text asc
|
||||||
options_queryset = MarketOption.objects.annotate(
|
options_queryset = MarketOption.objects.annotate(
|
||||||
total_bets=Coalesce(Sum("user_bets__amount"), 0)
|
total_bets=Coalesce(Sum("user_bets__amount"), 0)
|
||||||
@@ -30,38 +43,84 @@ def list_markets(request):
|
|||||||
return markets.prefetch_related(Prefetch("options", queryset=options_queryset))
|
return markets.prefetch_related(Prefetch("options", queryset=options_queryset))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/user/bets", response=List[UserBetSchema])
|
@router.get(f"{PREFIX}/user/bets", response=List[UserBetSchema])
|
||||||
def list_user_bets(request):
|
@decorate_view(require_game_enabled(path="market"))
|
||||||
|
def list_user_bets(request, event_path: str):
|
||||||
"""List all bets placed by the current user."""
|
"""List all bets placed by the current user."""
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
raise HttpError(401, "Authentication required")
|
raise HttpError(401, "Authentication required")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
UserBet.objects.filter(user=request.user)
|
UserBet.objects.filter(user=request.user, option__market__event=request.event)
|
||||||
.select_related("option__market")
|
.select_related("option__market")
|
||||||
.prefetch_related("option__market__options")
|
.prefetch_related("option__market__options")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{market_uuid}/actions/close")
|
@router.get(f"{PREFIX}/user/points", response=UserPointsSchema)
|
||||||
def close_market(request, market_uuid: str):
|
@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."""
|
"""Close a market. Admin only."""
|
||||||
if not request.user.is_staff:
|
if not request.user.is_staff:
|
||||||
raise HttpError(403, "Permission denied")
|
raise HttpError(403, "Permission denied")
|
||||||
|
|
||||||
market = get_object_or_404(Market, uuid=market_uuid)
|
market = get_object_or_404(Market, uuid=market_uuid, event=request.event)
|
||||||
market.status = Market.Status.CLOSED
|
market.status = Market.Status.CLOSED
|
||||||
market.save(update_fields=["status", "updated_at"])
|
market.save(update_fields=["status", "updated_at"])
|
||||||
return {"status": Market.Status.CLOSED}
|
return {"status": Market.Status.CLOSED}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{market_uuid}/actions/resolve", response=MarketListSchema)
|
@router.post(f"{PREFIX}/{{market_uuid}}/actions/resolve", response=MarketListSchema)
|
||||||
def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema):
|
@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."""
|
"""Resolve a market with a winning option. Admin only."""
|
||||||
if not request.user.is_staff:
|
if not request.user.is_staff:
|
||||||
raise HttpError(403, "Permission denied")
|
raise HttpError(403, "Permission denied")
|
||||||
|
|
||||||
market = get_object_or_404(Market, uuid=market_uuid)
|
market = get_object_or_404(Market, uuid=market_uuid, event=request.event)
|
||||||
winning_option = get_object_or_404(MarketOption, uuid=payload.winning_option_uuid)
|
winning_option = get_object_or_404(MarketOption, uuid=payload.winning_option_uuid)
|
||||||
|
|
||||||
if winning_option.market_id != market.id:
|
if winning_option.market_id != market.id:
|
||||||
@@ -72,9 +131,7 @@ def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema):
|
|||||||
market.save(update_fields=["winning_option", "status", "updated_at"])
|
market.save(update_fields=["winning_option", "status", "updated_at"])
|
||||||
|
|
||||||
# Calculate and distribute winnings
|
# Calculate and distribute winnings
|
||||||
all_bets = list(
|
all_bets = list(UserBet.objects.filter(option__market=market))
|
||||||
UserBet.objects.filter(option__market=market).select_related("user")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate total pot
|
# Calculate total pot
|
||||||
total_pot = sum(bet.amount for bet in all_bets)
|
total_pot = sum(bet.amount for bet in all_bets)
|
||||||
@@ -88,7 +145,6 @@ def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema):
|
|||||||
total_winning = sum(bet.amount for bet in winning_bets)
|
total_winning = sum(bet.amount for bet in winning_bets)
|
||||||
|
|
||||||
point_changes = []
|
point_changes = []
|
||||||
users_to_update = []
|
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# Award payouts to winners with multiplier
|
# Award payouts to winners with multiplier
|
||||||
@@ -97,11 +153,9 @@ def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema):
|
|||||||
payout = round(
|
payout = round(
|
||||||
bet.amount / total_winning * total_pot * market.multiplier
|
bet.amount / total_winning * total_pot * market.multiplier
|
||||||
)
|
)
|
||||||
bet.user.points += payout
|
|
||||||
users_to_update.append(bet.user)
|
|
||||||
point_changes.append(
|
point_changes.append(
|
||||||
UserPointChange(
|
UserPointChange(
|
||||||
user=bet.user,
|
user_id=bet.user_id,
|
||||||
market=market,
|
market=market,
|
||||||
amount=payout,
|
amount=payout,
|
||||||
reason=UserPointChange.Reason.BET_WON,
|
reason=UserPointChange.Reason.BET_WON,
|
||||||
@@ -112,30 +166,29 @@ def resolve_market(request, market_uuid: str, payload: ResolveMarketSchema):
|
|||||||
for bet in losing_bets:
|
for bet in losing_bets:
|
||||||
point_changes.append(
|
point_changes.append(
|
||||||
UserPointChange(
|
UserPointChange(
|
||||||
user=bet.user,
|
user_id=bet.user_id,
|
||||||
market=market,
|
market=market,
|
||||||
amount=-bet.amount,
|
amount=-bet.amount,
|
||||||
reason=UserPointChange.Reason.BET_LOST,
|
reason=UserPointChange.Reason.BET_LOST,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Bulk update users
|
|
||||||
for user in users_to_update:
|
|
||||||
user.save(update_fields=["points"])
|
|
||||||
|
|
||||||
# Bulk create point changes
|
# Bulk create point changes
|
||||||
UserPointChange.objects.bulk_create(point_changes)
|
UserPointChange.objects.bulk_create(point_changes)
|
||||||
|
|
||||||
return market
|
return market
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{market_uuid}/bets", response=UserBetSchema)
|
@router.post(f"{PREFIX}/{{market_uuid}}/bets", response=UserBetSchema)
|
||||||
def create_bet(request, market_uuid: str, payload: UserBetCreateSchema):
|
@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."""
|
"""Place a bet on a market option."""
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
raise HttpError(401, "Authentication required")
|
raise HttpError(401, "Authentication required")
|
||||||
|
|
||||||
market = get_object_or_404(Market, uuid=market_uuid)
|
market = get_object_or_404(Market, uuid=market_uuid, event=request.event)
|
||||||
option = get_object_or_404(MarketOption, uuid=payload.option_uuid)
|
option = get_object_or_404(MarketOption, uuid=payload.option_uuid)
|
||||||
|
|
||||||
if option.market_id != market.id:
|
if option.market_id != market.id:
|
||||||
@@ -161,8 +214,8 @@ def create_bet(request, market_uuid: str, payload: UserBetCreateSchema):
|
|||||||
# Calculate delta (amount to deduct from user's points)
|
# Calculate delta (amount to deduct from user's points)
|
||||||
delta = payload.amount - (existing_bet.amount if existing_bet else 0)
|
delta = payload.amount - (existing_bet.amount if existing_bet else 0)
|
||||||
|
|
||||||
# Check if user has enough points
|
# Check if user has enough points for this event
|
||||||
if request.user.points < delta:
|
if get_event_points(request.user, request.event) < delta:
|
||||||
raise HttpError(400, "Insufficient points for this bet")
|
raise HttpError(400, "Insufficient points for this bet")
|
||||||
|
|
||||||
user_bet, created = UserBet.objects.update_or_create(
|
user_bet, created = UserBet.objects.update_or_create(
|
||||||
@@ -171,9 +224,7 @@ def create_bet(request, market_uuid: str, payload: UserBetCreateSchema):
|
|||||||
defaults={"amount": payload.amount},
|
defaults={"amount": payload.amount},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Deduct points and record the change
|
# Record the change (the event balance is derived from these)
|
||||||
request.user.points -= delta
|
|
||||||
request.user.save(update_fields=["points"])
|
|
||||||
UserPointChange.objects.create(
|
UserPointChange.objects.create(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
market=market,
|
market=market,
|
||||||
|
|||||||
@@ -0,0 +1,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),
|
||||||
|
]
|
||||||
@@ -1,5 +1,26 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Sum
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
from events.scoping import EventScopedManager
|
||||||
|
|
||||||
|
|
||||||
|
# Each event's market starts every user with this balance
|
||||||
|
STARTING_POINTS = 1000
|
||||||
|
|
||||||
|
|
||||||
|
def get_event_points(user, event) -> int:
|
||||||
|
"""User's market balance for an event: starting points + the event's point changes.
|
||||||
|
|
||||||
|
BET_LOST rows are informational (the stake was already deducted by BET_PLACED).
|
||||||
|
"""
|
||||||
|
total = (
|
||||||
|
UserPointChange.objects.filter(user=user, market__event=event)
|
||||||
|
.exclude(reason=UserPointChange.Reason.BET_LOST)
|
||||||
|
.aggregate(total=Sum("amount"))["total"]
|
||||||
|
)
|
||||||
|
return STARTING_POINTS + (total or 0)
|
||||||
|
|
||||||
|
|
||||||
class BaseModel(models.Model):
|
class BaseModel(models.Model):
|
||||||
@@ -18,6 +39,12 @@ class Market(BaseModel):
|
|||||||
CLOSED = "closed", "Closed"
|
CLOSED = "closed", "Closed"
|
||||||
RESOLVED = "resolved", "Resolved"
|
RESOLVED = "resolved", "Resolved"
|
||||||
|
|
||||||
|
event = models.ForeignKey(
|
||||||
|
Event,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="markets",
|
||||||
|
)
|
||||||
|
|
||||||
title = models.CharField(max_length=255)
|
title = models.CharField(max_length=255)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
@@ -34,6 +61,9 @@ class Market(BaseModel):
|
|||||||
related_name="market_won",
|
related_name="market_won",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
objects = models.Manager()
|
||||||
|
for_event = EventScopedManager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
indexes = [
|
indexes = [
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ class ResolveMarketSchema(Schema):
|
|||||||
winning_option_uuid: str
|
winning_option_uuid: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserPointsSchema(Schema):
|
||||||
|
points: int
|
||||||
|
|
||||||
|
|
||||||
|
class BetHistoryPointSchema(Schema):
|
||||||
|
option_uuid: str
|
||||||
|
created_at: datetime
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
|
||||||
class UserBetCreateSchema(Schema):
|
class UserBetCreateSchema(Schema):
|
||||||
option_uuid: str
|
option_uuid: str
|
||||||
amount: int
|
amount: int
|
||||||
|
|||||||
@@ -1 +1,23 @@
|
|||||||
# Create your tests here.
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from accounts.models import CustomUser
|
||||||
|
from events.models import Event
|
||||||
|
from games.models import EventGame, Game
|
||||||
|
|
||||||
|
|
||||||
|
class MarketGameGatingTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.event = Event.objects.create(name="Event", path="event")
|
||||||
|
self.game = Game.objects.get(path="market", custom_path=True)
|
||||||
|
self.user = CustomUser.objects.create_user(username="user", password="x")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_market_is_403_when_not_enabled_for_event(self):
|
||||||
|
response = self.client.get(f"/api/events/{self.event.path}/market/")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_market_is_reachable_when_enabled_for_event(self):
|
||||||
|
EventGame.objects.create(event=self.event, game=self.game)
|
||||||
|
response = self.client.get(f"/api/events/{self.event.path}/market/")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(self.client.get(f"/{self.event.path}/market").status_code, 200)
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.shortcuts import render
|
from django.shortcuts import get_object_or_404, render
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
from games.decorators import require_game_enabled
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def market_home(request: HttpRequest):
|
@require_game_enabled(path="market")
|
||||||
return render(request, "market.html", {})
|
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.contrib import admin
|
||||||
|
from django_object_actions import (
|
||||||
|
DjangoObjectActions,
|
||||||
|
action,
|
||||||
|
takes_instance_or_queryset,
|
||||||
|
)
|
||||||
|
|
||||||
from noita.services.objectives import parse_objectives_and_store
|
from noita.services.objectives import parse_objectives_and_store
|
||||||
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
|
from .models import LogfileSubmission, Objectiv, ObjectivPoint, DeathCounter
|
||||||
|
|
||||||
|
|
||||||
@admin.register(LogfileSubmission)
|
@admin.register(LogfileSubmission)
|
||||||
class LogfileSubmissionAdmin(admin.ModelAdmin):
|
class LogfileSubmissionAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
"id",
|
"id",
|
||||||
"user",
|
"user",
|
||||||
@@ -26,7 +31,10 @@ class LogfileSubmissionAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
actions = ["validate_submission"]
|
actions = ["validate_submission"]
|
||||||
|
change_actions = ["validate_submission"]
|
||||||
|
|
||||||
|
@action(label="Validate", description="Validate selected submissions")
|
||||||
|
@takes_instance_or_queryset
|
||||||
def validate_submission(self, request, queryset):
|
def validate_submission(self, request, queryset):
|
||||||
for logfile in queryset:
|
for logfile in queryset:
|
||||||
parse_objectives_and_store(logfile)
|
parse_objectives_and_store(logfile)
|
||||||
|
|||||||
@@ -25,11 +25,13 @@ from .schemas import NoitaSubmissionOut
|
|||||||
router = Router()
|
router = Router()
|
||||||
NOITA_APP_ID = 881100
|
NOITA_APP_ID = 881100
|
||||||
|
|
||||||
|
PREFIX = "/events/{event_path}/noita"
|
||||||
|
|
||||||
@router.get("results", response=ResultsOut)
|
|
||||||
|
@router.get(f"{PREFIX}/results", response=ResultsOut)
|
||||||
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||||
def get_results(request: HttpRequest):
|
def get_results(request: HttpRequest, event_path: str):
|
||||||
cache_key = f"api:noita:results:{request.user.id}"
|
cache_key = f"api:{event_path}:noita:results:{request.user.id}"
|
||||||
cached_data = cache.get(cache_key)
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
if cached_data is not None:
|
if cached_data is not None:
|
||||||
@@ -42,7 +44,7 @@ def get_results(request: HttpRequest):
|
|||||||
"""
|
"""
|
||||||
# Group objectives by objectiv_id and count occurrences
|
# Group objectives by objectiv_id and count occurrences
|
||||||
user_objectives = (
|
user_objectives = (
|
||||||
Objectiv.objects.filter(user=request.user)
|
Objectiv.objects.filter(user=request.user, submission__event=request.event)
|
||||||
.values("objectiv_id")
|
.values("objectiv_id")
|
||||||
.annotate(count=Count("id"))
|
.annotate(count=Count("id"))
|
||||||
)
|
)
|
||||||
@@ -118,7 +120,7 @@ def get_results(request: HttpRequest):
|
|||||||
total_score += points
|
total_score += points
|
||||||
|
|
||||||
# Count deaths for the user
|
# 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 = {
|
data = {
|
||||||
"total_score": total_score,
|
"total_score": total_score,
|
||||||
@@ -126,19 +128,19 @@ def get_results(request: HttpRequest):
|
|||||||
"objectives": list(with_points.values()),
|
"objectives": list(with_points.values()),
|
||||||
}
|
}
|
||||||
|
|
||||||
cache.set(f"api:noita:results:{request.user.id}", data, 300)
|
cache.set(cache_key, data, 300)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@router.get("leaderboard", response=LeaderboardOut)
|
@router.get(f"{PREFIX}/leaderboard", response=LeaderboardOut)
|
||||||
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||||
def get_leaderboard(request: HttpRequest):
|
def get_leaderboard(request: HttpRequest, event_path: str):
|
||||||
"""
|
"""
|
||||||
Get the global leaderboard for all users ranked by total score.
|
Get the global leaderboard for all users ranked by total score.
|
||||||
|
|
||||||
Uses Window functions to rank users by their total score in descending order.
|
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)
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
if cached_data is not None:
|
if cached_data is not None:
|
||||||
@@ -150,7 +152,8 @@ def get_leaderboard(request: HttpRequest):
|
|||||||
|
|
||||||
# Get all objectives with calculated points (grouped by objectiv_id and user)
|
# Get all objectives with calculated points (grouped by objectiv_id and user)
|
||||||
all_objectives = (
|
all_objectives = (
|
||||||
Objectiv.objects.values("user", "objectiv_id")
|
Objectiv.objects.filter(submission__event=request.event)
|
||||||
|
.values("user", "objectiv_id")
|
||||||
.annotate(count=Count("id"))
|
.annotate(count=Count("id"))
|
||||||
.annotate(
|
.annotate(
|
||||||
# Fetch points from ObjectivPoint using Subquery
|
# Fetch points from ObjectivPoint using Subquery
|
||||||
@@ -201,12 +204,12 @@ def get_leaderboard(request: HttpRequest):
|
|||||||
for user_id, total_score in user_totals_dict.items():
|
for user_id, total_score in user_totals_dict.items():
|
||||||
user = User.objects.get(id=user_id)
|
user = User.objects.get(id=user_id)
|
||||||
objectives_count = (
|
objectives_count = (
|
||||||
Objectiv.objects.filter(user_id=user_id)
|
Objectiv.objects.filter(user_id=user_id, submission__event=request.event)
|
||||||
.values("objectiv_id")
|
.values("objectiv_id")
|
||||||
.distinct()
|
.distinct()
|
||||||
.count()
|
.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(
|
users_with_scores.append(
|
||||||
{
|
{
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
@@ -232,13 +235,17 @@ def get_leaderboard(request: HttpRequest):
|
|||||||
]
|
]
|
||||||
|
|
||||||
data = {"leaderboard": leaderboard}
|
data = {"leaderboard": leaderboard}
|
||||||
cache.set("api:noita:leaderboard", data, 300)
|
cache.set(cache_key, data, 300)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@router.post("submit", response={200: NoitaSubmissionOut, 400: dict})
|
@router.post(
|
||||||
|
f"{PREFIX}/submit", response={200: NoitaSubmissionOut, 400: dict, 500: dict}
|
||||||
|
)
|
||||||
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
@decorate_view(require_game_enabled(NOITA_APP_ID))
|
||||||
def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
|
def submit_log_file(
|
||||||
|
request: HttpRequest, event_path: str, file: UploadedFile = File(...)
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Submit a Noita run file (log file, screenshot, or video).
|
Submit a Noita run file (log file, screenshot, or video).
|
||||||
|
|
||||||
@@ -267,6 +274,7 @@ def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
|
|||||||
try:
|
try:
|
||||||
# Create submission
|
# Create submission
|
||||||
submission = LogfileSubmission.objects.create(
|
submission = LogfileSubmission.objects.create(
|
||||||
|
event=request.event,
|
||||||
user=request.user if request.user.is_authenticated else None,
|
user=request.user if request.user.is_authenticated else None,
|
||||||
content_type=file.content_type,
|
content_type=file.content_type,
|
||||||
file_size=file.size,
|
file_size=file.size,
|
||||||
@@ -284,8 +292,8 @@ def submit_log_file(request: HttpRequest, file: UploadedFile = File(...)):
|
|||||||
|
|
||||||
# Invalidate caches on successful submission
|
# Invalidate caches on successful submission
|
||||||
if submission.user:
|
if submission.user:
|
||||||
cache.delete(f"api:noita:results:{submission.user.id}")
|
cache.delete(f"api:{event_path}:noita:results:{submission.user.id}")
|
||||||
cache.delete("api:noita:leaderboard")
|
cache.delete(f"api:{event_path}:noita:leaderboard")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": str(submission.id),
|
"id": str(submission.id),
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-09-15 21:10
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("events", "0002_seed_polylan_43"),
|
||||||
|
("noita", "0012_deathcounter_unique_death_per_seen_at"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="deathcounter",
|
||||||
|
name="event",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="death_counters",
|
||||||
|
to="events.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="logfilesubmission",
|
||||||
|
name="event",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="logfile_submissions",
|
||||||
|
to="events.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
EVENT_PATH = "polylan-43"
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_event(apps, schema_editor):
|
||||||
|
Event = apps.get_model("events", "Event")
|
||||||
|
LogfileSubmission = apps.get_model("noita", "LogfileSubmission")
|
||||||
|
DeathCounter = apps.get_model("noita", "DeathCounter")
|
||||||
|
event = Event.objects.get(path=EVENT_PATH)
|
||||||
|
LogfileSubmission.objects.filter(event__isnull=True).update(event=event)
|
||||||
|
DeathCounter.objects.filter(event__isnull=True).update(event=event)
|
||||||
|
|
||||||
|
|
||||||
|
def noop(apps, schema_editor):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0013_deathcounter_event_logfilesubmission_event"),
|
||||||
|
("events", "0002_seed_polylan_43"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(backfill_event, noop),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("noita", "0014_backfill_event"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="deathcounter",
|
||||||
|
name="event",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="death_counters",
|
||||||
|
to="events.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="logfilesubmission",
|
||||||
|
name="event",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="logfile_submissions",
|
||||||
|
to="events.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -4,6 +4,9 @@ from django.utils import timezone
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
from events.scoping import EventScopedManager
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
@@ -21,6 +24,12 @@ class LogfileSubmission(models.Model):
|
|||||||
# Identification
|
# Identification
|
||||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
|
||||||
|
event = models.ForeignKey(
|
||||||
|
Event,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="logfile_submissions",
|
||||||
|
)
|
||||||
|
|
||||||
content_type = models.CharField(max_length=100, help_text="MIME type of the file")
|
content_type = models.CharField(max_length=100, help_text="MIME type of the file")
|
||||||
file_size = models.PositiveIntegerField(help_text="File size in bytes")
|
file_size = models.PositiveIntegerField(help_text="File size in bytes")
|
||||||
file = models.FileField(
|
file = models.FileField(
|
||||||
@@ -44,6 +53,9 @@ class LogfileSubmission(models.Model):
|
|||||||
|
|
||||||
processed = models.BooleanField(default=False)
|
processed = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
objects = models.Manager()
|
||||||
|
for_event = EventScopedManager()
|
||||||
|
|
||||||
|
|
||||||
class Objectiv(models.Model):
|
class Objectiv(models.Model):
|
||||||
objectiv_id = models.CharField(max_length=64)
|
objectiv_id = models.CharField(max_length=64)
|
||||||
@@ -71,10 +83,18 @@ class ObjectivPoint(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class DeathCounter(models.Model):
|
class DeathCounter(models.Model):
|
||||||
|
event = models.ForeignKey(
|
||||||
|
Event,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="death_counters",
|
||||||
|
)
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
seed = models.CharField(max_length=32)
|
seed = models.CharField(max_length=32)
|
||||||
seen_at = models.DateTimeField()
|
seen_at = models.DateTimeField()
|
||||||
|
|
||||||
|
objects = models.Manager()
|
||||||
|
for_event = EventScopedManager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
constraints = [
|
constraints = [
|
||||||
models.UniqueConstraint(
|
models.UniqueConstraint(
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ def parse_objectives_and_store(logfile: LogfileSubmission) -> None:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if idx == "DEATH":
|
if idx == "DEATH":
|
||||||
deaths.append(DeathCounter(user=logfile.user, seed=seed, seen_at=ts))
|
deaths.append(
|
||||||
|
DeathCounter(
|
||||||
|
event=logfile.event, user=logfile.user, seed=seed, seen_at=ts
|
||||||
|
)
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
objectives.append(
|
objectives.append(
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ import { defineConfig } from '@hey-api/openapi-ts';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
input: `http://localhost:7777/api/openapi.json`,
|
input: `http://localhost:7777/api/openapi.json`,
|
||||||
output: 'src/api/',
|
output: 'src/api/',
|
||||||
|
plugins: ['@hey-api/typescript', '@hey-api/sdk', '@tanstack/vue-query'],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django_object_actions import (
|
||||||
|
DjangoObjectActions,
|
||||||
|
action,
|
||||||
|
takes_instance_or_queryset,
|
||||||
|
)
|
||||||
from opus_magnum.models import (
|
from opus_magnum.models import (
|
||||||
SteamAPIKey,
|
SteamAPIKey,
|
||||||
SteamCollection,
|
SteamCollection,
|
||||||
@@ -90,7 +95,16 @@ class SteamCollectionAdmin(admin.ModelAdmin):
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
"Basic Information",
|
"Basic Information",
|
||||||
{"fields": ("steam_id", "url", "title", "description", "is_active")},
|
{
|
||||||
|
"fields": (
|
||||||
|
"event",
|
||||||
|
"steam_id",
|
||||||
|
"url",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"is_active",
|
||||||
|
)
|
||||||
|
},
|
||||||
),
|
),
|
||||||
("Author Information", {"fields": ("author_name", "author_steam_id")}),
|
("Author Information", {"fields": ("author_name", "author_steam_id")}),
|
||||||
(
|
(
|
||||||
@@ -185,7 +199,7 @@ class PuzzleResponseInline(admin.TabularInline):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(Submission)
|
@admin.register(Submission)
|
||||||
class SubmissionAdmin(admin.ModelAdmin):
|
class SubmissionAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||||
list_display = [
|
list_display = [
|
||||||
"id",
|
"id",
|
||||||
"user",
|
"user",
|
||||||
@@ -238,9 +252,11 @@ class SubmissionAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
actions = ["mark_as_validated"]
|
actions = ["mark_as_validated"]
|
||||||
|
change_actions = ["mark_as_validated"]
|
||||||
|
|
||||||
|
@action(label="Validate", description="Mark selected submissions as validated")
|
||||||
|
@takes_instance_or_queryset
|
||||||
def mark_as_validated(self, request, queryset):
|
def mark_as_validated(self, request, queryset):
|
||||||
"""Mark selected submissions as validated"""
|
|
||||||
updated = 0
|
updated = 0
|
||||||
for submission in queryset:
|
for submission in queryset:
|
||||||
if not submission.is_validated:
|
if not submission.is_validated:
|
||||||
@@ -254,11 +270,9 @@ class SubmissionAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
self.message_user(request, f"{updated} submissions marked as validated.")
|
self.message_user(request, f"{updated} submissions marked as validated.")
|
||||||
|
|
||||||
mark_as_validated.short_description = "Mark selected submissions as validated"
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(PuzzleResponse)
|
@admin.register(PuzzleResponse)
|
||||||
class PuzzleResponseAdmin(admin.ModelAdmin):
|
class PuzzleResponseAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||||
list_display = [
|
list_display = [
|
||||||
"puzzle_name",
|
"puzzle_name",
|
||||||
"submission",
|
"submission",
|
||||||
@@ -314,22 +328,22 @@ class PuzzleResponseAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
actions = ["mark_for_validation", "clear_validation_flag"]
|
actions = ["mark_for_validation", "clear_validation_flag"]
|
||||||
|
change_actions = ["mark_for_validation", "clear_validation_flag"]
|
||||||
|
|
||||||
|
@action(label="Mark for validation", description="Mark as needing validation")
|
||||||
|
@takes_instance_or_queryset
|
||||||
def mark_for_validation(self, request, queryset):
|
def mark_for_validation(self, request, queryset):
|
||||||
"""Mark selected responses as needing validation"""
|
|
||||||
updated = queryset.update(needs_manual_validation=True)
|
updated = queryset.update(needs_manual_validation=True)
|
||||||
self.message_user(request, f"{updated} responses marked for validation.")
|
self.message_user(request, f"{updated} responses marked for validation.")
|
||||||
|
|
||||||
|
@action(label="Clear validation flag", description="Clear validation flag")
|
||||||
|
@takes_instance_or_queryset
|
||||||
def clear_validation_flag(self, request, queryset):
|
def clear_validation_flag(self, request, queryset):
|
||||||
"""Clear validation flag for selected responses"""
|
|
||||||
updated = queryset.update(needs_manual_validation=False)
|
updated = queryset.update(needs_manual_validation=False)
|
||||||
self.message_user(
|
self.message_user(
|
||||||
request, f"{updated} responses cleared from validation queue."
|
request, f"{updated} responses cleared from validation queue."
|
||||||
)
|
)
|
||||||
|
|
||||||
mark_for_validation.short_description = "Mark as needing validation"
|
|
||||||
clear_validation_flag.short_description = "Clear validation flag"
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(SubmissionFile)
|
@admin.register(SubmissionFile)
|
||||||
class SubmissionFileAdmin(admin.ModelAdmin):
|
class SubmissionFileAdmin(admin.ModelAdmin):
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import csv
|
||||||
|
|
||||||
from ninja import Router, File
|
from ninja import Router, File
|
||||||
from ninja.files import UploadedFile
|
from ninja.files import UploadedFile
|
||||||
from ninja.pagination import paginate
|
from ninja.pagination import paginate
|
||||||
@@ -5,6 +7,8 @@ from ninja.errors import HttpError
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from ninja.security import django_auth_is_staff
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from typing import List
|
from typing import List
|
||||||
@@ -25,6 +29,7 @@ from .schemas import (
|
|||||||
SubmissionOut,
|
SubmissionOut,
|
||||||
PuzzleResponseOut,
|
PuzzleResponseOut,
|
||||||
ValidationIn,
|
ValidationIn,
|
||||||
|
SubmissionFileOut,
|
||||||
SteamCollectionItemOut,
|
SteamCollectionItemOut,
|
||||||
SteamCollectionOut,
|
SteamCollectionOut,
|
||||||
)
|
)
|
||||||
@@ -32,50 +37,53 @@ from .schemas import (
|
|||||||
router = Router()
|
router = Router()
|
||||||
OPUS_MAGNUM_APP_ID = 558990
|
OPUS_MAGNUM_APP_ID = 558990
|
||||||
|
|
||||||
|
PREFIX = "/events/{event_path}/opus-magnum"
|
||||||
|
|
||||||
@router.get("/puzzles", response=List[SteamCollectionItemOut])
|
|
||||||
|
@router.get(f"{PREFIX}/puzzles", response=List[SteamCollectionItemOut])
|
||||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def list_puzzles(request):
|
def list_puzzles(request, event_path: str):
|
||||||
"""Get list of available puzzles"""
|
"""Get list of available puzzles"""
|
||||||
return SteamCollectionItem.objects.select_related("collection").filter(
|
return SteamCollectionItem.objects.select_related("collection").filter(
|
||||||
collection__is_active=True
|
collection__is_active=True, collection__event=request.event
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/collection", response=SteamCollectionOut)
|
@router.get(f"{PREFIX}/collection", response=SteamCollectionOut)
|
||||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def get_collection(request):
|
def get_collection(request, event_path: str):
|
||||||
"""Get the active collection details"""
|
"""Get the active collection details"""
|
||||||
collection = get_object_or_404(SteamCollection, is_active=True)
|
return get_object_or_404(SteamCollection.for_event, is_active=True)
|
||||||
return collection
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/submissions", response=List[SubmissionOut])
|
@router.get(f"{PREFIX}/submissions", response=List[SubmissionOut])
|
||||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
@paginate
|
@paginate
|
||||||
def list_submissions(request):
|
def list_submissions(request, event_path: str):
|
||||||
"""Get paginated list of submissions"""
|
"""Get paginated list of submissions"""
|
||||||
return Submission.objects.prefetch_related(
|
return Submission.for_event.prefetch_related(
|
||||||
"responses__files", "responses__puzzle"
|
"responses__files", "responses__puzzle"
|
||||||
).filter(user=request.user)
|
).filter(user=request.user)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/submissions/{submission_id}", response=SubmissionOut)
|
@router.get(f"{PREFIX}/submissions/{{submission_id}}", response=SubmissionOut)
|
||||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def get_submission(request, submission_id: str):
|
def get_submission(request, event_path: str, submission_id: str):
|
||||||
"""Get detailed submission by ID"""
|
"""Get detailed submission by ID"""
|
||||||
return get_object_or_404(
|
return get_object_or_404(
|
||||||
Submission.objects.prefetch_related(
|
Submission.for_event.prefetch_related(
|
||||||
"responses__files", "responses__puzzle"
|
"responses__files", "responses__puzzle"
|
||||||
).filter(user=request.user),
|
).filter(user=request.user),
|
||||||
id=submission_id,
|
id=submission_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/submissions", response=SubmissionOut)
|
@router.post(
|
||||||
|
f"{PREFIX}/submissions", response={200: SubmissionOut, 400: dict, 500: dict}
|
||||||
|
)
|
||||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def create_submission(
|
def create_submission(
|
||||||
request, data: SubmissionIn, files: List[UploadedFile] = File(...)
|
request, event_path: str, data: SubmissionIn, files: List[UploadedFile] = File(...)
|
||||||
):
|
):
|
||||||
"""Create a new submission with multiple puzzle responses"""
|
"""Create a new submission with multiple puzzle responses"""
|
||||||
|
|
||||||
@@ -92,7 +100,9 @@ def create_submission(
|
|||||||
if data.responses:
|
if data.responses:
|
||||||
for puzzle in data.responses:
|
for puzzle in data.responses:
|
||||||
if not get_object_or_404(
|
if not get_object_or_404(
|
||||||
SteamCollectionItem, id=puzzle.puzzle_id
|
SteamCollectionItem,
|
||||||
|
id=puzzle.puzzle_id,
|
||||||
|
collection__event=request.event,
|
||||||
).collection.accepting_submissions:
|
).collection.accepting_submissions:
|
||||||
raise HttpError(
|
raise HttpError(
|
||||||
403, "This tournament is no longer accepting submissions"
|
403, "This tournament is no longer accepting submissions"
|
||||||
@@ -117,19 +127,26 @@ def create_submission(
|
|||||||
for response_data in data.responses
|
for response_data in data.responses
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Non-admin submissions always go through manual validation
|
||||||
|
needs_validation = (
|
||||||
|
data.manual_validation_requested or not request.user.is_staff
|
||||||
|
)
|
||||||
|
|
||||||
# Create the submission
|
# Create the submission
|
||||||
submission = Submission.objects.create(
|
submission = Submission.objects.create(
|
||||||
|
event=request.event,
|
||||||
user=request.user if request.user.is_authenticated else None,
|
user=request.user if request.user.is_authenticated else None,
|
||||||
notes=data.notes,
|
notes=data.notes,
|
||||||
manual_validation_requested=data.manual_validation_requested
|
manual_validation_requested=needs_validation or auto_request_validation,
|
||||||
or auto_request_validation,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
file_index = 0
|
file_index = 0
|
||||||
for response_data in data.responses:
|
for response_data in data.responses:
|
||||||
# Get the puzzle
|
# Get the puzzle
|
||||||
puzzle = get_object_or_404(
|
puzzle = get_object_or_404(
|
||||||
SteamCollectionItem, id=response_data.puzzle_id
|
SteamCollectionItem,
|
||||||
|
id=response_data.puzzle_id,
|
||||||
|
collection__event=request.event,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create the puzzle response
|
# Create the puzzle response
|
||||||
@@ -140,7 +157,7 @@ def create_submission(
|
|||||||
cost=response_data.cost,
|
cost=response_data.cost,
|
||||||
cycles=response_data.cycles,
|
cycles=response_data.cycles,
|
||||||
area=response_data.area,
|
area=response_data.area,
|
||||||
needs_manual_validation=data.manual_validation_requested,
|
needs_manual_validation=needs_validation,
|
||||||
ocr_confidence_cost=response_data.ocr_confidence_cost,
|
ocr_confidence_cost=response_data.ocr_confidence_cost,
|
||||||
ocr_confidence_cycles=response_data.ocr_confidence_cycles,
|
ocr_confidence_cycles=response_data.ocr_confidence_cycles,
|
||||||
ocr_confidence_area=response_data.ocr_confidence_area,
|
ocr_confidence_area=response_data.ocr_confidence_area,
|
||||||
@@ -150,7 +167,7 @@ def create_submission(
|
|||||||
"validated_cycles": response_data.cycles,
|
"validated_cycles": response_data.cycles,
|
||||||
"validated_area": response_data.area,
|
"validated_area": response_data.area,
|
||||||
}
|
}
|
||||||
if not data.manual_validation_requested
|
if not needs_validation
|
||||||
else {},
|
else {},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -192,12 +209,12 @@ def create_submission(
|
|||||||
response.mark_for_validation("Incomplete OCR data")
|
response.mark_for_validation("Incomplete OCR data")
|
||||||
|
|
||||||
# Reload with relations for response
|
# Reload with relations for response
|
||||||
submission = Submission.objects.prefetch_related(
|
submission = Submission.for_event.prefetch_related(
|
||||||
"responses__files", "responses__puzzle"
|
"responses__files", "responses__puzzle"
|
||||||
).get(id=submission.id)
|
).get(id=submission.id)
|
||||||
|
|
||||||
# Invalidate results cache on successful submission
|
# Invalidate results cache on successful submission
|
||||||
cache.delete("api:results:results")
|
cache.delete(f"api:{event_path}:results:results")
|
||||||
|
|
||||||
return submission
|
return submission
|
||||||
|
|
||||||
@@ -205,18 +222,25 @@ def create_submission(
|
|||||||
return 500, {"detail": f"Error creating submission: {str(e)}"}
|
return 500, {"detail": f"Error creating submission: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/responses/{response_id}/validate", response=PuzzleResponseOut)
|
@router.put(
|
||||||
|
f"{PREFIX}/responses/{{response_id}}/validate",
|
||||||
|
response={200: PuzzleResponseOut, 403: dict},
|
||||||
|
)
|
||||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def validate_response(request, response_id: int, data: ValidationIn):
|
def validate_response(request, event_path: str, response_id: int, data: ValidationIn):
|
||||||
"""Manually validate a puzzle response"""
|
"""Manually validate a puzzle response"""
|
||||||
|
|
||||||
if not request.user.is_authenticated or not request.user.is_staff:
|
if not request.user.is_authenticated or not request.user.is_staff:
|
||||||
return 403, {"detail": "Admin access required"}
|
return 403, {"detail": "Admin access required"}
|
||||||
|
|
||||||
response = get_object_or_404(PuzzleResponse, id=response_id)
|
response = get_object_or_404(
|
||||||
|
PuzzleResponse, id=response_id, submission__event=request.event
|
||||||
|
)
|
||||||
|
|
||||||
if data.puzzle is not None:
|
if data.puzzle is not None:
|
||||||
puzzle = get_object_or_404(SteamCollectionItem, id=data.puzzle)
|
puzzle = get_object_or_404(
|
||||||
|
SteamCollectionItem, id=data.puzzle, collection__event=request.event
|
||||||
|
)
|
||||||
response.puzzle = puzzle
|
response.puzzle = puzzle
|
||||||
|
|
||||||
# Update validated values
|
# Update validated values
|
||||||
@@ -236,20 +260,25 @@ def validate_response(request, response_id: int, data: ValidationIn):
|
|||||||
response.save()
|
response.save()
|
||||||
|
|
||||||
# Invalidate results cache when a response is validated
|
# Invalidate results cache when a response is validated
|
||||||
cache.delete("api:results:results")
|
cache.delete(f"api:{event_path}:results:results")
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.put("/responses/{response_id}/validate/auto", response=PuzzleResponseOut)
|
@router.put(
|
||||||
|
f"{PREFIX}/responses/{{response_id}}/validate/auto",
|
||||||
|
response={200: PuzzleResponseOut, 403: dict},
|
||||||
|
)
|
||||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def validate_auto(request, response_id: int):
|
def validate_auto(request, event_path: str, response_id: int):
|
||||||
"""Try to auto validate a puzzle response"""
|
"""Try to auto validate a puzzle response"""
|
||||||
|
|
||||||
if not request.user.is_authenticated or not request.user.is_staff:
|
if not request.user.is_authenticated or not request.user.is_staff:
|
||||||
return 403, {"detail": "Admin access required"}
|
return 403, {"detail": "Admin access required"}
|
||||||
|
|
||||||
response = get_object_or_404(PuzzleResponse, id=response_id)
|
response = get_object_or_404(
|
||||||
|
PuzzleResponse, id=response_id, submission__event=request.event
|
||||||
|
)
|
||||||
|
|
||||||
for file in response.files.all():
|
for file in response.files.all():
|
||||||
verify_and_validate_ocr_date_for_submission(file)
|
verify_and_validate_ocr_date_for_submission(file)
|
||||||
@@ -257,9 +286,12 @@ def validate_auto(request, response_id: int):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/responses/needs-validation", response=List[PuzzleResponseOut])
|
@router.get(
|
||||||
|
f"{PREFIX}/responses/needs-validation",
|
||||||
|
response={200: List[PuzzleResponseOut], 403: dict},
|
||||||
|
)
|
||||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def list_responses_needing_validation(request):
|
def list_responses_needing_validation(request, event_path: str):
|
||||||
"""Get all responses that need manual validation"""
|
"""Get all responses that need manual validation"""
|
||||||
|
|
||||||
if not request.user.is_authenticated or not request.user.is_staff:
|
if not request.user.is_authenticated or not request.user.is_staff:
|
||||||
@@ -267,21 +299,44 @@ def list_responses_needing_validation(request):
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
PuzzleResponse.objects.filter(needs_manual_validation=True)
|
PuzzleResponse.objects.filter(needs_manual_validation=True)
|
||||||
.filter(puzzle__collection__is_active=True)
|
.filter(
|
||||||
|
puzzle__collection__is_active=True, puzzle__collection__event=request.event
|
||||||
|
)
|
||||||
.select_related("puzzle", "submission")
|
.select_related("puzzle", "submission")
|
||||||
.prefetch_related("files")
|
.prefetch_related("files")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/submissions/{submission_id}/validate", response=SubmissionOut)
|
@router.get(
|
||||||
|
f"{PREFIX}/responses/{{response_id}}/files",
|
||||||
|
response={200: List[SubmissionFileOut], 403: dict},
|
||||||
|
)
|
||||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def validate_submission(request, submission_id: str):
|
def list_response_files(request, event_path: str, response_id: int):
|
||||||
|
"""Get the solution files of any player's response (admin only)"""
|
||||||
|
|
||||||
|
if not request.user.is_authenticated or not request.user.is_staff:
|
||||||
|
return 403, {"detail": "Admin access required"}
|
||||||
|
|
||||||
|
response = get_object_or_404(
|
||||||
|
PuzzleResponse, id=response_id, submission__event=request.event
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.files.all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
f"{PREFIX}/submissions/{{submission_id}}/validate",
|
||||||
|
response={200: SubmissionOut, 403: dict},
|
||||||
|
)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
|
def validate_submission(request, event_path: str, submission_id: str):
|
||||||
"""Mark entire submission as validated"""
|
"""Mark entire submission as validated"""
|
||||||
|
|
||||||
if not request.user.is_authenticated or not request.user.is_staff:
|
if not request.user.is_authenticated or not request.user.is_staff:
|
||||||
return 403, {"detail": "Admin access required"}
|
return 403, {"detail": "Admin access required"}
|
||||||
|
|
||||||
submission = get_object_or_404(Submission, id=submission_id)
|
submission = get_object_or_404(Submission.for_event, id=submission_id)
|
||||||
|
|
||||||
submission.is_validated = True
|
submission.is_validated = True
|
||||||
submission.validated_by = request.user
|
submission.validated_by = request.user
|
||||||
@@ -292,44 +347,48 @@ def validate_submission(request, submission_id: str):
|
|||||||
submission.responses.update(needs_manual_validation=False)
|
submission.responses.update(needs_manual_validation=False)
|
||||||
|
|
||||||
# Reload with relations
|
# Reload with relations
|
||||||
submission = Submission.objects.prefetch_related(
|
submission = Submission.for_event.prefetch_related(
|
||||||
"responses__files", "responses__puzzle"
|
"responses__files", "responses__puzzle"
|
||||||
).get(id=submission.id)
|
).get(id=submission.id)
|
||||||
|
|
||||||
# Invalidate results cache when submission is validated
|
# Invalidate results cache when submission is validated
|
||||||
cache.delete("api:results:results")
|
cache.delete(f"api:{event_path}:results:results")
|
||||||
|
|
||||||
return submission
|
return submission
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/submissions/{submission_id}")
|
@router.delete(
|
||||||
|
f"{PREFIX}/submissions/{{submission_id}}", response={200: dict, 403: dict}
|
||||||
|
)
|
||||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def delete_submission(request, submission_id: str):
|
def delete_submission(request, event_path: str, submission_id: str):
|
||||||
"""Delete a submission (admin only)"""
|
"""Delete a submission (admin only)"""
|
||||||
|
|
||||||
if not request.user.is_authenticated or not request.user.is_staff:
|
if not request.user.is_authenticated or not request.user.is_staff:
|
||||||
return 403, {"detail": "Admin access required"}
|
return 403, {"detail": "Admin access required"}
|
||||||
|
|
||||||
submission = get_object_or_404(Submission, id=submission_id)
|
submission = get_object_or_404(Submission.for_event, id=submission_id)
|
||||||
submission.delete()
|
submission.delete()
|
||||||
|
|
||||||
# Invalidate results cache when submission is deleted
|
# Invalidate results cache when submission is deleted
|
||||||
cache.delete("api:results:results")
|
cache.delete(f"api:{event_path}:results:results")
|
||||||
|
|
||||||
return {"detail": "Submission deleted successfully"}
|
return {"detail": "Submission deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get(f"{PREFIX}/stats")
|
||||||
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
def get_stats(request):
|
def get_stats(request, event_path: str):
|
||||||
"""Get submission statistics"""
|
"""Get submission statistics"""
|
||||||
|
|
||||||
total_submissions = Submission.objects.count()
|
total_submissions = Submission.for_event.count()
|
||||||
total_responses = PuzzleResponse.objects.count()
|
total_responses = PuzzleResponse.objects.filter(
|
||||||
needs_validation = PuzzleResponse.objects.filter(
|
submission__event=request.event
|
||||||
needs_manual_validation=True
|
|
||||||
).count()
|
).count()
|
||||||
validated_submissions = Submission.objects.filter(is_validated=True).count()
|
needs_validation = PuzzleResponse.objects.filter(
|
||||||
|
needs_manual_validation=True, submission__event=request.event
|
||||||
|
).count()
|
||||||
|
validated_submissions = Submission.for_event.filter(is_validated=True).count()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_submissions": total_submissions,
|
"total_submissions": total_submissions,
|
||||||
@@ -340,3 +399,41 @@ def get_stats(request):
|
|||||||
if total_responses
|
if total_responses
|
||||||
else 0,
|
else 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{PREFIX}/points.csv", auth=django_auth_is_staff)
|
||||||
|
@decorate_view(require_game_enabled(OPUS_MAGNUM_APP_ID))
|
||||||
|
def points_csv(request: HttpRequest, event_path: str):
|
||||||
|
"""Admin-only: points per player for each puzzle of the active collection,
|
||||||
|
one column per puzzle (1 puzzle = 1 round), `;` separated."""
|
||||||
|
puzzles = list(
|
||||||
|
SteamCollectionItem.objects.filter(
|
||||||
|
collection__is_active=True, collection__event=request.event
|
||||||
|
).order_by("order_index")
|
||||||
|
)
|
||||||
|
|
||||||
|
points = {}
|
||||||
|
for response in (
|
||||||
|
PuzzleResponse.objects.filter(
|
||||||
|
needs_manual_validation=False,
|
||||||
|
submission__event=request.event,
|
||||||
|
submission__user__isnull=False,
|
||||||
|
puzzle__in=puzzles,
|
||||||
|
)
|
||||||
|
.filter_user_best_response()
|
||||||
|
.select_related("submission__user")
|
||||||
|
):
|
||||||
|
user_points = points.setdefault(response.submission.user.username, {})
|
||||||
|
user_points[response.puzzle_id] = response.rank_points or 0
|
||||||
|
|
||||||
|
# text/plain so browsers render it inline - most force-download text/csv
|
||||||
|
response = HttpResponse(content_type="text/plain; charset=utf-8")
|
||||||
|
writer = csv.writer(response, delimiter=";")
|
||||||
|
writer.writerow(["username", *(puzzle.title for puzzle in puzzles)])
|
||||||
|
for username, user_points in sorted(
|
||||||
|
points.items(), key=lambda item: (-sum(item[1].values()), item[0])
|
||||||
|
):
|
||||||
|
writer.writerow(
|
||||||
|
[username, *(user_points.get(puzzle.id, 0) for puzzle in puzzles)]
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Django management command to fetch Steam Workshop collections
|
|||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from opus_magnum.utils import create_or_update_collection
|
from opus_magnum.utils import create_or_update_collection
|
||||||
|
from events.models import Event
|
||||||
from opus_magnum.models import SteamAPIKey, SteamCollection
|
from opus_magnum.models import SteamAPIKey, SteamCollection
|
||||||
|
|
||||||
|
|
||||||
@@ -12,6 +13,12 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument("url", type=str, help="Steam Workshop collection URL")
|
parser.add_argument("url", type=str, help="Steam Workshop collection URL")
|
||||||
|
parser.add_argument(
|
||||||
|
"--event",
|
||||||
|
type=str,
|
||||||
|
required=True,
|
||||||
|
help="Path of the event the collection belongs to",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--force",
|
"--force",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -22,6 +29,11 @@ class Command(BaseCommand):
|
|||||||
url = options["url"]
|
url = options["url"]
|
||||||
force = options["force"]
|
force = options["force"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = Event.objects.get(path=options["event"])
|
||||||
|
except Event.DoesNotExist:
|
||||||
|
raise CommandError(f"No event with path '{options['event']}'")
|
||||||
|
|
||||||
self.stdout.write(f"Fetching Steam collection from: {url}")
|
self.stdout.write(f"Fetching Steam collection from: {url}")
|
||||||
|
|
||||||
api_key = SteamAPIKey.objects.filter(is_active=True).first()
|
api_key = SteamAPIKey.objects.filter(is_active=True).first()
|
||||||
@@ -53,7 +65,7 @@ class Command(BaseCommand):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Fetch and create/update collection
|
# Fetch and create/update collection
|
||||||
collection, created = create_or_update_collection(url)
|
collection, created = create_or_update_collection(url, event)
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
|
|||||||
@@ -3,45 +3,31 @@
|
|||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
TABLE_PAIRS = [
|
||||||
|
("submissions_steamapikey", "opus_magnum_steamapikey"),
|
||||||
|
("submissions_steamcollection", "opus_magnum_steamcollection"),
|
||||||
|
("submissions_steamcollectionitem", "opus_magnum_steamcollectionitem"),
|
||||||
|
("submissions_submission", "opus_magnum_submission"),
|
||||||
|
("submissions_puzzleresponse", "opus_magnum_puzzleresponse"),
|
||||||
|
("submissions_submissionfile", "opus_magnum_submissionfile"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def migrate_data_forward(apps, schema_editor):
|
def migrate_data_forward(apps, schema_editor):
|
||||||
"""Copy data from submissions_* tables to opus_magnum_* tables"""
|
"""Copy data from submissions_* tables to opus_magnum_* tables.
|
||||||
# Use raw SQL to copy data while preserving all fields
|
|
||||||
|
The source tables only exist on databases that predate the submissions ->
|
||||||
|
opus_magnum rename; a database built fresh from this migration history
|
||||||
|
never has them, so skip pairs whose source table is absent instead of
|
||||||
|
failing.
|
||||||
|
"""
|
||||||
|
existing_tables = set(schema_editor.connection.introspection.table_names())
|
||||||
|
|
||||||
with schema_editor.connection.cursor() as cursor:
|
with schema_editor.connection.cursor() as cursor:
|
||||||
# Copy SteamAPIKey
|
for source, dest in TABLE_PAIRS:
|
||||||
cursor.execute("""
|
if source not in existing_tables:
|
||||||
INSERT INTO opus_magnum_steamapikey
|
continue
|
||||||
SELECT * FROM submissions_steamapikey
|
cursor.execute(f"INSERT INTO {dest} SELECT * FROM {source}")
|
||||||
""")
|
|
||||||
|
|
||||||
# Copy SteamCollection
|
|
||||||
cursor.execute("""
|
|
||||||
INSERT INTO opus_magnum_steamcollection
|
|
||||||
SELECT * FROM submissions_steamcollection
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Copy SteamCollectionItem
|
|
||||||
cursor.execute("""
|
|
||||||
INSERT INTO opus_magnum_steamcollectionitem
|
|
||||||
SELECT * FROM submissions_steamcollectionitem
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Copy Submission
|
|
||||||
cursor.execute("""
|
|
||||||
INSERT INTO opus_magnum_submission
|
|
||||||
SELECT * FROM submissions_submission
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Copy PuzzleResponse
|
|
||||||
cursor.execute("""
|
|
||||||
INSERT INTO opus_magnum_puzzleresponse
|
|
||||||
SELECT * FROM submissions_puzzleresponse
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Copy SubmissionFile
|
|
||||||
cursor.execute("""
|
|
||||||
INSERT INTO opus_magnum_submissionfile
|
|
||||||
SELECT * FROM submissions_submissionfile
|
|
||||||
""")
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_data_backward(apps, schema_editor):
|
def migrate_data_backward(apps, schema_editor):
|
||||||
|
|||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-09-15 21:09
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("events", "0002_seed_polylan_43"),
|
||||||
|
("opus_magnum", "0015_migrate_data_from_submissions"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="steamcollection",
|
||||||
|
name="event",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="steam_collections",
|
||||||
|
to="events.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="submission",
|
||||||
|
name="event",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="opus_magnum_submissions",
|
||||||
|
to="events.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
EVENT_PATH = "polylan-43"
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_event(apps, schema_editor):
|
||||||
|
Event = apps.get_model("events", "Event")
|
||||||
|
SteamCollection = apps.get_model("opus_magnum", "SteamCollection")
|
||||||
|
Submission = apps.get_model("opus_magnum", "Submission")
|
||||||
|
event = Event.objects.get(path=EVENT_PATH)
|
||||||
|
SteamCollection.objects.filter(event__isnull=True).update(event=event)
|
||||||
|
Submission.objects.filter(event__isnull=True).update(event=event)
|
||||||
|
|
||||||
|
|
||||||
|
def noop(apps, schema_editor):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("opus_magnum", "0016_steamcollection_event_submission_event"),
|
||||||
|
("events", "0002_seed_polylan_43"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(backfill_event, noop),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("opus_magnum", "0017_backfill_event"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="steamcollection",
|
||||||
|
name="event",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="steam_collections",
|
||||||
|
to="events.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="submission",
|
||||||
|
name="event",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="opus_magnum_submissions",
|
||||||
|
to="events.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# 0015 copied rows with their ids via raw INSERT ... SELECT *, which doesn't
|
||||||
|
# bump postgres sequences -> new inserts collide on the pkey. Resync them.
|
||||||
|
|
||||||
|
from django.core.management.color import no_style
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
MODEL_NAMES = [
|
||||||
|
"SteamAPIKey",
|
||||||
|
"SteamCollection",
|
||||||
|
"SteamCollectionItem",
|
||||||
|
"Submission",
|
||||||
|
"PuzzleResponse",
|
||||||
|
"SubmissionFile",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def reset_sequences(apps, schema_editor):
|
||||||
|
connection = schema_editor.connection
|
||||||
|
models = [apps.get_model("opus_magnum", name) for name in MODEL_NAMES]
|
||||||
|
statements = connection.ops.sequence_reset_sql(no_style(), models)
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
for sql in statements:
|
||||||
|
cursor.execute(sql)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("opus_magnum", "0018_event_required"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(reset_sequences, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-10-23 21:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("opus_magnum", "0019_reset_sequences"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="puzzleresponse",
|
||||||
|
name="area",
|
||||||
|
field=models.IntegerField(
|
||||||
|
blank=True, help_text="Area value from OCR", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="puzzleresponse",
|
||||||
|
name="cost",
|
||||||
|
field=models.IntegerField(
|
||||||
|
blank=True, help_text="Cost value from OCR", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="puzzleresponse",
|
||||||
|
name="cycles",
|
||||||
|
field=models.IntegerField(
|
||||||
|
blank=True, help_text="Cycles value from OCR", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -8,6 +8,9 @@ from django.db.models.expressions import Window
|
|||||||
from django.db.models.functions import Cast, Rank, RowNumber
|
from django.db.models.functions import Cast, Rank, RowNumber
|
||||||
from django.db.models.query import F
|
from django.db.models.query import F
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
from events.scoping import EventScopedManager
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
@@ -108,6 +111,12 @@ class SteamAPIKey(models.Model):
|
|||||||
class SteamCollection(models.Model):
|
class SteamCollection(models.Model):
|
||||||
"""Model representing a Steam Workshop collection"""
|
"""Model representing a Steam Workshop collection"""
|
||||||
|
|
||||||
|
event = models.ForeignKey(
|
||||||
|
Event,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="steam_collections",
|
||||||
|
)
|
||||||
|
|
||||||
# Basic collection info
|
# Basic collection info
|
||||||
steam_id = models.CharField(
|
steam_id = models.CharField(
|
||||||
max_length=50, unique=True, help_text="Steam collection ID from URL"
|
max_length=50, unique=True, help_text="Steam collection ID from URL"
|
||||||
@@ -164,6 +173,9 @@ class SteamCollection(models.Model):
|
|||||||
blank=True, help_text="Last error encountered when fetching data"
|
blank=True, help_text="Last error encountered when fetching data"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
objects = models.Manager()
|
||||||
|
for_event = EventScopedManager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
verbose_name = "Steam Collection"
|
verbose_name = "Steam Collection"
|
||||||
@@ -257,6 +269,12 @@ class Submission(models.Model):
|
|||||||
# Identification
|
# Identification
|
||||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
|
||||||
|
event = models.ForeignKey(
|
||||||
|
Event,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="opus_magnum_submissions",
|
||||||
|
)
|
||||||
|
|
||||||
# User information (optional for anonymous submissions)
|
# User information (optional for anonymous submissions)
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
User,
|
User,
|
||||||
@@ -299,6 +317,9 @@ class Submission(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
objects = models.Manager()
|
||||||
|
for_event = EventScopedManager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
verbose_name = "Submission"
|
verbose_name = "Submission"
|
||||||
@@ -379,9 +400,9 @@ class PuzzleResponse(models.Model):
|
|||||||
puzzle_name = models.CharField(
|
puzzle_name = models.CharField(
|
||||||
max_length=255, help_text="Puzzle name as detected by OCR"
|
max_length=255, help_text="Puzzle name as detected by OCR"
|
||||||
)
|
)
|
||||||
cost = models.IntegerField(blank=True, help_text="Cost value from OCR")
|
cost = models.IntegerField(null=True, blank=True, help_text="Cost value from OCR")
|
||||||
cycles = models.IntegerField(blank=True, help_text="Cycles value from OCR")
|
cycles = models.IntegerField(null=True, blank=True, help_text="Cycles value from OCR")
|
||||||
area = models.IntegerField(blank=True, help_text="Area value from OCR")
|
area = models.IntegerField(null=True, blank=True, help_text="Area value from OCR")
|
||||||
|
|
||||||
# Validation flags
|
# Validation flags
|
||||||
needs_manual_validation = models.BooleanField(
|
needs_manual_validation = models.BooleanField(
|
||||||
|
|||||||
@@ -1 +1,74 @@
|
|||||||
# Create your tests here.
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from accounts.models import CustomUser
|
||||||
|
from animations.models import PuzzlePointsFactor, PuzzlePointsValue
|
||||||
|
from events.models import Event
|
||||||
|
from games.models import EventGame, Game
|
||||||
|
|
||||||
|
from .api import OPUS_MAGNUM_APP_ID
|
||||||
|
from .models import PuzzleResponse, SteamCollection, SteamCollectionItem, Submission
|
||||||
|
|
||||||
|
URL = "/api/events/event/opus-magnum/points.csv"
|
||||||
|
|
||||||
|
|
||||||
|
class PointsCsvTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.event = Event.objects.create(name="Event", path="event")
|
||||||
|
game = Game.objects.create(name="Opus Magnum", steam_app_id=OPUS_MAGNUM_APP_ID)
|
||||||
|
EventGame.objects.create(event=self.event, game=game)
|
||||||
|
collection = SteamCollection.objects.create(
|
||||||
|
event=self.event, steam_id="1", url="https://example.com", is_active=True
|
||||||
|
)
|
||||||
|
factor = PuzzlePointsFactor.objects.create(cost=1, cycles=1, area=1)
|
||||||
|
value = PuzzlePointsValue.objects.create(points=[10, 5, 1])
|
||||||
|
self.p1, self.p2 = (
|
||||||
|
SteamCollectionItem.objects.create(
|
||||||
|
collection=collection,
|
||||||
|
steam_item_id=str(i),
|
||||||
|
title=f"Puzzle {i}",
|
||||||
|
order_index=i,
|
||||||
|
points_factor=factor,
|
||||||
|
points_value=value,
|
||||||
|
)
|
||||||
|
for i in (1, 2)
|
||||||
|
)
|
||||||
|
self.alice = CustomUser.objects.create_user(username="alice", password="x")
|
||||||
|
self.bob = CustomUser.objects.create_user(username="bob", password="x")
|
||||||
|
self.admin = CustomUser.objects.create_user(
|
||||||
|
username="admin", password="x", is_staff=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def respond(self, user, puzzle, score):
|
||||||
|
submission = Submission.objects.create(event=self.event, user=user)
|
||||||
|
PuzzleResponse.objects.create(
|
||||||
|
submission=submission,
|
||||||
|
puzzle=puzzle,
|
||||||
|
puzzle_name=puzzle.title,
|
||||||
|
validated_cost=score,
|
||||||
|
validated_cycles=0,
|
||||||
|
validated_area=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_requires_staff(self):
|
||||||
|
self.client.force_login(self.alice)
|
||||||
|
|
||||||
|
response = self.client.get(URL)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
def test_points_per_puzzle(self):
|
||||||
|
self.respond(self.alice, self.p1, 10)
|
||||||
|
self.respond(self.bob, self.p1, 20)
|
||||||
|
self.respond(self.bob, self.p2, 30)
|
||||||
|
self.client.force_login(self.admin)
|
||||||
|
|
||||||
|
response = self.client.get(URL)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
response.content.decode().splitlines(),
|
||||||
|
[
|
||||||
|
"username;Puzzle 1;Puzzle 2",
|
||||||
|
"bob;5;10",
|
||||||
|
"alice;10;0",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Utilities for fetching Steam Workshop collection data using Steam Web API
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import requests
|
import requests
|
||||||
|
from events.models import Event
|
||||||
from opus_magnum.models import SteamCollection, SteamCollectionItem, SubmissionFile
|
from opus_magnum.models import SteamCollection, SteamCollectionItem, SubmissionFile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -373,12 +374,15 @@ def fetch_steam_collection(url: str) -> Dict:
|
|||||||
return fetcher.fetch_collection_data(url)
|
return fetcher.fetch_collection_data(url)
|
||||||
|
|
||||||
|
|
||||||
def create_or_update_collection(url: str) -> Tuple[SteamCollection, bool]:
|
def create_or_update_collection(
|
||||||
|
url: str, event: Event
|
||||||
|
) -> Tuple[SteamCollection, bool]:
|
||||||
"""
|
"""
|
||||||
Create or update a Steam collection in the database
|
Create or update a Steam collection in the database
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: Steam Workshop collection URL
|
url: Steam Workshop collection URL
|
||||||
|
event: Event the collection belongs to
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (SteamCollection instance, created_flag)
|
Tuple of (SteamCollection instance, created_flag)
|
||||||
@@ -395,6 +399,7 @@ def create_or_update_collection(url: str) -> Tuple[SteamCollection, bool]:
|
|||||||
collection, created = SteamCollection.objects.update_or_create(
|
collection, created = SteamCollection.objects.update_or_create(
|
||||||
steam_id=data["steam_id"],
|
steam_id=data["steam_id"],
|
||||||
defaults={
|
defaults={
|
||||||
|
"event": event,
|
||||||
"url": data["url"],
|
"url": data["url"],
|
||||||
"title": data["title"],
|
"title": data["title"],
|
||||||
"description": data["description"],
|
"description": data["description"],
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user