Files
F-r-ldraledigplannerare/backend/app/routers/fetch_data.py
T
jacobh2 64734ae6e7 Silence the deprecation warnings our own code raises
Starlette renamed HTTP_422_UNPROCESSABLE_ENTITY to HTTP_422_UNPROCESSABLE_CONTENT
and now warns on the old spelling; the value is unchanged at 422, so the 20 call
sites in the routers move over with no wire-format or frontend impact.

Alembic >=1.16 supersedes version_path_separator with path_separator, which
governs both prepend_sys_path and version_locations, and warns when it is absent.

Together this takes pytest from 26 deprecation warnings to 6. The remainder are
slowapi calling asyncio.iscoroutinefunction; they are left visible deliberately,
with no filterwarnings entry, as a reminder to revisit once upstream ships a fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 18:48:02 +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_CONTENT, 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_CONTENT,
"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"}