fdbfb7d580
Three faults, all invisible while the tests passed. Advice was built for the first child only, so a second child got none. The planned days feeding every count were fetched once per parent and appended to one list, so a couple's days were counted twice and every intensity and projection derived from them was wrong. And dismissing a tip forgot the moment the page reloaded. Tips now carry the date they concern, lifted from figures the rules already computed, so a view of a month can show only what applies to it. They are built per child and identified per child, so the same rule firing for two children is two separate pieces of advice. A dismissal is stored against a fingerprint of the facts that were dismissed. When those facts change the advice returns with its new numbers, rather than one click silencing a warning about a forfeiture that keeps growing.
220 lines
8.1 KiB
Python
220 lines
8.1 KiB
Python
"""Database connection management.
|
|
|
|
Production: the schema is owned by Alembic (`alembic upgrade head`). The
|
|
`run_migrations()` helper below calls Alembic programmatically from the app
|
|
lifespan so the first boot auto-migrates on SQLite (dev convenience).
|
|
|
|
Tests: `ensure_schema()` applies the full DDL directly to an in-memory
|
|
connection so test fixtures stay fast and do not depend on Alembic.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import aiosqlite
|
|
|
|
# Full schema used by test fixtures directly (avoids a full Alembic run in tests).
|
|
# This MUST stay in sync with the migration chain (migrations/versions/*.py),
|
|
# i.e. it always reflects the state after the latest migration, not just 001.
|
|
_FULL_SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS workspace (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
planning_mode TEXT NOT NULL DEFAULT 'solo'
|
|
CHECK (planning_mode IN ('solo','couple')),
|
|
created_at TIMESTAMP NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS personal_data (
|
|
uuid TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
salary_type TEXT NOT NULL DEFAULT 'monthly'
|
|
CHECK (salary_type IN ('monthly','hourly')),
|
|
employment_status TEXT NOT NULL DEFAULT 'employed'
|
|
CHECK (employment_status IN ('employed','jobseeking','none')),
|
|
min_vacation_days_per_year INTEGER NOT NULL DEFAULT 20
|
|
CHECK (min_vacation_days_per_year >= 0 AND min_vacation_days_per_year <= 60),
|
|
personnummer_enc TEXT,
|
|
created_at TIMESTAMP NOT NULL,
|
|
deleted_at TIMESTAMP,
|
|
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS "user" (
|
|
id TEXT PRIMARY KEY,
|
|
personal_data_uuid TEXT NOT NULL UNIQUE,
|
|
email TEXT NOT NULL UNIQUE,
|
|
hashed_password TEXT NOT NULL,
|
|
created_at TIMESTAMP NOT NULL,
|
|
FOREIGN KEY (personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS workspace_invitation (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
invited_by_user_id TEXT NOT NULL,
|
|
status TEXT NOT NULL CHECK (status IN ('pending','accepted','revoked','expired')),
|
|
expires_at TIMESTAMP NOT NULL,
|
|
created_at TIMESTAMP NOT NULL,
|
|
accepted_at TIMESTAMP,
|
|
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (invited_by_user_id) REFERENCES "user"(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS child (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
birth_date DATE NOT NULL,
|
|
preschool_start_date DATE,
|
|
preschool_planning_days_per_year INTEGER NOT NULL DEFAULT 6
|
|
CHECK (preschool_planning_days_per_year >= 0 AND preschool_planning_days_per_year <= 30),
|
|
created_at TIMESTAMP NOT NULL,
|
|
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS leave_wish (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL,
|
|
child_id TEXT,
|
|
name TEXT NOT NULL,
|
|
name_message_key TEXT,
|
|
window_start_month INTEGER NOT NULL DEFAULT 1
|
|
CHECK (window_start_month >= 1 AND window_start_month <= 12),
|
|
window_start_day INTEGER NOT NULL DEFAULT 1
|
|
CHECK (window_start_day >= 1 AND window_start_day <= 31),
|
|
window_end_month INTEGER NOT NULL DEFAULT 1
|
|
CHECK (window_end_month >= 1 AND window_end_month <= 12),
|
|
window_end_day INTEGER NOT NULL DEFAULT 1
|
|
CHECK (window_end_day >= 1 AND window_end_day <= 31),
|
|
target_weeks INTEGER NOT NULL,
|
|
target_days INTEGER,
|
|
recurrence TEXT NOT NULL DEFAULT 'once'
|
|
CHECK (recurrence IN ('yearly','once')),
|
|
first_year INTEGER NOT NULL DEFAULT 2026,
|
|
last_year INTEGER,
|
|
priority INTEGER NOT NULL DEFAULT 1 CHECK (priority >= 1),
|
|
parent_mode TEXT NOT NULL DEFAULT 'either'
|
|
CHECK (parent_mode IN ('either','both','specific')),
|
|
parent_personal_data_uuid TEXT,
|
|
window_mode TEXT NOT NULL DEFAULT 'fixed'
|
|
CHECK (window_mode IN ('fixed','flexible')),
|
|
created_at TIMESTAMP NOT NULL,
|
|
updated_at TIMESTAMP NOT NULL,
|
|
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS parental_leave_fetch (
|
|
id TEXT PRIMARY KEY,
|
|
personal_data_uuid TEXT NOT NULL,
|
|
workspace_id TEXT NOT NULL,
|
|
child_id TEXT NOT NULL,
|
|
source TEXT NOT NULL CHECK (source IN ('scraped','manual')),
|
|
fetched_at TIMESTAMP NOT NULL,
|
|
total_days INTEGER NOT NULL,
|
|
days_used INTEGER NOT NULL,
|
|
days_left INTEGER NOT NULL,
|
|
reserved_days_total INTEGER NOT NULL DEFAULT 90,
|
|
reserved_days_left INTEGER NOT NULL,
|
|
transferable_days_left INTEGER NOT NULL,
|
|
dubbeldagar_left INTEGER NOT NULL,
|
|
tier_data TEXT NOT NULL,
|
|
created_at TIMESTAMP NOT NULL,
|
|
updated_at TIMESTAMP NOT NULL,
|
|
FOREIGN KEY (personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE,
|
|
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS vacation_days (
|
|
id TEXT PRIMARY KEY,
|
|
personal_data_uuid TEXT NOT NULL,
|
|
workspace_id TEXT NOT NULL,
|
|
year INTEGER NOT NULL,
|
|
total_days INTEGER NOT NULL,
|
|
saved_days_in INTEGER NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP NOT NULL,
|
|
updated_at TIMESTAMP NOT NULL,
|
|
UNIQUE(personal_data_uuid, year),
|
|
FOREIGN KEY (personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE,
|
|
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS planned_leave_day (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL,
|
|
child_id TEXT NOT NULL,
|
|
parent_personal_data_uuid TEXT NOT NULL,
|
|
date DATE NOT NULL,
|
|
day_type TEXT NOT NULL CHECK (day_type IN ('sjukpenning','lagsta','vacation')),
|
|
is_dubbeldag INTEGER NOT NULL DEFAULT 0,
|
|
wish_id TEXT,
|
|
is_pinned INTEGER NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP NOT NULL,
|
|
updated_at TIMESTAMP NOT NULL,
|
|
UNIQUE(workspace_id, child_id, date, parent_personal_data_uuid),
|
|
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE,
|
|
FOREIGN KEY (wish_id) REFERENCES leave_wish(id) ON DELETE SET NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS day_override (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL,
|
|
date DATE NOT NULL,
|
|
is_working_day INTEGER NOT NULL,
|
|
note TEXT,
|
|
created_at TIMESTAMP NOT NULL,
|
|
updated_at TIMESTAMP NOT NULL,
|
|
UNIQUE(workspace_id, date),
|
|
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS tip_dismissal (
|
|
id TEXT PRIMARY KEY,
|
|
workspace_id TEXT NOT NULL,
|
|
tip_id TEXT NOT NULL,
|
|
data_hash TEXT NOT NULL,
|
|
dismissed_at TIMESTAMP NOT NULL,
|
|
UNIQUE(workspace_id, tip_id),
|
|
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE
|
|
);
|
|
"""
|
|
|
|
|
|
async def connect(path: str) -> aiosqlite.Connection:
|
|
"""Open a connection and enable row-by-name access + FK enforcement.
|
|
|
|
Schema is applied via Alembic in the app lifespan (`run_migrations`).
|
|
For test fixtures, call `ensure_schema` directly.
|
|
"""
|
|
conn = await aiosqlite.connect(path)
|
|
conn.row_factory = aiosqlite.Row
|
|
await conn.execute("PRAGMA foreign_keys = ON;")
|
|
return conn
|
|
|
|
|
|
async def ensure_schema(conn: aiosqlite.Connection) -> None:
|
|
"""Apply the full DDL directly. Used by test fixtures only."""
|
|
await conn.executescript(_FULL_SCHEMA)
|
|
await conn.commit()
|
|
|
|
|
|
def run_migrations(database_url: str | None = None) -> None:
|
|
"""Run any pending Alembic migrations (called from app lifespan).
|
|
|
|
`database_url` overrides alembic.ini's sqlalchemy.url, allowing the app
|
|
config's `sqlite_path` to take precedence over the ini default.
|
|
"""
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
|
|
cfg = Config("alembic.ini")
|
|
if database_url:
|
|
cfg.set_main_option("sqlalchemy.url", database_url)
|
|
command.upgrade(cfg, "head")
|