Files
F-r-ldraledigplannerare/backend/scripts/seed_demo.py
T
jacobh2 e4719cb9ae Rework balances around per-parent days left and deduce the partner from FK
Manual entry now takes one parent's sjukpenningnivå, lägstanivå and
dubbeldagar left (plus optional reserved days) and derives the rest in
build_snapshot, shared with the scraper. The scraper also reads the
family panel and exposes a partner deduction; with two parents it is
saved as a 'deduced' snapshot (migration 002). Parent count is the single
source of entitlement: one member gets 480 days and 96 savable at age 4,
two get 240 and 48 each. Workspaces are capped at two parents.

Also lands the in-flight squash of migrations 002–011 into the initial
schema and the removal of planning_mode.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 12:27:29 +02:00

444 lines
18 KiB
Python

#!/usr/bin/env python3
"""Seed a realistic, ready-to-explore demo workspace for manual/browser testing.
Everything below goes through the normal service/repository layers — the same
`AuthService`, `WorkspaceService`, `ChildService`, `VacationDaysService`,
`DayOverrideService`, `LeaveWishService`, `ManualFetchService` and
`PlanningService` the API routers call — so it exercises real validation and
real code paths instead of poking SQLite directly. It creates:
1. A user with fixed, printed credentials (see DEMO_EMAIL/DEMO_PASSWORD)
so a human can just log in at http://localhost:5173.
2. A couple workspace with a partner (a login-less PersonalData profile,
via `WorkspaceService.add_placeholder_parent` — "solo planning for a
partner who hasn't registered", see PersonalData's docstring) so
shared/partner planning (reserved days, dubbeldagar) is visible.
3. A child born ~6 months before the day the script is first run, so the
age-4 boundary and the whole planning horizon sit a few years out
rather than silently aging out of a hardcoded year.
4. Vacation-days rows for only some years, so `is_assumed` (§3.2) shows up
on the years intentionally left unset.
5. Day overrides: one WORKED override on a date the calendar treats as a
holiday, and one OFF override on Valborgsmässoafton (30 April) — which
§6 says is deliberately NOT a default day off, making it the natural
"we take it off anyway" example.
6. Four leave wishes: two fixed recurring ("Sommar", "Jul", the latter
wrapping New Year's), one flexible spring wish bounded 03-01..05-31
(exercises the flexible/Easter-picking resolution path, §10.2), and one
low-priority fixed wish pinned to the demo user specifically, whose FP
pool and that year's vacation are deliberately drained so a real
shortfall (and its remedies) show up in the very first proposal.
7. A handful of already-applied planned days for the partner (who is left
with a large surplus pool) so the Dashboard/Kalender are not empty on
first load, and so `surplus_advice` (§9.2) has something to point at.
Idempotent / re-runnable: if the demo user already exists, the script prints
the existing summary and exits without touching the database — never
destructive by default. Pass --reset to delete the existing demo workspace
(and only that workspace, cascading via FK ON DELETE CASCADE — see
`app/db/connection.py`) and recreate it from scratch.
Usage (from backend/):
uv run python scripts/seed_demo.py # create once, or report existing
uv run python scripts/seed_demo.py --reset # wipe the demo workspace and rebuild
"""
from __future__ import annotations
import argparse
import asyncio
from dataclasses import dataclass
from datetime import date, timedelta
from uuid import UUID
import aiosqlite
from dateutil.relativedelta import relativedelta
from app.config import settings
from app.db.connection import connect, run_migrations
from app.entities.personal_data import PersonalData
from app.entities.user import User
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
from app.repositories_impl.sqlite.day_override_repository import SQLiteDayOverrideRepository
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
from app.repositories_impl.sqlite.user_repository import SQLiteUserRepository
from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository
from app.repositories_impl.sqlite.workspace_repository import (
SQLiteWorkspaceInvitationRepository,
SQLiteWorkspaceRepository,
)
from app.services.analytics_service import AnalyticsService
from app.services.auth_service import AuthService
from app.services.child_service import ChildService
from app.services.day_override_service import DayOverrideService
from app.services.fetch_data_service import ManualFetchService
from app.services.leave_wish_service import LeaveWishService
from app.services.planning_service import PlanningService
from app.services.vacation_days_service import VacationDaysService
from app.services.workspace_service import WorkspaceService
from app.utils.holidays import SUNDAY_HOLIDAY_NAME, get_swedish_holidays
DEMO_EMAIL = "demo@example.com"
DEMO_PASSWORD = "DemoPass123!" # dev-only, printed below — never a real secret
DEMO_NAME = "Alex"
PARTNER_NAME = "Sam"
CHILD_NAME = "Nova"
FRONTEND_URL = "http://localhost:5173"
@dataclass
class DemoServices:
"""Every service the seeder needs, wired the same way `dependencies.py` does."""
auth: AuthService
workspace: WorkspaceService
child: ChildService
vacation: VacationDaysService
day_override: DayOverrideService
leave_wish: LeaveWishService
manual_fetch: ManualFetchService
planning: PlanningService
users: SQLiteUserRepository
personal_data: SQLitePersonalDataRepository
workspaces: SQLiteWorkspaceRepository
def _build_services(conn: aiosqlite.Connection) -> DemoServices:
users = SQLiteUserRepository(conn)
personal_data = SQLitePersonalDataRepository(conn)
workspaces = SQLiteWorkspaceRepository(conn)
invitations = SQLiteWorkspaceInvitationRepository(conn)
children = SQLiteChildRepository(conn)
vacation_repo = SQLiteVacationDaysRepository(conn)
day_overrides = SQLiteDayOverrideRepository(conn)
leave_wishes = SQLiteLeaveWishRepository(conn)
parental_leave = SQLiteParentalLeaveRepository(conn)
planned_leave = SQLitePlannedLeaveRepository(conn)
analytics = AnalyticsService(parental_leave, planned_leave, children, personal_data, workspaces)
return DemoServices(
auth=AuthService(users, personal_data, workspaces),
workspace=WorkspaceService(
workspaces,
invitations,
personal_data,
users,
leave_wishes,
planned_leave,
parental_leave,
vacation_repo,
),
child=ChildService(children),
vacation=VacationDaysService(vacation_repo),
day_override=DayOverrideService(day_overrides),
leave_wish=LeaveWishService(leave_wishes, planned_leave),
manual_fetch=ManualFetchService(parental_leave),
planning=PlanningService(planned_leave, analytics, parental_leave, vacation_repo),
users=users,
personal_data=personal_data,
workspaces=workspaces,
)
async def _find_existing_demo_user(services: DemoServices) -> User | None:
return await services.users.get_by_email(DEMO_EMAIL)
async def _reset(services: DemoServices, user: User) -> None:
"""Delete the demo user's workspace, cascading via FK ON DELETE CASCADE.
Removes everything hanging off it (personal_data, the user row itself,
child, leave_wish, vacation_days, planned_leave_day, day_override,
parental_leave_fetch, workspace_invitation) — see the CASCADE chain in
app/db/connection.py. This never touches any other user's data: it only
ever deletes the one workspace resolved from the demo user's own
personal_data row.
"""
pd = await services.personal_data.get_by_id(user.personal_data_uuid)
if pd is None:
# Orphaned user row (shouldn't happen); nothing to cascade from.
return
await services.workspaces.delete(pd.workspace_id)
print(f"[reset] Deleted existing demo workspace {pd.workspace_id} (cascaded).")
async def _print_existing_summary(services: DemoServices, user: User) -> None:
pd = await services.personal_data.get_by_id(user.personal_data_uuid)
workspace_id = pd.workspace_id if pd is not None else None
print("Demo user already exists — nothing changed. Re-run with --reset to rebuild it.")
print(f" Email: {DEMO_EMAIL}")
print(f" Password: {DEMO_PASSWORD}")
print(f" Workspace id: {workspace_id}")
print(f" Open: {FRONTEND_URL}")
def _pick_worked_override_date(birth_year: int, birth_date: date) -> tuple[date, str]:
"""Pick a real calendar holiday, on/after the child's birth date, to force-work.
This is the "we must work despite the calendar" override direction (see
DayOverride's docstring), i.e. `is_working_day=True`. Looked up from the
actual holiday calendar rather than hardcoded, so it stays correct
regardless of which years the horizon covers.
"""
holidays = get_swedish_holidays(birth_year)
holidays.update(get_swedish_holidays(birth_year + 1))
# Every Sunday is also in this map (SUNDAY_HOLIDAY_NAME) — skip those, a
# named red day/klämdag makes a far more legible override example.
candidates = sorted(
d for d, name in holidays.items() if d >= birth_date and name != SUNDAY_HOLIDAY_NAME
)
chosen = candidates[0]
return chosen, holidays[chosen]
async def _seed(conn: aiosqlite.Connection, *, reset: bool) -> None:
services = _build_services(conn)
existing = await _find_existing_demo_user(services)
if existing is not None:
if not reset:
await _print_existing_summary(services, existing)
return
await _reset(services, existing)
today = date.today()
birth_date = today - relativedelta(months=6)
birth_year = birth_date.year
# -- 1. User + personal workspace + PersonalData (AuthService.register) --
_user, alex = await services.auth.register(
email=DEMO_EMAIL, password=DEMO_PASSWORD, display_name=DEMO_NAME
)
workspace_id = alex.workspace_id
# -- 2. Partner (no login — shared/partner planning needs a second parent
# on the workspace, but nothing requires them to have registered) -------
sam = await services.workspace.add_placeholder_parent(workspace_id, PARTNER_NAME)
# -- 3. Child, born ~6 months ago relative to today ----------------------
child = await services.child.create(
workspace_id=workspace_id,
name=CHILD_NAME,
birth_date=birth_date,
preschool_start_date=birth_date + relativedelta(years=1, months=4),
preschool_planning_days_per_year=6,
)
fourth_birthday = birth_date + relativedelta(years=4)
# -- 4. Vacation-days rows for only SOME years -----------------------
# Alex gets birth_year (deliberately 0 — see the manual FP entry below,
# this is the year the shortfall demo bites) and birth_year+1 (a normal
# entered year, with some carried-over days). Every other year up to the
# 4th birthday is left unset on purpose so `is_assumed` shows on them.
await services.vacation.upsert(
personal_data_uuid=alex.uuid, workspace_id=workspace_id, year=birth_year, total_days=0
)
await services.vacation.upsert(
personal_data_uuid=alex.uuid,
workspace_id=workspace_id,
year=birth_year + 1,
total_days=20,
saved_days_in=3,
)
# Sam gets one entered year too, rest assumed.
await services.vacation.upsert(
personal_data_uuid=sam.uuid, workspace_id=workspace_id, year=birth_year, total_days=25
)
# -- 5. Manual FP snapshot for Alex, deliberately near-drained ----------
# Narrative: Alex front-loaded almost all of their sjukpenning/lägstanivå
# days right after birth, before this planner was in use. Combined with
# the 0-day vacation year above, this guarantees the low-priority
# "Höstlov" wish below cannot be fully funded — a real, visible shortfall
# in the very first proposal, not a contrived one.
await services.manual_fetch.save_manual_entry(
personal_data_uuid=alex.uuid,
workspace_id=workspace_id,
child_id=child.id,
total_days=240,
sjukpenning_left=0,
lagsta_niva_left=0,
reserved_days_left=0,
dubbeldagar_left=55,
)
# -- 6. Day overrides -----------------------------------------------
worked_date, worked_holiday_name = _pick_worked_override_date(birth_year, birth_date)
await services.day_override.upsert(
workspace_id=workspace_id,
day=worked_date,
is_working_day=True,
note=f"Vi jobbar trots {worked_holiday_name.lower()} i år",
)
valborg_year = birth_year if date(birth_year, 4, 30) >= birth_date else birth_year + 1
await services.day_override.upsert(
workspace_id=workspace_id,
day=date(valborg_year, 4, 30),
is_working_day=False,
note="Vi tar ledigt på Valborgsmässoafton",
)
# -- 7. Leave wishes: fixed x2, flexible x1, and the deliberately-tight
# low-priority one pinned to Alex --------------------------------------
await services.leave_wish.create(
workspace_id=workspace_id,
child_id=child.id,
name="Sommar",
window_start_month=6,
window_start_day=15,
window_end_month=7,
window_end_day=26,
target_weeks=6,
recurrence="yearly",
first_year=birth_year,
priority=1,
parent_mode="either",
)
await services.leave_wish.create(
workspace_id=workspace_id,
child_id=child.id,
name="Jul",
window_start_month=12,
window_start_day=20,
window_end_month=1,
window_end_day=16, # wraps into January
target_weeks=4,
recurrence="yearly",
first_year=birth_year,
priority=1,
parent_mode="either",
)
await services.leave_wish.create(
workspace_id=workspace_id,
child_id=child.id,
name="Vår",
window_start_month=3,
window_start_day=1,
window_end_month=5,
window_end_day=31,
target_weeks=1,
recurrence="yearly",
first_year=birth_year + 1,
priority=2,
parent_mode="either",
window_mode="flexible",
)
await services.leave_wish.create(
workspace_id=workspace_id,
child_id=child.id,
name="Höstlov",
window_start_month=10,
window_start_day=25,
window_end_month=11,
window_end_day=1,
target_weeks=1,
recurrence="yearly",
first_year=birth_year,
priority=3,
parent_mode="specific",
parent_personal_data_uuid=alex.uuid,
)
# -- 8. A few already-applied planned days for Sam (ample balance) so
# the Dashboard/Kalender are not empty on first load --------------------
planned_start = today + timedelta(days=7)
applied_days = 0
for offset, day_type in ((0, "vacation"), (1, "vacation"), (2, "sjukpenning")):
await services.planning.add_planned_day(
workspace_id=workspace_id,
child_id=child.id,
parent_personal_data_uuid=sam.uuid,
date=planned_start + timedelta(days=offset),
day_type=day_type,
)
applied_days += 1
_print_summary(
workspace_id=workspace_id,
child_id=child.id,
alex=alex,
sam=sam,
birth_date=birth_date,
fourth_birthday=fourth_birthday,
worked_override=(worked_date, worked_holiday_name),
valborg_year=valborg_year,
applied_days=applied_days,
)
def _print_summary(
*,
workspace_id: UUID,
child_id: UUID,
alex: PersonalData,
sam: PersonalData,
birth_date: date,
fourth_birthday: date,
worked_override: tuple[date, str],
valborg_year: int,
applied_days: int,
) -> None:
worked_date, worked_name = worked_override
print("=" * 78)
print("DEMO WORKSPACE SEEDED")
print("=" * 78)
print()
print("Login credentials:")
print(f" Email: {DEMO_EMAIL}")
print(f" Password: {DEMO_PASSWORD}")
print()
print("Created:")
print(f" Workspace id: {workspace_id} (couple: 2 parents)")
print(f" Parent 1: {DEMO_NAME} ({alex.uuid}) — has a login, near-drained FP pool")
print(f" Parent 2: {PARTNER_NAME} ({sam.uuid}) — no login, large surplus pool")
print(f" Child: {CHILD_NAME} ({child_id}), born {birth_date.isoformat()}")
print(f" 4th birthday {fourth_birthday.isoformat()}")
print(" Vacation: entered for 2 years per parent, other years left")
print(" unset (is_assumed badge should show on those)")
print(
f" Overrides: worked {worked_date.isoformat()} ({worked_name}); "
f"off {valborg_year}-04-30 (Valborgsmässoafton)"
)
print(" Wishes: Sommar (fixed, either), Jul (fixed, either, wraps NY),")
print(" Vår (flexible, either, 03-01..05-31), Höstlov (fixed,")
print(f" specific->{DEMO_NAME}, deliberately underfunded -> shortfall)")
print(f" Applied days: {applied_days} planned_leave_day rows for {PARTNER_NAME}")
print()
print(f"Open: {FRONTEND_URL}")
async def main() -> None:
parser = argparse.ArgumentParser(
description="Seed a realistic, ready-to-explore demo workspace."
)
parser.add_argument(
"--reset",
action="store_true",
help="Delete the existing demo workspace (only that one) and recreate it.",
)
args = parser.parse_args()
if settings.database_type != "sqlite":
raise SystemExit(
f"seed_demo.py only supports sqlite (database_type={settings.database_type!r})"
)
# Defensive: a fresh checkout with no server ever run yet would have no
# schema. The dev server does this same call on every boot (see
# app/main.py's lifespan) — it's a no-op once already at head.
await asyncio.to_thread(run_migrations, f"sqlite:///{settings.sqlite_path}")
conn = await connect(settings.sqlite_path)
try:
await _seed(conn, reset=args.reset)
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(main())