#!/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), 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 await services.workspace.update_workspace(workspace_id, planning_mode="couple") # -- 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, days_used=240, reserved_days_total=90, reserved_days_left=0, transferable_days_left=0, dubbeldagar_left=55, sjukpenning_total=195, sjukpenning_used=195, lagsta_niva_total=45, lagsta_niva_used=45, ) # -- 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} (planning_mode=couple)") 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())