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>
593 lines
21 KiB
Python
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
|