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
|
tags
|
||||||
media/
|
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.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
import json
|
|
||||||
|
|
||||||
|
|
||||||
class CustomUser(AbstractUser):
|
class CustomUser(AbstractUser):
|
||||||
@@ -15,6 +14,9 @@ class CustomUser(AbstractUser):
|
|||||||
# Additional fields that might come from CAS
|
# Additional fields that might come from CAS
|
||||||
cas_attributes = models.JSONField(default=dict, blank=True)
|
cas_attributes = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
# Market points balance
|
||||||
|
points = models.IntegerField(default=1000)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.username} ({self.cas_user_id})"
|
return f"{self.username} ({self.cas_user_id})"
|
||||||
|
|
||||||
@@ -57,4 +59,3 @@ class CustomUser(AbstractUser):
|
|||||||
self.cas_attributes = attributes
|
self.cas_attributes = attributes
|
||||||
|
|
||||||
self.save()
|
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