Migrated from GitHub
  • HTML 52.7%
  • Python 45.8%
  • Nix 1.5%
Find a file
2026-06-07 13:29:58 +03:00
frunza production hardening: SECRET_KEY via env, DEBUG=False, ALLOWED_HOSTS=[frunza.sudakov.site] 2026-06-07 13:29:58 +03:00
frunza_queue Remove queue up/down buttons — drag-and-drop is the reordering mechanism 2026-06-07 13:26:57 +03:00
.gitignore Initial commit: Frunza Queue — volleyball court rotation manager 2026-06-07 13:20:21 +03:00
ARCHITECTURE.md Hardening pass: transaction safety, rate limiting, active team deletion prevention, rotation docs, DB constraints, admin hardening 2026-06-07 13:25:13 +03:00
devenv.lock Initial commit: Frunza Queue — volleyball court rotation manager 2026-06-07 13:20:21 +03:00
devenv.nix Initial commit: Frunza Queue — volleyball court rotation manager 2026-06-07 13:20:21 +03:00
devenv.yaml Initial commit: Frunza Queue — volleyball court rotation manager 2026-06-07 13:20:21 +03:00
HARDENING_REPORT.md Hardening pass: transaction safety, rate limiting, active team deletion prevention, rotation docs, DB constraints, admin hardening 2026-06-07 13:25:13 +03:00
manage.py Initial commit: Frunza Queue — volleyball court rotation manager 2026-06-07 13:20:21 +03:00
pyproject.toml Initial commit: Frunza Queue — volleyball court rotation manager 2026-06-07 13:20:21 +03:00
README.md Initial commit: Frunza Queue — volleyball court rotation manager 2026-06-07 13:20:21 +03:00
uv.lock Initial commit: Frunza Queue — volleyball court rotation manager 2026-06-07 13:20:21 +03:00

Frunza Queue — Volleyball Court Rotation

A mobile-first Django application that replaces a paper notebook for managing volleyball court rotation queues at Frunzenskaya ("Frunza").

The Problem

At Frunza, volleyball teams rotate through a single court. The current process is entirely verbal:

  1. Two teams are actively playing.
  2. Additional teams line up in a queue.
  3. The next team in queue is responsible for keeping score.
  4. When a match finishes, the winning team stays on court.
  5. The losing team leaves the court.
  6. The next queued team enters the court.
  7. New teams may arrive throughout the day and join the queue.

This becomes confusing quickly — people forget the queue order, score disputes arise, and there is no shared source of truth.

Frunza Queue is that source of truth. It runs on any phone browser and requires no login, no registration, and no setup beyond typing a one-word admin password.

Queue Rules

  1. Two teams play a single match at a time.
  2. All other teams wait in a first-in-first-out queue.
  3. The first team in the queue is "Next Up" — they keep score and enter the court when the current match ends.
  4. When a match ends, the winner stays on court. The loser leaves. The next queued team enters.
  5. If there is no queued team, the winner waits on court until a new team arrives.
  6. A match is never declared automatically — an admin always confirms which team won (or acknowledges a draw / cancel).
  7. Points are recorded manually by an admin for each active team (+1 / -1 per tap).
  8. Teams may arrive at any time. The system starts empty.

Architecture

┌─────────────────────────────────────┐
│         nginx / gunicorn            │  ← HTTPS + WSGI
│         frunza.sudakov.site         │
├─────────────────────────────────────┤
│         Django (frunza/)            │  ← Models, Views, Templates
├─────────────────────────────────────┤
│         SQLite (db.sqlite3)         │  ← Single file, no DB server
└─────────────────────────────────────┘

Stack

Layer Choice Rationale
Framework Django 5.x Mature, batteries-included, SQLite-ready
Database SQLite Zero config, survives reboot, single file
Templating Django Templates Server-rendered, no JS framework needed
Frontend HTMX + CSS Minimal client-side, no build step
WSGI Server Gunicorn Production-grade, easy to configure
Web Server nginx Reverse proxy, static files, HTTPS

Design Decisions

  • No user accounts. The system is shared by everyone at the court. A single admin password prevents random people from editing scores.
  • One court, one match. There is exactly one active match at any time.
  • No tournament logic. No brackets, no elimination rounds, no automatic advancement. Just a queue.
  • State lives in the database. Server restarts are safe.
  • Minimal JS. HTMX for dynamic updates, a few lines of vanilla JS for score buttons. No build step.

Mobile-First UI

The layout is designed for a phone screen held in one hand:

  1. Header — Site name, admin unlock button, admin status indicator.
  2. Active Match — Two team names + scores. Large, bold, tap-friendly.
  3. Queue — List of waiting teams. First one highlighted as "Next Up".
  4. Recent Activity — Small, scrollable event log.

Admin controls appear inline only when admin mode is unlocked.

Database Schema

Team

Column Type Notes
id AutoField (PK)
name CharField(100) Unique, e.g. "Team Eagles"
is_active BooleanField Currently playing
score IntegerField Current match score
queue_order IntegerField Position in queue (nullable)
created_at DateTimeField Auto-now-add

is_active is True for the two teams on court. queue_order is NULL for active teams and a sequential integer for waiting teams. This avoids an extra join table and makes queue reordering trivial.

Match

Column Type Notes
id AutoField (PK)
team_a ForeignKey(Team)
team_b ForeignKey(Team)
score_a IntegerField Final score for team_a
score_b IntegerField Final score for team_b
winner ForeignKey(Team) Nullable — admin picks winner
started_at DateTimeField
ended_at DateTimeField Nullable until match ends

EventLog

Column Type Notes
id AutoField (PK)
timestamp DateTimeField Auto-now-add
event_type CharField(50) E.g. "team_joined", "match_end"
description CharField(255) Human-readable text

AdminSettings

Column Type Notes
id AutoField (PK) Singleton row
admin_password CharField(128) Django-hashed password

Only one row ever exists. The password is set via Django createsuperuser or a management command, never in the admin UI (it would be a security risk to have a self-service password field on a public page).

Setup Instructions

Prerequisites

  • Python 3.11+
  • uv (recommended) or pip
  • (Optional) direnv for automatic environment loading

Local Development

# Clone the repo
git clone <url> frunza
cd frunza

# Create virtual environment and install dependencies
uv sync

# Run database migrations
uv run python manage.py migrate

# Set the admin password (you'll be prompted)
uv run python manage.py set_admin_password

# Start the development server
uv run python manage.py runserver

Visit http://127.0.0.1:8000.

With direnv

If you have direnv and devenv installed:

cd frunza
direnv allow

This will activate the devenv shell automatically when you enter the directory.

Management Commands

set_admin_password

Prompts for an admin password and stores it as a Django-hashed password in the database. Run once during initial setup.

uv run python manage.py set_admin_password

If the password row already exists, it will be updated.

reset

Resets the system to a clean state (deletes all teams, matches, and events).

uv run python manage.py reset

Deployment

Production Server (with SQLite)

The application uses SQLite. For most real-world deployments this is fine — this application has at most ~20 teams and ~100 matches per day. SQLite can handle millions of requests at that scale.

Do not use SQLite over a network filesystem (NFS, distributed storage). Keep db.sqlite3 on local disk.

nginx + Gunicorn

# Install gunicorn (already in dependencies)
uv sync

# Collect static files
uv run python manage.py collectstatic --noinput

# Run with gunicorn (3 workers should be plenty)
uv run gunicorn --workers 3 --bind 127.0.0.1:8001 frunza.wsgi:application

Example nginx config:

server {
    listen 80;
    server_name frunza.sudakov.site;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name frunza.sudakov.site;

    ssl_certificate     /etc/nginx/ssl/frunza.sudakov.site.crt;
    ssl_certificate_key /etc/nginx/ssl/frunza.sudakov.site.key;

    client_max_body_size 4M;

    location /static/ {
        alias /home/denis/frunza/static/;
        expires 30d;
    }

    location / {
        proxy_pass http://127.0.0.1:8001;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

systemd service

[Unit]
Description=Frunza Queue Gunicorn
After=network.target

[Service]
Type=simple
User=denis
WorkingDirectory=/home/denis/frunza
Environment=DJANGO_SETTINGS_MODULE=frunza.settings
ExecStart=/home/denis/frunza/.venv/bin/gunicorn \
    --workers 3 \
    --bind 127.0.0.1:8001 \
    frunza.wsgi:application
Restart=always

[Install]
WantedBy=multi-user.target

Hosting on frunza.sudakov.site

  1. Set up DNS A record pointing frunza.sudakov.site to your server IP.
  2. Configure nginx as above with SSL certificates (Let's Encrypt).
  3. Create the db.sqlite3 file in /home/denis/frunza/ with proper permissions so the gunicorn user can read/write.
  4. Run set_admin_password once.
  5. Start the gunicorn service.

Routes

Method Path Description
GET / Main page
POST /admin/unlock/ Unlock admin mode (accepts password)
POST /admin/lock/ Lock admin mode
POST /teams/add/ Add a team (accepts name)
POST /teams//rename/ Rename a team (accepts name)
POST /teams//delete/ Delete a team
POST /teams//move-up/ Move team up in queue
POST /teams//move-down/ Move team down in queue
POST /match/score/ Adjust score (accepts team_id, points)
POST /match/winner/ Declare winner (accepts winner_id)
POST /match/start/ Start a match with two teams

HTTP + HTMX Notes

All POST endpoints redirect back to / on success. If HTMX is enabled on the client, the response carries HX-Redirect headers so the page automatically refreshes.

For score updates, a targeted HTMX swap pattern is used: the match card is replaced in-place without a full page reload.

Assumptions

  1. One court only. This is not configurable. The app models a single volleyball court with a single queue.
  2. No user accounts. Everyone at the court sees the same view. The admin password is a shared secret (like a WiFi password).
  3. The admin is present. An admin must add teams, record scores, and declare winners. This mirrors the current paper system where someone is always managing the queue.
  4. No automatic winner detection. The admin decides who won and triggers the rotation. This handles edge cases like draws, walk-offs, and informal games gracefully.
  5. Teams arrive gradually. The system starts empty. Teams are added one by one as people show up.
  6. SQLite is sufficient. Daily traffic is at most a few hundred requests from a handful of phones. SQLite handles this easily.
  7. Phone-first. The UI targets a mobile viewport. Desktop is supported but not optimised.
  8. The database is the source of truth. No state is stored in memory. Server restarts are safe.

Future Roadmap (V2+ Ideas)

These are explicitly not part of V1. They are listed here to document what was considered and deferred.

  • Match timer / clock display — Show elapsed time on the active match.
  • History page — Browse completed matches and past events.
  • Team stats — Win/loss records, streak tracking.
  • Persistent team database — Teams re-joined later in the day are recognised and their history preserved.
  • Dark mode — Mobile-friendly dark theme.
  • Multiple courts — If Frunza ever adds a second court.
  • Web push notifications — Notify the next-up team it's their turn.
  • Export / share — Share queue status as a simple text message.
  • API — For future integrations or bots.

Development

Project Structure

frunza/
├── README.md
├── pyproject.toml
├── manage.py
├── frunza/                  # Django project package
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   ├── wsgi.py
│   └── asgi.py
├── frunza_queue/            # Main app (named to avoid stdlib `queue` conflict)
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── models.py
│   ├── views.py
│   ├── urls.py
│   ├── templates/
│   │   └── frunza_queue/
│   │       └── index.html
│   ├── static/
│   │   └── frunza_queue/
│   │       └── style.css
│   └── management/
│       └── commands/
│           ├── set_admin_password.py
│           └── reset.py
├── static/                  # Collected static files
└── db.sqlite3               # Created after first migrate

Adding Features

  1. Model changes → python manage.py makemigrations && migrate
  2. View changes → edit frunza_queue/views.py
  3. Template changes → edit frunza_queue/templates/frunza_queue/index.html
  4. Style changes → edit frunza_queue/static/frunza_queue/style.css

License

MIT