249e277696
Second real bug found by driving the running app while the suites were green. Flexible wishes were funded twice across propose -> apply -> re-propose. Because planned_leave_day carries no wish_id, the engine could not tell that applied days were that wish's own prior occurrence; it treated them as occupied space, routed the candidate search around them, and funded the year again on shifted dates while reporting weeks_funded 1.0. Applying twice turned "one week each spring" into 18 paid days instead of 9. Fixed wishes were unaffected and stay unaffected. Fix (allocation_service.py), per the approved approach of counting already-applied days inside the bounding range rather than adding a wish_id column: - _Ledger._persisted_lookup snapshots existing days BEFORE this run folds in its own freshly-computed windows, so a fixed wish's new placement is never mistaken for a flexible wish's history - _reuse_already_funded_window finds this wish's persisted dates within its bound and reuses the window containing them, via the ordinary fund_window path - full funding therefore yields zero new days and partial funding tops up exactly the remainder, with no duplication and no false shortfall - with no persisted dates, or if the reused window is no longer collision-free, it falls through to the original search, so an unsatisfied year is never frozen Calendar cache extended 2026-2035 -> 2026-2055 (39KB -> 116KB) and the allocator switched to strict, so an uncached year raises UncachedYearError -> ValidationError -> HTTP 422 instead of silently treating Julafton, Nyarsafton and Midsommarafton as working days. Also fixed normalize_day dropping the second name when two holidays share a date (Nationaldagen was being lost). 2049 and 2055 legitimately have 17 red days, not 18: Pingstdagen coincides with Nationaldagen. New backend/tests/integration/test_user_journey.py drives the real app over HTTP across seven journeys: the double-booking guard, the fixed-wish control, apply idempotency, fixed-never-displaced-by-flexible, flexible wishes tracking Easter per year, age-4 arithmetic self-consistency, and a recursive check that no Swedish text reaches the wire now that everything is message_key + data. New backend/scripts/seed_demo.py seeds a realistic workspace through the real service layer for manual testing: demo@example.com / DemoPass123! Backend 414 -> 435 tests. Frontend unchanged at 705. Still not verified in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
443 lines
18 KiB
Python
443 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)
|
|
|
|
return DemoServices(
|
|
auth=AuthService(users, personal_data, workspaces),
|
|
workspace=WorkspaceService(workspaces, invitations, personal_data),
|
|
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),
|
|
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", planning_goal="balanced"
|
|
)
|
|
|
|
# -- 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())
|