Skip to content
Merged
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
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip

- name: Install dependencies
run: pip install -e ".[dev]"

- name: Run tests
run: pytest --tb=short
95 changes: 95 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What This Is

`taken` is a Python FastAPI backend for duty scheduling at US Basketball Amsterdam (~125 members, ~12 teams, ~126 home games/season). It manages assignment of referee, table official, and hall duty (zaaldienst) roles to members per game, enforcing eligibility rules and optimizing for fairness. A companion Next.js website (separate repo) consumes the public API.

## Commands

```bash
# Install
pip install -e ".[dev]"

# Run dev server
uvicorn app.main:app --reload

# Run all tests
pytest

# Run a single test file
pytest tests/test_sync.py

# Run a single test by name
pytest tests/test_sync.py::test_sync_preview_returns_diff

# Database migrations
alembic upgrade head
alembic revision --autogenerate -m "description"
alembic downgrade -1
```

Interactive API docs when running locally: `http://localhost:8000/docs`

> **Alembic note:** `alembic.ini` intentionally has a blank `sqlalchemy.url`. `alembic/env.py` reads `DATABASE_URL` directly from the environment.

## Architecture

### Request Flow

```
Vercel → api/index.py (re-exports app) → app/main.py (router registration)
→ routers/ → services/ → db/models.py (SQLAlchemy ORM)
```

### Key Design Points

**Auth (two-tier):**

- Admin routes: HS256 JWT via `require_auth()` dependency (`app/middleware/auth.py`), injected per-route with `Depends()` — not global middleware
- `verify_password` accepts plain text if `ADMIN_PASSWORD` doesn't start with `$2b$`/`$2a$` (intentional for local dev; default: `"changeme"`)
- Public routes: Auth0 JWT (`Authorization: Bearer <auth0_token>`), verified against Auth0's JWKS endpoint

**Database:**

- Production: PostgreSQL via Neon. Both `app/db/engine.py` and `alembic/env.py` independently rewrite `postgres://` → `postgresql://` and append `sslmode=require` for `neon.tech` URLs — keep these in sync.
- Tests: in-memory SQLite with `StaticPool` (required so all connections share the same in-memory DB). The engine factory branches on `url.startswith("sqlite")` to skip PostgreSQL-only pool settings.

**foys.io sync (two-step):**

- `POST /seasons/{sid}/games/sync?preview=true` — returns diff (added/updated/removed/conflicts) without writing
- `POST /seasons/{sid}/games/sync?preview=false` — applies the diff; conflicts default to "keep local" unless overridden per `external_id` with `action: "overwrite"`

**Timeslots:** A `Timeslot` is a unique `(season_id, date, start_time)`. Games link via `GameTimeslot` (many-to-many). Two games are "adjacent" if within 150 minutes on the same day, affecting duty balance counting.

**Tests:** All tests use per-function in-memory SQLite; `get_db` is overridden via `app.dependency_overrides`. External foys.io HTTP calls are mocked with `unittest.mock.patch`.

### Configuration (`app/config.py`, pydantic-settings)

| Variable | Purpose |
| ----------------------------------------- | ----------------------------------------- |
| `DATABASE_URL` | Defaults to `sqlite:///./test.db` locally |
| `ADMIN_PASSWORD` | Plain text (dev) or bcrypt hash (prod) |
| `JWT_SECRET` | HS256 signing key |
| `AUTH0_DOMAIN` / `AUTH0_AUDIENCE` | Verifies website user JWTs |
| `FOYS_FEDERATION_ID` / `FOYS_HOME_ORG_ID` | foys.io defaults (hardcoded) |

## Implementation Status

This is a mid-build project. Phases 1–2 are complete:

- Phase 1: auth, seasons CRUD, DB engine, ORM models, Alembic migration
- Phase 2: foys.io sync service, games listing, timeslot management

**Phases 3–6 not yet implemented:** members/assignments/balance/solver routers, OR-Tools CP-SAT solver (`app/engine/`), Excel export, Auth0/API key middleware, public endpoints.

The canonical architecture plan (full API spec, DB schema, solver design) is in `PLAN.md`.

### Planned Solver (Phase 4)

OR-Tools CP-SAT with ~63,000 binary decision variables (126 games × 5 duties × 100 eligible members), 30-second time limit, falling back to greedy on timeout.

## Deployment

Vercel: `vercel.json` rewrites all traffic to `/api/index.py`. Test files are excluded from the function bundle via `excludeFiles`.
22 changes: 8 additions & 14 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ US Basketball Amsterdam (~125 members, 12 teams, ~126 home games/season) needs a
| **Database** | PostgreSQL on Neon | Native types, Vercel Marketplace, free tier |
| **Optimization** | Google OR-Tools (CP-SAT) | Constraint satisfaction + optimization solver — ideal for duty assignment |
| **Auth (admin)** | JWT Bearer tokens (`python-jose`) | Board member login with password |
| **Auth (public)** | API key + Auth0 token verification (`PyJWKClient`) | Dual protection: API key validates the caller is the website, Auth0 token validates the user is a club member |
| **Auth (public)** | Auth0 token verification (`PyJWKClient`) | Verifies the user is an authenticated club member |
| **Testing** | pytest + httpx | Standard Python testing |
| **Deploy** | Vercel (Fluid Compute) | Same platform as website |

Expand Down Expand Up @@ -68,7 +68,7 @@ taken/
audit.py # GET /seasons/{sid}/audit
export.py # GET /seasons/{sid}/export
publish.py # POST /seasons/{sid}/publish
public.py # GET /public/schema (API-key + Auth0 auth)
public.py # GET /public/schema (Auth0 auth)
schemas/ # Pydantic request/response models
season.py
member.py
Expand All @@ -93,7 +93,6 @@ taken/
middleware/
__init__.py
auth.py # JWT verification for admin routes
api_key.py # API key verification for public routes
auth0.py # Auth0 token verification for public routes
alembic/ # Migration files
versions/
Expand Down Expand Up @@ -206,7 +205,7 @@ sum(zaal[ts, :]) == 1

## API Endpoints

All admin routes require `Authorization: Bearer <jwt>`. Public routes require both `x-api-key` and Auth0 token.
All admin routes require `Authorization: Bearer <jwt>`. Public routes require `Authorization: Bearer <auth0_token>`.

### Auth

Expand Down Expand Up @@ -278,17 +277,14 @@ GET /seasons/{sid}/export → .xlsx file
POST /seasons/{sid}/publish → { ok, published_at }
```

### Public (for website) — requires both API key + Auth0 token
### Public (for website) — requires Auth0 token

```
GET /public/schema → PublicSchema
GET /public/schema?team=H3 → PublicSchema (filtered)
```

**Dual auth on public routes:**

1. `x-api-key` header — validates the caller is the club website (not a random client)
2. `Authorization: Bearer <auth0_token>` — the website forwards the user's Auth0 JWT; the taken API verifies it against Auth0's JWKS endpoint (`https://<AUTH0_DOMAIN>/.well-known/jwks.json`) to confirm the user is an authenticated club member
`Authorization: Bearer <auth0_token>` — the website forwards the user's Auth0 JWT; the taken API verifies it against Auth0's JWKS endpoint (`https://<AUTH0_DOMAIN>/.well-known/jwks.json`) to confirm the user is an authenticated club member.

The taken API needs these env vars for Auth0 verification:

Expand Down Expand Up @@ -420,15 +416,14 @@ Response shape for `/public/schema`:

### Phase 5: Public API + Publish

- Implement API-key middleware (`app/middleware/api_key.py`)
- Implement Auth0 token verification middleware (`app/middleware/auth0.py`) — verifies user JWT against Auth0 JWKS
- Combine both as dependencies on public routes (both must pass)
- Add as dependency on public routes
- Implement `GET /public/schema`
- Implement `POST /seasons/{sid}/publish`
- Implement `services/validation.py` — constraint violation checks
- Write tests for public API (mock both API key and Auth0 token verification)
- Write tests for public API (mock Auth0 token verification)

**Deliverable:** Website can fetch and display duty schedule. Endpoint is protected by both API key (service-level) and Auth0 token (user-level).
**Deliverable:** Website can fetch and display duty schedule. Endpoint is protected by Auth0 token (user must be authenticated club member).

### Phase 6: Export + Polish

Expand Down Expand Up @@ -465,7 +460,6 @@ from app.main import app # FastAPI instance
DATABASE_URL=postgres://... # Auto-provisioned by Neon
ADMIN_PASSWORD=<bcrypt hash>
JWT_SECRET=<random 256-bit hex>
API_KEY=<for website integration>
AUTH0_DOMAIN=<tenant>.auth0.com # For verifying user tokens from the website
AUTH0_AUDIENCE=<api-audience> # Auth0 API identifier or client ID
FOYS_FEDERATION_ID=52cfa65e-9782-4a81-ab35-e2f981fcb7a9
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# taken

Python/FastAPI backend for duty scheduling at [US Basketball Amsterdam](https://usbasketball.nl). Manages referee, table official, and hall duty (zaaldienst) assignments across ~125 members and ~126 home games per season.

## Setup

```bash
pip install -e ".[dev]"
cp .env.example .env # set DATABASE_URL, ADMIN_PASSWORD, JWT_SECRET
alembic upgrade head
uvicorn app.main:app --reload
```

API docs available at `http://localhost:8000/docs`.

## Tests

```bash
pytest
```

## Deployment

Deployed on Vercel (Fluid Compute). Set environment variables via Vercel dashboard or `vercel env`. Database is PostgreSQL on Neon (Vercel Marketplace).

---

For architecture, commands, and development notes see [CLAUDE.md](CLAUDE.md).
1 change: 0 additions & 1 deletion app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ class Settings(BaseSettings):
admin_password: str = "changeme"
jwt_secret: str = "insecure-dev-secret-change-in-production"
jwt_expire_days: int = 7
api_key: str = ""
auth0_domain: str = ""
auth0_audience: str = ""
foys_federation_id: str = "52cfa65e-9782-4a81-ab35-e2f981fcb7a9"
Expand Down
Loading