Compare commits

...
8 Commits
50 changed files with 2534 additions and 326 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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):
-58
View File
@@ -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
View File
@@ -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
+41
View File
@@ -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=
+3 -6
View File
@@ -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
-47
View File
@@ -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
-66
View File
@@ -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=
+44 -1
View File
@@ -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}).",
)
+20
View File
@@ -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 = [
+3 -1
View File
@@ -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"
) )
+235
View File
@@ -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()
+3 -3
View File
@@ -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"]}),
+54 -18
View File
@@ -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,
+18
View File
@@ -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)
+10
View File
@@ -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
+2 -1
View File
@@ -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}.",
) )
+6 -4
View File
@@ -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
+6 -2
View File
@@ -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
+5 -3
View File
@@ -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 = [
+3 -1
View File
@@ -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
+46 -19
View File
@@ -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)
+2 -2
View File
@@ -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>
+5
View File
@@ -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
+15 -1
View File
@@ -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
* *
+76
View File
@@ -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
}
+2 -2
View File
@@ -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);
} }
+18
View File
@@ -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",
+19 -3
View File
@@ -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,
} }
+12 -3
View File
@@ -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 };
}); });
+4 -1
View File
@@ -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"),
] ]
+34
View File
@@ -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"})
+1 -1
View File
@@ -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(),
+4
View File
@@ -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]
Generated
+977 -2
View File
File diff suppressed because it is too large Load Diff