Compare commits
8
Commits
59b568ff41
...
b837abdba0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b837abdba0
|
||
|
|
4fd683ff2c
|
||
|
|
8509be548e
|
||
|
|
382ba01f3e
|
||
|
|
6a5a224d3b
|
||
|
|
fdf84f9524
|
||
|
|
e8b018ec49
|
||
|
|
b74d20cbe0
|
+1
-1
@@ -20,4 +20,4 @@ polylan_submitter/db.sqlite3
|
|||||||
polylan_submitter/.vscode
|
polylan_submitter/.vscode
|
||||||
polylan_submitter/settingsLocal.py
|
polylan_submitter/settingsLocal.py
|
||||||
|
|
||||||
deploy/supervisor/polylan-submitter.env
|
deploy/docker/polylan-submitter.env
|
||||||
|
|||||||
+1
-1
@@ -178,4 +178,4 @@ pyrightconfig.json
|
|||||||
tags
|
tags
|
||||||
media/
|
media/
|
||||||
settingsLocal.py
|
settingsLocal.py
|
||||||
deploy/supervisor/polylan-submitter.env
|
deploy/docker/polylan-submitter.env
|
||||||
|
|||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
# Single OCI image bundling the Vue/Vite frontend, the Django/Channels web
|
# Single OCI image bundling the Vue/Vite frontend, the Django/Channels web
|
||||||
# process (daphne) and the RQ worker, managed together by supervisord - see
|
# process (daphne) and the RQ worker, managed together by supervisord - see
|
||||||
# deploy/docker/README.md for why (single-container Proxmox deploy) and how
|
# deploy/docker/README.md for why (single-container Proxmox deploy) and how
|
||||||
# to run it. Postgres, Redis and S3 stay external, configured the same way
|
# to run it. Postgres, Redis and S3 stay external (see
|
||||||
# as the bare-metal deploy in deploy/ (see deploy/supervisor/*.env.dist for
|
# deploy/docker/polylan-submitter.env.dist for the full env var list) - this
|
||||||
# the full env var list) - this image carries no infra, just the app.
|
# image carries no infra, just the app.
|
||||||
#
|
#
|
||||||
# Build (from repo root, requires BuildKit/buildx - RUN --mount=type=cache
|
# Build (from repo root, requires BuildKit/buildx - RUN --mount=type=cache
|
||||||
# below is rejected by the legacy builder):
|
# below is rejected by the legacy builder):
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Deploys polylan-submitter from /opt/polylan-submitter: pulls latest master,
|
|
||||||
# syncs the venv, renders and installs the supervisor config, restarts the
|
|
||||||
# supervisor programs, installs the (static) HAProxy config, reloads
|
|
||||||
# HAProxy, then re-adds the backend servers over HAProxy's admin socket -
|
|
||||||
# a reload always resets HAProxy back to just the one static server in
|
|
||||||
# deploy/haproxy/haproxy.cfg, so the rest get added fresh every time.
|
|
||||||
#
|
|
||||||
# Run on the target host (needs sudo for cp/supervisorctl/systemctl/socat):
|
|
||||||
# deploy/deploy.sh
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
APP_DIR=/opt/polylan-submitter
|
|
||||||
RUN_DIR="$APP_DIR/run"
|
|
||||||
SUPERVISOR_CONF_DIR=/etc/supervisor/conf.d
|
|
||||||
HAPROXY_CONF=/etc/haproxy/haproxy.cfg
|
|
||||||
HAPROXY_ADMIN_SOCK=/run/haproxy/admin.sock
|
|
||||||
HAPROXY_BACKEND=polylan_submitter_web
|
|
||||||
|
|
||||||
# Single source of truth for daphne process count: drives supervisor's
|
|
||||||
# numprocs, and how many backend servers get re-added to HAProxy below
|
|
||||||
# (server 00 is already static in haproxy.cfg, so only 01..N-1 are added).
|
|
||||||
NUM_WEB_WORKERS=5
|
|
||||||
|
|
||||||
cd "$APP_DIR"
|
|
||||||
git pull --ff-only
|
|
||||||
uv sync --frozen
|
|
||||||
|
|
||||||
sudo mkdir -p "$RUN_DIR"
|
|
||||||
sudo chown polylan:polylan "$RUN_DIR"
|
|
||||||
|
|
||||||
render_dir=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$render_dir"' EXIT
|
|
||||||
|
|
||||||
sed "s/__NUM_WEB_WORKERS__/$NUM_WEB_WORKERS/" \
|
|
||||||
"$APP_DIR/deploy/supervisor/polylan-submitter.conf" > "$render_dir/polylan-submitter.conf"
|
|
||||||
sudo cp "$render_dir/polylan-submitter.conf" "$SUPERVISOR_CONF_DIR/polylan-submitter.conf"
|
|
||||||
|
|
||||||
sudo supervisorctl reread
|
|
||||||
sudo supervisorctl update
|
|
||||||
sudo supervisorctl restart polylan-submitter:
|
|
||||||
|
|
||||||
sudo mkdir -p "$(dirname "$HAPROXY_ADMIN_SOCK")"
|
|
||||||
sudo cp "$APP_DIR/deploy/haproxy/haproxy.cfg" "$HAPROXY_CONF"
|
|
||||||
sudo haproxy -c -f "$HAPROXY_CONF"
|
|
||||||
sudo systemctl reload haproxy
|
|
||||||
|
|
||||||
# The admin socket only processes one command per connection - sending
|
|
||||||
# several commands into one socat session silently drops everything after
|
|
||||||
# the first, so each command below gets its own connection.
|
|
||||||
for ((i = 1; i < NUM_WEB_WORKERS; i++)); do
|
|
||||||
printf -v socket_num "%02d" "$i"
|
|
||||||
server="${HAPROXY_BACKEND}/web-${socket_num}"
|
|
||||||
socket="unix@${RUN_DIR}/web-${socket_num}.sock"
|
|
||||||
echo "add server ${server} ${socket} check" | sudo socat - "UNIX-CONNECT:${HAPROXY_ADMIN_SOCK}"
|
|
||||||
echo "enable server ${server}" | sudo socat - "UNIX-CONNECT:${HAPROXY_ADMIN_SOCK}"
|
|
||||||
done
|
|
||||||
+13
-11
@@ -9,12 +9,11 @@ one container:
|
|||||||
- `worker` - two `rqworker` processes for background jobs (submission OCR,
|
- `worker` - two `rqworker` processes for background jobs (submission OCR,
|
||||||
AAndT sync, etc.)
|
AAndT sync, etc.)
|
||||||
|
|
||||||
Postgres, Redis and S3-compatible storage are **not** bundled - the image
|
Postgres, Redis and S3-compatible storage are **not** bundled (see
|
||||||
expects them the same way the bare-metal deploy does (see
|
`deploy/docker/polylan-submitter.env.dist` for the full list of env vars:
|
||||||
`deploy/supervisor/polylan-submitter.env.dist` for the full list of env
|
`POSTGRES_*`, `REDIS_HOST`/`REDIS_PORT`, `AWS_*`, `DJANGO_SECRET_KEY`, etc.).
|
||||||
vars: `POSTGRES_*`, `REDIS_HOST`/`REDIS_PORT`, `AWS_*`, `DJANGO_SECRET_KEY`,
|
Point those at wherever those services already live (other containers/LXCs,
|
||||||
etc.). Point those at wherever those services already live (other
|
or existing infra).
|
||||||
containers/LXCs, or existing infra).
|
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@ docker buildx build --load -t polylan-submitter .
|
|||||||
```sh
|
```sh
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name polylan-submitter \
|
--name polylan-submitter \
|
||||||
--env-file deploy/supervisor/polylan-submitter.env \
|
--env-file deploy/docker/polylan-submitter.env \
|
||||||
-p 8000:8000 \
|
-p 8000:8000 \
|
||||||
polylan-submitter
|
polylan-submitter
|
||||||
```
|
```
|
||||||
@@ -41,15 +40,18 @@ shared S3 bucket, so run it once per deploy instead of racing multiple
|
|||||||
replicas on every boot:
|
replicas on every boot:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run --rm --env-file deploy/supervisor/polylan-submitter.env \
|
docker run --rm --env-file deploy/docker/polylan-submitter.env \
|
||||||
polylan-submitter python manage.py collectstatic --noinput
|
polylan-submitter python manage.py collectstatic --noinput
|
||||||
```
|
```
|
||||||
|
|
||||||
## Scaling
|
## Scaling
|
||||||
|
|
||||||
There's one `daphne` process per container (unlike the bare-metal deploy's
|
There's one `daphne` process per container - it binds the only port the
|
||||||
`NUM_WEB_WORKERS` behind HAProxy) - scale by running multiple container
|
container exposes, so running more of them would need a reverse proxy
|
||||||
replicas behind an external reverse proxy/load balancer instead.
|
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
|
## Deploying to Proxmox
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
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=
|
||||||
|
|
||||||
|
MARKET_ENABLED=true
|
||||||
|
STEAM_API_KEY=
|
||||||
|
AANDT_API_BASE_URL=
|
||||||
|
SUBMITTER_BASE_URL=
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
; Runs inside the single app container as the non-root "app" user (see
|
; 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
|
; Dockerfile's USER app - no [supervisord] user= needed, we never start as
|
||||||
; root here). Unlike deploy/supervisor/polylan-submitter.conf (multiple
|
; root here). This container runs ONE daphne process on TCP: scale by
|
||||||
; daphne processes behind HAProxy on one bare-metal host), this container
|
; running more container replicas behind an external reverse proxy/load
|
||||||
; runs ONE daphne process on TCP: scale by running more container replicas
|
; balancer instead of more processes per container.
|
||||||
; 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
|
; 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
|
; own log rotation for them) so `docker logs` / Proxmox's log viewer picks
|
||||||
@@ -29,7 +27,6 @@ stdout_logfile_maxbytes=0
|
|||||||
stderr_logfile=/dev/stderr
|
stderr_logfile=/dev/stderr
|
||||||
stderr_logfile_maxbytes=0
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
; RQ workers - same command as deploy/supervisor/polylan-submitter.conf.
|
|
||||||
[program:worker]
|
[program:worker]
|
||||||
command=/app/.venv/bin/python manage.py rqworker default --job-class django_tasks_rq.Job --with-scheduler
|
command=/app/.venv/bin/python manage.py rqworker default --job-class django_tasks_rq.Job --with-scheduler
|
||||||
directory=/app/polylan_submitter
|
directory=/app/polylan_submitter
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
# HAProxy config for polylan-submitter. Plain HTTP only - TLS is terminated
|
|
||||||
# upstream of this box, so nothing here handles certs or :443.
|
|
||||||
#
|
|
||||||
# Unlike nginx/Caddy's per-site snippet directories, HAProxy's default
|
|
||||||
# packaging expects one single /etc/haproxy/haproxy.cfg for the whole
|
|
||||||
# process, so this file IS the full config - deploy.sh copies it wholesale
|
|
||||||
# rather than dropping a fragment into an "enabled" dir. If this box also
|
|
||||||
# runs other things through HAProxy, fold this frontend/backend into your
|
|
||||||
# existing haproxy.cfg by hand instead of letting deploy.sh clobber it.
|
|
||||||
#
|
|
||||||
# This is no longer a template: it's a plain, ready-to-use config with one
|
|
||||||
# statically defined backend server (web-00, the first daphne socket), so
|
|
||||||
# the backend is never empty right after a reload - traffic has somewhere to
|
|
||||||
# go immediately. deploy.sh adds the remaining NUM_WEB_WORKERS-1 servers over
|
|
||||||
# the admin socket below (`add server` + `enable server`) every time it
|
|
||||||
# reloads HAProxy, since a reload always resets the running process back to
|
|
||||||
# exactly what's in this file. Deploy sequence is always: cp this file ->
|
|
||||||
# reload -> re-add the rest of the servers.
|
|
||||||
#
|
|
||||||
# WebSocket (submissions/clock/place) needs no special handling here: modern
|
|
||||||
# HAProxy in `mode http` detects the 101 Switching Protocols response and then
|
|
||||||
# just relays bytes both ways for the rest of that connection - that only
|
|
||||||
# works because keep-alive is the default connection mode below (no
|
|
||||||
# `option http-server-close` / `httpclose`, either of which would break it).
|
|
||||||
# Static files are served in-process by WhiteNoise (see MIDDLEWARE in
|
|
||||||
# settings.py), so nothing else needs to be carved out here either.
|
|
||||||
|
|
||||||
global
|
|
||||||
log /dev/log local0
|
|
||||||
maxconn 2000
|
|
||||||
stats socket /run/haproxy/admin.sock mode 660 level admin
|
|
||||||
|
|
||||||
defaults
|
|
||||||
mode http
|
|
||||||
log global
|
|
||||||
option httplog
|
|
||||||
timeout connect 5s
|
|
||||||
timeout client 60s
|
|
||||||
timeout server 60s
|
|
||||||
|
|
||||||
frontend polylan_submitter_http
|
|
||||||
bind *:7777
|
|
||||||
default_backend polylan_submitter_web
|
|
||||||
|
|
||||||
backend polylan_submitter_web
|
|
||||||
balance roundrobin
|
|
||||||
server web-00 unix@/opt/polylan-submitter/run/web-00.sock check
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# Supervisor config template for polylan-submitter.
|
|
||||||
#
|
|
||||||
# This is a template, not a ready-to-use file: __NUM_WEB_WORKERS__ is filled
|
|
||||||
# in by deploy.sh (the same number it also uses to re-add HAProxy backend
|
|
||||||
# servers after each reload - see deploy/haproxy/haproxy.cfg), which then
|
|
||||||
# installs the rendered result into /etc/supervisor/conf.d/ and
|
|
||||||
# reread/updates supervisor. Run deploy.sh rather than copying this file by
|
|
||||||
# hand.
|
|
||||||
#
|
|
||||||
# Assumes the app is checked out at /opt/polylan-submitter with a venv at
|
|
||||||
# /opt/polylan-submitter/.venv, and a "polylan" system user owning it
|
|
||||||
# (adduser --system --group polylan). Adjust to taste.
|
|
||||||
#
|
|
||||||
# App secrets/config (DJANGO_SECRET_KEY, POSTGRES_*, AWS_*, etc. - see
|
|
||||||
# settings.py for the full list, polylan-submitter.env.dist for the file
|
|
||||||
# template) are NOT listed in the `environment=` lines below - only
|
|
||||||
# SETTINGS_ENV_FILE_LOCATION is, pointing settings.py at the file so it can
|
|
||||||
# load_dotenv() it itself. That's the only var that needs to reach the
|
|
||||||
# process from here at all: it's just a path, not a secret, so unlike the
|
|
||||||
# ~19 real secrets it's safe to write directly into this tracked file, and
|
|
||||||
# there's no %(ENV_x)s crash risk since it's a literal string, not a
|
|
||||||
# pass-through. Rotating a secret just means editing the env file and
|
|
||||||
# running `supervisorctl restart polylan-submitter:` (which deploy.sh
|
|
||||||
# already does on every deploy) - dotenv is read fresh on every process
|
|
||||||
# start, no systemd/supervisord-wide restart involved.
|
|
||||||
|
|
||||||
[group:polylan-submitter]
|
|
||||||
programs=polylan-submitter-web,polylan-submitter-worker
|
|
||||||
|
|
||||||
; Daphne has no built-in multi-worker/--workers flag (single Twisted reactor
|
|
||||||
; per process), so "more workers" means more daphne processes. Each one binds
|
|
||||||
; its own unix socket under run/; HAProxy load-balances across all of them
|
|
||||||
; (see deploy/haproxy/haproxy.cfg). They share state fine since channel
|
|
||||||
; layers, sessions, cache, etc. all already go through Redis/Postgres rather
|
|
||||||
; than in-process memory. __NUM_WEB_WORKERS__ comes from deploy.sh's
|
|
||||||
; NUM_WEB_WORKERS - change it there, not here.
|
|
||||||
[program:polylan-submitter-web]
|
|
||||||
command=/opt/polylan-submitter/.venv/bin/daphne -u /opt/polylan-submitter/run/web-%(process_num)02d.sock polylan_submitter.asgi:application
|
|
||||||
directory=/opt/polylan-submitter/polylan_submitter
|
|
||||||
process_name=%(program_name)s-%(process_num)02d
|
|
||||||
user=polylan
|
|
||||||
numprocs=__NUM_WEB_WORKERS__
|
|
||||||
autostart=true
|
|
||||||
autorestart=true
|
|
||||||
stopasgroup=true
|
|
||||||
killasgroup=true
|
|
||||||
stopsignal=TERM
|
|
||||||
stdout_logfile=/var/log/supervisor/polylan-submitter-web-%(process_num)02d.log
|
|
||||||
stderr_logfile=/var/log/supervisor/polylan-submitter-web-%(process_num)02d.log
|
|
||||||
environment=DJANGO_SETTINGS_MODULE="polylan_submitter.settings",SETTINGS_ENV_FILE_LOCATION="/opt/polylan-submitter/deploy/supervisor/polylan-submitter.env"
|
|
||||||
|
|
||||||
; RQ workers can have multiple `--with-scheduler`, there is a redis-lock
|
|
||||||
[program:polylan-submitter-worker]
|
|
||||||
command=/opt/polylan-submitter/.venv/bin/python manage.py rqworker default --job-class django_tasks_rq.Job --with-scheduler
|
|
||||||
directory=/opt/polylan-submitter/polylan_submitter
|
|
||||||
process_name=%(program_name)s-%(process_num)02d
|
|
||||||
user=polylan
|
|
||||||
numprocs=2
|
|
||||||
autostart=true
|
|
||||||
autorestart=true
|
|
||||||
stopasgroup=true
|
|
||||||
killasgroup=true
|
|
||||||
stopsignal=TERM
|
|
||||||
stdout_logfile=/var/log/supervisor/polylan-submitter-worker-%(process_num)02d.log
|
|
||||||
stderr_logfile=/var/log/supervisor/polylan-submitter-worker-%(process_num)02d.log
|
|
||||||
environment=DJANGO_SETTINGS_MODULE="polylan_submitter.settings",SETTINGS_ENV_FILE_LOCATION="/opt/polylan-submitter/deploy/supervisor/polylan-submitter.env"
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# Template for deploy/supervisor/polylan-submitter.env inside the app
|
|
||||||
# checkout (/opt/polylan-submitter/deploy/supervisor/polylan-submitter.env on
|
|
||||||
# the target host) - that path is in .gitignore, same treatment as
|
|
||||||
# settingsLocal.py, so `git pull` never touches or overwrites it once it's
|
|
||||||
# there.
|
|
||||||
#
|
|
||||||
# Copy this file to that path, fill in real values, then:
|
|
||||||
# sudo chown polylan:polylan /opt/polylan-submitter/deploy/supervisor/polylan-submitter.env
|
|
||||||
# sudo chmod 600 /opt/polylan-submitter/deploy/supervisor/polylan-submitter.env
|
|
||||||
# (owned by "polylan", not root: this gets read by settings.py itself, which
|
|
||||||
# runs as the "polylan" user - see `user=` in polylan-submitter.conf. 600
|
|
||||||
# still keeps every other user out.)
|
|
||||||
#
|
|
||||||
# settings.py loads this via python-dotenv (see SETTINGS_ENV_FILE_LOCATION
|
|
||||||
# in polylan-submitter.conf's `environment=`, and the load_dotenv() call
|
|
||||||
# near the top of settings.py), not a shell - there's no $VAR expansion or
|
|
||||||
# shell-special-character risk here like a bash `source` would have. The one
|
|
||||||
# thing dotenv DOES expand is ${VAR_NAME} (braced, referencing another var
|
|
||||||
# already defined above it in this same file) - if a real secret value ever
|
|
||||||
# happens to literally contain that exact pattern, quote it (single or
|
|
||||||
# double quotes both suppress dotenv's own expansion and get stripped).
|
|
||||||
# Plain values like the ones below need no quoting at all.
|
|
||||||
#
|
|
||||||
# load_dotenv() is called with override=False, so an already-exported env
|
|
||||||
# var always wins over what's in this file - it only fills gaps. It's also
|
|
||||||
# read fresh on every process start (not cached in some long-lived daemon),
|
|
||||||
# so rotating a secret just means editing this file and running
|
|
||||||
# `sudo supervisorctl restart polylan-submitter:` - which deploy.sh already
|
|
||||||
# does on every routine deploy anyway.
|
|
||||||
#
|
|
||||||
# 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
|
|
||||||
|
|
||||||
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=
|
|
||||||
|
|
||||||
MARKET_ENABLED=true
|
|
||||||
STEAM_API_KEY=
|
|
||||||
AANDT_API_BASE_URL=
|
|
||||||
SUBMITTER_BASE_URL=
|
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
|
from django.conf import settings
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from django_object_actions import DjangoObjectActions, action
|
||||||
|
|
||||||
from .models import CustomUser
|
from .models import CustomUser
|
||||||
|
|
||||||
|
# Must match loadtest/locustfile.py's own defaults (LOAD_TEST_USER_COUNT,
|
||||||
|
# LOAD_TEST_USER_PREFIX, LOAD_TEST_PASSWORD) - see that file for what these
|
||||||
|
# accounts are for and how they're used.
|
||||||
|
LOAD_TEST_USER_COUNT = 500
|
||||||
|
LOAD_TEST_USER_PREFIX = "loadtest"
|
||||||
|
LOAD_TEST_PASSWORD = "loadtest-only-password"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CustomUser)
|
@admin.register(CustomUser)
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(DjangoObjectActions, UserAdmin):
|
||||||
"""Admin interface for CustomUser."""
|
"""Admin interface for CustomUser."""
|
||||||
|
|
||||||
# Add custom fields to the user admin
|
# Add custom fields to the user admin
|
||||||
@@ -29,6 +39,8 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
# Make CAS fields readonly in admin
|
# Make CAS fields readonly in admin
|
||||||
readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes")
|
readonly_fields = ("cas_user_id", "cas_groups", "cas_attributes")
|
||||||
|
|
||||||
|
changelist_actions = ("create_load_test_users",)
|
||||||
|
|
||||||
def get_cas_groups_display(self, obj):
|
def get_cas_groups_display(self, obj):
|
||||||
"""Display CAS groups in admin list."""
|
"""Display CAS groups in admin list."""
|
||||||
return obj.get_cas_groups_display()
|
return obj.get_cas_groups_display()
|
||||||
@@ -37,3 +49,34 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
|
|
||||||
def has_delete_permission(self, request, obj=None):
|
def has_delete_permission(self, request, obj=None):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def get_changelist_actions(self, request):
|
||||||
|
# Second layer of the same guard rail as the login endpoint itself
|
||||||
|
# (see testing_auth_views.py): the button doesn't even show up
|
||||||
|
# unless this deployment opted into load testing.
|
||||||
|
if not settings.LOAD_TEST_LOGIN_ENABLED:
|
||||||
|
return []
|
||||||
|
return super().get_changelist_actions(request)
|
||||||
|
|
||||||
|
@action(
|
||||||
|
label="Create load test users",
|
||||||
|
description=(
|
||||||
|
"Create/reset the local loadtestN accounts used by "
|
||||||
|
"loadtest/locustfile.py - authenticates via ModelBackend, "
|
||||||
|
"never the real CAS server"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def create_load_test_users(self, request, _queryset):
|
||||||
|
created = 0
|
||||||
|
for i in range(LOAD_TEST_USER_COUNT):
|
||||||
|
username = f"{LOAD_TEST_USER_PREFIX}{i}"
|
||||||
|
user, was_created = CustomUser.objects.get_or_create(username=username)
|
||||||
|
user.set_password(LOAD_TEST_PASSWORD)
|
||||||
|
user.save()
|
||||||
|
created += was_created
|
||||||
|
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"{created} created, {LOAD_TEST_USER_COUNT - created} already existed "
|
||||||
|
f"(password reset on all {LOAD_TEST_USER_COUNT}).",
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,3 +4,23 @@ from django.apps import AppConfig
|
|||||||
class AccountsConfig(AppConfig):
|
class AccountsConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "accounts"
|
name = "accounts"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
# django-admin-interface's Theme.logo/favicon are plain FileFields,
|
||||||
|
# so without this they'd resolve through STORAGES["default"] (S3 -
|
||||||
|
# see settings.py) like every submission/media upload. The theme's
|
||||||
|
# logo/favicon (see accounts/fixtures/admin_interface_theme.json)
|
||||||
|
# are actually a static asset (static/favicon.ico, built from
|
||||||
|
# public/favicon.ico by Vite - see vite.config.ts), so point those
|
||||||
|
# two fields at STATIC_ROOT/STATIC_URL instead, served by WhiteNoise
|
||||||
|
# like any other static file.
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.files.storage import FileSystemStorage
|
||||||
|
|
||||||
|
from admin_interface.models import Theme
|
||||||
|
|
||||||
|
static_storage = FileSystemStorage(
|
||||||
|
location=settings.STATIC_ROOT, base_url=settings.STATIC_URL
|
||||||
|
)
|
||||||
|
Theme._meta.get_field("logo").storage = static_storage
|
||||||
|
Theme._meta.get_field("favicon").storage = static_storage
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"model": "admin_interface.theme",
|
||||||
|
"pk": 2,
|
||||||
|
"fields": {
|
||||||
|
"name": "Django Purple (by Legrems)",
|
||||||
|
"active": true,
|
||||||
|
"title": "Super administrateur de poney ",
|
||||||
|
"title_color": "#E5B8FF",
|
||||||
|
"title_visible": true,
|
||||||
|
"logo": "favicon.ico",
|
||||||
|
"logo_color": "#831a96",
|
||||||
|
"logo_max_width": 400,
|
||||||
|
"logo_max_height": 50,
|
||||||
|
"logo_visible": true,
|
||||||
|
"favicon": "favicon.ico",
|
||||||
|
"env_name": "",
|
||||||
|
"env_color": "#831a96",
|
||||||
|
"env_visible_in_header": true,
|
||||||
|
"env_visible_in_favicon": true,
|
||||||
|
"language_chooser_active": true,
|
||||||
|
"language_chooser_control": "default-select",
|
||||||
|
"language_chooser_display": "code",
|
||||||
|
"css_header_background_color": "#2d0840",
|
||||||
|
"css_header_text_color": "#831a96",
|
||||||
|
"css_header_link_color": "#FFFFFF",
|
||||||
|
"css_header_link_hover_color": "#a82bb8",
|
||||||
|
"css_module_background_color": "#6b0f7d",
|
||||||
|
"css_module_background_selected_color": "#831a96",
|
||||||
|
"css_module_text_color": "#FFFFFF",
|
||||||
|
"css_module_link_color": "#FFFFFF",
|
||||||
|
"css_module_link_selected_color": "#FFFFFF",
|
||||||
|
"css_module_link_hover_color": "#a82bb8",
|
||||||
|
"css_module_rounded_corners": true,
|
||||||
|
"css_generic_link_color": "#2d0840",
|
||||||
|
"css_generic_link_hover_color": "#5a0d6b",
|
||||||
|
"css_save_button_background_color": "#2d0840",
|
||||||
|
"css_save_button_background_hover_color": "#5a0d6b",
|
||||||
|
"css_save_button_text_color": "#FFFFFF",
|
||||||
|
"css_delete_button_background_color": "#a82bb8",
|
||||||
|
"css_delete_button_background_hover_color": "#831a96",
|
||||||
|
"css_delete_button_text_color": "#FFFFFF",
|
||||||
|
"related_modal_active": true,
|
||||||
|
"related_modal_background_color": "#000000",
|
||||||
|
"related_modal_background_opacity": "0.3",
|
||||||
|
"related_modal_rounded_corners": true,
|
||||||
|
"related_modal_close_button_visible": true,
|
||||||
|
"list_filter_highlight": true,
|
||||||
|
"list_filter_dropdown": true,
|
||||||
|
"list_filter_sticky": true,
|
||||||
|
"list_filter_removal_links": false,
|
||||||
|
"foldable_apps": true,
|
||||||
|
"show_fieldsets_as_tabs": false,
|
||||||
|
"show_inlines_as_tabs": false,
|
||||||
|
"recent_actions_visible": true,
|
||||||
|
"form_submit_sticky": false,
|
||||||
|
"form_pagination_sticky": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from django.core.management import call_command
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def load_admin_theme(apps, schema_editor):
|
||||||
|
# Seeds the default django-admin-interface theme (see
|
||||||
|
# accounts/fixtures/admin_interface_theme.json). Runs once, the first
|
||||||
|
# time this migration is applied, so it won't clobber a theme later
|
||||||
|
# edited by hand through the admin.
|
||||||
|
call_command("loaddata", "admin_interface_theme.json")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0002_customuser_points"),
|
||||||
|
("admin_interface", "0032_alter_theme_defaults"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(load_admin_theme, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ class ClockTimer(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class Press(models.Model):
|
class Press(models.Model):
|
||||||
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="clock_presses")
|
event = models.ForeignKey(
|
||||||
|
Event, on_delete=models.CASCADE, related_name="clock_presses"
|
||||||
|
)
|
||||||
player = models.ForeignKey(
|
player = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="clock_presses"
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="clock_presses"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""Locust load test for the place game.
|
||||||
|
|
||||||
|
Simulates real players: log in, fetch the canvas, open the same websocket
|
||||||
|
the frontend uses, and place a pixel as soon as the per-user cooldown
|
||||||
|
allows - while a background greenlet keeps draining the broadcast stream so
|
||||||
|
each idle connection costs what a real one costs.
|
||||||
|
|
||||||
|
A placement that comes back rejected or errors out (timeout, dropped
|
||||||
|
connection, ...) has a configurable chance of triggering a "reload" -
|
||||||
|
tearing the socket down and redoing the page load (HTML + canvas fetch +
|
||||||
|
fresh websocket), the way a real, mildly frustrated player mashes refresh
|
||||||
|
instead of quietly waiting. That reconnect-storm behavior - many clients
|
||||||
|
reloading around the same time - is itself part of what's worth measuring
|
||||||
|
under load, not just steady-state placement throughput.
|
||||||
|
|
||||||
|
Authentication uses local Django accounts (ModelBackend), never the real
|
||||||
|
CAS server at polylan.ch - see testing_auth_views.py. This ONLY works
|
||||||
|
against a target where LOAD_TEST_LOGIN_ENABLED=true; never point it at
|
||||||
|
production.
|
||||||
|
|
||||||
|
Setup, on the target server:
|
||||||
|
export LOAD_TEST_LOGIN_ENABLED=true
|
||||||
|
Then, in the Django admin, open Accounts > Users and click "Create load
|
||||||
|
test users" (see accounts/admin.py) - it's only visible with the env
|
||||||
|
var above set. Creates/resets loadtest0..loadtest499.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
uv run locust -f loadtest/locustfile.py --host http://localhost:7777
|
||||||
|
|
||||||
|
Env vars:
|
||||||
|
PLACE_EVENT_PATH event slug under test (default: "loadtest")
|
||||||
|
LOAD_TEST_USER_COUNT must match accounts/admin.py's LOAD_TEST_USER_COUNT (default: 500)
|
||||||
|
LOAD_TEST_USER_PREFIX must match accounts/admin.py's LOAD_TEST_USER_PREFIX (default: "loadtest")
|
||||||
|
LOAD_TEST_PASSWORD must match accounts/admin.py's LOAD_TEST_PASSWORD (default: "loadtest-only-password")
|
||||||
|
RELOAD_ON_FAILURE_PROBABILITY chance a failed placement reloads instead
|
||||||
|
of just waiting for the next cooldown
|
||||||
|
(default: 0.7)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
|
||||||
|
import gevent
|
||||||
|
import websocket
|
||||||
|
from gevent.queue import Empty, Queue
|
||||||
|
from locust import HttpUser, between, events, task
|
||||||
|
|
||||||
|
EVENT_PATH = os.environ.get("PLACE_EVENT_PATH", "loadtest")
|
||||||
|
USER_COUNT = int(os.environ.get("LOAD_TEST_USER_COUNT", "500"))
|
||||||
|
USER_PREFIX = os.environ.get("LOAD_TEST_USER_PREFIX", "loadtest")
|
||||||
|
PASSWORD = os.environ.get("LOAD_TEST_PASSWORD", "loadtest-only-password")
|
||||||
|
RELOAD_ON_FAILURE_PROBABILITY = float(
|
||||||
|
os.environ.get("RELOAD_ON_FAILURE_PROBABILITY", "0.7")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mirrors place/models.py::PALETTE - kept as a plain constant here since
|
||||||
|
# loadtest runs standalone, without Django settings loaded.
|
||||||
|
PALETTE_SIZE = 16
|
||||||
|
|
||||||
|
CANVAS_HEADER = struct.Struct("<HH")
|
||||||
|
|
||||||
|
_next_account_index = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _next_username() -> str:
|
||||||
|
"""Round-robins through the pool of pre-created accounts so N Locust
|
||||||
|
users spread evenly across USER_COUNT real accounts even when N differs
|
||||||
|
from USER_COUNT."""
|
||||||
|
global _next_account_index
|
||||||
|
username = f"{USER_PREFIX}{_next_account_index % USER_COUNT}"
|
||||||
|
_next_account_index += 1
|
||||||
|
return username
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceUser(HttpUser):
|
||||||
|
wait_time = between(1, 3)
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
self.username = _next_username()
|
||||||
|
self._login()
|
||||||
|
self.width, self.height = self._fetch_canvas_size()
|
||||||
|
self._connect()
|
||||||
|
|
||||||
|
def on_stop(self):
|
||||||
|
self._teardown_ws()
|
||||||
|
|
||||||
|
def _login(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/testing/login/",
|
||||||
|
data={"username": self.username, "password": PASSWORD},
|
||||||
|
name="/testing/login/",
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"login failed for {self.username}: {response.status_code} "
|
||||||
|
f"{response.text}. Is LOAD_TEST_LOGIN_ENABLED set on the "
|
||||||
|
"target, and did you run the 'Create load test users' admin "
|
||||||
|
"action (accounts/admin.py)?"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fetch_canvas_size(self) -> tuple[int, int]:
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/events/{EVENT_PATH}/place/canvas",
|
||||||
|
name="/api/events/[event]/place/canvas",
|
||||||
|
)
|
||||||
|
width, height = CANVAS_HEADER.unpack_from(response.content, 0)
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
def _connect(self):
|
||||||
|
"""(Re)opens the websocket and a fresh reader greenlet, then syncs
|
||||||
|
next_placement_at to the server's own cooldown clock - every
|
||||||
|
connection starts with an 's|<next>|...' message (see
|
||||||
|
place/consumers.py::_initial_state) - rather than trusting local
|
||||||
|
bookkeeping. That sync is exactly what makes a page reload
|
||||||
|
meaningful to simulate: it re-anchors client state to the server's
|
||||||
|
truth instead of just reopening a blank socket."""
|
||||||
|
self.ws = self._open_ws()
|
||||||
|
self.next_placement_at = self._read_initial_next_placement_at()
|
||||||
|
self.reply_queue = Queue()
|
||||||
|
self._reader = gevent.spawn(self._reader_loop)
|
||||||
|
|
||||||
|
def _teardown_ws(self):
|
||||||
|
reader = getattr(self, "_reader", None)
|
||||||
|
if reader is not None:
|
||||||
|
reader.kill(block=False)
|
||||||
|
ws = getattr(self, "ws", None)
|
||||||
|
if ws is not None:
|
||||||
|
try:
|
||||||
|
ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _reload(self):
|
||||||
|
"""What a real player does after a placement fails: hit refresh.
|
||||||
|
Tears the socket down and redoes the page load over HTTP plus a
|
||||||
|
brand-new websocket - the same reconnect churn a real outage or
|
||||||
|
rejection wave would cause."""
|
||||||
|
self._teardown_ws()
|
||||||
|
self.client.get(f"/{EVENT_PATH}/place", name="/[event]/place (reload)")
|
||||||
|
self.width, self.height = self._fetch_canvas_size()
|
||||||
|
self._connect()
|
||||||
|
|
||||||
|
def _open_ws(self):
|
||||||
|
scheme = "wss" if self.client.base_url.startswith("https") else "ws"
|
||||||
|
host = self.client.base_url.split("://", 1)[1].rstrip("/")
|
||||||
|
url = f"{scheme}://{host}/ws/place/{EVENT_PATH}/"
|
||||||
|
cookie_header = "; ".join(
|
||||||
|
f"{name}={value}" for name, value in self.client.cookies.get_dict().items()
|
||||||
|
)
|
||||||
|
|
||||||
|
start = time.perf_counter()
|
||||||
|
exc = None
|
||||||
|
ws = None
|
||||||
|
try:
|
||||||
|
ws = websocket.create_connection(
|
||||||
|
url, header=[f"Cookie: {cookie_header}"], timeout=10
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
exc = e
|
||||||
|
events.request.fire(
|
||||||
|
request_type="WS",
|
||||||
|
name="ws_connect",
|
||||||
|
response_time=(time.perf_counter() - start) * 1000,
|
||||||
|
response_length=0,
|
||||||
|
exception=exc,
|
||||||
|
)
|
||||||
|
if exc is not None:
|
||||||
|
raise exc
|
||||||
|
return ws
|
||||||
|
|
||||||
|
def _read_initial_next_placement_at(self) -> float:
|
||||||
|
message = self.ws.recv()
|
||||||
|
next_ms = message.split("|")[1]
|
||||||
|
return int(next_ms) / 1000 if next_ms else 0.0
|
||||||
|
|
||||||
|
def _reader_loop(self):
|
||||||
|
"""The only greenlet that ever calls ws.recv() - place_pixel below
|
||||||
|
only ever writes, and reads back via reply_queue, so there's no race
|
||||||
|
between two greenlets reading the same socket. Broadcast pixels
|
||||||
|
from other players (and 'e|...' resize messages) are read and
|
||||||
|
dropped here, same as a real idle viewer."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
message = self.ws.recv()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if not message:
|
||||||
|
return
|
||||||
|
if message[0] in ("k", "r"):
|
||||||
|
self.reply_queue.put(message)
|
||||||
|
|
||||||
|
@task
|
||||||
|
def place_pixel(self):
|
||||||
|
now = time.time()
|
||||||
|
if now < self.next_placement_at:
|
||||||
|
return # still on cooldown - a real client wouldn't send either
|
||||||
|
|
||||||
|
x = random.randrange(self.width)
|
||||||
|
y = random.randrange(self.height)
|
||||||
|
color = random.randrange(PALETTE_SIZE)
|
||||||
|
message = f"p|{x}|{y}|{color}"
|
||||||
|
|
||||||
|
start = time.perf_counter()
|
||||||
|
exc = None
|
||||||
|
reply = None
|
||||||
|
try:
|
||||||
|
self.ws.send(message)
|
||||||
|
reply = self.reply_queue.get(timeout=10)
|
||||||
|
except (Empty, Exception) as e:
|
||||||
|
exc = e
|
||||||
|
|
||||||
|
events.request.fire(
|
||||||
|
request_type="WS",
|
||||||
|
name="place_pixel",
|
||||||
|
response_time=(time.perf_counter() - start) * 1000,
|
||||||
|
response_length=len(reply) if reply else 0,
|
||||||
|
exception=exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
accepted = reply is not None and reply.startswith("k|")
|
||||||
|
|
||||||
|
# Both an accepted placement (k|<next>) and a cooldown rejection
|
||||||
|
# (r|c|<next>) carry the server's authoritative cooldown end - sync
|
||||||
|
# to it either way, for the same reason _connect syncs off 's|...':
|
||||||
|
# it keeps us honest against clock drift and against several
|
||||||
|
# simulated users sharing one round-robined account, instead of
|
||||||
|
# hammering the server with attempts it will just keep rejecting.
|
||||||
|
if reply is not None and reply.startswith(("k|", "r|c|")):
|
||||||
|
next_ms = reply.rsplit("|", 1)[1]
|
||||||
|
self.next_placement_at = int(next_ms) / 1000
|
||||||
|
|
||||||
|
if not accepted and random.random() < RELOAD_ON_FAILURE_PROBABILITY:
|
||||||
|
self._reload()
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Locust load test for the market (betting) page.
|
||||||
|
|
||||||
|
Simulates real bettors: log in, load the market page the way the frontend
|
||||||
|
does (user info, markets, own bets, own event points, then one bet-history
|
||||||
|
fetch per market since the charts are open by default), then keep placing
|
||||||
|
or increasing bets, refreshing the page, and occasionally reloading it.
|
||||||
|
|
||||||
|
Bets follow the same rules as the real UI: one option per market, the
|
||||||
|
amount sent is the new total (so an increase re-sends the same option with
|
||||||
|
a bigger amount), and a user never bets more than their event points. A
|
||||||
|
400 the server is expected to give under load ("Insufficient points",
|
||||||
|
"Market is not open for betting", ...) counts as a success - it's the
|
||||||
|
business rule doing its job, not the server failing.
|
||||||
|
|
||||||
|
Authentication uses local Django accounts (ModelBackend), never the real
|
||||||
|
CAS server at polylan.ch - see testing_auth_views.py. This ONLY works
|
||||||
|
against a target where LOAD_TEST_LOGIN_ENABLED=true; never point it at
|
||||||
|
production.
|
||||||
|
|
||||||
|
Setup, on the target server:
|
||||||
|
export LOAD_TEST_LOGIN_ENABLED=true
|
||||||
|
In the Django admin:
|
||||||
|
- Accounts > Users > "Create load test users" (see accounts/admin.py)
|
||||||
|
creates/resets loadtest0..loadtest499.
|
||||||
|
- Market > Markets: create a few markets with status "Open" (and a few
|
||||||
|
options each) on the event under test. Without open markets the users
|
||||||
|
only browse.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
uv run locust -f loadtest/market_locustfile.py --host http://localhost:7777
|
||||||
|
|
||||||
|
Env vars:
|
||||||
|
MARKET_EVENT_PATH event slug under test (default: "loadtest")
|
||||||
|
LOAD_TEST_USER_COUNT must match accounts/admin.py's LOAD_TEST_USER_COUNT (default: 500)
|
||||||
|
LOAD_TEST_USER_PREFIX must match accounts/admin.py's LOAD_TEST_USER_PREFIX (default: "loadtest")
|
||||||
|
LOAD_TEST_PASSWORD must match accounts/admin.py's LOAD_TEST_PASSWORD (default: "loadtest-only-password")
|
||||||
|
MIN_BET_INCREMENT smallest amount added per bet (default: 10)
|
||||||
|
MAX_BET_INCREMENT largest amount added per bet (default: 50)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
|
||||||
|
from locust import HttpUser, between, task
|
||||||
|
|
||||||
|
EVENT_PATH = os.environ.get("MARKET_EVENT_PATH", "loadtest")
|
||||||
|
USER_COUNT = int(os.environ.get("LOAD_TEST_USER_COUNT", "500"))
|
||||||
|
USER_PREFIX = os.environ.get("LOAD_TEST_USER_PREFIX", "loadtest")
|
||||||
|
PASSWORD = os.environ.get("LOAD_TEST_PASSWORD", "loadtest-only-password")
|
||||||
|
MIN_BET_INCREMENT = int(os.environ.get("MIN_BET_INCREMENT", "10"))
|
||||||
|
MAX_BET_INCREMENT = int(os.environ.get("MAX_BET_INCREMENT", "50"))
|
||||||
|
|
||||||
|
API = f"/api/events/{EVENT_PATH}/market"
|
||||||
|
|
||||||
|
# 400s the market API returns on purpose - concurrent bettors racing for
|
||||||
|
# the same points, or a market closed mid-test. Anything else is a failure.
|
||||||
|
EXPECTED_BET_REJECTIONS = (
|
||||||
|
"Insufficient points",
|
||||||
|
"Market is not open",
|
||||||
|
"only bet on one option",
|
||||||
|
"Cannot decrease bet",
|
||||||
|
)
|
||||||
|
|
||||||
|
_next_account_index = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _next_username() -> str:
|
||||||
|
"""Round-robins through the pool of pre-created accounts (same scheme as
|
||||||
|
locustfile.py - kept separate so running this file doesn't also spawn
|
||||||
|
PlaceUser)."""
|
||||||
|
global _next_account_index
|
||||||
|
username = f"{USER_PREFIX}{_next_account_index % USER_COUNT}"
|
||||||
|
_next_account_index += 1
|
||||||
|
return username
|
||||||
|
|
||||||
|
|
||||||
|
class MarketUser(HttpUser):
|
||||||
|
wait_time = between(2, 6)
|
||||||
|
|
||||||
|
def on_start(self):
|
||||||
|
self.username = _next_username()
|
||||||
|
self.markets: list[dict] = []
|
||||||
|
# market uuid -> {"option": option uuid, "amount": total}
|
||||||
|
self.bets: dict[str, dict] = {}
|
||||||
|
self.points = 0
|
||||||
|
self._login()
|
||||||
|
self._load_page()
|
||||||
|
|
||||||
|
def _login(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/testing/login/",
|
||||||
|
data={"username": self.username, "password": PASSWORD},
|
||||||
|
name="/testing/login/",
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"login failed for {self.username}: {response.status_code} "
|
||||||
|
f"{response.text}. Is LOAD_TEST_LOGIN_ENABLED set on the "
|
||||||
|
"target, and did you run the 'Create load test users' admin "
|
||||||
|
"action (accounts/admin.py)?"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_page(self):
|
||||||
|
"""Full page load: HTML, then what useMarketStore.initializeMarketPage
|
||||||
|
fetches, then one history fetch per card (charts are shown by
|
||||||
|
default)."""
|
||||||
|
self.client.get(f"/{EVENT_PATH}/market", name="/[event]/market")
|
||||||
|
self.client.get("/api/user", name="/api/user")
|
||||||
|
self._fetch_markets()
|
||||||
|
self._fetch_bets()
|
||||||
|
self._fetch_points()
|
||||||
|
for market in self.markets:
|
||||||
|
self._fetch_history(market)
|
||||||
|
|
||||||
|
def _fetch_markets(self) -> dict[str, int]:
|
||||||
|
"""Returns each market's total pot, to tell which charts refetch."""
|
||||||
|
response = self.client.get(f"{API}/", name="/api/events/[event]/market/")
|
||||||
|
if response.ok:
|
||||||
|
self.markets = response.json()
|
||||||
|
return {
|
||||||
|
market["uuid"]: sum(opt["total_bets"] for opt in market["options"])
|
||||||
|
for market in self.markets
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fetch_bets(self):
|
||||||
|
response = self.client.get(
|
||||||
|
f"{API}/user/bets", name="/api/events/[event]/market/user/bets"
|
||||||
|
)
|
||||||
|
if response.ok:
|
||||||
|
self.bets = {
|
||||||
|
bet["market"]["uuid"]: {
|
||||||
|
"option": bet["option"]["uuid"],
|
||||||
|
"amount": bet["amount"],
|
||||||
|
}
|
||||||
|
for bet in response.json()
|
||||||
|
if bet.get("market")
|
||||||
|
}
|
||||||
|
|
||||||
|
def _fetch_points(self):
|
||||||
|
response = self.client.get(
|
||||||
|
f"{API}/user/points", name="/api/events/[event]/market/user/points"
|
||||||
|
)
|
||||||
|
if response.ok:
|
||||||
|
self.points = response.json()["points"]
|
||||||
|
|
||||||
|
def _fetch_history(self, market: dict):
|
||||||
|
self.client.get(
|
||||||
|
f"{API}/{market['uuid']}/history",
|
||||||
|
name="/api/events/[event]/market/[market]/history",
|
||||||
|
)
|
||||||
|
|
||||||
|
@task(5)
|
||||||
|
def place_bet(self):
|
||||||
|
open_markets = [m for m in self.markets if m["status"] == "open" and m["options"]]
|
||||||
|
if not open_markets or self.points < MIN_BET_INCREMENT:
|
||||||
|
return
|
||||||
|
|
||||||
|
market = random.choice(open_markets)
|
||||||
|
existing = self.bets.get(market["uuid"])
|
||||||
|
option = existing["option"] if existing else random.choice(market["options"])["uuid"]
|
||||||
|
increment = random.randint(MIN_BET_INCREMENT, min(MAX_BET_INCREMENT, self.points))
|
||||||
|
amount = (existing["amount"] if existing else 0) + increment
|
||||||
|
|
||||||
|
with self.client.post(
|
||||||
|
f"{API}/{market['uuid']}/bets",
|
||||||
|
json={"option_uuid": option, "amount": amount},
|
||||||
|
name="/api/events/[event]/market/[market]/bets",
|
||||||
|
catch_response=True,
|
||||||
|
) as response:
|
||||||
|
if response.status_code == 400 and any(
|
||||||
|
reason in response.text for reason in EXPECTED_BET_REJECTIONS
|
||||||
|
):
|
||||||
|
response.success()
|
||||||
|
|
||||||
|
# MarketCard.placeBet reloads bets + points after every attempt
|
||||||
|
self._fetch_bets()
|
||||||
|
self._fetch_points()
|
||||||
|
|
||||||
|
@task(3)
|
||||||
|
def refresh(self):
|
||||||
|
"""useMarketStore.refreshPage, plus the history refetch the charts do
|
||||||
|
whenever a market's pot changed."""
|
||||||
|
previous_pots = {
|
||||||
|
market["uuid"]: sum(opt["total_bets"] for opt in market["options"])
|
||||||
|
for market in self.markets
|
||||||
|
}
|
||||||
|
self.client.get("/api/user", name="/api/user")
|
||||||
|
pots = self._fetch_markets()
|
||||||
|
self._fetch_bets()
|
||||||
|
self._fetch_points()
|
||||||
|
for market in self.markets:
|
||||||
|
if pots.get(market["uuid"]) != previous_pots.get(market["uuid"]):
|
||||||
|
self._fetch_history(market)
|
||||||
|
|
||||||
|
@task(1)
|
||||||
|
def reload(self):
|
||||||
|
self._load_page()
|
||||||
@@ -15,8 +15,8 @@ class MarketOptionInline(admin.TabularInline):
|
|||||||
|
|
||||||
@admin.register(Market)
|
@admin.register(Market)
|
||||||
class MarketAdmin(DjangoObjectActions, admin.ModelAdmin):
|
class MarketAdmin(DjangoObjectActions, admin.ModelAdmin):
|
||||||
list_display = ["title", "status", "end_date", "created_by", "created_at"]
|
list_display = ["title", "status", "event", "end_date", "created_by", "created_at"]
|
||||||
list_filter = ["status", "created_at"]
|
list_filter = ["status", "created_at", "event"]
|
||||||
search_fields = ["uuid", "title"]
|
search_fields = ["uuid", "title"]
|
||||||
readonly_fields = [
|
readonly_fields = [
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -27,7 +27,7 @@ class MarketAdmin(DjangoObjectActions, admin.ModelAdmin):
|
|||||||
]
|
]
|
||||||
inlines = [MarketOptionInline]
|
inlines = [MarketOptionInline]
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
("Info", {"fields": ["uuid", "title", "description"]}),
|
("Info", {"fields": ["uuid", "title", "description", "event"]}),
|
||||||
("Configuration", {"fields": ["end_date", "multiplier"]}),
|
("Configuration", {"fields": ["end_date", "multiplier"]}),
|
||||||
("Status", {"fields": ["status", "winning_option"]}),
|
("Status", {"fields": ["status", "winning_option"]}),
|
||||||
("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}),
|
("Metadata", {"fields": ["created_by", "created_at", "updated_at"]}),
|
||||||
|
|||||||
@@ -6,12 +6,20 @@ from django.db.models import Sum, Prefetch
|
|||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from market.models import Market, MarketOption, UserBet, UserPointChange
|
from market.models import (
|
||||||
|
Market,
|
||||||
|
MarketOption,
|
||||||
|
UserBet,
|
||||||
|
UserPointChange,
|
||||||
|
get_event_points,
|
||||||
|
)
|
||||||
from market.schemas import (
|
from market.schemas import (
|
||||||
|
BetHistoryPointSchema,
|
||||||
MarketListSchema,
|
MarketListSchema,
|
||||||
ResolveMarketSchema,
|
ResolveMarketSchema,
|
||||||
UserBetCreateSchema,
|
UserBetCreateSchema,
|
||||||
UserBetSchema,
|
UserBetSchema,
|
||||||
|
UserPointsSchema,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -45,6 +53,45 @@ def list_user_bets(request, event_path: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{PREFIX}/user/points", response=UserPointsSchema)
|
||||||
|
def get_user_points(request, event_path: str):
|
||||||
|
"""Get the current user's points balance for this event."""
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
raise HttpError(401, "Authentication required")
|
||||||
|
|
||||||
|
return {"points": get_event_points(request.user, request.event)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(f"{PREFIX}/{{market_uuid}}/history", response=List[BetHistoryPointSchema])
|
||||||
|
def bet_history(request, event_path: str, market_uuid: str):
|
||||||
|
"""Points added to each option over time (anonymous, one entry per bet placed/increased)."""
|
||||||
|
market = get_object_or_404(
|
||||||
|
Market.objects.exclude(status=Market.Status.DRAFT),
|
||||||
|
uuid=market_uuid,
|
||||||
|
event=request.event,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A user can only bet on one option per market, so their option is unique
|
||||||
|
option_by_user = dict(
|
||||||
|
UserBet.objects.filter(option__market=market).values_list(
|
||||||
|
"user_id", "option__uuid"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
changes = UserPointChange.objects.filter(
|
||||||
|
market=market, reason=UserPointChange.Reason.BET_PLACED
|
||||||
|
).order_by("created_at")
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"option_uuid": str(option_by_user[change.user_id]),
|
||||||
|
"created_at": change.created_at,
|
||||||
|
"amount": -change.amount,
|
||||||
|
}
|
||||||
|
for change in changes
|
||||||
|
if change.user_id in option_by_user
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post(f"{PREFIX}/{{market_uuid}}/actions/close")
|
@router.post(f"{PREFIX}/{{market_uuid}}/actions/close")
|
||||||
def close_market(request, event_path: str, market_uuid: str):
|
def close_market(request, event_path: str, market_uuid: str):
|
||||||
"""Close a market. Admin only."""
|
"""Close a market. Admin only."""
|
||||||
@@ -76,9 +123,7 @@ def resolve_market(
|
|||||||
market.save(update_fields=["winning_option", "status", "updated_at"])
|
market.save(update_fields=["winning_option", "status", "updated_at"])
|
||||||
|
|
||||||
# Calculate and distribute winnings
|
# Calculate and distribute winnings
|
||||||
all_bets = list(
|
all_bets = list(UserBet.objects.filter(option__market=market))
|
||||||
UserBet.objects.filter(option__market=market).select_related("user")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate total pot
|
# Calculate total pot
|
||||||
total_pot = sum(bet.amount for bet in all_bets)
|
total_pot = sum(bet.amount for bet in all_bets)
|
||||||
@@ -92,7 +137,6 @@ def resolve_market(
|
|||||||
total_winning = sum(bet.amount for bet in winning_bets)
|
total_winning = sum(bet.amount for bet in winning_bets)
|
||||||
|
|
||||||
point_changes = []
|
point_changes = []
|
||||||
users_to_update = []
|
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# Award payouts to winners with multiplier
|
# Award payouts to winners with multiplier
|
||||||
@@ -101,11 +145,9 @@ def resolve_market(
|
|||||||
payout = round(
|
payout = round(
|
||||||
bet.amount / total_winning * total_pot * market.multiplier
|
bet.amount / total_winning * total_pot * market.multiplier
|
||||||
)
|
)
|
||||||
bet.user.points += payout
|
|
||||||
users_to_update.append(bet.user)
|
|
||||||
point_changes.append(
|
point_changes.append(
|
||||||
UserPointChange(
|
UserPointChange(
|
||||||
user=bet.user,
|
user_id=bet.user_id,
|
||||||
market=market,
|
market=market,
|
||||||
amount=payout,
|
amount=payout,
|
||||||
reason=UserPointChange.Reason.BET_WON,
|
reason=UserPointChange.Reason.BET_WON,
|
||||||
@@ -116,17 +158,13 @@ def resolve_market(
|
|||||||
for bet in losing_bets:
|
for bet in losing_bets:
|
||||||
point_changes.append(
|
point_changes.append(
|
||||||
UserPointChange(
|
UserPointChange(
|
||||||
user=bet.user,
|
user_id=bet.user_id,
|
||||||
market=market,
|
market=market,
|
||||||
amount=-bet.amount,
|
amount=-bet.amount,
|
||||||
reason=UserPointChange.Reason.BET_LOST,
|
reason=UserPointChange.Reason.BET_LOST,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Bulk update users
|
|
||||||
for user in users_to_update:
|
|
||||||
user.save(update_fields=["points"])
|
|
||||||
|
|
||||||
# Bulk create point changes
|
# Bulk create point changes
|
||||||
UserPointChange.objects.bulk_create(point_changes)
|
UserPointChange.objects.bulk_create(point_changes)
|
||||||
|
|
||||||
@@ -167,8 +205,8 @@ def create_bet(
|
|||||||
# Calculate delta (amount to deduct from user's points)
|
# Calculate delta (amount to deduct from user's points)
|
||||||
delta = payload.amount - (existing_bet.amount if existing_bet else 0)
|
delta = payload.amount - (existing_bet.amount if existing_bet else 0)
|
||||||
|
|
||||||
# Check if user has enough points
|
# Check if user has enough points for this event
|
||||||
if request.user.points < delta:
|
if get_event_points(request.user, request.event) < delta:
|
||||||
raise HttpError(400, "Insufficient points for this bet")
|
raise HttpError(400, "Insufficient points for this bet")
|
||||||
|
|
||||||
user_bet, created = UserBet.objects.update_or_create(
|
user_bet, created = UserBet.objects.update_or_create(
|
||||||
@@ -177,9 +215,7 @@ def create_bet(
|
|||||||
defaults={"amount": payload.amount},
|
defaults={"amount": payload.amount},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Deduct points and record the change
|
# Record the change (the event balance is derived from these)
|
||||||
request.user.points -= delta
|
|
||||||
request.user.save(update_fields=["points"])
|
|
||||||
UserPointChange.objects.create(
|
UserPointChange.objects.create(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
market=market,
|
market=market,
|
||||||
|
|||||||
@@ -1,10 +1,28 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Sum
|
||||||
|
|
||||||
from events.models import Event
|
from events.models import Event
|
||||||
from events.scoping import EventScopedManager
|
from events.scoping import EventScopedManager
|
||||||
|
|
||||||
|
|
||||||
|
# Each event's market starts every user with this balance
|
||||||
|
STARTING_POINTS = 1000
|
||||||
|
|
||||||
|
|
||||||
|
def get_event_points(user, event) -> int:
|
||||||
|
"""User's market balance for an event: starting points + the event's point changes.
|
||||||
|
|
||||||
|
BET_LOST rows are informational (the stake was already deducted by BET_PLACED).
|
||||||
|
"""
|
||||||
|
total = (
|
||||||
|
UserPointChange.objects.filter(user=user, market__event=event)
|
||||||
|
.exclude(reason=UserPointChange.Reason.BET_LOST)
|
||||||
|
.aggregate(total=Sum("amount"))["total"]
|
||||||
|
)
|
||||||
|
return STARTING_POINTS + (total or 0)
|
||||||
|
|
||||||
|
|
||||||
class BaseModel(models.Model):
|
class BaseModel(models.Model):
|
||||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ class ResolveMarketSchema(Schema):
|
|||||||
winning_option_uuid: str
|
winning_option_uuid: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserPointsSchema(Schema):
|
||||||
|
points: int
|
||||||
|
|
||||||
|
|
||||||
|
class BetHistoryPointSchema(Schema):
|
||||||
|
option_uuid: str
|
||||||
|
created_at: datetime
|
||||||
|
amount: int
|
||||||
|
|
||||||
|
|
||||||
class UserBetCreateSchema(Schema):
|
class UserBetCreateSchema(Schema):
|
||||||
option_uuid: str
|
option_uuid: str
|
||||||
amount: int
|
amount: int
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ class PlaceSettingsAdmin(DjangoObjectActions, admin.ModelAdmin):
|
|||||||
def resync_canvas(self, request, obj):
|
def resync_canvas(self, request, obj):
|
||||||
count = rebuild_canvas_from_history(obj.event, obj.width, obj.height)
|
count = rebuild_canvas_from_history(obj.event, obj.width, obj.height)
|
||||||
self.message_user(
|
self.message_user(
|
||||||
request, f"Canvas resynced from {count} placement(s) at {obj.width}x{obj.height}."
|
request,
|
||||||
|
f"Canvas resynced from {count} placement(s) at {obj.width}x{obj.height}.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ _client = redis.Redis.from_url(settings.PLACE_REDIS_URL)
|
|||||||
|
|
||||||
|
|
||||||
def _canvas_key(event_path: str) -> str:
|
def _canvas_key(event_path: str) -> str:
|
||||||
return f"place:canvas:{event_path}"
|
return f"place:{event_path}:canvas"
|
||||||
|
|
||||||
|
|
||||||
def read_canvas(event_path: str, width: int, height: int) -> bytes:
|
def read_canvas(event_path: str, width: int, height: int) -> bytes:
|
||||||
@@ -49,9 +49,11 @@ def rebuild_canvas_from_history(event, width: int, height: int) -> int:
|
|||||||
"""
|
"""
|
||||||
grid = bytearray(width * height)
|
grid = bytearray(width * height)
|
||||||
count = 0
|
count = 0
|
||||||
placements = PixelPlacement.objects.filter(event=event).order_by(
|
placements = (
|
||||||
"placed_at"
|
PixelPlacement.objects.filter(event=event)
|
||||||
).values_list("x", "y", "color_index")
|
.order_by("placed_at")
|
||||||
|
.values_list("x", "y", "color_index")
|
||||||
|
)
|
||||||
for x, y, color_index in placements.iterator():
|
for x, y, color_index in placements.iterator():
|
||||||
if x >= width or y >= height:
|
if x >= width or y >= height:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ class PlaceConsumer(AsyncWebsocketConsumer):
|
|||||||
await self.send(text_data=encode("r", "i"))
|
await self.send(text_data=encode("r", "i"))
|
||||||
return
|
return
|
||||||
|
|
||||||
result = await self._record_placement(user, x, y, color_index, place_settings.width)
|
result = await self._record_placement(
|
||||||
|
user, x, y, color_index, place_settings.width
|
||||||
|
)
|
||||||
|
|
||||||
if result["rejected"]:
|
if result["rejected"]:
|
||||||
await self.send(text_data=encode("r", "c", result["next"]))
|
await self.send(text_data=encode("r", "c", result["next"]))
|
||||||
@@ -110,7 +112,9 @@ class PlaceConsumer(AsyncWebsocketConsumer):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"rejected": False,
|
"rejected": False,
|
||||||
"next": to_epoch_ms(placement.placed_at + timedelta(seconds=PLACE_COOLDOWN_SECONDS)),
|
"next": to_epoch_ms(
|
||||||
|
placement.placed_at + timedelta(seconds=PLACE_COOLDOWN_SECONDS)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@database_sync_to_async
|
@database_sync_to_async
|
||||||
|
|||||||
@@ -35,9 +35,11 @@ def build_evolution_gif(event: Event) -> bytes:
|
|||||||
place_settings = PlaceSettings.current(event)
|
place_settings = PlaceSettings.current(event)
|
||||||
width, height = place_settings.width, place_settings.height
|
width, height = place_settings.width, place_settings.height
|
||||||
|
|
||||||
placements = PixelPlacement.objects.filter(event=event).order_by(
|
placements = (
|
||||||
"placed_at"
|
PixelPlacement.objects.filter(event=event)
|
||||||
).values_list("x", "y", "color_index")
|
.order_by("placed_at")
|
||||||
|
.values_list("x", "y", "color_index")
|
||||||
|
)
|
||||||
placements = list(placements)
|
placements = list(placements)
|
||||||
|
|
||||||
grid = bytearray(width * height)
|
grid = bytearray(width * height)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "polylan_submitter.settings")
|
|||||||
|
|
||||||
django_asgi_app = get_asgi_application()
|
django_asgi_app = get_asgi_application()
|
||||||
|
|
||||||
from submissions.routing import websocket_urlpatterns as submissions_websocket_urlpatterns
|
from submissions.routing import (
|
||||||
|
websocket_urlpatterns as submissions_websocket_urlpatterns,
|
||||||
|
)
|
||||||
from clock.routing import websocket_urlpatterns as clock_websocket_urlpatterns
|
from clock.routing import websocket_urlpatterns as clock_websocket_urlpatterns
|
||||||
from place.routing import websocket_urlpatterns as place_websocket_urlpatterns
|
from place.routing import websocket_urlpatterns as place_websocket_urlpatterns
|
||||||
|
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ from dotenv import load_dotenv
|
|||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
# Optional: point this at a KEY=value file (see
|
# Optional: point this at a KEY=value file (see
|
||||||
# deploy/supervisor/polylan-submitter.env.dist for the template, and
|
# deploy/docker/polylan-submitter.env.dist for the template - unused in the
|
||||||
# deploy/supervisor/polylan-submitter.conf for how it's wired up in
|
# Docker deploy itself, which passes env vars directly via `docker run
|
||||||
# production) to fill in the env vars read throughout this file. override
|
# --env-file` instead) to fill in the env vars read throughout this file.
|
||||||
# =False so an already-exported env var always wins over the file - this
|
# override=False so an already-exported env var always wins over the file -
|
||||||
# only fills gaps, same as every os.environ.get() below already treating a
|
# this only fills gaps, same as every os.environ.get() below already
|
||||||
# merely-blank value as "use my own default" rather than "".
|
# treating a merely-blank value as "use my own default" rather than "".
|
||||||
if os.environ.get("SETTINGS_ENV_FILE_LOCATION"):
|
if os.environ.get("SETTINGS_ENV_FILE_LOCATION"):
|
||||||
load_dotenv(os.environ["SETTINGS_ENV_FILE_LOCATION"], override=False)
|
load_dotenv(os.environ["SETTINGS_ENV_FILE_LOCATION"], override=False)
|
||||||
|
|
||||||
@@ -58,7 +58,9 @@ DEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true"
|
|||||||
|
|
||||||
ALLOWED_HOSTS = [
|
ALLOWED_HOSTS = [
|
||||||
host
|
host
|
||||||
for host in (os.environ.get("DJANGO_ALLOWED_HOSTS") or "127.0.0.1,localhost").split(",")
|
for host in (os.environ.get("DJANGO_ALLOWED_HOSTS") or "127.0.0.1,localhost").split(
|
||||||
|
","
|
||||||
|
)
|
||||||
if host
|
if host
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -67,7 +69,14 @@ ALLOWED_HOSTS = [
|
|||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"daphne",
|
"daphne",
|
||||||
"django.contrib.admin",
|
"admin_interface",
|
||||||
|
"colorfield",
|
||||||
|
# "palette" provides the admin/base.html override (the command-palette
|
||||||
|
# UI) and must precede "palette.apps.PaletteAdminConfig", which stands
|
||||||
|
# in for "django.contrib.admin" (same app name, custom AdminSite) to
|
||||||
|
# wire the palette into the admin.
|
||||||
|
"palette.apps.PaletteConfig",
|
||||||
|
"palette.apps.PaletteAdminConfig",
|
||||||
"django.contrib.auth",
|
"django.contrib.auth",
|
||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
@@ -207,6 +216,18 @@ AUTH_USER_MODEL = "accounts.CustomUser"
|
|||||||
# Simple CAS Configuration
|
# Simple CAS Configuration
|
||||||
CAS_SERVER_URL = "https://polylan.ch/cas/"
|
CAS_SERVER_URL = "https://polylan.ch/cas/"
|
||||||
|
|
||||||
|
# Enables POST /testing/login/ (see testing_auth_views.py), a
|
||||||
|
# username/password login for local-only accounts that authenticate through
|
||||||
|
# ModelBackend instead of SimpleCASBackend. This exists so load testing (see
|
||||||
|
# loadtest/locustfile.py) never sends traffic to the real CAS server at
|
||||||
|
# polylan.ch. Leave this off everywhere except a dedicated load-test
|
||||||
|
# environment - the "Create load test users" admin action on CustomUser
|
||||||
|
# (see accounts/admin.py) also hides itself without it, as a second layer
|
||||||
|
# of the same guard rail.
|
||||||
|
LOAD_TEST_LOGIN_ENABLED = (
|
||||||
|
os.environ.get("LOAD_TEST_LOGIN_ENABLED", "false").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
# Steam API Configuration
|
# Steam API Configuration
|
||||||
STEAM_API_KEY = os.environ.get("STEAM_API_KEY") or None # Set via environment variable
|
STEAM_API_KEY = os.environ.get("STEAM_API_KEY") or None # Set via environment variable
|
||||||
|
|
||||||
@@ -256,7 +277,9 @@ CHANNEL_LAYERS = {
|
|||||||
|
|
||||||
# Redis for place's live pixel canvas (SETRANGE on the hot path - not a fit
|
# Redis for place's live pixel canvas (SETRANGE on the hot path - not a fit
|
||||||
# for django-redis's cache API, so it gets its own raw redis-py client).
|
# for django-redis's cache API, so it gets its own raw redis-py client).
|
||||||
PLACE_REDIS_URL = os.environ.get("PLACE_REDIS_URL") or f"redis://{REDIS_HOST}:{REDIS_PORT}/15"
|
PLACE_REDIS_URL = (
|
||||||
|
os.environ.get("PLACE_REDIS_URL") or f"redis://{REDIS_HOST}:{REDIS_PORT}/15"
|
||||||
|
)
|
||||||
|
|
||||||
# Django Tasks Configuration (with RQ backend)
|
# Django Tasks Configuration (with RQ backend)
|
||||||
TASKS = {
|
TASKS = {
|
||||||
@@ -301,6 +324,7 @@ LOGOUT_REDIRECT_URL = "/"
|
|||||||
DJANGO_VITE = {
|
DJANGO_VITE = {
|
||||||
"default": {
|
"default": {
|
||||||
"dev_mode": DEBUG,
|
"dev_mode": DEBUG,
|
||||||
|
"dev_server_port": 5273,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
@@ -314,7 +338,9 @@ STATICFILES_DIRS = [
|
|||||||
# that, so these must be set (via env) wherever the app runs, dev included.
|
# that, so these must be set (via env) wherever the app runs, dev included.
|
||||||
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
|
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
|
||||||
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
|
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
|
||||||
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL") or "https://storage.polylan.ch"
|
AWS_S3_ENDPOINT_URL = (
|
||||||
|
os.environ.get("AWS_S3_ENDPOINT_URL") or "https://storage.polylan.ch"
|
||||||
|
)
|
||||||
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_STORAGE_BUCKET_NAME") or "dev-submissions"
|
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_STORAGE_BUCKET_NAME") or "dev-submissions"
|
||||||
|
|
||||||
_s3_storage_options = {
|
_s3_storage_options = {
|
||||||
@@ -329,21 +355,22 @@ STORAGES = {
|
|||||||
"BACKEND": "storages.backends.s3.S3Storage",
|
"BACKEND": "storages.backends.s3.S3Storage",
|
||||||
"OPTIONS": _s3_storage_options,
|
"OPTIONS": _s3_storage_options,
|
||||||
},
|
},
|
||||||
# Static files are collected to local disk and served by WhiteNoise in
|
# Static files are always collected to local disk and served by
|
||||||
# DEBUG (no need to push every CSS/JS tweak to S3 while developing);
|
# WhiteNoise (see WhiteNoiseMiddleware in MIDDLEWARE above, which is
|
||||||
# production collects straight to S3 like everything else here.
|
# likewise unconditional) - S3 is only for user-uploaded content
|
||||||
|
# ("default", above), never for the app's own CSS/JS/images.
|
||||||
"staticfiles": {
|
"staticfiles": {
|
||||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||||
if DEBUG
|
|
||||||
else "storages.backends.s3.S3Storage",
|
|
||||||
**({} if DEBUG else {"OPTIONS": _s3_storage_options}),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
SENTRY_ENVIRONMENT = os.environ.get("SENTRY_ENVIRONMENT") or ("development" if DEBUG else "production")
|
SENTRY_ENVIRONMENT = os.environ.get("SENTRY_ENVIRONMENT") or (
|
||||||
|
"development" if DEBUG else "production"
|
||||||
|
)
|
||||||
SENTRY_DSN = (
|
SENTRY_DSN = (
|
||||||
os.environ.get("SENTRY_DSN") or "https://cc62a4ce3f3470890b43accf02cc6d8c@sentry2.polylan.ch/12"
|
os.environ.get("SENTRY_DSN")
|
||||||
|
or "https://cc62a4ce3f3470890b43accf02cc6d8c@sentry2.polylan.ch/12"
|
||||||
)
|
)
|
||||||
|
|
||||||
import sentry_sdk # noqa
|
import sentry_sdk # noqa
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
|
from simple_cas_views import SimpleCASLoginView, SimpleCASLogoutView
|
||||||
|
from testing_auth_views import TestLoginView
|
||||||
from events.models import Event
|
from events.models import Event
|
||||||
from games.decorators import require_game_enabled
|
from games.decorators import require_game_enabled
|
||||||
from market.views import market_home
|
from market.views import market_home
|
||||||
@@ -112,6 +113,14 @@ urlpatterns = [
|
|||||||
path("", home, name="home"),
|
path("", home, name="home"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Load-test-only username/password login (see testing_auth_views.py) - the
|
||||||
|
# URL itself doesn't exist unless explicitly enabled, so it 404s rather than
|
||||||
|
# 403s in every environment that hasn't opted in.
|
||||||
|
if settings.LOAD_TEST_LOGIN_ENABLED:
|
||||||
|
urlpatterns.append(
|
||||||
|
path("testing/login/", TestLoginView.as_view(), name="testing.login")
|
||||||
|
)
|
||||||
|
|
||||||
# Serve media files in development
|
# Serve media files in development
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const { t } = useI18n();
|
|||||||
useTitle(computed(() => t("market.pageTitle")));
|
useTitle(computed(() => t("market.pageTitle")));
|
||||||
|
|
||||||
const marketStore = useMarketStore();
|
const marketStore = useMarketStore();
|
||||||
const { markets, userInfo, isLoading } = storeToRefs(marketStore);
|
const { markets, userInfo, userPoints, isLoading } = storeToRefs(marketStore);
|
||||||
|
|
||||||
const reloadPage = async () => {
|
const reloadPage = async () => {
|
||||||
await marketStore.refreshPage(props.eventPath);
|
await marketStore.refreshPage(props.eventPath);
|
||||||
@@ -56,7 +56,7 @@ onMounted(() => {
|
|||||||
{{ t("market.myBets") }}
|
{{ t("market.myBets") }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="text-lg font-semibold">
|
<div class="text-lg font-semibold">
|
||||||
<span class="text-primary">{{ userInfo.points }}</span>
|
<span class="text-primary">{{ userPoints }}</span>
|
||||||
<span class="text-base-content/60 ml-1">{{ t("common.pts") }}</span>
|
<span class="text-base-content/60 ml-1">{{ t("common.pts") }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -528,6 +528,11 @@ onMounted(async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scopeName.value = scope.name;
|
scopeName.value = scope.name;
|
||||||
|
// The picker auto-redirects back here when there's only one scope, so
|
||||||
|
// going "back" to it would loop - head to the event home instead.
|
||||||
|
if (scopesResponse.data?.length === 1) {
|
||||||
|
pageHeaderStore.setBackRoute({ name: "home", params: { eventPath: props.eventPath } });
|
||||||
|
}
|
||||||
pageHeaderStore.setTitle(t("submissions.pageTitle", { game: gameName.value, scope: scopeName.value }));
|
pageHeaderStore.setTitle(t("submissions.pageTitle", { game: gameName.value, scope: scopeName.value }));
|
||||||
|
|
||||||
// Add paste listener (only processes if hovering upload area)
|
// Add paste listener (only processes if hovering upload area)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
|
import { type Client, formDataBodySerializer, type Options as Options2, type TDataShape } from './client';
|
||||||
import { client } from './client.gen';
|
import { client } from './client.gen';
|
||||||
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetConfigData, PolylanSubmitterApiGetConfigResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen';
|
import type { AnimationsApiPuzzleResultsData, AnimationsApiPuzzleResultsResponses, AnimationsApiResultsData, AnimationsApiResultsResponses, AnimationsApiTopSubmissionsData, AnimationsApiTopSubmissionsResponses, ClockApiCurrentStateData, ClockApiCurrentStateResponses, ClockApiMyStatsData, ClockApiMyStatsResponses, GamesApiListEventGamesData, GamesApiListEventGamesResponses, GamesApiListGamesData, GamesApiListGamesResponses, MarketApiBetHistoryData, MarketApiBetHistoryResponses, MarketApiCloseMarketData, MarketApiCloseMarketResponses, MarketApiCreateBetData, MarketApiCreateBetResponses, MarketApiGetUserPointsData, MarketApiGetUserPointsResponses, MarketApiListMarketsData, MarketApiListMarketsResponses, MarketApiListUserBetsData, MarketApiListUserBetsResponses, MarketApiResolveMarketData, MarketApiResolveMarketResponses, NoitaApiGetLeaderboardData, NoitaApiGetLeaderboardResponses, NoitaApiGetResultsData, NoitaApiGetResultsResponses, NoitaApiSubmitLogFileData, NoitaApiSubmitLogFileErrors, NoitaApiSubmitLogFileResponses, OpusMagnumApiCreateSubmissionData, OpusMagnumApiCreateSubmissionResponses, OpusMagnumApiDeleteSubmissionData, OpusMagnumApiDeleteSubmissionResponses, OpusMagnumApiGetCollectionData, OpusMagnumApiGetCollectionResponses, OpusMagnumApiGetStatsData, OpusMagnumApiGetStatsResponses, OpusMagnumApiGetSubmissionData, OpusMagnumApiGetSubmissionResponses, OpusMagnumApiListPuzzlesData, OpusMagnumApiListPuzzlesResponses, OpusMagnumApiListResponsesNeedingValidationData, OpusMagnumApiListResponsesNeedingValidationResponses, OpusMagnumApiListSubmissionsData, OpusMagnumApiListSubmissionsResponses, OpusMagnumApiValidateAutoData, OpusMagnumApiValidateAutoResponses, OpusMagnumApiValidateResponseData, OpusMagnumApiValidateResponseResponses, OpusMagnumApiValidateSubmissionData, OpusMagnumApiValidateSubmissionResponses, PlaceApiCanvasData, PlaceApiCanvasResponses, PlaceApiEvolutionGifData, PlaceApiEvolutionGifResponses, PolylanSubmitterApiClearCacheData, PolylanSubmitterApiClearCacheResponses, PolylanSubmitterApiGetConfigData, PolylanSubmitterApiGetConfigResponses, PolylanSubmitterApiGetUserInfoData, PolylanSubmitterApiGetUserInfoResponses, PolylanSubmitterApiHealthCheckData, PolylanSubmitterApiHealthCheckResponses, SubmissionsApiCreateImageCropData, SubmissionsApiCreateImageCropResponses, SubmissionsApiCreateSubmissionData, SubmissionsApiCreateSubmissionErrors, SubmissionsApiCreateSubmissionResponses, SubmissionsApiDeleteImageCropData, SubmissionsApiDeleteImageCropResponses, SubmissionsApiGetSubmissionData, SubmissionsApiGetSubmissionResponses, SubmissionsApiListAvailableScopesData, SubmissionsApiListAvailableScopesErrors, SubmissionsApiListAvailableScopesResponses, SubmissionsApiListMyScopesData, SubmissionsApiListMyScopesErrors, SubmissionsApiListMyScopesResponses, SubmissionsApiListSubmissionsByGameData, SubmissionsApiListSubmissionsByGameResponses, SubmissionsApiListUserSubmissionsData, SubmissionsApiListUserSubmissionsErrors, SubmissionsApiListUserSubmissionsResponses, SubmissionsApiProcessCropOcrData, SubmissionsApiProcessCropOcrResponses, SubmissionsApiProcessFileOcrData, SubmissionsApiProcessFileOcrResponses } from './types.gen';
|
||||||
|
|
||||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
|
||||||
/**
|
/**
|
||||||
@@ -319,6 +319,20 @@ export const marketApiListMarkets = <ThrowOnError extends boolean = false>(optio
|
|||||||
*/
|
*/
|
||||||
export const marketApiListUserBets = <ThrowOnError extends boolean = false>(options: Options<MarketApiListUserBetsData, ThrowOnError>) => (options.client ?? client).get<MarketApiListUserBetsResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/market/user/bets', ...options });
|
export const marketApiListUserBets = <ThrowOnError extends boolean = false>(options: Options<MarketApiListUserBetsData, ThrowOnError>) => (options.client ?? client).get<MarketApiListUserBetsResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/market/user/bets', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get User Points
|
||||||
|
*
|
||||||
|
* Get the current user's points balance for this event.
|
||||||
|
*/
|
||||||
|
export const marketApiGetUserPoints = <ThrowOnError extends boolean = false>(options: Options<MarketApiGetUserPointsData, ThrowOnError>) => (options.client ?? client).get<MarketApiGetUserPointsResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/market/user/points', ...options });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bet History
|
||||||
|
*
|
||||||
|
* Points added to each option over time (anonymous, one entry per bet placed/increased).
|
||||||
|
*/
|
||||||
|
export const marketApiBetHistory = <ThrowOnError extends boolean = false>(options: Options<MarketApiBetHistoryData, ThrowOnError>) => (options.client ?? client).get<MarketApiBetHistoryResponses, unknown, ThrowOnError>({ url: '/api/events/{event_path}/market/{market_uuid}/history', ...options });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close Market
|
* Close Market
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1153,6 +1153,34 @@ export type UserBetSchema = {
|
|||||||
market?: MarketListSchema | null;
|
market?: MarketListSchema | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserPointsSchema
|
||||||
|
*/
|
||||||
|
export type UserPointsSchema = {
|
||||||
|
/**
|
||||||
|
* Points
|
||||||
|
*/
|
||||||
|
points: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BetHistoryPointSchema
|
||||||
|
*/
|
||||||
|
export type BetHistoryPointSchema = {
|
||||||
|
/**
|
||||||
|
* Option Uuid
|
||||||
|
*/
|
||||||
|
option_uuid: string;
|
||||||
|
/**
|
||||||
|
* Created At
|
||||||
|
*/
|
||||||
|
created_at: string;
|
||||||
|
/**
|
||||||
|
* Amount
|
||||||
|
*/
|
||||||
|
amount: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ResolveMarketSchema
|
* ResolveMarketSchema
|
||||||
*/
|
*/
|
||||||
@@ -2173,6 +2201,54 @@ export type MarketApiListUserBetsResponses = {
|
|||||||
|
|
||||||
export type MarketApiListUserBetsResponse = MarketApiListUserBetsResponses[keyof MarketApiListUserBetsResponses];
|
export type MarketApiListUserBetsResponse = MarketApiListUserBetsResponses[keyof MarketApiListUserBetsResponses];
|
||||||
|
|
||||||
|
export type MarketApiGetUserPointsData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
/**
|
||||||
|
* Event Path
|
||||||
|
*/
|
||||||
|
event_path: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/events/{event_path}/market/user/points';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarketApiGetUserPointsResponses = {
|
||||||
|
/**
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: UserPointsSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarketApiGetUserPointsResponse = MarketApiGetUserPointsResponses[keyof MarketApiGetUserPointsResponses];
|
||||||
|
|
||||||
|
export type MarketApiBetHistoryData = {
|
||||||
|
body?: never;
|
||||||
|
path: {
|
||||||
|
/**
|
||||||
|
* Event Path
|
||||||
|
*/
|
||||||
|
event_path: string;
|
||||||
|
/**
|
||||||
|
* Market Uuid
|
||||||
|
*/
|
||||||
|
market_uuid: string;
|
||||||
|
};
|
||||||
|
query?: never;
|
||||||
|
url: '/api/events/{event_path}/market/{market_uuid}/history';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarketApiBetHistoryResponses = {
|
||||||
|
/**
|
||||||
|
* Response
|
||||||
|
*
|
||||||
|
* OK
|
||||||
|
*/
|
||||||
|
200: Array<BetHistoryPointSchema>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MarketApiBetHistoryResponse = MarketApiBetHistoryResponses[keyof MarketApiBetHistoryResponses];
|
||||||
|
|
||||||
export type MarketApiCloseMarketData = {
|
export type MarketApiCloseMarketData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { useMarketStore } from "../stores/market";
|
|||||||
import { useEventPath } from "../composables/useEventPath";
|
import { useEventPath } from "../composables/useEventPath";
|
||||||
import type { Market, MarketOption } from "../types";
|
import type { Market, MarketOption } from "../types";
|
||||||
import type { UserBetSchema } from "../api/types.gen";
|
import type { UserBetSchema } from "../api/types.gen";
|
||||||
|
import PayoutChart from "./charts/PayoutChart.vue";
|
||||||
|
import BetHistoryChart from "./charts/BetHistoryChart.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
market: Market;
|
market: Market;
|
||||||
@@ -19,7 +21,7 @@ const emit = defineEmits<{
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const eventPath = useEventPath();
|
const eventPath = useEventPath();
|
||||||
const marketStore = useMarketStore();
|
const marketStore = useMarketStore();
|
||||||
const { userInfo, userBets } = storeToRefs(marketStore);
|
const { userInfo, userBets, userPoints } = storeToRefs(marketStore);
|
||||||
|
|
||||||
const selectedOption = ref<string | null>(null);
|
const selectedOption = ref<string | null>(null);
|
||||||
const betAmount = ref<number>(0);
|
const betAmount = ref<number>(0);
|
||||||
@@ -29,6 +31,7 @@ const existingBet = ref<UserBetSchema | null>(null);
|
|||||||
const showResolveModal = ref(false);
|
const showResolveModal = ref(false);
|
||||||
const selectedWinningOption = ref<string | null>(null);
|
const selectedWinningOption = ref<string | null>(null);
|
||||||
const resolveLoading = ref(false);
|
const resolveLoading = ref(false);
|
||||||
|
const showCharts = ref(true);
|
||||||
|
|
||||||
const timeRemaining = computed(() => {
|
const timeRemaining = computed(() => {
|
||||||
const endDate = new Date(props.market.end_date).getTime();
|
const endDate = new Date(props.market.end_date).getTime();
|
||||||
@@ -80,6 +83,19 @@ const getPotentialGain = (option: MarketOption) => {
|
|||||||
return Math.round(existingBet.value.amount * multiplier * props.market.multiplier);
|
return Math.round(existingBet.value.amount * multiplier * props.market.multiplier);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedMarketOption = computed(() =>
|
||||||
|
props.market.options.find((opt) => opt.uuid === selectedOption.value) ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Payout chart only makes sense while the user can still bet on the selected option
|
||||||
|
const showPayoutChart = computed(
|
||||||
|
() => props.market.status === "open" && !!userInfo.value?.is_authenticated && !!selectedMarketOption.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingAmountOnSelected = computed(() =>
|
||||||
|
existingBet.value && existingBet.value.option.uuid === selectedOption.value ? existingBet.value.amount : 0,
|
||||||
|
);
|
||||||
|
|
||||||
const closeMarket = async () => {
|
const closeMarket = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
@@ -147,7 +163,7 @@ const placeBet = async () => {
|
|||||||
|
|
||||||
if (!response.error) {
|
if (!response.error) {
|
||||||
// Reload user bets to reflect the new bet
|
// Reload user bets to reflect the new bet
|
||||||
await marketStore.loadUserBets(eventPath);
|
await Promise.all([marketStore.loadUserBets(eventPath), marketStore.loadUserPoints(eventPath)]);
|
||||||
updateExistingBet();
|
updateExistingBet();
|
||||||
betAmount.value = 0;
|
betAmount.value = 0;
|
||||||
} else {
|
} else {
|
||||||
@@ -286,6 +302,20 @@ const updateExistingBet = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts -->
|
||||||
|
<div class="px-8 pb-2">
|
||||||
|
<button @click="showCharts = !showCharts" class="btn btn-ghost btn-xs">
|
||||||
|
<i :class="['mdi', showCharts ? 'mdi-chevron-up' : 'mdi-chart-line']"></i>
|
||||||
|
{{ showCharts ? t("components.marketCharts.hideCharts") : t("components.marketCharts.showCharts") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="showCharts" class="card-body py-4 gap-6">
|
||||||
|
<PayoutChart v-if="showPayoutChart && selectedMarketOption" :market="market" :option="selectedMarketOption"
|
||||||
|
:existing-amount="existingAmountOnSelected" :stake="betAmount || 0"
|
||||||
|
:max-stake="existingAmountOnSelected + userPoints" />
|
||||||
|
<BetHistoryChart :market="market" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Bet Input -->
|
<!-- Bet Input -->
|
||||||
<div v-if="market.status === 'open' && userInfo?.is_authenticated && selectedOption" class="card-body py-4">
|
<div v-if="market.status === 'open' && userInfo?.is_authenticated && selectedOption" class="card-body py-4">
|
||||||
<div class="form-control gap-3">
|
<div class="form-control gap-3">
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from "vue";
|
||||||
|
import { useElementSize } from "@vueuse/core";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { marketApiBetHistory } from "../../api";
|
||||||
|
import type { BetHistoryPointSchema } from "../../api/types.gen";
|
||||||
|
import { useEventPath } from "../../composables/useEventPath";
|
||||||
|
import type { Market } from "../../types";
|
||||||
|
import { SERIES_COLORS, MAX_SERIES, linearScale, niceTicks, formatNumber, monotonePath } from "./chartUtils";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
market: Market;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const eventPath = useEventPath();
|
||||||
|
|
||||||
|
const container = ref<HTMLElement | null>(null);
|
||||||
|
const { width } = useElementSize(container);
|
||||||
|
|
||||||
|
const HEIGHT = 200;
|
||||||
|
const MARGIN = { top: 16, right: 56, bottom: 28, left: 52 };
|
||||||
|
const OTHER_KEY = "__other__";
|
||||||
|
|
||||||
|
const history = ref<BetHistoryPointSchema[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const loaded = ref(false);
|
||||||
|
|
||||||
|
const loadHistory = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await marketApiBetHistory({
|
||||||
|
path: { event_path: eventPath, market_uuid: props.market.uuid },
|
||||||
|
});
|
||||||
|
if (response.data) history.value = response.data;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
loaded.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Refetch whenever the pools change (bet placed, page refresh)
|
||||||
|
const totalPot = computed(() => props.market.options.reduce((sum, opt) => sum + opt.total_bets, 0));
|
||||||
|
watch(totalPot, loadHistory, { immediate: true });
|
||||||
|
|
||||||
|
// Colors follow the option (sorted by text), not its current rank, so they stay
|
||||||
|
// stable as pools change. Past the palette size the tail folds into "Other".
|
||||||
|
const series = computed(() => {
|
||||||
|
const options = [...props.market.options].sort((a, b) => a.text.localeCompare(b.text));
|
||||||
|
const fold = options.length > MAX_SERIES;
|
||||||
|
const kept = fold ? options.slice(0, MAX_SERIES - 1) : options;
|
||||||
|
const list = kept.map((opt, i) => ({ key: opt.uuid, label: opt.text, color: SERIES_COLORS[i] }));
|
||||||
|
if (fold) list.push({ key: OTHER_KEY, label: t("components.marketCharts.other"), color: SERIES_COLORS[MAX_SERIES - 1] });
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
const seriesKeyFor = (optionUuid: string) =>
|
||||||
|
series.value.some((s) => s.key === optionUuid) ? optionUuid : OTHER_KEY;
|
||||||
|
|
||||||
|
// One step per bet: cumulative pool of every series after that bet
|
||||||
|
const steps = computed(() => {
|
||||||
|
const totals: Record<string, number> = Object.fromEntries(series.value.map((s) => [s.key, 0]));
|
||||||
|
return history.value.map((entry) => {
|
||||||
|
totals[seriesKeyFor(entry.option_uuid)] += entry.amount;
|
||||||
|
return {
|
||||||
|
time: new Date(entry.created_at).getTime(),
|
||||||
|
entry,
|
||||||
|
totals: { ...totals },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeDomain = computed<[number, number]>(() => {
|
||||||
|
const first = steps.value[0]?.time ?? Date.now();
|
||||||
|
const last = steps.value[steps.value.length - 1]?.time ?? first;
|
||||||
|
const end = Math.max(last, Math.min(Date.now(), new Date(props.market.end_date).getTime()), first + 60_000);
|
||||||
|
// Small lead-in so every curve visibly rises from 0
|
||||||
|
return [first - (end - first) * 0.04, end];
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalTotals = computed(() => steps.value[steps.value.length - 1]?.totals ?? {});
|
||||||
|
|
||||||
|
const yTicks = computed(() => niceTicks(Math.max(0, ...Object.values(finalTotals.value))));
|
||||||
|
const plotWidth = computed(() => Math.max(width.value - MARGIN.left - MARGIN.right, 10));
|
||||||
|
const x = computed(() => linearScale(timeDomain.value, [MARGIN.left, MARGIN.left + plotWidth.value]));
|
||||||
|
const y = computed(() =>
|
||||||
|
linearScale([0, yTicks.value[yTicks.value.length - 1]], [HEIGHT - MARGIN.bottom, MARGIN.top]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const timeFormat = computed(() =>
|
||||||
|
timeDomain.value[1] - timeDomain.value[0] < 24 * 3600_000 ? "HH:mm" : "DD/MM HH:mm",
|
||||||
|
);
|
||||||
|
const xTicks = computed(() => {
|
||||||
|
const [t0, t1] = timeDomain.value;
|
||||||
|
return [0, 1 / 3, 2 / 3, 1].map((f) => t0 + (t1 - t0) * f);
|
||||||
|
});
|
||||||
|
|
||||||
|
const paths = computed(() =>
|
||||||
|
series.value.map((s) => {
|
||||||
|
// A point at every bet (not just this option's) so the curve goes through
|
||||||
|
// the real totals wherever the crosshair can land
|
||||||
|
const points: [number, number][] = [
|
||||||
|
[x.value(timeDomain.value[0]), y.value(0)],
|
||||||
|
...steps.value.map((step): [number, number] => [x.value(step.time), y.value(step.totals[s.key])]),
|
||||||
|
[x.value(timeDomain.value[1]), y.value(finalTotals.value[s.key] ?? 0)],
|
||||||
|
];
|
||||||
|
return { ...s, d: monotonePath(points) };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Direct end labels for up to 4 series, dropping any that would collide
|
||||||
|
const endLabels = computed(() => {
|
||||||
|
if (series.value.length > 4) return [];
|
||||||
|
const placed: { key: string; y: number; value: number }[] = [];
|
||||||
|
const candidates = series.value
|
||||||
|
.map((s) => ({ key: s.key, value: finalTotals.value[s.key] ?? 0 }))
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
for (const c of candidates) {
|
||||||
|
const cy = y.value(c.value);
|
||||||
|
if (placed.every((p) => Math.abs(p.y - cy) >= 12)) placed.push({ ...c, y: cy });
|
||||||
|
}
|
||||||
|
return placed;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Crosshair snaps to the nearest bet
|
||||||
|
const hoverIndex = ref<number | null>(null);
|
||||||
|
const onPointerMove = (event: PointerEvent) => {
|
||||||
|
if (!steps.value.length) return;
|
||||||
|
const rect = (event.currentTarget as SVGElement).getBoundingClientRect();
|
||||||
|
const px = event.clientX - rect.left;
|
||||||
|
let best = 0;
|
||||||
|
steps.value.forEach((step, i) => {
|
||||||
|
if (Math.abs(x.value(step.time) - px) < Math.abs(x.value(steps.value[best].time) - px)) best = i;
|
||||||
|
});
|
||||||
|
hoverIndex.value = best;
|
||||||
|
};
|
||||||
|
const hoverStep = computed(() => (hoverIndex.value === null ? null : steps.value[hoverIndex.value]));
|
||||||
|
|
||||||
|
const optionLabel = (optionUuid: string) =>
|
||||||
|
props.market.options.find((o) => o.uuid === optionUuid)?.text ?? "";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="viz-root">
|
||||||
|
<div class="flex flex-wrap items-baseline justify-between gap-2 mb-2">
|
||||||
|
<h4 class="text-sm font-semibold">{{ t("components.marketCharts.historyTitle") }}</h4>
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-base-content/70">
|
||||||
|
<span v-for="s in series" :key="s.key" class="flex items-center gap-1.5">
|
||||||
|
<span class="legend-line" :style="{ background: s.color }"></span>{{ s.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="container" class="relative transition-opacity" :class="{ 'opacity-50': loading && loaded }">
|
||||||
|
<div v-if="!loaded" class="flex justify-center items-center" :style="{ height: `${HEIGHT}px` }">
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!steps.length" class="flex justify-center items-center text-sm text-base-content/60" :style="{ height: `${HEIGHT}px` }">
|
||||||
|
{{ t("components.marketCharts.noBets") }}
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<svg v-if="width > 0" :width="width" :height="HEIGHT" class="block" @pointermove="onPointerMove" @pointerleave="hoverIndex = null">
|
||||||
|
<g v-for="tick in yTicks" :key="`y${tick}`">
|
||||||
|
<line :x1="MARGIN.left" :x2="MARGIN.left + plotWidth" :y1="y(tick)" :y2="y(tick)" class="grid" />
|
||||||
|
<text :x="MARGIN.left - 8" :y="y(tick)" text-anchor="end" dominant-baseline="middle" class="tick">{{ formatNumber(tick) }}</text>
|
||||||
|
</g>
|
||||||
|
<text v-for="(tick, i) in xTicks" :key="`x${i}`" :x="x(tick)" :y="HEIGHT - 8"
|
||||||
|
:text-anchor="i === 0 ? 'start' : i === xTicks.length - 1 ? 'end' : 'middle'" class="tick">
|
||||||
|
{{ dayjs(tick).format(timeFormat) }}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<path v-for="p in paths" :key="p.key" :d="p.d" class="series-line" :stroke="p.color" />
|
||||||
|
|
||||||
|
<text v-for="label in endLabels" :key="`l${label.key}`" :x="MARGIN.left + plotWidth + 6" :y="label.y" dominant-baseline="middle" class="label">
|
||||||
|
{{ formatNumber(label.value) }}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<g v-if="hoverStep">
|
||||||
|
<line :x1="x(hoverStep.time)" :x2="x(hoverStep.time)" :y1="MARGIN.top" :y2="HEIGHT - MARGIN.bottom" class="crosshair" />
|
||||||
|
<circle v-for="s in series" :key="`h${s.key}`" :cx="x(hoverStep.time)" :cy="y(hoverStep.totals[s.key])" r="4" :fill="s.color" class="marker" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div v-if="hoverStep" class="tooltip-box" :style="{ left: `${Math.min(x(hoverStep.time) + 12, width - 170)}px`, top: `${MARGIN.top}px` }">
|
||||||
|
<div class="text-xs text-base-content/60 mb-1">{{ dayjs(hoverStep.time).format("DD/MM HH:mm") }}</div>
|
||||||
|
<div v-for="s in series" :key="`t${s.key}`" class="flex items-center gap-2">
|
||||||
|
<span class="legend-line" :style="{ background: s.color }"></span>
|
||||||
|
<strong>{{ formatNumber(hoverStep.totals[s.key]) }}</strong>
|
||||||
|
<span class="text-base-content/60 truncate">{{ s.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details v-if="steps.length" class="mt-2 text-xs">
|
||||||
|
<summary class="cursor-pointer text-base-content/60">{{ t("components.marketCharts.showTable") }}</summary>
|
||||||
|
<div class="max-h-48 overflow-y-auto mt-1">
|
||||||
|
<table class="table table-xs tabular-nums">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ t("components.marketCharts.time") }}</th>
|
||||||
|
<th>{{ t("components.marketCharts.option") }}</th>
|
||||||
|
<th class="text-right">{{ t("components.marketCharts.amount") }}</th>
|
||||||
|
<th class="text-right">{{ t("components.marketCharts.optionPool") }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(step, i) in steps" :key="i">
|
||||||
|
<td>{{ dayjs(step.time).format("DD/MM HH:mm") }}</td>
|
||||||
|
<td>{{ optionLabel(step.entry.option_uuid) }}</td>
|
||||||
|
<td class="text-right">+{{ formatNumber(step.entry.amount) }}</td>
|
||||||
|
<td class="text-right">{{ formatNumber(step.totals[seriesKeyFor(step.entry.option_uuid)]) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped src="./chart.css"></style>
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { useElementSize } from "@vueuse/core";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import type { Market, MarketOption } from "../../types";
|
||||||
|
import { SERIES_COLORS, linearScale, niceTicks, formatNumber, monotonePath } from "./chartUtils";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
market: Market;
|
||||||
|
option: MarketOption;
|
||||||
|
// What the user already has on this option (0 if no bet yet)
|
||||||
|
existingAmount: number;
|
||||||
|
// Total stake currently entered in the bet input
|
||||||
|
stake: number;
|
||||||
|
// Highest total stake the user can afford on this option
|
||||||
|
maxStake: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const container = ref<HTMLElement | null>(null);
|
||||||
|
const { width } = useElementSize(container);
|
||||||
|
|
||||||
|
const HEIGHT = 200;
|
||||||
|
const MARGIN = { top: 16, right: 16, bottom: 28, left: 52 };
|
||||||
|
const SAMPLES = 60;
|
||||||
|
|
||||||
|
const totalPot = computed(() => props.market.options.reduce((sum, opt) => sum + opt.total_bets, 0));
|
||||||
|
|
||||||
|
// Mirrors resolve_market: payout = stake / option pool * total pot * multiplier,
|
||||||
|
// with the user's existing bet replaced by the given total stake.
|
||||||
|
const payoutFor = (stake: number) => {
|
||||||
|
const optionPool = props.option.total_bets - props.existingAmount + stake;
|
||||||
|
if (optionPool <= 0) return 0;
|
||||||
|
const pot = totalPot.value - props.existingAmount + stake;
|
||||||
|
return Math.round((stake / optionPool) * pot * props.market.multiplier);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bets can only be increased, so the stake axis starts at the existing bet
|
||||||
|
const xMin = computed(() => props.existingAmount);
|
||||||
|
const xMax = computed(() => Math.max(props.maxStake, props.stake, xMin.value + 1));
|
||||||
|
|
||||||
|
const points = computed(() =>
|
||||||
|
Array.from({ length: SAMPLES + 1 }, (_, i) => {
|
||||||
|
const stake = Math.round(xMin.value + ((xMax.value - xMin.value) * i) / SAMPLES);
|
||||||
|
return { stake, payout: payoutFor(stake) };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const yTicks = computed(() => niceTicks(Math.max(...points.value.map((p) => p.payout), xMax.value)));
|
||||||
|
const plotWidth = computed(() => Math.max(width.value - MARGIN.left - MARGIN.right, 10));
|
||||||
|
const x = computed(() => linearScale([xMin.value, xMax.value], [MARGIN.left, MARGIN.left + plotWidth.value]));
|
||||||
|
const y = computed(() =>
|
||||||
|
linearScale([0, yTicks.value[yTicks.value.length - 1]], [HEIGHT - MARGIN.bottom, MARGIN.top]),
|
||||||
|
);
|
||||||
|
const xTicks = computed(() => [
|
||||||
|
xMin.value,
|
||||||
|
...niceTicks(xMax.value).filter((v) => v > xMin.value && v <= xMax.value && x.value(v) - x.value(xMin.value) > 40),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const payoutPath = computed(() => monotonePath(points.value.map((p) => [x.value(p.stake), y.value(p.payout)])));
|
||||||
|
const stakePath = computed(
|
||||||
|
() => `M${x.value(xMin.value)},${y.value(xMin.value)}L${x.value(xMax.value)},${y.value(xMax.value)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const current = computed(() => {
|
||||||
|
const stake = Math.max(props.stake, props.existingAmount);
|
||||||
|
const payout = payoutFor(stake);
|
||||||
|
return { stake, payout, gain: payout - stake };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the current-stake label inside the plot near either edge
|
||||||
|
const labelAnchor = computed(() => {
|
||||||
|
const ratio = (x.value(current.value.stake) - MARGIN.left) / plotWidth.value;
|
||||||
|
return ratio > 0.7 ? "end" : ratio < 0.15 ? "start" : "middle";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Crosshair snaps to the nearest sampled stake
|
||||||
|
const hover = ref<{ stake: number; payout: number } | null>(null);
|
||||||
|
const onPointerMove = (event: PointerEvent) => {
|
||||||
|
const rect = (event.currentTarget as SVGElement).getBoundingClientRect();
|
||||||
|
const ratio = (event.clientX - rect.left - MARGIN.left) / plotWidth.value;
|
||||||
|
const index = Math.round(Math.min(Math.max(ratio, 0), 1) * SAMPLES);
|
||||||
|
hover.value = points.value[index];
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableRows = computed(() => {
|
||||||
|
const stakes = [0, 0.25, 0.5, 0.75, 1].map((f) => Math.round(xMin.value + (xMax.value - xMin.value) * f));
|
||||||
|
stakes.push(current.value.stake);
|
||||||
|
return [...new Set(stakes)]
|
||||||
|
.filter((stake) => stake > 0)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map((stake) => ({ stake, payout: payoutFor(stake), gain: payoutFor(stake) - stake }));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="viz-root">
|
||||||
|
<div class="flex flex-wrap items-baseline justify-between gap-2 mb-2">
|
||||||
|
<h4 class="text-sm font-semibold">{{ t("components.marketCharts.payoutTitle", { option: option.text }) }}</h4>
|
||||||
|
<div class="flex gap-4 text-xs text-base-content/70">
|
||||||
|
<span class="flex items-center gap-1.5"><span class="legend-line" :style="{ background: SERIES_COLORS[0] }"></span>{{ t("components.marketCharts.payout") }}</span>
|
||||||
|
<span class="flex items-center gap-1.5"><span class="legend-line legend-line--muted"></span>{{ t("components.marketCharts.stake") }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="container" class="relative">
|
||||||
|
<svg v-if="width > 0" :width="width" :height="HEIGHT" class="block" @pointermove="onPointerMove" @pointerleave="hover = null">
|
||||||
|
<!-- Grid + y axis -->
|
||||||
|
<g v-for="tick in yTicks" :key="`y${tick}`">
|
||||||
|
<line :x1="MARGIN.left" :x2="MARGIN.left + plotWidth" :y1="y(tick)" :y2="y(tick)" class="grid" />
|
||||||
|
<text :x="MARGIN.left - 8" :y="y(tick)" text-anchor="end" dominant-baseline="middle" class="tick">{{ formatNumber(tick) }}</text>
|
||||||
|
</g>
|
||||||
|
<text v-for="tick in xTicks" :key="`x${tick}`" :x="x(tick)" :y="HEIGHT - 8" text-anchor="middle" class="tick">{{ formatNumber(tick) }}</text>
|
||||||
|
|
||||||
|
<path :d="stakePath" class="stake-line" />
|
||||||
|
<path :d="payoutPath" class="series-line" :stroke="SERIES_COLORS[0]" />
|
||||||
|
|
||||||
|
<!-- Current stake -->
|
||||||
|
<g v-if="current.stake > 0">
|
||||||
|
<circle :cx="x(current.stake)" :cy="y(current.payout)" r="4" :fill="SERIES_COLORS[0]" class="marker" />
|
||||||
|
<text :x="x(current.stake)" :y="y(current.payout) - 10" :text-anchor="labelAnchor" class="label">
|
||||||
|
{{ formatNumber(current.payout) }} {{ t("common.pts") }}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Hover -->
|
||||||
|
<g v-if="hover">
|
||||||
|
<line :x1="x(hover.stake)" :x2="x(hover.stake)" :y1="MARGIN.top" :y2="HEIGHT - MARGIN.bottom" class="crosshair" />
|
||||||
|
<circle :cx="x(hover.stake)" :cy="y(hover.payout)" r="4" :fill="SERIES_COLORS[0]" class="marker" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div v-if="hover" class="tooltip-box" :style="{ left: `${Math.min(x(hover.stake) + 12, width - 170)}px`, top: `${MARGIN.top}px` }">
|
||||||
|
<div class="text-xs text-base-content/60 mb-1">{{ t("components.marketCharts.ifYouBet", { n: formatNumber(hover.stake) }) }}</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="legend-line" :style="{ background: SERIES_COLORS[0] }"></span>
|
||||||
|
<strong>{{ formatNumber(hover.payout) }}</strong>
|
||||||
|
<span class="text-base-content/60">{{ t("components.marketCharts.payout") }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="legend-line legend-line--muted"></span>
|
||||||
|
<strong>{{ hover.payout - hover.stake >= 0 ? "+" : "" }}{{ formatNumber(hover.payout - hover.stake) }}</strong>
|
||||||
|
<span class="text-base-content/60">{{ t("components.marketCharts.netGain") }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-base-content/60 mt-1">{{ t("components.marketCharts.payoutHint") }}</p>
|
||||||
|
|
||||||
|
<details class="mt-2 text-xs">
|
||||||
|
<summary class="cursor-pointer text-base-content/60">{{ t("components.marketCharts.showTable") }}</summary>
|
||||||
|
<table class="table table-xs mt-1 tabular-nums">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-right">{{ t("components.marketCharts.stake") }}</th>
|
||||||
|
<th class="text-right">{{ t("components.marketCharts.payout") }}</th>
|
||||||
|
<th class="text-right">{{ t("components.marketCharts.netGain") }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in tableRows" :key="row.stake" :class="{ 'font-semibold': row.stake === current.stake }">
|
||||||
|
<td class="text-right">{{ formatNumber(row.stake) }}</td>
|
||||||
|
<td class="text-right">{{ formatNumber(row.payout) }}</td>
|
||||||
|
<td class="text-right">{{ row.gain >= 0 ? "+" : "" }}{{ formatNumber(row.gain) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped src="./chart.css"></style>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/* Shared chart chrome. Marks carry series colors; text and chrome use theme ink. */
|
||||||
|
.grid {
|
||||||
|
stroke: color-mix(in oklab, var(--color-base-content) 12%, transparent);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tick {
|
||||||
|
fill: color-mix(in oklab, var(--color-base-content) 55%, transparent);
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
fill: var(--color-base-content);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-line {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stake-line {
|
||||||
|
fill: none;
|
||||||
|
stroke: color-mix(in oklab, var(--color-base-content) 35%, transparent);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker {
|
||||||
|
stroke: var(--color-base-100);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crosshair {
|
||||||
|
stroke: color-mix(in oklab, var(--color-base-content) 35%, transparent);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-line {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-line--muted {
|
||||||
|
background: color-mix(in oklab, var(--color-base-content) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-box {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
min-width: 150px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--radius-box);
|
||||||
|
background: var(--color-base-200);
|
||||||
|
border: 1px solid color-mix(in oklab, var(--color-base-content) 10%, transparent);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Categorical slots, dark steps (the site always runs the dark "dim" theme).
|
||||||
|
// Validated against the dim card surface (#2a303c): adjacent CVD ΔE >= 8.4.
|
||||||
|
// Order matters for colorblind safety - assign in order, never cycle.
|
||||||
|
export const SERIES_COLORS = [
|
||||||
|
'#3987e5', // blue
|
||||||
|
'#d95926', // orange
|
||||||
|
'#199e70', // aqua
|
||||||
|
'#c98500', // yellow
|
||||||
|
'#d55181', // magenta
|
||||||
|
'#008300', // green
|
||||||
|
'#9085e9', // violet
|
||||||
|
'#e66767', // red
|
||||||
|
]
|
||||||
|
|
||||||
|
export const MAX_SERIES = SERIES_COLORS.length
|
||||||
|
|
||||||
|
export const linearScale = (domain: [number, number], range: [number, number]) => {
|
||||||
|
const [d0, d1] = domain
|
||||||
|
const [r0, r1] = range
|
||||||
|
const span = d1 - d0 || 1
|
||||||
|
return (v: number) => r0 + ((v - d0) / span) * (r1 - r0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round tick values (0 / 250 / 500 ...) covering [0, max]
|
||||||
|
export const niceTicks = (max: number, count = 4): number[] => {
|
||||||
|
if (max <= 0) return [0]
|
||||||
|
const raw = max / count
|
||||||
|
const mag = 10 ** Math.floor(Math.log10(raw))
|
||||||
|
const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? raw
|
||||||
|
const ticks: number[] = []
|
||||||
|
for (let v = 0; v <= max + step * 0.001; v += step) ticks.push(Math.round(v))
|
||||||
|
if (ticks[ticks.length - 1] < max) ticks.push(Math.round(ticks[ticks.length - 1] + step))
|
||||||
|
return ticks
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatNumber = (n: number) => Math.round(n).toLocaleString()
|
||||||
|
|
||||||
|
// Smooth SVG path through the points (monotone cubic, like d3.curveMonotoneX):
|
||||||
|
// it never overshoots, so a growing total never appears to dip.
|
||||||
|
export const monotonePath = (points: [number, number][]): string => {
|
||||||
|
const n = points.length
|
||||||
|
if (n === 0) return ''
|
||||||
|
if (n < 3) return points.map(([px, py], i) => `${i ? 'L' : 'M'}${px},${py}`).join('')
|
||||||
|
|
||||||
|
const slopes: number[] = []
|
||||||
|
for (let i = 0; i < n - 1; i++) {
|
||||||
|
const dx = points[i + 1][0] - points[i][0]
|
||||||
|
slopes.push(dx === 0 ? 0 : (points[i + 1][1] - points[i][1]) / dx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tangents: zero at local extrema, harmonic mean elsewhere
|
||||||
|
const tangents = points.map((_, i) => {
|
||||||
|
if (i === 0) return slopes[0]
|
||||||
|
if (i === n - 1) return slopes[n - 2]
|
||||||
|
const a = slopes[i - 1]
|
||||||
|
const b = slopes[i]
|
||||||
|
return a * b <= 0 ? 0 : (2 * a * b) / (a + b)
|
||||||
|
})
|
||||||
|
|
||||||
|
let d = `M${points[0][0]},${points[0][1]}`
|
||||||
|
for (let i = 0; i < n - 1; i++) {
|
||||||
|
const [x0, y0] = points[i]
|
||||||
|
const [x1, y1] = points[i + 1]
|
||||||
|
const h = (x1 - x0) / 3
|
||||||
|
d += `C${x0 + h},${y0 + h * tangents[i]},${x1 - h},${y1 - h * tangents[i + 1]},${x1},${y1}`
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ const { t } = useI18n();
|
|||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const { userInfo } = storeToRefs(userStore);
|
const { userInfo } = storeToRefs(userStore);
|
||||||
const pageHeaderStore = usePageHeaderStore();
|
const pageHeaderStore = usePageHeaderStore();
|
||||||
const { title: dynamicTitle } = storeToRefs(pageHeaderStore);
|
const { title: dynamicTitle, backRoute: dynamicBackRoute } = storeToRefs(pageHeaderStore);
|
||||||
|
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
if (dynamicTitle.value) return dynamicTitle.value;
|
if (dynamicTitle.value) return dynamicTitle.value;
|
||||||
@@ -21,7 +21,7 @@ const title = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
const backRoute = route.meta.getBackRoute?.(route);
|
const backRoute = dynamicBackRoute.value ?? route.meta.getBackRoute?.(route);
|
||||||
if (backRoute) {
|
if (backRoute) {
|
||||||
router.push(backRoute);
|
router.push(backRoute);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -302,6 +302,24 @@
|
|||||||
"errorPlacingBet": "Error placing bet",
|
"errorPlacingBet": "Error placing bet",
|
||||||
"ended": "Ended"
|
"ended": "Ended"
|
||||||
},
|
},
|
||||||
|
"marketCharts": {
|
||||||
|
"showCharts": "Show charts",
|
||||||
|
"hideCharts": "Hide charts",
|
||||||
|
"payoutTitle": "Potential payout if “{option}” wins",
|
||||||
|
"payout": "Payout",
|
||||||
|
"stake": "Your stake",
|
||||||
|
"netGain": "Net gain",
|
||||||
|
"ifYouBet": "If you bet {n} pts",
|
||||||
|
"payoutHint": "Based on the current pools. Payouts shift as others bet.",
|
||||||
|
"historyTitle": "Points bet over time",
|
||||||
|
"noBets": "No bets yet",
|
||||||
|
"other": "Other",
|
||||||
|
"showTable": "Show data table",
|
||||||
|
"time": "Time",
|
||||||
|
"option": "Option",
|
||||||
|
"amount": "Amount",
|
||||||
|
"optionPool": "Option pool"
|
||||||
|
},
|
||||||
"puzzleCard": {
|
"puzzleCard": {
|
||||||
"byAuthor": "by {author}",
|
"byAuthor": "by {author}",
|
||||||
"moreTags": "+{n} more",
|
"moreTags": "+{n} more",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { polylanSubmitterApiGetUserInfo, marketApiListMarkets, marketApiListUserBets } from '@/api'
|
import { polylanSubmitterApiGetUserInfo, marketApiListMarkets, marketApiListUserBets, marketApiGetUserPoints } from '@/api'
|
||||||
import type { Market } from '@/types'
|
import type { Market } from '@/types'
|
||||||
import type { UserInfoOut, UserBetSchema } from '@/api/types.gen'
|
import type { UserInfoOut, UserBetSchema } from '@/api/types.gen'
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ export const useMarketStore = defineStore('market', () => {
|
|||||||
const markets = ref<Market[]>([])
|
const markets = ref<Market[]>([])
|
||||||
const userInfo = ref<UserInfoOut | undefined>()
|
const userInfo = ref<UserInfoOut | undefined>()
|
||||||
const userBets = ref<UserBetSchema[]>([])
|
const userBets = ref<UserBetSchema[]>([])
|
||||||
|
const userPoints = ref<number>(0)
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const error = ref<string>('')
|
const error = ref<string>('')
|
||||||
|
|
||||||
@@ -49,6 +50,18 @@ export const useMarketStore = defineStore('market', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadUserPoints = async (eventPath: string) => {
|
||||||
|
try {
|
||||||
|
const response = await marketApiGetUserPoints({ path: { event_path: eventPath } })
|
||||||
|
if (response.data) {
|
||||||
|
userPoints.value = response.data.points
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = 'Failed to load user points'
|
||||||
|
console.error('Error loading user points:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const initializeMarketPage = async (eventPath: string) => {
|
const initializeMarketPage = async (eventPath: string) => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -59,9 +72,9 @@ export const useMarketStore = defineStore('market', () => {
|
|||||||
loadMarkets(eventPath),
|
loadMarkets(eventPath),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Load user bets if authenticated
|
// Load user bets and points if authenticated
|
||||||
if (userInfo.value?.is_authenticated) {
|
if (userInfo.value?.is_authenticated) {
|
||||||
await loadUserBets(eventPath)
|
await Promise.all([loadUserBets(eventPath), loadUserPoints(eventPath)])
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
@@ -73,6 +86,7 @@ export const useMarketStore = defineStore('market', () => {
|
|||||||
loadUserInfo(),
|
loadUserInfo(),
|
||||||
loadMarkets(eventPath),
|
loadMarkets(eventPath),
|
||||||
userInfo.value?.is_authenticated ? loadUserBets(eventPath) : Promise.resolve(),
|
userInfo.value?.is_authenticated ? loadUserBets(eventPath) : Promise.resolve(),
|
||||||
|
userInfo.value?.is_authenticated ? loadUserPoints(eventPath) : Promise.resolve(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +95,7 @@ export const useMarketStore = defineStore('market', () => {
|
|||||||
markets,
|
markets,
|
||||||
userInfo,
|
userInfo,
|
||||||
userBets,
|
userBets,
|
||||||
|
userPoints,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
|
||||||
@@ -88,6 +103,7 @@ export const useMarketStore = defineStore('market', () => {
|
|||||||
loadUserInfo,
|
loadUserInfo,
|
||||||
loadMarkets,
|
loadMarkets,
|
||||||
loadUserBets,
|
loadUserBets,
|
||||||
|
loadUserPoints,
|
||||||
initializeMarketPage,
|
initializeMarketPage,
|
||||||
refreshPage,
|
refreshPage,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
import type { RouteLocationRaw } from "vue-router";
|
||||||
|
|
||||||
// Lets a routed page hand AppHeader a title built from data only that page
|
// Lets a routed page hand AppHeader a title built from data only that page
|
||||||
// has fetched (e.g. "Noita Submissions — Semifinal", assembled from an API
|
// has fetched (e.g. "Noita Submissions — Semifinal", assembled from an API
|
||||||
@@ -7,14 +8,22 @@ import { ref } from "vue";
|
|||||||
// stale title never lingers while the next page is still loading.
|
// stale title never lingers while the next page is still loading.
|
||||||
export const usePageHeaderStore = defineStore("pageHeader", () => {
|
export const usePageHeaderStore = defineStore("pageHeader", () => {
|
||||||
const title = ref("");
|
const title = ref("");
|
||||||
|
// Overrides route.meta.getBackRoute when the right target depends on
|
||||||
|
// fetched data (e.g. skip the scope picker when it would auto-redirect).
|
||||||
|
const backRoute = ref<RouteLocationRaw | null>(null);
|
||||||
|
|
||||||
const setTitle = (value: string) => {
|
const setTitle = (value: string) => {
|
||||||
title.value = value;
|
title.value = value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const clear = () => {
|
const setBackRoute = (value: RouteLocationRaw) => {
|
||||||
title.value = "";
|
backRoute.value = value;
|
||||||
};
|
};
|
||||||
|
|
||||||
return { title, setTitle, clear };
|
const clear = () => {
|
||||||
|
title.value = "";
|
||||||
|
backRoute.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { title, backRoute, setTitle, setBackRoute, clear };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ def list_my_scopes(request: HttpRequest, event_path: str, game_path: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(f"{PREFIX}/{{game_path}}/available-scopes", response={200: list[ScopeOut], 403: dict})
|
@router.get(
|
||||||
|
f"{PREFIX}/{{game_path}}/available-scopes",
|
||||||
|
response={200: list[ScopeOut], 403: dict},
|
||||||
|
)
|
||||||
def list_available_scopes(request: HttpRequest, event_path: str, game_path: str):
|
def list_available_scopes(request: HttpRequest, event_path: str, game_path: str):
|
||||||
"""Scopes the current user may submit to for this game - superusers see
|
"""Scopes the current user may submit to for this game - superusers see
|
||||||
every active scope, everyone else only the ones assigned to them. Used
|
every active scope, everyone else only the ones assigned to them. Used
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from django.db import migrations
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("submissions", "0007_submission_scope"),
|
("submissions", "0007_submission_scope"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Test-only username/password login.
|
||||||
|
|
||||||
|
Lets load-testing tools (see loadtest/locustfile.py) authenticate local
|
||||||
|
Django accounts through AUTHENTICATION_BACKENDS' ModelBackend, entirely
|
||||||
|
bypassing SimpleCASBackend - so hammering this app under load never sends a
|
||||||
|
single request to the real CAS server at polylan.ch.
|
||||||
|
|
||||||
|
Only wired up in urls.py when settings.LOAD_TEST_LOGIN_ENABLED is true, and
|
||||||
|
that must stay off in production: it lets anyone who knows a local test
|
||||||
|
account's password log in without going through CAS at all. The "Create
|
||||||
|
load test users" admin action on CustomUser (see accounts/admin.py) is
|
||||||
|
hidden unless that same flag is set, as a second layer of the same guard
|
||||||
|
rail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.contrib.auth import authenticate, login
|
||||||
|
from django.http import HttpRequest, JsonResponse
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.views import View
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
|
class TestLoginView(View):
|
||||||
|
def post(self, request: HttpRequest):
|
||||||
|
username = request.POST.get("username")
|
||||||
|
password = request.POST.get("password")
|
||||||
|
|
||||||
|
user = authenticate(request, username=username, password=password)
|
||||||
|
if user is None:
|
||||||
|
return JsonResponse({"detail": "invalid credentials"}, status=401)
|
||||||
|
|
||||||
|
login(request, user)
|
||||||
|
return JsonResponse({"detail": "ok"})
|
||||||
@@ -12,7 +12,7 @@ export default defineConfig({
|
|||||||
// server, so dev-mode asset URLs (e.g. @font-face url()s injected
|
// server, so dev-mode asset URLs (e.g. @font-face url()s injected
|
||||||
// via JS-imported CSS) must be absolute, not root-relative, or the
|
// via JS-imported CSS) must be absolute, not root-relative, or the
|
||||||
// browser resolves them against Django's origin instead of Vite's.
|
// browser resolves them against Django's origin instead of Vite's.
|
||||||
origin: 'http://localhost:5173',
|
origin: 'http://localhost:5273',
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ dependencies = [
|
|||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
"django-object-actions>=5.1.2",
|
"django-object-actions>=5.1.2",
|
||||||
"python-dotenv>=1.2.3",
|
"python-dotenv>=1.2.3",
|
||||||
|
"django-command-palette>=2022.11.25",
|
||||||
|
"django-admin-interface>=0.32.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
@@ -37,10 +39,12 @@ dev = [
|
|||||||
"django-stubs-ext",
|
"django-stubs-ext",
|
||||||
"django-types",
|
"django-types",
|
||||||
"ipython",
|
"ipython",
|
||||||
|
"locust>=2.46.0",
|
||||||
"pre-commit",
|
"pre-commit",
|
||||||
"pyjwt",
|
"pyjwt",
|
||||||
"pyright",
|
"pyright",
|
||||||
"ruff",
|
"ruff",
|
||||||
|
"websocket-client",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv.workspace]
|
[tool.uv.workspace]
|
||||||
|
|||||||
Reference in New Issue
Block a user