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 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, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -24,6 +24,41 @@ from db import Base, engine, get_db
from models import Food, Recipe 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): def init_db(retries: int = 10):
"""Crea las tablas, reintentando por si la BD tarda en levantar. No tumba """Crea las tablas, reintentando por si la BD tarda en levantar. No tumba
el arranque si falla: /health seguirá respondiendo.""" el arranque si falla: /health seguirá respondiendo."""
@@ -31,6 +66,7 @@ def init_db(retries: int = 10):
for _ in range(retries): for _ in range(retries):
try: try:
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_migrate_recipes()
return True return True
except Exception: except Exception:
time.sleep(2) time.sleep(2)
@@ -168,12 +204,24 @@ class RecipeIn(BaseModel):
name: str name: str
servings: int = 1 servings: int = 1
items: list[RecipeItemIn] = [] # validado: cada item necesita food+grams 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 deviceToken: str | None = None
@app.get("/recipes") @app.get("/recipes")
def search_recipes(q: str = "", limit: int = 20, db: Session = Depends(get_db)): 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() q = (q or "").strip()
if q: if q:
query = query.filter(func.lower(Recipe.name).like(like_pattern(q), escape="\\")) 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() 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") @app.post("/recipes")
def add_recipe(body: RecipeIn, db: Session = Depends(get_db)): def add_recipe(body: RecipeIn, db: Session = Depends(get_db)):
items = [i.model_dump() for i in body.items] 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.name = body.name.strip()[:140]
r.servings = body.servings r.servings = body.servings
r.items = items 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 r.author_token = body.deviceToken
db.commit() db.commit()
return r.to_app() return r.to_app()

View File

@@ -37,6 +37,18 @@ class Recipe(Base):
name = Column(String, nullable=False, index=True) name = Column(String, nullable=False, index=True)
servings = Column(Integer, default=1) servings = Column(Integer, default=1)
items = Column(JSON, default=list) # [{"food": {...}, "grams", "portionLabel"}] 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) author_token = Column(String, nullable=True)
created_at = Column(DateTime, server_default=func.now()) created_at = Column(DateTime, server_default=func.now())
@@ -46,4 +58,15 @@ class Recipe(Base):
"name": self.name, "name": self.name,
"servings": self.servings, "servings": self.servings,
"items": self.items or [], "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