97a7200c1a
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
247 lines
9.0 KiB
Python
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"}
|