e4719cb9ae
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>
243 lines
8.8 KiB
Python
243 lines
8.8 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
|
|
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
|
|
|
|
# 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,
|
|
)
|
|
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,
|
|
)
|
|
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"}
|