The /plan crash: AttributeError: 'str' object has no attribute 'get' in sync_user_household — Mealie's user-self response sometimes returns household as a slug-string (newer versions) instead of a dict. Fix: defensive — accept dict OR str, also fall back to top-level householdId / householdSlug fields. Doesn't crash if all are missing. Mobile + UX pass (Cobb 2026-04-29 — 'two kids screaming at the store'): - Mushroom pick button bumped 30px → 48px (Apple HIG min tap target), inner SVG 16 → 24px, kept opacity transition. Stays one-handed-friendly. - Recipe cards: padding 14px → 18px, min-height 88px, font-size 1.08em → 1.2em on the title. Bigger comfortable tap area. - Recipe grid: 1col by default, 2col at 720px+, 3col at 1100px+ (was 560/900). Phones get single column, the bigger card. - Search bar: 14px → 16px font (kills iOS auto-zoom on focus), 8px → 10px padding. Now sticky at top of /recipes with backdrop-blur. - Pill chip rows for sort + category — horizontally scrollable, hidden scrollbar, pill rounded shape, active state in purple-deep bg. - Sort options: newest, recent (last_made), a-z, updated. Default = newest. - Category chips: pulled live from Mealie's /api/organizers/categories, top 14 shown. 'all' chip clears filter. Mealie client: list_recipes() now accepts orderBy + orderDirection + categories[] + tags[] params. New list_categories() helper. Server: _sort_to_order() maps our sort keys to Mealie's orderBy. Recipes + json endpoints both honor sort + cat query params and pass through to the AJAX paginator.
545 lines
19 KiB
Python
545 lines
19 KiB
Python
"""Flask app — v0.2 foundation.
|
|
|
|
Adds Authentik OIDC + sulkta-mariadb DB + Fernet crypto for per-user Mealie
|
|
tokens. v0.1 admin endpoints stay (still bearer-gated for now); user-facing
|
|
routes start using OIDC sessions.
|
|
|
|
Routes (current):
|
|
GET /healthz liveness, no auth
|
|
GET / redirects to /login if no session,
|
|
else /me
|
|
GET /login start OIDC flow
|
|
GET /auth/callback OIDC callback
|
|
POST /logout clear session
|
|
GET /me "who am I" page (json for now)
|
|
|
|
GET /connect-mealie prompt for Mealie token
|
|
POST /connect-mealie store encrypted token
|
|
POST /disconnect-mealie delete encrypted token
|
|
|
|
GET /api/recipes (admin bearer) proxy Mealie list
|
|
POST /api/sterilize/preview/<slug> (admin bearer) v0.1 sterilizer
|
|
POST /api/sterilize/apply/<slug> (admin bearer) v0.1 sterilizer
|
|
"""
|
|
from datetime import date, datetime, timedelta
|
|
from functools import wraps
|
|
|
|
from flask import Flask, jsonify, redirect, render_template, request, session, url_for
|
|
|
|
from .config import load
|
|
from .crypto import TokenCrypto
|
|
from .db import DB
|
|
from .forge import Forge
|
|
from .mealie import Mealie, MealieError
|
|
from .oidc import init_oauth
|
|
from .sterilizer import Sterilizer
|
|
|
|
|
|
cfg = load()
|
|
db = DB(host=cfg.db_host, port=cfg.db_port, name=cfg.db_name, user=cfg.db_user, password=cfg.db_password)
|
|
crypto = TokenCrypto(cfg.fernet_key)
|
|
forge = Forge(
|
|
base_url=cfg.clawdforge_url,
|
|
token=cfg.clawdforge_token,
|
|
default_model=cfg.default_model,
|
|
default_timeout=cfg.default_timeout_secs,
|
|
)
|
|
# System-tier Mealie client (Cobb's "Cauldron" token; admin batch ops only)
|
|
system_mealie = Mealie(base_url=cfg.mealie_api_url, api_token=cfg.mealie_api_token)
|
|
system_sterilizer = Sterilizer(mealie=system_mealie, forge=forge, model=cfg.default_model)
|
|
|
|
|
|
def create_app() -> Flask:
|
|
app = Flask(__name__)
|
|
app.secret_key = cfg.secret_key
|
|
app.config.update(
|
|
SESSION_COOKIE_HTTPONLY=True,
|
|
SESSION_COOKIE_SAMESITE="Lax",
|
|
# NOT setting SESSION_COOKIE_SECURE=True — LAN is plain HTTP for now.
|
|
# If we ever front this with TLS, flip secure=True.
|
|
)
|
|
|
|
# Apply migrations on startup
|
|
applied = db.migrate()
|
|
if applied:
|
|
app.logger.info("applied migrations: %s", applied)
|
|
|
|
oauth = init_oauth(
|
|
app,
|
|
issuer=cfg.oidc_issuer,
|
|
client_id=cfg.oidc_client_id,
|
|
client_secret=cfg.oidc_client_secret,
|
|
)
|
|
|
|
# ---------- helpers --------------------------------------------------
|
|
|
|
def require_bearer(fn):
|
|
@wraps(fn)
|
|
def w(*a, **kw):
|
|
auth = request.headers.get("Authorization", "")
|
|
if not auth.startswith("Bearer "):
|
|
return jsonify({"error": "missing bearer"}), 401
|
|
tok = auth[7:].strip()
|
|
if not _const_eq(tok, cfg.admin_bearer):
|
|
return jsonify({"error": "forbidden"}), 403
|
|
return fn(*a, **kw)
|
|
return w
|
|
|
|
def require_session(fn):
|
|
@wraps(fn)
|
|
def w(*a, **kw):
|
|
if not session.get("user"):
|
|
return redirect(url_for("login", next=request.path))
|
|
return fn(*a, **kw)
|
|
return w
|
|
|
|
def current_user_mealie() -> Mealie | None:
|
|
u = session.get("user")
|
|
if not u:
|
|
return None
|
|
blob = db.get_user_mealie_token_blob(u["sub"])
|
|
if not blob:
|
|
return None
|
|
try:
|
|
tok = crypto.decrypt(blob)
|
|
except Exception:
|
|
return None
|
|
return Mealie(base_url=cfg.mealie_api_url, api_token=tok)
|
|
|
|
def sync_user_household(sub: str) -> int | None:
|
|
"""Pull the user's Mealie household, upsert into cauldron, ensure
|
|
membership. Idempotent. Returns local household_id or None.
|
|
|
|
Mealie's user-self response shape varies across versions — the
|
|
`household` field can be a dict (with id+name+slug), a plain
|
|
slug-string, or absent. We also accept top-level householdId /
|
|
householdSlug. If we can't resolve a real ID, fall back to slug.
|
|
"""
|
|
client = current_user_mealie()
|
|
if not client:
|
|
return None
|
|
try:
|
|
me = client.who_am_i()
|
|
except Exception:
|
|
return None
|
|
|
|
h = me.get("household")
|
|
h_id_mealie: str | None = None
|
|
h_name: str | None = None
|
|
|
|
if isinstance(h, dict):
|
|
h_id_mealie = h.get("id") or h.get("slug")
|
|
h_name = h.get("name") or h.get("slug")
|
|
elif isinstance(h, str) and h:
|
|
# newer Mealie versions return household as a slug string
|
|
h_id_mealie = h
|
|
h_name = h
|
|
|
|
# Fall back to top-level fields
|
|
h_id_mealie = h_id_mealie or me.get("householdId") or me.get("household_id") or me.get("householdSlug") or me.get("household_slug")
|
|
h_name = h_name or me.get("householdName") or h_id_mealie or "default"
|
|
|
|
if not h_id_mealie:
|
|
return None
|
|
|
|
local_id = db.upsert_household(mealie_household_id=str(h_id_mealie), name=str(h_name))
|
|
existing = db.list_household_member_subs(local_id)
|
|
role = "admin" if not existing else "member"
|
|
db.add_household_member(local_id, sub, role=role)
|
|
return local_id
|
|
|
|
def current_household_id() -> int | None:
|
|
u = session.get("user")
|
|
if not u:
|
|
return None
|
|
h = db.get_user_household_id(u["sub"])
|
|
if h is None:
|
|
h = sync_user_household(u["sub"])
|
|
return h
|
|
|
|
def monday_of(d: date) -> date:
|
|
return d - timedelta(days=d.weekday())
|
|
|
|
# ---------- public ---------------------------------------------------
|
|
|
|
@app.get("/healthz")
|
|
def healthz():
|
|
upstream = {}
|
|
try:
|
|
upstream["clawdforge"] = forge.healthz()
|
|
except Exception as e:
|
|
upstream["clawdforge_error"] = str(e)
|
|
try:
|
|
with db.conn() as c, c.cursor() as cur:
|
|
cur.execute("SELECT 1 AS ok")
|
|
cur.fetchone()
|
|
upstream["db"] = "ok"
|
|
except Exception as e:
|
|
upstream["db_error"] = str(e)
|
|
return jsonify({"ok": True, "upstream": upstream})
|
|
|
|
@app.get("/")
|
|
def index():
|
|
if not session.get("user"):
|
|
return redirect(url_for("login"))
|
|
return redirect(url_for("me"))
|
|
|
|
@app.get("/login")
|
|
def login():
|
|
# Stash where to go after login
|
|
nxt = request.args.get("next") or "/me"
|
|
session["post_login_next"] = nxt
|
|
return oauth.cauldron.authorize_redirect(cfg.oidc_redirect_uri)
|
|
|
|
@app.get("/auth/callback")
|
|
def auth_callback():
|
|
token = oauth.cauldron.authorize_access_token()
|
|
userinfo = token.get("userinfo") or oauth.cauldron.userinfo(token=token)
|
|
sub = userinfo.get("sub") or userinfo.get("email")
|
|
email = userinfo.get("email") or sub
|
|
name = userinfo.get("name") or userinfo.get("preferred_username")
|
|
if not sub or not email:
|
|
return ("missing sub/email in userinfo", 400)
|
|
|
|
db.upsert_user(sub=sub, email=email, display_name=name)
|
|
session["user"] = {"sub": sub, "email": email, "name": name}
|
|
return redirect(session.pop("post_login_next", "/me"))
|
|
|
|
@app.post("/logout")
|
|
def logout():
|
|
session.clear()
|
|
return redirect(url_for("login"))
|
|
|
|
@app.get("/me")
|
|
@require_session
|
|
def me():
|
|
u = session["user"]
|
|
connected = db.get_user_mealie_token_blob(u["sub"]) is not None
|
|
mealie_user = None
|
|
household_size = 0
|
|
if connected:
|
|
client = current_user_mealie()
|
|
if client:
|
|
try:
|
|
mealie_user = client.who_am_i()
|
|
except Exception:
|
|
mealie_user = None
|
|
# Lazy-sync household if not done yet
|
|
hid = current_household_id()
|
|
if hid:
|
|
household_size = len(db.list_household_member_subs(hid))
|
|
return render_template(
|
|
"me.html",
|
|
user=u, connected=connected, mealie_user=mealie_user,
|
|
household_size=household_size, active="me",
|
|
)
|
|
|
|
@app.get("/me.json")
|
|
@require_session
|
|
def me_json():
|
|
u = session["user"]
|
|
connected = db.get_user_mealie_token_blob(u["sub"]) is not None
|
|
return jsonify({"user": u, "mealie_connected": connected})
|
|
|
|
# ---------- mealie connect flow --------------------------------------
|
|
|
|
@app.get("/connect-mealie")
|
|
@require_session
|
|
def connect_mealie_get():
|
|
u = session["user"]
|
|
return render_template(
|
|
"connect.html",
|
|
user=u,
|
|
mealie_url=cfg.mealie_public_url,
|
|
active="me",
|
|
)
|
|
|
|
@app.post("/connect-mealie")
|
|
@require_session
|
|
def connect_mealie_post():
|
|
u = session["user"]
|
|
token = (request.form.get("mealie_token") or "").strip()
|
|
if not token:
|
|
return ("empty token", 400)
|
|
|
|
# Validate against Mealie before storing — don't persist a bad token
|
|
test = Mealie(base_url=cfg.mealie_api_url, api_token=token)
|
|
try:
|
|
test.who_am_i()
|
|
except MealieError as e:
|
|
return (f"token rejected by Mealie: {e}", 400)
|
|
|
|
blob = crypto.encrypt(token)
|
|
db.set_user_mealie_token_blob(u["sub"], blob)
|
|
# Sync household membership so the user immediately joins the shared
|
|
# picks pool + scoreboard (no manual admin step needed for first member).
|
|
sync_user_household(u["sub"])
|
|
return redirect(url_for("me"))
|
|
|
|
@app.post("/disconnect-mealie")
|
|
@require_session
|
|
def disconnect_mealie():
|
|
u = session["user"]
|
|
db.delete_user_mealie_token(u["sub"])
|
|
return redirect(url_for("me"))
|
|
|
|
# ---------- recipes (user-tier) --------------------------------------
|
|
|
|
@app.get("/recipes")
|
|
@require_session
|
|
def recipes_list():
|
|
client = current_user_mealie()
|
|
if not client:
|
|
return redirect(url_for("connect_mealie_get"))
|
|
u = session["user"]
|
|
sort = request.args.get("sort", "newest")
|
|
category = (request.args.get("cat") or "").strip()
|
|
order_by, order_dir = _sort_to_order(sort)
|
|
try:
|
|
data = client.list_recipes(
|
|
page=1, per_page=20,
|
|
order_by=order_by, order_direction=order_dir,
|
|
categories=[category] if category else None,
|
|
)
|
|
except Exception:
|
|
data = {"items": [], "total": 0, "total_pages": 1}
|
|
# Categories for the chip row
|
|
categories: list[dict] = []
|
|
try:
|
|
cat_data = client.list_categories()
|
|
categories = cat_data.get("items") or []
|
|
except Exception:
|
|
pass
|
|
|
|
items = data.get("items", []) or []
|
|
total = data.get("total", len(items))
|
|
pages = data.get("total_pages", 1) or 1
|
|
hid = current_household_id()
|
|
pick_slugs = db.list_household_pick_slugs(hid) if hid else set()
|
|
for it in items:
|
|
it["picked"] = it.get("slug") in pick_slugs
|
|
return render_template(
|
|
"recipes.html",
|
|
recipes=items, total=total, pages=pages,
|
|
sort=sort, category=category,
|
|
categories=categories,
|
|
active="recipes",
|
|
)
|
|
|
|
@app.get("/api/recipes.json")
|
|
@require_session
|
|
def recipes_json():
|
|
"""Paginated + searchable + sortable + category-filtered recipes
|
|
for the infinite-scroll AJAX path."""
|
|
client = current_user_mealie()
|
|
if not client:
|
|
return jsonify({"error": "not connected"}), 409
|
|
u = session["user"]
|
|
page = max(1, int(request.args.get("page", "1")))
|
|
search = (request.args.get("q") or "").strip() or None
|
|
sort = request.args.get("sort", "newest")
|
|
category = (request.args.get("cat") or "").strip() or None
|
|
order_by, order_dir = _sort_to_order(sort)
|
|
try:
|
|
data = client.list_recipes(
|
|
page=page, per_page=20, search=search,
|
|
order_by=order_by, order_direction=order_dir,
|
|
categories=[category] if category else None,
|
|
)
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 502
|
|
items = data.get("items", []) or []
|
|
hid = current_household_id()
|
|
pick_slugs = db.list_household_pick_slugs(hid) if hid else set()
|
|
for it in items:
|
|
it["picked"] = it.get("slug") in pick_slugs
|
|
return jsonify({
|
|
"items": items,
|
|
"page": page,
|
|
"total": data.get("total"),
|
|
"total_pages": data.get("total_pages") or 1,
|
|
"next": page + 1 if page < (data.get("total_pages") or 1) else None,
|
|
})
|
|
|
|
@app.post("/api/picks/<slug>")
|
|
@require_session
|
|
def add_pick(slug: str):
|
|
u = session["user"]
|
|
name = (request.json or {}).get("name", "") if request.is_json else request.form.get("name", "")
|
|
if not name:
|
|
# Look it up from Mealie if missing
|
|
client = current_user_mealie()
|
|
if client:
|
|
try:
|
|
r = client.get_recipe(slug)
|
|
name = r.get("name") or slug
|
|
except Exception:
|
|
name = slug
|
|
else:
|
|
name = slug
|
|
added = db.add_meal_pick(u["sub"], slug, name)
|
|
return jsonify({"ok": True, "added": added, "slug": slug})
|
|
|
|
@app.delete("/api/picks/<slug>")
|
|
@require_session
|
|
def del_pick(slug: str):
|
|
u = session["user"]
|
|
removed = db.remove_meal_pick(u["sub"], slug)
|
|
return jsonify({"ok": True, "removed": removed, "slug": slug})
|
|
|
|
@app.get("/api/picks.json")
|
|
@require_session
|
|
def list_picks_json():
|
|
u = session["user"]
|
|
return jsonify({"picks": db.list_meal_picks(u["sub"])})
|
|
|
|
@app.get("/picks")
|
|
@require_session
|
|
def picks_view():
|
|
u = session["user"]
|
|
hid = current_household_id()
|
|
picks = db.list_household_picks_with_pickers(hid) if hid else []
|
|
my_picks = db.list_meal_pick_slugs(u["sub"])
|
|
for p in picks:
|
|
p["mine"] = p["slug"] in my_picks
|
|
return render_template(
|
|
"picks.html",
|
|
picks=picks,
|
|
active="picks",
|
|
household_size=len(db.list_household_member_subs(hid)) if hid else 0,
|
|
)
|
|
|
|
@app.get("/plan")
|
|
@require_session
|
|
def plan_view():
|
|
u = session["user"]
|
|
hid = current_household_id()
|
|
if not hid:
|
|
return redirect(url_for("connect_mealie_get"))
|
|
|
|
today = date.today()
|
|
this_monday = monday_of(today)
|
|
# Auto-lock any past unlocked weeks before reading
|
|
db.auto_lock_past_unlocked_plans(hid, this_monday)
|
|
|
|
plan = db.get_or_create_plan(hid, this_monday)
|
|
scoreboard = db.household_scoreboard(hid)
|
|
streak = db.household_streak(hid)
|
|
pick_count = len(db.list_household_pick_slugs(hid))
|
|
|
|
# Look up display name for locked_by
|
|
locked_by_display = None
|
|
if plan.get("locked_by_sub"):
|
|
with db.conn() as c, c.cursor() as cur:
|
|
cur.execute(
|
|
"SELECT display_name, email FROM cauldron_users WHERE authentik_sub=%s",
|
|
(plan["locked_by_sub"],),
|
|
)
|
|
r = cur.fetchone()
|
|
if r:
|
|
locked_by_display = r["display_name"] or (r["email"] or "").split("@")[0]
|
|
|
|
return render_template(
|
|
"plan.html",
|
|
week_start=plan["week_start"],
|
|
plan=plan,
|
|
locked_by_display=locked_by_display,
|
|
scoreboard=scoreboard,
|
|
streak=streak,
|
|
current_user_sub=u["sub"],
|
|
pick_count=pick_count,
|
|
active="plan",
|
|
)
|
|
|
|
@app.post("/api/plan/lock")
|
|
@require_session
|
|
def plan_lock():
|
|
u = session["user"]
|
|
hid = current_household_id()
|
|
if not hid:
|
|
return jsonify({"error": "no household"}), 409
|
|
today = date.today()
|
|
this_monday = monday_of(today)
|
|
plan = db.get_or_create_plan(hid, this_monday)
|
|
if plan.get("locked_at"):
|
|
return jsonify({"ok": False, "already_locked": True, "by": plan.get("locked_by_sub")})
|
|
updated = db.lock_plan(plan["id"], sub=u["sub"], reason="user")
|
|
return jsonify({"ok": True, "locked_at": updated["locked_at"].isoformat() if updated["locked_at"] else None})
|
|
|
|
@app.get("/list")
|
|
@require_session
|
|
def list_view():
|
|
return render_template("stub.html", title="list", coming="aggregated shopping list from this week's plan", active="list")
|
|
|
|
@app.get("/recipes/<slug>")
|
|
@require_session
|
|
def recipe_detail(slug: str):
|
|
client = current_user_mealie()
|
|
if not client:
|
|
return redirect(url_for("connect_mealie_get"))
|
|
u = session["user"]
|
|
try:
|
|
recipe = client.get_recipe(slug)
|
|
except Exception as e:
|
|
return (f"recipe load failed: {e}", 502)
|
|
picked = slug in db.list_meal_pick_slugs(u["sub"])
|
|
return render_template(
|
|
"recipe_detail.html",
|
|
recipe=recipe,
|
|
public_url=cfg.mealie_public_url,
|
|
picked=picked,
|
|
active="recipes",
|
|
)
|
|
|
|
# ---------- v0.1 admin endpoints (carry over) ------------------------
|
|
|
|
@app.get("/api/recipes")
|
|
@require_bearer
|
|
def list_recipes_api():
|
|
page = int(request.args.get("page", "1"))
|
|
per_page = min(int(request.args.get("per_page", "50")), 200)
|
|
return jsonify(system_mealie.list_recipes(page=page, per_page=per_page))
|
|
|
|
@app.post("/api/sterilize/preview/<slug>")
|
|
@require_bearer
|
|
def sterilize_preview(slug: str):
|
|
try:
|
|
return jsonify(system_sterilizer.preview_recipe(slug))
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 502
|
|
|
|
@app.post("/api/sterilize/apply/<slug>")
|
|
@require_bearer
|
|
def sterilize_apply(slug: str):
|
|
create_missing = request.args.get("create_missing", "true").lower() == "true"
|
|
try:
|
|
return jsonify(system_sterilizer.apply_recipe(slug, create_missing=create_missing))
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 502
|
|
|
|
return app
|
|
|
|
|
|
def _sort_to_order(sort: str) -> tuple[str, str]:
|
|
"""Map our sort keys to Mealie's orderBy + direction."""
|
|
return {
|
|
"newest": ("created_at", "desc"),
|
|
"oldest": ("created_at", "asc"),
|
|
"az": ("name", "asc"),
|
|
"za": ("name", "desc"),
|
|
"made": ("last_made", "desc"),
|
|
"updated": ("updated_at", "desc"),
|
|
}.get(sort, ("created_at", "desc"))
|
|
|
|
|
|
def _const_eq(a: str, b: str) -> bool:
|
|
if len(a) != len(b):
|
|
return False
|
|
diff = 0
|
|
for x, y in zip(a.encode(), b.encode()):
|
|
diff |= x ^ y
|
|
return diff == 0
|
|
|
|
|
|
# gunicorn entrypoint
|
|
app = create_app()
|