Files
autoayuno-api/main.py
Carlos Narro 21f5682aac telemetría: instalaciones únicas con id anónimo de instalación
Nueva tabla telemetry_active(install_id, day) (create_all la crea). add_telemetry
hace upsert (install_id, day) si viene install_id. El summary añade installsTotal
(únicas de siempre), installsActive (en el periodo) y dauToday. Tests: dos ids
únicos + uno duplicado → 2 únicas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:42:02 +02:00

429 lines
14 KiB
Python

"""API de autoayuno: catálogo compartido de alimentos (alimentado de Open Food
Facts bajo demanda) y recetas publicables. Sin cuentas: publicar es anónimo con
un deviceToken."""
import uuid
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from sqlalchemy import func, text
from sqlalchemy.orm import Session
def like_pattern(q: str) -> str:
"""Escapa comodines de LIKE (%, _, \\) para tratar la query como texto."""
q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return f"%{q.lower()}%"
def clamp_limit(limit: int) -> int:
return max(1, min(limit, 50))
import off
from db import Base, engine, get_db
from models import Food, Recipe, TelemetryDaily, TelemetryActive
# Columnas añadidas a `recipes` después de la v1 (id/name/servings/items). En
# Postgres la tabla ya existe y `create_all` NO la altera, así que las añadimos a
# mano (idempotente). En SQLite fresco no hace falta: `create_all` ya las trae.
_RECIPE_NEW_COLUMNS = {
"category": "VARCHAR DEFAULT 'otros'",
"prep_minutes": "INTEGER",
"steps": "JSON",
"notes": "VARCHAR",
"author_id": "VARCHAR",
"author_name": "VARCHAR",
"parent_id": "VARCHAR",
"parent_author_name": "VARCHAR",
"root_id": "VARCHAR",
"root_author_name": "VARCHAR",
"visibility": "VARCHAR DEFAULT 'public'",
"updated_at": "TIMESTAMP",
}
def _migrate_recipes():
"""Añade en Postgres las columnas nuevas de `recipes` si faltan (best-effort)."""
if not engine.url.get_backend_name().startswith("postgres"):
return
try:
with engine.begin() as conn:
for name, ddl in _RECIPE_NEW_COLUMNS.items():
try:
conn.execute(text(
f"ALTER TABLE recipes ADD COLUMN IF NOT EXISTS {name} {ddl}"))
except Exception:
pass
except Exception:
pass
def _migrate_telemetry():
"""Añade en Postgres la columna `seconds` de `telemetry_daily` si falta
(la tabla ya existe desde v2.27 sin esa columna; create_all no la altera)."""
if not engine.url.get_backend_name().startswith("postgres"):
return
try:
with engine.begin() as conn:
conn.execute(text(
"ALTER TABLE telemetry_daily ADD COLUMN IF NOT EXISTS "
"seconds INTEGER DEFAULT 0"))
except Exception:
pass
def init_db(retries: int = 10):
"""Crea las tablas, reintentando por si la BD tarda en levantar. No tumba
el arranque si falla: /health seguirá respondiendo."""
import time
for _ in range(retries):
try:
Base.metadata.create_all(bind=engine)
_migrate_recipes()
_migrate_telemetry()
return True
except Exception:
time.sleep(2)
return False
# En import: un intento best-effort (crea tablas ya para tests/SQLite).
init_db(retries=1)
app = FastAPI(title="autoayuno-api")
@app.on_event("startup")
def _startup():
init_db()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
def health():
return {"status": "ok"}
# ---------------------------------------------------------------------------
# Alimentos
# ---------------------------------------------------------------------------
class PortionIn(BaseModel):
name: str
grams: float
class FoodIn(BaseModel):
id: str | None = None
name: str
kcalPer100g: float
zone: str = "otros"
portions: list[PortionIn] = [] # validado: cada ración necesita name+grams
barcode: str | None = None
deviceToken: str | None = None
@app.get("/foods")
def search_foods(q: str = "", limit: int = 20, db: Session = Depends(get_db)):
q = (q or "").strip()
limit = clamp_limit(limit)
if len(q) < 2:
return []
local = (
db.query(Food)
.filter(func.lower(Food.name).like(like_pattern(q), escape="\\"))
.limit(limit)
.all()
)
results = [f.to_app() for f in local]
# Si hay pocos, enriquecemos con Open Food Facts y guardamos.
if len(local) < 5:
items = off.search(q)
# Dedupe en una sola query (evita N+1 contra Postgres).
off_ids = [i["off_id"] for i in items if i.get("off_id")]
existing = set()
if off_ids:
existing = {
r[0]
for r in db.query(Food.off_id).filter(Food.off_id.in_(off_ids)).all()
}
seen_names = {f.name.lower() for f in local}
for item in items:
if item.get("off_id") and item["off_id"] in existing:
continue
if item["name"].lower() in seen_names:
continue
food = Food(
id=str(uuid.uuid4()),
name=item["name"],
kcal_100g=item["kcal_100g"],
zone="otros",
portions=[],
barcode=item.get("barcode"),
source="off",
off_id=item.get("off_id"),
)
db.add(food)
seen_names.add(item["name"].lower())
if item.get("off_id"):
existing.add(item["off_id"])
results.append(food.to_app())
db.commit()
return results[:limit]
@app.post("/foods")
def add_food(body: FoodIn, db: Session = Depends(get_db)):
portions = [p.model_dump() for p in body.portions]
# Upsert por id del cliente: republicar actualiza en vez de duplicar.
food = db.get(Food, body.id) if body.id else None
if food is None:
food = Food(id=body.id or str(uuid.uuid4()), source="user")
db.add(food)
food.name = body.name.strip()[:120]
food.kcal_100g = body.kcalPer100g
food.zone = body.zone
food.portions = portions
food.barcode = body.barcode
food.author_token = body.deviceToken
db.commit()
return food.to_app()
# ---------------------------------------------------------------------------
# Recetas
# ---------------------------------------------------------------------------
class FoodSnapshotIn(BaseModel):
name: str
kcalPer100g: float
zone: str = "otros"
portions: list[PortionIn] = []
class RecipeItemIn(BaseModel):
food: FoodSnapshotIn
grams: float
portionLabel: str | None = None
class RecipeIn(BaseModel):
id: str | None = None
name: str
servings: int = 1
items: list[RecipeItemIn] = [] # validado: cada item necesita food+grams
category: str = "otros"
prepMinutes: int | None = None
steps: list[str] = []
notes: str | None = None
authorId: str | None = None
authorName: str | None = None
parentId: str | None = None
parentAuthorName: str | None = None
rootId: str | None = None
rootAuthorName: str | None = None
visibility: str = "public"
deviceToken: str | None = None
@app.get("/recipes")
def search_recipes(q: str = "", limit: int = 20, db: Session = Depends(get_db)):
# Buscar solo devuelve recetas PÚBLICAS (las privadas/no listadas no salen).
query = db.query(Recipe).filter(Recipe.visibility == "public")
q = (q or "").strip()
if q:
query = query.filter(func.lower(Recipe.name).like(like_pattern(q), escape="\\"))
return [r.to_app() for r in query.limit(clamp_limit(limit)).all()]
@app.get("/recipes/{recipe_id}")
def get_recipe(recipe_id: str, db: Session = Depends(get_db)):
r = db.query(Recipe).filter(Recipe.id == recipe_id).first()
if r is None:
raise HTTPException(status_code=404, detail="receta no encontrada")
return r.to_app()
@app.get("/cookbook/{author_id}")
def cookbook(author_id: str, db: Session = Depends(get_db)):
"""Todas las recetas de un autor (para respaldo/restaurar; usado en B3)."""
rows = db.query(Recipe).filter(Recipe.author_id == author_id).all()
return [r.to_app() for r in rows]
@app.delete("/cookbook/{author_id}")
def delete_cookbook(author_id: str, db: Session = Depends(get_db)):
"""Opt-out del respaldo: borra las copias PRIVADAS de un autor. Lo que
publicó a propósito (pública / no listada) se mantiene."""
n = (
db.query(Recipe)
.filter(Recipe.author_id == author_id, Recipe.visibility == "private")
.delete()
)
db.commit()
return {"deleted": n}
# ---------------------------------------------------------------------------
# Telemetría de uso ANÓNIMA (opt-in en la app; apagada por defecto)
# ---------------------------------------------------------------------------
class TelemetryIn(BaseModel):
day: str
app_version: str | None = None
install_id: str | None = None
active_modules: list[str] = []
opens_by_module: dict[str, int] = {}
seconds_by_module: dict[str, int] = {}
@app.post("/telemetry")
def add_telemetry(body: TelemetryIn, db: Session = Depends(get_db)):
"""Recibe un lote diario y agregado de uso. NO lleva identificador: solo
incrementa contadores por día y módulo/vista. No guarda IP, nombre ni
contenido."""
day = (body.day or "").strip()[:10]
if len(day) != 10:
raise HTTPException(status_code=400, detail="day inválido (YYYY-MM-DD)")
# Agregamos los incrementos de ESTE envío por clave antes de tocar la BD:
# una clave puede aparecer en varios mapas, y crear dos filas pendientes con
# la misma PK reventaría el INSERT.
agg: dict[str, dict[str, int]] = {}
def add(module: str, active: int = 0, opens: int = 0, seconds: int = 0):
module = str(module).strip()[:40]
if not module:
return
e = agg.setdefault(module, {"active": 0, "opens": 0, "seconds": 0})
e["active"] += active
e["opens"] += opens
e["seconds"] += seconds
# Un envío = una instalación activa ese día que cede estadística.
add("__submissions__", active=1)
# Versión de la app (para ver la fragmentación): clave 'ver:<versión>'.
version = (body.app_version or "unknown").strip()[:16]
add(f"ver:{version}", active=1)
for m in (body.active_modules or [])[:40]:
add(m, active=1)
for m, c in (body.opens_by_module or {}).items():
try:
c = int(c)
except (TypeError, ValueError):
continue
if c > 0:
add(m, opens=min(c, 100000))
for m, s in (body.seconds_by_module or {}).items():
try:
s = int(s)
except (TypeError, ValueError):
continue
if s > 0:
# Tope por clave y envío (~1 día): evita valores absurdos.
add(m, seconds=min(s, 86400))
for module, delta in agg.items():
row = db.get(TelemetryDaily, (day, module))
if row is None:
row = TelemetryDaily(day=day, module=module, active=0, opens=0, seconds=0)
db.add(row)
row.active = (row.active or 0) + delta["active"]
row.opens = (row.opens or 0) + delta["opens"]
row.seconds = (row.seconds or 0) + delta["seconds"]
# Presencia (instalación, día) para contar instalaciones únicas.
install_id = (body.install_id or "").strip()[:64]
if install_id and db.get(TelemetryActive, (install_id, day)) is None:
db.add(TelemetryActive(install_id=install_id, day=day))
db.commit()
return {"ok": True}
@app.get("/telemetry/summary")
def telemetry_summary(days: int = 30, db: Session = Depends(get_db)):
"""Resumen agregado de los últimos [days] días: envíos y, por módulo, en
cuántos días-instalación estuvo activo y cuántas veces se abrió."""
from datetime import date, timedelta
days = max(1, min(days, 3650))
cutoff = (date.today() - timedelta(days=days)).isoformat()
rows = (
db.query(
TelemetryDaily.module,
func.sum(TelemetryDaily.active),
func.sum(TelemetryDaily.opens),
func.sum(TelemetryDaily.seconds),
)
.filter(TelemetryDaily.day >= cutoff)
.group_by(TelemetryDaily.module)
.all()
)
submissions = 0
modules = []
for module, active, opens, seconds in rows:
if module == "__submissions__":
submissions = int(active or 0)
continue
modules.append({
"module": module,
"activeInstallDays": int(active or 0),
"opens": int(opens or 0),
"seconds": int(seconds or 0),
})
modules.sort(key=lambda x: x["opens"], reverse=True)
# Instalaciones únicas (por install_id): total, activas en el periodo y hoy.
today = date.today().isoformat()
installs_total = db.query(
func.count(func.distinct(TelemetryActive.install_id))).scalar() or 0
installs_active = db.query(
func.count(func.distinct(TelemetryActive.install_id))
).filter(TelemetryActive.day >= cutoff).scalar() or 0
dau_today = db.query(
func.count(func.distinct(TelemetryActive.install_id))
).filter(TelemetryActive.day == today).scalar() or 0
return {
"sinceDays": days,
"submissions": submissions,
"installsTotal": int(installs_total),
"installsActive": int(installs_active),
"dauToday": int(dau_today),
"modules": modules,
}
@app.post("/recipes")
def add_recipe(body: RecipeIn, db: Session = Depends(get_db)):
items = [i.model_dump() for i in body.items]
r = db.get(Recipe, body.id) if body.id else None
if r is None:
r = Recipe(id=body.id or str(uuid.uuid4()))
db.add(r)
r.name = body.name.strip()[:140]
r.servings = body.servings
r.items = items
r.category = (body.category or "otros")[:20]
r.prep_minutes = body.prepMinutes
r.steps = [str(s)[:500] for s in (body.steps or [])][:60]
r.notes = (body.notes or None)
r.author_id = body.authorId
r.author_name = (body.authorName or None)
r.parent_id = body.parentId
r.parent_author_name = body.parentAuthorName
r.root_id = body.rootId
r.root_author_name = body.rootAuthorName
r.visibility = body.visibility if body.visibility in (
"private", "unlisted", "public") else "public"
r.author_token = body.deviceToken
db.commit()
return r.to_app()