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>
674 lines
23 KiB
Python
674 lines
23 KiB
Python
"""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
|