Files
lifetrack/apps/api/app/modules/imports/service.py
T
MeeJayandClaude Opus 5 93f0689c1e Initial import: LifeTrack v1 (santé, vape, finances)
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>
2026-08-14 10:48:57 +02:00

247 lines
8.0 KiB
Python

from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.errors import DomainValidationError, NotFoundError
from app.core.importing.base import (
ImporterParseError,
RowError,
UpsertOutcome,
)
from app.core.importing.registry import IMPORTER_REGISTRY, detect_importer
from app.core.ingest.base import IngestRecord
from app.core.ingest.registry import INGEST_REGISTRY
from app.core.timeutils import utcnow
from app.modules.auth.models import User
from app.modules.imports.models import ImportRun
from app.modules.imports.schemas import (
IngestPayload,
IngestRecordError,
IngestResponse,
SourceInfo,
)
MAX_ERROR_DETAILS = 100
def list_sources() -> list[SourceInfo]:
return [
SourceInfo(
id=cls.id,
label=cls.label,
domain=cls.domain,
accepted_extensions=cls.accepted_extensions,
)
for cls in sorted(IMPORTER_REGISTRY.values(), key=lambda c: c.id)
]
def resolve_importer_id(source: str, filename: str, head: bytes) -> str:
"""Resolve the form field `source` ("auto" or an importer id) to an id."""
if source == "auto":
detected = detect_importer(filename, head)
if detected is None:
raise DomainValidationError(
"Format non reconnu, choisissez un profil de source."
)
return detected.id
if source not in IMPORTER_REGISTRY:
raise DomainValidationError(
"Profil de source inconnu.", details={"source": source}
)
return source
def run_import(
db: Session, user: User, importer_id: str, filename: str, data: bytes
) -> ImportRun:
"""Orchestrate one synchronous import run (see architecture §5.3).
Row-level errors increment counters without stopping the run; a fatal
parse error marks the run as failed (partial domain rows rolled back).
A single commit ends the run.
"""
importer_cls = IMPORTER_REGISTRY[importer_id]
run = ImportRun(
user_id=user.id,
importer_id=importer_id,
domain=importer_cls.domain,
filename=filename,
file_size=len(data),
status="completed",
started_at=utcnow(),
)
db.add(run)
db.flush() # assign run.id so imported rows can reference it
importer = importer_cls()
importer.import_run_id = run.id
counters = {outcome: 0 for outcome in UpsertOutcome}
error_details: list[dict[str, Any]] = []
rows_total = 0
fatal_message: str | None = None
def record_error(row: int, message: str) -> None:
counters[UpsertOutcome.ERROR] += 1
if len(error_details) < MAX_ERROR_DETAILS:
error_details.append({"row": row, "message": message})
iterator = importer.parse(data, filename)
row = 0
while fatal_message is None:
row += 1
try:
record = next(iterator)
except StopIteration:
break
except RowError as exc:
rows_total += 1
record_error(row, str(exc))
continue
except ImporterParseError as exc:
fatal_message = str(exc) or "Fichier illisible pour ce profil de source."
break
except Exception: # noqa: BLE001 — importer bug must fail the run, not the API
fatal_message = "Erreur inattendue pendant la lecture du fichier."
break
rows_total += 1
try:
outcome = importer.upsert(db, user.id, record)
except RowError as exc:
record_error(row, str(exc))
continue
except Exception: # noqa: BLE001 — a broken row must not stop the run
record_error(row, "Erreur inattendue sur cette ligne.")
continue
if outcome is UpsertOutcome.ERROR:
record_error(row, "Ligne rejetée par l'importeur.")
else:
counters[outcome] += 1
if fatal_message is not None:
# Drop partial domain rows AND the flushed run, then persist a failed run.
db.rollback()
run = ImportRun(
user_id=user.id,
importer_id=importer_id,
domain=importer_cls.domain,
filename=filename,
file_size=len(data),
status="failed",
rows_total=rows_total,
rows_inserted=0,
rows_updated=0,
rows_duplicates=0,
rows_errors=counters[UpsertOutcome.ERROR],
error_details=(error_details + [{"row": row, "message": fatal_message}])[
:MAX_ERROR_DETAILS
],
started_at=utcnow(),
finished_at=utcnow(),
)
db.add(run)
db.commit()
return run
run.rows_total = rows_total
run.rows_inserted = counters[UpsertOutcome.INSERTED]
run.rows_updated = counters[UpsertOutcome.UPDATED]
run.rows_duplicates = counters[UpsertOutcome.DUPLICATE]
run.rows_errors = counters[UpsertOutcome.ERROR]
run.error_details = error_details
run.finished_at = utcnow()
db.commit()
return run
def get_run(db: Session, user_id: int, run_id: int) -> ImportRun:
run = db.get(ImportRun, run_id)
if run is None or run.user_id != user_id:
raise NotFoundError("Import introuvable.")
return run
def runs_query(user_id: int, domain: str | None, sort: str | None):
stmt = select(ImportRun).where(ImportRun.user_id == user_id)
if domain:
stmt = stmt.where(ImportRun.domain == domain)
sort = sort or "-started_at"
allowed = {"started_at": ImportRun.started_at}
descending = sort.startswith("-")
field_name = sort.lstrip("-")
column = allowed.get(field_name)
if column is None:
raise DomainValidationError(
"Champ de tri non autorisé.", details={"sort": sort}
)
order = column.desc() if descending else column.asc()
return stmt.order_by(order, ImportRun.id.desc())
def rollback_run(db: Session, user_id: int, run_id: int) -> None:
"""Delete a run; imported domain rows disappear via FK ON DELETE CASCADE."""
run = get_run(db, user_id, run_id)
db.delete(run)
db.commit()
def run_ingest(
db: Session, user: User, domain: str, payload: IngestPayload
) -> IngestResponse:
handler = INGEST_REGISTRY.get(domain)
if handler is None:
raise NotFoundError("Domaine d'ingestion inconnu.", details={"domain": domain})
counters = {outcome: 0 for outcome in UpsertOutcome}
errors: list[IngestRecordError] = []
for index, item in enumerate(payload.records):
if item.type not in handler.record_types:
errors.append(
IngestRecordError(
index=index,
type=item.type,
message="Type d'enregistrement non pris en charge.",
)
)
continue
record = IngestRecord(
type=item.type,
data=item.data,
external_id=item.external_id,
source=payload.source,
)
try:
outcome = handler.apply(db, user.id, record)
except RowError as exc:
errors.append(
IngestRecordError(index=index, type=item.type, message=str(exc))
)
continue
except Exception: # noqa: BLE001 — a broken record must not fail the batch
errors.append(
IngestRecordError(
index=index,
type=item.type,
message="Erreur inattendue sur cet enregistrement.",
)
)
continue
if outcome is UpsertOutcome.ERROR:
errors.append(
IngestRecordError(
index=index, type=item.type, message="Enregistrement rejeté."
)
)
else:
counters[outcome] += 1
db.commit()
return IngestResponse(
domain=domain,
received=len(payload.records),
inserted=counters[UpsertOutcome.INSERTED],
updated=counters[UpsertOutcome.UPDATED],
duplicates=counters[UpsertOutcome.DUPLICATE],
errors=errors,
)