diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fd7be61 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..78f0316 --- /dev/null +++ b/CLAUDE.md @@ -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 `), 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`. diff --git a/PLAN.md b/PLAN.md index 26cc931..06c09eb 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 | @@ -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 @@ -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/ @@ -206,7 +205,7 @@ sum(zaal[ts, :]) == 1 ## API Endpoints -All admin routes require `Authorization: Bearer `. Public routes require both `x-api-key` and Auth0 token. +All admin routes require `Authorization: Bearer `. Public routes require `Authorization: Bearer `. ### Auth @@ -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 ` — the website forwards the user's Auth0 JWT; the taken API verifies it against Auth0's JWKS endpoint (`https:///.well-known/jwks.json`) to confirm the user is an authenticated club member +`Authorization: Bearer ` — the website forwards the user's Auth0 JWT; the taken API verifies it against Auth0's JWKS endpoint (`https:///.well-known/jwks.json`) to confirm the user is an authenticated club member. The taken API needs these env vars for Auth0 verification: @@ -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 @@ -465,7 +460,6 @@ from app.main import app # FastAPI instance DATABASE_URL=postgres://... # Auto-provisioned by Neon ADMIN_PASSWORD= JWT_SECRET= -API_KEY= AUTH0_DOMAIN=.auth0.com # For verifying user tokens from the website AUTH0_AUDIENCE= # Auth0 API identifier or client ID FOYS_FEDERATION_ID=52cfa65e-9782-4a81-ab35-e2f981fcb7a9 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a1e9fd1 --- /dev/null +++ b/README.md @@ -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). diff --git a/app/config.py b/app/config.py index 43c0ba9..a1ba91c 100644 --- a/app/config.py +++ b/app/config.py @@ -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"