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

593 lines
21 KiB
Python

"""JSON ingestion handler for the health domain (CONVENTIONS C4).
`POST /api/ingest/health` (device key scope `ingest:health`) accepts BOTH:
1. the canonical normalized shape of architecture §5.5
`{"type": "steps", "external_id": "...", "data": {"day": "2026-08-12",
"steps": 9421, "calories_kcal": 2350, "distance_m": 6800}}`;
2. the health-connect-webhook bridge shape (snake_case Health Connect records,
docs/research/health-connect.md §4.1) where the payload of one array item is
passed as `data` — `{"start_time", "end_time", "count"/"energy"/"volume",
"metadata": {"id": ...}, "origin_app": ...}`.
The adapter below flattens both into the same field names. The full incoming
payload is always kept in the `raw` JSON column so the mapping can be replayed.
"""
import datetime as dt
import hashlib
import json
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 RowError, UpsertOutcome
from app.core.ingest.base import BaseIngestHandler, IngestRecord
from app.core.ingest.registry import register_ingest_handler
from app.core.timeutils import resolve_tz
from app.modules.health.models import (
DailyActivity,
FoodEntry,
MealType,
SportType,
WaterEntry,
WeightEntry,
Workout,
)
DEFAULT_TZ = ZoneInfo("Europe/Paris")
# Sender-declared source -> DataSource registry value (§1.5). Anything else is
# kept verbatim so an unknown bridge still ranks last in the merge priority.
SOURCE_ALIASES = {
"android_bridge": "health_connect",
"health-connect-webhook": "health_connect",
"health_connect_webhook": "health_connect",
"hc-webhook": "health_connect",
"hc_webhook": "health_connect",
"companion-app": "health_connect",
"companion_app": "health_connect",
"healthconnect": "health_connect",
"health_connect": "health_connect",
"fitshow": "fitshow",
"manual": "manual",
"ingest": "api",
"": "api",
}
ACTIVITY_TYPES: dict[str, str] = {
"steps": "steps",
"distance": "distance_m",
"active_calories": "active_kcal",
"total_calories": "total_kcal",
}
SPORT_ALIASES: dict[str, SportType] = {
"running_treadmill": SportType.TREADMILL_RUN,
"treadmill_run": SportType.TREADMILL_RUN,
"walking_treadmill": SportType.TREADMILL_WALK,
"treadmill_walk": SportType.TREADMILL_WALK,
"treadmill": SportType.TREADMILL_WALK,
"running": SportType.RUNNING,
"run": SportType.RUNNING,
"walking": SportType.WALKING,
"walk": SportType.WALKING,
"biking": SportType.CYCLING,
"biking_stationary": SportType.CYCLING,
"cycling": SportType.CYCLING,
"swimming_pool": SportType.SWIMMING,
"swimming_open_water": SportType.SWIMMING,
"swimming": SportType.SWIMMING,
"strength_training": SportType.STRENGTH,
"weightlifting": SportType.STRENGTH,
"strength": SportType.STRENGTH,
"high_intensity_interval_training": SportType.HIIT,
"hiit": SportType.HIIT,
"yoga": SportType.YOGA,
"hiking": SportType.HIKING,
}
MEAL_BY_CODE = {
1: MealType.BREAKFAST,
2: MealType.LUNCH,
3: MealType.DINNER,
4: MealType.SNACK,
}
MEAL_BY_NAME = {
"breakfast": MealType.BREAKFAST,
"petit_dejeuner": MealType.BREAKFAST,
"lunch": MealType.LUNCH,
"dejeuner": MealType.LUNCH,
"dinner": MealType.DINNER,
"diner": MealType.DINNER,
"snack": MealType.SNACK,
"collation": MealType.SNACK,
}
# --- Adapter helpers ----------------------------------------------------------
def normalize_source(source: str | None) -> str:
key = (source or "").strip().lower()
return SOURCE_ALIASES.get(key, key or "api")[:50]
def flatten(data: dict[str, Any]) -> dict[str, Any]:
"""Merge the bridge's nested `value`/`metadata` objects into a flat view."""
flat: dict[str, Any] = {}
nested = data.get("value")
if isinstance(nested, dict):
flat.update(nested)
metadata = data.get("metadata")
if isinstance(metadata, dict):
for key, value in metadata.items():
flat[f"metadata_{key}"] = value
for key, value in data.items():
if key not in {"value", "metadata"} or not isinstance(value, dict):
flat[key] = value
return flat
def first(flat: dict[str, Any], *keys: str) -> Any:
for key in keys:
value = flat.get(key)
if value is not None and value != "":
return value
return None
def as_decimal(value: Any) -> Decimal | None:
if value is None or value == "":
return None
try:
return Decimal(str(float(value)))
except (TypeError, ValueError, ArithmeticError):
return None
def as_int(value: Any) -> int | None:
number = as_decimal(value)
return None if number is None else int(number)
def as_datetime(value: Any) -> dt.datetime | None:
if value is None or value == "":
return None
if isinstance(value, dt.datetime):
parsed = value
else:
text = str(value).strip().replace("Z", "+00:00")
try:
parsed = dt.datetime.fromisoformat(text)
except ValueError:
try: # epoch millis/seconds sent by some bridges
number = float(text)
except ValueError:
return None
if number > 1e11:
number /= 1000
parsed = dt.datetime.fromtimestamp(number, tz=dt.UTC)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=DEFAULT_TZ)
return parsed.astimezone(dt.UTC)
def resolve_day(flat: dict[str, Any], tz: ZoneInfo) -> dt.date | None:
raw_day = first(flat, "day", "date", "local_date")
if raw_day is not None:
try:
return dt.date.fromisoformat(str(raw_day)[:10])
except ValueError:
return None
moment = as_datetime(
first(flat, "start_time", "started_at", "time", "measured_at", "end_time")
)
return None if moment is None else moment.astimezone(tz).date()
def external_id_of(record: IngestRecord, flat: dict[str, Any]) -> str | None:
return record.external_id or first(
flat, "metadata_id", "external_id", "id", "uuid", "client_record_id"
)
def content_digest(record_type: str, payload: dict[str, Any]) -> str:
"""Fallback dedupe key when the source carries no id (architecture §5.1)."""
body = json.dumps(payload, sort_keys=True, default=str, ensure_ascii=False)
return hashlib.sha256(f"{record_type}|{body}".encode()).hexdigest()
def meal_of(flat: dict[str, Any], moment: dt.datetime, tz: ZoneInfo) -> MealType:
raw = first(flat, "meal_type", "meal", "meal_name")
if isinstance(raw, int) or (isinstance(raw, str) and raw.isdigit()):
mapped = MEAL_BY_CODE.get(int(raw))
if mapped is not None:
return mapped
if isinstance(raw, str):
mapped = MEAL_BY_NAME.get(raw.strip().lower())
if mapped is not None:
return mapped
hour = moment.astimezone(tz).hour
if hour < 11:
return MealType.BREAKFAST
if hour < 15:
return MealType.LUNCH
if hour < 18:
return MealType.SNACK
return MealType.DINNER
def sport_of(flat: dict[str, Any]) -> tuple[SportType, str | None]:
raw = first(
flat, "exercise_type", "sport_type", "activity_type", "exercise", "type"
)
key = str(raw or "").strip().lower().replace("-", "_").replace(" ", "_")
mapped = SPORT_ALIASES.get(key)
if mapped is not None:
return mapped, None
return SportType.OTHER, (str(raw)[:100] if raw else None)
# --- Handler ------------------------------------------------------------------
@register_ingest_handler
class HealthIngestHandler(BaseIngestHandler):
domain: ClassVar[str] = "health"
record_types: ClassVar[tuple[str, ...]] = (
"weight",
"steps",
"distance",
"active_calories",
"total_calories",
"exercise_session",
"nutrition",
"hydration",
)
def apply(self, db: Session, user_id: int, record: IngestRecord) -> UpsertOutcome:
tz = self._user_tz(db, user_id)
source = normalize_source(record.source)
flat = flatten(record.data or {})
if record.type in ACTIVITY_TYPES:
return self._apply_activity(db, user_id, source, record, flat, tz)
if record.type == "weight":
return self._apply_weight(db, user_id, source, record, flat)
if record.type == "exercise_session":
return self._apply_workout(db, user_id, source, record, flat)
if record.type == "nutrition":
return self._apply_nutrition(db, user_id, source, record, flat, tz)
if record.type == "hydration":
return self._apply_hydration(db, user_id, source, record, flat)
raise RowError("Type d'enregistrement non pris en charge.")
# -- shared plumbing --
@staticmethod
def _user_tz(db: Session, user_id: int) -> ZoneInfo:
from app.modules.health.models import HealthProfile
profile = db.scalar(
select(HealthProfile).where(HealthProfile.user_id == user_id)
)
try:
return resolve_tz(profile.timezone if profile else None)
except Exception: # noqa: BLE001 — a broken profile tz must not fail ingest
return DEFAULT_TZ
@staticmethod
def _dedupe_keys(
record: IngestRecord, flat: dict[str, Any], kind: str, payload: dict[str, Any]
) -> tuple[str | None, str | None]:
"""(external_id, content_hash) per architecture §5.1: the content hash
is only the FALLBACK when the source carries no id — never both, so two
sources describing the same event stay distinct rows (the cross-source
overlap rule handles workouts)."""
external_id = external_id_of(record, flat)
if external_id is not None:
return str(external_id)[:255], None
return None, content_digest(kind, payload)
@staticmethod
def _existing(
db: Session,
model: type,
user_id: int,
source: str,
external_id: str | None,
digest: str | None,
):
if external_id is not None:
return db.scalar(
select(model).where(
model.user_id == user_id,
model.source == source,
model.external_id == external_id,
)
)
return db.scalar(
select(model).where(model.user_id == user_id, model.content_hash == digest)
)
# -- record types --
def _apply_activity(
self,
db: Session,
user_id: int,
source: str,
record: IngestRecord,
flat: dict[str, Any],
tz: ZoneInfo,
) -> UpsertOutcome:
day = resolve_day(flat, tz)
if day is None:
raise RowError("Jour introuvable dans l'enregistrement d'activité.")
distance = as_decimal(first(flat, "distance_m", "distance", "meters"))
if distance is None:
km = as_decimal(first(flat, "distance_km", "kilometers"))
distance = km * 1000 if km is not None else None
values: dict[str, Any] = {
"steps": as_int(first(flat, "steps", "count", "step_count")),
"distance_m": int(distance) if distance is not None else None,
"active_kcal": as_decimal(
first(flat, "active_kcal", "active_calories", "calories_kcal")
),
"total_kcal": as_decimal(
first(flat, "total_kcal", "total_calories", "total_energy_kcal")
),
"active_minutes": as_int(first(flat, "active_minutes", "move_minutes")),
"floors": as_int(first(flat, "floors", "floors_climbed")),
}
primary = ACTIVITY_TYPES[record.type]
if values[primary] is None:
fallback = first(flat, "value", "amount", "energy_kcal", "energy", "kcal")
if primary == "steps":
values[primary] = as_int(fallback)
elif primary == "distance_m":
values[primary] = (
int(as_decimal(fallback))
if as_decimal(fallback) is not None
else None
)
else:
values[primary] = as_decimal(fallback)
if values[primary] is None:
raise RowError("Valeur manquante pour cet enregistrement d'activité.")
row = db.scalar(
select(DailyActivity).where(
DailyActivity.user_id == user_id,
DailyActivity.date == day,
DailyActivity.source == source,
)
)
raw = dict(record.data or {})
if row is None:
db.add(
DailyActivity(
user_id=user_id,
date=day,
source=source,
external_id=external_id_of(record, flat),
raw=raw,
**values,
)
)
db.flush()
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
db.flush()
return UpsertOutcome.UPDATED
def _apply_weight(
self,
db: Session,
user_id: int,
source: str,
record: IngestRecord,
flat: dict[str, Any],
) -> UpsertOutcome:
moment = as_datetime(
first(flat, "measured_at", "time", "start_time", "end_time", "date")
)
weight = as_decimal(first(flat, "weight_kg", "weight", "kilograms", "value"))
if moment is None or weight is None:
raise RowError("Pesée incomplète : horodatage et poids sont requis.")
if not 20 < float(weight) < 400:
raise RowError("Poids hors bornes (20-400 kg).")
external_id, digest = self._dedupe_keys(
record, flat, "weight", {"t": moment.isoformat(), "w": str(weight)}
)
if self._existing(db, WeightEntry, user_id, source, external_id, digest):
return UpsertOutcome.DUPLICATE
slot_taken = db.scalar(
select(WeightEntry).where(
WeightEntry.user_id == user_id,
WeightEntry.source == source,
WeightEntry.measured_at == moment,
)
)
if slot_taken is not None:
return UpsertOutcome.DUPLICATE
db.add(
WeightEntry(
user_id=user_id,
source=source,
external_id=external_id,
content_hash=digest,
measured_at=moment,
weight_kg=weight,
body_fat_pct=as_decimal(first(flat, "body_fat_pct", "body_fat")),
raw=dict(record.data or {}),
)
)
db.flush()
return UpsertOutcome.INSERTED
def _apply_workout(
self,
db: Session,
user_id: int,
source: str,
record: IngestRecord,
flat: dict[str, Any],
) -> UpsertOutcome:
started = as_datetime(first(flat, "start_time", "started_at", "begin"))
ended = as_datetime(first(flat, "end_time", "ended_at", "finish"))
if started is None:
raise RowError("Séance sans horodatage de début.")
if ended is None:
duration = as_int(first(flat, "duration_s", "duration_seconds"))
ended = started + dt.timedelta(seconds=duration) if duration else None
if ended is None or ended <= started:
raise RowError("Séance sans durée exploitable.")
external_id, digest = self._dedupe_keys(
record,
flat,
"exercise_session",
{"s": started.isoformat(), "e": ended.isoformat()},
)
if self._existing(db, Workout, user_id, source, external_id, digest):
return UpsertOutcome.DUPLICATE
sport, label = sport_of(flat)
distance = as_decimal(first(flat, "distance_m", "distance", "meters"))
workout = Workout(
user_id=user_id,
source=source,
external_id=external_id,
content_hash=digest,
started_at=started,
ended_at=ended,
sport_type=sport,
sport_label=label,
kcal=as_decimal(
first(flat, "energy_kcal", "calories_kcal", "kcal", "calories")
),
distance_m=int(distance) if distance is not None else None,
steps=as_int(first(flat, "steps", "count")),
avg_hr=as_int(
first(flat, "avg_hr", "average_heart_rate", "heart_rate_avg")
),
max_hr=as_int(first(flat, "max_hr", "max_heart_rate")),
raw=dict(record.data or {}),
)
db.add(workout)
db.flush()
self._flag_overlaps(db, workout)
return UpsertOutcome.INSERTED
@staticmethod
def _flag_overlaps(db: Session, workout: Workout) -> None:
from app.modules.health.service import flag_overlapping_duplicates
flag_overlapping_duplicates(db, workout)
def _apply_nutrition(
self,
db: Session,
user_id: int,
source: str,
record: IngestRecord,
flat: dict[str, Any],
tz: ZoneInfo,
) -> UpsertOutcome:
moment = as_datetime(first(flat, "eaten_at", "start_time", "time", "date"))
kcal = as_decimal(first(flat, "energy_kcal", "energy", "calories", "kcal"))
if moment is None or kcal is None:
raise RowError("Repas incomplet : horodatage et calories sont requis.")
name = str(first(flat, "name", "food_name", "label") or "Repas")[:200]
sodium_mg = as_decimal(first(flat, "sodium_mg"))
if sodium_mg is None:
sodium_g = as_decimal(first(flat, "sodium_g", "sodium"))
sodium_mg = sodium_g * 1000 if sodium_g is not None else None
external_id, digest = self._dedupe_keys(
record,
flat,
"nutrition",
{"t": moment.isoformat(), "n": name, "k": str(kcal)},
)
if self._existing(db, FoodEntry, user_id, source, external_id, digest):
return UpsertOutcome.DUPLICATE
db.add(
FoodEntry(
user_id=user_id,
source=source,
external_id=external_id,
content_hash=digest,
eaten_at=moment,
meal=meal_of(flat, moment, tz),
name=name,
brand=(str(first(flat, "brand") or "")[:100] or None),
quantity=as_decimal(first(flat, "quantity", "serving_quantity"))
or Decimal(1),
unit=str(first(flat, "unit") or "portion")[:20],
kcal=kcal,
protein_g=as_decimal(first(flat, "protein_g", "protein")),
carbs_g=as_decimal(
first(flat, "carbs_g", "total_carbohydrate_g", "total_carbohydrate")
),
fat_g=as_decimal(first(flat, "fat_g", "total_fat_g", "total_fat")),
fiber_g=as_decimal(
first(flat, "fiber_g", "dietary_fiber_g", "dietary_fiber")
),
sugar_g=as_decimal(first(flat, "sugar_g", "sugar")),
sat_fat_g=as_decimal(
first(flat, "sat_fat_g", "saturated_fat_g", "saturated_fat")
),
sodium_mg=sodium_mg,
raw=dict(record.data or {}),
)
)
db.flush()
return UpsertOutcome.INSERTED
def _apply_hydration(
self,
db: Session,
user_id: int,
source: str,
record: IngestRecord,
flat: dict[str, Any],
) -> UpsertOutcome:
moment = as_datetime(first(flat, "drunk_at", "start_time", "time", "date"))
volume = as_decimal(first(flat, "volume_ml", "volume", "milliliters"))
if volume is None:
liters = as_decimal(first(flat, "volume_liters", "liters", "value"))
volume = liters * 1000 if liters is not None else None
if moment is None or volume is None:
raise RowError("Hydratation incomplète : horodatage et volume requis.")
volume_ml = int(volume)
if not 0 < volume_ml <= 5000:
raise RowError("Volume hors bornes (1-5000 ml).")
external_id, digest = self._dedupe_keys(
record, flat, "hydration", {"t": moment.isoformat(), "v": volume_ml}
)
if self._existing(db, WaterEntry, user_id, source, external_id, digest):
return UpsertOutcome.DUPLICATE
db.add(
WaterEntry(
user_id=user_id,
source=source,
external_id=external_id,
content_hash=digest,
drunk_at=moment,
volume_ml=volume_ml,
)
)
db.flush()
return UpsertOutcome.INSERTED