Compare commits

...
157 Commits
Author SHA1 Message Date
Legrems 407cc22911 opus-stats-score 2026-10-25 14:20:49 +01:00
Legrems 5fc79197f6 feat: add final.png export 2026-10-25 14:07:57 +01:00
Legrems 73b279d7fe feat(place): final end time + destruction wave 2026-10-25 12:55:24 +01:00
Legrems 1844b973e9 feat: add admin stats 2026-10-25 10:48:54 +01:00
Legrems 6cee1135a8 feat: add option to disable pixel placement 2026-10-25 10:42:58 +01:00
Legrems 4f2cf08b27 feat: heatmap time decay also in addition of placements 2026-10-25 10:29:20 +01:00
Legrems 9bb02463da feat: heatmap + evo + decay 2026-10-25 10:25:29 +01:00
Legrems 0b34e66d81 show opus submissions for admin 2026-10-24 04:27:40 +02:00
Legrems d180eb3c90 deploy scripts 2026-10-24 04:15:31 +02:00
Legrems 5c70c38523 feat: systemd deployment 2026-10-24 04:04:38 +02:00
Legrems 026d592483 feat: evolution per player (optional) 2026-10-24 03:49:34 +02:00
Legrems 87e6e38291 add place csv export 2026-10-24 02:11:09 +02:00
Legrems 723da9aa38 repair legacy table in migration 2026-10-24 01:18:08 +02:00
Legrems 421106bf71 fix staleTime to 5m instead of infinity 2026-10-23 23:56:20 +02:00
Legrems f42649b888 fix submittion null score 2026-10-23 23:35:38 +02:00
Legrems f75a1b0338 reset opus-magnum sequence 2026-10-23 21:26:22 +02:00
Legrems 1c6d9202fc increase place size 2026-10-23 20:21:57 +02:00
Legrems bc3d8158ec fetch steam collection from event path 2026-10-23 18:53:28 +02:00
Legrems 6e7a68d509 proxy headers 2026-10-23 18:38:48 +02:00
Legrems 2c4dac628e feat(games): disable games inline 2026-10-23 18:06:59 +02:00
Legrems cf51b187d2 opus-magnum steam collection linked to event in admin 2026-10-23 18:03:33 +02:00
Legrems b5716d6dcd various display fixes 2026-10-16 13:25:56 +02:00
Legrems ad7726d978 try to match to steam game 2026-10-16 05:28:15 +02:00
Legrems 89fb745e74 wip: planning-slot test 2026-10-16 05:20:52 +02:00
Legrems 2f8fdb755b feat(games): show games in library mode also 2026-10-16 02:41:27 +02:00
Legrems 54ffe89445 fix(clock): colliding points annotation with CustomUser.points field 2026-10-16 02:30:06 +02:00
Legrems 377bc91bcb fix: small return fixes in api schema 2026-10-16 02:29:39 +02:00
Legrems 60d730a683 feat(submissions): add small file preview before submitting 2026-10-16 02:24:41 +02:00
Legrems 1daf2757b8 fix(submissions): broadcast crop delete + fix for crop admin 2026-10-16 02:19:18 +02:00
Legrems a110575e0a feat(submissions): show all submissions for admin 2026-10-16 02:11:04 +02:00
Legrems bfaef2e402 fix: move other (clock+place) live icon to top ehader too 2026-10-16 02:06:33 +02:00
Legrems 3d040dd377 fix(submissions): top-header icon for live connection (ws) 2026-10-16 02:03:54 +02:00
Legrems a6ea3719b9 fix(submissions): default to frontend search to prevent wrong 404 on hard refresh 2026-10-16 01:55:38 +02:00
Legrems 13c09adff3 feat(submissions): auto-replace player in scopes, cancel sync + info about the sync 2026-10-16 01:49:53 +02:00
Legrems de5ee8ae7a feat(submissions): sync scopes admin from token 2026-10-16 01:34:56 +02:00
Legrems 8daa91d6c5 fix(submissions): better reconnect for ws 2026-10-16 01:26:43 +02:00
Legrems acd08e216a fix: use vue-query; make less useless api call 2026-10-16 01:22:16 +02:00
Legrems b403311ce1 fix(games): card fixes; colors + width 2026-10-16 01:13:24 +02:00
Legrems 1ca4643774 fix(clock): dedup tasks lock bug removal 2026-10-16 01:08:50 +02:00
Legrems 27d3c08a72 feat(games): add steam pages with steam price/reduc 2026-10-16 01:03:46 +02:00
Legrems 919e611895 feat(market): enable/disable like any other games 2026-10-16 00:23:27 +02:00
Legrems 107fe8b69b chore: ruff 2026-10-16 00:18:52 +02:00
Legrems 15ab9a14ac feat(event): option to disable an event 2026-10-16 00:18:36 +02:00
Legrems b837abdba0 fix(submissions): having one scope only make the back return not work 2026-10-15 23:36:06 +02:00
Legrems 4fd683ff2c fix(market): locust file for testing 2026-10-15 23:31:32 +02:00
Legrems 8509be548e feat(market): graph for fun 2026-10-07 21:54:24 +02:00
Legrems 382ba01f3e fix(market): per-event bet loss+win 2026-10-07 21:44:33 +02:00
Legrems 6a5a224d3b fix: multiple fixes + event for market 2026-10-07 21:38:30 +02:00
Legrems fdf84f9524 chore: admin interface changes 2026-09-19 17:18:23 +02:00
Legrems e8b018ec49 chore: add locust files + testing setup 2026-09-19 16:24:19 +02:00
Legrems b74d20cbe0 chore: deployment move to docker only 2026-09-19 13:36:48 +02:00
Legrems 59b568ff41 chore: dockerfile (testing) 2026-09-18 23:00:38 +02:00
Legrems 979be91417 chore: deploy scripts + env 2026-09-17 17:28:06 +02:00
Legrems 28d2a7ad24 fix: only explicit games are considered enabled 2026-09-17 11:30:58 +02:00
Legrems e38b610456 feat: add embed for place game 2026-09-17 01:51:38 +02:00
Legrems 9fc9e19d68 fix: useEvent for clock/place games 2026-09-17 01:37:31 +02:00
Legrems 6866b45f66 feat: clock + place games 2026-09-17 01:33:54 +02:00
Legrems d104480188 feat: switch to spa 2026-09-17 00:56:17 +02:00
Legrems 15590e5162 feat: "spa", but more like single entry-point 2026-09-16 23:25:16 +02:00
Legrems 13b14abb12 chore: add i18n 2026-09-16 22:41:59 +02:00
Legrems f68c4e50e8 feat: use django-objects-actions 2026-09-16 19:07:40 +02:00
Legrems 4392122cfc fix: polylan sync 2026-09-16 18:42:37 +02:00
Legrems 9ec9a54649 refactor: add polylan-aandt generated client 2026-09-16 15:36:51 +02:00
Legrems fabf06e6d4 feat(submissions): scoped submissions per user/matchs, sync from polylan 2026-09-16 13:12:22 +02:00
Legrems 85e67599af fix: multi-events 2026-09-16 00:21:08 +02:00
Legrems 68de07645a feat: add custom 404/500 error pages 2026-09-16 00:07:35 +02:00
Legrems 8b9c254fd2 feat: add events app + "multi-tenants" aka multi-events 2026-09-16 00:03:26 +02:00
Legrems b5c2cdc037 chore: assets fixes 2026-09-15 21:55:16 +02:00
Legrems d534519de6 chore: uv.lock update 2026-09-15 16:56:51 +02:00
Legrems d7bdb85fb0 feat(websocket): frontend websocket 2026-05-25 13:01:22 +02:00
Legrems b64161b371 feat(websocket): submissions backend 2026-05-25 02:45:17 +02:00
Legrems c02a0aa985 add missing market api call 2026-05-25 02:40:53 +02:00
Legrems 202e04a554 feat(submissions): admin call to process ocr data 2026-05-25 02:39:51 +02:00
Legrems 0fc1fb255f feat(submissions): crop management 2026-05-25 02:34:23 +02:00
Legrems a769d1a0b3 fix: display ocr data submissions 2026-05-25 02:00:34 +02:00
Legrems 11d6fc423a fix(submissions): fix upload + bucket path 2026-05-25 01:50:27 +02:00
Legrems be81d40a90 feat(game): quick admin action for enable/disable 2026-05-25 01:23:23 +02:00
Legrems 9f9cb0a483 feat(submissions): base submissions frontend + api 2026-05-25 01:20:47 +02:00
Legrems 6a58a0289e feat(games): support non-steam games 2026-05-25 00:54:38 +02:00
Legrems c2602dbc02 feat: add django-channel and daphne 2026-05-25 00:42:34 +02:00
Legrems f543cdcd5c chore: django 6 migration + lib upgrade 2026-05-24 23:39:34 +02:00
Legrems f2fe87c12b feat: submissions app + django-storages + whitenoise 2026-05-24 23:37:22 +02:00
Legrems 3e91ef77b6 chore: vite build 2026-05-24 19:21:05 +02:00
Legrems 215d68a0e1 chore: vite build 2026-05-24 19:14:08 +02:00
Legrems 47812ffd09 feat(market): settings toggle 2026-05-24 19:11:02 +02:00
Legrems a5fe8aacaf feat(migration): frontend migration submissions -> opus-magnum 2026-05-24 18:54:14 +02:00
Legrems b437210eb3 feat(migration): old submissions -> opus magnum app 2026-05-24 18:51:01 +02:00
Legrems 5584e54b58 migrate to opus-magnum app 2026-05-24 18:48:14 +02:00
Legrems 35ea54ecea chore: market build app 2026-05-24 18:19:06 +02:00
Legrems 821e453bc0 feat(noita): stores 2026-05-24 09:29:30 +02:00
Legrems 9fd0122a67 chore: market app build 2026-05-24 09:24:56 +02:00
Legrems e557fe2cda feat(market): add draft status and multiplier 2026-05-24 09:20:01 +02:00
Legrems 79e7cef3ba feat(market): resolve for admin 2026-05-23 20:33:21 +02:00
Legrems a264336bd8 feat(market): track user points change 2026-05-23 20:26:59 +02:00
Legrems 42e3571fab fix(market): better market card display 2026-05-23 20:06:17 +02:00
Legrems 43b314bb20 fix(openapi-ts): replace api call with generated api client 2026-05-23 19:51:08 +02:00
Legrems f1afb2096f fix(openapi-ts): first pass for replacing with openapi-ts 2026-05-23 18:53:33 +02:00
Legrems 62a81e57ad feat(openapi-ts): add generated api client 2026-05-23 18:50:26 +02:00
Legrems 303b9e1c8a feat(frontend): add openapi-ts 2026-05-23 18:50:19 +02:00
Legrems f7c7eba4da feat(market): basic page + submit 2026-05-23 18:30:50 +02:00
Legrems ce30539808 feat(market): base models 2026-05-23 17:48:27 +02:00
Legrems 544112b204 feat(games): add games to disable + path 2026-05-23 14:18:44 +02:00
Legrems 9f94fb3974 fix: add puzzle title in results page 2026-05-22 17:24:29 +02:00
Legrems 25072e8eb4 cache for result api 2026-05-22 17:21:53 +02:00
Legrems 9e5ab8539a top winners + by puzzle 2026-05-22 09:32:13 +02:00
Legrems 4ba6a48246 fix ranking order + display winning gifs 2026-05-22 06:32:18 +02:00
Legrems 92dddca964 tournament outputs 2026-05-22 06:18:11 +02:00
Legrems bf21a5eae6 fix(noita): better display name 2026-05-15 21:11:23 +02:00
Legrems 89faa7cd40 chore: build assets 2026-05-15 20:47:22 +02:00
Legrems a800c7fff7 fix(noita): all objectives display 2026-05-15 20:43:30 +02:00
Legrems 2ee8cd6be3 chore: vite build 2026-05-15 14:51:05 +02:00
Legrems 35824de310 admin display 2026-05-15 14:50:59 +02:00
Legrems 65cc2e555f fix: parse + store 2026-05-15 03:31:20 +02:00
Legrems 6d996a4e2f chore: vite build 2026-05-15 03:26:35 +02:00
Legrems f774ff3340 feat(opus): add coef subcards 2026-05-15 03:24:07 +02:00
Legrems 779393106d fix(opus): wrong frontend ordering points -> rank-points 2026-05-15 03:05:33 +02:00
Legrems 3e04f8312a feat(noita): death counter + ranking display 2026-05-15 02:15:00 +02:00
Legrems 7cfab20826 rework noita objectives + deathcounter 2026-05-15 01:50:34 +02:00
Legrems 754b0b0803 noita update 2026-05-10 23:09:42 +02:00
Legrems 79f469a393 chore: build vite assets 2026-05-10 04:01:57 +02:00
Legrems 5aeff9c218 feat(api): add cache + busting for admin 2026-05-10 04:00:53 +02:00
Legrems 2264401a91 fix: correct mime type for noita log file, opus card status display 2026-05-10 03:47:37 +02:00
Legrems 9662181d4d fix(cas): https in prod hihi 2026-05-10 03:28:13 +02:00
Legrems 90f1ce13cf fix: login with furl 2026-05-10 03:24:18 +02:00
Legrems f90377ac69 chore: remove building part in pyproject 2026-05-10 03:12:11 +02:00
Legrems d8d09c21d4 setup sentry 2026-05-10 03:11:10 +02:00
Legrems 3c86ca4c91 vite missing files 2026-05-10 03:05:45 +02:00
Legrems 0b138e315c vite assets build 2026-05-10 02:44:09 +02:00
Legrems d2a9dbe4a4 opus-magnum results view 2026-05-10 02:38:56 +02:00
Legrems fa53d74295 noita leaderboard + view + management commands 2026-05-10 02:11:30 +02:00
Legrems 52a6a4adb2 noita objectives + leaderboard 2026-05-10 02:01:10 +02:00
Legrems 69b6b46ee2 basic noita parsing 2026-05-10 01:45:29 +02:00
Legrems 119fdc2a51 basic noita submissions 2026-05-10 00:57:51 +02:00
Legrems 01b0dbd1d9 base for noita submitter 2026-05-10 00:36:15 +02:00
Legrems 19cc52c9f8 home page + opus result api working again 2026-05-10 00:17:20 +02:00
Legrems 07f5c76a52 opus-magnum split 2026-05-09 23:56:10 +02:00
Legrems 8584102402 remove not working endpoint 2026-05-09 23:42:26 +02:00
Legrems 404af4f90d chore: opus-submitter -> polylan-submitter 2026-05-09 23:25:34 +02:00
Legrems eb1eed852b chore: ruff format + fix 2026-05-09 22:13:40 +02:00
Legrems 11229a3906 frontend build 2025-11-28 14:35:20 +01:00
Legrems 38e2b5e858 remove custom manager on user 2025-11-28 14:34:29 +01:00
Legrems 1e4251c796 remove psycopg2, use psycopg 2025-11-28 14:25:59 +01:00
Legrems fa76fbce92 mutiple fixes 2025-11-28 14:05:26 +01:00
Legrems 9ee45463a8 better re-processing OCR for admin 2025-11-23 22:33:23 +01:00
Legrems 8f88548a59 single OCR thread in // 2025-11-23 12:20:59 +01:00
Legrems 012b72527b small ui fixes 2025-11-23 12:03:07 +01:00
Legrems 596731a8a7 auto-reload for admin + auto validation for all using python 2025-10-31 02:41:44 +01:00
Legrems e0ada1e26d add re-validation in python 2025-10-31 01:56:26 +01:00
Legrems f98145d6db some fixes 2025-10-31 01:05:57 +01:00
Legrems 0e1e77c2dd ruff + submissions validation 2025-10-30 14:43:19 +01:00
Legrems 15de496501 try for better ocr puzzle 2025-10-30 14:29:50 +01:00
Legrems 8960f551e6 add manual validation request on the submission form 2025-10-30 12:12:33 +01:00
Legrems 2260c7cc27 change for ocr confidence 2025-10-30 12:01:49 +01:00
Legrems b5f31a8c72 small ui tweaks 2025-10-30 11:32:00 +01:00
Legrems 6a882ce39a settingslocal 2025-10-29 04:18:19 +01:00
Legrems 6dcd0174f9 fix statics? 2025-10-29 04:12:53 +01:00
Legrems fdbd660d22 add gunicorn 2025-10-29 03:51:24 +01:00
446 changed files with 42288 additions and 3591 deletions
+23
View File
@@ -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
+2
View File
@@ -177,3 +177,5 @@ pyrightconfig.json
tags
media/
settingsLocal.py
deploy/docker/polylan-submitter.env
+75
View File
@@ -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"]
+73
View File
@@ -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.
+23
View File
@@ -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 "$@"
+44
View File
@@ -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=
+19
View File
@@ -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"
+43
View File
@@ -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
+125
View File
@@ -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
+39
View File
@@ -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
-24
View File
@@ -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?
-231
View File
@@ -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
-32
View File
@@ -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'
-6
View File
@@ -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()),
],
),
]
-3
View File
@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.
-3
View File
@@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.
-68
View File
@@ -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,
}
-16
View File
@@ -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()
-177
View File
@@ -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": False,
}
}
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"),
]
-44
View File
@@ -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
View File
@@ -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

-271
View File
@@ -1,271 +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 removed - using API data only
// 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)
}
const reloadPage = () => {
window.location.reload()
}
</script>
<template>
<div class="min-h-screen bg-base-200">
<!-- Header -->
<div class="navbar bg-base-100 shadow-lg">
<div class="container mx-auto">
<div class="flex-1">
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
</div>
<div class="flex-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="reloadPage" class="btn btn-sm btn-outline">
<i class="mdi mdi-refresh mr-1"></i>
Retry
</button>
</div>
<!-- Main Content -->
<div v-else class="space-y-8">
<!-- Collection Info -->
<div 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 } 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,128 +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>()
// Utility functions removed - not used in template
</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>
-5
View File
@@ -1,5 +0,0 @@
import { createApp } from 'vue'
import App from '@/App.vue'
import './style.css'
createApp(App).mount('#app')
-298
View File
@@ -1,298 +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 as any
});
}
/**
* 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
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();
-88
View File
@@ -1,88 +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
original_filename?: 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[]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-33
View File
@@ -1,33 +0,0 @@
{
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot": {
"file": "assets/materialdesignicons-webfont-CSr8KVlo.eot",
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.eot"
},
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf": {
"file": "assets/materialdesignicons-webfont-B7mPwVP_.ttf",
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf"
},
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff": {
"file": "assets/materialdesignicons-webfont-PXm3-2wK.woff",
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff"
},
"node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2": {
"file": "assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
"src": "node_modules/.pnpm/@mdi+font@7.4.47/node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2"
},
"src/main.ts": {
"file": "assets/main-XyhNv7AL.js",
"name": "main",
"src": "src/main.ts",
"isEntry": true,
"css": [
"assets/main-ClCUrjHA.css"
],
"assets": [
"assets/materialdesignicons-webfont-CSr8KVlo.eot",
"assets/materialdesignicons-webfont-Dp5v-WZN.woff2",
"assets/materialdesignicons-webfont-PXm3-2wK.woff",
"assets/materialdesignicons-webfont-B7mPwVP_.ttf"
]
}
}
-1
View File
@@ -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

-253
View File
@@ -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,
}
-6
View File
@@ -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'],
},
),
]
@@ -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),
),
]
-3
View File
@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.
-3
View File
@@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.
-15
View File
@@ -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
View File
@@ -1 +0,0 @@
{"root":["./src/main.ts","./src/services/apiService.ts","./src/services/ocrService.ts","./src/types/index.ts","./src/App.vue","./src/components/AdminPanel.vue","./src/components/FileUpload.vue","./src/components/PuzzleCard.vue","./src/components/SubmissionForm.vue"],"version":"5.9.3"}
-2
View File
@@ -1,2 +0,0 @@
declare const _default: import("vite").UserConfig;
export default _default;
-22
View File
@@ -1,22 +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/vite"),
rollupOptions: {
input: { main: resolve('./src/main.ts') }
}
}
});
-25
View File
@@ -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/vite"),
rollupOptions: {
input:
{ main: resolve('./src/main.ts') }
}
}
})
+36
View File
@@ -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?
+82
View File
@@ -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}).",
)
+26
View File
@@ -0,0 +1,26 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "accounts"
def ready(self):
# django-admin-interface's Theme.logo/favicon are plain FileFields,
# so without this they'd resolve through STORAGES["default"] (S3 -
# see settings.py) like every submission/media upload. The theme's
# logo/favicon (see accounts/fixtures/admin_interface_theme.json)
# are actually a static asset (static/favicon.ico, built from
# public/favicon.ico by Vite - see vite.config.ts), so point those
# two fields at STATIC_ROOT/STATIC_URL instead, served by WhiteNoise
# like any other static file.
from django.conf import settings
from django.core.files.storage import FileSystemStorage
from admin_interface.models import Theme
static_storage = FileSystemStorage(
location=settings.STATIC_ROOT, base_url=settings.STATIC_URL
)
Theme._meta.get_field("logo").storage = static_storage
Theme._meta.get_field("favicon").storage = static_storage
@@ -0,0 +1,60 @@
[
{
"model": "admin_interface.theme",
"pk": 2,
"fields": {
"name": "Django Purple (by Legrems)",
"active": true,
"title": "Super administrateur de poney ",
"title_color": "#E5B8FF",
"title_visible": true,
"logo": "favicon.ico",
"logo_color": "#831a96",
"logo_max_width": 400,
"logo_max_height": 50,
"logo_visible": true,
"favicon": "favicon.ico",
"env_name": "",
"env_color": "#831a96",
"env_visible_in_header": true,
"env_visible_in_favicon": true,
"language_chooser_active": true,
"language_chooser_control": "default-select",
"language_chooser_display": "code",
"css_header_background_color": "#2d0840",
"css_header_text_color": "#831a96",
"css_header_link_color": "#FFFFFF",
"css_header_link_hover_color": "#a82bb8",
"css_module_background_color": "#6b0f7d",
"css_module_background_selected_color": "#831a96",
"css_module_text_color": "#FFFFFF",
"css_module_link_color": "#FFFFFF",
"css_module_link_selected_color": "#FFFFFF",
"css_module_link_hover_color": "#a82bb8",
"css_module_rounded_corners": true,
"css_generic_link_color": "#2d0840",
"css_generic_link_hover_color": "#5a0d6b",
"css_save_button_background_color": "#2d0840",
"css_save_button_background_hover_color": "#5a0d6b",
"css_save_button_text_color": "#FFFFFF",
"css_delete_button_background_color": "#a82bb8",
"css_delete_button_background_hover_color": "#831a96",
"css_delete_button_text_color": "#FFFFFF",
"related_modal_active": true,
"related_modal_background_color": "#000000",
"related_modal_background_opacity": "0.3",
"related_modal_rounded_corners": true,
"related_modal_close_button_visible": true,
"list_filter_highlight": true,
"list_filter_dropdown": true,
"list_filter_sticky": true,
"list_filter_removal_links": false,
"foldable_apps": true,
"show_fieldsets_as_tabs": false,
"show_inlines_as_tabs": false,
"recent_actions_visible": true,
"form_submit_sticky": false,
"form_pagination_sticky": false
}
}
]
@@ -0,0 +1,137 @@
# Generated by Django 5.2.7 on 2025-10-28 23:41
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="CustomUser",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"cas_user_id",
models.CharField(blank=True, max_length=50, null=True, unique=True),
),
("cas_groups", models.JSONField(blank=True, default=list)),
("cas_attributes", models.JSONField(blank=True, default=dict)),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
]
@@ -0,0 +1,17 @@
# Generated by Django 5.2.7 on 2026-05-23 18:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="customuser",
name="points",
field=models.IntegerField(default=1000),
),
]
@@ -0,0 +1,21 @@
from django.core.management import call_command
from django.db import migrations
def load_admin_theme(apps, schema_editor):
# Seeds the default django-admin-interface theme (see
# accounts/fixtures/admin_interface_theme.json). Runs once, the first
# time this migration is applied, so it won't clobber a theme later
# edited by hand through the admin.
call_command("loaddata", "admin_interface_theme.json")
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_customuser_points"),
("admin_interface", "0032_alter_theme_defaults"),
]
operations = [
migrations.RunPython(load_admin_theme, migrations.RunPython.noop),
]
@@ -1,6 +1,5 @@
from django.contrib.auth.models import AbstractUser
from django.db import models
import json
class CustomUser(AbstractUser):
@@ -15,6 +14,9 @@ class CustomUser(AbstractUser):
# Additional fields that might come from CAS
cas_attributes = models.JSONField(default=dict, blank=True)
# Market points balance
points = models.IntegerField(default=1000)
def __str__(self):
return f"{self.username} ({self.cas_user_id})"
@@ -57,4 +59,3 @@ class CustomUser(AbstractUser):
self.cas_attributes = attributes
self.save()
+1
View File
@@ -0,0 +1 @@
# Create your tests here.
+1
View File
@@ -0,0 +1 @@
# Create your views here.
+59
View File
@@ -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",),
},
),
)
+247
View File
@@ -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
+6
View File
@@ -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),
),
]
+24
View File
@@ -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)
+115
View File
@@ -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]
+1
View File
@@ -0,0 +1 @@
# Create your tests here.
+1
View File
@@ -0,0 +1 @@
# Create your views here.
+23
View File
@@ -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")
+79
View File
@@ -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,
}
+9
View File
@@ -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
+3
View File
@@ -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}"
+154
View File
@@ -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"],
},
),
]
+81
View File
@@ -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})"
+7
View File
@@ -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()),
]
+20
View File
@@ -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
+38
View File
@@ -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())
)
+12
View File
@@ -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()
+89
View File
@@ -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)
+57
View File
@@ -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))
+33
View File
@@ -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)
+13
View File
@@ -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"]
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class EventsConfig(AppConfig):
name = "events"
+30
View File
@@ -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,
},
),
]

Some files were not shown because too many files have changed in this diff Show More