commit 150a323a3754eebceb023d43e0d8ae93d3ed7887 Author: Carlos Narro Date: Wed Jul 1 19:35:02 2026 +0200 feat: autoayuno-api (FastAPI) — alimentos (OFF) y recetas Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..676236d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.db +.venv/ +venv/ +.pytest_cache/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9349b08 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . + +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a53797e --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# autoayuno-api + +Catálogo compartido de **alimentos** (alimentado de Open Food Facts bajo demanda) +y **recetas** publicables para la app [autoayuno]. Sin cuentas: publicar es +anónimo con un `deviceToken`. Lo personal del usuario vive en su dispositivo; +aquí solo lo compartido. + +## Stack +FastAPI + SQLAlchemy + Postgres (SQLite como fallback en dev). Docker → Dokploy. +Dominio: `api-autoayuno.carlosnarro.com`. + +## Endpoints +- `GET /health` +- `GET /foods?q=` — busca en la BD; si hay pocos, importa de Open Food Facts y guarda. +- `POST /foods` — alta anónima de un alimento (`source=user`). +- `GET /recipes?q=`, `GET /recipes/{id}`, `POST /recipes` — recetas públicas. + +Las respuestas usan la forma que espera la app (`kcalPer100g`, `zone`, `portions`, `origin`). + +## Desarrollo +```bash +pip install -r requirements.txt +uvicorn main:app --reload # SQLite local por defecto +pytest # tests con SQLite en memoria +``` + +## Producción (Dokploy) +Variable `DATABASE_URL` con el Postgres. Build por Dockerfile, puerto 8000. diff --git a/db.py b/db.py new file mode 100644 index 0000000..41d5f9c --- /dev/null +++ b/db.py @@ -0,0 +1,36 @@ +"""Conexión a la base de datos. Postgres en producción (DATABASE_URL); SQLite +como fallback para desarrollo/tests.""" +import os + +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker +from sqlalchemy.pool import StaticPool + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./autoayuno.db") + +# psycopg2 usa 'postgresql://'; algunos proveedores dan 'postgres://'. +if DATABASE_URL.startswith("postgres://"): + DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://", 1) + +if DATABASE_URL.startswith("sqlite"): + connect_args = {"check_same_thread": False} + # SQLite en memoria: una sola conexión compartida (StaticPool) para que las + # tablas creadas al arrancar sean visibles por todas las sesiones. + is_memory = ":memory:" in DATABASE_URL or DATABASE_URL == "sqlite://" + engine = create_engine( + DATABASE_URL, + connect_args=connect_args, + poolclass=StaticPool if is_memory else None, + ) +else: + engine = create_engine(DATABASE_URL, pool_pre_ping=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/main.py b/main.py new file mode 100644 index 0000000..8e687ee --- /dev/null +++ b/main.py @@ -0,0 +1,141 @@ +"""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 +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +from sqlalchemy import func +from sqlalchemy.orm import Session + +import off +from db import Base, engine, get_db +from models import Food, Recipe + +Base.metadata.create_all(bind=engine) + +app = FastAPI(title="autoayuno-api") +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +# --------------------------------------------------------------------------- +# Alimentos +# --------------------------------------------------------------------------- + +class FoodIn(BaseModel): + name: str + kcalPer100g: float + zone: str = "otros" + portions: list = [] + 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() + if len(q) < 2: + return [] + local = ( + db.query(Food) + .filter(func.lower(Food.name).like(f"%{q.lower()}%")) + .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: + seen_off = {f.off_id for f in local if f.off_id} + seen_names = {f.name.lower() for f in local} + for item in off.search(q): + if item["off_id"] and item["off_id"] in seen_off: + continue + if item["name"].lower() in seen_names: + continue + # Dedupe contra la BD por off_id. + if item["off_id"] and db.query(Food).filter(Food.off_id == item["off_id"]).first(): + 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()) + results.append(food.to_app()) + db.commit() + return results[:limit] + + +@app.post("/foods") +def add_food(body: FoodIn, db: Session = Depends(get_db)): + food = Food( + id=str(uuid.uuid4()), + name=body.name.strip()[:120], + kcal_100g=body.kcalPer100g, + zone=body.zone, + portions=body.portions or [], + barcode=body.barcode, + source="user", + author_token=body.deviceToken, + ) + db.add(food) + db.commit() + return food.to_app() + + +# --------------------------------------------------------------------------- +# Recetas +# --------------------------------------------------------------------------- + +class RecipeIn(BaseModel): + name: str + servings: int = 1 + items: list = [] + deviceToken: str | None = None + + +@app.get("/recipes") +def search_recipes(q: str = "", limit: int = 20, db: Session = Depends(get_db)): + query = db.query(Recipe) + q = (q or "").strip() + if q: + query = query.filter(func.lower(Recipe.name).like(f"%{q.lower()}%")) + return [r.to_app() for r in query.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() + return r.to_app() if r else {} + + +@app.post("/recipes") +def add_recipe(body: RecipeIn, db: Session = Depends(get_db)): + r = Recipe( + id=str(uuid.uuid4()), + name=body.name.strip()[:140], + servings=body.servings, + items=body.items or [], + author_token=body.deviceToken, + ) + db.add(r) + db.commit() + return r.to_app() diff --git a/models.py b/models.py new file mode 100644 index 0000000..217e201 --- /dev/null +++ b/models.py @@ -0,0 +1,49 @@ +"""Modelos de la base de datos: alimentos y recetas del catálogo compartido.""" +from sqlalchemy import Column, Float, Integer, String, JSON, DateTime, func + +from db import Base + + +class Food(Base): + __tablename__ = "foods" + + id = Column(String, primary_key=True) + name = Column(String, nullable=False, index=True) + kcal_100g = Column(Float, nullable=False) + zone = Column(String, default="otros") + portions = Column(JSON, default=list) # [{"name","grams"}] + barcode = Column(String, nullable=True) + source = Column(String, default="user") # off | user + off_id = Column(String, nullable=True, index=True) + author_token = Column(String, nullable=True) + created_at = Column(DateTime, server_default=func.now()) + + def to_app(self) -> dict: + """Serializa a la forma que espera la app (Food.fromJson).""" + return { + "id": self.id, + "name": self.name, + "kcalPer100g": self.kcal_100g, + "zone": self.zone or "otros", + "portions": self.portions or [], + "origin": "remote", + } + + +class Recipe(Base): + __tablename__ = "recipes" + + id = Column(String, primary_key=True) + name = Column(String, nullable=False, index=True) + servings = Column(Integer, default=1) + items = Column(JSON, default=list) # [{"food": {...}, "grams", "portionLabel"}] + author_token = Column(String, nullable=True) + created_at = Column(DateTime, server_default=func.now()) + + def to_app(self) -> dict: + return { + "id": self.id, + "name": self.name, + "servings": self.servings, + "items": self.items or [], + } diff --git a/off.py b/off.py new file mode 100644 index 0000000..ac4e9a7 --- /dev/null +++ b/off.py @@ -0,0 +1,46 @@ +"""Cliente de Open Food Facts: busca productos y los normaliza a la forma de +alimento de la app. Tolerante a fallos: devuelve [] si algo va mal.""" +import httpx + +_SEARCH = "https://world.openfoodfacts.org/cgi/search.pl" + + +def search(q: str, limit: int = 12) -> list[dict]: + """Devuelve alimentos normalizados: {name, kcal_100g, off_id, barcode}.""" + try: + resp = httpx.get( + _SEARCH, + params={ + "search_terms": q, + "search_simple": 1, + "action": "process", + "json": 1, + "page_size": limit, + "fields": "product_name,nutriments,code", + }, + timeout=6.0, + headers={"User-Agent": "autoayuno/1.0 (self-hosted)"}, + ) + if resp.status_code != 200: + return [] + out = [] + for p in resp.json().get("products", []): + name = (p.get("product_name") or "").strip() + kcal = p.get("nutriments", {}).get("energy-kcal_100g") + if not name or kcal is None: + continue + try: + kcal = float(kcal) + except (TypeError, ValueError): + continue + if kcal <= 0: + continue + out.append({ + "name": name[:120], + "kcal_100g": kcal, + "off_id": p.get("code"), + "barcode": p.get("code"), + }) + return out + except Exception: + return [] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..deb6e7b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy==2.0.36 +psycopg2-binary==2.9.10 +httpx==0.28.1 +pydantic==2.10.4 +pytest==8.3.4 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..35ea08d --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,46 @@ +import os +import sys + +# BD en memoria para tests, antes de importar la app. +os.environ["DATABASE_URL"] = "sqlite://" +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +import off # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 +from main import app # noqa: E402 + +client = TestClient(app) + + +def test_health(): + assert client.get("/health").json() == {"status": "ok"} + + +def test_add_and_search_food(): + r = client.post("/foods", json={"name": "Mi salsa", "kcalPer100g": 120}) + assert r.status_code == 200 + assert r.json()["kcalPer100g"] == 120 + res = client.get("/foods", params={"q": "salsa"}).json() + assert any(f["name"] == "Mi salsa" for f in res) + + +def test_search_imports_from_off(monkeypatch): + monkeypatch.setattr( + off, "search", + lambda q, limit=12: [{"name": "Cosa OFF", "kcal_100g": 90, "off_id": "111", "barcode": "111"}], + ) + res = client.get("/foods", params={"q": "cosarara"}).json() + assert any(f["name"] == "Cosa OFF" for f in res) + + +def test_publish_and_get_recipe(): + r = client.post("/recipes", json={ + "name": "Ensalada", + "servings": 2, + "items": [{"food": {"name": "Lechuga", "kcalPer100g": 15}, "grams": 100}], + }) + assert r.status_code == 200 + rid = r.json()["id"] + got = client.get(f"/recipes/{rid}").json() + assert got["name"] == "Ensalada" + assert got["servings"] == 2