Files
F-r-ldraledigplannerare/backend/README.md
T
jacobh2 782415122d Separate user intent from planner output via day assertions
The calendar mixed two kinds of truth in one table. `planned_leave_day` was
simultaneously the allocator's output cache, the user's override store
(`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that
analytics and the age-4 maths read. The planner therefore had to ask "am I
allowed to touch this row?" on every sync, and got it wrong at the edges.

Every user assertion is now one `day_assertion` row — a concrete `day_type`
replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and
`day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes
purely derived with a single writer, and `PlanningService.pinned_keys()` is the
single answer to "is this day pinned", used by the allocator's loader, by
`sync_plan` and by the read router alike.

On top of that, every calendar band is now a real event: an ad-hoc band opens
the same full editor as a wish-backed one and saving it promotes it to a
`LeaveWish`, so it can be named, repeated yearly, assigned to either or both
parents, and handed to the planner. "Planering" is a `moveable` toggle plus a
from/to month bound plus a separate lock/unlock action on the days themselves,
replacing a three-rung ladder whose "planner decides" rung set a search window
equal to the drawn dates and so could never move anything.

Bugs found and fixed along the way:

- The plan erased its own history. Fixed windows clip to today, so an elapsed
  auto-placed day fell into `stale_keys` and was deleted on the next sync —
  and a background sync runs on every workspace load. Past rows are now frozen
  and counted as spent.
- Migration 010's downgrade silently destroyed every pinned day and exclusion
  (`is_pinned` returned as DEFAULT 0, the exclusion table came back empty,
  exit 0, no warning). It now reconstructs both from `day_assertion`, and 009
  itemises what it cannot recover before dropping the table.
- Deleting a period left its assertions behind, locking those dates forever
  with no UI path to clear them. Wish deletion now clears them by `wish_id`.
- `DayAssertionService` and `LeaveWishService` committed multi-statement writes
  without a transaction; a crash mid-write diverged the tables or half-deleted
  a series. Both now run in a unit of work.
- A sibling year's shortfall was never surfaced: the toast only inspected the
  edited occurrence's outcome, so a fully unfunded later year rendered as an
  empty band with no warning.
- A flexible window whose bound had entirely elapsed was dropped from the
  proposal with no outcome at all. It now reports as `unplaced`.
- Unlocking a period deleted its days instead of returning them to the planner.
- The e2e global setup posted to a route deleted in this change, which would
  have failed the whole suite before its first test.

Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 |
97.4. Migrations 009 and 010 audited against a hand-built realistic database:
the upgrade reconciles exactly and a constraint violation fails safe, leaving
the schema at 008.

Not complete. Step 4's toggle, month picker and lock/unlock action are verified
by browser walk and unit tests, but the partial-lock readout was not exercised.
`ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner
detects expiring days and then does nothing about them) — not implemented here.
Remaining smaller items are listed in the session's restructure notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
2026-09-21 07:20:14 +02:00

117 lines
6.3 KiB
Markdown

# Backend — Föräldraledigplannerare
FastAPI + Python 3.14, packages managed with `uv`. Layered architecture
(entities → repositories → services → routers) with dependency injection.
**Postgres is a partial stub, not a working swap today:** only
`repositories_impl/postgres/vacation_days_repository.py` exists; every other
`app/dependencies.py` repository factory raises `ValueError` for any
`database_type` other than `"sqlite"`. SQLite is the only usable backend
until the remaining Postgres repositories land.
## Setup
```bash
uv sync # install deps (incl. dev group)
# --reload-exclude keeps the dev SQLite file (and its -wal/-shm/-journal
# siblings) out of the watched tree — a write-heavy request otherwise
# self-triggers a reload mid-request and drops the connection, returning a
# 503 *after* the write already committed (see frontend/package.json's
# dev:backend/login:backend for the same flags).
uv run uvicorn app.main:app --reload --reload-exclude '*.db' --reload-exclude '*.db-shm' --reload-exclude '*.db-wal' --reload-exclude '*.db-journal' # run dev server on :8000
uv run pytest # run tests with coverage
uv run ruff check . # lint
```
`uv run pytest` alone does not enforce a coverage floor (`pyproject.toml`'s
`addopts` only requests a report). CI additionally passes
`--cov-fail-under=80` (see the root `CLAUDE.md`) — a locally green run can
still fail in CI if coverage dropped.
## Configuration
Env vars (prefix `APP_`, nested via `__`; see `.env.example`). `Settings`
lives in `app/config.py` — that file's module docstring mentions a
`HMAC_PEPPER` setting; **no such setting exists**, so do not look for it here
or set it expecting an effect.
- `APP_ENV` — `development` (default) | `test` | `production`. In production,
`Settings` refuses to start unless `APP_JWT_SECRET` is a random value of at
least 32 characters (the shipped dev default is rejected). Production also
disables `/docs`, `/redoc`, and `/openapi.json`.
- `APP_DATABASE_TYPE` — `sqlite` (default) | `postgres` (not usable yet — see above).
- `APP_SQLITE_PATH` — default `./foraldraledig.db`.
- `APP_CORS_ORIGINS` — JSON list; default `["http://localhost:5173"]`.
- `APP_JWT_SECRET` — required to be a strong, random secret in production.
- `APP_JWT_ALGORITHM` — default `HS256`.
- `APP_JWT_ACCESS_TOKEN_EXPIRE_MINUTES` — default `15`.
- `APP_JWT_REFRESH_TOKEN_EXPIRE_DAYS` — default `7`.
- `APP_LOG_LEVEL` — default `INFO`.
- `APP_SCRAPER_ENABLED` — **defaults to `true`.** Legal/ToS review of FK scraping + BankID
automation completed 2026-06; the scraper is cleared for use. Set to `false` in any
environment without Playwright's Chromium installed (`uv run playwright install chromium`).
- `APP_SCRAPER_HEADLESS` — default `true`.
- `APP_SCRAPER_SESSION_TTL_SECONDS` — default `180`; auto-cancels a stalled scraper session.
- `APP_SCRAPER_DEBUG_ENABLED` — default `false`. **When enabled, writes personal data to disk**
(an HTML dump + full-page screenshot of the logged-in FK page — name, balances, payout
amounts). Requires `APP_SCRAPER_DEBUG_DIR` to also be set. Local debugging only; never enable
in a shared or production environment.
- `APP_SCRAPER_DEBUG_DIR` — where debug artifacts are written when the above is enabled.
- `APP_SCRAPER_DEBUG_DUMP_ON_SUCCESS` — default `false`. Also writes the same debug artifacts
(HTML + full-page screenshot) after a **successful** extraction, not just on error — a capture
aid for confirming FK's real DOM (e.g. the family panel structure `app/scrapers/fk_scraper.py`
currently assumes). Same personal-data warning as `APP_SCRAPER_DEBUG_ENABLED` applies; only
takes effect when both `APP_SCRAPER_DEBUG_ENABLED` and `APP_SCRAPER_DEBUG_DIR` are also set.
- `APP_SCRAPER_RESULT_RETENTION_SECONDS` — default `300`; how long a finished scraper session's
result stays in memory before being purged.
- `APP_RULES__*` — the Swedish parental-leave rule constants (`LeaveRules`, nested under `rules`),
overridable per field, e.g. `APP_RULES__MAX_SAVABLE_DAYS_AT_AGE_4=96`. Defaults: 390
sjukpenning days, 90 lägstanivå days, 90 reserved days/parent, 60 max dubbeldagar over the
first 15 months, 96 max savable at age 4 (48 per parent when the child has two or more
parents — see `resolve_age4_savable_cap`), age-12 hard expiry, 6 preschool planning days/year,
25 statutory vacation days/year, 20 minimum vacation days/year that must be taken. These are
common-case figures only — children born before 2014, multiples, and single custody differ and
are not modeled.
## Layout
```
app/
config.py # pydantic-settings (app config + frozen LeaveRules)
dependencies.py # DI factories (pick repo impl by settings.database_type)
entities/ # plain dataclasses (domain)
models/ # pydantic API schemas
repositories/ # abstract interfaces
repositories_impl/ # concrete impls per backend (sqlite/, postgres/)
services/ # business logic
routers/ # FastAPI routes
db/connection.py # connection + (dev) schema bootstrap
utils/ # errors, logger
```
## Demo data (manual/browser testing)
`scripts/seed_demo.py` seeds a realistic, ready-to-explore workspace through the
normal service/repository layers (no raw SQL) — a couple workspace with a
partner, a child born ~6 months ago, vacation rows for only some years (so the
`is_assumed` badge is visible), day overrides, and a spread of leave wishes
(fixed, flexible, and one deliberately underfunded to show a shortfall +
remedies + surplus advice). Login credentials are fixed and printed at the end.
```bash
uv run python scripts/seed_demo.py # create once; re-running is a no-op
uv run python scripts/seed_demo.py --reset # wipe the demo workspace and rebuild
```
Then open `http://localhost:5173` and log in with the printed `demo@example.com`
credentials. Safe to re-run: without `--reset` it detects the existing demo
user and changes nothing; `--reset` only ever deletes the one demo workspace.
## Reference vertical slice
`VacationDays` is implemented end-to-end (entity → schema → repo interface →
SQLite impl → service → router → tests). **Copy this slice** when adding a new
entity.
> The inline schema in `db/connection.py` exists only so dev/tests are
> self-contained. Production schema is owned by Alembic migrations (a senior task).