Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et finances personnelles avec import de relevés bancaires. Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS + Vite + Tailwind + ECharts, déploiement Docker Compose. Modules auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework de connecteurs à deux voies (importeurs de fichiers + ingestion JSON) pour brancher de nouvelles sources sans toucher au noyau. Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16 (28 tables, SPA servie par nginx, wizard de premier démarrage). Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les documents de conception et de recherche dans docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
90 lines
3.0 KiB
Python
90 lines
3.0 KiB
Python
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, Header, Query, UploadFile
|
|
from fastapi.security import HTTPAuthorizationCredentials
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.config import get_settings
|
|
from app.core.database import get_db
|
|
from app.core.dependencies import authenticate_ingest, bearer, get_current_user
|
|
from app.core.errors import PayloadTooLargeError
|
|
from app.core.pagination import Page, PageParams, paginate
|
|
from app.modules.auth.models import User
|
|
from app.modules.imports import service
|
|
from app.modules.imports.schemas import (
|
|
ImportRunRead,
|
|
IngestPayload,
|
|
IngestResponse,
|
|
SourceInfo,
|
|
)
|
|
|
|
router = APIRouter(prefix="/imports", tags=["imports"])
|
|
ingest_router = APIRouter(prefix="/ingest", tags=["ingest"])
|
|
|
|
# Mounted by the module loader in addition to `router` (architecture §5.5).
|
|
extra_routers = [ingest_router]
|
|
|
|
DbDep = Annotated[Session, Depends(get_db)]
|
|
UserDep = Annotated[User, Depends(get_current_user)]
|
|
|
|
|
|
@router.get("/sources", response_model=list[SourceInfo])
|
|
def list_sources(user: UserDep) -> list[SourceInfo]:
|
|
return service.list_sources()
|
|
|
|
|
|
@router.post("", response_model=ImportRunRead, status_code=201)
|
|
def upload_import(
|
|
db: DbDep,
|
|
user: UserDep,
|
|
file: Annotated[UploadFile, File()],
|
|
source: Annotated[str, Form()],
|
|
) -> ImportRunRead:
|
|
data = file.file.read()
|
|
if len(data) > get_settings().max_upload_bytes:
|
|
raise PayloadTooLargeError("Fichier trop volumineux (limite : 20 Mio).")
|
|
filename = file.filename or "import"
|
|
importer_id = service.resolve_importer_id(source, filename, data[:4096])
|
|
run = service.run_import(db, user, importer_id, filename, data)
|
|
return ImportRunRead.model_validate(run)
|
|
|
|
|
|
@router.get("", response_model=Page[ImportRunRead])
|
|
def list_imports(
|
|
db: DbDep,
|
|
user: UserDep,
|
|
params: Annotated[PageParams, Depends()],
|
|
domain: Annotated[str | None, Query()] = None,
|
|
sort: Annotated[str | None, Query()] = None,
|
|
) -> Page[ImportRunRead]:
|
|
stmt = service.runs_query(user.id, domain, sort)
|
|
items, total = paginate(db, stmt, params)
|
|
return Page(
|
|
items=[ImportRunRead.model_validate(run) for run in items],
|
|
total=total,
|
|
page=params.page,
|
|
page_size=params.page_size,
|
|
)
|
|
|
|
|
|
@router.get("/{run_id}", response_model=ImportRunRead)
|
|
def get_import(run_id: int, db: DbDep, user: UserDep) -> ImportRunRead:
|
|
return ImportRunRead.model_validate(service.get_run(db, user.id, run_id))
|
|
|
|
|
|
@router.delete("/{run_id}", status_code=204)
|
|
def rollback_import(run_id: int, db: DbDep, user: UserDep) -> None:
|
|
service.rollback_run(db, user.id, run_id)
|
|
|
|
|
|
@ingest_router.post("/{domain}", response_model=IngestResponse)
|
|
def ingest(
|
|
domain: str,
|
|
payload: IngestPayload,
|
|
db: DbDep,
|
|
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)],
|
|
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
|
|
) -> IngestResponse:
|
|
user = authenticate_ingest(db, credentials, x_api_key, f"ingest:{domain}")
|
|
return service.run_ingest(db, user, domain, payload)
|