fix(review): validar raciones/items, upsert por id (no duplicar), escape LIKE, clamp limit, 404, dedupe en 1 query

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-07-01 23:29:57 +02:00
parent b90c48c257
commit 07fb68a18f
2 changed files with 95 additions and 32 deletions

106
main.py
View File

@@ -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()

View File

@@ -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",