Files
F-r-ldraledigplannerare/backend/app/routers/fetch_data.py
T
jacobh2 97a7200c1a Parse transferable days from FK instead of deriving them
The scraper never read "överlåtbara dagar" from Försäkringskassan. It only
visited ansok-och-planera-foraldrapenning#/valjdagar and derived the
reserved/transferable split in build_snapshot, assuming transferable days
are spent before reserved ones. On real data that gave 49 for the user and
0 for the partner where FK states 129 and 71.

The assumption cannot be repaired: reserved-first, transferable-first,
proportional and cap-based models were all checked against real figures and
none reproduces FK's accounting (reserved-first fits the partner but is 10
days off for the user). FK publishes the real numbers per parent per tier on
the min-foraldrapenning page, which the scraper had never visited.

- Scrape the "Dagar ni kan föra över" panel after the valjdagar extraction,
  summing sjukpenningnivå + lägstanivå per parent. The totals are not
  literals on the page. Parse only direct-child <p> elements: the first
  group embeds a tooltip with decoy "45 dagar"/"90 dagar" text.
- build_snapshot accepts the reported value and derives reserved days from
  it; the old arithmetic remains only as a fallback. An implausible FK value
  degrades to that fallback rather than failing the fetch.
- Record provenance in transferable_days_reported (migration 006) and badge
  derived values as estimates in the dashboard breakdown. Drop the footnote's
  claim that at most 150 days are transferable — real data contradicts it as
  a per-family figure.
- Select the child's section by name when FK lists several; never guess.

Verified against a live BankID scrape: scraped 240/101/139 with 129
transferable, deduced partner 240/169/71 with 71, both flagged as reported.

Incidental fixes found along the way:

- The fetch router never forwarded the partner's transferable figure to
  persist_partner_deduction, so the partner's stored snapshot would have kept
  the flawed split even once the parse landed.
- 'deduced' snapshots were labelled "Manually entered" by a two-way ternary
  over a three-value union. Replaced with a compiler-checked exhaustive map.
- Alembic resolved its database from alembic.ini alone, ignoring
  APP_SQLITE_PATH, so migrations silently targeted the developer's real
  database even when pointed elsewhere. env.py now resolves it, preferring
  -x sqlalchemy.url, then a real APP_SQLITE_PATH, then the ini value.
- backend/captures/ was not ignored: the root pattern was anchored as
  /captures/ despite a comment saying scraper dumps must never be tracked.
- CLAUDE.md quoted test counts and coverage percentages that had gone stale,
  which made a correct verification report look suspect. Gate thresholds kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXPBfr1v7F71t6QcbHCaaT
2026-09-20 09:11:53 +02:00

247 lines
9.0 KiB
Python

"""HTTP routes for parental-leave data: manual entry + scraper session.
POST /api/parental-leave/manual — manual entry (always available)
GET /api/parental-leave/{personal_data_uuid}/{child_id} — latest snapshot
GET /api/parental-leave/{personal_data_uuid}/{child_id}/history — all snapshots
Scraper endpoints (start / poll / cancel) return 501 when scraper_enabled is
false. When enabled, they drive the FKScraperService session state machine.
"""
import logging
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from starlette.requests import Request
from app.config import settings
from app.dependencies import (
ChildRepositoryDep,
CurrentUserDep,
ParentalLeaveRepositoryDep,
PersonalDataRepositoryDep,
WorkspaceScopeDep,
)
from app.entities.parental_leave import ParentalLeaveFetch
from app.rate_limit import limiter
from app.models.parental_leave import (
ManualFetchRequest,
ParentalLeaveFetchResponse,
)
from app.scrapers.fk_scraper import FKScraperService, PartnerDeduction
from app.services.fetch_data_service import ManualFetchService, persist_partner_deduction
from app.utils.errors import ValidationError
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/parental-leave", tags=["parental-leave"])
scraper_router = APIRouter(prefix="/api/fetch-data", tags=["fetch-data"])
_SCRAPER_DISABLED = HTTPException(
status.HTTP_501_NOT_IMPLEMENTED,
"FK scraping is disabled. Set APP_SCRAPER_ENABLED=true to enable it. "
"Use /api/parental-leave/manual for manual data entry.",
)
# Module-level service instance — sessions are in-memory per process.
_scraper_service = FKScraperService()
# ---------------------------------------------------------------------------
# Parental-leave CRUD (manual entry, always available)
# ---------------------------------------------------------------------------
@router.post(
"/manual", response_model=ParentalLeaveFetchResponse, status_code=status.HTTP_201_CREATED
)
async def save_manual_entry(
payload: ManualFetchRequest,
repo: ParentalLeaveRepositoryDep,
pd_repo: PersonalDataRepositoryDep,
scope: WorkspaceScopeDep,
) -> ParentalLeaveFetchResponse:
scope.check_workspace(payload.workspace_id)
await scope.check_member(payload.personal_data_uuid)
await scope.check_child(payload.child_id)
# `pd_repo` lets the service default `total_days` off the workspace's
# parent count when the caller omits it — see
# `ManualFetchService.save_manual_entry`'s docstring.
service = ManualFetchService(repo, pd_repo)
try:
fetch = await service.save_manual_entry(
personal_data_uuid=payload.personal_data_uuid,
workspace_id=payload.workspace_id,
child_id=payload.child_id,
sjukpenning_left=payload.sjukpenning_left,
lagsta_niva_left=payload.lagsta_niva_left,
dubbeldagar_left=payload.dubbeldagar_left,
reserved_days_left=payload.reserved_days_left,
total_days=payload.total_days,
source=payload.source,
)
except ValidationError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
return ParentalLeaveFetchResponse.from_entity(fetch)
@router.get("/{personal_data_uuid}/{child_id}", response_model=ParentalLeaveFetchResponse | None)
async def get_latest(
personal_data_uuid: UUID,
child_id: UUID,
repo: ParentalLeaveRepositoryDep,
scope: WorkspaceScopeDep,
) -> ParentalLeaveFetchResponse | None:
await scope.check_member(personal_data_uuid)
await scope.check_child(child_id)
service = ManualFetchService(repo)
fetch = await service.get_latest(personal_data_uuid, child_id)
return ParentalLeaveFetchResponse.from_entity(fetch) if fetch else None
@router.get(
"/{personal_data_uuid}/{child_id}/history", response_model=list[ParentalLeaveFetchResponse]
)
async def get_history(
personal_data_uuid: UUID,
child_id: UUID,
repo: ParentalLeaveRepositoryDep,
scope: WorkspaceScopeDep,
) -> list[ParentalLeaveFetchResponse]:
await scope.check_member(personal_data_uuid)
await scope.check_child(child_id)
service = ManualFetchService(repo)
history = await service.list_history(personal_data_uuid, child_id)
return [ParentalLeaveFetchResponse.from_entity(f) for f in history]
# ---------------------------------------------------------------------------
# Scraper session endpoints
# ---------------------------------------------------------------------------
class _StartRequest(BaseModel):
workspace_id: UUID | None = None
personal_data_uuid: UUID | None = None
child_id: UUID | None = None
class _StartResponse(BaseModel):
session_id: str
status: str
workspace_id: UUID
personal_data_uuid: UUID
child_id: UUID
class _CancelRequest(BaseModel):
session_id: str
@scraper_router.post("/start", response_model=_StartResponse)
@limiter.limit("20/minute")
async def start_fetch(
request: Request,
payload: _StartRequest,
current_user: CurrentUserDep,
scope: WorkspaceScopeDep,
child_repo: ChildRepositoryDep,
parental_leave_repo: ParentalLeaveRepositoryDep,
personal_data_repo: PersonalDataRepositoryDep,
) -> _StartResponse:
"""`workspace_id` is always the caller's own — `payload.workspace_id` is
accepted on the wire (frontend compatibility) but never trusted; the
caller's own personal_data_uuid/child_id default when omitted, and any
id explicitly supplied is verified against the caller's workspace.
"""
if not settings.scraper_enabled:
raise _SCRAPER_DISABLED
workspace_id = scope.workspace_id
personal_data_uuid = payload.personal_data_uuid or scope.personal_data_uuid
await scope.check_member(personal_data_uuid)
if payload.child_id is not None:
child = await scope.check_child(payload.child_id)
child_id = child.id
child_name = child.name
else:
children = await child_repo.list_by_workspace(workspace_id)
if not children:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"No child in workspace — add one before fetching.",
)
child_id = children[0].id
child_name = children[0].name
# Capture the repos for the background task. The connection lives for the
# app's lifetime (app.state.db_connection), so using it from the
# background coroutine is safe — aiosqlite serialises all operations
# through its internal queue.
async def _persist(fetch: ParentalLeaveFetch, partner: PartnerDeduction | None) -> None:
await parental_leave_repo.create(fetch)
if partner is not None:
# Best-effort: the caller's own snapshot above already succeeded,
# so a failure deducing/persisting the partner's snapshot must
# never fail the whole scraper session (`persist_partner_deduction`
# already swallows repo errors internally — this is a second,
# redundant layer in case that ever changes).
try:
await persist_partner_deduction(
personal_data_repo=personal_data_repo,
parental_leave_repo=parental_leave_repo,
workspace_id=workspace_id,
child_id=child_id,
caller_personal_data_uuid=personal_data_uuid,
total_days=partner.total_days,
sjukpenning_left=partner.sjukpenning_left,
lagsta_niva_left=partner.lagsta_niva_left,
dubbeldagar_left=partner.dubbeldagar_left,
transferable_days_left=partner.transferable_days_left,
)
except Exception as exc:
logger.warning("partner-deduction persist failed, session unaffected: %s", exc)
session_id = await _scraper_service.start_session(
workspace_id=workspace_id,
personal_data_uuid=personal_data_uuid,
child_id=child_id,
owner_user_id=current_user.id,
persist_callback=_persist,
child_name=child_name,
)
return _StartResponse(
session_id=session_id,
status="launching",
workspace_id=workspace_id,
personal_data_uuid=personal_data_uuid,
child_id=child_id,
)
@scraper_router.get("/poll")
@limiter.limit("120/minute")
async def poll_fetch(
request: Request,
session_id: str,
current_user: CurrentUserDep,
) -> dict:
if not settings.scraper_enabled:
raise _SCRAPER_DISABLED
return await _scraper_service.poll_session(session_id, current_user.id)
@scraper_router.post("/cancel")
@limiter.limit("20/minute")
async def cancel_fetch(
request: Request,
payload: _CancelRequest,
current_user: CurrentUserDep,
) -> dict:
if not settings.scraper_enabled:
raise _SCRAPER_DISABLED
await _scraper_service.cancel_session(payload.session_id, current_user.id)
return {"status": "cancelled"}