Files
lifetrack/apps/api/app/modules/health/importers.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

674 lines
23 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""File importers for the health module (CONVENTIONS C3).
Three profiles:
- `foodvisor_csv` : Foodvisor RGPD/in-app export -> FoodEntry;
- `health_sync_csv` : Health Sync CSV export -> WeightEntry / DailyActivity;
- `weight_generic_csv` : plain `date;poids` history -> WeightEntry.
All of them are tolerant by design (docs/research/nutrition-sources.md §2.2 and
§7.3): utf-8-sig then cp1252, `,`/`;`/tab delimiters, comma decimals, FR/EN
header aliases, timestamps without offset interpreted in Europe/Paris.
When the source has no native id, `external_id = sha256(normalized row)[:32]`.
"""
import csv
import datetime as dt
import hashlib
import io
import json
import re
import unicodedata
from collections.abc import Iterator
from decimal import Decimal
from typing import Any, ClassVar
from zoneinfo import ZoneInfo
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.importing.base import (
BaseImporter,
ImporterParseError,
NormalizedRecord,
RowError,
UpsertOutcome,
)
from app.core.importing.hashing import content_hash
from app.core.importing.registry import register_importer
from app.modules.health.models import (
DailyActivity,
FoodEntry,
MealType,
WeightEntry,
)
DEFAULT_TZ = ZoneInfo("Europe/Paris")
EXTERNAL_ID_LEN = 32
# --- Decoding / parsing helpers -----------------------------------------------
def decode_bytes(data: bytes) -> str:
"""utf-8-sig first, cp1252 as documented fallback (C3.3)."""
for encoding in ("utf-8-sig", "cp1252"):
try:
return data.decode(encoding)
except UnicodeDecodeError:
continue
return data.decode("utf-8", errors="replace")
def sniff_delimiter(sample: str) -> str:
header = sample.splitlines()[0] if sample.splitlines() else ""
counts = {sep: header.count(sep) for sep in (";", ",", "\t", "|")}
best = max(counts, key=lambda sep: counts[sep])
return best if counts[best] > 0 else ","
def norm_key(value: str) -> str:
"""Header normalisation: lowercase, accent-free, snake_case."""
text = unicodedata.normalize("NFKD", value or "")
text = "".join(ch for ch in text if not unicodedata.combining(ch))
text = re.sub(r"[^0-9a-zA-Z]+", "_", text.lower())
return text.strip("_")
def pick(row: dict[str, Any], aliases: tuple[str, ...]) -> Any:
"""First non-empty value among the normalized aliases (exact then prefix)."""
for alias in aliases:
if alias in row and str(row[alias]).strip() != "":
return row[alias]
for alias in aliases:
for key, value in row.items():
if key.startswith(alias) and str(value).strip() != "":
return value
return None
def to_decimal(value: Any) -> Decimal | None:
"""Tolerant number parsing: comma decimals, spaces, units, `traces`, `< x`."""
if value is None:
return None
text = str(value).strip()
if text == "" or text in {"-", "--", "NA", "N/A", "null"}:
return None
if "trace" in text.lower():
return Decimal(0)
text = text.replace("<", "").replace(" ", "").replace(" ", "")
text = text.replace(",", ".")
match = re.search(r"-?\d+(?:\.\d+)?", text)
if match is None:
return None
try:
return Decimal(match.group(0))
except ArithmeticError:
return None
def to_float(value: Any) -> float | None:
decimal = to_decimal(value)
return None if decimal is None else float(decimal)
_DATE_FORMATS = (
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M",
"%Y-%m-%d",
"%d/%m/%Y %H:%M:%S",
"%d/%m/%Y %H:%M",
"%d/%m/%Y",
"%d-%m-%Y %H:%M",
"%d-%m-%Y",
"%d.%m.%Y",
"%Y/%m/%d",
)
def parse_datetime(value: Any, tz: ZoneInfo = DEFAULT_TZ) -> dt.datetime | None:
"""Parse a timestamp to aware UTC; naive input is read as local time."""
if value is None:
return None
text = str(value).strip()
if not text:
return None
iso = text.replace("Z", "+00:00")
parsed: dt.datetime | None = None
try:
parsed = dt.datetime.fromisoformat(iso)
except ValueError:
for fmt in _DATE_FORMATS:
try:
# These legacy formats carry no offset on purpose; the tz is
# applied a few lines below (Europe/Paris by default).
parsed = dt.datetime.strptime(text, fmt) # noqa: DTZ007
break
except ValueError:
continue
if parsed is None:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=tz)
return parsed.astimezone(dt.UTC)
def parse_date(value: Any, tz: ZoneInfo = DEFAULT_TZ) -> dt.date | None:
parsed = parse_datetime(value, tz)
return None if parsed is None else parsed.astimezone(tz).date()
def row_external_id(prefix: str, row: dict[str, Any]) -> str:
"""sha256 of the normalized row (§1.5), namespaced by importer."""
payload = json.dumps(
{key: str(value) for key, value in sorted(row.items())},
sort_keys=True,
ensure_ascii=False,
)
digest = hashlib.sha256(f"{prefix}|{payload}".encode()).hexdigest()
return digest[:EXTERNAL_ID_LEN]
def read_rows(data: bytes) -> tuple[list[str], list[dict[str, Any]]]:
"""Decode + parse a CSV into normalized-header dicts."""
text = decode_bytes(data)
if not text.strip():
raise ImporterParseError("Fichier vide.")
delimiter = sniff_delimiter(text)
reader = csv.DictReader(io.StringIO(text), delimiter=delimiter)
if not reader.fieldnames:
raise ImporterParseError("En-têtes de colonnes introuvables.")
headers = [norm_key(name) for name in reader.fieldnames]
rows: list[dict[str, Any]] = []
for raw in reader:
row = {
norm_key(key): ("" if value is None else value)
for key, value in raw.items()
if key is not None
}
if any(str(value).strip() for value in row.values()):
rows.append(row)
return headers, rows
def head_headers(head: bytes) -> set[str]:
"""Normalized headers of the first line — used by `sniff` (never raises)."""
try:
text = decode_bytes(head)
first = text.splitlines()[0] if text.splitlines() else ""
delimiter = sniff_delimiter(text)
return {norm_key(part) for part in first.split(delimiter)}
except Exception: # noqa: BLE001 — sniff must never raise (C3.3)
return set()
def has_any(headers: set[str], aliases: tuple[str, ...]) -> bool:
return any(
header == alias or header.startswith(alias)
for header in headers
for alias in aliases
)
# --- Column alias tables -------------------------------------------------------
DATE_ALIASES = (
"date",
"jour",
"day",
"datetime",
"date_heure",
"horodatage",
"timestamp",
)
TIME_ALIASES = ("heure", "time", "hour")
MEAL_ALIASES = ("repas", "meal", "type_de_repas", "meal_type", "moment", "categorie")
NAME_ALIASES = (
"aliment",
"nom",
"name",
"food",
"food_name",
"libelle",
"produit",
"product",
)
BRAND_ALIASES = ("marque", "brand")
QUANTITY_ALIASES = ("quantite", "quantity", "portion", "amount", "serving", "poids_g")
UNIT_ALIASES = ("unite", "unit")
KCAL_ALIASES = ("calories", "kcal", "energie", "energy", "energie_kcal", "energy_kcal")
PROTEIN_ALIASES = ("proteines", "protein", "proteins", "prot")
CARBS_ALIASES = (
"glucides",
"carbs",
"carbohydrates",
"carbohydrate",
"total_carbohydrate",
)
FAT_ALIASES = ("lipides", "fat", "fats", "total_fat", "matieres_grasses")
FIBER_ALIASES = ("fibres", "fiber", "dietary_fiber", "fibre")
SUGAR_ALIASES = ("sucres", "sugar", "sugars", "sucre")
SATFAT_ALIASES = ("acides_gras_satures", "saturated_fat", "satures", "ags", "sat_fat")
SODIUM_ALIASES = ("sodium", "sel", "salt")
WEIGHT_ALIASES = ("poids", "weight", "poids_kg", "weight_kg", "masse", "body_weight")
STEPS_ALIASES = ("pas", "steps", "step_count", "nombre_de_pas")
DISTANCE_ALIASES = ("distance", "distance_m", "distance_km", "distance_metres")
ACTIVE_KCAL_ALIASES = (
"calories_actives",
"active_calories",
"active_energy",
"active_kcal",
"calories_brulees",
)
TOTAL_KCAL_ALIASES = (
"calories_totales",
"total_calories",
"total_energy",
"total_kcal",
)
ACTIVE_MIN_ALIASES = (
"minutes_actives",
"active_minutes",
"move_minutes",
"duree_activite",
)
BODYFAT_ALIASES = ("masse_grasse", "body_fat", "fat_percentage", "graisse")
MEAL_MAP: tuple[tuple[tuple[str, ...], MealType], ...] = (
(("petit_dejeuner", "petit_dej", "breakfast", "matin"), MealType.BREAKFAST),
(("collation", "snack", "gouter", "encas", "en_cas"), MealType.SNACK),
(("dejeuner", "lunch", "midi", "déjeuner"), MealType.LUNCH),
(("diner", "dinner", "souper", "soir"), MealType.DINNER),
)
def map_meal(value: Any) -> MealType:
key = norm_key(str(value or ""))
for aliases, meal in MEAL_MAP:
if any(alias in key for alias in aliases):
return meal
return MealType.SNACK
# --- Shared upsert helpers ----------------------------------------------------
def _find_event(
db: Session,
model: type,
user_id: int,
source: str,
external_id: str | None,
digest: str | None,
):
if external_id is not None:
row = db.scalar(
select(model).where(
model.user_id == user_id,
model.source == source,
model.external_id == external_id,
)
)
if row is not None:
return row
if digest is not None:
row = db.scalar(
select(model).where(model.user_id == user_id, model.content_hash == digest)
)
if row is not None:
return row
return None
def _dedupe_keys(record: NormalizedRecord) -> tuple[str | None, str | None]:
"""(external_id, content_hash) per architecture §5.1.
The content hash is only the FALLBACK used when the source provides no id
— never both, otherwise the `(user_id, content_hash)` constraint would also
dedupe across importers/sources, which the spec keeps distinct.
"""
if record.external_id:
return record.external_id[:255], None
return None, content_hash(record)
def _weight_slot_taken(
db: Session, user_id: int, source: str, measured_at: dt.datetime
) -> bool:
"""`uq_weight_entries_user_ts_source` guard — never let an import crash on it."""
return (
db.scalar(
select(WeightEntry).where(
WeightEntry.user_id == user_id,
WeightEntry.source == source,
WeightEntry.measured_at == measured_at,
)
)
is not None
)
def _upsert_daily_activity(
db: Session,
user_id: int,
source: str,
day: dt.date,
values: dict[str, Any],
raw: dict[str, Any],
import_run_id: int | None,
) -> UpsertOutcome:
"""Natural key (user, day, source) upsert-replacement (architecture §5.1).
Returns DUPLICATE when every value is already identical (re-importing the
same file is a no-op), UPDATED when the source refreshed its numbers.
"""
row = db.scalar(
select(DailyActivity).where(
DailyActivity.user_id == user_id,
DailyActivity.date == day,
DailyActivity.source == source,
)
)
if row is None:
db.add(
DailyActivity(
user_id=user_id,
date=day,
source=source,
import_run_id=import_run_id,
raw=raw,
**values,
)
)
db.flush() # make the row visible to the next lookups of the same run
return UpsertOutcome.INSERTED
changed = False
for field, value in values.items():
if value is None:
continue
if getattr(row, field) != value:
setattr(row, field, value)
changed = True
if not changed:
return UpsertOutcome.DUPLICATE
row.raw = raw
row.import_run_id = import_run_id
return UpsertOutcome.UPDATED
# --- foodvisor_csv -------------------------------------------------------------
@register_importer
class FoodvisorCsvImporter(BaseImporter):
id: ClassVar[str] = "foodvisor_csv"
label: ClassVar[str] = "Foodvisor (export CSV)"
domain: ClassVar[str] = "health"
accepted_extensions: ClassVar[tuple[str, ...]] = (".csv",)
source: ClassVar[str] = "foodvisor"
@classmethod
def sniff(cls, filename: str, head: bytes) -> bool:
if not filename.lower().endswith(".csv"):
return False
headers = head_headers(head)
return (
has_any(headers, MEAL_ALIASES)
and has_any(headers, KCAL_ALIASES)
and has_any(headers, NAME_ALIASES)
)
def parse(self, data: bytes, filename: str) -> Iterator[NormalizedRecord]:
_, rows = read_rows(data)
if not rows:
raise ImporterParseError("Aucune ligne exploitable dans le fichier.")
for row in rows:
name = pick(row, NAME_ALIASES)
kcal = to_decimal(pick(row, KCAL_ALIASES))
when = parse_datetime(pick(row, DATE_ALIASES))
if when is None:
time_part = pick(row, TIME_ALIASES)
date_part = pick(row, DATE_ALIASES)
if date_part and time_part:
when = parse_datetime(f"{date_part} {time_part}")
if not name or kcal is None or when is None:
raise RowError(
"Ligne incomplète : date, aliment et calories sont requis."
)
quantity = to_decimal(pick(row, QUANTITY_ALIASES)) or Decimal(100)
yield NormalizedRecord(
kind="food_entry",
external_id=str(pick(row, ("id", "entry_id", "uuid")) or "")
or row_external_id(self.id, row),
dedupe_fields=("eaten_at", "name", "kcal", "meal"),
data={
"eaten_at": when,
"meal": map_meal(pick(row, MEAL_ALIASES)),
"name": str(name)[:200],
"brand": (str(pick(row, BRAND_ALIASES) or "") or None),
"quantity": quantity,
"unit": str(pick(row, UNIT_ALIASES) or "g")[:20],
"kcal": kcal,
"protein_g": to_decimal(pick(row, PROTEIN_ALIASES)),
"carbs_g": to_decimal(pick(row, CARBS_ALIASES)),
"fat_g": to_decimal(pick(row, FAT_ALIASES)),
"fiber_g": to_decimal(pick(row, FIBER_ALIASES)),
"sugar_g": to_decimal(pick(row, SUGAR_ALIASES)),
"sat_fat_g": to_decimal(pick(row, SATFAT_ALIASES)),
"sodium_mg": to_decimal(pick(row, SODIUM_ALIASES)),
"raw": {key: str(value) for key, value in row.items()},
},
)
def upsert(
self, db: Session, user_id: int, record: NormalizedRecord
) -> UpsertOutcome:
external_id, digest = _dedupe_keys(record)
if _find_event(db, FoodEntry, user_id, self.source, external_id, digest):
return UpsertOutcome.DUPLICATE
data = dict(record.data)
raw = data.pop("raw", None)
db.add(
FoodEntry(
user_id=user_id,
source=self.source,
external_id=external_id,
content_hash=digest,
import_run_id=self.import_run_id,
raw=raw,
**data,
)
)
db.flush()
return UpsertOutcome.INSERTED
# --- health_sync_csv -----------------------------------------------------------
@register_importer
class HealthSyncCsvImporter(BaseImporter):
"""Health Sync exports: one row per day (or per measure) with a flexible
set of metric columns (poids, pas, distance, calories…)."""
id: ClassVar[str] = "health_sync_csv"
label: ClassVar[str] = "Health Sync / Health Connect (export CSV)"
domain: ClassVar[str] = "health"
accepted_extensions: ClassVar[tuple[str, ...]] = (".csv",)
source: ClassVar[str] = "csv_import"
_METRIC_GROUPS = (
STEPS_ALIASES,
DISTANCE_ALIASES,
ACTIVE_KCAL_ALIASES,
TOTAL_KCAL_ALIASES,
ACTIVE_MIN_ALIASES,
WEIGHT_ALIASES,
)
@classmethod
def sniff(cls, filename: str, head: bytes) -> bool:
if not filename.lower().endswith(".csv"):
return False
headers = head_headers(head)
if not has_any(headers, DATE_ALIASES):
return False
if has_any(headers, MEAL_ALIASES):
return False # nutrition export -> foodvisor_csv
matched = sum(1 for group in cls._METRIC_GROUPS if has_any(headers, group))
return matched >= 2
def parse(self, data: bytes, filename: str) -> Iterator[NormalizedRecord]:
_, rows = read_rows(data)
if not rows:
raise ImporterParseError("Aucune ligne exploitable dans le fichier.")
for row in rows:
when = parse_datetime(pick(row, DATE_ALIASES))
if when is None:
raise RowError("Date illisible sur cette ligne.")
time_part = pick(row, TIME_ALIASES)
if time_part:
combined = parse_datetime(
f"{parse_date(pick(row, DATE_ALIASES))} {time_part}"
)
if combined is not None:
when = combined
day = when.astimezone(DEFAULT_TZ).date()
raw = {key: str(value) for key, value in row.items()}
weight = to_decimal(pick(row, WEIGHT_ALIASES))
if weight is not None and 20 < float(weight) < 400:
yield NormalizedRecord(
kind="weight",
external_id=row_external_id(f"{self.id}:weight", row),
dedupe_fields=("measured_at", "weight_kg"),
data={
"measured_at": when,
"weight_kg": weight,
"body_fat_pct": to_decimal(pick(row, BODYFAT_ALIASES)),
"raw": raw,
},
)
distance_key = next(
(key for key in row if has_any({key}, DISTANCE_ALIASES)), None
)
distance = to_decimal(row.get(distance_key)) if distance_key else None
if distance is not None and distance_key.endswith("km"):
distance = distance * 1000 # Health Sync exports km on some locales
values = {
"steps": int(to_float(pick(row, STEPS_ALIASES)) or 0)
if pick(row, STEPS_ALIASES) is not None
else None,
"active_kcal": to_decimal(pick(row, ACTIVE_KCAL_ALIASES)),
"total_kcal": to_decimal(pick(row, TOTAL_KCAL_ALIASES)),
"distance_m": int(distance) if distance is not None else None,
"active_minutes": int(to_float(pick(row, ACTIVE_MIN_ALIASES)) or 0)
if pick(row, ACTIVE_MIN_ALIASES) is not None
else None,
}
if any(value is not None for value in values.values()):
yield NormalizedRecord(
kind="daily_activity",
external_id=row_external_id(f"{self.id}:activity", row),
dedupe_fields=("day", "steps", "active_kcal", "distance_m"),
data={"day": day, **values, "raw": raw},
)
def upsert(
self, db: Session, user_id: int, record: NormalizedRecord
) -> UpsertOutcome:
external_id, digest = _dedupe_keys(record)
data = dict(record.data)
raw = data.pop("raw", None)
if record.kind == "weight":
if _find_event(
db, WeightEntry, user_id, self.source, external_id, digest
) or _weight_slot_taken(db, user_id, self.source, data["measured_at"]):
return UpsertOutcome.DUPLICATE
db.add(
WeightEntry(
user_id=user_id,
source=self.source,
external_id=external_id,
content_hash=digest,
import_run_id=self.import_run_id,
raw=raw,
**data,
)
)
db.flush()
return UpsertOutcome.INSERTED
day = data.pop("day")
return _upsert_daily_activity(
db, user_id, self.source, day, data, raw, self.import_run_id
)
# --- weight_generic_csv --------------------------------------------------------
@register_importer
class WeightGenericCsvImporter(BaseImporter):
"""Minimal `date;poids` history typed by the user."""
id: ClassVar[str] = "weight_generic_csv"
label: ClassVar[str] = "Historique de poids (CSV date/poids)"
domain: ClassVar[str] = "health"
accepted_extensions: ClassVar[tuple[str, ...]] = (".csv",)
source: ClassVar[str] = "csv_import"
@classmethod
def sniff(cls, filename: str, head: bytes) -> bool:
if not filename.lower().endswith(".csv"):
return False
headers = {header for header in head_headers(head) if header}
if len(headers) > 3:
return False
return has_any(headers, DATE_ALIASES) and has_any(headers, WEIGHT_ALIASES)
def parse(self, data: bytes, filename: str) -> Iterator[NormalizedRecord]:
_, rows = read_rows(data)
if not rows:
raise ImporterParseError("Aucune ligne exploitable dans le fichier.")
for row in rows:
when = parse_datetime(pick(row, DATE_ALIASES))
weight = to_decimal(pick(row, WEIGHT_ALIASES))
if when is None or weight is None:
raise RowError("Ligne incomplète : date et poids sont requis.")
if not 20 < float(weight) < 400:
raise RowError("Poids hors bornes (20-400 kg).")
yield NormalizedRecord(
kind="weight",
external_id=row_external_id(self.id, row),
dedupe_fields=("measured_at", "weight_kg"),
data={
"measured_at": when,
"weight_kg": weight,
"raw": {key: str(value) for key, value in row.items()},
},
)
def upsert(
self, db: Session, user_id: int, record: NormalizedRecord
) -> UpsertOutcome:
external_id, digest = _dedupe_keys(record)
data = dict(record.data)
raw = data.pop("raw", None)
if _find_event(
db, WeightEntry, user_id, self.source, external_id, digest
) or _weight_slot_taken(db, user_id, self.source, data["measured_at"]):
return UpsertOutcome.DUPLICATE
db.add(
WeightEntry(
user_id=user_id,
source=self.source,
external_id=external_id,
content_hash=digest,
import_run_id=self.import_run_id,
raw=raw,
**data,
)
)
db.flush()
return UpsertOutcome.INSERTED