"""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