Files
autoayuno-api/main.py
Carlos Narro 3092e19b73 telemetría anónima: POST /telemetry (contadores agregados) + GET /telemetry/summary
Tabla telemetry_daily (day, module, active, opens) sin identificador de usuario
ni contenido: solo cuenta cuántas instalaciones tienen cada módulo activo y
cuántas veces lo abren (fila __submissions__ = envíos del día). El POST agrega
por módulo antes de tocar la BD (evita PK duplicada intra-request). create_all
crea la tabla nueva; no necesita migración ALTER. Tests en SQLite en memoria.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:27:39 +02:00

371 lines
12 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
# 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 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()
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
active_modules: list[str] = []
opens_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. 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 módulo antes de tocar la BD:
# un módulo puede aparecer en active_modules y en opens_by_module, 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):
module = str(module).strip()[:40]
if not module:
return
e = agg.setdefault(module, {"active": 0, "opens": 0})
e["active"] += active
e["opens"] += opens
# Un envío = una instalación activa ese día que cede estadística.
add("__submissions__", 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 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)
db.add(row)
row.active = (row.active or 0) + delta["active"]
row.opens = (row.opens or 0) + delta["opens"]
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),
)
.filter(TelemetryDaily.day >= cutoff)
.group_by(TelemetryDaily.module)
.all()
)
submissions = 0
modules = []
for module, active, opens 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),
})
modules.sort(key=lambda x: x["opens"], reverse=True)
return {"sinceDays": days, "submissions": submissions, "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()