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