From 150a323a3754eebceb023d43e0d8ae93d3ed7887 Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Wed, 1 Jul 2026 19:35:02 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20autoayuno-api=20(FastAPI)=20=E2=80=94?= =?UTF-8?q?=20alimentos=20(OFF)=20y=20recetas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .gitignore | 7 +++ Dockerfile | 9 +++ README.md | 28 +++++++++ db.py | 36 ++++++++++++ main.py | 141 ++++++++++++++++++++++++++++++++++++++++++++++ models.py | 49 ++++++++++++++++ off.py | 46 +++++++++++++++ requirements.txt | 7 +++ tests/test_api.py | 46 +++++++++++++++ 9 files changed, 369 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 db.py create mode 100644 main.py create mode 100644 models.py create mode 100644 off.py create mode 100644 requirements.txt create mode 100644 tests/test_api.py 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