Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
428 changes: 242 additions & 186 deletions README.md

Large diffs are not rendered by default.

47 changes: 47 additions & 0 deletions website/borrel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# `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 --> 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`** &mdash; 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`** &mdash; an item that can be consumed at a borrel, grouped by `ProductCategory`.
- **`ProductCategory`** &mdash; product grouping (beer, soda, snacks, …); also relevant for the Silvasoft sync.
- **`ReservationItem`** &mdash; 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

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.
</content>
</invoke>
58 changes: 58 additions & 0 deletions website/orders/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# `orders/` &mdash; 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 &mdash; the canteens use this live, daily.

## Data model

```mermaid
classDiagram
direction LR
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 : user
Order --> Association : user_association
OrderBlacklistedUser --> User : OneToOne
```

- **`OrderVenue`** &mdash; 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`** &mdash; the catalogue item: `name`, `icon`, `current_price`, `available`, `orderable`, `ignore_shift_restrictions`, and `barcode` (for the scanner flow).
- **`Shift`** &mdash; 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`** &mdash; 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`** &mdash; OneToOne flag with an `explanation` field. Blacklisted users can't place new orders.

## Where the logic lives

- **`services.py`** &mdash; `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`** &mdash; 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`** &mdash; `list_active_shifts` (read), `place_order` (scope-gated by `orders:order`).
- **`api/v1/`** &mdash; 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/<shift>/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 (`<i class="fa-solid fa-desktop">`) 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` &mdash; it accounts for start, end, finalised, and the operator-flipped accepting-orders flag.
</content>
</invoke>
109 changes: 109 additions & 0 deletions website/thaliedje/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# `thaliedje/` &mdash; 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.

## Player inheritance &mdash; SpotifyPlayer vs MarietjePlayer

```mermaid
classDiagram
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 &mdash; 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 : OneToOne
SpotifyQueueItem --> Player
SpotifyQueueItem --> SpotifyTrack
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`, 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`** &mdash; same idea as `OrderBlacklistedUser` but for song-request abuse.
- **`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()`

`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 &mdash; 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 &mdash; 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.
</content>
</invoke>
47 changes: 47 additions & 0 deletions website/tosti/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# `tosti/` &mdash; 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 &mdash; 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 `<app>/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 &mdash; 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.
</content>
</invoke>
Loading