Compare commits
159
Commits
007bb4a0eb
...
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
|
||
|
|
9ee45463a8
|
||
|
|
8f88548a59
|
||
|
|
012b72527b
|
||
|
|
596731a8a7 | ||
|
|
e0ada1e26d | ||
|
|
f98145d6db | ||
|
|
0e1e77c2dd | ||
|
|
15de496501 | ||
|
|
8960f551e6 | ||
|
|
2260c7cc27 | ||
|
|
b5f31a8c72 | ||
|
|
6a882ce39a | ||
|
|
6dcd0174f9 | ||
|
|
fdbd660d22 | ||
|
|
533d856c5b | ||
|
|
72d5fbb803 |
@@ -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
|
||||
@@ -177,3 +177,5 @@ pyrightconfig.json
|
||||
|
||||
tags
|
||||
media/
|
||||
settingsLocal.py
|
||||
deploy/docker/polylan-submitter.env
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
# Single OCI image bundling the Vue/Vite frontend, the Django/Channels web
|
||||
# process (daphne) and the RQ worker, managed together by supervisord - see
|
||||
# deploy/docker/README.md for why (single-container Proxmox deploy) and how
|
||||
# to run it. Postgres, Redis and S3 stay external (see
|
||||
# deploy/docker/polylan-submitter.env.dist for the full env var list) - this
|
||||
# image carries no infra, just the app.
|
||||
#
|
||||
# Build (from repo root, requires BuildKit/buildx - RUN --mount=type=cache
|
||||
# below is rejected by the legacy builder):
|
||||
# docker buildx build --load -t polylan-submitter .
|
||||
|
||||
# ---- frontend build -------------------------------------------------------
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
WORKDIR /app
|
||||
COPY polylan_submitter/package.json polylan_submitter/pnpm-lock.yaml ./
|
||||
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate \
|
||||
&& pnpm install --frozen-lockfile
|
||||
COPY polylan_submitter/vite.config.ts polylan_submitter/tsconfig*.json ./
|
||||
COPY polylan_submitter/src ./src
|
||||
COPY polylan_submitter/public ./public
|
||||
RUN pnpm build
|
||||
|
||||
# ---- python dependencies ---------------------------------------------------
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS python-builder
|
||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock ./
|
||||
# --no-install-project: this pyproject has no [build-system]/package to
|
||||
# install, it only pins the app's dependencies - so just build the venv.
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev --no-install-project
|
||||
|
||||
# ---- runtime ----------------------------------------------------------------
|
||||
FROM python:3.13-slim AS runtime
|
||||
|
||||
# tesseract-ocr: shelled out to by pytesseract (submissions/tasks.py,
|
||||
# opus_magnum/utils.py) for OCR'ing Opus Magnum submission GIFs.
|
||||
# libgl1/libglib2.0-0: runtime shared libs opencv-python needs even though
|
||||
# it ships as a wheel.
|
||||
# libpq5: psycopg (plain, not psycopg[binary]/[c]) loads libpq via ctypes.
|
||||
# supervisor/tini: process supervision - see deploy/docker/supervisord.conf.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tesseract-ocr \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
libpq5 \
|
||||
supervisor \
|
||||
tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd --system app && useradd --system --gid app --create-home app
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=python-builder --chown=app:app /app/.venv /app/.venv
|
||||
ENV PATH="/app/.venv/bin:$PATH" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
DJANGO_SETTINGS_MODULE=polylan_submitter.settings
|
||||
|
||||
COPY --chown=app:app polylan_submitter/ /app/polylan_submitter/
|
||||
COPY --from=frontend-builder --chown=app:app /app/static_source/vite /app/polylan_submitter/static_source/vite
|
||||
COPY --chown=app:app deploy/docker/entrypoint.sh /app/entrypoint.sh
|
||||
COPY --chown=app:app deploy/docker/supervisord.conf /app/supervisord.conf
|
||||
|
||||
RUN rm -rf /app/polylan_submitter/node_modules /app/polylan_submitter/settingsLocal.py \
|
||||
&& mkdir -p /app/polylan_submitter/static /app/polylan_submitter/media \
|
||||
&& chown app:app /app/polylan_submitter/static /app/polylan_submitter/media \
|
||||
&& chmod +x /app/entrypoint.sh
|
||||
|
||||
USER app
|
||||
WORKDIR /app/polylan_submitter
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["tini", "--", "/app/entrypoint.sh"]
|
||||
CMD ["supervisord", "-c", "/app/supervisord.conf", "-n"]
|
||||
@@ -0,0 +1,73 @@
|
||||
# Container image
|
||||
|
||||
`Dockerfile` (repo root) builds a single OCI image with the Vite frontend
|
||||
already built in, and a `.venv` with all Python deps. At runtime it uses
|
||||
`supervisord` (`deploy/docker/supervisord.conf`) to run two processes in the
|
||||
one container:
|
||||
|
||||
- `web` - one `daphne` process serving the Django/Channels app on `:8000`
|
||||
- `worker` - two `rqworker` processes for background jobs (submission OCR,
|
||||
AAndT sync, etc.)
|
||||
|
||||
Postgres, Redis and S3-compatible storage are **not** bundled (see
|
||||
`deploy/docker/polylan-submitter.env.dist` for the full list of env vars:
|
||||
`POSTGRES_*`, `REDIS_HOST`/`REDIS_PORT`, `AWS_*`, `DJANGO_SECRET_KEY`, etc.).
|
||||
Point those at wherever those services already live (other containers/LXCs,
|
||||
or existing infra).
|
||||
|
||||
## Build
|
||||
|
||||
Requires BuildKit/buildx (`RUN --mount=type=cache` in the Dockerfile is
|
||||
rejected by the legacy builder):
|
||||
|
||||
```sh
|
||||
docker buildx build --load -t polylan-submitter .
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
docker run -d \
|
||||
--name polylan-submitter \
|
||||
--env-file deploy/docker/polylan-submitter.env \
|
||||
-p 8000:8000 \
|
||||
polylan-submitter
|
||||
```
|
||||
|
||||
Migrations run automatically on container start (set `MIGRATE_ON_START=false`
|
||||
to skip). `collectstatic` does not run automatically - it pushes to the
|
||||
shared S3 bucket, so run it once per deploy instead of racing multiple
|
||||
replicas on every boot:
|
||||
|
||||
```sh
|
||||
docker run --rm --env-file deploy/docker/polylan-submitter.env \
|
||||
polylan-submitter python manage.py collectstatic --noinput
|
||||
```
|
||||
|
||||
## Scaling
|
||||
|
||||
There's one `daphne` process per container - it binds the only port the
|
||||
container exposes, so running more of them would need a reverse proxy
|
||||
inside the container too. Scale by running multiple container replicas
|
||||
behind an external reverse proxy/load balancer instead (each replica also
|
||||
runs its own pair of `rqworker` processes, so worker capacity scales the
|
||||
same way).
|
||||
|
||||
## Deploying to Proxmox
|
||||
|
||||
Recent Proxmox VE versions can create an LXC container directly from an OCI
|
||||
image (`pct create <vmid> <image-ref> ...` with an OCI-format image
|
||||
reference, currently a tech-preview feature - check `pct create --help` and
|
||||
the Proxmox VE docs for your installed version, since the exact flags have
|
||||
been changing across releases). Practically that means:
|
||||
|
||||
1. Push the built image to a registry Proxmox can reach (a local registry,
|
||||
GHCR, Docker Hub, etc.) - `docker tag`/`docker push`.
|
||||
2. Create the container on the Proxmox host from that image reference,
|
||||
passing the same env vars as above.
|
||||
3. Map/forward port 8000 as needed, and put whatever TLS-terminating
|
||||
reverse proxy you already run in front of it.
|
||||
|
||||
If OCI-to-LXC isn't available on your Proxmox version, the same image runs
|
||||
fine in a plain VM (or an LXC) with any container runtime (Docker/Podman)
|
||||
installed instead.
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Container entrypoint: runs migrations (unless disabled), then execs
|
||||
# whatever CMD supplied (supervisord by default - see the Dockerfile).
|
||||
#
|
||||
# collectstatic is intentionally NOT run here: it pushes to the shared S3
|
||||
# bucket (see STORAGES in settings.py), so running it on every container
|
||||
# start would race across replicas for no benefit - run it once per deploy
|
||||
# instead:
|
||||
# docker run --rm --env-file <env file> polylan-submitter \
|
||||
# python manage.py collectstatic --noinput
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${MIGRATE_ON_START:-true}" = "true" ]; then
|
||||
echo "Running migrations..."
|
||||
python manage.py migrate --noinput
|
||||
fi
|
||||
|
||||
# Kick off the periodic Steam store refresh (games.tasks). No-op when a refresh
|
||||
# is already queued, so every replica can safely run it on start.
|
||||
python manage.py schedule_steam_refresh || echo "Could not schedule Steam store refresh"
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,44 @@
|
||||
# Template for the --env-file passed to `docker run` (see
|
||||
# deploy/docker/README.md). Copy this to deploy/docker/polylan-submitter.env,
|
||||
# fill in real values, and keep it out of git (already in .gitignore) and
|
||||
# world-readable perms (chmod 600) since it holds secrets.
|
||||
#
|
||||
# Docker reads this file itself (KEY=VALUE per line) before the container's
|
||||
# Python even starts - no python-dotenv, no $VAR/${VAR} expansion, no shell
|
||||
# involved. settings.py just reads the values from os.environ.
|
||||
#
|
||||
# Rotating a secret means editing this file and restarting the container
|
||||
# (`docker restart polylan-submitter` or replacing it, depending on how
|
||||
# you're running it) - values are read fresh on every process start.
|
||||
#
|
||||
# See settings.py for what each of these does and its dev-mode default.
|
||||
|
||||
DJANGO_SECRET_KEY=
|
||||
DJANGO_DEBUG=false
|
||||
DJANGO_ALLOWED_HOSTS=submitter.polylan.ch
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS=https://polylan-submitter.legrems.xyz
|
||||
DJANGO_BEHIND_TLS_PROXY=true
|
||||
|
||||
POSTGRES_DB=
|
||||
POSTGRES_USER=
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_HOST=
|
||||
POSTGRES_PORT=
|
||||
|
||||
REDIS_HOST=
|
||||
REDIS_PORT=
|
||||
PLACE_REDIS_URL=
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_S3_ENDPOINT_URL=
|
||||
AWS_STORAGE_BUCKET_NAME=
|
||||
|
||||
SENTRY_ENVIRONMENT=production
|
||||
SENTRY_DSN=
|
||||
|
||||
STEAM_API_KEY=
|
||||
STEAM_STORE_REFRESH_MINUTES=360
|
||||
STEAM_STORE_COUNTRY=ch
|
||||
AANDT_API_BASE_URL=
|
||||
SUBMITTER_BASE_URL=
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env sh
|
||||
# One-off build+push to the Gitea container registry for testing.
|
||||
# Usage: deploy/docker/push.sh [tag] (run from repo root, default tag: test)
|
||||
set -eu
|
||||
|
||||
REGISTRY=git.legrems.mom
|
||||
OWNER=polylan
|
||||
IMAGE=polylan-submitter
|
||||
TAG="${1:-test}"
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
REF="$REGISTRY/$OWNER/$IMAGE:$TAG"
|
||||
# buildx (BuildKit) is required: the Dockerfile uses RUN --mount=type=cache
|
||||
# for the uv dep cache, which the legacy builder rejects.
|
||||
docker buildx build --load -t "$REF" .
|
||||
docker push "$REF"
|
||||
|
||||
echo "Pushed $REF"
|
||||
@@ -0,0 +1,43 @@
|
||||
; Runs inside the single app container as the non-root "app" user (see
|
||||
; Dockerfile's USER app - no [supervisord] user= needed, we never start as
|
||||
; root here). This container runs ONE daphne process on TCP: scale by
|
||||
; running more container replicas behind an external reverse proxy/load
|
||||
; balancer instead of more processes per container.
|
||||
;
|
||||
; Program logs go to stdout/stderr (logfile_maxbytes=0 disables supervisor's
|
||||
; own log rotation for them) so `docker logs` / Proxmox's log viewer picks
|
||||
; them up - no log files inside the container to manage.
|
||||
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
pidfile=/tmp/supervisord.pid
|
||||
logfile=/dev/null
|
||||
logfile_maxbytes=0
|
||||
|
||||
[program:web]
|
||||
command=/app/.venv/bin/daphne -b 0.0.0.0 -p 8000 polylan_submitter.asgi:application
|
||||
directory=/app/polylan_submitter
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
stopsignal=TERM
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[program:worker]
|
||||
command=/app/.venv/bin/python manage.py rqworker default --job-class django_tasks_rq.Job --with-scheduler
|
||||
directory=/app/polylan_submitter
|
||||
process_name=%(program_name)s-%(process_num)02d
|
||||
numprocs=2
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
stopsignal=TERM
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
# Zero-downtime prod deploy: pull, sync deps, build front, migrate, then
|
||||
# gracefully reload gunicorn (see polylan-submitter.service, installed and
|
||||
# restarted instead when it changed) and restart the RQ worker (see
|
||||
# polylan-submitter-worker.conf).
|
||||
#
|
||||
# Run from anywhere as the user owning the checkout, needs sudo for
|
||||
# systemctl/supervisorctl (no-op when already root):
|
||||
# ./deploy/systemd/deploy.sh
|
||||
#
|
||||
# Migrations run while the old code still serves requests, so keep them
|
||||
# backward compatible (nullable/default columns, drop/rename in a later deploy).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE="${SERVICE:-polylan-submitter}"
|
||||
WORKER="${WORKER:-polylan-submitter-worker}"
|
||||
SUPERVISOR_CONF_DIR="${SUPERVISOR_CONF_DIR:-/etc/supervisor/conf.d}"
|
||||
WEB_URL="${WEB_URL:-https://polylan-submitter.legrems.xyz/}"
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
DEPLOY_DIR="$REPO_DIR/deploy/systemd"
|
||||
APP_DIR="$REPO_DIR/polylan_submitter"
|
||||
PYTHON="$REPO_DIR/.venv/bin/python"
|
||||
# Same as the unit's Environment=, so migrate/collectstatic see the prod settings.
|
||||
# Relative to $APP_DIR, where manage.py runs.
|
||||
export SETTINGS_ENV_FILE_LOCATION="${SETTINGS_ENV_FILE_LOCATION:-polylan_submitter/.env}"
|
||||
|
||||
# gunicorn's master doesn't reload itself on HUP, so a server upgrade needs a
|
||||
# full restart instead.
|
||||
server_versions() {
|
||||
"$PYTHON" - <<'EOF' 2>/dev/null || true
|
||||
import importlib.metadata as m
|
||||
for pkg in ("gunicorn", "uvicorn", "uvicorn-worker"):
|
||||
try:
|
||||
print(pkg, m.version(pkg))
|
||||
except m.PackageNotFoundError:
|
||||
print(pkg, "missing")
|
||||
EOF
|
||||
}
|
||||
|
||||
# wait_for <description> <command...>: retry for ~30s, fail the deploy if it
|
||||
# never succeeds.
|
||||
wait_for() {
|
||||
local what="$1"
|
||||
shift
|
||||
for _ in $(seq 1 30); do
|
||||
if "$@" >/dev/null 2>&1; then
|
||||
echo " $what: ok"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo " $what: FAILED" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
web_responds() {
|
||||
# 000 = nothing listening, 5xx = Caddy up but gunicorn down (502) or broken.
|
||||
local code
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' "$WEB_URL")"
|
||||
[ "$code" != "000" ] && [ "$code" -lt 500 ]
|
||||
}
|
||||
|
||||
worker_running() {
|
||||
sudo supervisorctl status "$WORKER" | grep -q RUNNING
|
||||
}
|
||||
|
||||
cd "$REPO_DIR"
|
||||
|
||||
echo "==> git pull"
|
||||
git pull --ff-only
|
||||
|
||||
before="$(server_versions)"
|
||||
|
||||
echo "==> uv sync"
|
||||
uv sync --frozen --no-dev
|
||||
|
||||
after="$(server_versions)"
|
||||
|
||||
echo "==> frontend build"
|
||||
cd "$APP_DIR"
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
echo "==> migrate"
|
||||
"$PYTHON" manage.py migrate --noinput
|
||||
|
||||
echo "==> collectstatic"
|
||||
"$PYTHON" manage.py collectstatic --noinput
|
||||
|
||||
echo "==> $SERVICE (systemd)"
|
||||
unit_changed=false
|
||||
if ! cmp -s "$DEPLOY_DIR/$SERVICE.service" "/etc/systemd/system/$SERVICE.service"; then
|
||||
sudo install -m 644 "$DEPLOY_DIR/$SERVICE.service" "/etc/systemd/system/$SERVICE.service"
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable "$SERVICE"
|
||||
unit_changed=true
|
||||
fi
|
||||
|
||||
# HUP re-forks workers with the master's original args, so a new ExecStart
|
||||
# (unit change) or a new gunicorn/uvicorn needs a full restart.
|
||||
if [ "$unit_changed" = true ]; then
|
||||
echo "==> unit file changed, full restart of $SERVICE"
|
||||
sudo systemctl restart "$SERVICE"
|
||||
elif [ "$before" != "$after" ]; then
|
||||
echo "==> server packages changed, full restart of $SERVICE"
|
||||
sudo systemctl restart "$SERVICE"
|
||||
else
|
||||
echo "==> graceful reload of $SERVICE"
|
||||
sudo systemctl reload "$SERVICE"
|
||||
fi
|
||||
wait_for "$SERVICE active" systemctl is-active --quiet "$SERVICE"
|
||||
wait_for "$SERVICE responds on $WEB_URL" web_responds
|
||||
|
||||
echo "==> $WORKER (supervisor)"
|
||||
sudo install -m 644 "$DEPLOY_DIR/$WORKER.conf" "$SUPERVISOR_CONF_DIR/$WORKER.conf"
|
||||
# reread+update picks up config changes (and starts it on first install),
|
||||
# restart loads the new code - TERM is a warm shutdown, the running job ends first.
|
||||
sudo supervisorctl reread
|
||||
sudo supervisorctl update "$WORKER"
|
||||
sudo supervisorctl restart "$WORKER"
|
||||
wait_for "$WORKER running" worker_running
|
||||
|
||||
echo "==> done"
|
||||
@@ -0,0 +1,24 @@
|
||||
; Prod RQ worker (submission OCR, AAndT sync, Steam refresh...), run by the
|
||||
; host's supervisord next to the polylan-submitter systemd unit (web).
|
||||
;
|
||||
; Installed to /etc/supervisor/conf.d/ by deploy/systemd/deploy.sh, which also
|
||||
; restarts it on every deploy so it picks up the new code.
|
||||
;
|
||||
; Logs: supervisorctl tail -f polylan-submitter-worker
|
||||
|
||||
[program:polylan-submitter-worker]
|
||||
command=/home/caddy/polylan-submitter/.venv/bin/python manage.py rqworker default --job-class django_tasks_rq.Job --with-scheduler
|
||||
directory=/home/caddy/polylan-submitter/polylan_submitter
|
||||
; Relative to directory, same as the systemd unit.
|
||||
environment=SETTINGS_ENV_FILE_LOCATION="polylan_submitter/.env",PYTHONUNBUFFERED="1"
|
||||
user=root
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=5
|
||||
; rqworker does a warm shutdown on TERM: it finishes the current job (OCR can
|
||||
; take a while) before exiting, only SIGKILLed after stopwaitsecs.
|
||||
stopsignal=TERM
|
||||
stopwaitsecs=120
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
redirect_stderr=true
|
||||
@@ -0,0 +1,39 @@
|
||||
# Prod web process: gunicorn master + uvicorn ASGI workers, behind Caddy.
|
||||
#
|
||||
# `systemctl reload polylan-submitter` sends HUP to the gunicorn master: it
|
||||
# keeps the :7777 socket open, boots new workers on the new code, then
|
||||
# gracefully stops the old ones - no downtime. See deploy/systemd/deploy.sh.
|
||||
#
|
||||
# Installed to /etc/systemd/system/ by deploy.sh: when this file changed it
|
||||
# does daemon-reload + a full restart (HUP keeps the old ExecStart args).
|
||||
|
||||
[Unit]
|
||||
Description=PolyLAN submitter (gunicorn + uvicorn workers)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=always
|
||||
RestartSec=1
|
||||
User=root
|
||||
WorkingDirectory=/home/caddy/polylan-submitter/polylan_submitter
|
||||
# Relative to WorkingDirectory, loaded by settings.py via python-dotenv.
|
||||
Environment="SETTINGS_ENV_FILE_LOCATION=polylan_submitter/.env"
|
||||
|
||||
# No --preload: with it, HUP re-forks from the already imported (old) code.
|
||||
# --forwarded-allow-ips replaces daphne's --proxy-headers: trust
|
||||
# X-Forwarded-* only from Caddy (local here).
|
||||
ExecStart=/home/caddy/polylan-submitter/.venv/bin/gunicorn polylan_submitter.asgi:application \
|
||||
--worker-class uvicorn_worker.UvicornWorker \
|
||||
--workers 2 \
|
||||
--bind 0.0.0.0:7777 \
|
||||
--graceful-timeout 20 \
|
||||
--forwarded-allow-ips 127.0.0.1
|
||||
ExecReload=/bin/kill -s HUP $MAINPID
|
||||
|
||||
# SIGTERM to the master only, it then drains its workers (graceful-timeout).
|
||||
KillMode=mixed
|
||||
TimeoutStopSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,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,231 +0,0 @@
|
||||
# Opus Magnum Submission API Usage
|
||||
|
||||
## Overview
|
||||
|
||||
The API is built with Django Ninja and provides endpoints for managing puzzle submissions with OCR validation and S3 file storage.
|
||||
|
||||
## Base URL
|
||||
- Development: `http://localhost:8000/api/`
|
||||
- API Documentation: `http://localhost:8000/api/docs/`
|
||||
|
||||
## Authentication
|
||||
Most endpoints support both authenticated and anonymous submissions. Admin endpoints require staff permissions.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Required for S3 Storage
|
||||
```bash
|
||||
USE_S3=true
|
||||
AWS_ACCESS_KEY_ID=your_access_key
|
||||
AWS_SECRET_ACCESS_KEY=your_secret_key
|
||||
AWS_STORAGE_BUCKET_NAME=your_bucket_name
|
||||
AWS_S3_REGION_NAME=us-east-1
|
||||
```
|
||||
|
||||
### Optional
|
||||
```bash
|
||||
STEAM_API_KEY=your_steam_api_key
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### 1. Get Available Puzzles
|
||||
```http
|
||||
GET /api/submissions/puzzles
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"steam_item_id": "3479143948",
|
||||
"title": "P41-FLOC",
|
||||
"author_name": "Flame Legrems",
|
||||
"description": "A challenging puzzle...",
|
||||
"tags": ["puzzle", "chemistry"],
|
||||
"order_index": 0,
|
||||
"steam_url": "https://steamcommunity.com/workshop/filedetails/?id=3479143948",
|
||||
"created_at": "2025-05-29T11:19:24Z",
|
||||
"updated_at": "2025-05-30T22:15:09Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 2. Create Submission
|
||||
```http
|
||||
POST /api/submissions/submissions
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
Form Data:
|
||||
- `data`: JSON with submission data
|
||||
- `files`: Array of uploaded files
|
||||
|
||||
Example data:
|
||||
```json
|
||||
{
|
||||
"notes": "My best solutions so far",
|
||||
"responses": [
|
||||
{
|
||||
"puzzle_id": 1,
|
||||
"puzzle_name": "P41-FLOC",
|
||||
"cost": "150",
|
||||
"cycles": "89",
|
||||
"area": "12",
|
||||
"needs_manual_validation": false,
|
||||
"ocr_confidence_score": 0.95
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"user": null,
|
||||
"notes": "My best solutions so far",
|
||||
"responses": [
|
||||
{
|
||||
"id": 1,
|
||||
"puzzle": 1,
|
||||
"puzzle_name": "P41-FLOC",
|
||||
"cost": "150",
|
||||
"cycles": "89",
|
||||
"area": "12",
|
||||
"needs_manual_validation": false,
|
||||
"files": [
|
||||
{
|
||||
"id": 1,
|
||||
"original_filename": "solution.gif",
|
||||
"file_size": 1024000,
|
||||
"content_type": "image/gif",
|
||||
"file_url": "https://bucket.s3.amazonaws.com/media/submissions/123.../file.gif",
|
||||
"ocr_processed": false,
|
||||
"created_at": "2025-10-29T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"final_cost": "150",
|
||||
"final_cycles": "89",
|
||||
"final_area": "12"
|
||||
}
|
||||
],
|
||||
"total_responses": 1,
|
||||
"needs_validation": false,
|
||||
"is_validated": false,
|
||||
"created_at": "2025-10-29T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. List Submissions
|
||||
```http
|
||||
GET /api/submissions/submissions?limit=20&offset=0
|
||||
```
|
||||
|
||||
### 4. Get Submission Details
|
||||
```http
|
||||
GET /api/submissions/submissions/{submission_id}
|
||||
```
|
||||
|
||||
### 5. Admin: Validate Response (Staff Only)
|
||||
```http
|
||||
PUT /api/submissions/responses/{response_id}/validate
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Body:
|
||||
```json
|
||||
{
|
||||
"validated_cost": "150",
|
||||
"validated_cycles": "89",
|
||||
"validated_area": "12"
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Admin: List Responses Needing Validation (Staff Only)
|
||||
```http
|
||||
GET /api/submissions/responses/needs-validation
|
||||
```
|
||||
|
||||
### 7. Admin: Validate Entire Submission (Staff Only)
|
||||
```http
|
||||
POST /api/submissions/submissions/{submission_id}/validate
|
||||
```
|
||||
|
||||
### 8. Get Statistics
|
||||
```http
|
||||
GET /api/submissions/stats
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"total_submissions": 150,
|
||||
"total_responses": 300,
|
||||
"needs_validation": 25,
|
||||
"validated_submissions": 120,
|
||||
"validation_rate": 0.8
|
||||
}
|
||||
```
|
||||
|
||||
## OCR Validation Logic
|
||||
|
||||
The system automatically flags responses for manual validation when:
|
||||
|
||||
1. **Incomplete OCR Data**: Missing cost, cycles, or area values
|
||||
2. **Low Confidence**: OCR confidence score below threshold
|
||||
3. **Manual Flag**: Explicitly marked by frontend OCR processing
|
||||
|
||||
### Manual Validation Workflow
|
||||
|
||||
1. Admin views responses needing validation: `GET /responses/needs-validation`
|
||||
2. Admin reviews the uploaded files and OCR results
|
||||
3. Admin provides corrected values: `PUT /responses/{id}/validate`
|
||||
4. System updates `validated_*` fields and clears validation flag
|
||||
5. Optional: Mark entire submission as validated: `POST /submissions/{id}/validate`
|
||||
|
||||
## File Storage
|
||||
|
||||
- **Development**: Files stored locally in `media/submissions/`
|
||||
- **Production**: Files stored in S3 with path structure: `submissions/{submission_id}/{uuid}_{filename}`
|
||||
- **Supported Formats**: JPEG, PNG, GIF, MP4, WebM
|
||||
- **Size Limit**: 10MB per file
|
||||
|
||||
## Error Handling
|
||||
|
||||
The API returns standard HTTP status codes:
|
||||
|
||||
- `200`: Success
|
||||
- `400`: Bad Request (validation errors)
|
||||
- `401`: Unauthorized
|
||||
- `403`: Forbidden (admin required)
|
||||
- `404`: Not Found
|
||||
- `500`: Internal Server Error
|
||||
|
||||
Error Response Format:
|
||||
```json
|
||||
{
|
||||
"detail": "Error message",
|
||||
"code": "error_code"
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
The Vue frontend should:
|
||||
|
||||
1. Upload files with OCR data extracted client-side
|
||||
2. Group files by detected puzzle name
|
||||
3. Create one submission with multiple responses
|
||||
4. Handle validation flags and display admin feedback
|
||||
5. Show file upload progress and S3 URLs
|
||||
|
||||
## Admin Interface
|
||||
|
||||
Django Admin provides:
|
||||
|
||||
- **Submission Management**: View, validate, and manage submissions
|
||||
- **Response Validation**: Bulk actions for validation workflow
|
||||
- **File Management**: View uploaded files and OCR data
|
||||
- **Statistics Dashboard**: Track validation rates and submission metrics
|
||||
@@ -1,32 +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,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'accounts'
|
||||
@@ -1,47 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-28 23:41
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CustomUser',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('cas_user_id', models.CharField(blank=True, max_length=50, null=True, unique=True)),
|
||||
('cas_groups', models.JSONField(blank=True, default=list)),
|
||||
('cas_attributes', models.JSONField(blank=True, default=dict)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
},
|
||||
managers=[
|
||||
('objects', django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -1,68 +0,0 @@
|
||||
from ninja import NinjaAPI
|
||||
from ninja.security import django_auth
|
||||
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",
|
||||
)
|
||||
|
||||
# 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",
|
||||
"File upload to S3",
|
||||
"OCR validation tracking",
|
||||
"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,177 +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": True,
|
||||
}
|
||||
}
|
||||
STATIC_URL = "/static/"
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "static_www")
|
||||
STATICFILES_DIRS = [
|
||||
os.path.join(BASE_DIR, "static"),
|
||||
os.path.join(BASE_DIR, "static/vite"),
|
||||
]
|
||||
@@ -1,44 +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):
|
||||
return render(request, "index.html", {})
|
||||
|
||||
|
||||
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,344 +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 { puzzleHelpers, submissionHelpers, errorHelpers, apiService } from './services/apiService'
|
||||
import type { SteamCollection, SteamCollectionItem, Submission, PuzzleResponse, UserInfo } from './types'
|
||||
|
||||
// API data
|
||||
const collections = ref<SteamCollection[]>([])
|
||||
const puzzles = ref<SteamCollectionItem[]>([])
|
||||
const submissions = ref<Submission[]>([])
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const isLoading = ref(true)
|
||||
const showSubmissionModal = ref(false)
|
||||
const error = ref<string>('')
|
||||
|
||||
// Mock data for development
|
||||
const mockCollections: SteamCollection[] = [
|
||||
{
|
||||
id: 1,
|
||||
steam_id: '3479142989',
|
||||
title: 'PolyLAN 41',
|
||||
description: 'Puzzle for PolyLAN 41 fil rouge',
|
||||
author_name: 'Flame Legrems',
|
||||
total_items: 10,
|
||||
unique_visitors: 31,
|
||||
current_favorites: 1,
|
||||
created_at: '2025-05-29T11:19:24Z',
|
||||
updated_at: '2025-05-30T22:15:09Z'
|
||||
}
|
||||
]
|
||||
|
||||
const mockPuzzles: SteamCollectionItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
steam_item_id: '3479143948',
|
||||
title: 'P41-FLOC',
|
||||
author_name: 'Flame Legrems',
|
||||
description: 'A challenging puzzle involving complex molecular arrangements',
|
||||
tags: ['puzzle', 'chemistry', 'advanced'],
|
||||
order_index: 0,
|
||||
collection: 1,
|
||||
created_at: '2025-05-29T11:19:24Z',
|
||||
updated_at: '2025-05-30T22:15:09Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
steam_item_id: '3479143084',
|
||||
title: 'P41-40',
|
||||
author_name: 'Flame Legrems',
|
||||
description: 'Test your optimization skills with this intricate design challenge',
|
||||
tags: ['optimization', 'design'],
|
||||
order_index: 1,
|
||||
collection: 1,
|
||||
created_at: '2025-05-29T11:19:24Z',
|
||||
updated_at: '2025-05-30T22:15:09Z'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
steam_item_id: '3479143304',
|
||||
title: 'P41-39',
|
||||
author_name: 'Flame Legrems',
|
||||
description: 'A puzzle focusing on efficient resource management',
|
||||
tags: ['efficiency', 'resources'],
|
||||
order_index: 2,
|
||||
collection: 1,
|
||||
created_at: '2025-05-29T11:19:24Z',
|
||||
updated_at: '2025-05-30T22:15:09Z'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
steam_item_id: '3479143433',
|
||||
title: 'P41-38',
|
||||
author_name: 'Flame Legrems',
|
||||
description: 'Master the art of precise timing in this temporal challenge',
|
||||
tags: ['timing', 'precision'],
|
||||
order_index: 3,
|
||||
collection: 1,
|
||||
created_at: '2025-05-29T11:19:24Z',
|
||||
updated_at: '2025-05-30T22:15:09Z'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
steam_item_id: '3479143537',
|
||||
title: 'P41-37',
|
||||
author_name: 'Flame Legrems',
|
||||
description: 'Explore innovative solutions in this creative puzzle',
|
||||
tags: ['creative', 'innovation'],
|
||||
order_index: 4,
|
||||
collection: 1,
|
||||
created_at: '2025-05-29T11:19:24Z',
|
||||
updated_at: '2025-05-30T22:15:09Z'
|
||||
}
|
||||
]
|
||||
|
||||
// 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
|
||||
const puzzleId = typeof response.puzzle === 'number' ? response.puzzle : response.puzzle.id
|
||||
if (!grouped[puzzleId]) {
|
||||
grouped[puzzleId] = []
|
||||
}
|
||||
grouped[puzzleId].push(response)
|
||||
})
|
||||
})
|
||||
return grouped
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
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
|
||||
console.log('Loading puzzles...')
|
||||
const loadedPuzzles = await puzzleHelpers.loadPuzzles()
|
||||
puzzles.value = loadedPuzzles
|
||||
console.log('Puzzles loaded:', loadedPuzzles.length)
|
||||
|
||||
// Create mock collection from loaded puzzles for display
|
||||
if (loadedPuzzles.length > 0) {
|
||||
collections.value = [{
|
||||
id: 1,
|
||||
steam_id: '3479142989',
|
||||
title: 'PolyLAN 41',
|
||||
description: 'Puzzle collection for PolyLAN 41 fil rouge',
|
||||
author_name: 'Flame Legrems',
|
||||
total_items: loadedPuzzles.length,
|
||||
unique_visitors: 31,
|
||||
current_favorites: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}]
|
||||
console.log('Collection created')
|
||||
}
|
||||
|
||||
// Load existing submissions
|
||||
console.log('Loading submissions...')
|
||||
const loadedSubmissions = await submissionHelpers.loadSubmissions()
|
||||
submissions.value = loadedSubmissions
|
||||
console.log('Submissions loaded:', loadedSubmissions.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')
|
||||
}
|
||||
})
|
||||
|
||||
const handleSubmission = async (submissionData: {
|
||||
files: any[],
|
||||
notes?: string
|
||||
}) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
// Create submission via API
|
||||
const response = await submissionHelpers.createFromFiles(
|
||||
submissionData.files,
|
||||
puzzles.value,
|
||||
submissionData.notes
|
||||
)
|
||||
|
||||
if (response.error) {
|
||||
error.value = response.error
|
||||
alert(`Submission failed: ${response.error}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
// Add to local submissions list
|
||||
submissions.value.unshift(response.data)
|
||||
|
||||
// Show success message
|
||||
const puzzleNames = response.data.responses.map(r => r.puzzle_name).join(', ')
|
||||
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`)
|
||||
|
||||
// Close modal
|
||||
showSubmissionModal.value = false
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = errorHelpers.getErrorMessage(err)
|
||||
error.value = errorMessage
|
||||
alert(`Submission failed: ${errorMessage}`)
|
||||
console.error('Submission error:', err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openSubmissionModal = () => {
|
||||
showSubmissionModal.value = true
|
||||
}
|
||||
|
||||
const closeSubmissionModal = () => {
|
||||
showSubmissionModal.value = false
|
||||
}
|
||||
|
||||
// Function to match puzzle name from OCR to actual puzzle
|
||||
const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null => {
|
||||
return puzzleHelpers.findPuzzleByName(puzzles.value, ocrPuzzleName)
|
||||
}
|
||||
</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-none">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Loading State -->
|
||||
<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="window.location.reload()" 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 v-if="collections.length > 0" class="mb-8">
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl">{{ collections[0].title }}</h2>
|
||||
<p class="text-base-content/70">{{ collections[0].description }}</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 puzzles"
|
||||
:key="puzzle.id"
|
||||
:puzzle="puzzle"
|
||||
:responses="responsesByPuzzle[puzzle.id] || []"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="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="showSubmissionModal" class="modal modal-open">
|
||||
<div class="modal-box max-w-4xl">
|
||||
<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="puzzles"
|
||||
:find-puzzle-by-name="findPuzzleByName"
|
||||
@submit="handleSubmission"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,280 +0,0 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<i class="mdi mdi-shield-account text-2xl text-warning"></i>
|
||||
Admin Panel
|
||||
</h2>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow mb-6">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Total Submissions</div>
|
||||
<div class="stat-value text-primary">{{ stats.total_submissions }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Total Responses</div>
|
||||
<div class="stat-value text-secondary">{{ stats.total_responses }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Need Validation</div>
|
||||
<div class="stat-value text-warning">{{ stats.needs_validation }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Validation Rate</div>
|
||||
<div class="stat-value text-success">{{ Math.round(stats.validation_rate * 100) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Responses Needing Validation -->
|
||||
<div v-if="responsesNeedingValidation.length > 0">
|
||||
<h3 class="text-lg font-bold mb-4">Responses Needing Validation</h3>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Puzzle</th>
|
||||
<th>OCR Data</th>
|
||||
<th>Confidence</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="response in responsesNeedingValidation" :key="response.id">
|
||||
<td>
|
||||
<div class="font-bold">{{ response.puzzle_name }}</div>
|
||||
<div class="text-sm opacity-50">ID: {{ response.id }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-sm space-y-1">
|
||||
<div>Cost: {{ response.cost || '-' }}</div>
|
||||
<div>Cycles: {{ response.cycles || '-' }}</div>
|
||||
<div>Area: {{ response.area || '-' }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="badge badge-warning badge-sm">
|
||||
{{ response.ocr_confidence_score ? Math.round(response.ocr_confidence_score * 100) + '%' : 'Low' }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
@click="openValidationModal(response)"
|
||||
class="btn btn-sm btn-primary"
|
||||
>
|
||||
<i class="mdi mdi-check-circle mr-1"></i>
|
||||
Validate
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-8">
|
||||
<i class="mdi mdi-check-all text-6xl text-success opacity-50"></i>
|
||||
<p class="text-lg font-medium mt-2">All responses validated!</p>
|
||||
<p class="text-sm opacity-70">No responses currently need manual validation.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Validation Modal -->
|
||||
<div v-if="validationModal.show" class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">Validate Response</h3>
|
||||
|
||||
<div v-if="validationModal.response" class="space-y-4">
|
||||
<div class="alert alert-info">
|
||||
<i class="mdi mdi-information-outline"></i>
|
||||
<div>
|
||||
<div class="font-bold">{{ validationModal.response.puzzle_name }}</div>
|
||||
<div class="text-sm">Review and correct the OCR data below</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Cost</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="validationModal.data.validated_cost"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:placeholder="validationModal.response.cost || 'Enter cost'"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Cycles</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="validationModal.data.validated_cycles"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:placeholder="validationModal.response.cycles || 'Enter cycles'"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Area</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="validationModal.data.validated_area"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:placeholder="validationModal.response.area || 'Enter area'"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button @click="closeValidationModal" class="btn btn-ghost">Cancel</button>
|
||||
<button
|
||||
@click="submitValidation"
|
||||
class="btn btn-primary"
|
||||
:disabled="isValidating"
|
||||
>
|
||||
<span v-if="isValidating" class="loading loading-spinner loading-sm"></span>
|
||||
{{ isValidating ? 'Validating...' : 'Validate' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop" @click="closeValidationModal"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { apiService } from '../services/apiService'
|
||||
import type { PuzzleResponse } from '../types'
|
||||
|
||||
// Reactive data
|
||||
const stats = ref({
|
||||
total_submissions: 0,
|
||||
total_responses: 0,
|
||||
needs_validation: 0,
|
||||
validated_submissions: 0,
|
||||
validation_rate: 0
|
||||
})
|
||||
|
||||
const responsesNeedingValidation = ref<PuzzleResponse[]>([])
|
||||
const isLoading = ref(false)
|
||||
const isValidating = ref(false)
|
||||
|
||||
const validationModal = ref({
|
||||
show: false,
|
||||
response: null as PuzzleResponse | null,
|
||||
data: {
|
||||
validated_cost: '',
|
||||
validated_cycles: '',
|
||||
validated_area: ''
|
||||
}
|
||||
})
|
||||
|
||||
// Methods
|
||||
const loadData = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
// Load stats (skip if endpoint doesn't exist)
|
||||
try {
|
||||
const statsResponse = await apiService.getStats()
|
||||
if (statsResponse.data) {
|
||||
stats.value = statsResponse.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Stats endpoint not available:', error)
|
||||
// Set default stats
|
||||
stats.value = {
|
||||
total_submissions: 0,
|
||||
total_responses: 0,
|
||||
needs_validation: 0,
|
||||
validated_submissions: 0,
|
||||
validation_rate: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Load responses needing validation
|
||||
const responsesResponse = await apiService.getResponsesNeedingValidation()
|
||||
if (responsesResponse.data) {
|
||||
responsesNeedingValidation.value = responsesResponse.data
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load admin data:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openValidationModal = (response: PuzzleResponse) => {
|
||||
validationModal.value.response = response
|
||||
validationModal.value.data = {
|
||||
validated_cost: response.cost || '',
|
||||
validated_cycles: response.cycles || '',
|
||||
validated_area: response.area || ''
|
||||
}
|
||||
validationModal.value.show = true
|
||||
}
|
||||
|
||||
const closeValidationModal = () => {
|
||||
validationModal.value.show = false
|
||||
validationModal.value.response = null
|
||||
validationModal.value.data = {
|
||||
validated_cost: '',
|
||||
validated_cycles: '',
|
||||
validated_area: ''
|
||||
}
|
||||
}
|
||||
|
||||
const submitValidation = async () => {
|
||||
if (!validationModal.value.response?.id) return
|
||||
|
||||
try {
|
||||
isValidating.value = true
|
||||
|
||||
const response = await apiService.validateResponse(
|
||||
validationModal.value.response.id,
|
||||
validationModal.value.data
|
||||
)
|
||||
|
||||
if (response.error) {
|
||||
alert(`Validation failed: ${response.error}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove from validation list
|
||||
responsesNeedingValidation.value = responsesNeedingValidation.value.filter(
|
||||
r => r.id !== validationModal.value.response?.id
|
||||
)
|
||||
|
||||
// Update stats
|
||||
stats.value.needs_validation = Math.max(0, stats.value.needs_validation - 1)
|
||||
|
||||
closeValidationModal()
|
||||
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error)
|
||||
alert('Validation failed')
|
||||
} finally {
|
||||
isValidating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
// Expose refresh method
|
||||
defineExpose({
|
||||
refresh: loadData
|
||||
})
|
||||
</script>
|
||||
@@ -1,309 +0,0 @@
|
||||
<template>
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Upload Solution Files</span>
|
||||
<span class="label-text-alt text-xs">Images or GIFs only</span>
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center hover:border-primary transition-colors duration-300"
|
||||
:class="{ 'border-primary bg-primary/5': isDragOver }"
|
||||
@drop="handleDrop"
|
||||
@dragover.prevent="isDragOver = true"
|
||||
@dragleave="isDragOver = false"
|
||||
@dragenter.prevent
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,.gif"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
|
||||
<div v-if="files.length === 0" class="space-y-4">
|
||||
<div class="mx-auto w-12 h-12 text-base-content/40 flex items-center justify-center">
|
||||
<i class="mdi mdi-cloud-upload text-5xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-base-content/70 mb-2">Drop your files here or</p>
|
||||
<button
|
||||
type="button"
|
||||
@click="fileInput?.click()"
|
||||
class="btn btn-primary btn-sm"
|
||||
>
|
||||
Choose Files
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50">
|
||||
Supported formats: JPG, PNG, GIF (max 256MB each)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="(file, index) in files"
|
||||
:key="index"
|
||||
class="relative group"
|
||||
>
|
||||
<div class="aspect-square rounded-lg overflow-hidden bg-base-200">
|
||||
<img
|
||||
:src="file.preview"
|
||||
:alt="file.file.name"
|
||||
class="w-full h-full object-cover"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center">
|
||||
<button
|
||||
@click="removeFile(index)"
|
||||
class="btn btn-error btn-sm btn-circle"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<p class="text-xs font-medium truncate">{{ file.file.name }}</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{{ formatFileSize(file.file.size) }} • {{ file.type.toUpperCase() }}
|
||||
</p>
|
||||
|
||||
<!-- OCR Status and Results -->
|
||||
<div v-if="file.ocrProcessing" class="mt-1 flex items-center gap-1">
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
<span class="text-xs text-info">Extracting puzzle data...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="file.ocrError" class="mt-1">
|
||||
<p class="text-xs text-error">{{ file.ocrError }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="file.ocrData" class="mt-1 space-y-1">
|
||||
<div class="text-xs flex items-center justify-between">
|
||||
<span class="font-medium text-success">✓ OCR Complete</span>
|
||||
<button
|
||||
@click="retryOCR(file)"
|
||||
class="btn btn-xs btn-ghost"
|
||||
title="Retry OCR"
|
||||
>
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs space-y-1 bg-base-200 p-2 rounded">
|
||||
<div v-if="file.ocrData.puzzle">
|
||||
<strong>Puzzle:</strong> {{ file.ocrData.puzzle }}
|
||||
</div>
|
||||
<div v-if="file.ocrData.cost">
|
||||
<strong>Cost:</strong> {{ file.ocrData.cost }}
|
||||
</div>
|
||||
<div v-if="file.ocrData.cycles">
|
||||
<strong>Cycles:</strong> {{ file.ocrData.cycles }}
|
||||
</div>
|
||||
<div v-if="file.ocrData.area">
|
||||
<strong>Area:</strong> {{ file.ocrData.area }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manual OCR trigger for non-auto detected files -->
|
||||
<div v-else-if="!file.ocrProcessing && !file.ocrError && !file.ocrData" class="mt-1">
|
||||
<button
|
||||
@click="processOCR(file)"
|
||||
class="btn btn-xs btn-outline"
|
||||
>
|
||||
<i class="mdi mdi-text-recognition"></i>
|
||||
Extract Puzzle Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
@click="fileInput?.click()"
|
||||
class="btn btn-outline btn-sm"
|
||||
>
|
||||
Add More Files
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="label">
|
||||
<span class="label-text-alt text-error">{{ error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { ocrService, type OpusMagnumData } from '../services/ocrService'
|
||||
import type { SubmissionFile, SteamCollectionItem } from '@/types'
|
||||
|
||||
interface Props {
|
||||
modelValue: SubmissionFile[]
|
||||
puzzles?: SteamCollectionItem[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
'update:modelValue': [files: SubmissionFile[]]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const isDragOver = ref(false)
|
||||
const error = ref('')
|
||||
const files = ref<SubmissionFile[]>([])
|
||||
|
||||
// Watch for external changes to modelValue
|
||||
watch(() => props.modelValue, (newFiles) => {
|
||||
files.value = newFiles
|
||||
}, { immediate: true })
|
||||
|
||||
// Watch for internal changes and emit
|
||||
watch(files, (newFiles) => {
|
||||
emit('update:modelValue', newFiles)
|
||||
}, { deep: true })
|
||||
|
||||
// Watch for puzzle changes and update OCR service
|
||||
watch(() => props.puzzles, (newPuzzles) => {
|
||||
if (newPuzzles && newPuzzles.length > 0) {
|
||||
const puzzleNames = newPuzzles.map(puzzle => puzzle.title)
|
||||
ocrService.setAvailablePuzzleNames(puzzleNames)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files) {
|
||||
processFiles(Array.from(target.files))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
isDragOver.value = false
|
||||
|
||||
if (event.dataTransfer?.files) {
|
||||
processFiles(Array.from(event.dataTransfer.files))
|
||||
}
|
||||
}
|
||||
|
||||
const processFiles = async (newFiles: File[]) => {
|
||||
error.value = ''
|
||||
|
||||
for (const file of newFiles) {
|
||||
if (!isValidFile(file)) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const preview = await createPreview(file)
|
||||
const fileType = file.type.startsWith('image/gif') ? 'gif' : 'image'
|
||||
|
||||
const submissionFile: SubmissionFile = {
|
||||
file,
|
||||
preview,
|
||||
type: fileType,
|
||||
ocrProcessing: false,
|
||||
ocrError: undefined,
|
||||
ocrData: undefined
|
||||
}
|
||||
|
||||
files.value.push(submissionFile)
|
||||
|
||||
// Start OCR processing for Opus Magnum images (with delay to ensure reactivity)
|
||||
if (isOpusMagnumImage(file)) {
|
||||
nextTick(() => {
|
||||
processOCR(submissionFile)
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = `Failed to process ${file.name}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isValidFile = (file: File): boolean => {
|
||||
// Check file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
error.value = `${file.name} is not a valid image file`
|
||||
return false
|
||||
}
|
||||
|
||||
// Check file size (256MB limit)
|
||||
if (file.size > 256 * 1024 * 1024) {
|
||||
error.value = `${file.name} is too large (max 256MB)`
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const createPreview = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => resolve(e.target?.result as string)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
files.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const isOpusMagnumImage = (file: File): boolean => {
|
||||
// Basic heuristic - could be enhanced with actual image analysis
|
||||
return file.type.startsWith('image/') && file.size > 50000 // > 50KB likely screenshot
|
||||
}
|
||||
|
||||
const processOCR = async (submissionFile: SubmissionFile) => {
|
||||
// Find the file in the reactive array to ensure proper reactivity
|
||||
const fileIndex = files.value.findIndex(f => f.file === submissionFile.file)
|
||||
if (fileIndex === -1) return
|
||||
|
||||
// Update the reactive array directly
|
||||
files.value[fileIndex].ocrProcessing = true
|
||||
files.value[fileIndex].ocrError = undefined
|
||||
files.value[fileIndex].ocrData = undefined
|
||||
|
||||
try {
|
||||
console.log('Starting OCR processing for:', submissionFile.file.name)
|
||||
await ocrService.initialize()
|
||||
const ocrData = await ocrService.extractOpusMagnumData(submissionFile.file)
|
||||
console.log('OCR completed:', ocrData)
|
||||
|
||||
// Force reactivity update
|
||||
await nextTick()
|
||||
files.value[fileIndex].ocrData = ocrData
|
||||
await nextTick()
|
||||
} catch (error) {
|
||||
console.error('OCR processing failed:', error)
|
||||
files.value[fileIndex].ocrError = 'Failed to extract puzzle data'
|
||||
} finally {
|
||||
files.value[fileIndex].ocrProcessing = false
|
||||
}
|
||||
}
|
||||
|
||||
const retryOCR = (submissionFile: SubmissionFile) => {
|
||||
processOCR(submissionFile)
|
||||
}
|
||||
</script>
|
||||
@@ -1,140 +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 line-clamp-2">
|
||||
{{ 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">
|
||||
<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'
|
||||
|
||||
interface Props {
|
||||
puzzle: SteamCollectionItem
|
||||
responses?: PuzzleResponse[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,127 +0,0 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-xl mb-6">
|
||||
<i class="mdi mdi-check-circle text-2xl text-primary"></i>
|
||||
Submit Solution
|
||||
</h2>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||
<!-- Detected Puzzles Summary -->
|
||||
<div v-if="Object.keys(responsesByPuzzle).length > 0" class="alert alert-info">
|
||||
<i class="mdi mdi-information-outline text-xl"></i>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-bold">Detected Puzzles ({{ Object.keys(responsesByPuzzle).length }})</h4>
|
||||
<div class="text-sm space-y-1 mt-1">
|
||||
<div v-for="(data, puzzleName) in responsesByPuzzle" :key="puzzleName" class="flex justify-between">
|
||||
<span>{{ puzzleName }}</span>
|
||||
<span class="badge badge-ghost badge-sm">{{ data.files.length }} file(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload -->
|
||||
<FileUpload v-model="submissionFiles" :puzzles="puzzles" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Notes (Optional)</span>
|
||||
<span class="label-text-alt">{{ notesLength }}/500</span>
|
||||
</label>
|
||||
<textarea
|
||||
v-model="notes"
|
||||
class="textarea textarea-bordered h-24 resize-none"
|
||||
placeholder="Add any notes about your solution, approach, or interesting findings..."
|
||||
maxlength="500"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="card-actions justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
<span v-if="isSubmitting" class="loading loading-spinner loading-sm"></span>
|
||||
{{ isSubmitting ? 'Submitting...' : 'Submit Solution' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import FileUpload from './FileUpload.vue'
|
||||
import type { SteamCollectionItem, SubmissionFile } from '@/types'
|
||||
|
||||
interface Props {
|
||||
puzzles: SteamCollectionItem[]
|
||||
findPuzzleByName: (name: string) => SteamCollectionItem | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
submit: [submissionData: { files: SubmissionFile[], notes?: string }]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const submissionFiles = ref<SubmissionFile[]>([])
|
||||
const notes = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const notesLength = computed(() => notes.value.length)
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
return submissionFiles.value.length > 0 &&
|
||||
!isSubmitting.value
|
||||
})
|
||||
|
||||
// Group files by detected puzzle
|
||||
const responsesByPuzzle = computed(() => {
|
||||
const grouped: Record<string, { puzzle: SteamCollectionItem | null, files: SubmissionFile[] }> = {}
|
||||
|
||||
submissionFiles.value.forEach(file => {
|
||||
if (file.ocrData?.puzzle) {
|
||||
const puzzleName = file.ocrData.puzzle
|
||||
if (!grouped[puzzleName]) {
|
||||
grouped[puzzleName] = {
|
||||
puzzle: props.findPuzzleByName(puzzleName),
|
||||
files: []
|
||||
}
|
||||
}
|
||||
grouped[puzzleName].files.push(file)
|
||||
}
|
||||
})
|
||||
|
||||
return grouped
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
// Emit the files and notes for the parent to handle API submission
|
||||
emit('submit', {
|
||||
files: submissionFiles.value,
|
||||
notes: notes.value.trim() || undefined
|
||||
})
|
||||
|
||||
// Reset form
|
||||
submissionFiles.value = []
|
||||
notes.value = ''
|
||||
|
||||
} catch (error) {
|
||||
console.error('Submission error:', error)
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from '@/App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -1,299 +0,0 @@
|
||||
import { createWorker } from 'tesseract.js';
|
||||
|
||||
export interface OpusMagnumData {
|
||||
puzzle: string;
|
||||
cost: string;
|
||||
cycles: string;
|
||||
area: string;
|
||||
}
|
||||
|
||||
export interface OCRRegion {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export class OpusMagnumOCRService {
|
||||
private worker: Tesseract.Worker | null = null;
|
||||
private availablePuzzleNames: string[] = [];
|
||||
|
||||
// Regions based on main.py coordinates (adjusted for web usage)
|
||||
private readonly regions: Record<string, OCRRegion> = {
|
||||
puzzle: { x: 15, y: 600, width: 330, height: 28 },
|
||||
cost: { x: 412, y: 603, width: 65, height: 22 },
|
||||
cycles: { x: 577, y: 603, width: 65, height: 22 },
|
||||
area: { x: 739, y: 603, width: 65, height: 22 }
|
||||
};
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (this.worker) return;
|
||||
|
||||
this.worker = await createWorker('eng');
|
||||
await this.worker.setParameters({
|
||||
tessedit_ocr_engine_mode: '3',
|
||||
tessedit_pageseg_mode: '7'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the list of available puzzle names for better OCR matching
|
||||
*/
|
||||
setAvailablePuzzleNames(puzzleNames: string[]): void {
|
||||
this.availablePuzzleNames = puzzleNames;
|
||||
}
|
||||
|
||||
async extractOpusMagnumData(imageFile: File): Promise<OpusMagnumData> {
|
||||
if (!this.worker) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
// Convert file to image element for canvas processing
|
||||
const imageUrl = URL.createObjectURL(imageFile);
|
||||
const img = new Image();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
img.onload = async () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// Extract text from each region
|
||||
const results: Partial<OpusMagnumData> = {};
|
||||
|
||||
for (const [key, region] of Object.entries(this.regions)) {
|
||||
const regionCanvas = document.createElement('canvas');
|
||||
const regionCtx = regionCanvas.getContext('2d')!;
|
||||
|
||||
regionCanvas.width = region.width;
|
||||
regionCanvas.height = region.height;
|
||||
|
||||
// Extract region from main image
|
||||
regionCtx.drawImage(
|
||||
canvas,
|
||||
region.x, region.y, region.width, region.height,
|
||||
0, 0, region.width, region.height
|
||||
);
|
||||
|
||||
// Convert to grayscale and invert (similar to main.py processing)
|
||||
const imageData = regionCtx.getImageData(0, 0, region.width, region.height);
|
||||
this.preprocessImage(imageData);
|
||||
regionCtx.putImageData(imageData, 0, 0);
|
||||
|
||||
// Configure OCR based on content type
|
||||
let config: any = {};
|
||||
if (key === 'cost') {
|
||||
// Cost field has digits + 'G' for gold (content type: 'digits_with_6')
|
||||
await this.worker!.setParameters({
|
||||
tessedit_char_whitelist: '0123456789G'
|
||||
});
|
||||
} else if (key === 'cycles' || key === 'area') {
|
||||
// Pure digits (content type: 'digits')
|
||||
await this.worker!.setParameters({
|
||||
tessedit_char_whitelist: '0123456789'
|
||||
});
|
||||
} else if (key === 'puzzle') {
|
||||
// Puzzle name - allow alphanumeric, spaces, and dashes
|
||||
await this.worker!.setParameters({
|
||||
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 -'
|
||||
});
|
||||
} else {
|
||||
// Default - allow all characters
|
||||
await this.worker!.setParameters({
|
||||
tessedit_char_whitelist: ''
|
||||
});
|
||||
}
|
||||
|
||||
// Perform OCR on the region
|
||||
const { data: { text } } = await this.worker!.recognize(regionCanvas);
|
||||
let cleanText = text.trim();
|
||||
|
||||
// Post-process based on field type
|
||||
if (key === 'cost') {
|
||||
// Handle common OCR misreadings where G is read as 6
|
||||
// If the text ends with 6 and looks like it should be G, remove it
|
||||
if (cleanText.endsWith('6') && cleanText.length > 1) {
|
||||
// Check if removing the last character gives a reasonable cost value
|
||||
const withoutLast = cleanText.slice(0, -1);
|
||||
if (/^\d+$/.test(withoutLast)) {
|
||||
cleanText = withoutLast;
|
||||
}
|
||||
}
|
||||
// Remove any trailing G characters
|
||||
cleanText = cleanText.replace(/G+$/g, '');
|
||||
// Ensure only digits remain
|
||||
cleanText = cleanText.replace(/[^0-9]/g, '');
|
||||
} else if (key === 'cycles' || key === 'area') {
|
||||
// Ensure only digits remain
|
||||
cleanText = cleanText.replace(/[^0-9]/g, '');
|
||||
} else if (key === 'puzzle') {
|
||||
// Post-process puzzle names with fuzzy matching
|
||||
cleanText = this.findBestPuzzleMatch(cleanText);
|
||||
}
|
||||
|
||||
results[key as keyof OpusMagnumData] = cleanText;
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
|
||||
resolve({
|
||||
puzzle: results.puzzle || '',
|
||||
cost: results.cost || '',
|
||||
cycles: results.cycles || '',
|
||||
area: results.area || ''
|
||||
});
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
|
||||
img.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
private preprocessImage(imageData: ImageData): void {
|
||||
// Convert to grayscale and invert (similar to cv2.bitwise_not in main.py)
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
// Convert to grayscale
|
||||
const gray = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]);
|
||||
|
||||
// Invert the grayscale value
|
||||
const inverted = 255 - gray;
|
||||
|
||||
data[i] = inverted; // Red
|
||||
data[i + 1] = inverted; // Green
|
||||
data[i + 2] = inverted; // Blue
|
||||
// Alpha channel (data[i + 3]) remains unchanged
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein distance between two strings
|
||||
*/
|
||||
private levenshteinDistance(str1: string, str2: string): number {
|
||||
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
|
||||
|
||||
for (let i = 0; i <= str1.length; i++) matrix[0][i] = i;
|
||||
for (let j = 0; j <= str2.length; j++) matrix[j][0] = j;
|
||||
|
||||
for (let j = 1; j <= str2.length; j++) {
|
||||
for (let i = 1; i <= str1.length; i++) {
|
||||
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
||||
matrix[j][i] = Math.min(
|
||||
matrix[j][i - 1] + 1, // deletion
|
||||
matrix[j - 1][i] + 1, // insertion
|
||||
matrix[j - 1][i - 1] + indicator // substitution
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[str2.length][str1.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best matching puzzle name from available options
|
||||
*/
|
||||
private findBestPuzzleMatch(ocrText: string): string {
|
||||
if (!this.availablePuzzleNames.length) {
|
||||
return ocrText.trim();
|
||||
}
|
||||
|
||||
const cleanedOcr = ocrText.trim();
|
||||
|
||||
// First try exact match (case insensitive)
|
||||
const exactMatch = this.availablePuzzleNames.find(
|
||||
name => name.toLowerCase() === cleanedOcr.toLowerCase()
|
||||
);
|
||||
if (exactMatch) return exactMatch;
|
||||
|
||||
// Then try fuzzy matching
|
||||
let bestMatch = cleanedOcr;
|
||||
let bestScore = Infinity;
|
||||
|
||||
for (const puzzleName of this.availablePuzzleNames) {
|
||||
// Calculate similarity scores
|
||||
const distance = this.levenshteinDistance(
|
||||
cleanedOcr.toLowerCase(),
|
||||
puzzleName.toLowerCase()
|
||||
);
|
||||
|
||||
// Normalize by length to get a similarity ratio
|
||||
const maxLength = Math.max(cleanedOcr.length, puzzleName.length);
|
||||
const similarity = 1 - (distance / maxLength);
|
||||
|
||||
// Consider it a good match if similarity is above 70%
|
||||
if (similarity > 0.7 && distance < bestScore) {
|
||||
bestScore = distance;
|
||||
bestMatch = puzzleName;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
|
||||
async terminate(): Promise<void> {
|
||||
if (this.worker) {
|
||||
await this.worker.terminate();
|
||||
this.worker = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility method to validate if an image looks like an Opus Magnum screenshot
|
||||
static isValidOpusMagnumImage(file: File): boolean {
|
||||
// Basic validation - could be enhanced with actual image analysis
|
||||
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
|
||||
return validTypes.includes(file.type);
|
||||
}
|
||||
|
||||
// Debug method to visualize OCR regions (similar to main.py debug rectangles)
|
||||
static drawDebugRegions(imageFile: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const imageUrl = URL.createObjectURL(imageFile);
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// Draw debug rectangles
|
||||
ctx.strokeStyle = '#00ff00';
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
const service = new OpusMagnumOCRService();
|
||||
Object.values(service.regions).forEach(region => {
|
||||
ctx.strokeRect(region.x, region.y, region.width, region.height);
|
||||
});
|
||||
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
resolve(canvas.toDataURL());
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
reject(new Error('Failed to load image for debug'));
|
||||
};
|
||||
|
||||
img.src = imageUrl;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for the application
|
||||
export const ocrService = new OpusMagnumOCRService();
|
||||
@@ -1,87 +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
|
||||
}
|
||||
|
||||
export interface SubmissionFile {
|
||||
file: File
|
||||
preview: string
|
||||
type: 'image' | 'gif'
|
||||
ocrData?: OpusMagnumData
|
||||
ocrProcessing?: boolean
|
||||
ocrError?: string
|
||||
}
|
||||
|
||||
export interface PuzzleResponse {
|
||||
id?: number
|
||||
puzzle: number | SteamCollectionItem
|
||||
puzzle_name: string
|
||||
cost?: string
|
||||
cycles?: string
|
||||
area?: string
|
||||
needs_manual_validation?: boolean
|
||||
ocr_confidence_score?: 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
|
||||
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[]
|
||||
}
|
||||
@@ -1,253 +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.http import Http404
|
||||
from django.utils import timezone
|
||||
from django.shortcuts import get_object_or_404
|
||||
from typing import List
|
||||
|
||||
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").all()
|
||||
|
||||
|
||||
@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():
|
||||
# Create the submission
|
||||
submission = Submission.objects.create(
|
||||
user=request.user if request.user.is_authenticated else None,
|
||||
notes=data.notes,
|
||||
)
|
||||
|
||||
file_index = 0
|
||||
for response_data in data.responses:
|
||||
# Get the puzzle
|
||||
try:
|
||||
puzzle = SteamCollectionItem.objects.get(id=response_data.puzzle_id)
|
||||
except SteamCollectionItem.DoesNotExist:
|
||||
return 400, {
|
||||
"detail": f"Puzzle with id {response_data.puzzle_id} not found"
|
||||
}
|
||||
|
||||
# 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=response_data.needs_manual_validation,
|
||||
ocr_confidence_score=response_data.ocr_confidence_score,
|
||||
)
|
||||
|
||||
# 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
|
||||
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"}
|
||||
|
||||
try:
|
||||
response = PuzzleResponse.objects.select_related("puzzle").get(id=response_id)
|
||||
|
||||
# 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
|
||||
|
||||
except PuzzleResponse.DoesNotExist:
|
||||
raise Http404("Response not found")
|
||||
|
||||
|
||||
@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)
|
||||
.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"}
|
||||
|
||||
try:
|
||||
submission = Submission.objects.get(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
|
||||
|
||||
except Submission.DoesNotExist:
|
||||
raise Http404("Submission not found")
|
||||
|
||||
|
||||
@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"}
|
||||
|
||||
try:
|
||||
submission = Submission.objects.get(id=submission_id)
|
||||
submission.delete()
|
||||
return {"detail": "Submission deleted successfully"}
|
||||
|
||||
except Submission.DoesNotExist:
|
||||
raise Http404("Submission not found")
|
||||
|
||||
|
||||
@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": validated_submissions / total_submissions
|
||||
if total_submissions > 0
|
||||
else 0,
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SubmissionsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'submissions'
|
||||
@@ -1,72 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-29 00:11
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Collection',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('url', models.URLField()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SteamCollection',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('steam_id', models.CharField(help_text='Steam collection ID from URL', max_length=50, unique=True)),
|
||||
('url', models.URLField(help_text='Full Steam Workshop collection URL')),
|
||||
('title', models.CharField(blank=True, help_text='Collection title', max_length=255)),
|
||||
('description', models.TextField(blank=True, help_text='Collection description')),
|
||||
('author_name', models.CharField(blank=True, help_text='Steam username of collection creator', max_length=100)),
|
||||
('author_steam_id', models.CharField(blank=True, help_text='Steam ID of collection creator', max_length=50)),
|
||||
('total_items', models.PositiveIntegerField(default=0, help_text='Number of items in collection')),
|
||||
('unique_visitors', models.PositiveIntegerField(default=0, help_text='Number of unique visitors')),
|
||||
('current_favorites', models.PositiveIntegerField(default=0, help_text='Current number of favorites')),
|
||||
('total_favorites', models.PositiveIntegerField(default=0, help_text='Total unique favorites')),
|
||||
('steam_created_date', models.DateTimeField(blank=True, help_text='When collection was created on Steam', null=True)),
|
||||
('steam_updated_date', models.DateTimeField(blank=True, help_text='When collection was last updated on Steam', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('last_fetched', models.DateTimeField(blank=True, help_text='When data was last fetched from Steam', null=True)),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this collection is actively tracked')),
|
||||
('fetch_error', models.TextField(blank=True, help_text='Last error encountered when fetching data')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Steam Collection',
|
||||
'verbose_name_plural': 'Steam Collections',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SteamCollectionItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('steam_item_id', models.CharField(help_text='Steam Workshop item ID', max_length=50)),
|
||||
('title', models.CharField(blank=True, help_text='Item title', max_length=255)),
|
||||
('author_name', models.CharField(blank=True, help_text='Steam username of item creator', max_length=100)),
|
||||
('author_steam_id', models.CharField(blank=True, help_text='Steam ID of item creator', max_length=50)),
|
||||
('description', models.TextField(blank=True, help_text='Item description')),
|
||||
('tags', models.JSONField(blank=True, default=list, help_text='Item tags as JSON array')),
|
||||
('order_index', models.PositiveIntegerField(default=0, help_text='Order of item in collection')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='submissions.steamcollection')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Steam Collection Item',
|
||||
'verbose_name_plural': 'Steam Collection Items',
|
||||
'ordering': ['collection', 'order_index'],
|
||||
'unique_together': {('collection', 'steam_item_id')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,31 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-29 00:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('submissions', '0002_delete_collection'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SteamAPIKey',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text="Descriptive name for this API key (e.g., 'Production Key', 'Development Key')", max_length=100, unique=True)),
|
||||
('api_key', models.CharField(help_text='Steam Web API key from https://steamcommunity.com/dev/apikey', max_length=64)),
|
||||
('is_active', models.BooleanField(default=True, help_text='Whether this API key should be used')),
|
||||
('description', models.TextField(blank=True, help_text='Optional description or notes about this API key')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('last_used', models.DateTimeField(blank=True, help_text='When this API key was last used', null=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Steam API Key',
|
||||
'verbose_name_plural': 'Steam API Keys',
|
||||
'ordering': ['-is_active', 'name'],
|
||||
},
|
||||
),
|
||||
]
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-29 01:32
|
||||
|
||||
import django.db.models.deletion
|
||||
import submissions.models
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('submissions', '0003_steamapikey'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Submission',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('notes', models.TextField(blank=True, help_text='Optional notes about the submission')),
|
||||
('is_validated', models.BooleanField(default=False, help_text='Whether this submission has been manually validated')),
|
||||
('validated_at', models.DateTimeField(blank=True, help_text='When this submission was validated', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(blank=True, help_text='User who made the submission (null for anonymous)', null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
('validated_by', models.ForeignKey(blank=True, help_text='Admin user who validated this submission', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='validated_submissions', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Submission',
|
||||
'verbose_name_plural': 'Submissions',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PuzzleResponse',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('puzzle_name', models.CharField(help_text='Puzzle name as detected by OCR', max_length=255)),
|
||||
('cost', models.CharField(blank=True, help_text='Cost value from OCR', max_length=20)),
|
||||
('cycles', models.CharField(blank=True, help_text='Cycles value from OCR', max_length=20)),
|
||||
('area', models.CharField(blank=True, help_text='Area value from OCR', max_length=20)),
|
||||
('needs_manual_validation', models.BooleanField(default=False, help_text='Whether OCR failed and manual validation is needed')),
|
||||
('ocr_confidence_score', models.FloatField(blank=True, help_text='OCR confidence score (0.0 to 1.0)', null=True)),
|
||||
('validated_cost', models.CharField(blank=True, help_text='Manually validated cost value', max_length=20)),
|
||||
('validated_cycles', models.CharField(blank=True, help_text='Manually validated cycles value', max_length=20)),
|
||||
('validated_area', models.CharField(blank=True, help_text='Manually validated area value', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('puzzle', models.ForeignKey(help_text='The puzzle this response is for', on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='submissions.steamcollectionitem')),
|
||||
('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='submissions.submission')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Puzzle Response',
|
||||
'verbose_name_plural': 'Puzzle Responses',
|
||||
'ordering': ['submission', 'puzzle__order_index'],
|
||||
'unique_together': {('submission', 'puzzle')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SubmissionFile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file', models.FileField(help_text='Uploaded file (image/gif)', upload_to=submissions.models.submission_file_upload_path)),
|
||||
('original_filename', models.CharField(help_text='Original filename as uploaded by user', max_length=255)),
|
||||
('file_size', models.PositiveIntegerField(help_text='File size in bytes')),
|
||||
('content_type', models.CharField(help_text='MIME type of the file', max_length=100)),
|
||||
('ocr_processed', models.BooleanField(default=False, help_text='Whether OCR has been processed for this file')),
|
||||
('ocr_raw_data', models.JSONField(blank=True, help_text='Raw OCR data as JSON', null=True)),
|
||||
('ocr_error', models.TextField(blank=True, help_text='OCR processing error message')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('response', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='submissions.puzzleresponse')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Submission File',
|
||||
'verbose_name_plural': 'Submission Files',
|
||||
'ordering': ['response', 'created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-29 01:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('submissions', '0004_submission_puzzleresponse_submissionfile'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='submission',
|
||||
name='notes',
|
||||
field=models.TextField(blank=True, help_text='Optional notes about the submission', null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -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"></div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"root":["./src/main.ts","./src/App.vue","./src/components/HelloWorld.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/dist"),
|
||||
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,137 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-28 23:41
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="CustomUser",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="last login"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_superuser",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||
verbose_name="superuser status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
error_messages={
|
||||
"unique": "A user with that username already exists."
|
||||
},
|
||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
max_length=150,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||
],
|
||||
verbose_name="username",
|
||||
),
|
||||
),
|
||||
(
|
||||
"first_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="first name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="last name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
blank=True, max_length=254, verbose_name="email address"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_staff",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates whether the user can log into this admin site.",
|
||||
verbose_name="staff status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||
verbose_name="active",
|
||||
),
|
||||
),
|
||||
(
|
||||
"date_joined",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="date joined"
|
||||
),
|
||||
),
|
||||
(
|
||||
"cas_user_id",
|
||||
models.CharField(blank=True, max_length=50, null=True, unique=True),
|
||||
),
|
||||
("cas_groups", models.JSONField(blank=True, default=list)),
|
||||
("cas_attributes", models.JSONField(blank=True, default=dict)),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.group",
|
||||
verbose_name="groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_permissions",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Specific permissions for this user.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.permission",
|
||||
verbose_name="user permissions",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "user",
|
||||
"verbose_name_plural": "users",
|
||||
"abstract": False,
|
||||
},
|
||||
managers=[
|
||||
("objects", django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -1,6 +1,5 @@
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
import json
|
||||
|
||||
|
||||
class CustomUser(AbstractUser):
|
||||
@@ -15,6 +14,9 @@ class CustomUser(AbstractUser):
|
||||
# Additional fields that might come from CAS
|
||||
cas_attributes = models.JSONField(default=dict, blank=True)
|
||||
|
||||
# Market points balance
|
||||
points = models.IntegerField(default=1000)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.username} ({self.cas_user_id})"
|
||||
|
||||
@@ -57,4 +59,3 @@ class CustomUser(AbstractUser):
|
||||
self.cas_attributes = attributes
|
||||
|
||||
self.save()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1 @@
|
||||
# Create your views here.
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AnimationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
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 @@
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1 @@
|
||||
# Create your views here.
|
||||
@@ -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"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user