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:
106
main.py
106
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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user