diff --git a/cauldron/config.py b/cauldron/config.py index 83c6344..787e205 100644 --- a/cauldron/config.py +++ b/cauldron/config.py @@ -36,6 +36,12 @@ class Config: # Per-user token at-rest crypto fernet_key: str + # Comma-separated list of authentik subjects who get the operator-tier + # /me admin tools panel (consolidate, discover scrape, etc). Anyone not + # in this list sees a household-friendly /me with the destructive tools + # hidden. Default empty = nobody gets admin tools (safe-fail). + admin_subs: tuple[str, ...] + def load() -> Config: return Config( @@ -64,4 +70,9 @@ def load() -> Config: db_user=os.environ["DB_USER"], db_password=os.environ["DB_PASSWORD"], fernet_key=os.environ["CAULDRON_FERNET_KEY"], + admin_subs=tuple( + s.strip() + for s in os.environ.get("CAULDRON_ADMIN_SUBS", "").split(",") + if s.strip() + ), ) diff --git a/cauldron/server.py b/cauldron/server.py index 7a70b26..ad21742 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -146,6 +146,14 @@ def create_app() -> Flask: client_secret=cfg.oidc_client_secret, ) + # Make `is_admin` available in every template — used by _base.html + # to gate the /discover top-nav tab and by /me to gate the operator + # tools panel. Driven by the CAULDRON_ADMIN_SUBS env var. + @app.context_processor + def _inject_is_admin(): + u = session.get("user") or {} + return {"is_admin": u.get("sub") in cfg.admin_subs} + # ---------- helpers -------------------------------------------------- def require_bearer(fn): @@ -168,6 +176,21 @@ def create_app() -> Flask: return fn(*a, **kw) return w + def require_admin(fn): + """Layered on top of require_session — only members of cfg.admin_subs + proceed. Non-admins get a 404 (not a 403) so the route's existence + isn't advertised. Used for `/discover` and `/consolidate` whose + admin-only nature was scoped per Cobb 2026-05-02.""" + @wraps(fn) + def w(*a, **kw): + u = session.get("user") + if not u: + return redirect(url_for("login", next=request.path)) + if u.get("sub") not in cfg.admin_subs: + return ("not found", 404) + return fn(*a, **kw) + return w + def current_user_mealie() -> Mealie | None: u = session.get("user") if not u: @@ -366,6 +389,7 @@ def create_app() -> Flask: "me.html", user=u, connected=connected, mealie_user=mealie_user, household_size=household_size, active="me", + is_admin=(u["sub"] in cfg.admin_subs), ) @app.get("/me.json") @@ -458,7 +482,7 @@ def create_app() -> Flask: limit=per_page, offset=0, ) pick_slugs = db.list_household_pick_slugs(hid) - items = [_index_row_to_card(r, pick_slugs) for r in rows] + items = [_index_row_to_card(r, pick_slugs, cfg.mealie_public_url) for r in rows] total = (state or {}).get("recipe_count") or len(items) pages = max(1, (total + per_page - 1) // per_page) @@ -515,7 +539,7 @@ def create_app() -> Flask: ranked = search_index(rows, search, limit=80) start = (page - 1) * per_page slice_ = ranked[start:start + per_page] - items = [_index_row_to_card(r, pick_slugs) for r in slice_] + items = [_index_row_to_card(r, pick_slugs, cfg.mealie_public_url) for r in slice_] total = len(ranked) total_pages = max(1, (total + per_page - 1) // per_page) return jsonify({ @@ -536,7 +560,7 @@ def create_app() -> Flask: ) has_next = len(rows) > per_page rows = rows[:per_page] - items = [_index_row_to_card(r, pick_slugs) for r in rows] + items = [_index_row_to_card(r, pick_slugs, cfg.mealie_public_url) for r in rows] # total — cheap-ish: count only when we don't already know total = (state or {}).get("recipe_count") or len(items) total_pages = max(1, (total + per_page - 1) // per_page) @@ -1913,7 +1937,7 @@ def create_app() -> Flask: # ---------- foods consolidator (Step 3) ------------------------------ @app.get("/consolidate") - @require_session + @require_admin def consolidate_page(): hid = current_household_id() if not hid: @@ -1924,7 +1948,7 @@ def create_app() -> Flask: ) @app.post("/api/foods/consolidate-start") - @require_session + @require_admin def consolidate_start(): u = session["user"] hid = current_household_id() @@ -1943,7 +1967,7 @@ def create_app() -> Flask: return jsonify({"ok": True, "job_id": job_id}) @app.get("/api/foods/consolidate-status") - @require_session + @require_admin def consolidate_status(): hid = current_household_id() if not hid: @@ -1954,7 +1978,7 @@ def create_app() -> Flask: return jsonify({"job": _consolidate_job_payload(job)}) @app.get("/api/foods/consolidate-jobs//proposals") - @require_session + @require_admin def consolidate_proposals(job_id: int): hid = current_household_id() if not hid: @@ -1977,7 +2001,7 @@ def create_app() -> Flask: }) @app.post("/api/foods/consolidate-apply/") - @require_session + @require_admin def consolidate_apply(job_id: int): hid = current_household_id() if not hid: @@ -1999,7 +2023,7 @@ def create_app() -> Flask: return jsonify({"ok": True, "approved_count": len(approved_ids)}) @app.post("/api/foods/consolidate-cancel/") - @require_session + @require_admin def consolidate_cancel(job_id: int): hid = current_household_id() if not hid: @@ -2060,7 +2084,7 @@ def create_app() -> Flask: # ---------- Discover v0.1 (browse external recipes) ------------------ @app.get("/discover") - @require_session + @require_admin def discover_page(): # Discover is a global, cross-household corpus — no household # gate. But we still want a connected user before showing the @@ -2077,7 +2101,7 @@ def create_app() -> Flask: ) @app.get("/api/discover/search") - @require_session + @require_admin def discover_search(): args = request.args q = (args.get("q") or "").strip() or None @@ -2179,7 +2203,7 @@ def create_app() -> Flask: return jsonify({"recipes": out, "count": len(out)}) @app.post("/api/discover/import/") - @require_session + @require_admin def discover_import(discover_id: int): u = session["user"] row = db.get_discovered_recipe(discover_id) @@ -2218,7 +2242,7 @@ def create_app() -> Flask: return jsonify({"ok": True, "slug": new_slug}) @app.post("/api/discover/reject/") - @require_session + @require_admin def discover_reject(discover_id: int): """Per-household 'skip from discover'. Audit F-2 routes 2026-05-02: previously this flipped the GLOBAL `cauldron_discovered_recipes.status @@ -2241,7 +2265,7 @@ def create_app() -> Flask: return jsonify({"ok": True, "scope": "household"}) @app.post("/api/discover/scrape-start") - @require_session + @require_admin def discover_scrape_start(): u = session["user"] active = db.running_discover_job() @@ -2299,7 +2323,7 @@ def create_app() -> Flask: }) @app.get("/api/discover/scrape-status") - @require_session + @require_admin def discover_scrape_status(): job = db.latest_discover_job() if not job: @@ -2307,7 +2331,7 @@ def create_app() -> Flask: return jsonify({"job": _consolidate_job_payload(job)}) @app.post("/api/discover/scrape-cancel/") - @require_session + @require_admin def discover_scrape_cancel(job_id: int): job = db.get_discover_job(job_id) if not job: @@ -2468,9 +2492,16 @@ def _index_stale(state: dict | None) -> bool: return age > _INDEX_TTL_SECS -def _index_row_to_card(row: dict, pick_slugs: set[str]) -> dict: +def _index_row_to_card(row: dict, pick_slugs: set[str], mealie_public_url: str = "") -> dict: """Index row → recipe card dict the JS frontend expects (matches the - Mealie recipe shape closely enough for renderCard).""" + Mealie recipe shape closely enough for renderCard). + + image_url: Mealie serves recipe thumbnails at + `/api/media/recipes//images/min-original.webp` + when an image was uploaded (`raw.image` is truthy and not the literal + 'no image' sentinel). We build the URL once server-side so the + frontend doesn't need to know Mealie's path scheme. + """ import json as _json raw = row.get("raw_json") if isinstance(raw, str): @@ -2479,6 +2510,15 @@ def _index_row_to_card(row: dict, pick_slugs: set[str]) -> dict: except Exception: raw = {} raw = raw or {} + image_url = None + raw_id = raw.get("id") + raw_img = raw.get("image") + if raw_id and raw_img and raw_img != "no image" and mealie_public_url: + image_url = ( + f"{mealie_public_url.rstrip('/')}" + f"/api/media/recipes/{raw_id}/images/min-original.webp" + f"?v={raw_img}" + ) return { "slug": row["slug"], "name": row["name"], @@ -2487,6 +2527,7 @@ def _index_row_to_card(row: dict, pick_slugs: set[str]) -> dict: "dateUpdated": (raw.get("dateUpdated") if raw else None) or (row["date_updated"].isoformat() if row.get("date_updated") else None), "tags": raw.get("tags") or [], "picked": row["slug"] in pick_slugs, + "image_url": image_url, } diff --git a/cauldron/templates/_base.html b/cauldron/templates/_base.html index edcb145..c62b023 100644 --- a/cauldron/templates/_base.html +++ b/cauldron/templates/_base.html @@ -111,7 +111,7 @@ nav.nav a.active::after { /* Main */ main { - max-width: 920px; margin: 0 auto; padding: 36px 22px 80px; + max-width: 1500px; margin: 0 auto; padding: 36px 22px 80px; position: relative; } @@ -282,9 +282,10 @@ ol li, ul li { margin: .35em 0; } } @media (min-width: 720px) { .recipe-grid { grid-template-columns: 1fr 1fr; gap: 16px; } } @media (min-width: 1100px) { .recipe-grid { grid-template-columns: 1fr 1fr 1fr; } } +@media (min-width: 1400px) { .recipe-grid { grid-template-columns: 1fr 1fr 1fr 1fr; } } .recipe-card { - display: block; padding: 18px 18px 18px 20px; + display: flex; flex-direction: column; border: 1px solid var(--line); background: var(--surface); border-radius: 8px; text-decoration: none; color: inherit; @@ -292,10 +293,26 @@ ol li, ul li { margin: .35em 0; } transition: all .2s ease; min-height: 88px; /* makes the whole card a comfortable tap */ } +.recipe-card .rimg { + width: 100%; aspect-ratio: 16/10; + background: var(--bg-2) center/cover no-repeat; + border-bottom: 1px solid var(--line); + display: block; +} +.recipe-card .rimg.placeholder { + display: flex; align-items: center; justify-content: center; + color: var(--muted); font-size: 28px; opacity: .55; +} +.recipe-card .rbody { padding: 14px 16px 16px 20px; } +.recipe-card.picked > .rbody::before { + content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; + background: var(--purple-bright); box-shadow: 0 0 12px var(--purple-glow); +} .recipe-card.picked { border-color: var(--purple-dim); background: rgba(45, 29, 74, .35); } .recipe-card.picked::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--purple-bright); box-shadow: 0 0 12px var(--purple-glow); + z-index: 1; } .recipe-card:active, .recipe-card:hover { border-color: var(--purple-dim); background: var(--surface-2); @@ -303,6 +320,7 @@ ol li, ul li { margin: .35em 0; } box-shadow: 0 4px 24px -8px var(--purple-glow); } .recipe-card .rmain { display: block; padding-right: 56px; } +.recipe-card .pick-btn { z-index: 2; } .recipe-card .rname { color: var(--bone); font-family: var(--serif); font-weight: 600; font-size: 1.2em; letter-spacing: .02em; line-height: 1.25; @@ -482,7 +500,7 @@ button { font-family: inherit; } picks plan list - discover + {% if is_admin %}discover{% endif %} me

mealie's parser is per-recipe; this kicks off a bulk pass over your whole library. review proposals, apply the good ones.

🪄 bulk sterilize recipes →

+ + {% if is_admin %}

scan your foods table for dupes, ask hecate to pick canonicals, merge in mealie. one-time cleanup; aliases get attached to the survivors so the parser fuzzy-matches variants from now on.

🔮 consolidate foods table →

+ {% endif %}

find duplicate recipes by name + ingredient similarity. hecate picks the canonical to keep; you confirm per cluster before mealie deletes the others. permanent — review carefully.

🌀 dedupe recipes →

@@ -66,8 +69,10 @@

have hecate generate per-recipe metadata — cuisine, complexity, macros, primary protein/carb, comfort tier, summary. the plan generator reads this so "high protein week" is a real query, not just a vibe.

✨ enrich recipes →

+ {% if is_admin %}

browse a cross-household corpus of scraped recipes — search by cuisine / protein / time / kid-friendliness. one click sends a recipe to your mealie library; sterilize+enrich pipelines run on it like any other.

🌐 discover recipes →

+ {% endif %} {% endif %} diff --git a/cauldron/templates/recipes.html b/cauldron/templates/recipes.html index bb4070a..fd8a700 100644 --- a/cauldron/templates/recipes.html +++ b/cauldron/templates/recipes.html @@ -75,13 +75,19 @@ ].filter(Boolean).join(' · '); const picked = r.picked ? ' picked' : ''; const onClass = r.picked ? ' on' : ''; + const img = r.image_url + ? `` + : `🍴`; return ` - -
${escapeHtml(r.name||'(untitled)')}
-
${meta}
- ${tags ? `
${tags}
` : ''} + ${img} + + +
${escapeHtml(r.name||'(untitled)')}
+
${meta}
+ ${tags ? `
${tags}
` : ''} +
+
-
`; }