diff --git a/main.py b/main.py index 24f98a0..233cdc1 100644 --- a/main.py +++ b/main.py @@ -3,12 +3,22 @@ Facts bajo demanda) y recetas publicables. Sin cuentas: publicar es anónimo con un deviceToken.""" import uuid -from fastapi import Depends, FastAPI +from fastapi import Depends, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from sqlalchemy import func 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 @@ -53,11 +63,17 @@ def health(): # Alimentos # --------------------------------------------------------------------------- +class PortionIn(BaseModel): + name: str + grams: float + + class FoodIn(BaseModel): + id: str | None = None name: str kcalPer100g: float zone: str = "otros" - portions: list = [] + portions: list[PortionIn] = [] # validado: cada ración necesita name+grams barcode: str | None = None deviceToken: str | None = None @@ -65,11 +81,12 @@ class FoodIn(BaseModel): @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(f"%{q.lower()}%")) + .filter(func.lower(Food.name).like(like_pattern(q), escape="\\")) .limit(limit) .all() ) @@ -77,16 +94,21 @@ def search_foods(q: str = "", limit: int = 20, db: Session = Depends(get_db)): # 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} + 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 off.search(q): - if item["off_id"] and item["off_id"] in seen_off: + for item in items: + if item.get("off_id") and item["off_id"] in existing: 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"], @@ -99,6 +121,8 @@ def search_foods(q: str = "", limit: int = 20, db: Session = Depends(get_db)): ) 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] @@ -106,17 +130,18 @@ def search_foods(q: str = "", limit: int = 20, db: Session = Depends(get_db)): @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) + 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() @@ -125,10 +150,24 @@ def add_food(body: FoodIn, db: Session = Depends(get_db)): # 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 = [] + items: list[RecipeItemIn] = [] # validado: cada item necesita food+grams deviceToken: str | None = None @@ -137,25 +176,28 @@ 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()] + 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() - return r.to_app() if r else {} + if r is None: + raise HTTPException(status_code=404, detail="receta no encontrada") + return r.to_app() @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) + 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.author_token = body.deviceToken db.commit() return r.to_app() diff --git a/tests/test_api.py b/tests/test_api.py index 35ea08d..908142c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -33,6 +33,27 @@ def test_search_imports_from_off(monkeypatch): assert any(f["name"] == "Cosa OFF" for f in res) +def test_food_upsert_by_id_no_duplicates(): + r1 = client.post("/foods", json={"id": "fixed-1", "name": "Salsa X", "kcalPer100g": 100}) + assert r1.json()["id"] == "fixed-1" + client.post("/foods", json={"id": "fixed-1", "name": "Salsa X v2", "kcalPer100g": 110}) + res = client.get("/foods", params={"q": "salsa x"}).json() + matches = [f for f in res if f["id"] == "fixed-1"] + assert len(matches) == 1 + assert matches[0]["kcalPer100g"] == 110 # actualizado, no duplicado + + +def test_food_rejects_malformed_portion(): + r = client.post("/foods", json={ + "name": "Mala", "kcalPer100g": 50, "portions": [{"name": "taza"}] # falta grams + }) + assert r.status_code == 422 # pydantic valida la forma + + +def test_get_missing_recipe_is_404(): + assert client.get("/recipes/nope").status_code == 404 + + def test_publish_and_get_recipe(): r = client.post("/recipes", json={ "name": "Ensalada",