MVP frontend+backend
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user