MVP frontend+backend

This commit is contained in:
2026-06-14 09:58:30 +02:00
parent d9539f23cc
commit 772d853e37
56 changed files with 5099 additions and 207 deletions
+38 -2
View File
@@ -1,11 +1,12 @@
"""HTTP routes for auth: register, login, logout, me, refresh."""
from fastapi import APIRouter, Cookie, HTTPException, Response, status
from pydantic import BaseModel
from starlette.requests import Request
from app.config import settings
from app.dependencies import AuthServiceDep, CurrentUserDep
from app.main import limiter
from app.dependencies import AuthServiceDep, ChildRepositoryDep, CurrentUserDep, PersonalDataRepositoryDep
from app.rate_limit import limiter
from app.models.auth import LoginRequest, RegisterRequest, TokenResponse, UserMeResponse
from app.utils.errors import AuthError, ConflictError, UnauthorizedError
@@ -83,6 +84,41 @@ async def me(current_user: CurrentUserDep) -> UserMeResponse:
)
class _ChildContext(BaseModel):
id: str
name: str
birth_date: str
class _UserContext(BaseModel):
personal_data_uuid: str
workspace_id: str
parent_name: str
children: list[_ChildContext]
@router.get("/context", response_model=_UserContext)
async def get_context(
current_user: CurrentUserDep,
pd_repo: PersonalDataRepositoryDep,
child_repo: ChildRepositoryDep,
) -> _UserContext:
"""Return workspace + children for the current user. Used by the dev harness."""
pd = await pd_repo.get_by_id(current_user.personal_data_uuid)
if pd is None:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "No parent profile found")
children = await child_repo.list_by_workspace(pd.workspace_id)
return _UserContext(
personal_data_uuid=str(pd.uuid),
workspace_id=str(pd.workspace_id),
parent_name=pd.name,
children=[
_ChildContext(id=str(c.id), name=c.name, birth_date=str(c.birth_date))
for c in children
],
)
def _set_refresh_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=_REFRESH_COOKIE,
+114 -21
View File
@@ -1,26 +1,33 @@
"""HTTP routes for parental-leave data: manual entry + scraper status.
"""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 (POST /api/fetch-data/start, GET /api/fetch-data/poll,
POST /api/fetch-data/cancel) are stubs returning 501 until the legal gate
clears (ARCHITECTURE.md §2.3).
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 CurrentUserDep, ParentalLeaveRepositoryDep
from app.main import limiter
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
@@ -29,14 +36,18 @@ scraper_router = APIRouter(prefix="/api/fetch-data", tags=["fetch-data"])
_SCRAPER_DISABLED = HTTPException(
status.HTTP_501_NOT_IMPLEMENTED,
"FK scraping is disabled pending legal/ToS + BankID review (§2.3). "
"Use /api/parental-leave/manual for data entry.",
)
_SCRAPER_NOT_IMPLEMENTED = HTTPException(
status.HTTP_501_NOT_IMPLEMENTED,
"FK scraper is not yet 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
@@ -94,26 +105,108 @@ async def get_history(
return [ParentalLeaveFetchResponse.from_entity(f) for f in history]
# Scraper stubs — 501 until legal gate clears (settings.scraper_enabled).
@scraper_router.post("/start")
# ---------------------------------------------------------------------------
# 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, _current_user: CurrentUserDep) -> None:
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
raise _SCRAPER_NOT_IMPLEMENTED
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("20/minute")
async def poll_fetch(request: Request, _current_user: CurrentUserDep) -> None:
@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
raise _SCRAPER_NOT_IMPLEMENTED
return await _scraper_service.poll_session(session_id)
@scraper_router.post("/cancel")
@limiter.limit("20/minute")
async def cancel_fetch(request: Request, _current_user: CurrentUserDep) -> None:
async def cancel_fetch(
request: Request,
payload: _CancelRequest,
_current_user: CurrentUserDep,
) -> dict:
if not settings.scraper_enabled:
raise _SCRAPER_DISABLED
raise _SCRAPER_NOT_IMPLEMENTED
await _scraper_service.cancel_session(payload.session_id)
return {"status": "cancelled"}