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