recetas Fase B: modelo ampliado (pasos/notas/categoría/autoría/linaje/visibilidad), migración idempotente ALTER, publicar guarda todo, buscar solo públicas, endpoint /cookbook/{author_id}

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-07-03 07:13:53 +02:00
parent 07fb68a18f
commit 0844287043
3 changed files with 141 additions and 2 deletions

71
main.py
View File

@@ -6,7 +6,7 @@ import uuid
from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from sqlalchemy import func
from sqlalchemy import func, text
from sqlalchemy.orm import Session
@@ -24,6 +24,41 @@ from db import Base, engine, get_db
from models import Food, Recipe
# Columnas añadidas a `recipes` después de la v1 (id/name/servings/items). En
# Postgres la tabla ya existe y `create_all` NO la altera, así que las añadimos a
# mano (idempotente). En SQLite fresco no hace falta: `create_all` ya las trae.
_RECIPE_NEW_COLUMNS = {
"category": "VARCHAR DEFAULT 'otros'",
"prep_minutes": "INTEGER",
"steps": "JSON",
"notes": "VARCHAR",
"author_id": "VARCHAR",
"author_name": "VARCHAR",
"parent_id": "VARCHAR",
"parent_author_name": "VARCHAR",
"root_id": "VARCHAR",
"root_author_name": "VARCHAR",
"visibility": "VARCHAR DEFAULT 'public'",
"updated_at": "TIMESTAMP",
}
def _migrate_recipes():
"""Añade en Postgres las columnas nuevas de `recipes` si faltan (best-effort)."""
if not engine.url.get_backend_name().startswith("postgres"):
return
try:
with engine.begin() as conn:
for name, ddl in _RECIPE_NEW_COLUMNS.items():
try:
conn.execute(text(
f"ALTER TABLE recipes ADD COLUMN IF NOT EXISTS {name} {ddl}"))
except Exception:
pass
except Exception:
pass
def init_db(retries: int = 10):
"""Crea las tablas, reintentando por si la BD tarda en levantar. No tumba
el arranque si falla: /health seguirá respondiendo."""
@@ -31,6 +66,7 @@ def init_db(retries: int = 10):
for _ in range(retries):
try:
Base.metadata.create_all(bind=engine)
_migrate_recipes()
return True
except Exception:
time.sleep(2)
@@ -168,12 +204,24 @@ class RecipeIn(BaseModel):
name: str
servings: int = 1
items: list[RecipeItemIn] = [] # validado: cada item necesita food+grams
category: str = "otros"
prepMinutes: int | None = None
steps: list[str] = []
notes: str | None = None
authorId: str | None = None
authorName: str | None = None
parentId: str | None = None
parentAuthorName: str | None = None
rootId: str | None = None
rootAuthorName: str | None = None
visibility: str = "public"
deviceToken: str | None = None
@app.get("/recipes")
def search_recipes(q: str = "", limit: int = 20, db: Session = Depends(get_db)):
query = db.query(Recipe)
# Buscar solo devuelve recetas PÚBLICAS (las privadas/no listadas no salen).
query = db.query(Recipe).filter(Recipe.visibility == "public")
q = (q or "").strip()
if q:
query = query.filter(func.lower(Recipe.name).like(like_pattern(q), escape="\\"))
@@ -188,6 +236,13 @@ def get_recipe(recipe_id: str, db: Session = Depends(get_db)):
return r.to_app()
@app.get("/cookbook/{author_id}")
def cookbook(author_id: str, db: Session = Depends(get_db)):
"""Todas las recetas de un autor (para respaldo/restaurar; usado en B3)."""
rows = db.query(Recipe).filter(Recipe.author_id == author_id).all()
return [r.to_app() for r in rows]
@app.post("/recipes")
def add_recipe(body: RecipeIn, db: Session = Depends(get_db)):
items = [i.model_dump() for i in body.items]
@@ -198,6 +253,18 @@ def add_recipe(body: RecipeIn, db: Session = Depends(get_db)):
r.name = body.name.strip()[:140]
r.servings = body.servings
r.items = items
r.category = (body.category or "otros")[:20]
r.prep_minutes = body.prepMinutes
r.steps = [str(s)[:500] for s in (body.steps or [])][:60]
r.notes = (body.notes or None)
r.author_id = body.authorId
r.author_name = (body.authorName or None)
r.parent_id = body.parentId
r.parent_author_name = body.parentAuthorName
r.root_id = body.rootId
r.root_author_name = body.rootAuthorName
r.visibility = body.visibility if body.visibility in (
"private", "unlisted", "public") else "public"
r.author_token = body.deviceToken
db.commit()
return r.to_app()

View File

@@ -37,6 +37,18 @@ class Recipe(Base):
name = Column(String, nullable=False, index=True)
servings = Column(Integer, default=1)
items = Column(JSON, default=list) # [{"food": {...}, "grams", "portionLabel"}]
category = Column(String, default="otros")
prep_minutes = Column(Integer, nullable=True)
steps = Column(JSON, default=list) # ["paso 1", "paso 2"]
notes = Column(String, nullable=True)
author_id = Column(String, nullable=True, index=True)
author_name = Column(String, nullable=True)
parent_id = Column(String, nullable=True)
parent_author_name = Column(String, nullable=True)
root_id = Column(String, nullable=True)
root_author_name = Column(String, nullable=True)
visibility = Column(String, default="public", index=True)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
author_token = Column(String, nullable=True)
created_at = Column(DateTime, server_default=func.now())
@@ -46,4 +58,15 @@ class Recipe(Base):
"name": self.name,
"servings": self.servings,
"items": self.items or [],
"category": self.category or "otros",
"prepMinutes": self.prep_minutes,
"steps": self.steps or [],
"notes": self.notes,
"authorId": self.author_id,
"authorName": self.author_name,
"parentId": self.parent_id,
"parentAuthorName": self.parent_author_name,
"rootId": self.root_id,
"rootAuthorName": self.root_author_name,
"visibility": self.visibility or "public",
}

49
test_recipes.py Normal file
View File

@@ -0,0 +1,49 @@
"""Tests de recetas (Fase B): publicar con campos nuevos, buscar solo públicas,
obtener por id y cookbook por autor. SQLite en memoria."""
import os
os.environ["DATABASE_URL"] = "sqlite://"
from fastapi.testclient import TestClient # noqa: E402
from main import app # noqa: E402
client = TestClient(app)
def test_publicar_y_obtener_con_campos_nuevos():
body = {
"id": "r1", "name": "Flan", "servings": 4,
"items": [], "steps": ["Bate", "Hornea"], "notes": "de la abuela",
"category": "postres", "prepMinutes": 30,
"authorId": "auth-1", "authorName": "Carlos",
"visibility": "public", "deviceToken": "dev-1",
}
r = client.post("/recipes", json=body)
assert r.status_code < 300, r.text
got = client.get("/recipes/r1").json()
assert got["steps"] == ["Bate", "Hornea"]
assert got["category"] == "postres"
assert got["prepMinutes"] == 30
assert got["notes"] == "de la abuela"
assert got["authorName"] == "Carlos"
assert got["visibility"] == "public"
def test_buscar_solo_publicas_y_cookbook():
client.post("/recipes", json={"id": "pub", "name": "Pública", "servings": 1,
"items": [], "visibility": "public", "authorId": "a1"})
client.post("/recipes", json={"id": "prv", "name": "Privada", "servings": 1,
"items": [], "visibility": "private", "authorId": "a1"})
names = [x["name"] for x in client.get("/recipes?q=").json()]
assert "Pública" in names
assert "Privada" not in names
ids = {x["id"] for x in client.get("/cookbook/a1").json()}
assert ids == {"pub", "prv"}
def test_receta_anonima_sin_nombre():
client.post("/recipes", json={"id": "anon", "name": "Anón", "servings": 1,
"items": [], "visibility": "public", "authorId": "a2"})
got = client.get("/recipes/anon").json()
assert got["authorName"] is None