From 9ccd9116a9ac47159baab50d071d9b2b44db129e Mon Sep 17 00:00:00 2001 From: Job Doesburg Date: Sun, 28 Jun 2026 21:24:44 +0200 Subject: [PATCH 1/6] README: rewrite for new contributors; add Mermaid diagrams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README had grown into a marketing-y feature list with thin architecture, no diagrams, and the production-ops surface (Sentry, SURFconext, Yivi server, Silvasoft, fridge client) mentioned only in passing or not at all. Restructure for the reader who is actually landing here for the first time. - New "What it does" section that explains the user-facing surface in plain language, replacing the "πŸš€ Features" bullet list that read like a brochure. - New "Architecture at a glance" section with two Mermaid diagrams that render natively on GitHub: - **App + external-service graph** showing the natural data-flow relationships between the Django apps and the outside systems they talk to. Helps a new contributor see at a glance which app fits where and what depends on what. - **Auth flow sequence diagram** covering SAML login β†’ OAuth consent β†’ token β†’ API/MCP request, plus the optional Yivi age- verification side-quest for the beer fridge. This is the most asked-about flow in the codebase and never had a picture. - New "Operations & external services" table β€” Sentry (production error tracking, with the org URL), SURFconext SAML federation (where to manage the SP), Yivi, Spotify, Marietje, Silvasoft, the TOSTI-fridge-client repo, and GitHub Actions. Each row says what the service does and where to look on the TOSTI side. - The OAuth/MCP section now leads with a "use authorization-code + PKCE" line and points at the in-app docs (``/oauth/docs/``, ``/mcp/docs/``, ``/api/docs``) as the source of truth. Drops the duplicated step-by-step MCP auth flow (the diagram covers it and ``/oauth/docs/`` has it in detail). - The ``πŸ“ Project structure`` tree gets updated descriptions for the apps that grew (tampon, tosti now hosts OAuth + MCP infra), and notes which apps will have their own README. - Dropped the ``πŸ“ Code Style`` subsection β€” duplicates CONTRIBUTING.md/AGENTS.md and was drifting. - Dropped the ``πŸ”’ Security`` near-duplicate section β€” points at SECURITY.md instead. - Fixed the ``- -`` typo in the Acknowledgments list. - Tightened the development-setup steps from a numbered list to a single annotated shell block so newcomers can copy-paste through it. No CI / setup commands actually changed β€” this is pure documentation. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 435 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 249 insertions(+), 186 deletions(-) diff --git a/README.md b/README.md index 54b0ee77..10cadd4b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ [![Deploy](https://github.com/KiOui/TOSTI/actions/workflows/deploy.yaml/badge.svg)](https://github.com/KiOui/TOSTI/actions/workflows/deploy.yaml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -TOSTI is a comprehensive web application designed for [Tartarus](https://tartarus.science.ru.nl) to manage take-away orders and various other features for student associations at Radboud University. +TOSTI is the order system that runs the canteens in the Huygens building at Radboud University. Originally built for [Tartarus](https://tartarus.science.ru.nl) and now shared across study associations that operate canteens there, it covers ordering, payments, music, age-gated beer fridges, venue reservations, borrels, and a handful of adjacent things that the canteens need. + +It lives at and is maintained by the website committee of Tartarus. ## 🎯 Scope β€” what belongs in TOSTI @@ -18,121 +20,207 @@ If a proposed feature fails any of these tests, it doesn't belong in TOSTI β€” e When in doubt, **don't**. Open an issue and let the website committee weigh in before writing code. -## πŸš€ Features - -### Core Features -- **Order Management System**: Online ordering system for take-away items (such as tostis) -- **Financial Transactions**: User balance tracking and transaction management -- **User Authentication**: SAML-based SSO integration (with Radboud University via SURFconext) -- **Music Control**: Spotify and Marietje integration for controlling music players -- **Room Reservations**: Venue reservation system with calendar integration -- **Borrel Management**: Event reservation system with inventory tracking -- **Age Verification**: Yivi-based age verification system -- **Smart Fridge Access**: Digital lock system for automated fridge access, using [TOSTI-fridge-client](https://github.com/KiOui/TOSTI-fridge-client) -- **QR Code Identification**: Token-based user identification system -- **Bookkeeping Integration**: Synchronization with Silvasoft accounting system - -### Additional Features -- Multi-venue support with separate canteens (North/South) -- Real-time order status tracking -- Statistics and analytics dashboard -- OAuth2 API for third-party integrations -- iCal feeds for reservations -- Automated music scheduling - -## πŸ—οΈ Architecture - -TOSTI is built using: -- **Backend**: Django 6 (Python) -- **Frontend**: Django templates with Bootstrap 5 -- **Database**: PostgreSQL (production) / SQLite (development) -- **Caching**: File-based cache (production) / In-memory (development) -- **Authentication**: SAML2 (via djangosaml2) -- **API**: Django REST Framework with OAuth2 + drf-spectacular for OpenAPI -- **MCP server**: in-process (`django-mcp-server`) at `/mcp` -- **Task Scheduling**: Custom cron implementation -- **Containerization**: Docker & Docker Compose +## πŸš€ What it does + +The user-facing surface: + +- **Ordering** at the canteen counter β€” pick a venue, pick products, the bakers see your order in a live queue and call your name when it's ready. +- **Music** in the canteens β€” a shared jukebox where anyone with an account can request a song to be added to the venue's queue. Backed by Spotify in the Noordkantine and Marietje (read-only) in the Zuidkantine. +- **Beer fridges** β€” age-gated digital locks. Verify your age once with Yivi and the fridge will open for you (during opening hours). +- **Venue reservations** β€” book canteens and meeting rooms; a calendar shows what's coming up. +- **Borrels** β€” event reservations with inventory tracking. +- **Bathroom-stock notifications (T.A.M.P.O.N.)** β€” report empty or damaged menstrual-product boxes so the MenstruaCie can restock. +- **Balance & deposits** β€” hand in deposit cans for credit, spend the credit on future purchases. + +Cross-cutting: + +- **Single sign-on** via SAML (SURFconext federation β†’ Radboud accounts). +- **Public REST API** at `/api/v1/` with OAuth 2.0 authentication. +- **MCP server** at `/mcp` so AI assistants can act on a user's behalf with their permission. See [`/oauth/docs/`](https://tosti.science.ru.nl/oauth/docs/) and [`/mcp/docs/`](https://tosti.science.ru.nl/mcp/docs/) on a running deployment. +- **iCal feeds** for reservations. +- **Statistics page** showing the canteens' activity. + +## πŸ—οΈ Architecture at a glance + +```mermaid +graph LR + subgraph "External services" + SURFconext[SURFconext SAML] + Sentry[Sentry] + Spotify[Spotify API] + Marietje[Marietje] + Yivi[Yivi server] + Silvasoft[Silvasoft accounting] + FridgeClient[TOSTI fridge client] + end + + subgraph "TOSTI Django apps" + users[users] + tosti[tosti] + orders[orders] + thaliedje[thaliedje] + venues[venues] + borrel[borrel] + fridges[fridges] + age[age] + yivi_app[yivi] + tampon[tampon] + transactions[transactions] + silvasoft_app[silvasoft] + end + + SURFconext -. SAML .-> users + users --> tosti + orders --> users + orders --> venues + orders --> transactions + thaliedje --> users + thaliedje --> venues + thaliedje -. requests .-> Spotify + thaliedje -. requests .-> Marietje + venues --> users + borrel --> venues + borrel --> users + borrel --> transactions + fridges --> users + fridges --> age + fridges -. unlock .-> FridgeClient + age --> yivi_app + yivi_app -. attribute disclosure .-> Yivi + tampon --> users + transactions --> users + silvasoft_app --> transactions + silvasoft_app -. invoices .-> Silvasoft + tosti -. errors .-> Sentry +``` + +The graph isn't a hard import dependency β€” it's the natural data-flow story. Each app keeps its concerns local; cross-app coupling is meant to stay minimal (see [Modular Django apps](#modular-django-apps)). + +### Tech stack + +- **Backend**: Django 6 on Python 3.14. +- **Frontend**: Django templates + Bootstrap 5; Vue 3 for the interactive bits. +- **Database**: PostgreSQL in production, SQLite in development. +- **Caching**: file-backed in production, in-memory in development. +- **Async tasks**: Celery + Redis for jobs (e.g. emails); `django-celery-beat` schedules periodic work. +- **Auth**: SAML 2 (`djangosaml2`) federating to SURFconext. OAuth 2.0 (`django-oauth-toolkit`) for the public API + MCP. +- **API**: Django REST Framework + `drf-spectacular` for OpenAPI. +- **MCP**: in-process `django-mcp-server` at `/mcp`. +- **Error tracking**: Sentry SDK. +- **Container/deploy**: Docker Compose + Caddy on a self-hosted VM; CI/CD via GitHub Actions. + +### Auth flow (SAML + OAuth + MCP) + +The same identity chain serves the website, the API, and AI assistants. Anything that protects a route ultimately resolves through this path: + +```mermaid +sequenceDiagram + autonumber + participant User + participant Client as Client (browser / app / MCP) + participant TOSTI + participant SURF as SURFconext (SAML IdP) + participant Yivi as Yivi server + + Client->>TOSTI: GET protected resource + TOSTI-->>Client: 401 + WWW-Authenticate (RFC 9728 metadata) + Note over Client,TOSTI: For MCP / OAuth clients only β€” browser users see a login page. + + Client->>TOSTI: GET /.well-known/oauth-authorization-server + Client->>TOSTI: POST /oauth/register/ (RFC 7591, public client) + Client->>TOSTI: redirect User to /oauth/authorize/?code_challenge=… + + TOSTI->>User: redirect to SURFconext SAML login + User->>SURF: Radboud username + password + SURF-->>TOSTI: SAML assertion β†’ Django session created + + TOSTI->>User: consent screen (which scopes to grant) + User-->>TOSTI: ticks scopes, hits "Authorise" + + TOSTI-->>Client: redirect with authorization code + Client->>TOSTI: POST /oauth/token/ (code + verifier) + TOSTI-->>Client: access_token (+ refresh_token) + + Client->>TOSTI: API/MCP call with Authorization: Bearer … + + opt If the resource needs age-verified user (beer fridge) + TOSTI->>User: show "verify your age" QR + User->>Yivi: scan, disclose "over 18" + student-number attributes + Yivi-->>TOSTI: verified disclosure + TOSTI-->>User: age verified, fridge will open during opening hours + end +``` + +The `/oauth/docs/` page on a running deployment goes into detail on the OAuth flow specifically (which grant type, where to register, what scopes exist). ### Modular Django apps Each piece of TOSTI functionality lives in its own Django app, with **minimal cross-app dependencies**. The goal is that adding or removing a feature should be as simple as adding or removing a line from `INSTALLED_APPS` β€” no other app should break, no template should fail to render, no URL should 500. -In practice this means: models, views, URLs, services, signals, API endpoints (`/api/v1/`), and **MCP tools** (`/mcp.py`) for a feature all live inside that feature's app. The `tosti` app contains only shared infrastructure (settings, base templates, cross-cutting helpers). When you build a new feature, build it as its own app β€” see `CONTRIBUTING.md` for the conventions. +In practice: models, views, URLs, services, signals, API endpoints (`/api/v1/`), and MCP tools (`/mcp.py`) for a feature all live inside that feature's app. The `tosti` app contains only shared infrastructure (settings, base templates, cross-cutting helpers). -## πŸ“ Project Structure +When you build a new feature, build it as its own app. The full rationale and conventions are in [`AGENTS.md`](AGENTS.md) and [`CONTRIBUTING.md`](CONTRIBUTING.md). -``` +## πŸ“ Project structure + +```text website/ -β”œβ”€β”€ age/ # Age verification module -β”œβ”€β”€ announcements/ # System announcements -β”œβ”€β”€ associations/ # Student associations management -β”œβ”€β”€ borrel/ # Event/borrel reservation system -β”œβ”€β”€ cron/ # Custom cron job implementation -β”œβ”€β”€ fridges/ # Smart fridge access control -β”œβ”€β”€ orders/ # Core ordering system -β”œβ”€β”€ qualifications/ # User qualifications (e.g., borrel brevet) -β”œβ”€β”€ silvasoft/ # Bookkeeping integration -β”œβ”€β”€ status_screen/ # Order status display -β”œβ”€β”€ thaliedje/ # Music player control -β”œβ”€β”€ tosti/ # Main application settings -β”œβ”€β”€ transactions/ # Financial transactions -β”œβ”€β”€ users/ # User management -β”œβ”€β”€ venues/ # Venue reservation system -└── yivi/ # Yivi integration for age verification +β”œβ”€β”€ age/ # Age verification gate (fronts yivi/) +β”œβ”€β”€ announcements/ # Banner / popup announcements +β”œβ”€β”€ associations/ # Study associations metadata +β”œβ”€β”€ borrel/ # Borrel reservations + inventory +β”œβ”€β”€ cron/ # Custom periodic-job framework +β”œβ”€β”€ fridges/ # Smart beer-fridge unlock flow +β”œβ”€β”€ orders/ # Core: shifts, products, orders +β”œβ”€β”€ qualifications/ # User qualifications (e.g. borrel brevet) +β”œβ”€β”€ silvasoft/ # Silvasoft accounting sync +β”œβ”€β”€ status_screen/ # Order status display for the canteen TV +β”œβ”€β”€ tampon/ # T.A.M.P.O.N. menstrual-products tracker +β”œβ”€β”€ thaliedje/ # Music players (Spotify + Marietje) +β”œβ”€β”€ tosti/ # Shared infra: settings, templates, OAuth, MCP +β”œβ”€β”€ transactions/ # User balance + transactions log +β”œβ”€β”€ users/ # User model + auth glue +β”œβ”€β”€ venues/ # Venues + reservations + calendar +└── yivi/ # Yivi attribute-disclosure adapter ``` -## πŸ› οΈ Development Setup +Bigger apps (`orders`, `thaliedje`, `tosti`, `silvasoft`, `venues`, `borrel`) have their own `README.md` with the data model and quirks. Smaller apps are short enough to read directly. + +## πŸ› οΈ Local development ### Prerequisites -- Python 3.14+ (recommended to use [pyenv](https://github.com/pyenv/pyenv)) + +- Python 3.14+ (recommended: [pyenv](https://github.com/pyenv/pyenv)) - [uv](https://docs.astral.sh/uv/) for dependency management - Git -### Installation +### Setup -1. **Clone the repository** - ```bash - git clone https://github.com/KiOui/TOSTI.git - cd TOSTI - ``` - -2. **Install uv** - ```bash - curl -LsSf https://astral.sh/uv/install.sh | sh - ``` +```bash +git clone https://github.com/KiOui/TOSTI.git +cd TOSTI -3. **Set up uv environment** - ```bash - uv sync --locked --all-extras --dev - ``` +# Install uv if you don't have it +curl -LsSf https://astral.sh/uv/install.sh | sh -4. **Set up the database** - ```bash - cd website - uv run ./manage.py migrate - ``` +# Install dependencies + dev tools +uv sync --locked --all-extras --dev -5. **Create a superuser** - ```bash - uv run ./manage.py createsuperuser - ``` +# Initialise the database +cd website +uv run ./manage.py migrate -6. **Load initial data (optional)** - ```bash - uv run ./manage.py loaddata tosti/fixtures/default.json - ``` +# Create a superuser for the admin +uv run ./manage.py createsuperuser -7. **Run the development server** - ```bash - uv run ./manage.py runserver - ``` +# Optional: load demo fixtures +uv run ./manage.py loaddata tosti/fixtures/default.json -The application will be available at `http://localhost:8000`. +# Run the dev server +uv run ./manage.py runserver +``` -### Development Notes -- SAML authentication is disabled in development mode -- Use `/admin-login` in production for local authentication -- API documentation is available at `/api/docs` +The app will be on . SAML is disabled in development; use `/admin-login` to sign in with the superuser you just created. Interactive API docs are at `/api/docs` once the server is running. ### Vendored frontend libraries @@ -184,13 +272,30 @@ The Neucha font is also self-hosted under `website/tosti/static/tosti/fonts/`. Verify Vue: `head -2 website/tosti/static/tosti/js/vue.global.prod.js` should print the expected version, and the file should be a single-line minified bundle (not ~16 000 lines of source β€” that would be the dev build in disguise). -## 🐳 Production Deployment +### Tests and lint + +```bash +cd website +uv run python manage.py test # full suite +uv run python manage.py test # one app +uv run coverage run manage.py test && \ + uv run coverage report + +# Lint + format +uv run black website +uv run flake8 website +uv run pydocstyle website +``` + +CI runs the same things on every push. Treat failures as blocking. + +## 🐳 Production deployment -TOSTI runs in production as a Docker Compose stack on a VM. Deployments are automated: every push to `master` that passes CI (test + lint + image build) is deployed by `.github/workflows/deploy.yaml`. +TOSTI runs in production as a Docker Compose stack on a VM. Every push to `master` that passes CI (test + lint + image build) is deployed automatically by `.github/workflows/deploy.yaml`. Deployment configuration β€” `docker-compose.yml`, `Caddyfile`, `.env.example` β€” lives in [`deploy/`](deploy/). See [`deploy/README.md`](deploy/README.md) for VM prerequisites, required GitHub secrets, and rollback procedure. -To run the stack manually (e.g. on a fresh VM before CI is wired up): +Manual stack-up (e.g. on a fresh VM before CI is wired): ```bash cd deploy @@ -198,119 +303,77 @@ cp .env.example .env # fill in POSTGRES_PASSWORD, DJANGO_SECRET_KEY, YIVI_TOKEN docker compose up -d ``` -## πŸ”§ Configuration - -Specific configuration is managed through Django Constance for runtime settings: +## 🌐 Operations & external services -- **General**: Footer text, cleaning scheme URL -- **Email**: Notification recipients for reservations -- **Shifts**: Default maximum orders per shift -- **Music (Thaliedje)**: Start/stop times, holiday mode -- **Silvasoft**: API credentials for bookkeeping -- **Fridges**: Daily opening requirements +TOSTI is small but it touches a handful of external systems. If something breaks in production, the source is usually one of these: -## πŸ“‘ API +| Service | What it does | Where to look | +| --- | --- | --- | +| **Sentry** | Captures unhandled exceptions, performance traces, and selected log messages from the production deployment. First stop for any "something blew up" investigation. | | +| **SURFconext** | The Dutch research-and-education SAML federation. TOSTI is registered as a service provider so any Radboud student can sign in with their RU account. The integration is configured in `tosti/settings/production.py:SAML_CONFIG`. | | +| **Yivi server** | Backs age verification for the beer fridge. TOSTI asks Yivi for two attributes (over-18 proof + student number); Yivi delivers them if the user discloses them through the app. | Β· TOSTI side in [`age/`](website/age/) and [`yivi/`](website/yivi/) | +| **Spotify Web API** | Backs the Noordkantine music player. The OAuth token for the canteen account is stored encrypted in the DB. Rotation is manual. | TOSTI side in [`thaliedje/`](website/thaliedje/) | +| **Marietje** | Backs the Zuidkantine music player. Read-only from TOSTI's side. | TOSTI side in [`thaliedje/marietje.py`](website/thaliedje/marietje.py) | +| **Silvasoft** | The accounting system. TOSTI syncs invoices for sales and balance top-ups. | TOSTI side in [`silvasoft/`](website/silvasoft/) | +| **TOSTI-fridge-client** | The hardware-side daemon that runs on the Raspberry Pi inside each beer fridge. It polls TOSTI to learn whether a given QR-code-bearing user is allowed to open the fridge. | [Repo](https://github.com/KiOui/TOSTI-fridge-client) | +| **GitHub Actions** | CI (test + lint + image build) and CD (push-based deploy to the VM). | [Workflows](.github/workflows/) | -TOSTI provides a RESTful API with OAuth 2.0 authentication. **For any integration with TOSTI you should use the authorization-code grant with PKCE** β€” see the [OAuth integration guide](/oauth/docs/) on a running deployment for the full flow, scopes, registration, and tokens. +Each integration has a dedicated section in the relevant app's README (where one exists) explaining the auth model, the call shape, and the known sharp edges. -### Available Scopes -- `read`: Read access to the API -- `write`: Write access to the API -- `orders:order`: Place orders -- `orders:manage`: Manage all orders -- `thaliedje:request`: Request songs -- `thaliedje:manage`: Control music players -- `transactions:write`: Create transactions +## πŸ”§ Runtime configuration -### API Documentation -Interactive API documentation is available at `/api/docs` when running the application. +Most settings are environment variables (see `deploy/.env.example`). A handful of operational toggles are exposed via Django Constance for runtime editing without a redeploy: -### MCP server (Model Context Protocol) +- **General**: footer text, cleaning-scheme URL. +- **Email**: notification recipients for reservations. +- **Shifts**: default maximum orders per shift. +- **Music (Thaliedje)**: start/stop times, holiday mode. +- **Silvasoft**: API credentials for bookkeeping. +- **Fridges**: daily opening requirements. -TOSTI exposes a small subset of the API as LLM-callable tools at `/mcp`. This lets any MCP-compatible AI assistant read state and act on the user's behalf with the same OAuth2 / session credentials they'd use for the REST API. +Edit these from `/admin/constance/config/` in production. -Each app contributes its own tools via an `/mcp.py` module, auto-discovered by `django-mcp-server`. To add a tool, drop a method on a `MCPToolset` subclass in the relevant app β€” no central registration. Tools currently published: +## πŸ“‘ API & MCP -| Tool | Owning app | Required scope | Description | -| --- | --- | --- | --- | -| `list_venues` | `venues` | none | List all venues. | -| `create_venue_reservation` | `venues` | `write` | Request (unaccepted) a venue reservation. | -| `list_active_shifts` | `orders` | none | List shifts currently open for ordering. | -| `place_order` | `orders` | `orders:order` | Place a single-item order in an active shift. | -| `get_player_state` | `thaliedje` | none | Current track & playback state for a venue's player. | -| `search_tracks` | `thaliedje` | none | Search the music catalog via a venue's player. | -| `request_song` | `thaliedje` | `thaliedje:request` | Add a track to a player's queue. | +TOSTI has a public REST API at `/api/v1/` and an MCP server at `/mcp`. Both speak OAuth 2.0. The integration guide on a running deployment is the source of truth: -**Auth flow** (works with any RFC-compliant MCP client): +- [`/api/docs`](https://tosti.science.ru.nl/api/docs) β€” interactive Swagger UI for the REST API. +- [`/oauth/docs/`](https://tosti.science.ru.nl/oauth/docs/) β€” OAuth integration guide (which grant type, how to register a client, scopes, redirect URIs). +- [`/mcp/docs/`](https://tosti.science.ru.nl/mcp/docs/) β€” reference for every tool the MCP server exposes. +- [`/explainers/#ai-assistant`](https://tosti.science.ru.nl/explainers/#ai-assistant) β€” end-user how-to for connecting Claude / similar AI assistants. -1. Client POSTs to `/mcp` without credentials β†’ server returns `401` with `WWW-Authenticate: Bearer realm="tosti", resource_metadata="https://tosti.science.ru.nl/.well-known/oauth-protected-resource"`. -2. Client GETs the resource metadata (RFC 9728) β†’ discovers the authorization server. -3. Client GETs `/.well-known/oauth-authorization-server` (RFC 8414) β†’ discovers the `registration_endpoint`, `authorization_endpoint`, `token_endpoint`, supported scopes, etc. -4. Client POSTs to `/oauth/register/` (RFC 7591) with its redirect URIs β†’ server creates a public OAuth2 application on the fly and returns the `client_id`. -5. Client runs the standard authorization-code-with-PKCE flow. User logs in via SAML β†’ SURFconext β†’ user lands on TOSTI's consent screen β†’ approves the scopes the client asked for. -6. Client receives an access token; subsequent MCP calls go to `/mcp` with `Authorization: Bearer `. +**For new integrations: use the authorization-code grant with PKCE.** That's the only flow our tooling, docs and examples are written for. See `/oauth/docs/` for the full details. -Authenticated MCP requests pass through `OAuth2Authentication` (or `SessionAuthentication` for the browser-flow case) β€” same chain as DRF. - -## πŸ§ͺ Testing - -Run the test suite: -```bash -cd website -uv run python manage.py test -``` - -Run with coverage: -```bash -uv run coverage run website/manage.py test website/ -uv run coverage report -``` +### Available scopes -## πŸ” Code Quality - -### Linting -```bash -uv run black website -uv run flake8 website -uv run pydocstyle website -``` - -### Checks -The project uses GitHub Actions for automated testing and linting on every push. +- `read` β€” authenticated read access to the website +- `write` β€” authenticated write access to the website +- `orders:order` β€” place orders on the user's behalf +- `orders:manage` β€” manage orders on the user's behalf +- `thaliedje:request` β€” request songs on the user's behalf +- `thaliedje:manage` β€” manage music players on the user's behalf +- `transactions:write` β€” create transactions ## 🀝 Contributing -Contributions are welcome! - -### Development Workflow -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Run tests and linting -5. Submit a pull request +Contributions welcome. The conventions live in [`CONTRIBUTING.md`](CONTRIBUTING.md) (humans) and [`AGENTS.md`](AGENTS.md) (AI coding tools). Both repeat the same scope-check up top β€” read it before writing code. -### Code Style -- Follow PEP 8 -- Use Black for formatting -- Write docstrings for all functions -- Maximum line length: 119 characters +For a security issue, see [`SECURITY.md`](SECURITY.md). ## πŸ“§ Contact -- **Maintainers**: Website committee of Tartarus -- **Email**: tartaruswebsite@science.ru.nl -- **Security Issues**: www-tosti@science.ru.nl - -## πŸ”’ Security - -For security vulnerabilities, please email www-tosti@science.ru.nl instead of creating a public issue. +- **Maintainers**: Website committee of Tartarus. +- **General**: . +- **Security**: (see [`SECURITY.md`](SECURITY.md)). ## πŸ“„ License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +MIT β€” see [`LICENSE`](LICENSE). ## πŸ™ Acknowledgments -- Original developers: Lars van Rhijn, Job Doesburg -- All contributors who have helped improve TOSTI -- - CNCZ for hosting infrastructure +- Original developers: Lars van Rhijn, Job Doesburg. +- Everyone who has contributed to TOSTI since. +- CNCZ for the hosting infrastructure. + + \ No newline at end of file From 0e1a7645fdf65d9a9e30b6ee10e11e1c621d194c Mon Sep 17 00:00:00 2001 From: Job Doesburg Date: Sun, 28 Jun 2026 21:34:08 +0200 Subject: [PATCH 2/6] README: drop the external-services subgraph and the Yivi auth side-quest Per review feedback: - Removing the ``subgraph "External services"`` box around the external nodes. The shape of the box was suggesting more grouping than was useful; the external services already read as external by not being inside the TOSTI box. - Removing the optional Yivi age-verification block from the auth sequence diagram. The diagram is about the SAML+OAuth chain that protects every route; Yivi is a separate one-shot disclosure flow that runs once on the age-verification page, not on every API call. It was distracting and slightly misleading. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 10cadd4b..8391d7da 100644 --- a/README.md +++ b/README.md @@ -44,15 +44,13 @@ Cross-cutting: ```mermaid graph LR - subgraph "External services" - SURFconext[SURFconext SAML] - Sentry[Sentry] - Spotify[Spotify API] - Marietje[Marietje] - Yivi[Yivi server] - Silvasoft[Silvasoft accounting] - FridgeClient[TOSTI fridge client] - end + SURFconext[SURFconext SAML] + Sentry[Sentry] + Spotify[Spotify API] + Marietje[Marietje] + Yivi[Yivi server] + Silvasoft[Silvasoft accounting] + FridgeClient[TOSTI fridge client] subgraph "TOSTI Django apps" users[users] @@ -120,7 +118,6 @@ sequenceDiagram participant Client as Client (browser / app / MCP) participant TOSTI participant SURF as SURFconext (SAML IdP) - participant Yivi as Yivi server Client->>TOSTI: GET protected resource TOSTI-->>Client: 401 + WWW-Authenticate (RFC 9728 metadata) @@ -142,13 +139,6 @@ sequenceDiagram TOSTI-->>Client: access_token (+ refresh_token) Client->>TOSTI: API/MCP call with Authorization: Bearer … - - opt If the resource needs age-verified user (beer fridge) - TOSTI->>User: show "verify your age" QR - User->>Yivi: scan, disclose "over 18" + student-number attributes - Yivi-->>TOSTI: verified disclosure - TOSTI-->>User: age verified, fridge will open during opening hours - end ``` The `/oauth/docs/` page on a running deployment goes into detail on the OAuth flow specifically (which grant type, where to register, what scopes exist). From 594bf9fa9fbbd8b86178faf3e1d06f4875e4c44c Mon Sep 17 00:00:00 2001 From: Job Doesburg Date: Sun, 28 Jun 2026 21:36:41 +0200 Subject: [PATCH 3/6] README: fix SURFconext URL + use the canonical www-tosti email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SURFconext: the right management URL is the SP dashboard at https://sp.surfconext.nl/ (with the entity id ``spd_ru_tosti``), managed through the welcome portal at https://welcome.surfconext.nl/ β€” not the IdP serviceregistry I'd listed. - Contact section: drop ``tartaruswebsite@science.ru.nl`` (legacy) and point everything at the canonical ``www-tosti@science.ru.nl``, the address SECURITY.md already uses. Same inbox; one address for everything keeps the project surface consistent. --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8391d7da..da8cd6d8 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ TOSTI is small but it touches a handful of external systems. If something breaks | Service | What it does | Where to look | | --- | --- | --- | | **Sentry** | Captures unhandled exceptions, performance traces, and selected log messages from the production deployment. First stop for any "something blew up" investigation. | | -| **SURFconext** | The Dutch research-and-education SAML federation. TOSTI is registered as a service provider so any Radboud student can sign in with their RU account. The integration is configured in `tosti/settings/production.py:SAML_CONFIG`. | | +| **SURFconext** | The Dutch research-and-education SAML federation. TOSTI is registered as a service provider (entity `spd_ru_tosti`) so any Radboud student can sign in with their RU account. The integration is configured in `tosti/settings/production.py:SAML_CONFIG`. | SP dashboard Β· managed through | | **Yivi server** | Backs age verification for the beer fridge. TOSTI asks Yivi for two attributes (over-18 proof + student number); Yivi delivers them if the user discloses them through the app. | Β· TOSTI side in [`age/`](website/age/) and [`yivi/`](website/yivi/) | | **Spotify Web API** | Backs the Noordkantine music player. The OAuth token for the canteen account is stored encrypted in the DB. Rotation is manual. | TOSTI side in [`thaliedje/`](website/thaliedje/) | | **Marietje** | Backs the Zuidkantine music player. Read-only from TOSTI's side. | TOSTI side in [`thaliedje/marietje.py`](website/thaliedje/marietje.py) | @@ -353,8 +353,7 @@ For a security issue, see [`SECURITY.md`](SECURITY.md). ## πŸ“§ Contact - **Maintainers**: Website committee of Tartarus. -- **General**: . -- **Security**: (see [`SECURITY.md`](SECURITY.md)). +- **Email**: (use the same address for security issues; see [`SECURITY.md`](SECURITY.md)). ## πŸ“„ License From 1be91cd7cc7aba7374e91237e765632c05343976 Mon Sep 17 00:00:00 2001 From: Job Doesburg Date: Sun, 28 Jun 2026 21:46:56 +0200 Subject: [PATCH 4/6] Fix architecture graph to reflect real imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous graph was a free-hand sketch; some edges (e.g. ordersβ†’transactions, borrelβ†’users/transactions) didn't match the code, and real edges (ageβ†’users, fridgesβ†’venues, silvasoftβ†’orders/venues, borrelβ†’qualifications, *β†’associations) were missing. Also drop the "originally built for / now shared" framing in the intro β€” TOSTI was built for Tartarus, not inherited from elsewhere. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 56 +++++++++++++++++--------------- website/borrel/README.md | 42 ++++++++++++++++++++++++ website/orders/README.md | 56 ++++++++++++++++++++++++++++++++ website/thaliedje/README.md | 65 +++++++++++++++++++++++++++++++++++++ website/tosti/README.md | 47 +++++++++++++++++++++++++++ website/venues/README.md | 47 +++++++++++++++++++++++++++ 6 files changed, 287 insertions(+), 26 deletions(-) create mode 100644 website/borrel/README.md create mode 100644 website/orders/README.md create mode 100644 website/thaliedje/README.md create mode 100644 website/tosti/README.md create mode 100644 website/venues/README.md diff --git a/README.md b/README.md index da8cd6d8..b19dfad7 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Deploy](https://github.com/KiOui/TOSTI/actions/workflows/deploy.yaml/badge.svg)](https://github.com/KiOui/TOSTI/actions/workflows/deploy.yaml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -TOSTI is the order system that runs the canteens in the Huygens building at Radboud University. Originally built for [Tartarus](https://tartarus.science.ru.nl) and now shared across study associations that operate canteens there, it covers ordering, payments, music, age-gated beer fridges, venue reservations, borrels, and a handful of adjacent things that the canteens need. +TOSTI is the order system for the canteens in the Huygens building at Radboud University. Built by and for [Tartarus](https://tartarus.science.ru.nl), it covers ordering, payments, music, age-gated beer fridges, venue reservations, borrels, and a handful of adjacent things that the canteens need. It lives at and is maintained by the website committee of Tartarus. @@ -52,47 +52,51 @@ graph LR Silvasoft[Silvasoft accounting] FridgeClient[TOSTI fridge client] - subgraph "TOSTI Django apps" - users[users] - tosti[tosti] - orders[orders] - thaliedje[thaliedje] - venues[venues] - borrel[borrel] - fridges[fridges] - age[age] - yivi_app[yivi] - tampon[tampon] - transactions[transactions] - silvasoft_app[silvasoft] - end + tosti[tosti] + associations[associations] + users[users] + venues[venues] + orders[orders] + transactions[transactions] + thaliedje[thaliedje] + qualifications[qualifications] + borrel[borrel] + yivi_app[yivi] + age[age] + fridges[fridges] + silvasoft_app[silvasoft] SURFconext -. SAML .-> users - users --> tosti + users --> associations + venues --> associations + venues --> users + orders --> associations orders --> users orders --> venues - orders --> transactions + transactions --> users thaliedje --> users thaliedje --> venues thaliedje -. requests .-> Spotify thaliedje -. requests .-> Marietje - venues --> users + borrel --> associations borrel --> venues - borrel --> users - borrel --> transactions + borrel --> qualifications + age --> users + age --> yivi_app + yivi_app -. attribute disclosure .-> Yivi fridges --> users + fridges --> venues fridges --> age fridges -. unlock .-> FridgeClient - age --> yivi_app - yivi_app -. attribute disclosure .-> Yivi - tampon --> users - transactions --> users - silvasoft_app --> transactions + silvasoft_app --> associations + silvasoft_app --> venues + silvasoft_app --> orders + silvasoft_app --> borrel silvasoft_app -. invoices .-> Silvasoft tosti -. errors .-> Sentry ``` -The graph isn't a hard import dependency β€” it's the natural data-flow story. Each app keeps its concerns local; cross-app coupling is meant to stay minimal (see [Modular Django apps](#modular-django-apps)). +Solid arrows are real import / FK dependencies between apps; dashed arrows are calls to external services. Each app keeps its concerns local; cross-app coupling is meant to stay minimal (see [Modular Django apps](#modular-django-apps)). A handful of small apps (`tampon`, `announcements`, `status_screen`, …) are omitted to keep the graph readable. ### Tech stack diff --git a/website/borrel/README.md b/website/borrel/README.md new file mode 100644 index 00000000..e188923c --- /dev/null +++ b/website/borrel/README.md @@ -0,0 +1,42 @@ +# `borrel/` — borrel reservations + inventory + +A "borrel" is a Dutch student-association event with drinks. Borrels happen at the canteens; the boards of the participating associations need to book a venue, pre-declare which products they'll consume, and after the event reconcile what was actually used. This app owns that flow. + +It's distinct from `orders/` — orders are individual transactions during a baker's shift; a borrel is a planned event with its own product list and its own settlement. + +## Data model + +```mermaid +classDiagram + direction LR + BorrelReservation --> Venue + BorrelReservation --> User : organisers + BorrelReservation --> "0..*" ReservationItem + ReservationItem --> Product + Product --> ProductCategory +``` + +- **`BorrelReservation`** — the booking itself: title, venue, start/end time, organising association, organisers, comments, an accepted flag like a regular venue reservation, plus borrel-specific fields (expected number of attendees, etc.). +- **`Product`** — an item that can be consumed at a borrel. Has price, VAT rate, category, an `is_active` flag. +- **`ProductCategory`** — grouping for the product list (beer, soda, snacks, …) and for the Silvasoft sync. +- **`ReservationItem`** — line item linking a `BorrelReservation` to a `Product` with both a planned quantity (declared in advance) and an actual quantity (filled in after the event). + +## Lifecycle + +1. **Plan.** An organiser creates a `BorrelReservation` for a venue and time. Optionally pre-fills the product list with expected quantities. Auto-emails the venue manager. +2. **Accept.** The venue manager flips `accepted=True`. Borrel appears in the calendar. +3. **Happen.** The actual borrel runs at the canteen. +4. **Settle.** After the event, the organiser fills in the actual quantities consumed. Once finalised the reservation is locked. +5. **Sync to accounting.** [`silvasoft/`](../silvasoft/) picks up the settled reservation and creates a `SilvasoftBorrelReservationInvoice` for the organising association. See that app's README. + +## Permissions + +The model has association-aware permission checks: only members of the organising association (or staff) can edit a reservation. The actual brevet-style "can you order this product" gate is in [`qualifications/`](../qualifications/) (e.g. only borrel-brevet holders can pour beer). Borrel-app code consults that app's `qualifications` mixin. + +## Gotchas + +- **Don't double-book a venue.** Like regular venue reservations, model-level `full_clean` catches collisions but only if you call it. Use `services.py` rather than direct `.save()` if there's logic to share. +- **Once settled, don't edit.** The Silvasoft sync may have already picked up the values. Edits after settlement require coordinating with whoever runs the accounting reconciliation. +- **Product price changes don't retroactively update line items.** `ReservationItem` snapshots the price at the time it was added so settlements are stable. + + \ No newline at end of file diff --git a/website/orders/README.md b/website/orders/README.md new file mode 100644 index 00000000..4c10edb3 --- /dev/null +++ b/website/orders/README.md @@ -0,0 +1,56 @@ +# `orders/` — ordering, shifts, products + +This is the heart of TOSTI. Bakers run shifts; users place orders during those shifts; the system tracks who paid, what's ready, and who got blacklisted for not picking things up. + +It's the biggest app in the project. Treat changes here as production-affecting — the canteens use this live, daily. + +## Data model + +```mermaid +classDiagram + direction LR + OrderVenue --> "1..*" Shift + Shift --> "0..*" Order + Shift --> "0..*" Product : products available + OrderVenue --> "0..*" Product + Order --> Product + Order --> User : placed by + OrderBlacklistedUser --> User : flag +``` + +- **`OrderVenue`** wraps a `venues.Venue` with ordering-specific config (e.g. whether ordering is enabled there). +- **`Product`** lives in an OrderVenue's catalogue: name, price, availability, max-per-shift, max-per-user, whether it needs preparation or just a barcode scan. +- **`Shift`** is a time window during which a baker accepts orders. It has start/end times, a max-orders cap, an "accepting orders" toggle (the hand button in the baker view), and a `finalized` flag once it's closed permanently. +- **`Order`** ties a user to a product within a shift. Status fields are `paid` and `ready`; both must be true before the customer picks it up. Has a `priority` field so baker-placed orders jump to the top of the queue. +- **`OrderBlacklistedUser`** flags users who didn't pay or pick up. Blacklisted users can't place new orders. + +## Where the logic lives + +- **`services.py`** — `add_user_order`, `list_active_shifts`. Permission checks, capacity checks, blacklist checks all happen here. View and MCP tool both call into the same service. +- **`models.py`** — the data and its invariants. `Shift.is_active`, `Shift.can_order`, `Product.is_available` are queryable properties (django-queryable-properties) so they work in `.filter()` and `.annotate()` without an N+1. +- **`mcp.py`** — `list_active_shifts` (read), `place_order` (scope-gated by `orders:order`). +- **`api/v1/`** — the REST endpoints powering the baker view's live queue, the user order page, the scanner. + +## Polling + +The baker view, the user order list, and the scanner all poll every few seconds. **Anything you add to the order-list endpoints needs to be cheap at scale.** Use `select_related` / `prefetch_related`, lean on queryable properties, and count queries with `django.db.connection.queries` if in doubt. There's a section on this in [`CONTRIBUTING.md`](../../CONTRIBUTING.md#performance-and-database-queries). + +## The scanner + +`/orders//admin/scanner/` is the barcode-scanner flow for selling pre-packaged products at the counter (cans, snacks). Camera capture lives in `static/orders/js/admin-scanner.js`; the API endpoint is `PlayerTrackAddAPIView`-style POST that resolves the barcode β†’ product β†’ order. Scanned orders are marked with a "scanned" user-icon (``) instead of a real user. + +## Deposit transactions + +Deposit cans are processed at the counter through the `transactions/` app, not this one. See the *Process deposit* explainer tab for the baker flow. + +## Explainers + +This app contributes the *Order*, *Manage a shift*, *Hand in deposit*, and *Process deposit* tabs to `/explainers/`. See `apps.py:OrdersConfig.explainer_tabs` for the registration and `templates/orders/explainer*.html` for the content. + +## Gotchas + +- **Two shifts in one venue at the same time is forbidden.** The constraint is in the model `clean`; rely on it rather than re-checking in views. +- **Finalizing a shift is permanent.** Once `finalized=True`, the shift is locked. There's no admin path to un-finalize without writing SQL by hand. +- **Don't roll your own "is the shift active" check.** Use `Shift.is_active` — it accounts for start, end, finalised, and the operator-flipped accepting-orders flag. + + \ No newline at end of file diff --git a/website/thaliedje/README.md b/website/thaliedje/README.md new file mode 100644 index 00000000..d5db262b --- /dev/null +++ b/website/thaliedje/README.md @@ -0,0 +1,65 @@ +# `thaliedje/` — canteen music players + +Thaliedje runs the shared jukebox in the Huygens canteens. Anyone with a Radboud account can request a song; the player plays it in the canteen for everyone there to hear. + +There are two backends: **Spotify** (Noordkantine) and **Marietje** (Zuidkantine, read-only). They share a base `Player` model and a polymorphism layer (`InheritanceManager`), so most code paths can treat them uniformly. + +## Data model + +```mermaid +classDiagram + direction LR + Player <|-- SpotifyPlayer + Player <|-- MarietjePlayer + Player --> Venue : one per venue + SpotifyQueueItem --> Player + SpotifyQueueItem --> SpotifyTrack + SpotifyQueueItem --> User : requested by + SpotifyTrack --> "0..*" SpotifyArtist + ThaliedjeBlacklistedUser --> User + ThaliedjeControlEvent --> Venue : event-time perms + PlayerLogEntry --> Player + PlayerLogEntry --> User +``` + +- **`Player`** is the abstract base (slug, venue). Concrete subclasses `SpotifyPlayer` and `MarietjePlayer` implement `request_song`, `queue`, `current_track_name`, etc. +- **`SpotifyPlayer`** holds the OAuth credentials for the Spotify account that powers a venue. It's also where playback state (current track, queue, shuffle, repeat) is read from. +- **`MarietjePlayer`** wraps the (read-only) Marietje API for Zuidkantine. It can report what's playing but not queue, search, or control. +- **`SpotifyQueueItem`** is the TOSTI-side request log: which user requested which track at which time on which player. +- **`SpotifyTrack` / `SpotifyArtist`** are denormalised caches so the request log keeps making sense after a track is removed from Spotify. +- **`ThaliedjeBlacklistedUser`** — same idea as `OrderBlacklistedUser` but for song-request abuse. +- **`ThaliedjeControlEvent`** lets a reservation event override the default request/control permissions for the duration of the booking (e.g. a private borrel where only the organisers can queue). +- **`PlayerLogEntry`** is the audit log for control-plane actions (start, pause, next, etc.). + +## Always use `select_subclasses()` + +`Player.objects.get(...)` returns a base-class instance with all the `current_*` properties raising `NotImplementedError`. Always go through the `InheritanceManager`: + +```python +player = Player.objects.select_subclasses().get(venue__slug=slug) +``` + +There's a helper in `mcp.py:get_player_for_venue` that does this. Use it. + +## The auto-start trap + +`SpotifyPlayer.request_song` queues the track. If the player is paused, it used to also try to start playback + skip to the new track. That can silently consume the just-queued song when Spotify rejects the `start_playback` call (which happens any time there's no active context — typical off-hours state). The current implementation only auto-starts when `_current_playback is not None`. See the regression tests in `tests/test_player.py`. + +If you need to touch `request_song` again, re-read those tests first. + +## Spotify API quirks worth knowing + +- **`do_spotify_request` swallows `SpotifyException` and `ReadTimeout`.** Failed calls return `None`. Useful for resilience but easy to miss when debugging — if a behaviour you expected didn't happen, check Sentry logs for a "Spotify error" line. +- **`spotify.queue()` lags.** A track that just started playing can still appear at queue position 0 for a few seconds. The merged-queue work (see `services.py:observe_player_state` if present) tolerates this; new code should too. +- **`spotify.search()` returns a dict keyed by query type**, not a flat list of tracks. The MCP `search_tracks` service normalises both into the same flat shape; if you call `player.search()` directly, expect `{"tracks": [...], "albums": [...]}`. + +## MCP + +This app exposes `get_player_state`, `search_tracks`, and `request_song` to AI assistants. `search_tracks` is `openWorldHint: True` (it queries Spotify's external catalogue); `request_song` requires the `thaliedje:request` scope. See `mcp.py`. + +## Tests + +- `tests/test_mcp.py` covers the MCP tools and the `search_tracks` shape normalisation (regression for the Sentry crash from iterating Spotify's dict as a list). +- `tests/test_player.py` covers the `request_song` auto-start logic and Spotify projection. + + \ No newline at end of file diff --git a/website/tosti/README.md b/website/tosti/README.md new file mode 100644 index 00000000..22c71432 --- /dev/null +++ b/website/tosti/README.md @@ -0,0 +1,47 @@ +# `tosti/` — shared infrastructure + +The `tosti` app is the seam between the per-feature apps and the rest of the project. It contains no business logic of its own (no orders, no music, no fridges); it owns settings, base templates, cross-cutting helpers, and the OAuth/MCP plumbing every other app benefits from. + +If you find yourself adding feature behaviour here, stop. Per [`CONTRIBUTING.md`](../../CONTRIBUTING.md), feature behaviour belongs in its own app. + +## What's here + +| Thing | File(s) | Notes | +| --- | --- | --- | +| Project settings | `settings/base.py` + `production.py` / `development.py` | `INSTALLED_APPS`, middleware, CSP, OAuth config, Sentry init, SAML config. | +| Base templates | `templates/tosti/base.html` etc. | The site chrome (navbar, footer, modals, QR popup). Feature apps extend `tosti/base.html`. | +| OAuth discovery + DCR | `oauth_discovery.py` | RFC 8414 / 9728 metadata endpoints, RFC 7591 dynamic client registration, the custom `AuthorizationView` with granular per-scope consent. | +| OAuth integration docs | `templates/tosti/oauth_integration.html` + `views.py:OAuthIntegrationDocsView` | The `/oauth/docs/` page. | +| MCP wiring | `mcp.py` | Shared `require_scope` helper, `SERVER_INSTRUCTIONS` block, `stamp_tool_annotations`. The actual MCP server is initialised in `apps.py:TostiConfig.ready()`. | +| MCP tools docs | `templates/tosti/mcp_tools.html` + `views.py:MCPToolsDocsView` | The `/mcp/docs/` page. Reads the live `mcp_server` registry at request time. | +| Middleware | `middleware.py` | `HealthCheckMiddleware` (short-circuits `/live` `/ready`), `RequestMetricsMiddleware` (Sentry metrics), `MCPLandingMiddleware` (browser-friendly `/mcp`), `WWWAuthenticateMiddleware` (RFC 9728 pointer for 401s). | +| Connected apps tab | `views.py:ConnectedAppsView` | Lets users see and revoke the OAuth clients they've granted access to. | +| API credentials tab | `views.py:OAuthCredentialsRequestView` | Lets developers create their own OAuth applications. | +| Explainers framework | `templates/tosti/explainers.html` + `views.py:ExplainerView` | Each app registers tabs via `explainer_tabs` on its `AppConfig`. | +| Statistics framework | `views.py:StatisticsView` | Each app registers blocks via `statistics` on its `AppConfig`. | +| Celery + cron entry points | `celery.py`, `tasks.py`, `signals.py`, `metrics.py` | Email delivery, data minimisation cron orchestration, Sentry metric emission helper. | + +## What it doesn't have + +- Feature-specific tools, models, or business logic. Those live in the feature apps. The `tosti` app should be installable / removable only by the original developers if you wanted to fork TOSTI into something else — in practice it's always installed. +- A REST API. Public REST endpoints live in `tosti/api/v1/` but that's a routing shell; the actual endpoints are in each feature app's `/api/v1/`. + +## Hooks other apps use + +Apps register cross-cutting UI by adding methods to their `AppConfig`. The `tosti` app's views walk every `AppConfig`, call the method if present, and stitch the results together. Current hooks: + +| Method | Used by | What it does | +| --- | --- | --- | +| `menu_items(request)` | Navbar | Adds nav-menu entries. | +| `user_account_tabs(request)` | `/users/account/` | Adds tabs to the account page. | +| `explainer_tabs(request)` | `/explainers/` | Adds tabs to the explainers page. | +| `statistics(request)` | `/statistics/` | Adds blocks to the statistics page. | +| `announcements(request)` | Banner | Adds inline announcements. | + +If you need a new cross-cutting surface, add a new hook here rather than editing other apps' templates. + +## Tests + +`tests/test_mcp.py` is the cross-cutting test module — OAuth discovery, dynamic client registration, the `/mcp` auth gate, the consent screen, the connected-apps revoke flow. Per-tool MCP behaviour is tested in each owning app's tests. + + \ No newline at end of file diff --git a/website/venues/README.md b/website/venues/README.md new file mode 100644 index 00000000..a666df92 --- /dev/null +++ b/website/venues/README.md @@ -0,0 +1,47 @@ +# `venues/` — venues + reservations + calendar + +A "venue" in TOSTI is a physical room or area (canteen, meeting room, brew room, etc.) that can host shifts, host music players, be reserved, or all three. This app owns the venue catalogue and the reservation system; other apps (`orders`, `thaliedje`, `borrel`) hang their own concerns off the `Venue` model. + +## Data model + +```mermaid +classDiagram + direction LR + Venue --> "0..*" Reservation + Reservation --> User : created by + Reservation --> "0..1" User : accepted by +``` + +- **`Venue`** — name, slug, opening hours, can-it-be-reserved flag, color shown in the calendar. +- **`Reservation`** — user-created request to use a venue for some time window. Has a title, optional comments, an `accepted` flag (false by default; a manager flips it true), start and end times. + +Other apps refer to `Venue` as a foreign key (`orders.OrderVenue.venue`, `thaliedje.Player.venue`, etc.) but they don't extend the venue itself. If you need venue-related config for your app, follow that pattern. + +## The calendar + +`/venues/calendar/` shows a FullCalendar view of all reservations across all reservable venues. Each reservation is colour-coded by its venue. iCal feeds are exposed via `django-ical` so anyone can subscribe to a venue's reservations in their own calendar app. + +## Approval workflow + +New reservations are created with `accepted=False`. The user gets an email confirming the request was received; a notification email goes to the venue manager. The manager reviews and either accepts (flips the flag, optional email back to the user) or rejects (deletes / leaves unaccepted, depending on convention). + +There's an `automatically_accept_first_reservation` venue-level flag that lets specific venues short-circuit this for the first booking. Use carefully. + +## Services + +`services.py:create_reservation` is the canonical reservation-creation path: + +- Checks the venue accepts reservations (`venue.can_be_reserved`). +- Checks `end > start`. +- Runs model `full_clean()` (catches collisions and other model-level invariants). +- Calls `send_reservation_request_email`. + +The MCP `create_venue_reservation` tool and any future REST creation endpoint should both go through this service rather than re-implementing the validation. The `venues:write` scope gates the MCP path. + +## Gotchas + +- **`Venue.objects.all()` includes inactive venues.** Use the queryset manager methods to filter active-only if that's what you want. +- **Reservations don't lock out other reservations on save** — collision detection happens in `full_clean`. If you create a reservation without `full_clean`, you can double-book a venue. +- **The `slug` is the public identifier**, not the pk. URL converters and the MCP tool both resolve by slug. + + \ No newline at end of file From 4b9d3f5927ec8ec39e3d195ea9f7d6ac0f7d3a45 Mon Sep 17 00:00:00 2001 From: Job Doesburg Date: Sun, 28 Jun 2026 22:30:56 +0200 Subject: [PATCH 5/6] thaliedje README: show Player inheritance asymmetry Make explicit that SpotifyPlayer implements the full control-plane API (request/search/start/pause/next/previous/volume/shuffle/repeat) while MarietjePlayer is read-only and the same methods are no-ops with can_control / can_request_song returning False. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/thaliedje/README.md | 53 +++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/website/thaliedje/README.md b/website/thaliedje/README.md index d5db262b..b7186422 100644 --- a/website/thaliedje/README.md +++ b/website/thaliedje/README.md @@ -4,13 +4,58 @@ Thaliedje runs the shared jukebox in the Huygens canteens. Anyone with a Radboud There are two backends: **Spotify** (Noordkantine) and **Marietje** (Zuidkantine, read-only). They share a base `Player` model and a polymorphism layer (`InheritanceManager`), so most code paths can treat them uniformly. -## Data model +## Player inheritance — SpotifyPlayer vs MarietjePlayer ```mermaid classDiagram - direction LR + direction TB + class Player { + +slug + +venue + +current_track_name + +current_artists + +current_image + +is_playing + +queue + +request_song() * + +search() * + +start() * + +pause() * + +next() / previous() * + +volume / shuffle / repeat * + } + class SpotifyPlayer { + +client_id / client_secret + +cache_path + +request_song() + +search() + +start() / pause() + +next() / previous() + +volume / shuffle / repeat + +do_spotify_request() + } + class MarietjePlayer { + +marietje_url + +current_track_name (read-only) + +current_artists (read-only) + +queue (read-only) + +request_song() = no-op + +search() = no-op + +start() / pause() = no-op + +can_control() = False + +can_request_song() = False + } Player <|-- SpotifyPlayer Player <|-- MarietjePlayer +``` + +`SpotifyPlayer` is the full-featured backend — queueing, searching, transport control, volume/shuffle/repeat, all backed by the Spotify Web API. `MarietjePlayer` is read-only: it can report what's currently playing in the Zuidkantine by polling Marietje's HTTP API, but all the control-plane methods are explicit no-ops and `can_control` / `can_request_song` return `False` so the UI hides the buttons. New player features should expect to be Spotify-only unless there's a concrete reason to extend Marietje. + +## Data model + +```mermaid +classDiagram + direction LR Player --> Venue : one per venue SpotifyQueueItem --> Player SpotifyQueueItem --> SpotifyTrack @@ -22,9 +67,7 @@ classDiagram PlayerLogEntry --> User ``` -- **`Player`** is the abstract base (slug, venue). Concrete subclasses `SpotifyPlayer` and `MarietjePlayer` implement `request_song`, `queue`, `current_track_name`, etc. -- **`SpotifyPlayer`** holds the OAuth credentials for the Spotify account that powers a venue. It's also where playback state (current track, queue, shuffle, repeat) is read from. -- **`MarietjePlayer`** wraps the (read-only) Marietje API for Zuidkantine. It can report what's playing but not queue, search, or control. +- **`Player`** is the polymorphic base (slug, venue). `SpotifyPlayer` and `MarietjePlayer` are the concrete subclasses. - **`SpotifyQueueItem`** is the TOSTI-side request log: which user requested which track at which time on which player. - **`SpotifyTrack` / `SpotifyArtist`** are denormalised caches so the request log keeps making sense after a track is removed from Spotify. - **`ThaliedjeBlacklistedUser`** — same idea as `OrderBlacklistedUser` but for song-request abuse. From 867a13d10f3bfc08098bf560e706584b57714690 Mon Sep 17 00:00:00 2001 From: Job Doesburg Date: Sun, 28 Jun 2026 23:15:40 +0200 Subject: [PATCH 6/6] Per-app READMEs: fix data models against the actual code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier diagrams were drawn from memory and got several things wrong. Corrections: orders/ add OrderVenue OneToOneβ†’Venue, the Product.available_at M2M, Shift.assignees M2M, Order.user_association FK, the OrderBlacklistedUser OneToOne; replace the false "queryable property" claim for Shift.is_active. venues/ add Reservationβ†’Association, user_updated FK, users_access M2M, the tri-state `accepted`, `join_code`, `needs_music_keys`, and the `active` queryable property. Drop the wrong claim that rejected reservations get deleted. borrel/ the big one: BorrelReservation owns its own start/end/title plus a OneToOne to venues.Reservation (it does NOT live on Venue directly). Add the three role users (user_created/updated/submitted), users_access M2M, tri-state accepted, queryable `active` and `submitted`. ReservationItem snapshots product_name / price_per_unit. thaliedje/ add SpotifyArtist, the SpotifyTrack.track_artists M2M, the ThaliedjeControlEvent OneToOne to venues.Reservation and its three permission tiers (association_* / selected_users_* / everyone_*) plus the queryable properties (start, end, active, player). Co-Authored-By: Claude Opus 4.7 (1M context) --- website/borrel/README.md | 19 ++++++++++++------- website/orders/README.md | 26 ++++++++++++++------------ website/thaliedje/README.md | 19 ++++++++++--------- website/venues/README.md | 17 ++++++++++------- 4 files changed, 46 insertions(+), 35 deletions(-) diff --git a/website/borrel/README.md b/website/borrel/README.md index e188923c..fe3469ca 100644 --- a/website/borrel/README.md +++ b/website/borrel/README.md @@ -9,17 +9,22 @@ It's distinct from `orders/` — orders are individual transactions during a ```mermaid classDiagram direction LR - BorrelReservation --> Venue - BorrelReservation --> User : organisers - BorrelReservation --> "0..*" ReservationItem + BorrelReservation --> Reservation : venue_reservation (0..1, OneToOne) + BorrelReservation --> Association + BorrelReservation --> User : user_created + BorrelReservation --> User : user_updated + BorrelReservation --> User : user_submitted + BorrelReservation "1" <--> "0..*" User : users_access (M2M) + ReservationItem --> BorrelReservation : items ReservationItem --> Product Product --> ProductCategory ``` -- **`BorrelReservation`** — the booking itself: title, venue, start/end time, organising association, organisers, comments, an accepted flag like a regular venue reservation, plus borrel-specific fields (expected number of attendees, etc.). -- **`Product`** — an item that can be consumed at a borrel. Has price, VAT rate, category, an `is_active` flag. -- **`ProductCategory`** — grouping for the product list (beer, soda, snacks, …) and for the Silvasoft sync. -- **`ReservationItem`** — line item linking a `BorrelReservation` to a `Product` with both a planned quantity (declared in advance) and an actual quantity (filled in after the event). +- **`BorrelReservation`** — the booking itself. Owns its own `start`/`end`/`title`/`comments`/`association`, the tri-state `accepted` (None / True / False) approval flag, the three role users (`user_created`, `user_updated`, `user_submitted`) plus the `users_access` M2M, and a `join_code`. The optional OneToOne `venue_reservation -> venues.Reservation` ties the borrel to the underlying venue booking when one exists. +- **Queryable properties:** `active` (`RangeCheckProperty` on `start`/`end`) and `submitted` (`AnnotationProperty` derived from `submitted_at`). Both usable in `.filter()` / `.annotate()`. Regular properties `can_be_changed` and `can_be_submitted` gate UI actions. +- **`Product`** — an item that can be consumed at a borrel, grouped by `ProductCategory`. +- **`ProductCategory`** — product grouping (beer, soda, snacks, …); also relevant for the Silvasoft sync. +- **`ReservationItem`** — line item with `amount_reserved` (planned) and `amount_used` (settled). **Snapshots** `product_name`, `product_description`, `product_price_per_unit` at the time the item was added; the FK to `Product` is `SET_NULL`-on-delete so settlements remain stable when products are removed or repriced. `unique_together = (reservation, product_name)`. ## Lifecycle diff --git a/website/orders/README.md b/website/orders/README.md index 4c10edb3..62f9d1a1 100644 --- a/website/orders/README.md +++ b/website/orders/README.md @@ -9,25 +9,27 @@ It's the biggest app in the project. Treat changes here as production-affecting ```mermaid classDiagram direction LR - OrderVenue --> "1..*" Shift - Shift --> "0..*" Order - Shift --> "0..*" Product : products available - OrderVenue --> "0..*" Product + OrderVenue --> Venue : OneToOne + OrderVenue "1" <--> "0..*" Product : available_at (M2M) + Shift --> OrderVenue : venue + Shift "1" <--> "0..*" User : assignees (M2M) + Order --> Shift Order --> Product - Order --> User : placed by - OrderBlacklistedUser --> User : flag + Order --> User : user + Order --> Association : user_association + OrderBlacklistedUser --> User : OneToOne ``` -- **`OrderVenue`** wraps a `venues.Venue` with ordering-specific config (e.g. whether ordering is enabled there). -- **`Product`** lives in an OrderVenue's catalogue: name, price, availability, max-per-shift, max-per-user, whether it needs preparation or just a barcode scan. -- **`Shift`** is a time window during which a baker accepts orders. It has start/end times, a max-orders cap, an "accepting orders" toggle (the hand button in the baker view), and a `finalized` flag once it's closed permanently. -- **`Order`** ties a user to a product within a shift. Status fields are `paid` and `ready`; both must be true before the customer picks it up. Has a `priority` field so baker-placed orders jump to the top of the queue. -- **`OrderBlacklistedUser`** flags users who didn't pay or pick up. Blacklisted users can't place new orders. +- **`OrderVenue`** — OneToOne wrapper around `venues.Venue`. Holds ordering-specific config (whether ordering is enabled, scanner settings, etc.). `Product.available_at` is the M2M that pins each product to one or more venues. +- **`Product`** — the catalogue item: `name`, `icon`, `current_price`, `available`, `orderable`, `ignore_shift_restrictions`, and `barcode` (for the scanner flow). +- **`Shift`** — a time window (`start`, `end`) during which a baker accepts orders. `can_order` is the operator-toggleable "currently taking orders" flag; `finalized` permanently locks the shift once it's closed. Bakers are tracked through the `assignees` M2M to `User`. +- **`Order`** — ties a `user` (and their `user_association`) to a `product` within a `shift`. Lifecycle is tracked by three boolean+timestamp pairs: `ready` / `ready_at`, `paid` / `paid_at`, `picked_up` / `picked_up_at`. `type` distinguishes a normal counter order from a scanned (pre-packaged) one; `prioritize` / `deprioritize` lets bakers reorder the queue. +- **`OrderBlacklistedUser`** — OneToOne flag with an `explanation` field. Blacklisted users can't place new orders. ## Where the logic lives - **`services.py`** — `add_user_order`, `list_active_shifts`. Permission checks, capacity checks, blacklist checks all happen here. View and MCP tool both call into the same service. -- **`models.py`** — the data and its invariants. `Shift.is_active`, `Shift.can_order`, `Product.is_available` are queryable properties (django-queryable-properties) so they work in `.filter()` and `.annotate()` without an N+1. +- **`models.py`** — the data and its invariants. `Shift.is_active`, `Shift.number_of_orders` are regular `@property`s; they hit the database on access, so when you need them in bulk for a queryset, annotate explicitly rather than looping. - **`mcp.py`** — `list_active_shifts` (read), `place_order` (scope-gated by `orders:order`). - **`api/v1/`** — the REST endpoints powering the baker view's live queue, the user order page, the scanner. diff --git a/website/thaliedje/README.md b/website/thaliedje/README.md index b7186422..59badee6 100644 --- a/website/thaliedje/README.md +++ b/website/thaliedje/README.md @@ -56,22 +56,23 @@ classDiagram ```mermaid classDiagram direction LR - Player --> Venue : one per venue + Player --> Venue : OneToOne SpotifyQueueItem --> Player SpotifyQueueItem --> SpotifyTrack - SpotifyQueueItem --> User : requested by - SpotifyTrack --> "0..*" SpotifyArtist - ThaliedjeBlacklistedUser --> User - ThaliedjeControlEvent --> Venue : event-time perms + SpotifyQueueItem --> User : requested_by + SpotifyTrack "1" <--> "0..*" SpotifyArtist : track_artists (M2M) + ThaliedjeBlacklistedUser --> User : OneToOne + ThaliedjeControlEvent --> Reservation : event (OneToOne) + ThaliedjeControlEvent "1" <--> "0..*" User : selected_users (M2M) PlayerLogEntry --> Player PlayerLogEntry --> User ``` -- **`Player`** is the polymorphic base (slug, venue). `SpotifyPlayer` and `MarietjePlayer` are the concrete subclasses. -- **`SpotifyQueueItem`** is the TOSTI-side request log: which user requested which track at which time on which player. -- **`SpotifyTrack` / `SpotifyArtist`** are denormalised caches so the request log keeps making sense after a track is removed from Spotify. +- **`Player`** is the polymorphic base (`slug`, OneToOne to `venues.Venue`). `SpotifyPlayer` and `MarietjePlayer` are the concrete subclasses; both store backend-specific credentials (`client_id`/`client_secret` and a per-player `cache_path`). +- **`SpotifyQueueItem`** is the TOSTI-side request log: which user requested which track at which time on which player. Note this is created for Spotify-backed players only (Marietje can't accept requests). +- **`SpotifyTrack` / `SpotifyArtist`** are denormalised caches so the request log keeps making sense after a track is removed from Spotify. `SpotifyTrack.track_artists` is the M2M between them. - **`ThaliedjeBlacklistedUser`** — same idea as `OrderBlacklistedUser` but for song-request abuse. -- **`ThaliedjeControlEvent`** lets a reservation event override the default request/control permissions for the duration of the booking (e.g. a private borrel where only the organisers can queue). +- **`ThaliedjeControlEvent`** has a OneToOne to `venues.Reservation` and lets that reservation override the default request/control permissions for the duration of the booking. Permissions are split three ways: `association_*` (anyone in the organising association), `selected_users_*` (the M2M-linked users), and `everyone_*`. Also has `respect_blacklist` and `check_throttling` toggles. Queryable properties `start` / `end` / `active` lift the parent reservation's range so you can filter `ThaliedjeControlEvent.objects.filter(active=True)`; `player` resolves to `event.venue.player` for convenience. - **`PlayerLogEntry`** is the audit log for control-plane actions (start, pause, next, etc.). ## Always use `select_subclasses()` diff --git a/website/venues/README.md b/website/venues/README.md index a666df92..e1de1f13 100644 --- a/website/venues/README.md +++ b/website/venues/README.md @@ -7,13 +7,16 @@ A "venue" in TOSTI is a physical room or area (canteen, meeting room, brew room, ```mermaid classDiagram direction LR - Venue --> "0..*" Reservation - Reservation --> User : created by - Reservation --> "0..1" User : accepted by + Reservation --> Venue + Reservation --> Association + Reservation --> User : user_created + Reservation --> User : user_updated + Reservation "1" <--> "0..*" User : users_access (M2M) ``` -- **`Venue`** — name, slug, opening hours, can-it-be-reserved flag, color shown in the calendar. -- **`Reservation`** — user-created request to use a venue for some time window. Has a title, optional comments, an `accepted` flag (false by default; a manager flips it true), start and end times. +- **`Venue`** — `name`, `slug`, `active`, `color_in_calendar`, `can_be_reserved`, `automatically_accept_first_reservation`. No FKs out — other apps point at it (`orders.OrderVenue.venue` OneToOne, `thaliedje.Player.venue` OneToOne, etc.). +- **`Reservation`** — `title`, `start`, `end`, `comments`, `needs_music_keys`, and the tri-state `accepted` (`None` = pending, `True` = approved, `False` = rejected). Bookkeeping fields: `user_created`, `user_updated`, the `users_access` M2M, and a one-shot `join_code` (auto-generated on first save) so the organisers can share the reservation with people who weren't on the original list. +- **`Reservation.active`** is a queryable property (`RangeCheckProperty` on `start`/`end`); usable in `.filter()` / `.annotate()`. `can_be_changed` is a regular `@property` that's true when the reservation is still pending and hasn't started yet. Other apps refer to `Venue` as a foreign key (`orders.OrderVenue.venue`, `thaliedje.Player.venue`, etc.) but they don't extend the venue itself. If you need venue-related config for your app, follow that pattern. @@ -23,9 +26,9 @@ Other apps refer to `Venue` as a foreign key (`orders.OrderVenue.venue`, `thalie ## Approval workflow -New reservations are created with `accepted=False`. The user gets an email confirming the request was received; a notification email goes to the venue manager. The manager reviews and either accepts (flips the flag, optional email back to the user) or rejects (deletes / leaves unaccepted, depending on convention). +New reservations are created with `accepted=None` (pending). The user gets an email confirming the request was received; a notification email goes to the venue manager. The manager reviews and either accepts (`accepted=True`) or rejects (`accepted=False`). Reservations are never auto-deleted on rejection — rejected ones still show up in the admin so the audit trail is intact. -There's an `automatically_accept_first_reservation` venue-level flag that lets specific venues short-circuit this for the first booking. Use carefully. +There's an `automatically_accept_first_reservation` venue-level flag that lets specific venues short-circuit this for the first booking (subject to no overlap). Use carefully. ## Services