213 lines
7.2 KiB
Python
213 lines
7.2 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.
|
|
"""
|
|
|
|
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,
|
|
)
|
|
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
|
|
from app.services.fetch_data_service import ManualFetchService
|
|
from app.utils.errors import ValidationError
|
|
|
|
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,
|
|
_current_user: CurrentUserDep,
|
|
) -> ParentalLeaveFetchResponse:
|
|
service = ManualFetchService(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,
|
|
total_days=payload.total_days,
|
|
days_used=payload.days_used,
|
|
reserved_days_total=payload.reserved_days_total,
|
|
reserved_days_left=payload.reserved_days_left,
|
|
transferable_days_left=payload.transferable_days_left,
|
|
dubbeldagar_left=payload.dubbeldagar_left,
|
|
sjukpenning_total=payload.sjukpenning_total,
|
|
sjukpenning_used=payload.sjukpenning_used,
|
|
lagsta_niva_total=payload.lagsta_niva_total,
|
|
lagsta_niva_used=payload.lagsta_niva_used,
|
|
)
|
|
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,
|
|
_current_user: CurrentUserDep,
|
|
) -> ParentalLeaveFetchResponse | None:
|
|
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,
|
|
_current_user: CurrentUserDep,
|
|
) -> list[ParentalLeaveFetchResponse]:
|
|
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,
|
|
pd_repo: PersonalDataRepositoryDep,
|
|
child_repo: ChildRepositoryDep,
|
|
parental_leave_repo: ParentalLeaveRepositoryDep,
|
|
) -> _StartResponse:
|
|
if not settings.scraper_enabled:
|
|
raise _SCRAPER_DISABLED
|
|
|
|
personal_data_uuid = payload.personal_data_uuid or current_user.personal_data_uuid
|
|
|
|
if payload.workspace_id is None or payload.child_id is None:
|
|
pd = await pd_repo.get_by_id(personal_data_uuid)
|
|
if pd is None:
|
|
raise HTTPException(
|
|
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
"No parent profile for this account.",
|
|
)
|
|
workspace_id = payload.workspace_id or pd.workspace_id
|
|
|
|
if payload.child_id is None:
|
|
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
|
|
else:
|
|
child_id = payload.child_id
|
|
else:
|
|
workspace_id = payload.workspace_id
|
|
child_id = payload.child_id
|
|
|
|
# Capture the repo 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) -> None:
|
|
await parental_leave_repo.create(fetch)
|
|
|
|
session_id = await _scraper_service.start_session(
|
|
workspace_id=workspace_id,
|
|
personal_data_uuid=personal_data_uuid,
|
|
child_id=child_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)
|
|
|
|
|
|
@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)
|
|
return {"status": "cancelled"}
|