Compare commits
143
Commits
9ee45463a8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
407cc22911
|
||
|
|
5fc79197f6
|
||
|
|
73b279d7fe
|
||
|
|
1844b973e9
|
||
|
|
6cee1135a8
|
||
|
|
4f2cf08b27
|
||
|
|
9bb02463da
|
||
|
|
0b34e66d81
|
||
|
|
d180eb3c90
|
||
|
|
5c70c38523
|
||
|
|
026d592483
|
||
|
|
87e6e38291
|
||
|
|
723da9aa38
|
||
|
|
421106bf71
|
||
|
|
f42649b888
|
||
|
|
f75a1b0338
|
||
|
|
1c6d9202fc
|
||
|
|
bc3d8158ec
|
||
|
|
6e7a68d509
|
||
|
|
2c4dac628e
|
||
|
|
cf51b187d2
|
||
|
|
b5716d6dcd
|
||
|
|
ad7726d978
|
||
|
|
89fb745e74
|
||
|
|
2f8fdb755b
|
||
|
|
54ffe89445
|
||
|
|
377bc91bcb
|
||
|
|
60d730a683
|
||
|
|
1daf2757b8
|
||
|
|
a110575e0a
|
||
|
|
bfaef2e402
|
||
|
|
3d040dd377
|
||
|
|
a6ea3719b9
|
||
|
|
13c09adff3
|
||
|
|
de5ee8ae7a
|
||
|
|
8daa91d6c5
|
||
|
|
acd08e216a
|
||
|
|
b403311ce1
|
||
|
|
1ca4643774
|
||
|
|
27d3c08a72
|
||
|
|
919e611895
|
||
|
|
107fe8b69b
|
||
|
|
15ab9a14ac
|
||
|
|
b837abdba0
|
||
|
|
4fd683ff2c
|
||
|
|
8509be548e
|
||
|
|
382ba01f3e
|
||
|
|
6a5a224d3b
|
||
|
|
fdf84f9524
|
||
|
|
e8b018ec49
|
||
|
|
b74d20cbe0
|
||
|
|
59b568ff41
|
||
|
|
979be91417
|
||
|
|
28d2a7ad24
|
||
|
|
e38b610456
|
||
|
|
9fc9e19d68
|
||
|
|
6866b45f66
|
||
|
|
d104480188
|
||
|
|
15590e5162
|
||
|
|
13b14abb12
|
||
|
|
f68c4e50e8
|
||
|
|
4392122cfc
|
||
|
|
9ec9a54649
|
||
|
|
fabf06e6d4
|
||
|
|
85e67599af
|
||
|
|
68de07645a
|
||
|
|
8b9c254fd2
|
||
|
|
b5c2cdc037
|
||
|
|
d534519de6
|
||
|
|
d7bdb85fb0
|
||
|
|
b64161b371
|
||
|
|
c02a0aa985
|
||
|
|
202e04a554
|
||
|
|
0fc1fb255f
|
||
|
|
a769d1a0b3
|
||
|
|
11d6fc423a
|
||
|
|
be81d40a90
|
||
|
|
9f9cb0a483
|
||
|
|
6a58a0289e
|
||
|
|
c2602dbc02
|
||
|
|
f543cdcd5c
|
||
|
|
f2fe87c12b
|
||
|
|
3e91ef77b6
|
||
|
|
215d68a0e1
|
||
|
|
47812ffd09
|
||
|
|
a5fe8aacaf
|
||
|
|
b437210eb3
|
||
|
|
5584e54b58
|
||
|
|
35ea54ecea
|
||
|
|
821e453bc0
|
||
|
|
9fd0122a67
|
||
|
|
e557fe2cda
|
||
|
|
79e7cef3ba
|
||
|
|
a264336bd8
|
||
|
|
42e3571fab
|
||
|
|
43b314bb20
|
||
|
|
f1afb2096f
|
||
|
|
62a81e57ad
|
||
|
|
303b9e1c8a
|
||
|
|
f7c7eba4da
|
||
|
|
ce30539808
|
||
|
|
544112b204
|
||
|
|
9f94fb3974
|
||
|
|
25072e8eb4
|
||
|
|
9e5ab8539a
|
||
|
|
4ba6a48246
|
||
|
|
92dddca964
|
||
|
|
bf21a5eae6
|
||
|
|
89faa7cd40
|
||
|
|
a800c7fff7
|
||
|
|
2ee8cd6be3
|
||
|
|
35824de310
|
||
|
|
65cc2e555f
|
||
|
|
6d996a4e2f
|
||
|
|
f774ff3340
|
||
|
|
779393106d
|
||
|
|
3e04f8312a
|
||
|
|
7cfab20826
|
||
|
|
754b0b0803
|
||
|
|
79f469a393
|
||
|
|
5aeff9c218
|
||
|
|
2264401a91
|
||
|
|
9662181d4d
|
||
|
|
90f1ce13cf
|
||
|
|
f90377ac69
|
||
|
|
d8d09c21d4
|
||
|
|
3c86ca4c91
|
||
|
|
0b138e315c
|
||
|
|
d2a9dbe4a4
|
||
|
|
fa53d74295
|
||
|
|
52a6a4adb2
|
||
|
|
69b6b46ee2
|
||
|
|
119fdc2a51
|
||
|
|
01b0dbd1d9
|
||
|
|
19cc52c9f8
|
||
|
|
07f5c76a52
|
||
|
|
8584102402
|
||
|
|
404af4f90d
|
||
|
|
eb1eed852b
|
||
|
|
11229a3906
|
||
|
|
38e2b5e858
|
||
|
|
1e4251c796
|
||
|
|
fa76fbce92
|
@@ -0,0 +1,23 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
**/__pycache__
|
||||||
|
**/*.pyc
|
||||||
|
*.egg-info
|
||||||
|
.ruff_cache
|
||||||
|
.pytest_cache
|
||||||
|
ctagsfile
|
||||||
|
images
|
||||||
|
main.py
|
||||||
|
gifs_opus_magnum
|
||||||
|
|
||||||
|
polylan_submitter/node_modules
|
||||||
|
polylan_submitter/static
|
||||||
|
polylan_submitter/static_source/vite
|
||||||
|
polylan_submitter/media
|
||||||
|
polylan_submitter/db.sqlite3
|
||||||
|
polylan_submitter/.vscode
|
||||||
|
polylan_submitter/settingsLocal.py
|
||||||
|
|
||||||
|
deploy/docker/polylan-submitter.env
|
||||||
@@ -178,3 +178,4 @@ pyrightconfig.json
|
|||||||
tags
|
tags
|
||||||
media/
|
media/
|
||||||
settingsLocal.py
|
settingsLocal.py
|
||||||
|
deploy/docker/polylan-submitter.env
|
||||||
|
|||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
# Single OCI image bundling the Vue/Vite frontend, the Django/Channels web
|
||||||
|
# process (daphne) and the RQ worker, managed together by supervisord - see
|
||||||
|
# deploy/docker/README.md for why (single-container Proxmox deploy) and how
|
||||||
|
# to run it. Postgres, Redis and S3 stay external (see
|
||||||
|
# deploy/docker/polylan-submitter.env.dist for the full env var list) - this
|
||||||
|
# image carries no infra, just the app.
|
||||||
|
#
|
||||||
|
# Build (from repo root, requires BuildKit/buildx - RUN --mount=type=cache
|
||||||
|
# below is rejected by the legacy builder):
|
||||||
|
# docker buildx build --load -t polylan-submitter .
|
||||||
|
|
||||||
|
# ---- frontend build -------------------------------------------------------
|
||||||
|
FROM node:22-alpine AS frontend-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY polylan_submitter/package.json polylan_submitter/pnpm-lock.yaml ./
|
||||||
|
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate \
|
||||||
|
&& pnpm install --frozen-lockfile
|
||||||
|
COPY polylan_submitter/vite.config.ts polylan_submitter/tsconfig*.json ./
|
||||||
|
COPY polylan_submitter/src ./src
|
||||||
|
COPY polylan_submitter/public ./public
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# ---- python dependencies ---------------------------------------------------
|
||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS python-builder
|
||||||
|
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
# --no-install-project: this pyproject has no [build-system]/package to
|
||||||
|
# install, it only pins the app's dependencies - so just build the venv.
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --frozen --no-dev --no-install-project
|
||||||
|
|
||||||
|
# ---- runtime ----------------------------------------------------------------
|
||||||
|
FROM python:3.13-slim AS runtime
|
||||||
|
|
||||||
|
# tesseract-ocr: shelled out to by pytesseract (submissions/tasks.py,
|
||||||
|
# opus_magnum/utils.py) for OCR'ing Opus Magnum submission GIFs.
|
||||||
|
# libgl1/libglib2.0-0: runtime shared libs opencv-python needs even though
|
||||||
|
# it ships as a wheel.
|
||||||
|
# libpq5: psycopg (plain, not psycopg[binary]/[c]) loads libpq via ctypes.
|
||||||
|
# supervisor/tini: process supervision - see deploy/docker/supervisord.conf.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
tesseract-ocr \
|
||||||
|
libgl1 \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libpq5 \
|
||||||
|
supervisor \
|
||||||
|
tini \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN groupadd --system app && useradd --system --gid app --create-home app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=python-builder --chown=app:app /app/.venv /app/.venv
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH" \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
DJANGO_SETTINGS_MODULE=polylan_submitter.settings
|
||||||
|
|
||||||
|
COPY --chown=app:app polylan_submitter/ /app/polylan_submitter/
|
||||||
|
COPY --from=frontend-builder --chown=app:app /app/static_source/vite /app/polylan_submitter/static_source/vite
|
||||||
|
COPY --chown=app:app deploy/docker/entrypoint.sh /app/entrypoint.sh
|
||||||
|
COPY --chown=app:app deploy/docker/supervisord.conf /app/supervisord.conf
|
||||||
|
|
||||||
|
RUN rm -rf /app/polylan_submitter/node_modules /app/polylan_submitter/settingsLocal.py \
|
||||||
|
&& mkdir -p /app/polylan_submitter/static /app/polylan_submitter/media \
|
||||||
|
&& chown app:app /app/polylan_submitter/static /app/polylan_submitter/media \
|
||||||
|
&& chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
USER app
|
||||||
|
WORKDIR /app/polylan_submitter
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
ENTRYPOINT ["tini", "--", "/app/entrypoint.sh"]
|
||||||
|
CMD ["supervisord", "-c", "/app/supervisord.conf", "-n"]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Container image
|
||||||
|
|
||||||
|
`Dockerfile` (repo root) builds a single OCI image with the Vite frontend
|
||||||
|
already built in, and a `.venv` with all Python deps. At runtime it uses
|
||||||
|
`supervisord` (`deploy/docker/supervisord.conf`) to run two processes in the
|
||||||
|
one container:
|
||||||
|
|
||||||
|
- `web` - one `daphne` process serving the Django/Channels app on `:8000`
|
||||||
|
- `worker` - two `rqworker` processes for background jobs (submission OCR,
|
||||||
|
AAndT sync, etc.)
|
||||||
|
|
||||||
|
Postgres, Redis and S3-compatible storage are **not** bundled (see
|
||||||
|
`deploy/docker/polylan-submitter.env.dist` for the full list of env vars:
|
||||||
|
`POSTGRES_*`, `REDIS_HOST`/`REDIS_PORT`, `AWS_*`, `DJANGO_SECRET_KEY`, etc.).
|
||||||
|
Point those at wherever those services already live (other containers/LXCs,
|
||||||
|
or existing infra).
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Requires BuildKit/buildx (`RUN --mount=type=cache` in the Dockerfile is
|
||||||
|
rejected by the legacy builder):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker buildx build --load -t polylan-submitter .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -d \
|
||||||
|
--name polylan-submitter \
|
||||||
|
--env-file deploy/docker/polylan-submitter.env \
|
||||||
|
-p 8000:8000 \
|
||||||
|
polylan-submitter
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations run automatically on container start (set `MIGRATE_ON_START=false`
|
||||||
|
to skip). `collectstatic` does not run automatically - it pushes to the
|
||||||
|
shared S3 bucket, so run it once per deploy instead of racing multiple
|
||||||
|
replicas on every boot:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run --rm --env-file deploy/docker/polylan-submitter.env \
|
||||||
|
polylan-submitter python manage.py collectstatic --noinput
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scaling
|
||||||
|
|
||||||
|
There's one `daphne` process per container - it binds the only port the
|
||||||
|
container exposes, so running more of them would need a reverse proxy
|
||||||
|
inside the container too. Scale by running multiple container replicas
|
||||||
|
behind an external reverse proxy/load balancer instead (each replica also
|
||||||
|
runs its own pair of `rqworker` processes, so worker capacity scales the
|
||||||
|
same way).
|
||||||
|
|
||||||
|
## Deploying to Proxmox
|
||||||
|
|
||||||
|
Recent Proxmox VE versions can create an LXC container directly from an OCI
|
||||||
|
image (`pct create <vmid> <image-ref> ...` with an OCI-format image
|
||||||
|
reference, currently a tech-preview feature - check `pct create --help` and
|
||||||
|
the Proxmox VE docs for your installed version, since the exact flags have
|
||||||
|
been changing across releases). Practically that means:
|
||||||
|
|
||||||
|
1. Push the built image to a registry Proxmox can reach (a local registry,
|
||||||
|
GHCR, Docker Hub, etc.) - `docker tag`/`docker push`.
|
||||||
|
2. Create the container on the Proxmox host from that image reference,
|
||||||
|
passing the same env vars as above.
|
||||||
|
3. Map/forward port 8000 as needed, and put whatever TLS-terminating
|
||||||
|
reverse proxy you already run in front of it.
|
||||||
|
|
||||||
|
If OCI-to-LXC isn't available on your Proxmox version, the same image runs
|
||||||
|
fine in a plain VM (or an LXC) with any container runtime (Docker/Podman)
|
||||||
|
installed instead.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Container entrypoint: runs migrations (unless disabled), then execs
|
||||||
|
# whatever CMD supplied (supervisord by default - see the Dockerfile).
|
||||||
|
#
|
||||||
|
# collectstatic is intentionally NOT run here: it pushes to the shared S3
|
||||||
|
# bucket (see STORAGES in settings.py), so running it on every container
|
||||||
|
# start would race across replicas for no benefit - run it once per deploy
|
||||||
|
# instead:
|
||||||
|
# docker run --rm --env-file <env file> polylan-submitter \
|
||||||
|
# python manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "${MIGRATE_ON_START:-true}" = "true" ]; then
|
||||||
|
echo "Running migrations..."
|
||||||
|
python manage.py migrate --noinput
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Kick off the periodic Steam store refresh (games.tasks). No-op when a refresh
|
||||||
|
# is already queued, so every replica can safely run it on start.
|
||||||
|
python manage.py schedule_steam_refresh || echo "Could not schedule Steam store refresh"
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Template for the --env-file passed to `docker run` (see
|
||||||
|
# deploy/docker/README.md). Copy this to deploy/docker/polylan-submitter.env,
|
||||||
|
# fill in real values, and keep it out of git (already in .gitignore) and
|
||||||
|
# world-readable perms (chmod 600) since it holds secrets.
|
||||||
|
#
|
||||||
|
# Docker reads this file itself (KEY=VALUE per line) before the container's
|
||||||
|
# Python even starts - no python-dotenv, no $VAR/${VAR} expansion, no shell
|
||||||
|
# involved. settings.py just reads the values from os.environ.
|
||||||
|
#
|
||||||
|
# Rotating a secret means editing this file and restarting the container
|
||||||
|
# (`docker restart polylan-submitter` or replacing it, depending on how
|
||||||
|
# you're running it) - values are read fresh on every process start.
|
||||||
|
#
|
||||||
|
# See settings.py for what each of these does and its dev-mode default.
|
||||||
|
|
||||||
|
DJANGO_SECRET_KEY=
|
||||||
|
DJANGO_DEBUG=false
|
||||||
|
DJANGO_ALLOWED_HOSTS=submitter.polylan.ch
|
||||||
|
DJANGO_CSRF_TRUSTED_ORIGINS=https://polylan-submitter.legrems.xyz
|
||||||
|
DJANGO_BEHIND_TLS_PROXY=true
|
||||||
|
|
||||||
|
POSTGRES_DB=
|
||||||
|
POSTGRES_USER=
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
POSTGRES_HOST=
|
||||||
|
POSTGRES_PORT=
|
||||||
|
|
||||||
|
REDIS_HOST=
|
||||||
|
REDIS_PORT=
|
||||||
|
PLACE_REDIS_URL=
|
||||||
|
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_S3_ENDPOINT_URL=
|
||||||
|
AWS_STORAGE_BUCKET_NAME=
|
||||||
|
|
||||||
|
SENTRY_ENVIRONMENT=production
|
||||||
|
SENTRY_DSN=
|
||||||
|
|
||||||
|
STEAM_API_KEY=
|
||||||
|
STEAM_STORE_REFRESH_MINUTES=360
|
||||||
|
STEAM_STORE_COUNTRY=ch
|
||||||
|
AANDT_API_BASE_URL=
|
||||||
|
SUBMITTER_BASE_URL=
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# One-off build+push to the Gitea container registry for testing.
|
||||||
|
# Usage: deploy/docker/push.sh [tag] (run from repo root, default tag: test)
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
REGISTRY=git.legrems.mom
|
||||||
|
OWNER=polylan
|
||||||
|
IMAGE=polylan-submitter
|
||||||
|
TAG="${1:-test}"
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
REF="$REGISTRY/$OWNER/$IMAGE:$TAG"
|
||||||
|
# buildx (BuildKit) is required: the Dockerfile uses RUN --mount=type=cache
|
||||||
|
# for the uv dep cache, which the legacy builder rejects.
|
||||||
|
docker buildx build --load -t "$REF" .
|
||||||
|
docker push "$REF"
|
||||||
|
|
||||||
|
echo "Pushed $REF"
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
; Runs inside the single app container as the non-root "app" user (see
|
||||||
|
; Dockerfile's USER app - no [supervisord] user= needed, we never start as
|
||||||
|
; root here). This container runs ONE daphne process on TCP: scale by
|
||||||
|
; running more container replicas behind an external reverse proxy/load
|
||||||
|
; balancer instead of more processes per container.
|
||||||
|
;
|
||||||
|
; Program logs go to stdout/stderr (logfile_maxbytes=0 disables supervisor's
|
||||||
|
; own log rotation for them) so `docker logs` / Proxmox's log viewer picks
|
||||||
|
; them up - no log files inside the container to manage.
|
||||||
|
|
||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
pidfile=/tmp/supervisord.pid
|
||||||
|
logfile=/dev/null
|
||||||
|
logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:web]
|
||||||
|
command=/app/.venv/bin/daphne -b 0.0.0.0 -p 8000 polylan_submitter.asgi:application
|
||||||
|
directory=/app/polylan_submitter
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stopasgroup=true
|
||||||
|
killasgroup=true
|
||||||
|
stopsignal=TERM
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:worker]
|
||||||
|
command=/app/.venv/bin/python manage.py rqworker default --job-class django_tasks_rq.Job --with-scheduler
|
||||||
|
directory=/app/polylan_submitter
|
||||||
|
process_name=%(program_name)s-%(process_num)02d
|
||||||
|
numprocs=2
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stopasgroup=true
|
||||||
|
killasgroup=true
|
||||||
|
stopsignal=TERM
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
Executable
+125
@@ -0,0 +1,125 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Zero-downtime prod deploy: pull, sync deps, build front, migrate, then
|
||||||
|
# gracefully reload gunicorn (see polylan-submitter.service, installed and
|
||||||
|
# restarted instead when it changed) and restart the RQ worker (see
|
||||||
|
# polylan-submitter-worker.conf).
|
||||||
|
#
|
||||||
|
# Run from anywhere as the user owning the checkout, needs sudo for
|
||||||
|
# systemctl/supervisorctl (no-op when already root):
|
||||||
|
# ./deploy/systemd/deploy.sh
|
||||||
|
#
|
||||||
|
# Migrations run while the old code still serves requests, so keep them
|
||||||
|
# backward compatible (nullable/default columns, drop/rename in a later deploy).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SERVICE="${SERVICE:-polylan-submitter}"
|
||||||
|
WORKER="${WORKER:-polylan-submitter-worker}"
|
||||||
|
SUPERVISOR_CONF_DIR="${SUPERVISOR_CONF_DIR:-/etc/supervisor/conf.d}"
|
||||||
|
WEB_URL="${WEB_URL:-https://polylan-submitter.legrems.xyz/}"
|
||||||
|
|
||||||
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
DEPLOY_DIR="$REPO_DIR/deploy/systemd"
|
||||||
|
APP_DIR="$REPO_DIR/polylan_submitter"
|
||||||
|
PYTHON="$REPO_DIR/.venv/bin/python"
|
||||||
|
# Same as the unit's Environment=, so migrate/collectstatic see the prod settings.
|
||||||
|
# Relative to $APP_DIR, where manage.py runs.
|
||||||
|
export SETTINGS_ENV_FILE_LOCATION="${SETTINGS_ENV_FILE_LOCATION:-polylan_submitter/.env}"
|
||||||
|
|
||||||
|
# gunicorn's master doesn't reload itself on HUP, so a server upgrade needs a
|
||||||
|
# full restart instead.
|
||||||
|
server_versions() {
|
||||||
|
"$PYTHON" - <<'EOF' 2>/dev/null || true
|
||||||
|
import importlib.metadata as m
|
||||||
|
for pkg in ("gunicorn", "uvicorn", "uvicorn-worker"):
|
||||||
|
try:
|
||||||
|
print(pkg, m.version(pkg))
|
||||||
|
except m.PackageNotFoundError:
|
||||||
|
print(pkg, "missing")
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# wait_for <description> <command...>: retry for ~30s, fail the deploy if it
|
||||||
|
# never succeeds.
|
||||||
|
wait_for() {
|
||||||
|
local what="$1"
|
||||||
|
shift
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
if "$@" >/dev/null 2>&1; then
|
||||||
|
echo " $what: ok"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo " $what: FAILED" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
web_responds() {
|
||||||
|
# 000 = nothing listening, 5xx = Caddy up but gunicorn down (502) or broken.
|
||||||
|
local code
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' "$WEB_URL")"
|
||||||
|
[ "$code" != "000" ] && [ "$code" -lt 500 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
worker_running() {
|
||||||
|
sudo supervisorctl status "$WORKER" | grep -q RUNNING
|
||||||
|
}
|
||||||
|
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
|
||||||
|
echo "==> git pull"
|
||||||
|
git pull --ff-only
|
||||||
|
|
||||||
|
before="$(server_versions)"
|
||||||
|
|
||||||
|
echo "==> uv sync"
|
||||||
|
uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
after="$(server_versions)"
|
||||||
|
|
||||||
|
echo "==> frontend build"
|
||||||
|
cd "$APP_DIR"
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
echo "==> migrate"
|
||||||
|
"$PYTHON" manage.py migrate --noinput
|
||||||
|
|
||||||
|
echo "==> collectstatic"
|
||||||
|
"$PYTHON" manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
echo "==> $SERVICE (systemd)"
|
||||||
|
unit_changed=false
|
||||||
|
if ! cmp -s "$DEPLOY_DIR/$SERVICE.service" "/etc/systemd/system/$SERVICE.service"; then
|
||||||
|
sudo install -m 644 "$DEPLOY_DIR/$SERVICE.service" "/etc/systemd/system/$SERVICE.service"
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable "$SERVICE"
|
||||||
|
unit_changed=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# HUP re-forks workers with the master's original args, so a new ExecStart
|
||||||
|
# (unit change) or a new gunicorn/uvicorn needs a full restart.
|
||||||
|
if [ "$unit_changed" = true ]; then
|
||||||
|
echo "==> unit file changed, full restart of $SERVICE"
|
||||||
|
sudo systemctl restart "$SERVICE"
|
||||||
|
elif [ "$before" != "$after" ]; then
|
||||||
|
echo "==> server packages changed, full restart of $SERVICE"
|
||||||
|
sudo systemctl restart "$SERVICE"
|
||||||
|
else
|
||||||
|
echo "==> graceful reload of $SERVICE"
|
||||||
|
sudo systemctl reload "$SERVICE"
|
||||||
|
fi
|
||||||
|
wait_for "$SERVICE active" systemctl is-active --quiet "$SERVICE"
|
||||||
|
wait_for "$SERVICE responds on $WEB_URL" web_responds
|
||||||
|
|
||||||
|
echo "==> $WORKER (supervisor)"
|
||||||
|
sudo install -m 644 "$DEPLOY_DIR/$WORKER.conf" "$SUPERVISOR_CONF_DIR/$WORKER.conf"
|
||||||
|
# reread+update picks up config changes (and starts it on first install),
|
||||||
|
# restart loads the new code - TERM is a warm shutdown, the running job ends first.
|
||||||
|
sudo supervisorctl reread
|
||||||
|
sudo supervisorctl update "$WORKER"
|
||||||
|
sudo supervisorctl restart "$WORKER"
|
||||||
|
wait_for "$WORKER running" worker_running
|
||||||
|
|
||||||
|
echo "==> done"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
; Prod RQ worker (submission OCR, AAndT sync, Steam refresh...), run by the
|
||||||
|
; host's supervisord next to the polylan-submitter systemd unit (web).
|
||||||
|
;
|
||||||
|
; Installed to /etc/supervisor/conf.d/ by deploy/systemd/deploy.sh, which also
|
||||||
|
; restarts it on every deploy so it picks up the new code.
|
||||||
|
;
|
||||||
|
; Logs: supervisorctl tail -f polylan-submitter-worker
|
||||||
|
|
||||||
|
[program:polylan-submitter-worker]
|
||||||
|
command=/home/caddy/polylan-submitter/.venv/bin/python manage.py rqworker default --job-class django_tasks_rq.Job --with-scheduler
|
||||||
|
directory=/home/caddy/polylan-submitter/polylan_submitter
|
||||||
|
; Relative to directory, same as the systemd unit.
|
||||||
|
environment=SETTINGS_ENV_FILE_LOCATION="polylan_submitter/.env",PYTHONUNBUFFERED="1"
|
||||||
|
user=root
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
startsecs=5
|
||||||
|
; rqworker does a warm shutdown on TERM: it finishes the current job (OCR can
|
||||||
|
; take a while) before exiting, only SIGKILLed after stopwaitsecs.
|
||||||
|
stopsignal=TERM
|
||||||
|
stopwaitsecs=120
|
||||||
|
stopasgroup=true
|
||||||
|
killasgroup=true
|
||||||
|
redirect_stderr=true
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Prod web process: gunicorn master + uvicorn ASGI workers, behind Caddy.
|
||||||
|
#
|
||||||
|
# `systemctl reload polylan-submitter` sends HUP to the gunicorn master: it
|
||||||
|
# keeps the :7777 socket open, boots new workers on the new code, then
|
||||||
|
# gracefully stops the old ones - no downtime. See deploy/systemd/deploy.sh.
|
||||||
|
#
|
||||||
|
# Installed to /etc/systemd/system/ by deploy.sh: when this file changed it
|
||||||
|
# does daemon-reload + a full restart (HUP keeps the old ExecStart args).
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=PolyLAN submitter (gunicorn + uvicorn workers)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
Restart=always
|
||||||
|
RestartSec=1
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/home/caddy/polylan-submitter/polylan_submitter
|
||||||
|
# Relative to WorkingDirectory, loaded by settings.py via python-dotenv.
|
||||||
|
Environment="SETTINGS_ENV_FILE_LOCATION=polylan_submitter/.env"
|
||||||
|
|
||||||
|
# No --preload: with it, HUP re-forks from the already imported (old) code.
|
||||||
|
# --forwarded-allow-ips replaces daphne's --proxy-headers: trust
|
||||||
|
# X-Forwarded-* only from Caddy (local here).
|
||||||
|
ExecStart=/home/caddy/polylan-submitter/.venv/bin/gunicorn polylan_submitter.asgi:application \
|
||||||
|
--worker-class uvicorn_worker.UvicornWorker \
|
||||||
|
--workers 2 \
|
||||||
|
--bind 0.0.0.0:7777 \
|
||||||
|
--graceful-timeout 20 \
|
||||||
|
--forwarded-allow-ips 127.0.0.1
|
||||||
|
ExecReload=/bin/kill -s HUP $MAINPID
|
||||||
|
|
||||||
|
# SIGTERM to the master only, it then drains its workers (graceful-timeout).
|
||||||
|
KillMode=mixed
|
||||||
|
TimeoutStopSec=30
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
from django.contrib.auth.admin import UserAdmin
|
|
||||||
from .models import CustomUser
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CustomUser)
|
|
||||||
class CustomUserAdmin(UserAdmin):
|
|
||||||
"""Admin interface for CustomUser."""
|
|
||||||
|
|
||||||
# Add custom fields to the user admin
|
|
||||||
fieldsets = UserAdmin.fieldsets + (
|
|
||||||
(
|
|
||||||
"CAS Information",
|
|
||||||
{
|
|
||||||
"fields": ("cas_user_id", "cas_groups", "cas_attributes"),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add custom fields to the list display
|
|
||||||
list_display = UserAdmin.list_display + ("cas_user_id", "get_cas_groups_display")
|
|
||||||
|
|
||||||
# Add search fields
|
|
||||||
search_fields = UserAdmin.search_fields + ("cas_user_id",)
|
|
||||||
|
|
||||||
# Add filters
|
|
||||||
list_filter = UserAdmin.list_filter + ("cas_groups",)
|
|
||||||
|
|
||||||
# Make CAS fields readonly in admin
|
|
||||||
readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes")
|
|
||||||
|
|
||||||
def get_cas_groups_display(self, obj):
|
|
||||||
"""Display CAS groups in admin list."""
|
|
||||||
return obj.get_cas_groups_display()
|
|
||||||
|
|
||||||
get_cas_groups_display.short_description = "CAS Groups"
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
from ninja import NinjaAPI
|
|
||||||
from submissions.api import router as submissions_router
|
|
||||||
from submissions.schemas import UserInfoOut
|
|
||||||
|
|
||||||
# Create the main API instance
|
|
||||||
api = NinjaAPI(
|
|
||||||
title="Opus Magnum Submission API",
|
|
||||||
version="1.0.0",
|
|
||||||
description="""API for managing Opus Magnum puzzle submissions.
|
|
||||||
|
|
||||||
The Opus Magnum Submission API allows clients to upload, manage, validate, and review puzzle solution submissions for the Opus Magnum puzzle game community.
|
|
||||||
It provides features for user authentication, puzzle listing, submission uploads, automated and manual OCR validation, and administrative workflows.
|
|
||||||
""",
|
|
||||||
openapi_extra={
|
|
||||||
"info": {
|
|
||||||
"contact": {
|
|
||||||
"name": "Legrems",
|
|
||||||
"email": "loic.gremaud@polylan.ch",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add authentication for protected endpoints
|
|
||||||
# api.auth = django_auth # Uncomment if you want global auth
|
|
||||||
|
|
||||||
# Include the submissions router
|
|
||||||
api.add_router("/submissions/", submissions_router, tags=["submissions"])
|
|
||||||
|
|
||||||
|
|
||||||
# Health check endpoint
|
|
||||||
@api.get("/health")
|
|
||||||
def health_check(request):
|
|
||||||
"""Health check endpoint"""
|
|
||||||
return {"status": "healthy", "service": "opus-magnum-api"}
|
|
||||||
|
|
||||||
|
|
||||||
# API info endpoint
|
|
||||||
@api.get("/info")
|
|
||||||
def api_info(request):
|
|
||||||
"""Get API information"""
|
|
||||||
return {
|
|
||||||
"name": "Opus Magnum Submission API",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "API for managing puzzle submissions with OCR validation",
|
|
||||||
"features": [
|
|
||||||
"Multi-puzzle submissions",
|
|
||||||
"OCR validation",
|
|
||||||
"Manual validation workflow",
|
|
||||||
"Admin validation tools",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# User info endpoint
|
|
||||||
@api.get("/user", response=UserInfoOut)
|
|
||||||
def get_user_info(request):
|
|
||||||
"""Get current user information"""
|
|
||||||
user = request.user
|
|
||||||
|
|
||||||
if user.is_authenticated:
|
|
||||||
return {
|
|
||||||
"id": user.id,
|
|
||||||
"username": user.username,
|
|
||||||
"first_name": user.first_name,
|
|
||||||
"last_name": user.last_name,
|
|
||||||
"email": user.email,
|
|
||||||
"is_authenticated": True,
|
|
||||||
"is_staff": user.is_staff,
|
|
||||||
"is_superuser": user.is_superuser,
|
|
||||||
"cas_groups": getattr(user, "cas_groups", []),
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"is_authenticated": False,
|
|
||||||
"is_staff": False,
|
|
||||||
"is_superuser": False,
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
"""
|
|
||||||
ASGI config for opus_submitter project.
|
|
||||||
|
|
||||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
|
||||||
|
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "opus_submitter.settings")
|
|
||||||
|
|
||||||
application = get_asgi_application()
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
"""
|
|
||||||
Django settings for opus_submitter project.
|
|
||||||
|
|
||||||
Generated by 'django-admin startproject' using Django 5.2.7.
|
|
||||||
|
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/5.2/topics/settings/
|
|
||||||
|
|
||||||
For the full list of settings and their values, see
|
|
||||||
https://docs.djangoproject.com/en/5.2/ref/settings/
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
|
||||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
|
||||||
SECRET_KEY = "django-insecure-m0ivj_3gpbf281jl1$&n5pdo!5le(bp4z31u(1&4s=n#!tpy=n"
|
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
|
||||||
DEBUG = True
|
|
||||||
|
|
||||||
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
|
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
"django.contrib.admin",
|
|
||||||
"django.contrib.auth",
|
|
||||||
"django.contrib.contenttypes",
|
|
||||||
"django.contrib.sessions",
|
|
||||||
"django.contrib.messages",
|
|
||||||
"django.contrib.staticfiles",
|
|
||||||
"django_vite",
|
|
||||||
"accounts",
|
|
||||||
"submissions",
|
|
||||||
]
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
|
||||||
"django.middleware.security.SecurityMiddleware",
|
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
||||||
"django.middleware.common.CommonMiddleware",
|
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
||||||
# Removed CASMiddleware to avoid conflicts with our custom backend
|
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
||||||
]
|
|
||||||
|
|
||||||
ROOT_URLCONF = "opus_submitter.urls"
|
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
||||||
"DIRS": [BASE_DIR / "templates"],
|
|
||||||
"APP_DIRS": True,
|
|
||||||
"OPTIONS": {
|
|
||||||
"context_processors": [
|
|
||||||
"django.template.context_processors.request",
|
|
||||||
"django.contrib.auth.context_processors.auth",
|
|
||||||
"django.contrib.messages.context_processors.messages",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
WSGI_APPLICATION = "opus_submitter.wsgi.application"
|
|
||||||
|
|
||||||
|
|
||||||
# Database
|
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
|
||||||
|
|
||||||
DATABASES = {
|
|
||||||
"default": {
|
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
|
||||||
"NAME": BASE_DIR / "db.sqlite3",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Password validation
|
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
|
||||||
{
|
|
||||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# Internationalization
|
|
||||||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
|
||||||
|
|
||||||
LANGUAGE_CODE = "en-us"
|
|
||||||
|
|
||||||
TIME_ZONE = "UTC"
|
|
||||||
|
|
||||||
USE_I18N = True
|
|
||||||
|
|
||||||
USE_TZ = True
|
|
||||||
|
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
|
||||||
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
|
||||||
|
|
||||||
STATIC_URL = "static/"
|
|
||||||
|
|
||||||
# Default primary key field type
|
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|
||||||
|
|
||||||
# Custom User Model
|
|
||||||
AUTH_USER_MODEL = "accounts.CustomUser"
|
|
||||||
|
|
||||||
# Simple CAS Configuration
|
|
||||||
CAS_SERVER_URL = "https://polylan.ch/cas/"
|
|
||||||
|
|
||||||
# Steam API Configuration
|
|
||||||
STEAM_API_KEY = os.environ.get("STEAM_API_KEY", None) # Set via environment variable
|
|
||||||
|
|
||||||
# File Upload Settings
|
|
||||||
MEDIA_URL = "/media/"
|
|
||||||
MEDIA_ROOT = BASE_DIR / "media"
|
|
||||||
|
|
||||||
# File Upload Limits
|
|
||||||
FILE_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB
|
|
||||||
DATA_UPLOAD_MAX_MEMORY_SIZE = 256 * 1024 * 1024 # 256MB
|
|
||||||
|
|
||||||
# Allowed file types for submissions
|
|
||||||
ALLOWED_SUBMISSION_TYPES = [
|
|
||||||
"image/jpeg",
|
|
||||||
"image/jpg",
|
|
||||||
"image/png",
|
|
||||||
"image/gif",
|
|
||||||
"video/mp4",
|
|
||||||
"video/webm",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Authentication backends
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
|
||||||
"django.contrib.auth.backends.ModelBackend",
|
|
||||||
"simple_cas_backend.SimpleCASBackend",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Login/Logout URLs
|
|
||||||
LOGIN_URL = "/cas/login/"
|
|
||||||
LOGOUT_URL = "/cas/logout/"
|
|
||||||
LOGIN_REDIRECT_URL = "/"
|
|
||||||
LOGOUT_REDIRECT_URL = "/"
|
|
||||||
DJANGO_VITE = {
|
|
||||||
"default": {
|
|
||||||
"dev_mode": False,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
STATIC_URL = "/static/"
|
|
||||||
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
|
||||||
STATICFILES_DIRS = [
|
|
||||||
os.path.join(BASE_DIR, "static_source"),
|
|
||||||
os.path.join(BASE_DIR, "static_source/vite"),
|
|
||||||
]
|
|
||||||
|
|
||||||
from opus_submitter.settingsLocal import * # noqa
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
"""
|
|
||||||
URL configuration for opus_submitter project.
|
|
||||||
|
|
||||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
|
||||||
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
|
||||||
Examples:
|
|
||||||
Function views
|
|
||||||
1. Add an import: from my_app import views
|
|
||||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
|
||||||
Class-based views
|
|
||||||
1. Add an import: from other_app.views import Home
|
|
||||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
|
||||||
Including another URLconf
|
|
||||||
1. Import the include() function: from django.urls import include, path
|
|
||||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.contrib import admin
|
|
||||||
from django.http import HttpRequest
|
|
||||||
from django.shortcuts import render
|
|
||||||
from django.urls import path
|
|
||||||
from django.contrib.auth.decorators import login_required
|
|
||||||
from django.conf import settings
|
|
||||||
from django.conf.urls.static import static
|
|
||||||
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
|
|
||||||
from .api import api
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def home(request: HttpRequest):
|
|
||||||
from submissions.models import SteamCollection
|
|
||||||
|
|
||||||
return render(
|
|
||||||
request,
|
|
||||||
"index.html",
|
|
||||||
{
|
|
||||||
"collection": SteamCollection.objects.filter(is_active=True).last(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("admin/", admin.site.urls),
|
|
||||||
path("cas/login/", SimpleCASLoginView.as_view(), name="cas_ng_login"),
|
|
||||||
path("cas/logout/", SimpleCASLogoutView.as_view(), name="cas_ng_logout"),
|
|
||||||
path("api/", api.urls),
|
|
||||||
path("", home, name="home"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Serve media files in development
|
|
||||||
if settings.DEBUG:
|
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,247 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, computed } from "vue";
|
|
||||||
import PuzzleCard from "@/components/PuzzleCard.vue";
|
|
||||||
import SubmissionForm from "@/components/SubmissionForm.vue";
|
|
||||||
import AdminPanel from "@/components/AdminPanel.vue";
|
|
||||||
import { apiService, errorHelpers } from "@/services/apiService";
|
|
||||||
import { usePuzzlesStore } from "@/stores/puzzles";
|
|
||||||
import { useSubmissionsStore } from "@/stores/submissions";
|
|
||||||
import type { PuzzleResponse, UserInfo } from "@/types";
|
|
||||||
import { useCountdown } from "@vueuse/core";
|
|
||||||
import { storeToRefs } from "pinia";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
collectionTitle: string;
|
|
||||||
collectionUrl: string;
|
|
||||||
collectionDescription: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const puzzlesStore = usePuzzlesStore();
|
|
||||||
const submissionsStore = useSubmissionsStore();
|
|
||||||
|
|
||||||
const { submissions, isSubmissionModalOpen } = storeToRefs(submissionsStore);
|
|
||||||
const { openSubmissionModal, loadSubmissions, closeSubmissionModal } =
|
|
||||||
submissionsStore;
|
|
||||||
|
|
||||||
// Local state
|
|
||||||
const userInfo = ref<UserInfo | null>(null);
|
|
||||||
const isLoading = ref(true);
|
|
||||||
const error = ref<string>("");
|
|
||||||
|
|
||||||
// Computed properties
|
|
||||||
const isSuperuser = computed(() => {
|
|
||||||
return userInfo.value?.is_superuser || false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Computed property to get responses grouped by puzzle
|
|
||||||
const responsesByPuzzle = computed(() => {
|
|
||||||
const grouped: Record<number, PuzzleResponse[]> = {};
|
|
||||||
submissions.value.forEach((submission) => {
|
|
||||||
submission.responses.forEach((response) => {
|
|
||||||
// Handle both number and object types for puzzle field
|
|
||||||
if (!grouped[response.puzzle]) {
|
|
||||||
grouped[response.puzzle] = [];
|
|
||||||
}
|
|
||||||
grouped[response.puzzle].push(response);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return grouped;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function initialize() {
|
|
||||||
try {
|
|
||||||
isLoading.value = true;
|
|
||||||
error.value = "";
|
|
||||||
|
|
||||||
console.log("Starting data load...");
|
|
||||||
|
|
||||||
// Load user info
|
|
||||||
console.log("Loading user info...");
|
|
||||||
const userResponse = await apiService.getUserInfo();
|
|
||||||
if (userResponse.data) {
|
|
||||||
userInfo.value = userResponse.data;
|
|
||||||
console.log("User info loaded:", userResponse.data);
|
|
||||||
} else if (userResponse.error) {
|
|
||||||
console.warn("User info error:", userResponse.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load puzzles from API using store
|
|
||||||
console.log("Loading puzzles...");
|
|
||||||
await puzzlesStore.loadPuzzles();
|
|
||||||
console.log("Puzzles loaded:", puzzlesStore.puzzles.length);
|
|
||||||
|
|
||||||
// Load existing submissions using store
|
|
||||||
console.log("Loading submissions...");
|
|
||||||
await loadSubmissions();
|
|
||||||
console.log("Submissions loaded:", submissions.value.length);
|
|
||||||
|
|
||||||
console.log("Data load complete!");
|
|
||||||
} catch (err) {
|
|
||||||
error.value = errorHelpers.getErrorMessage(err);
|
|
||||||
console.error("Failed to load data:", err);
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
console.log("Loading state set to false");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userInfo.value?.is_superuser) {
|
|
||||||
start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { remaining, start } = useCountdown(60, {
|
|
||||||
onComplete() {
|
|
||||||
initialize();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await initialize();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Function to match puzzle name from OCR to actual puzzle
|
|
||||||
const findPuzzleByName = (ocrPuzzleName: string) => {
|
|
||||||
return puzzlesStore.findPuzzleByName(ocrPuzzleName);
|
|
||||||
};
|
|
||||||
|
|
||||||
const reloadPage = () => {
|
|
||||||
window.location.reload();
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-screen bg-base-200">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="navbar bg-base-100 shadow-lg">
|
|
||||||
<div class="container mx-auto">
|
|
||||||
<div class="flex-1">
|
|
||||||
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div
|
|
||||||
v-if="userInfo?.is_authenticated"
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<div class="text-sm">
|
|
||||||
<span class="font-medium">{{ userInfo.username }}</span>
|
|
||||||
<span
|
|
||||||
v-if="userInfo.is_superuser"
|
|
||||||
class="badge badge-warning badge-xs ml-1"
|
|
||||||
>Admin</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-sm text-base-content/70">Not logged in</div>
|
|
||||||
<div class="flex flex-col items-end gap-2">
|
|
||||||
<a href="/api/docs" class="btn btn-xs">API docs</a>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col items-end gap-2">
|
|
||||||
<a href="/admin" class="btn btn-xs btn-warning">Admin panel</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div v-if="userInfo?.is_superuser" class="flex justify-center">
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="mb-6 text-base-content/70">
|
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
|
||||||
Auto reload page in {{ remaining }} seconds ...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="isLoading"
|
|
||||||
class="flex justify-center items-center min-h-[400px]"
|
|
||||||
>
|
|
||||||
<div class="text-center">
|
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
|
||||||
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error State -->
|
|
||||||
<div v-else-if="error" class="alert alert-error max-w-2xl mx-auto">
|
|
||||||
<i class="mdi mdi-alert-circle text-xl"></i>
|
|
||||||
<div>
|
|
||||||
<h3 class="font-bold">Error Loading Data</h3>
|
|
||||||
<div class="text-sm">{{ error }}</div>
|
|
||||||
</div>
|
|
||||||
<button @click="reloadPage" class="btn btn-sm btn-outline">
|
|
||||||
<i class="mdi mdi-refresh mr-1"></i>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<div v-else class="space-y-8">
|
|
||||||
<!-- Collection Info -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="card bg-base-100 shadow-lg">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title text-2xl">{{ props.collectionTitle }}</h2>
|
|
||||||
<p class="text-base-content/70">
|
|
||||||
{{ props.collectionDescription }}
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap gap-4 mt-4">
|
|
||||||
<button @click="openSubmissionModal" class="btn btn-primary">
|
|
||||||
<i class="mdi mdi-plus mr-2"></i>
|
|
||||||
Submit Solution
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Admin Panel (only for superusers) -->
|
|
||||||
<div v-if="isSuperuser">
|
|
||||||
<AdminPanel />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Puzzles Grid -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
<PuzzleCard
|
|
||||||
v-for="puzzle in puzzlesStore.puzzles"
|
|
||||||
:key="puzzle.id"
|
|
||||||
:puzzle="puzzle"
|
|
||||||
:responses="responsesByPuzzle[puzzle.id] || []"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div v-if="puzzlesStore.puzzles.length === 0" class="text-center py-12">
|
|
||||||
<div class="text-6xl mb-4">🧩</div>
|
|
||||||
<h3 class="text-xl font-bold mb-2">No Puzzles Available</h3>
|
|
||||||
<p class="text-base-content/70">
|
|
||||||
Check back later for new puzzle collections!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submission Modal -->
|
|
||||||
<div v-if="isSubmissionModalOpen" class="modal modal-open">
|
|
||||||
<div class="modal-box max-w-6xl">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h3 class="font-bold text-lg">Submit Solution</h3>
|
|
||||||
<button
|
|
||||||
@click="closeSubmissionModal"
|
|
||||||
class="btn btn-sm btn-circle btn-ghost"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-close"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SubmissionForm
|
|
||||||
:puzzles="puzzlesStore.puzzles"
|
|
||||||
:find-puzzle-by-name="findPuzzleByName"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-300"
|
|
||||||
>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div class="flex-1">
|
|
||||||
<h3 class="card-title text-lg font-bold">{{ puzzle.title }}</h3>
|
|
||||||
<p class="text-sm text-base-content/70 mb-2">
|
|
||||||
by {{ puzzle.author_name }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 mb-3">
|
|
||||||
<div class="badge badge-primary badge-sm">
|
|
||||||
{{ puzzle.steam_item_id }}
|
|
||||||
</div>
|
|
||||||
<div class="badge badge-ghost badge-sm">
|
|
||||||
Order: {{ puzzle.order_index + 1 }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
|
||||||
v-if="puzzle.description"
|
|
||||||
class="text-sm text-base-content/80 mb-4"
|
|
||||||
>
|
|
||||||
{{ puzzle.description }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="puzzle.tags && puzzle.tags.length > 0"
|
|
||||||
class="flex flex-wrap gap-1 mb-4"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-for="tag in puzzle.tags.slice(0, 3)"
|
|
||||||
:key="tag"
|
|
||||||
class="badge badge-outline badge-xs"
|
|
||||||
>
|
|
||||||
{{ tag }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="puzzle.tags.length > 3"
|
|
||||||
class="badge badge-outline badge-xs"
|
|
||||||
>
|
|
||||||
+{{ puzzle.tags.length - 3 }} more
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col items-end gap-2">
|
|
||||||
<div class="tooltip" data-tip="View on Steam Workshop">
|
|
||||||
<a
|
|
||||||
:href="`https://steamcommunity.com/workshop/filedetails/?id=${puzzle.steam_item_id}`"
|
|
||||||
target="_blank"
|
|
||||||
class="btn btn-ghost btn-sm btn-square"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-steam text-lg"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Responses Table -->
|
|
||||||
<div v-if="responses && responses.length > 0" class="mt-6">
|
|
||||||
<div class="divider">
|
|
||||||
<span class="text-sm font-medium"
|
|
||||||
>Solutions ({{ responses.length }})</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<table class="table table-xs">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Cost</th>
|
|
||||||
<th>Cycles</th>
|
|
||||||
<th>Area</th>
|
|
||||||
<th>Files</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr
|
|
||||||
v-for="response in responses"
|
|
||||||
:key="response.id"
|
|
||||||
class="hover"
|
|
||||||
>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
v-if="response.final_cost || response.cost"
|
|
||||||
class="badge badge-success badge-xs"
|
|
||||||
>
|
|
||||||
{{ response.final_cost || response.cost }}
|
|
||||||
</span>
|
|
||||||
<span v-else class="text-base-content/50">-</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
v-if="response.final_cycles || response.cycles"
|
|
||||||
class="badge badge-info badge-xs"
|
|
||||||
>
|
|
||||||
{{ response.final_cycles || response.cycles }}
|
|
||||||
</span>
|
|
||||||
<span v-else class="text-base-content/50">-</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
v-if="response.final_area || response.area"
|
|
||||||
class="badge badge-warning badge-xs"
|
|
||||||
>
|
|
||||||
{{ response.final_area || response.area }}
|
|
||||||
</span>
|
|
||||||
<span v-else class="text-base-content/50">-</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<span class="badge badge-ghost badge-xs">{{
|
|
||||||
response.files?.length || 0
|
|
||||||
}}</span>
|
|
||||||
<div
|
|
||||||
v-if="response.files?.length"
|
|
||||||
class="tooltip"
|
|
||||||
:data-tip="
|
|
||||||
response.files
|
|
||||||
.map((f) => f.original_filename || f.file?.name)
|
|
||||||
.join(', ')
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-information-outline text-xs"></i>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="response.needs_manual_validation"
|
|
||||||
class="tooltip"
|
|
||||||
data-tip="Needs manual validation"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-alert-circle text-xs text-warning"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- No responses state -->
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="mt-6 text-center py-4 border-2 border-dashed border-base-300 rounded-lg hover:border-primary transition-colors duration-300 cursor-pointer"
|
|
||||||
@click="openSubmissionModal"
|
|
||||||
>
|
|
||||||
<i class="mdi mdi-upload text-2xl text-base-content/40"></i>
|
|
||||||
<p class="text-sm text-base-content/60 mt-2">No solutions yet</p>
|
|
||||||
<p class="text-xs text-base-content/40">
|
|
||||||
Upload solutions using the submit button
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { SteamCollectionItem, PuzzleResponse } from "@/types";
|
|
||||||
import { useSubmissionsStore } from "@/stores/submissions";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
puzzle: SteamCollectionItem;
|
|
||||||
responses?: PuzzleResponse[];
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<Props>();
|
|
||||||
|
|
||||||
const { openSubmissionModal } = useSubmissionsStore();
|
|
||||||
</script>
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
export interface SteamCollection {
|
|
||||||
id: number
|
|
||||||
steam_id: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
author_name: string
|
|
||||||
total_items: number
|
|
||||||
unique_visitors: number
|
|
||||||
current_favorites: number
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SteamCollectionItem {
|
|
||||||
id: number
|
|
||||||
steam_item_id: string
|
|
||||||
title: string
|
|
||||||
author_name: string
|
|
||||||
description: string
|
|
||||||
tags: string[]
|
|
||||||
order_index: number
|
|
||||||
collection: number
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OpusMagnumData {
|
|
||||||
puzzle: string
|
|
||||||
cost: string
|
|
||||||
cycles: string
|
|
||||||
area: string
|
|
||||||
confidence: {
|
|
||||||
puzzle: number
|
|
||||||
cost: number
|
|
||||||
cycles: number
|
|
||||||
area: number
|
|
||||||
overall: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubmissionFile {
|
|
||||||
file: File
|
|
||||||
file_url: string
|
|
||||||
preview: string
|
|
||||||
type: 'image' | 'gif'
|
|
||||||
ocrData?: OpusMagnumData
|
|
||||||
ocrProcessing?: boolean
|
|
||||||
ocrError?: string
|
|
||||||
original_filename?: string
|
|
||||||
manualPuzzleSelection?: string
|
|
||||||
needsManualPuzzleSelection?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PuzzleResponse {
|
|
||||||
id?: number
|
|
||||||
// puzzle: number | SteamCollectionItem
|
|
||||||
puzzle: number
|
|
||||||
puzzle_name: string
|
|
||||||
cost?: string
|
|
||||||
cycles?: string
|
|
||||||
area?: string
|
|
||||||
needs_manual_validation?: boolean
|
|
||||||
ocr_confidence_cost?: number
|
|
||||||
ocr_confidence_cycles?: number
|
|
||||||
ocr_confidence_area?: number
|
|
||||||
validated_cost?: string
|
|
||||||
validated_cycles?: string
|
|
||||||
validated_area?: string
|
|
||||||
final_cost?: string
|
|
||||||
final_cycles?: string
|
|
||||||
final_area?: string
|
|
||||||
files?: SubmissionFile[]
|
|
||||||
created_at?: string
|
|
||||||
updated_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Submission {
|
|
||||||
id?: string
|
|
||||||
user?: number | null
|
|
||||||
responses: PuzzleResponse[]
|
|
||||||
notes?: string
|
|
||||||
is_validated?: boolean
|
|
||||||
validated_by?: number | null
|
|
||||||
validated_at?: string | null
|
|
||||||
manual_validation_requested?: boolean
|
|
||||||
total_responses?: number
|
|
||||||
needs_validation?: boolean
|
|
||||||
created_at?: string
|
|
||||||
updated_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserInfo {
|
|
||||||
id?: number
|
|
||||||
username?: string
|
|
||||||
first_name?: string
|
|
||||||
last_name?: string
|
|
||||||
email?: string
|
|
||||||
is_authenticated: boolean
|
|
||||||
is_staff: boolean
|
|
||||||
is_superuser: boolean
|
|
||||||
cas_groups?: string[]
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot"
|
|
||||||
},
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-B7mPwVP_.ttf",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf"
|
|
||||||
},
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-PXm3-2wK.woff",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff"
|
|
||||||
},
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
|
|
||||||
},
|
|
||||||
"src/main.ts": {
|
|
||||||
"file": "assets/main-CNlI4PW6.js",
|
|
||||||
"name": "main",
|
|
||||||
"src": "src/main.ts",
|
|
||||||
"isEntry": true,
|
|
||||||
"css": [
|
|
||||||
"assets/main-HDjkw-xK.css"
|
|
||||||
],
|
|
||||||
"assets": [
|
|
||||||
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
|
||||||
"assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
|
|
||||||
"assets/materialdesignicons-webfont-PXm3-2wK.woff",
|
|
||||||
"assets/materialdesignicons-webfont-B7mPwVP_.ttf"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot"
|
|
||||||
},
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-B7mPwVP_.ttf",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf"
|
|
||||||
},
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-PXm3-2wK.woff",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff"
|
|
||||||
},
|
|
||||||
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2": {
|
|
||||||
"file": "assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
|
|
||||||
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
|
|
||||||
},
|
|
||||||
"src/main.ts": {
|
|
||||||
"file": "assets/main-NIi3b_aN.js",
|
|
||||||
"name": "main",
|
|
||||||
"src": "src/main.ts",
|
|
||||||
"isEntry": true,
|
|
||||||
"css": [
|
|
||||||
"assets/main-CYuvChoP.css"
|
|
||||||
],
|
|
||||||
"assets": [
|
|
||||||
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
|
|
||||||
"assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
|
|
||||||
"assets/materialdesignicons-webfont-PXm3-2wK.woff",
|
|
||||||
"assets/materialdesignicons-webfont-B7mPwVP_.ttf"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,285 +0,0 @@
|
|||||||
from ninja import Router, File
|
|
||||||
from ninja.files import UploadedFile
|
|
||||||
from ninja.pagination import paginate
|
|
||||||
from django.db import transaction
|
|
||||||
from django.core.files.base import ContentFile
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.shortcuts import get_object_or_404
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from opus_submitter.submissions.utils import verify_and_validate_ocr_date_for_submission
|
|
||||||
|
|
||||||
from .models import Submission, PuzzleResponse, SubmissionFile, SteamCollectionItem
|
|
||||||
from .schemas import (
|
|
||||||
SubmissionIn,
|
|
||||||
SubmissionOut,
|
|
||||||
PuzzleResponseOut,
|
|
||||||
ValidationIn,
|
|
||||||
SteamCollectionItemOut,
|
|
||||||
)
|
|
||||||
|
|
||||||
router = Router()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/puzzles", response=List[SteamCollectionItemOut])
|
|
||||||
def list_puzzles(request):
|
|
||||||
"""Get list of available puzzles"""
|
|
||||||
return SteamCollectionItem.objects.select_related("collection").filter(
|
|
||||||
collection__is_active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/submissions", response=List[SubmissionOut])
|
|
||||||
@paginate
|
|
||||||
def list_submissions(request):
|
|
||||||
"""Get paginated list of submissions"""
|
|
||||||
return Submission.objects.prefetch_related(
|
|
||||||
"responses__files", "responses__puzzle"
|
|
||||||
).filter(user=request.user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/submissions/{submission_id}", response=SubmissionOut)
|
|
||||||
def get_submission(request, submission_id: str):
|
|
||||||
"""Get detailed submission by ID"""
|
|
||||||
return get_object_or_404(
|
|
||||||
Submission.objects.prefetch_related(
|
|
||||||
"responses__files", "responses__puzzle"
|
|
||||||
).filter(user=request.user),
|
|
||||||
id=submission_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/submissions", response=SubmissionOut)
|
|
||||||
def create_submission(
|
|
||||||
request, data: SubmissionIn, files: List[UploadedFile] = File(...)
|
|
||||||
):
|
|
||||||
"""Create a new submission with multiple puzzle responses"""
|
|
||||||
|
|
||||||
# Validate that we have files
|
|
||||||
if not files:
|
|
||||||
return 400, {"detail": "At least one file is required"}
|
|
||||||
|
|
||||||
# Group files by puzzle (based on filename or order)
|
|
||||||
# For now, we'll assume files are provided in the same order as responses
|
|
||||||
if len(files) < len(data.responses):
|
|
||||||
return 400, {"detail": "Not enough files for all responses"}
|
|
||||||
|
|
||||||
print(data, files)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with transaction.atomic():
|
|
||||||
# Check if any confidence score is below 80% to auto-request validation
|
|
||||||
auto_request_validation = any(
|
|
||||||
(
|
|
||||||
response_data.ocr_confidence_cost is not None
|
|
||||||
and response_data.ocr_confidence_cost < 0.8
|
|
||||||
)
|
|
||||||
or (
|
|
||||||
response_data.ocr_confidence_cycles is not None
|
|
||||||
and response_data.ocr_confidence_cycles < 0.8
|
|
||||||
)
|
|
||||||
or (
|
|
||||||
response_data.ocr_confidence_area is not None
|
|
||||||
and response_data.ocr_confidence_area < 0.8
|
|
||||||
)
|
|
||||||
for response_data in data.responses
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create the submission
|
|
||||||
submission = Submission.objects.create(
|
|
||||||
user=request.user if request.user.is_authenticated else None,
|
|
||||||
notes=data.notes,
|
|
||||||
manual_validation_requested=data.manual_validation_requested
|
|
||||||
or auto_request_validation,
|
|
||||||
)
|
|
||||||
|
|
||||||
file_index = 0
|
|
||||||
for response_data in data.responses:
|
|
||||||
# Get the puzzle
|
|
||||||
puzzle = get_object_or_404(
|
|
||||||
SteamCollectionItem, id=response_data.puzzle_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create the puzzle response
|
|
||||||
response = PuzzleResponse.objects.create(
|
|
||||||
submission=submission,
|
|
||||||
puzzle=puzzle,
|
|
||||||
puzzle_name=response_data.puzzle_name,
|
|
||||||
cost=response_data.cost,
|
|
||||||
cycles=response_data.cycles,
|
|
||||||
area=response_data.area,
|
|
||||||
needs_manual_validation=data.manual_validation_requested,
|
|
||||||
ocr_confidence_cost=response_data.ocr_confidence_cost,
|
|
||||||
ocr_confidence_cycles=response_data.ocr_confidence_cycles,
|
|
||||||
ocr_confidence_area=response_data.ocr_confidence_area,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Process files for this response
|
|
||||||
# For simplicity, we'll take one file per response
|
|
||||||
# In a real implementation, you'd need better file-to-response mapping
|
|
||||||
print("FI", file_index, files)
|
|
||||||
if file_index < len(files):
|
|
||||||
uploaded_file = files[file_index]
|
|
||||||
|
|
||||||
# Validate file type
|
|
||||||
if not uploaded_file.content_type.startswith(("image/", "video/")):
|
|
||||||
return 400, {
|
|
||||||
"detail": f"Invalid file type: {uploaded_file.content_type}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Validate file size (256MB limit)
|
|
||||||
if uploaded_file.size > 256 * 1024 * 1024:
|
|
||||||
return 400, {"detail": "File too large (max 256MB)"}
|
|
||||||
|
|
||||||
# Create submission file
|
|
||||||
submission_file = SubmissionFile.objects.create(
|
|
||||||
response=response,
|
|
||||||
original_filename=uploaded_file.name,
|
|
||||||
file_size=uploaded_file.size,
|
|
||||||
content_type=uploaded_file.content_type,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save the file
|
|
||||||
submission_file.file.save(
|
|
||||||
uploaded_file.name, ContentFile(uploaded_file.read()), save=True
|
|
||||||
)
|
|
||||||
|
|
||||||
file_index += 1
|
|
||||||
|
|
||||||
# Check if OCR validation is needed
|
|
||||||
if not all(
|
|
||||||
[response_data.cost, response_data.cycles, response_data.area]
|
|
||||||
):
|
|
||||||
response.mark_for_validation("Incomplete OCR data")
|
|
||||||
|
|
||||||
# Reload with relations for response
|
|
||||||
submission = Submission.objects.prefetch_related(
|
|
||||||
"responses__files", "responses__puzzle"
|
|
||||||
).get(id=submission.id)
|
|
||||||
|
|
||||||
return submission
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
return 500, {"detail": f"Error creating submission: {str(e)}"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/responses/{response_id}/validate", response=PuzzleResponseOut)
|
|
||||||
def validate_response(request, response_id: int, data: ValidationIn):
|
|
||||||
"""Manually validate a puzzle response"""
|
|
||||||
|
|
||||||
if not request.user.is_authenticated or not request.user.is_staff:
|
|
||||||
return 403, {"detail": "Admin access required"}
|
|
||||||
|
|
||||||
response = get_object_or_404(PuzzleResponse, id=response_id)
|
|
||||||
|
|
||||||
if data.puzzle is not None:
|
|
||||||
puzzle = get_object_or_404(SteamCollectionItem, id=data.puzzle)
|
|
||||||
response.puzzle = puzzle
|
|
||||||
|
|
||||||
# Update validated values
|
|
||||||
if data.validated_cost is not None:
|
|
||||||
response.validated_cost = data.validated_cost
|
|
||||||
|
|
||||||
if data.validated_cycles is not None:
|
|
||||||
response.validated_cycles = data.validated_cycles
|
|
||||||
|
|
||||||
if data.validated_area is not None:
|
|
||||||
response.validated_area = data.validated_area
|
|
||||||
|
|
||||||
# Mark as no longer needing validation if we have all values
|
|
||||||
if all([response.final_cost, response.final_cycles, response.final_area]):
|
|
||||||
response.needs_manual_validation = False
|
|
||||||
|
|
||||||
response.save()
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/responses/{response_id}/validate/auto", response=PuzzleResponseOut)
|
|
||||||
def validate_response(request, response_id: int):
|
|
||||||
"""Try to auto validate a puzzle response"""
|
|
||||||
|
|
||||||
if not request.user.is_authenticated or not request.user.is_staff:
|
|
||||||
return 403, {"detail": "Admin access required"}
|
|
||||||
|
|
||||||
response = get_object_or_404(PuzzleResponse, id=response_id)
|
|
||||||
|
|
||||||
for file in response.files.all():
|
|
||||||
verify_and_validate_ocr_date_for_submission(file)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/responses/needs-validation", response=List[PuzzleResponseOut])
|
|
||||||
def list_responses_needing_validation(request):
|
|
||||||
"""Get all responses that need manual validation"""
|
|
||||||
|
|
||||||
if not request.user.is_authenticated or not request.user.is_staff:
|
|
||||||
return 403, {"detail": "Admin access required"}
|
|
||||||
|
|
||||||
return (
|
|
||||||
PuzzleResponse.objects.filter(needs_manual_validation=True)
|
|
||||||
.filter(puzzle__collection__is_active=True)
|
|
||||||
.select_related("puzzle", "submission")
|
|
||||||
.prefetch_related("files")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/submissions/{submission_id}/validate", response=SubmissionOut)
|
|
||||||
def validate_submission(request, submission_id: str):
|
|
||||||
"""Mark entire submission as validated"""
|
|
||||||
|
|
||||||
if not request.user.is_authenticated or not request.user.is_staff:
|
|
||||||
return 403, {"detail": "Admin access required"}
|
|
||||||
|
|
||||||
submission = get_object_or_404(Submission, id=submission_id)
|
|
||||||
|
|
||||||
submission.is_validated = True
|
|
||||||
submission.validated_by = request.user
|
|
||||||
submission.validated_at = timezone.now()
|
|
||||||
submission.save()
|
|
||||||
|
|
||||||
# Also mark all responses as not needing validation
|
|
||||||
submission.responses.update(needs_manual_validation=False)
|
|
||||||
|
|
||||||
# Reload with relations
|
|
||||||
submission = Submission.objects.prefetch_related(
|
|
||||||
"responses__files", "responses__puzzle"
|
|
||||||
).get(id=submission.id)
|
|
||||||
|
|
||||||
return submission
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/submissions/{submission_id}")
|
|
||||||
def delete_submission(request, submission_id: str):
|
|
||||||
"""Delete a submission (admin only)"""
|
|
||||||
|
|
||||||
if not request.user.is_authenticated or not request.user.is_staff:
|
|
||||||
return 403, {"detail": "Admin access required"}
|
|
||||||
|
|
||||||
submission = get_object_or_404(Submission, id=submission_id)
|
|
||||||
submission.delete()
|
|
||||||
return {"detail": "Submission deleted successfully"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
|
||||||
def get_stats(request):
|
|
||||||
"""Get submission statistics"""
|
|
||||||
|
|
||||||
total_submissions = Submission.objects.count()
|
|
||||||
total_responses = PuzzleResponse.objects.count()
|
|
||||||
needs_validation = PuzzleResponse.objects.filter(
|
|
||||||
needs_manual_validation=True
|
|
||||||
).count()
|
|
||||||
validated_submissions = Submission.objects.filter(is_validated=True).count()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total_submissions": total_submissions,
|
|
||||||
"total_responses": total_responses,
|
|
||||||
"needs_validation": needs_validation,
|
|
||||||
"validated_submissions": validated_submissions,
|
|
||||||
"validation_rate": (total_responses - needs_validation) / total_responses
|
|
||||||
if total_responses
|
|
||||||
else 0,
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
{% load django_vite %}
|
|
||||||
<html lang="en" data-theme="dim">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Opus Magnum Puzzle Submitter</title>
|
|
||||||
{% vite_hmr_client %}
|
|
||||||
{% vite_asset 'src/main.ts' %}
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app" data-collection-title="{{ collection.title }}" data-collection-url="{{ collection.url }}" data-collection-description="{{ collection.description }}"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"root":["./src/main.ts","./src/services/apiService.ts","./src/services/ocrService.ts","./src/stores/index.ts","./src/stores/puzzles.ts","./src/stores/submissions.ts","./src/types/index.ts","./src/App.vue","./src/components/AdminPanel.vue","./src/components/FileUpload.vue","./src/components/PuzzleCard.vue","./src/components/SubmissionForm.vue"],"version":"5.9.3"}
|
|
||||||
Vendored
-2
@@ -1,2 +0,0 @@
|
|||||||
declare const _default: import("vite").UserConfig;
|
|
||||||
export default _default;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import vue from '@vitejs/plugin-vue';
|
|
||||||
import { resolve } from 'path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
|
||||||
// https://vitejs.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
base: '/static/',
|
|
||||||
plugins: [
|
|
||||||
vue(),
|
|
||||||
tailwindcss(),
|
|
||||||
],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
manifest: 'manifest.json',
|
|
||||||
outDir: resolve("./static_source/vite"),
|
|
||||||
rollupOptions: {
|
|
||||||
input: { main: resolve('./src/main.ts') }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Vite build output (generated locally via `pnpm build`, served by whitenoise)
|
||||||
|
static_source/vite/assets
|
||||||
|
static_source/vite/manifest.json
|
||||||
|
static_source/vite/icon.png
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
# vue-tsc emits a compiled copy of vite.config.ts here (tsconfig.node.json,
|
||||||
|
# composite project ref) — Vite's config lookup prefers .js over .ts, so a
|
||||||
|
# committed copy silently shadows every edit to the real config.
|
||||||
|
/vite.config.js
|
||||||
|
/vite.config.d.ts
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from django_object_actions import DjangoObjectActions, action
|
||||||
|
|
||||||
|
from .models import CustomUser
|
||||||
|
|
||||||
|
# Must match loadtest/locustfile.py's own defaults (LOAD_TEST_USER_COUNT,
|
||||||
|
# LOAD_TEST_USER_PREFIX, LOAD_TEST_PASSWORD) - see that file for what these
|
||||||
|
# accounts are for and how they're used.
|
||||||
|
LOAD_TEST_USER_COUNT = 500
|
||||||
|
LOAD_TEST_USER_PREFIX = "loadtest"
|
||||||
|
LOAD_TEST_PASSWORD = "loadtest-only-password"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(CustomUser)
|
||||||
|
class CustomUserAdmin(DjangoObjectActions, UserAdmin):
|
||||||
|
"""Admin interface for CustomUser."""
|
||||||
|
|
||||||
|
# Add custom fields to the user admin
|
||||||
|
fieldsets = UserAdmin.fieldsets + (
|
||||||
|
(
|
||||||
|
"CAS Information",
|
||||||
|
{
|
||||||
|
"fields": ("cas_user_id", "cas_groups", "cas_attributes"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add custom fields to the list display
|
||||||
|
list_display = UserAdmin.list_display + ("cas_user_id", "get_cas_groups_display")
|
||||||
|
|
||||||
|
# Add search fields
|
||||||
|
search_fields = UserAdmin.search_fields + ("cas_user_id",)
|
||||||
|
|
||||||
|
# Add filters
|
||||||
|
list_filter = UserAdmin.list_filter + ("cas_groups",)
|
||||||
|
|
||||||
|
# Make CAS fields readonly in admin
|
||||||
|
readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes")
|
||||||
|
|
||||||
|
changelist_actions = ("create_load_test_users",)
|
||||||
|
|
||||||
|
def get_cas_groups_display(self, obj):
|
||||||
|
"""Display CAS groups in admin list."""
|
||||||
|
return obj.get_cas_groups_display()
|
||||||
|
|
||||||
|
get_cas_groups_display.short_description = "CAS Groups"
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_changelist_actions(self, request):
|
||||||
|
# Second layer of the same guard rail as the login endpoint itself
|
||||||
|
# (see testing_auth_views.py): the button doesn't even show up
|
||||||
|
# unless this deployment opted into load testing.
|
||||||
|
if not settings.LOAD_TEST_LOGIN_ENABLED:
|
||||||
|
return []
|
||||||
|
return super().get_changelist_actions(request)
|
||||||
|
|
||||||
|
@action(
|
||||||
|
label="Create load test users",
|
||||||
|
description=(
|
||||||
|
"Create/reset the local loadtestN accounts used by "
|
||||||
|
"loadtest/locustfile.py - authenticates via ModelBackend, "
|
||||||
|
"never the real CAS server"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def create_load_test_users(self, request, _queryset):
|
||||||
|
created = 0
|
||||||
|
for i in range(LOAD_TEST_USER_COUNT):
|
||||||
|
username = f"{LOAD_TEST_USER_PREFIX}{i}"
|
||||||
|
user, was_created = CustomUser.objects.get_or_create(username=username)
|
||||||
|
user.set_password(LOAD_TEST_PASSWORD)
|
||||||
|
user.save()
|
||||||
|
created += was_created
|
||||||
|
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"{created} created, {LOAD_TEST_USER_COUNT - created} already existed "
|
||||||
|
f"(password reset on all {LOAD_TEST_USER_COUNT}).",
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AccountsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "accounts"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
# django-admin-interface's Theme.logo/favicon are plain FileFields,
|
||||||
|
# so without this they'd resolve through STORAGES["default"] (S3 -
|
||||||
|
# see settings.py) like every submission/media upload. The theme's
|
||||||
|
# logo/favicon (see accounts/fixtures/admin_interface_theme.json)
|
||||||
|
# are actually a static asset (static/favicon.ico, built from
|
||||||
|
# public/favicon.ico by Vite - see vite.config.ts), so point those
|
||||||
|
# two fields at STATIC_ROOT/STATIC_URL instead, served by WhiteNoise
|
||||||
|
# like any other static file.
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.files.storage import FileSystemStorage
|
||||||
|
|
||||||
|
from admin_interface.models import Theme
|
||||||
|
|
||||||
|
static_storage = FileSystemStorage(
|
||||||
|
location=settings.STATIC_ROOT, base_url=settings.STATIC_URL
|
||||||
|
)
|
||||||
|
Theme._meta.get_field("logo").storage = static_storage
|
||||||
|
Theme._meta.get_field("favicon").storage = static_storage
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "admin_interface.theme",
|
||||||
|
"pk": 2,
|
||||||
|
"fields": {
|
||||||
|
"name": "Django Purple (by Legrems)",
|
||||||
|
"active": true,
|
||||||
|
"title": "Super administrateur de poney ",
|
||||||
|
"title_color": "#E5B8FF",
|
||||||
|
"title_visible": true,
|
||||||
|
"logo": "favicon.ico",
|
||||||
|
"logo_color": "#831a96",
|
||||||
|
"logo_max_width": 400,
|
||||||
|
"logo_max_height": 50,
|
||||||
|
"logo_visible": true,
|
||||||
|
"favicon": "favicon.ico",
|
||||||
|
"env_name": "",
|
||||||
|
"env_color": "#831a96",
|
||||||
|
"env_visible_in_header": true,
|
||||||
|
"env_visible_in_favicon": true,
|
||||||
|
"language_chooser_active": true,
|
||||||
|
"language_chooser_control": "default-select",
|
||||||
|
"language_chooser_display": "code",
|
||||||
|
"css_header_background_color": "#2d0840",
|
||||||
|
"css_header_text_color": "#831a96",
|
||||||
|
"css_header_link_color": "#FFFFFF",
|
||||||
|
"css_header_link_hover_color": "#a82bb8",
|
||||||
|
"css_module_background_color": "#6b0f7d",
|
||||||
|
"css_module_background_selected_color": "#831a96",
|
||||||
|
"css_module_text_color": "#FFFFFF",
|
||||||
|
"css_module_link_color": "#FFFFFF",
|
||||||
|
"css_module_link_selected_color": "#FFFFFF",
|
||||||
|
"css_module_link_hover_color": "#a82bb8",
|
||||||
|
"css_module_rounded_corners": true,
|
||||||
|
"css_generic_link_color": "#2d0840",
|
||||||
|
"css_generic_link_hover_color": "#5a0d6b",
|
||||||
|
"css_save_button_background_color": "#2d0840",
|
||||||
|
"css_save_button_background_hover_color": "#5a0d6b",
|
||||||
|
"css_save_button_text_color": "#FFFFFF",
|
||||||
|
"css_delete_button_background_color": "#a82bb8",
|
||||||
|
"css_delete_button_background_hover_color": "#831a96",
|
||||||
|
"css_delete_button_text_color": "#FFFFFF",
|
||||||
|
"related_modal_active": true,
|
||||||
|
"related_modal_background_color": "#000000",
|
||||||
|
"related_modal_background_opacity": "0.3",
|
||||||
|
"related_modal_rounded_corners": true,
|
||||||
|
"related_modal_close_button_visible": true,
|
||||||
|
"list_filter_highlight": true,
|
||||||
|
"list_filter_dropdown": true,
|
||||||
|
"list_filter_sticky": true,
|
||||||
|
"list_filter_removal_links": false,
|
||||||
|
"foldable_apps": true,
|
||||||
|
"show_fieldsets_as_tabs": false,
|
||||||
|
"show_inlines_as_tabs": false,
|
||||||
|
"recent_actions_visible": true,
|
||||||
|
"form_submit_sticky": false,
|
||||||
|
"form_pagination_sticky": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-23 18:11
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="customuser",
|
||||||
|
name="points",
|
||||||
|
field=models.IntegerField(default=1000),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from django.core.management import call_command
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def load_admin_theme(apps, schema_editor):
|
||||||
|
# Seeds the default django-admin-interface theme (see
|
||||||
|
# accounts/fixtures/admin_interface_theme.json). Runs once, the first
|
||||||
|
# time this migration is applied, so it won't clobber a theme later
|
||||||
|
# edited by hand through the admin.
|
||||||
|
call_command("loaddata", "admin_interface_theme.json")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0002_customuser_points"),
|
||||||
|
("admin_interface", "0032_alter_theme_defaults"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(load_admin_theme, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -14,6 +14,9 @@ class CustomUser(AbstractUser):
|
|||||||
# Additional fields that might come from CAS
|
# Additional fields that might come from CAS
|
||||||
cas_attributes = models.JSONField(default=dict, blank=True)
|
cas_attributes = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
# Market points balance
|
||||||
|
points = models.IntegerField(default=1000)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.username} ({self.cas_user_id})"
|
return f"{self.username} ({self.cas_user_id})"
|
||||||
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from animations.models import PuzzlePointsFactor, PuzzlePointsValue
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PuzzlePointsFactor)
|
||||||
|
class PuzzlePointsFactorAdmin(admin.ModelAdmin):
|
||||||
|
list_display = [
|
||||||
|
"id",
|
||||||
|
"cost",
|
||||||
|
"cycles",
|
||||||
|
"area",
|
||||||
|
"special_notes",
|
||||||
|
]
|
||||||
|
list_filter = ["cost", "cycles", "area", "special_notes"]
|
||||||
|
search_fields = ["cost", "cycles", "area", "special_notes"]
|
||||||
|
readonly_fields = ["created_at", "updated_at"]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
"Basic Information",
|
||||||
|
{"fields": ("cost", "cycles", "area")},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Special notes",
|
||||||
|
{
|
||||||
|
"fields": ("special_notes",),
|
||||||
|
"description": "Special notes about the puzzle. May be some extra restriction, etc...",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Metadata",
|
||||||
|
{
|
||||||
|
"fields": ("created_at", "updated_at"),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PuzzlePointsValue)
|
||||||
|
class PuzzlePointsValueAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["id", "points"]
|
||||||
|
list_filter = ["points"]
|
||||||
|
search_fields = ["points"]
|
||||||
|
readonly_fields = ["created_at", "updated_at"]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
"Basic Information",
|
||||||
|
{"fields": ("points",)},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Metadata",
|
||||||
|
{
|
||||||
|
"fields": ("created_at", "updated_at"),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from ninja import Router
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
from accounts.models import CustomUser
|
||||||
|
from animations.schemas import (
|
||||||
|
RankingSchema,
|
||||||
|
TournamentSubmissionsOut,
|
||||||
|
TournamentPuzzleResultsOut,
|
||||||
|
PuzzleSubmissionsOut,
|
||||||
|
PuzzleResultsOut,
|
||||||
|
PuzzleSubmissionWithRankOut,
|
||||||
|
PuzzlePointsFactorOut,
|
||||||
|
WinnerResponseOut,
|
||||||
|
WinnerFileOut,
|
||||||
|
)
|
||||||
|
from opus_magnum.models import PuzzleResponse, SteamCollectionItem, SteamCollection
|
||||||
|
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
PREFIX = "/events/{event_path}/results"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{PREFIX}/results", response=RankingSchema)
|
||||||
|
def results(request: HttpRequest, event_path: str) -> dict:
|
||||||
|
cache_key = f"api:{event_path}:results:results"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
responses_by_userid = defaultdict(list)
|
||||||
|
responses_by_puzzleid = defaultdict(list)
|
||||||
|
|
||||||
|
for response in list(
|
||||||
|
PuzzleResponse.objects.filter(
|
||||||
|
needs_manual_validation=False, submission__event=request.event
|
||||||
|
)
|
||||||
|
.filter_user_best_response()
|
||||||
|
.prefetch_related("submission__user")
|
||||||
|
):
|
||||||
|
responses_by_userid[response.submission.user.id].append(response)
|
||||||
|
responses_by_puzzleid[response.puzzle.id].append(response)
|
||||||
|
|
||||||
|
ranking = {}
|
||||||
|
|
||||||
|
for puzzle_id, responses in responses_by_puzzleid.items():
|
||||||
|
ranking[puzzle_id] = sorted(
|
||||||
|
responses, key=lambda x: (x.rank_points is None, x.rank_points or 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"users": list(CustomUser.objects.filter(pk__in=responses_by_userid.keys())),
|
||||||
|
"puzzles": list(
|
||||||
|
SteamCollectionItem.objects.filter(collection__event=request.event)
|
||||||
|
),
|
||||||
|
"responses_by_userid": dict(responses_by_userid),
|
||||||
|
"ranking_by_puzzle": ranking,
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(cache_key, data, 300)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{PREFIX}/top-submissions", response=TournamentSubmissionsOut)
|
||||||
|
def top_submissions(request: HttpRequest, event_path: str, limit: int = 5) -> dict:
|
||||||
|
"""Get tournament top submissions for each puzzle. Only available when tournament is closed."""
|
||||||
|
cache_key = f"api:{event_path}:results:top_submissions:{limit}"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
collection = get_object_or_404(SteamCollection, is_active=True, event=request.event)
|
||||||
|
|
||||||
|
# Only allow access when tournament is closed
|
||||||
|
if collection.accepting_submissions:
|
||||||
|
raise HttpError(403, "Tournament is still accepting submissions")
|
||||||
|
|
||||||
|
# Get all puzzles
|
||||||
|
puzzles = SteamCollectionItem.objects.filter(collection=collection).order_by(
|
||||||
|
"order_index"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
submissions_list = []
|
||||||
|
for puzzle in puzzles:
|
||||||
|
# Get the top N responses for this puzzle (ranked by points, highest first)
|
||||||
|
top_responses = (
|
||||||
|
PuzzleResponse.objects.filter(puzzle=puzzle, needs_manual_validation=False)
|
||||||
|
.filter_user_best_response()
|
||||||
|
.annotate_rank_points()
|
||||||
|
.order_by("-rank_points")[:limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build submission list for this puzzle
|
||||||
|
puzzle_submissions = []
|
||||||
|
for response in top_responses:
|
||||||
|
# Get submission files
|
||||||
|
files = response.files.all()
|
||||||
|
response_files = [
|
||||||
|
WinnerFileOut(
|
||||||
|
file_url=file.file_url or "",
|
||||||
|
original_filename=file.original_filename,
|
||||||
|
)
|
||||||
|
for file in files
|
||||||
|
]
|
||||||
|
|
||||||
|
# Calculate total coefficient
|
||||||
|
total_coef = None
|
||||||
|
if (
|
||||||
|
puzzle.points_factor
|
||||||
|
and response.final_cost is not None
|
||||||
|
and response.final_cycles is not None
|
||||||
|
and response.final_area is not None
|
||||||
|
):
|
||||||
|
total_coef = (
|
||||||
|
puzzle.points_factor.cost * response.final_cost
|
||||||
|
+ puzzle.points_factor.cycles * response.final_cycles
|
||||||
|
+ puzzle.points_factor.area * response.final_area
|
||||||
|
)
|
||||||
|
|
||||||
|
submission_data = WinnerResponseOut(
|
||||||
|
user_id=response.submission.user.id if response.submission.user else 0,
|
||||||
|
username=response.submission.user.username
|
||||||
|
if response.submission.user
|
||||||
|
else "Anonymous",
|
||||||
|
final_cost=response.final_cost,
|
||||||
|
final_cycles=response.final_cycles,
|
||||||
|
final_area=response.final_area,
|
||||||
|
rank_points=response.rank_points,
|
||||||
|
total_coef=total_coef,
|
||||||
|
files=response_files,
|
||||||
|
)
|
||||||
|
puzzle_submissions.append(submission_data)
|
||||||
|
|
||||||
|
submissions_list.append(
|
||||||
|
PuzzleSubmissionsOut(
|
||||||
|
puzzle_id=puzzle.id,
|
||||||
|
puzzle_title=puzzle.title,
|
||||||
|
submissions=puzzle_submissions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {"submissions": submissions_list}
|
||||||
|
cache.set(cache_key, data, 300)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{PREFIX}/puzzle-results", response=TournamentPuzzleResultsOut)
|
||||||
|
def puzzle_results(request: HttpRequest, event_path: str, limit: int = 5) -> dict:
|
||||||
|
"""Get tournament results organized by puzzle with coefficients. Only available when tournament is closed."""
|
||||||
|
cache_key = f"api:{event_path}:results:puzzle_results:{limit}"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
|
||||||
|
if cached_data is not None:
|
||||||
|
return cached_data
|
||||||
|
|
||||||
|
collection = get_object_or_404(SteamCollection, is_active=True, event=request.event)
|
||||||
|
|
||||||
|
# Only allow access when tournament is closed
|
||||||
|
if collection.accepting_submissions:
|
||||||
|
raise HttpError(403, "Tournament is still accepting submissions")
|
||||||
|
|
||||||
|
# Get all puzzles
|
||||||
|
puzzles = SteamCollectionItem.objects.filter(collection=collection).order_by(
|
||||||
|
"order_index"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
results_list = []
|
||||||
|
for puzzle in puzzles:
|
||||||
|
# Get the top N responses for this puzzle (ranked by points)
|
||||||
|
top_responses = (
|
||||||
|
PuzzleResponse.objects.filter(puzzle=puzzle, needs_manual_validation=False)
|
||||||
|
.filter_user_best_response()
|
||||||
|
.annotate_rank_points()
|
||||||
|
.order_by("-rank_points")[:limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build submission list for this puzzle with rank
|
||||||
|
puzzle_submissions = []
|
||||||
|
for rank, response in enumerate(top_responses, 1):
|
||||||
|
# Get submission files
|
||||||
|
files = response.files.all()
|
||||||
|
response_files = [
|
||||||
|
WinnerFileOut(
|
||||||
|
file_url=file.file_url or "",
|
||||||
|
original_filename=file.original_filename,
|
||||||
|
)
|
||||||
|
for file in files
|
||||||
|
]
|
||||||
|
|
||||||
|
# Calculate total coefficient
|
||||||
|
total_coef = None
|
||||||
|
if (
|
||||||
|
puzzle.points_factor
|
||||||
|
and response.final_cost is not None
|
||||||
|
and response.final_cycles is not None
|
||||||
|
and response.final_area is not None
|
||||||
|
):
|
||||||
|
total_coef = (
|
||||||
|
puzzle.points_factor.cost * response.final_cost
|
||||||
|
+ puzzle.points_factor.cycles * response.final_cycles
|
||||||
|
+ puzzle.points_factor.area * response.final_area
|
||||||
|
)
|
||||||
|
|
||||||
|
submission_data = PuzzleSubmissionWithRankOut(
|
||||||
|
rank=rank,
|
||||||
|
user_id=response.submission.user.id if response.submission.user else 0,
|
||||||
|
username=response.submission.user.username
|
||||||
|
if response.submission.user
|
||||||
|
else "Anonymous",
|
||||||
|
final_cost=response.final_cost,
|
||||||
|
final_cycles=response.final_cycles,
|
||||||
|
final_area=response.final_area,
|
||||||
|
rank_points=response.rank_points,
|
||||||
|
total_coef=total_coef,
|
||||||
|
files=response_files,
|
||||||
|
)
|
||||||
|
puzzle_submissions.append(submission_data)
|
||||||
|
|
||||||
|
# Get points factor if available
|
||||||
|
points_factor = None
|
||||||
|
if puzzle.points_factor:
|
||||||
|
points_factor = PuzzlePointsFactorOut(
|
||||||
|
cost=puzzle.points_factor.cost,
|
||||||
|
cycles=puzzle.points_factor.cycles,
|
||||||
|
area=puzzle.points_factor.area,
|
||||||
|
)
|
||||||
|
|
||||||
|
results_list.append(
|
||||||
|
PuzzleResultsOut(
|
||||||
|
puzzle_id=puzzle.id,
|
||||||
|
puzzle_title=puzzle.title,
|
||||||
|
points_factor=points_factor,
|
||||||
|
submissions=puzzle_submissions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {"results": results_list}
|
||||||
|
cache.set(cache_key, data, 300)
|
||||||
|
return data
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class SubmissionsConfig(AppConfig):
|
class AnimationsConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "submissions"
|
name = "animations"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-23 22:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PuzzlePointsFactor",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("cost", models.IntegerField()),
|
||||||
|
("cycles", models.IntegerField()),
|
||||||
|
("area", models.IntegerField()),
|
||||||
|
("special_notes", models.TextField(blank=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-24 01:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("animations", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="PuzzlePointsValue",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("points", models.JSONField(default=[])),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-05-05 13:38
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("animations", "0002_puzzlepointsvalue"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="puzzlepointsvalue",
|
||||||
|
name="points",
|
||||||
|
field=models.JSONField(default=list),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzlePointsFactor(models.Model):
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
cost = models.IntegerField()
|
||||||
|
cycles = models.IntegerField()
|
||||||
|
area = models.IntegerField()
|
||||||
|
|
||||||
|
special_notes = models.TextField(blank=True)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.cost} - {self.cycles} - {self.area}"
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzlePointsValue(models.Model):
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
points = models.JSONField(default=list)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
from ninja import ModelSchema, Schema
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from opus_magnum.models import PuzzleResponse
|
||||||
|
from opus_magnum.schemas import SteamCollectionItemOut
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleResponseRankingOut(ModelSchema):
|
||||||
|
class Meta:
|
||||||
|
model = PuzzleResponse
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"puzzle_name",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
points: int | None = None
|
||||||
|
rank_points: int | None = None
|
||||||
|
puzzle_user_rank: int
|
||||||
|
user_response_rank: int
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
|
||||||
|
final_cost: int | None
|
||||||
|
final_cycles: int | None
|
||||||
|
final_area: int | None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_user_id(obj) -> int:
|
||||||
|
return obj.submission.user.id
|
||||||
|
|
||||||
|
|
||||||
|
class UserDisplayOut(Schema):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
is_staff: bool
|
||||||
|
|
||||||
|
|
||||||
|
class RankingSchema(Schema):
|
||||||
|
users: list[UserDisplayOut]
|
||||||
|
puzzles: list[SteamCollectionItemOut]
|
||||||
|
responses_by_userid: dict[int, list[PuzzleResponseRankingOut]]
|
||||||
|
ranking_by_puzzle: dict[int, list[PuzzleResponseRankingOut]]
|
||||||
|
|
||||||
|
|
||||||
|
class WinnerFileOut(Schema):
|
||||||
|
"""Schema for winner submission file"""
|
||||||
|
|
||||||
|
file_url: str
|
||||||
|
original_filename: str
|
||||||
|
|
||||||
|
|
||||||
|
class WinnerResponseOut(Schema):
|
||||||
|
"""Schema for winner response with files"""
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
final_cost: Optional[int]
|
||||||
|
final_cycles: Optional[int]
|
||||||
|
final_area: Optional[int]
|
||||||
|
rank_points: Optional[int]
|
||||||
|
total_coef: Optional[int]
|
||||||
|
files: List[WinnerFileOut]
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleSubmissionsOut(Schema):
|
||||||
|
"""Schema for puzzle with all top submissions"""
|
||||||
|
|
||||||
|
puzzle_id: int
|
||||||
|
puzzle_title: str
|
||||||
|
submissions: List[WinnerResponseOut]
|
||||||
|
|
||||||
|
|
||||||
|
class TournamentSubmissionsOut(Schema):
|
||||||
|
"""Schema for tournament top submissions results"""
|
||||||
|
|
||||||
|
submissions: List[PuzzleSubmissionsOut]
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzlePointsFactorOut(Schema):
|
||||||
|
"""Schema for puzzle points factor"""
|
||||||
|
|
||||||
|
cost: int
|
||||||
|
cycles: int
|
||||||
|
area: int
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleSubmissionWithRankOut(Schema):
|
||||||
|
"""Schema for puzzle submission with rank"""
|
||||||
|
|
||||||
|
rank: int
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
final_cost: Optional[int]
|
||||||
|
final_cycles: Optional[int]
|
||||||
|
final_area: Optional[int]
|
||||||
|
rank_points: Optional[int]
|
||||||
|
total_coef: Optional[int]
|
||||||
|
files: List[WinnerFileOut]
|
||||||
|
|
||||||
|
|
||||||
|
class PuzzleResultsOut(Schema):
|
||||||
|
"""Schema for puzzle-specific results with coefficients"""
|
||||||
|
|
||||||
|
puzzle_id: int
|
||||||
|
puzzle_title: str
|
||||||
|
points_factor: Optional[PuzzlePointsFactorOut]
|
||||||
|
submissions: List[PuzzleSubmissionWithRankOut]
|
||||||
|
|
||||||
|
|
||||||
|
class TournamentPuzzleResultsOut(Schema):
|
||||||
|
"""Schema for tournament puzzle-specific results"""
|
||||||
|
|
||||||
|
results: List[PuzzleResultsOut]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import ClockTimer, Press
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ClockTimer)
|
||||||
|
class ClockTimerAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("event", "end_time", "initial_duration")
|
||||||
|
list_editable = ("initial_duration",)
|
||||||
|
list_filter = ("event",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Press)
|
||||||
|
class PressAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"event",
|
||||||
|
"player",
|
||||||
|
"pressed_at",
|
||||||
|
"remaining_before_press",
|
||||||
|
"seconds_added",
|
||||||
|
)
|
||||||
|
list_filter = ("event", "player")
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.utils import timezone
|
||||||
|
from ninja import Router
|
||||||
|
from ninja.decorators import decorate_view
|
||||||
|
|
||||||
|
from games.decorators import require_game_enabled
|
||||||
|
|
||||||
|
from .models import PRESS_BONUS_SECONDS, ClockTimer, Press
|
||||||
|
from .schemas import ClockStateOut, PlayerStatsOut
|
||||||
|
from .scoring import ranked_players
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
PREFIX = "/events/{event_path}/clock"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{PREFIX}/current", response=ClockStateOut)
|
||||||
|
@decorate_view(require_game_enabled(path="clock"))
|
||||||
|
def current_state(request: HttpRequest, event_path: str):
|
||||||
|
timer = ClockTimer.current(request.event)
|
||||||
|
timer.reset_if_expired()
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
next_press_at = None
|
||||||
|
cooldown_seconds = None
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
last_press = (
|
||||||
|
Press.objects.filter(player=request.user, event=request.event)
|
||||||
|
.order_by("-pressed_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if last_press is not None:
|
||||||
|
candidate = last_press.pressed_at + timedelta(
|
||||||
|
seconds=last_press.cooldown_seconds
|
||||||
|
)
|
||||||
|
if candidate > now:
|
||||||
|
next_press_at = candidate
|
||||||
|
cooldown_seconds = last_press.cooldown_seconds
|
||||||
|
|
||||||
|
return {
|
||||||
|
"server_time": now,
|
||||||
|
"end_time": timer.end_time,
|
||||||
|
"initial_duration": timer.initial_duration,
|
||||||
|
"press_bonus_seconds": PRESS_BONUS_SECONDS,
|
||||||
|
"next_press_at": next_press_at,
|
||||||
|
"cooldown_seconds": cooldown_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{PREFIX}/me", response=PlayerStatsOut)
|
||||||
|
@decorate_view(require_game_enabled(path="clock"))
|
||||||
|
def my_stats(request: HttpRequest, event_path: str):
|
||||||
|
ranked = list(
|
||||||
|
ranked_players(request.event)
|
||||||
|
.values("id", "clock_points", "rank")
|
||||||
|
.order_by("-clock_points", "id")
|
||||||
|
)
|
||||||
|
total_players = len(ranked)
|
||||||
|
my_index = next(
|
||||||
|
(i for i, row in enumerate(ranked) if row["id"] == request.user.id), None
|
||||||
|
)
|
||||||
|
|
||||||
|
mine = ranked[my_index] if my_index is not None else None
|
||||||
|
above = ranked[my_index - 1] if my_index is not None and my_index > 0 else None
|
||||||
|
below = (
|
||||||
|
ranked[my_index + 1]
|
||||||
|
if my_index is not None and my_index + 1 < total_players
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"points": mine["clock_points"] if mine else 0.0,
|
||||||
|
"rank": mine["rank"] if mine else total_players,
|
||||||
|
"total_players": total_players,
|
||||||
|
"points_above": above["clock_points"] if above else None,
|
||||||
|
"points_below": below["clock_points"] if below else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ClockConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "clock"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
from . import signals # noqa: F401
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
def clock_group(event_path: str) -> str:
|
||||||
|
"""Channel group for one event's clock room - every event ticks independently."""
|
||||||
|
return f"clock_room_{event_path}"
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from channels.db import database_sync_to_async
|
||||||
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
from games.models import Game
|
||||||
|
|
||||||
|
from .broadcast import clock_group
|
||||||
|
from .models import PRESS_BONUS_SECONDS, ClockTimer, Press, random_cooldown_seconds
|
||||||
|
from .wire import encode, to_epoch_ms
|
||||||
|
|
||||||
|
|
||||||
|
class ClockConsumer(AsyncWebsocketConsumer):
|
||||||
|
async def connect(self):
|
||||||
|
self.event_path = self.scope["url_route"]["kwargs"]["event_path"]
|
||||||
|
self.event = await self._get_enabled_event(
|
||||||
|
self.event_path, self.scope.get("user")
|
||||||
|
)
|
||||||
|
if self.event is None:
|
||||||
|
await self.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.group_name = clock_group(self.event_path)
|
||||||
|
await self.channel_layer.group_add(self.group_name, self.channel_name)
|
||||||
|
await self.accept()
|
||||||
|
await self.send(text_data=await self._initial_state(self.scope.get("user")))
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
if getattr(self, "group_name", None):
|
||||||
|
await self.channel_layer.group_discard(self.group_name, self.channel_name)
|
||||||
|
|
||||||
|
async def receive(self, text_data=None, bytes_data=None):
|
||||||
|
if text_data == "press":
|
||||||
|
await self._handle_press()
|
||||||
|
|
||||||
|
async def _handle_press(self):
|
||||||
|
user = self.scope.get("user")
|
||||||
|
if user is None or not user.is_authenticated:
|
||||||
|
await self.send(text_data=encode("r", "a"))
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await self._record_press(user)
|
||||||
|
|
||||||
|
if result["rejected"]:
|
||||||
|
await self.send(text_data=encode("r", "c", result["next"], result["now"]))
|
||||||
|
return
|
||||||
|
|
||||||
|
# The cooldown outcome is only useful to the player who just pressed -
|
||||||
|
# broadcasting it to every connection would be pure waste.
|
||||||
|
await self.send(text_data=encode("k", result["next"], result["now"]))
|
||||||
|
|
||||||
|
# Everyone else only needs to know the shared timer moved.
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
self.group_name,
|
||||||
|
{
|
||||||
|
"type": "tick",
|
||||||
|
"end": result["end"],
|
||||||
|
"now": result["now"],
|
||||||
|
"pressed": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _get_enabled_event(self, event_path, user):
|
||||||
|
event = Event.objects.available_to(user).filter(path=event_path).first()
|
||||||
|
if event is None:
|
||||||
|
return None
|
||||||
|
if not Game.objects.enabled_for_event(event).filter(path="clock").exists():
|
||||||
|
return None
|
||||||
|
return event
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _record_press(self, user):
|
||||||
|
with transaction.atomic():
|
||||||
|
timer = ClockTimer.objects.select_for_update().get(event=self.event)
|
||||||
|
now = timezone.now()
|
||||||
|
timer.reset_if_expired(now)
|
||||||
|
|
||||||
|
last_press = (
|
||||||
|
Press.objects.filter(player=user, event=self.event)
|
||||||
|
.order_by("-pressed_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if last_press is not None:
|
||||||
|
next_allowed = last_press.pressed_at + timedelta(
|
||||||
|
seconds=last_press.cooldown_seconds
|
||||||
|
)
|
||||||
|
if next_allowed > now:
|
||||||
|
return {
|
||||||
|
"rejected": True,
|
||||||
|
"next": to_epoch_ms(next_allowed),
|
||||||
|
"now": to_epoch_ms(now),
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining = (timer.end_time - now).total_seconds()
|
||||||
|
cooldown = random_cooldown_seconds()
|
||||||
|
press = Press.objects.create(
|
||||||
|
event=self.event,
|
||||||
|
player=user,
|
||||||
|
remaining_before_press=remaining,
|
||||||
|
seconds_added=PRESS_BONUS_SECONDS,
|
||||||
|
cooldown_seconds=cooldown,
|
||||||
|
)
|
||||||
|
timer.end_time = timer.end_time + timedelta(seconds=PRESS_BONUS_SECONDS)
|
||||||
|
timer.save(update_fields=["end_time"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"rejected": False,
|
||||||
|
"end": to_epoch_ms(timer.end_time),
|
||||||
|
"now": to_epoch_ms(now),
|
||||||
|
"next": to_epoch_ms(press.pressed_at + timedelta(seconds=cooldown)),
|
||||||
|
}
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _initial_state(self, user):
|
||||||
|
timer = ClockTimer.current(self.event)
|
||||||
|
timer.reset_if_expired()
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
next_press_at = None
|
||||||
|
if user is not None and user.is_authenticated:
|
||||||
|
last_press = (
|
||||||
|
Press.objects.filter(player=user, event=self.event)
|
||||||
|
.order_by("-pressed_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if last_press is not None:
|
||||||
|
candidate = last_press.pressed_at + timedelta(
|
||||||
|
seconds=last_press.cooldown_seconds
|
||||||
|
)
|
||||||
|
if candidate > now:
|
||||||
|
next_press_at = to_epoch_ms(candidate)
|
||||||
|
|
||||||
|
return encode(
|
||||||
|
"s",
|
||||||
|
to_epoch_ms(timer.end_time),
|
||||||
|
to_epoch_ms(now),
|
||||||
|
timer.initial_duration,
|
||||||
|
PRESS_BONUS_SECONDS,
|
||||||
|
next_press_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def tick(self, event):
|
||||||
|
await self.send(
|
||||||
|
text_data=encode(
|
||||||
|
"t",
|
||||||
|
event["end"],
|
||||||
|
event["now"],
|
||||||
|
"1" if event.get("pressed") else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-09-16 23:11
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("events", "0002_seed_polylan_43"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ClockTimer",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("initial_duration", models.PositiveIntegerField(default=60)),
|
||||||
|
("end_time", models.DateTimeField()),
|
||||||
|
(
|
||||||
|
"event",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="clock_timer",
|
||||||
|
to="events.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Press",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("pressed_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("remaining_before_press", models.FloatField()),
|
||||||
|
("seconds_added", models.PositiveIntegerField(default=5)),
|
||||||
|
("cooldown_seconds", models.FloatField()),
|
||||||
|
(
|
||||||
|
"event",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="clock_presses",
|
||||||
|
to="events.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"player",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="clock_presses",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["-pressed_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import random
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
from events.scoping import EventScopedManager
|
||||||
|
|
||||||
|
DEFAULT_TIMER_DURATION = 60
|
||||||
|
PRESS_BONUS_SECONDS = 5
|
||||||
|
PRESS_COOLDOWN_MIN_SECONDS = PRESS_BONUS_SECONDS
|
||||||
|
PRESS_COOLDOWN_MAX_SECONDS = PRESS_BONUS_SECONDS * 2
|
||||||
|
|
||||||
|
|
||||||
|
def random_cooldown_seconds() -> float:
|
||||||
|
return random.uniform(PRESS_COOLDOWN_MIN_SECONDS, PRESS_COOLDOWN_MAX_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
class ClockTimer(models.Model):
|
||||||
|
"""The shared countdown for one event. One row per event, reset in place.
|
||||||
|
|
||||||
|
Unlike the enable/disable toggle for every other game (EventGame.enabled,
|
||||||
|
checked before a client can even reach this app), there's no separate
|
||||||
|
is_enabled flag here - the registry is the single source of truth.
|
||||||
|
"""
|
||||||
|
|
||||||
|
event = models.OneToOneField(
|
||||||
|
Event, on_delete=models.CASCADE, related_name="clock_timer"
|
||||||
|
)
|
||||||
|
initial_duration = models.PositiveIntegerField(default=DEFAULT_TIMER_DURATION)
|
||||||
|
end_time = models.DateTimeField()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"ClockTimer({self.event}, end_time={self.end_time.isoformat()})"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def current(cls, event: Event) -> "ClockTimer":
|
||||||
|
"""Fetch the event's singleton timer, creating it on first-ever run."""
|
||||||
|
obj, _ = cls.objects.get_or_create(
|
||||||
|
event=event,
|
||||||
|
defaults={
|
||||||
|
"end_time": timezone.now() + timedelta(seconds=DEFAULT_TIMER_DURATION)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def reset_if_expired(self, now=None) -> bool:
|
||||||
|
"""Resets end_time back to a fresh window if it has passed. Returns True if reset."""
|
||||||
|
now = now or timezone.now()
|
||||||
|
if self.end_time > now:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.end_time = now + timedelta(seconds=self.initial_duration)
|
||||||
|
self.save(update_fields=["end_time"])
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class Press(models.Model):
|
||||||
|
event = models.ForeignKey(
|
||||||
|
Event, on_delete=models.CASCADE, related_name="clock_presses"
|
||||||
|
)
|
||||||
|
player = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="clock_presses"
|
||||||
|
)
|
||||||
|
pressed_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
remaining_before_press = models.FloatField()
|
||||||
|
seconds_added = models.PositiveIntegerField(default=PRESS_BONUS_SECONDS)
|
||||||
|
# Randomized 1x-2x PRESS_BONUS_SECONDS at press time and stored, since it's no
|
||||||
|
# longer a fixed constant that can be recomputed later.
|
||||||
|
cooldown_seconds = models.FloatField()
|
||||||
|
|
||||||
|
objects = models.Manager()
|
||||||
|
for_event = EventScopedManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-pressed_at"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.player} pressed at {self.remaining_before_press:.1f}s left ({self.event})"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import re_path
|
||||||
|
|
||||||
|
from .consumers import ClockConsumer
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
re_path(r"^ws/clock/(?P<event_path>[\w-]+)/$", ClockConsumer.as_asgi()),
|
||||||
|
]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from ninja import Schema
|
||||||
|
|
||||||
|
|
||||||
|
class ClockStateOut(Schema):
|
||||||
|
server_time: datetime
|
||||||
|
end_time: datetime
|
||||||
|
initial_duration: int
|
||||||
|
press_bonus_seconds: int
|
||||||
|
next_press_at: datetime | None
|
||||||
|
cooldown_seconds: float | None
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerStatsOut(Schema):
|
||||||
|
points: float
|
||||||
|
rank: int
|
||||||
|
total_players: int
|
||||||
|
points_above: float | None
|
||||||
|
points_below: float | None
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from django.db.models import ExpressionWrapper, F, FloatField, Q, Sum, Value, Window
|
||||||
|
from django.db.models.functions import Coalesce, Power, Rank
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
|
||||||
|
# points = 1000 * (1 / (remaining_seconds + 0.5)) ** 2, computed per press and
|
||||||
|
# summed per player. Never stored - always derived live from Press rows.
|
||||||
|
POINTS_BASE = 1000.0
|
||||||
|
POINTS_OFFSET = 0.5
|
||||||
|
POINTS_EXPONENT = -2.0
|
||||||
|
|
||||||
|
|
||||||
|
def _press_points_expression():
|
||||||
|
base = ExpressionWrapper(
|
||||||
|
F("clock_presses__remaining_before_press") + Value(POINTS_OFFSET),
|
||||||
|
output_field=FloatField(),
|
||||||
|
)
|
||||||
|
return ExpressionWrapper(
|
||||||
|
Value(POINTS_BASE) * Power(base, Value(POINTS_EXPONENT)),
|
||||||
|
output_field=FloatField(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ranked_players(event: Event):
|
||||||
|
"""All users annotated with their live total `clock_points` (not `points`,
|
||||||
|
that one is the market balance field on CustomUser) for this event and
|
||||||
|
DB-computed RANK(). Points only ever come from presses made in `event` -
|
||||||
|
a player's history in another event never leaks into this leaderboard."""
|
||||||
|
from accounts.models import CustomUser
|
||||||
|
|
||||||
|
points = Coalesce(
|
||||||
|
Sum(_press_points_expression(), filter=Q(clock_presses__event=event)),
|
||||||
|
Value(0.0),
|
||||||
|
output_field=FloatField(),
|
||||||
|
)
|
||||||
|
return CustomUser.objects.annotate(clock_points=points).annotate(
|
||||||
|
rank=Window(expression=Rank(), order_by=F("clock_points").desc())
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from games.models import EventGame
|
||||||
|
|
||||||
|
from .tasks import start_clock_heartbeat
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=EventGame)
|
||||||
|
def on_event_game_saved(sender, instance, **kwargs):
|
||||||
|
if instance.game.path == "clock" and instance.enabled:
|
||||||
|
start_clock_heartbeat()
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Self-rescheduling heartbeat, replacing pplace's standalone `watch_timer`
|
||||||
|
management command with the project's existing self-rescheduling django-tasks
|
||||||
|
pattern (see submissions.tasks.sync_event_game_scopes_task).
|
||||||
|
|
||||||
|
Resets any event's timer once it expires - even if nobody presses or
|
||||||
|
reconnects to notice - and gives already-connected clients a periodic tick.
|
||||||
|
Note this is a safety net, not the only way the timer gets reset: every press
|
||||||
|
and every new connection (see consumers.py) also calls reset_if_expired(), so
|
||||||
|
the game works correctly even if this chain is never started or dies -
|
||||||
|
connected-but-idle clients just won't see the countdown restart until someone
|
||||||
|
acts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.db import transaction
|
||||||
|
from django.tasks import task
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from games.models import EventGame
|
||||||
|
|
||||||
|
from .broadcast import clock_group
|
||||||
|
from .models import ClockTimer
|
||||||
|
from .wire import to_epoch_ms
|
||||||
|
|
||||||
|
HEARTBEAT_INTERVAL_SECONDS = 1
|
||||||
|
# One chain ticks every event with the game enabled - this lock keeps a
|
||||||
|
# second chain from starting underneath an already-running one (e.g. two
|
||||||
|
# EventGame saves close together). Refreshed every tick, wide enough that a
|
||||||
|
# single slow tick doesn't let a second chain slip in. The lock holds the
|
||||||
|
# chain id: if it expires anyway (worker down/restarted for a few seconds) and
|
||||||
|
# a new chain starts, the old one stops at its next tick (instead of both
|
||||||
|
# ticking forever).
|
||||||
|
HEARTBEAT_LOCK_KEY = "clock:heartbeat:lock"
|
||||||
|
HEARTBEAT_LOCK_TIMEOUT_SECONDS = HEARTBEAT_INTERVAL_SECONDS * 5
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def clock_heartbeat_task(chain_id: str):
|
||||||
|
if cache.get(HEARTBEAT_LOCK_KEY) != chain_id:
|
||||||
|
# Another chain owns the lock (or it expired), stop this one
|
||||||
|
return
|
||||||
|
|
||||||
|
event_games = list(
|
||||||
|
EventGame.objects.filter(game__path="clock", enabled=True).select_related(
|
||||||
|
"event"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not event_games:
|
||||||
|
# Nothing left to tick - let the chain die. start_clock_heartbeat()
|
||||||
|
# starts a fresh one next time an event enables the game.
|
||||||
|
if cache.get(HEARTBEAT_LOCK_KEY) == chain_id:
|
||||||
|
cache.delete(HEARTBEAT_LOCK_KEY)
|
||||||
|
return
|
||||||
|
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
for event_game in event_games:
|
||||||
|
event = event_game.event
|
||||||
|
ClockTimer.current(event)
|
||||||
|
with transaction.atomic():
|
||||||
|
timer = ClockTimer.objects.select_for_update().get(event=event)
|
||||||
|
timer.reset_if_expired()
|
||||||
|
|
||||||
|
async_to_sync(channel_layer.group_send)(
|
||||||
|
clock_group(event.path),
|
||||||
|
{
|
||||||
|
"type": "tick",
|
||||||
|
"end": to_epoch_ms(timer.end_time),
|
||||||
|
"now": to_epoch_ms(timezone.now()),
|
||||||
|
"pressed": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
cache.set(HEARTBEAT_LOCK_KEY, chain_id, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS)
|
||||||
|
clock_heartbeat_task.using(
|
||||||
|
run_after=timezone.now() + timedelta(seconds=HEARTBEAT_INTERVAL_SECONDS)
|
||||||
|
).enqueue(chain_id)
|
||||||
|
|
||||||
|
|
||||||
|
def start_clock_heartbeat():
|
||||||
|
"""Enqueues the heartbeat chain, unless one is already running."""
|
||||||
|
chain_id = uuid4().hex
|
||||||
|
if cache.add(HEARTBEAT_LOCK_KEY, chain_id, timeout=HEARTBEAT_LOCK_TIMEOUT_SECONDS):
|
||||||
|
clock_heartbeat_task.enqueue(chain_id)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
from games.models import EventGame, Game
|
||||||
|
|
||||||
|
from .tasks import HEARTBEAT_LOCK_KEY, clock_heartbeat_task, start_clock_heartbeat
|
||||||
|
|
||||||
|
LOCMEM_CACHE = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CACHES=LOCMEM_CACHE)
|
||||||
|
@mock.patch("clock.tasks.get_channel_layer")
|
||||||
|
class ClockHeartbeatTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
event = Event.objects.create(name="Event", path="event")
|
||||||
|
clock = Game.objects.create(name="Clock", path="clock", custom_path=True)
|
||||||
|
self.event_game = EventGame.objects.create(event=event, game=clock)
|
||||||
|
cache.clear() # post_save signal can start a chain
|
||||||
|
|
||||||
|
@mock.patch("clock.tasks.clock_heartbeat_task")
|
||||||
|
def test_only_one_chain_is_started(self, task, _):
|
||||||
|
start_clock_heartbeat()
|
||||||
|
start_clock_heartbeat()
|
||||||
|
|
||||||
|
task.enqueue.assert_called_once_with(cache.get(HEARTBEAT_LOCK_KEY))
|
||||||
|
|
||||||
|
def test_owning_chain_reschedules_itself(self, channel_layer):
|
||||||
|
channel_layer.return_value.group_send = mock.AsyncMock()
|
||||||
|
cache.set(HEARTBEAT_LOCK_KEY, "chain")
|
||||||
|
with mock.patch("clock.tasks.clock_heartbeat_task") as patched:
|
||||||
|
clock_heartbeat_task.call("chain")
|
||||||
|
|
||||||
|
patched.using.return_value.enqueue.assert_called_once_with("chain")
|
||||||
|
channel_layer.return_value.group_send.assert_awaited_once()
|
||||||
|
self.assertEqual(cache.get(HEARTBEAT_LOCK_KEY), "chain")
|
||||||
|
|
||||||
|
def test_superseded_chain_stops(self, channel_layer):
|
||||||
|
cache.set(HEARTBEAT_LOCK_KEY, "new-chain")
|
||||||
|
with mock.patch("clock.tasks.clock_heartbeat_task") as patched:
|
||||||
|
clock_heartbeat_task.call("old-chain")
|
||||||
|
|
||||||
|
patched.using.assert_not_called()
|
||||||
|
channel_layer.assert_not_called()
|
||||||
|
self.assertEqual(cache.get(HEARTBEAT_LOCK_KEY), "new-chain")
|
||||||
|
|
||||||
|
def test_chain_dies_and_releases_lock_when_game_disabled(self, _):
|
||||||
|
EventGame.objects.filter(pk=self.event_game.pk).update(enabled=False)
|
||||||
|
cache.set(HEARTBEAT_LOCK_KEY, "chain")
|
||||||
|
with mock.patch("clock.tasks.clock_heartbeat_task") as patched:
|
||||||
|
clock_heartbeat_task.call("chain")
|
||||||
|
|
||||||
|
patched.using.assert_not_called()
|
||||||
|
self.assertIsNone(cache.get(HEARTBEAT_LOCK_KEY))
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Compact wire format for the clock websocket.
|
||||||
|
|
||||||
|
JSON's braces/quotes/keys are pure overhead for messages this small and this
|
||||||
|
frequent (a heartbeat every second, to every connection). Each message is
|
||||||
|
instead a short string: a one-letter type tag followed by '|'-separated
|
||||||
|
fields, in a fixed order the client already knows from this table (missing/
|
||||||
|
null fields are written as an empty segment):
|
||||||
|
|
||||||
|
s|<end>|<now>|<duration>|<bonus>|<next> initial state, once per connection
|
||||||
|
t|<end>|<now>|<pressed> shared timer update ("pressed" is "1" or "")
|
||||||
|
k|<next>|<now> your press was accepted
|
||||||
|
r|c|<next>|<now> your press was rejected: on cooldown
|
||||||
|
r|a your press was rejected: not logged in
|
||||||
|
|
||||||
|
Client -> server has exactly one message, sent as the bare literal "press"
|
||||||
|
(no fields, so no delimiter needed).
|
||||||
|
|
||||||
|
Whether the game is enabled at all for this event (EventGame.enabled) is
|
||||||
|
checked once, at connect time - a connection is refused outright rather than
|
||||||
|
accepted-but-disabled, so there's no "disabled" case in this wire format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DELIMITER = "|"
|
||||||
|
|
||||||
|
|
||||||
|
def encode(*parts) -> str:
|
||||||
|
return DELIMITER.join("" if p is None else str(p) for p in parts)
|
||||||
|
|
||||||
|
|
||||||
|
def to_epoch_ms(dt) -> int:
|
||||||
|
"""Epoch-ms integer: far cheaper than an ISO-8601 string and needs no
|
||||||
|
client-side parsing (the frontend already does its clock math in epoch ms)."""
|
||||||
|
return int(dt.timestamp() * 1000)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Event
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Event)
|
||||||
|
class EventAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["name", "path", "is_available", "created_at"]
|
||||||
|
list_editable = ["is_available"]
|
||||||
|
list_filter = ["is_available"]
|
||||||
|
search_fields = ["name", "path"]
|
||||||
|
prepopulated_fields = {"path": ("name",)}
|
||||||
|
readonly_fields = ["created_at", "updated_at"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class EventsConfig(AppConfig):
|
||||||
|
name = "events"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
from .models import Event
|
||||||
|
from .scoping import current_event
|
||||||
|
|
||||||
|
|
||||||
|
class EventContextMiddleware:
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
try:
|
||||||
|
return self.get_response(request)
|
||||||
|
finally:
|
||||||
|
# Not current_event.reset(token): under ASGI, process_view and this
|
||||||
|
# finally block can run in different contextvars.Context copies
|
||||||
|
# (sync_to_async(thread_sensitive=True) copies the context), and
|
||||||
|
# Token.reset() requires the exact Context it was created in.
|
||||||
|
# set(None) has no such constraint.
|
||||||
|
current_event.set(None)
|
||||||
|
|
||||||
|
def process_view(self, request, view_func, view_args, view_kwargs):
|
||||||
|
event_path = view_kwargs.get("event_path")
|
||||||
|
if event_path:
|
||||||
|
request.event = get_object_or_404(
|
||||||
|
Event.objects.available_to(getattr(request, "user", None)),
|
||||||
|
path=event_path,
|
||||||
|
)
|
||||||
|
current_event.set(request.event)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-09-15 21:08
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Event",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("name", models.CharField(max_length=255)),
|
||||||
|
("path", models.SlugField(max_length=100, unique=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
EVENT_NAME = "PolyLAN 43"
|
||||||
|
EVENT_PATH = "polylan-43"
|
||||||
|
|
||||||
|
|
||||||
|
def seed_event(apps, schema_editor):
|
||||||
|
Event = apps.get_model("events", "Event")
|
||||||
|
Event.objects.get_or_create(path=EVENT_PATH, defaults={"name": EVENT_NAME})
|
||||||
|
|
||||||
|
|
||||||
|
def unseed_event(apps, schema_editor):
|
||||||
|
Event = apps.get_model("events", "Event")
|
||||||
|
Event.objects.filter(path=EVENT_PATH).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("events", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed_event, unseed_event),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 6.0.5 on 2026-10-15 22:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("events", "0002_seed_polylan_43"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="event",
|
||||||
|
name="is_available",
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class BaseModel(models.Model):
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class EventQuerySet(models.QuerySet):
|
||||||
|
def available_to(self, user):
|
||||||
|
"""Unavailable events are hidden from everyone but staff, so they can
|
||||||
|
still be prepared before being opened up."""
|
||||||
|
if user is not None and user.is_staff:
|
||||||
|
return self
|
||||||
|
return self.filter(is_available=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Event(BaseModel):
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
path = models.SlugField(max_length=100, unique=True)
|
||||||
|
is_available = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
objects = EventQuerySet.as_manager()
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import contextvars
|
||||||
|
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
current_event = contextvars.ContextVar("current_event", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class EventScopedQuerySet(models.QuerySet):
|
||||||
|
def for_current_event(self):
|
||||||
|
event = current_event.get()
|
||||||
|
if event is None:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
f"{self.model.__name__}.for_event was used outside an event-scoped request"
|
||||||
|
)
|
||||||
|
return self.filter(event=event)
|
||||||
|
|
||||||
|
|
||||||
|
class EventScopedManager(models.Manager.from_queryset(EventScopedQuerySet)):
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().for_current_event()
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from games.models import Game
|
||||||
|
from submissions.models import Submission
|
||||||
|
|
||||||
|
from .models import Event
|
||||||
|
from .scoping import current_event
|
||||||
|
|
||||||
|
|
||||||
|
class EventScopedManagerTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.event_a = Event.objects.create(name="Event A", path="event-a")
|
||||||
|
self.event_b = Event.objects.create(name="Event B", path="event-b")
|
||||||
|
self.game = Game.objects.create(name="Test Game", path="test-game")
|
||||||
|
self.submission_a = Submission.objects.create(
|
||||||
|
event=self.event_a, game=self.game
|
||||||
|
)
|
||||||
|
self.submission_b = Submission.objects.create(
|
||||||
|
event=self.event_b, game=self.game
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_for_event_raises_outside_event_context(self):
|
||||||
|
with self.assertRaises(ImproperlyConfigured):
|
||||||
|
list(Submission.for_event.all())
|
||||||
|
|
||||||
|
def test_for_event_filters_to_current_event(self):
|
||||||
|
token = current_event.set(self.event_a)
|
||||||
|
try:
|
||||||
|
results = list(Submission.for_event.all())
|
||||||
|
finally:
|
||||||
|
current_event.reset(token)
|
||||||
|
|
||||||
|
self.assertEqual(results, [self.submission_a])
|
||||||
|
|
||||||
|
|
||||||
|
class EventAvailabilityTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from accounts.models import CustomUser
|
||||||
|
|
||||||
|
self.hidden = Event.objects.create(
|
||||||
|
name="Hidden", path="hidden", is_available=False
|
||||||
|
)
|
||||||
|
self.user = CustomUser.objects.create_user(username="user", password="x")
|
||||||
|
self.staff = CustomUser.objects.create_user(
|
||||||
|
username="staff", password="x", is_staff=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unavailable_event_is_404_for_regular_user(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(f"/{self.hidden.path}/")
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_unavailable_event_is_visible_to_staff(self):
|
||||||
|
self.client.force_login(self.staff)
|
||||||
|
response = self.client.get(f"/{self.hidden.path}/")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_available_to_filters_for_regular_user_only(self):
|
||||||
|
self.assertNotIn(self.hidden, Event.objects.available_to(self.user))
|
||||||
|
self.assertIn(self.hidden, Event.objects.available_to(self.staff))
|
||||||
|
|
||||||
|
def test_home_prefers_available_event_for_staff(self):
|
||||||
|
available = Event.objects.create(name="Open", path="open")
|
||||||
|
Event.objects.filter(pk=self.hidden.pk).update(
|
||||||
|
created_at=available.created_at + timedelta(days=1)
|
||||||
|
)
|
||||||
|
self.client.force_login(self.staff)
|
||||||
|
response = self.client.get("/")
|
||||||
|
self.assertRedirects(
|
||||||
|
response, f"/{available.path}/", fetch_redirect_response=False
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Create your views here.
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from django.contrib import admin, messages
|
||||||
|
from django_object_actions import (
|
||||||
|
DjangoObjectActions,
|
||||||
|
action,
|
||||||
|
takes_instance_or_queryset,
|
||||||
|
)
|
||||||
|
|
||||||
|
from submissions.tasks import schedule_scope_sync, stop_scope_sync
|
||||||
|
|
||||||
|
from .models import EventGame, Game, PlanningSlot
|
||||||
|
from .tasks import refresh_steam_games_task
|
||||||
|
|
||||||
|
|
||||||
|
class EventGameInline(admin.TabularInline):
|
||||||
|
model = EventGame
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Game)
|
||||||
|
class GameAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["name", "steam_app_id", "updated_at"]
|
||||||
|
search_fields = ["name", "steam_app_id"]
|
||||||
|
readonly_fields = [
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"steam_price_initial",
|
||||||
|
"steam_price_final",
|
||||||
|
"steam_discount_percent",
|
||||||
|
"steam_currency",
|
||||||
|
"steam_is_free",
|
||||||
|
"steam_short_description",
|
||||||
|
"steam_header_image",
|
||||||
|
"steam_info_updated_at",
|
||||||
|
]
|
||||||
|
inlines = [EventGameInline]
|
||||||
|
actions = ["refresh_steam_info"]
|
||||||
|
|
||||||
|
@admin.action(description="Refresh Steam store info")
|
||||||
|
def refresh_steam_info(self, request, queryset):
|
||||||
|
game_ids = list(
|
||||||
|
queryset.filter(steam_app_id__isnull=False).values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
refresh_steam_games_task.enqueue(game_ids=game_ids)
|
||||||
|
self.message_user(
|
||||||
|
request, f"Queued Steam store refresh for {len(game_ids)} game(s)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(EventGame)
|
||||||
|
class EventGameAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||||
|
list_display = ["game", "event", "enabled", "has_apibot_token"]
|
||||||
|
list_filter = ["event", "enabled"]
|
||||||
|
search_fields = ["game__name", "event__name"]
|
||||||
|
actions = ["enable", "disable", "sync_scopes", "stop_scope_sync"]
|
||||||
|
change_actions = ["sync_scopes", "stop_scope_sync"]
|
||||||
|
|
||||||
|
@admin.display(description="AAndT token", boolean=True)
|
||||||
|
def has_apibot_token(self, obj):
|
||||||
|
return bool(obj.apibot_token)
|
||||||
|
|
||||||
|
def _set_enabled(self, request, queryset, enabled: bool):
|
||||||
|
# save() per row (not update()) so post_save fires, e.g. the clock heartbeat
|
||||||
|
changed = 0
|
||||||
|
for event_game in queryset.select_related("game").filter(enabled=not enabled):
|
||||||
|
event_game.enabled = enabled
|
||||||
|
event_game.save(update_fields=["enabled"])
|
||||||
|
changed += 1
|
||||||
|
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"{'Enabled' if enabled else 'Disabled'} {changed} EventGame(s)"
|
||||||
|
f" ({queryset.count() - changed} already {'enabled' if enabled else 'disabled'}).",
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.action(description="Enable selected EventGames")
|
||||||
|
def enable(self, request, queryset):
|
||||||
|
self._set_enabled(request, queryset, enabled=True)
|
||||||
|
|
||||||
|
@admin.action(description="Disable selected EventGames")
|
||||||
|
def disable(self, request, queryset):
|
||||||
|
self._set_enabled(request, queryset, enabled=False)
|
||||||
|
|
||||||
|
@action(
|
||||||
|
label="Sync scopes",
|
||||||
|
description="Sync scopes from AAndT for selected EventGames",
|
||||||
|
)
|
||||||
|
@takes_instance_or_queryset
|
||||||
|
def sync_scopes(self, request, queryset):
|
||||||
|
tokened = queryset.exclude(apibot_token__isnull=True).exclude(apibot_token="")
|
||||||
|
skipped = queryset.count() - tokened.count()
|
||||||
|
|
||||||
|
started = sum(schedule_scope_sync(event_game.id) for event_game in tokened)
|
||||||
|
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"Started scope sync for {started} EventGame(s)"
|
||||||
|
f" ({tokened.count() - started} already running).",
|
||||||
|
)
|
||||||
|
if skipped:
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"Skipped {skipped} EventGame(s) with no AAndT token configured.",
|
||||||
|
level=messages.WARNING,
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(
|
||||||
|
label="Stop scope sync",
|
||||||
|
description="Stop the scope sync loop of selected EventGames",
|
||||||
|
)
|
||||||
|
@takes_instance_or_queryset
|
||||||
|
def stop_scope_sync(self, request, queryset):
|
||||||
|
stopped = sum(stop_scope_sync(event_game.id) for event_game in queryset)
|
||||||
|
|
||||||
|
self.message_user(request, f"Stopped scope sync for {stopped} EventGame(s).")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PlanningSlot)
|
||||||
|
class PlanningSlotAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["title", "event", "begin", "end", "column", "is_multi_anims"]
|
||||||
|
list_filter = ["event", "is_multi_anims"]
|
||||||
|
search_fields = ["title"]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from ninja import Router
|
||||||
|
|
||||||
|
from .models import Game
|
||||||
|
from .schemas import GameOut, PlanningSlotOut, SteamGameOut
|
||||||
|
from .services.planning import planning_with_games
|
||||||
|
from .tasks import schedule_steam_refresh
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
event_router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response=List[GameOut])
|
||||||
|
def list_games(request):
|
||||||
|
return Game.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
@event_router.get("/events/{event_path}/games", response=List[GameOut])
|
||||||
|
def list_event_games(request, event_path: str):
|
||||||
|
return Game.objects.enabled_for_event(request.event)
|
||||||
|
|
||||||
|
|
||||||
|
@event_router.get("/events/{event_path}/games/steam", response=List[SteamGameOut])
|
||||||
|
def list_event_steam_games(request, event_path: str):
|
||||||
|
"""The event's Steam games with their store info (price, discount, blurb)."""
|
||||||
|
# Safety net in case the periodic refresh loop isn't running (no-op otherwise).
|
||||||
|
schedule_steam_refresh()
|
||||||
|
return Game.objects.enabled_for_event(request.event).filter(
|
||||||
|
steam_app_id__isnull=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@event_router.get("/events/{event_path}/planning", response=List[PlanningSlotOut])
|
||||||
|
def list_event_planning(request, event_path: str):
|
||||||
|
"""The event planning, with the covers of the games we know."""
|
||||||
|
return planning_with_games(request.event.planning_slots.all())
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class AccountsConfig(AppConfig):
|
class GamesConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "accounts"
|
name = "games"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
from events.models import Event
|
||||||
|
from .models import Game
|
||||||
|
|
||||||
|
|
||||||
|
def require_game_enabled(steam_app_id: int | None = None, *, path: str | None = None):
|
||||||
|
"""Gate a view (or, via @decorate_view, an API operation) behind an
|
||||||
|
EventGame being enabled for the current event. Steam games are looked up
|
||||||
|
by steam_app_id (the original, positional form); custom non-Steam games
|
||||||
|
(no steam_app_id) are looked up by their Game.path instead, e.g.
|
||||||
|
require_game_enabled(path="clock")."""
|
||||||
|
|
||||||
|
def decorator(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapper(request, *args, **kwargs):
|
||||||
|
event = get_object_or_404(Event, path=kwargs.get("event_path"))
|
||||||
|
games = Game.objects.enabled_for_event(event)
|
||||||
|
games = (
|
||||||
|
games.filter(steam_app_id=steam_app_id)
|
||||||
|
if steam_app_id is not None
|
||||||
|
else games.filter(path=path)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not games.exists():
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
return decorator
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user