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>
429 lines
14 KiB
Python
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()
|