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()
|
||||
|
||||
23
models.py
23
models.py
@@ -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
49
test_recipes.py
Normal 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
|
||||
Reference in New Issue
Block a user