From 084428704309d1fd0ef72aeb32d7a21541797916 Mon Sep 17 00:00:00 2001 From: Carlos Narro Date: Fri, 3 Jul 2026 07:13:53 +0200 Subject: [PATCH] =?UTF-8?q?recetas=20Fase=20B:=20modelo=20ampliado=20(paso?= =?UTF-8?q?s/notas/categor=C3=ADa/autor=C3=ADa/linaje/visibilidad),=20migr?= =?UTF-8?q?aci=C3=B3n=20idempotente=20ALTER,=20publicar=20guarda=20todo,?= =?UTF-8?q?=20buscar=20solo=20p=C3=BAblicas,=20endpoint=20/cookbook/{autho?= =?UTF-8?q?r=5Fid}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- main.py | 71 +++++++++++++++++++++++++++++++++++++++++++++++-- models.py | 23 ++++++++++++++++ test_recipes.py | 49 ++++++++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 test_recipes.py diff --git a/main.py b/main.py index 233cdc1..a763400 100644 --- a/main.py +++ b/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() diff --git a/models.py b/models.py index 217e201..b8bc221 100644 --- a/models.py +++ b/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", } diff --git a/test_recipes.py b/test_recipes.py new file mode 100644 index 0000000..1c39d41 --- /dev/null +++ b/test_recipes.py @@ -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