4d70c8950d
`uv run pytest` reported 26 DeprecationWarnings, 20 of them raised by code we own. Two independent sources, both already renamed upstream. Starlette renamed HTTP_422_UNPROCESSABLE_ENTITY to HTTP_422_UNPROCESSABLE_CONTENT, matching the RFC 9110 name for the status. Rename all 21 call sites across the twelve routers that raise it. The numeric value is unchanged at 422, so the wire format and the frontend's error handling are untouched -- this is forward-compat debt only. The warnings attributed to fastapi/routing.py were these same handler frames raising through the dependency call, and go with them. Alembic >=1.16 supersedes `version_path_separator` with `path_separator`, which governs both `prepend_sys_path` and `version_locations`; its absence is what triggered the legacy-splitting warning on every config load. Swap the key in alembic.ini. Deliberately not fixing slowapi's `asyncio.iscoroutinefunction` warning (6 of the 26): it is upstream with no released fix, and no `filterwarnings` entry is added, so it stays visible as a reminder to drop the pin once one lands. Verified from backend/: ruff check and format clean, 893 passed with coverage at 92%, and the warnings summary is now exactly those 6 slowapi lines. `alembic upgrade head` runs 001 through 008 without the path_separator warning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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_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"}
|