diff --git a/main.py b/main.py index cc77dfe..d31c7ea 100644 --- a/main.py +++ b/main.py @@ -21,7 +21,7 @@ def clamp_limit(limit: int) -> int: import off from db import Base, engine, get_db -from models import Food, Recipe +from models import Food, Recipe, TelemetryDaily # Columnas añadidas a `recipes` después de la v1 (id/name/servings/items). En @@ -256,6 +256,93 @@ def delete_cookbook(author_id: str, db: Session = Depends(get_db)): 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] diff --git a/models.py b/models.py index b8bc221..c47a4c9 100644 --- a/models.py +++ b/models.py @@ -70,3 +70,17 @@ class Recipe(Base): "rootAuthorName": self.root_author_name, "visibility": self.visibility or "public", } + + +class TelemetryDaily(Base): + """Contadores de uso ANÓNIMOS y agregados, por día y módulo. NO hay + identificador de usuario ni contenido de salud: solo cuántas instalaciones + tienen cada módulo activo (`active`) y cuántas veces lo abren (`opens`). La + fila especial module='__submissions__' cuenta los envíos del día (≈ nº de + instalaciones activas que ceden estadística).""" + __tablename__ = "telemetry_daily" + + day = Column(String, primary_key=True) # 'YYYY-MM-DD' + module = Column(String, primary_key=True) # nombre del módulo o '__submissions__' + active = Column(Integer, default=0) + opens = Column(Integer, default=0) diff --git a/test_telemetry.py b/test_telemetry.py new file mode 100644 index 0000000..5673cd3 --- /dev/null +++ b/test_telemetry.py @@ -0,0 +1,52 @@ +"""Tests de telemetría anónima: el POST agrega contadores por día/módulo y el +summary los devuelve. SQLite en memoria. Sin identificador de usuario.""" +import os + +os.environ["DATABASE_URL"] = "sqlite://" + +from datetime import date # noqa: E402 + +from fastapi.testclient import TestClient # noqa: E402 + +from main import app # noqa: E402 + +client = TestClient(app) + + +def _today() -> str: + return date.today().isoformat() + + +def test_telemetria_agrega_y_resume(): + day = _today() + # Dos envíos de "instalaciones" distintas el mismo día. + r1 = client.post("/telemetry", json={ + "day": day, + "app_version": "2.27.0", + "active_modules": ["fasting", "weight", "alcohol"], + "opens_by_module": {"alcohol": 3, "weight": 1}, + }) + assert r1.status_code < 300, r1.text + r2 = client.post("/telemetry", json={ + "day": day, + "active_modules": ["fasting", "alcohol"], + "opens_by_module": {"alcohol": 2}, + }) + assert r2.status_code < 300, r2.text + + summary = client.get("/telemetry/summary").json() + assert summary["submissions"] == 2 # dos envíos ese día + by_module = {m["module"]: m for m in summary["modules"]} + # alcohol: activo en 2 envíos, abierto 3+2 = 5 veces. + assert by_module["alcohol"]["activeInstallDays"] == 2 + assert by_module["alcohol"]["opens"] == 5 + # fasting: activo en 2 envíos, 0 aperturas. + assert by_module["fasting"]["activeInstallDays"] == 2 + assert by_module["fasting"]["opens"] == 0 + # No se filtra ningún identificador ni contenido: solo módulos. + assert "__submissions__" not in by_module + + +def test_day_invalido_rechazado(): + r = client.post("/telemetry", json={"day": "hoy"}) + assert r.status_code == 400