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."""
|
un deviceToken."""
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI
|
from fastapi import Depends, FastAPI, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
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
|
import off
|
||||||
from db import Base, engine, get_db
|
from db import Base, engine, get_db
|
||||||
from models import Food, Recipe
|
from models import Food, Recipe
|
||||||
@@ -53,11 +63,17 @@ def health():
|
|||||||
# Alimentos
|
# Alimentos
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class PortionIn(BaseModel):
|
||||||
|
name: str
|
||||||
|
grams: float
|
||||||
|
|
||||||
|
|
||||||
class FoodIn(BaseModel):
|
class FoodIn(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
name: str
|
name: str
|
||||||
kcalPer100g: float
|
kcalPer100g: float
|
||||||
zone: str = "otros"
|
zone: str = "otros"
|
||||||
portions: list = []
|
portions: list[PortionIn] = [] # validado: cada ración necesita name+grams
|
||||||
barcode: str | None = None
|
barcode: str | None = None
|
||||||
deviceToken: str | None = None
|
deviceToken: str | None = None
|
||||||
|
|
||||||
@@ -65,11 +81,12 @@ class FoodIn(BaseModel):
|
|||||||
@app.get("/foods")
|
@app.get("/foods")
|
||||||
def search_foods(q: str = "", limit: int = 20, db: Session = Depends(get_db)):
|
def search_foods(q: str = "", limit: int = 20, db: Session = Depends(get_db)):
|
||||||
q = (q or "").strip()
|
q = (q or "").strip()
|
||||||
|
limit = clamp_limit(limit)
|
||||||
if len(q) < 2:
|
if len(q) < 2:
|
||||||
return []
|
return []
|
||||||
local = (
|
local = (
|
||||||
db.query(Food)
|
db.query(Food)
|
||||||
.filter(func.lower(Food.name).like(f"%{q.lower()}%"))
|
.filter(func.lower(Food.name).like(like_pattern(q), escape="\\"))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.all()
|
.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.
|
# Si hay pocos, enriquecemos con Open Food Facts y guardamos.
|
||||||
if len(local) < 5:
|
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}
|
seen_names = {f.name.lower() for f in local}
|
||||||
for item in off.search(q):
|
for item in items:
|
||||||
if item["off_id"] and item["off_id"] in seen_off:
|
if item.get("off_id") and item["off_id"] in existing:
|
||||||
continue
|
continue
|
||||||
if item["name"].lower() in seen_names:
|
if item["name"].lower() in seen_names:
|
||||||
continue
|
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(
|
food = Food(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
name=item["name"],
|
name=item["name"],
|
||||||
@@ -99,6 +121,8 @@ def search_foods(q: str = "", limit: int = 20, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
db.add(food)
|
db.add(food)
|
||||||
seen_names.add(item["name"].lower())
|
seen_names.add(item["name"].lower())
|
||||||
|
if item.get("off_id"):
|
||||||
|
existing.add(item["off_id"])
|
||||||
results.append(food.to_app())
|
results.append(food.to_app())
|
||||||
db.commit()
|
db.commit()
|
||||||
return results[:limit]
|
return results[:limit]
|
||||||
@@ -106,17 +130,18 @@ def search_foods(q: str = "", limit: int = 20, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
@app.post("/foods")
|
@app.post("/foods")
|
||||||
def add_food(body: FoodIn, db: Session = Depends(get_db)):
|
def add_food(body: FoodIn, db: Session = Depends(get_db)):
|
||||||
food = Food(
|
portions = [p.model_dump() for p in body.portions]
|
||||||
id=str(uuid.uuid4()),
|
# Upsert por id del cliente: republicar actualiza en vez de duplicar.
|
||||||
name=body.name.strip()[:120],
|
food = db.get(Food, body.id) if body.id else None
|
||||||
kcal_100g=body.kcalPer100g,
|
if food is None:
|
||||||
zone=body.zone,
|
food = Food(id=body.id or str(uuid.uuid4()), source="user")
|
||||||
portions=body.portions or [],
|
db.add(food)
|
||||||
barcode=body.barcode,
|
food.name = body.name.strip()[:120]
|
||||||
source="user",
|
food.kcal_100g = body.kcalPer100g
|
||||||
author_token=body.deviceToken,
|
food.zone = body.zone
|
||||||
)
|
food.portions = portions
|
||||||
db.add(food)
|
food.barcode = body.barcode
|
||||||
|
food.author_token = body.deviceToken
|
||||||
db.commit()
|
db.commit()
|
||||||
return food.to_app()
|
return food.to_app()
|
||||||
|
|
||||||
@@ -125,10 +150,24 @@ def add_food(body: FoodIn, db: Session = Depends(get_db)):
|
|||||||
# Recetas
|
# 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):
|
class RecipeIn(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
name: str
|
name: str
|
||||||
servings: int = 1
|
servings: int = 1
|
||||||
items: list = []
|
items: list[RecipeItemIn] = [] # validado: cada item necesita food+grams
|
||||||
deviceToken: str | None = None
|
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)
|
query = db.query(Recipe)
|
||||||
q = (q or "").strip()
|
q = (q or "").strip()
|
||||||
if q:
|
if q:
|
||||||
query = query.filter(func.lower(Recipe.name).like(f"%{q.lower()}%"))
|
query = query.filter(func.lower(Recipe.name).like(like_pattern(q), escape="\\"))
|
||||||
return [r.to_app() for r in query.limit(limit).all()]
|
return [r.to_app() for r in query.limit(clamp_limit(limit)).all()]
|
||||||
|
|
||||||
|
|
||||||
@app.get("/recipes/{recipe_id}")
|
@app.get("/recipes/{recipe_id}")
|
||||||
def get_recipe(recipe_id: str, db: Session = Depends(get_db)):
|
def get_recipe(recipe_id: str, db: Session = Depends(get_db)):
|
||||||
r = db.query(Recipe).filter(Recipe.id == recipe_id).first()
|
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")
|
@app.post("/recipes")
|
||||||
def add_recipe(body: RecipeIn, db: Session = Depends(get_db)):
|
def add_recipe(body: RecipeIn, db: Session = Depends(get_db)):
|
||||||
r = Recipe(
|
items = [i.model_dump() for i in body.items]
|
||||||
id=str(uuid.uuid4()),
|
r = db.get(Recipe, body.id) if body.id else None
|
||||||
name=body.name.strip()[:140],
|
if r is None:
|
||||||
servings=body.servings,
|
r = Recipe(id=body.id or str(uuid.uuid4()))
|
||||||
items=body.items or [],
|
db.add(r)
|
||||||
author_token=body.deviceToken,
|
r.name = body.name.strip()[:140]
|
||||||
)
|
r.servings = body.servings
|
||||||
db.add(r)
|
r.items = items
|
||||||
|
r.author_token = body.deviceToken
|
||||||
db.commit()
|
db.commit()
|
||||||
return r.to_app()
|
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)
|
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():
|
def test_publish_and_get_recipe():
|
||||||
r = client.post("/recipes", json={
|
r = client.post("/recipes", json={
|
||||||
"name": "Ensalada",
|
"name": "Ensalada",
|
||||||
|
|||||||
Reference in New Issue
Block a user