diff --git a/cauldron/db.py b/cauldron/db.py
index 9242461..96f8dc5 100644
--- a/cauldron/db.py
+++ b/cauldron/db.py
@@ -186,6 +186,48 @@ MIGRATIONS = [
FOREIGN KEY (cauldron_food_id) REFERENCES cauldron_foods(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""",
+ # 012 — AI-generated meal plan slots. One row per (plan, day). Created
+ # when a household member triggers /api/plan/generate. picker_subs JSON
+ # holds the authentik_subs of household members who pinned this slot's
+ # recipe (empty list if AI-chosen). reason is the AI's user-facing
+ # rationale. notes is reserved for future swap/edit history.
+ """
+ CREATE TABLE IF NOT EXISTS cauldron_meal_plan_slots (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ plan_id BIGINT NOT NULL,
+ day VARCHAR(10) NOT NULL,
+ recipe_slug VARCHAR(255) NOT NULL,
+ recipe_name VARCHAR(500) NOT NULL,
+ source ENUM('mealie','pick') NOT NULL DEFAULT 'mealie',
+ picker_subs JSON,
+ reason VARCHAR(500),
+ notes JSON,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY uk_plan_day (plan_id, day),
+ INDEX idx_plan (plan_id),
+ FOREIGN KEY (plan_id) REFERENCES cauldron_meal_plans(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
+ """,
+ # 013 — pick-points ledger. 1pt awarded when a member's pick lands in
+ # a generated plan ('pick_used'). Reserved reasons for v0.4: first-to-
+ # lock + streak bonuses. Joins to households + plans + users so a row
+ # disappears cleanly if any of them are removed.
+ """
+ CREATE TABLE IF NOT EXISTS cauldron_pick_points (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ household_id BIGINT NOT NULL,
+ plan_id BIGINT NOT NULL,
+ authentik_sub VARCHAR(190) NOT NULL,
+ points INT NOT NULL,
+ reason ENUM('pick_used','first_to_lock','streak_bonus') NOT NULL,
+ awarded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ INDEX idx_household_user (household_id, authentik_sub),
+ INDEX idx_plan (plan_id),
+ FOREIGN KEY (household_id) REFERENCES cauldron_households(id) ON DELETE CASCADE,
+ FOREIGN KEY (plan_id) REFERENCES cauldron_meal_plans(id) ON DELETE CASCADE,
+ FOREIGN KEY (authentik_sub) REFERENCES cauldron_users(authentik_sub) ON DELETE CASCADE
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
+ """,
]
@@ -393,31 +435,59 @@ class DB:
return cur.rowcount
def household_scoreboard(self, household_id: int) -> list[dict]:
- """Per-user lock counts + most recent lock time. Joins to users for
- display name. Excludes auto-locks (those are no-one's win)."""
+ """Per-user lock counts + pick-points + most recent lock time.
+
+ Three numbers per row:
+ wins — user-locked weeks (excludes auto-locks)
+ weeks_locked — alias for wins, preserved for older callers
+ points — sum of cauldron_pick_points for this user/household
+
+ Sort: points desc, then wins desc, then last_win desc — points are
+ the headline metric in v0.3 (every pick lands → matters daily).
+
+ We compute lock counts and points as separate scalar subqueries so
+ the JOIN doesn't blow up on the cartesian (members × plans × points).
+ """
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
SELECT
- u.authentik_sub AS sub,
- u.email AS email,
- u.display_name AS display_name,
- COUNT(p.id) AS wins,
- MAX(p.locked_at) AS last_win
+ u.authentik_sub AS sub,
+ u.email AS email,
+ u.display_name AS display_name,
+ COALESCE((
+ SELECT COUNT(*) FROM cauldron_meal_plans p
+ WHERE p.locked_by_sub = m.authentik_sub
+ AND p.household_id = m.household_id
+ AND p.locked_reason = 'user'
+ ), 0) AS wins,
+ (
+ SELECT MAX(p.locked_at) FROM cauldron_meal_plans p
+ WHERE p.locked_by_sub = m.authentik_sub
+ AND p.household_id = m.household_id
+ AND p.locked_reason = 'user'
+ ) AS last_win,
+ COALESCE((
+ SELECT SUM(pp.points) FROM cauldron_pick_points pp
+ WHERE pp.household_id = m.household_id
+ AND pp.authentik_sub = m.authentik_sub
+ ), 0) AS points
FROM cauldron_household_members m
LEFT JOIN cauldron_users u
ON u.authentik_sub = m.authentik_sub
- LEFT JOIN cauldron_meal_plans p
- ON p.locked_by_sub = m.authentik_sub
- AND p.household_id = m.household_id
- AND p.locked_reason = 'user'
WHERE m.household_id = %s
- GROUP BY u.authentik_sub, u.email, u.display_name
- ORDER BY wins DESC, last_win DESC
+ ORDER BY points DESC, wins DESC, last_win DESC
""",
(household_id,),
)
- return [dict(r) for r in cur.fetchall()]
+ out = []
+ for r in cur.fetchall():
+ d = dict(r)
+ d["points"] = int(d.get("points") or 0)
+ d["wins"] = int(d.get("wins") or 0)
+ d["weeks_locked"] = d["wins"]
+ out.append(d)
+ return out
def household_streak(self, household_id: int) -> dict | None:
"""Compute current win streak: walk back from most recent locked week,
@@ -451,6 +521,143 @@ class DB:
"count": count,
}
+ # --- plan slots + pick points (v0.3 A4) --------------------------------
+
+ # Day order is stable Mon..Sun. Used everywhere we need to render slots
+ # in calendar order.
+ PLAN_DAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
+
+ def list_plan_slots(self, plan_id: int) -> list[dict]:
+ """All slots for a plan, ordered Mon..Sun. picker_subs is decoded
+ from JSON to a list (or [] if null)."""
+ import json as _json
+ with self.conn() as c, c.cursor() as cur:
+ cur.execute(
+ """
+ SELECT id, plan_id, day, recipe_slug, recipe_name, source,
+ picker_subs, reason, notes, created_at
+ FROM cauldron_meal_plan_slots
+ WHERE plan_id = %s
+ """,
+ (plan_id,),
+ )
+ rows = [dict(r) for r in cur.fetchall()]
+ for r in rows:
+ ps = r.get("picker_subs")
+ if isinstance(ps, str):
+ try:
+ r["picker_subs"] = _json.loads(ps)
+ except Exception:
+ r["picker_subs"] = []
+ elif ps is None:
+ r["picker_subs"] = []
+ n = r.get("notes")
+ if isinstance(n, str):
+ try:
+ r["notes"] = _json.loads(n)
+ except Exception:
+ r["notes"] = None
+ order = {d: i for i, d in enumerate(self.PLAN_DAYS)}
+ rows.sort(key=lambda r: order.get((r.get("day") or "").lower(), 99))
+ return rows
+
+ def save_plan_slots(self, plan_id: int, slots: list[dict]) -> int:
+ """INSERT IGNORE every slot. Returns count actually inserted —
+ callers can use this to detect race contention (zero rows = someone
+ else already saved this plan)."""
+ import json as _json
+ if not slots:
+ return 0
+ inserted = 0
+ with self.conn() as c, c.cursor() as cur:
+ for s in slots:
+ cur.execute(
+ """
+ INSERT IGNORE INTO cauldron_meal_plan_slots
+ (plan_id, day, recipe_slug, recipe_name, source,
+ picker_subs, reason, notes)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
+ """,
+ (
+ plan_id,
+ (s.get("day") or "").lower()[:10],
+ s["recipe_slug"][:255],
+ (s.get("recipe_name") or s["recipe_slug"])[:500],
+ s.get("source") or ("pick" if s.get("picker_subs") else "mealie"),
+ _json.dumps(s.get("picker_subs") or []),
+ (s.get("reason") or "")[:500] or None,
+ _json.dumps(s["notes"]) if s.get("notes") is not None else None,
+ ),
+ )
+ inserted += cur.rowcount
+ return inserted
+
+ def delete_plan_slots(self, plan_id: int) -> int:
+ """Wipe slots for a plan (used by re-roll). Also nukes the matching
+ pick_points so the ledger doesn't double-count on regenerate."""
+ with self.conn() as c, c.cursor() as cur:
+ cur.execute("DELETE FROM cauldron_meal_plan_slots WHERE plan_id=%s", (plan_id,))
+ slots_removed = cur.rowcount
+ cur.execute("DELETE FROM cauldron_pick_points WHERE plan_id=%s", (plan_id,))
+ return slots_removed
+
+ def mark_plan_generated(self, plan_id: int, sub: str) -> dict:
+ """Set generated_by_sub + generated_at IF not already set. Returns
+ the post-update plan row. Idempotent for the same generator."""
+ with self.conn() as c, c.cursor() as cur:
+ cur.execute(
+ """
+ UPDATE cauldron_meal_plans
+ SET generated_by_sub = %s,
+ generated_at = NOW()
+ WHERE id = %s AND generated_at IS NULL
+ """,
+ (sub, plan_id),
+ )
+ cur.execute("SELECT * FROM cauldron_meal_plans WHERE id=%s", (plan_id,))
+ return dict(cur.fetchone())
+
+ def clear_plan_generated(self, plan_id: int) -> None:
+ """Re-roll path: clear the generated_by/at marker so the next
+ generate writes fresh metadata."""
+ with self.conn() as c, c.cursor() as cur:
+ cur.execute(
+ """
+ UPDATE cauldron_meal_plans
+ SET generated_by_sub = NULL,
+ generated_at = NULL
+ WHERE id = %s
+ """,
+ (plan_id,),
+ )
+
+ def award_pick_points(
+ self,
+ household_id: int,
+ plan_id: int,
+ sub: str,
+ points: int,
+ reason: str = "pick_used",
+ ) -> int:
+ """Insert one ledger row. Returns the new row id. Reason must be one
+ of the ENUM values; we don't validate here — DB will reject bad ones."""
+ with self.conn() as c, c.cursor() as cur:
+ cur.execute(
+ """
+ INSERT INTO cauldron_pick_points
+ (household_id, plan_id, authentik_sub, points, reason)
+ VALUES (%s, %s, %s, %s, %s)
+ """,
+ (household_id, plan_id, sub, int(points), reason),
+ )
+ return cur.lastrowid
+
+ def enrich_plan_with_slots(self, plan: dict) -> dict:
+ """In-place: add `slots` key to a plan dict. Returns the same dict
+ for chaining. Empty list if there are no slots yet."""
+ plan["slots"] = self.list_plan_slots(plan["id"]) if plan.get("id") else []
+ return plan
+
# --- meal picks ---------------------------------------------------------
def add_meal_pick(self, sub: str, slug: str, name: str) -> bool:
diff --git a/cauldron/forge.py b/cauldron/forge.py
index 7e2c9e9..f6fed2e 100644
--- a/cauldron/forge.py
+++ b/cauldron/forge.py
@@ -1,4 +1,7 @@
"""Thin HTTP client for clawdforge — we're a consumer."""
+import json
+import re
+
import requests
@@ -6,6 +9,9 @@ class ForgeError(RuntimeError):
pass
+_DAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")
+
+
class Forge:
def __init__(self, *, base_url: str, token: str, default_model: str, default_timeout: int):
self.base_url = base_url.rstrip("/")
@@ -62,3 +68,175 @@ class Forge:
raise ForgeError(f"upstream {r.status_code}: {r.text[:500]}")
return r.json()
+
+ def generate_plan(
+ self,
+ *,
+ picks: list[dict],
+ recipes: list[dict],
+ slots: int = 7,
+ week_start: str,
+ model: str | None = None,
+ ) -> list[dict]:
+ """Ask Sonnet for a {slots}-day plan. Returns a list of slot dicts
+ shaped like:
+ {"day": "monday", "recipe_slug": "...", "recipe_name": "...",
+ "picker_subs": [...], "reason": "...", "source": "pick"|"mealie"}
+
+ Validates structure aggressively — wrong shape / wrong slot count /
+ slug-not-in-pool → ForgeError. Caller surfaces a 502 to the user.
+
+ recipes: [{slug, name, tags?}], picks: [{slug, name, picker_subs}].
+ Picks are the family's pinned recipes; the prompt mandates each one
+ appears exactly once when the pool is large enough.
+ """
+ if slots < 1 or slots > 14:
+ raise ForgeError(f"bad slot count: {slots}")
+ if not recipes:
+ raise ForgeError("recipe pool empty — cannot generate")
+
+ # Build a slug → name map for validation. Use the recipe pool plus
+ # picks (picks should already be in the pool, but be defensive).
+ valid_by_slug: dict[str, str] = {}
+ for r in recipes:
+ slug = r.get("slug")
+ if slug:
+ valid_by_slug[slug] = r.get("name") or slug
+ for p in picks:
+ slug = p.get("slug")
+ if slug:
+ valid_by_slug.setdefault(slug, p.get("name") or slug)
+
+ prompt = self._build_plan_prompt(
+ picks=picks, recipes=recipes, slots=slots, week_start=week_start,
+ )
+ result = self.run(prompt, model=model or "sonnet")
+ parsed = _extract_plan_slots(result)
+ if not isinstance(parsed, list):
+ raise ForgeError("model output: 'slots' must be a list")
+ if len(parsed) != slots:
+ raise ForgeError(f"model output: got {len(parsed)} slots, expected {slots}")
+
+ # Pick attribution lookup keyed by slug → list[sub]
+ pick_subs_by_slug: dict[str, list[str]] = {}
+ for p in picks:
+ slug = p.get("slug")
+ if slug:
+ pick_subs_by_slug[slug] = list(p.get("picker_subs") or [])
+
+ out = []
+ seen_days: set[str] = set()
+ for raw in parsed:
+ if not isinstance(raw, dict):
+ raise ForgeError("model output: each slot must be an object")
+ day = (raw.get("day") or "").strip().lower()
+ slug = (raw.get("recipe_slug") or "").strip()
+ if day not in _DAYS:
+ raise ForgeError(f"model output: bad day '{day}'")
+ if day in seen_days:
+ raise ForgeError(f"model output: duplicate day '{day}'")
+ seen_days.add(day)
+ if not slug or slug not in valid_by_slug:
+ raise ForgeError(f"model output: unknown recipe_slug '{slug}'")
+
+ # Trust the model's picker_subs only if they intersect the real
+ # set. We have ground truth in pick_subs_by_slug — prefer it.
+ real_pickers = pick_subs_by_slug.get(slug, [])
+ model_pickers = raw.get("picker_subs") or []
+ if not isinstance(model_pickers, list):
+ model_pickers = []
+ picker_subs = real_pickers if real_pickers else [
+ s for s in model_pickers if isinstance(s, str)
+ ]
+ source = "pick" if real_pickers else "mealie"
+
+ out.append({
+ "day": day,
+ "recipe_slug": slug,
+ "recipe_name": valid_by_slug[slug],
+ "picker_subs": picker_subs,
+ "reason": (raw.get("reason") or "")[:500],
+ "source": source,
+ })
+ return out
+
+ @staticmethod
+ def _build_plan_prompt(*, picks, recipes, slots, week_start) -> str:
+ pool_lines = []
+ for r in recipes:
+ slug = r.get("slug") or ""
+ name = r.get("name") or slug
+ tags = r.get("tags") or []
+ tag_str = ""
+ if tags:
+ # First 3 tags only — keeps prompt token count under control
+ cleaned = []
+ for t in tags[:3]:
+ if isinstance(t, dict):
+ cleaned.append(t.get("name") or "")
+ elif isinstance(t, str):
+ cleaned.append(t)
+ cleaned = [c for c in cleaned if c]
+ if cleaned:
+ tag_str = f" [{', '.join(cleaned)}]"
+ pool_lines.append(f"- {slug}: {name}{tag_str}")
+
+ pick_lines = []
+ for p in picks:
+ slug = p.get("slug") or ""
+ name = p.get("name") or slug
+ pickers = p.get("pickers") or []
+ picker_subs = p.get("picker_subs") or []
+ who = ", ".join(pickers) if pickers else "household"
+ subs_repr = json.dumps(picker_subs)
+ pick_lines.append(f"- {slug}: {name} (picked by [{who}], picker_subs={subs_repr})")
+
+ picks_block = "\n".join(pick_lines) if pick_lines else "(none)"
+ pool_block = "\n".join(pool_lines)
+
+ return (
+ f"You are a family meal planner. Build a {slots}-day dinner plan "
+ f"for the week of {week_start}.\n\n"
+ f"POOL (all available recipes):\n{pool_block}\n\n"
+ f"PICKS (recipes the family pre-selected — every pick MUST appear "
+ f"if pool size >= {slots}; no repeats unless pool < {slots}):\n"
+ f"{picks_block}\n\n"
+ "Output JSON ONLY, no prose: "
+ '{"slots": [{"day": "monday", "recipe_slug": "...", '
+ '"picker_subs": [...] or [], "reason": "..."}, ...]}\n\n'
+ "Rules:\n"
+ f"- Use exactly {slots} recipes\n"
+ "- Distribute picks evenly across the week — don't bunch them\n"
+ "- \"reason\" is a one-line user-facing rationale "
+ "(e.g., \"balances heavy and light meals\", \"honors abby's pick\")\n"
+ "- \"picker_subs\" is the array of authentik_sub strings of family "
+ "members who picked this recipe (empty list if AI-chosen)\n"
+ "- Day order: monday..sunday\n"
+ )
+
+
+def _extract_plan_slots(forge_result: dict):
+ """clawdforge wraps its return; the JSON we asked for can sit in a few
+ different shapes. Normalize aggressively."""
+ if not isinstance(forge_result, dict):
+ raise ForgeError("forge result not a dict")
+ inner = forge_result.get("result", forge_result)
+ # `result` may be a string when claude returned non-JSON — try to scrape
+ if isinstance(inner, str):
+ inner = _parse_json_blob(inner)
+ if isinstance(inner, dict) and "slots" in inner:
+ return inner["slots"]
+ if isinstance(inner, list):
+ return inner
+ raise ForgeError(f"forge result missing 'slots' key: {str(inner)[:200]}")
+
+
+def _parse_json_blob(s: str):
+ s = s.strip()
+ # Strip code fences if Sonnet wrapped its output
+ s = re.sub(r"^```(?:json)?\s*", "", s)
+ s = re.sub(r"\s*```$", "", s)
+ try:
+ return json.loads(s)
+ except Exception as e:
+ raise ForgeError(f"could not parse model JSON: {e}; head={s[:200]!r}") from e
diff --git a/cauldron/server.py b/cauldron/server.py
index 7b75536..39728c7 100644
--- a/cauldron/server.py
+++ b/cauldron/server.py
@@ -29,8 +29,8 @@ from flask import Flask, jsonify, redirect, render_template, request, session, u
from .config import load
from .crypto import TokenCrypto
from .db import DB
-from .forge import Forge
-from . import foods
+from .forge import Forge, ForgeError
+from . import aggregator, foods
from .mealie import Mealie, MealieError
from .oidc import init_oauth
from .recipe_index import flatten_recipe, refresh_household_index, search_index
@@ -500,27 +500,29 @@ def create_app() -> Flask:
db.auto_lock_past_unlocked_plans(hid, this_monday)
plan = db.get_or_create_plan(hid, this_monday)
+ db.enrich_plan_with_slots(plan)
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
+ # Resolve display names for any subs we render — locked_by + every
+ # picker referenced by any slot. One round-trip; small set.
+ sub_display: dict[str, str] = _resolve_sub_displays(db, plan)
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]
+ locked_by_display = sub_display.get(plan["locked_by_sub"])
+
+ generated_by_display = None
+ if plan.get("generated_by_sub"):
+ generated_by_display = sub_display.get(plan["generated_by_sub"])
return render_template(
"plan.html",
week_start=plan["week_start"],
plan=plan,
locked_by_display=locked_by_display,
+ generated_by_display=generated_by_display,
+ sub_display=sub_display,
scoreboard=scoreboard,
streak=streak,
current_user_sub=u["sub"],
@@ -543,10 +545,211 @@ def create_app() -> Flask:
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.post("/api/plan/generate")
+ @require_session
+ def plan_generate():
+ 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({
+ "error": "plan_locked",
+ "locked_by_sub": plan.get("locked_by_sub"),
+ "locked_at": plan["locked_at"].isoformat() if plan.get("locked_at") else None,
+ }), 409
+
+ # Idempotency / race: if slots already exist, return them. Two
+ # concurrent generators end up with the first writer's slots; the
+ # second sees them and returns 409 with the existing plan.
+ existing = db.list_plan_slots(plan["id"])
+ if existing:
+ db.enrich_plan_with_slots(plan)
+ return jsonify({
+ "error": "plan_already_generated",
+ "plan": _plan_payload(plan),
+ }), 409
+
+ # Pull picks (with picker_subs) + recipe pool (slug+name+tags only)
+ picks = db.list_household_picks_with_pickers(hid)
+ rows = db.list_indexed_recipes(hid, limit=2000, offset=0)
+ recipes = []
+ for r in rows:
+ tags = []
+ raw = r.get("raw_json")
+ if isinstance(raw, str):
+ try:
+ raw = _json_loads(raw)
+ except Exception:
+ raw = None
+ if isinstance(raw, dict):
+ tags = raw.get("tags") or []
+ recipes.append({"slug": r["slug"], "name": r["name"], "tags": tags})
+
+ if not recipes:
+ return jsonify({"error": "no_recipes_indexed"}), 409
+
+ try:
+ slots = forge.generate_plan(
+ picks=picks, recipes=recipes,
+ slots=7, week_start=this_monday.isoformat(),
+ )
+ except ForgeError as e:
+ return jsonify({"error": "forge_failed", "detail": str(e)}), 502
+
+ inserted = db.save_plan_slots(plan["id"], slots)
+ if inserted == 0:
+ # Race lost — re-read and return the winner's plan
+ db.enrich_plan_with_slots(plan)
+ return jsonify({
+ "error": "plan_already_generated",
+ "plan": _plan_payload(plan),
+ }), 409
+
+ # Award 1pt per pick that landed (one row per (sub, pick_used) per
+ # plan, deduped client-side: a pick with two pickers gives both 1pt).
+ for s in slots:
+ for sub in s.get("picker_subs") or []:
+ try:
+ db.award_pick_points(hid, plan["id"], sub, 1, "pick_used")
+ except Exception as exc:
+ app.logger.warning("award_pick_points failed for %s: %s", sub, exc)
+
+ plan = db.mark_plan_generated(plan["id"], u["sub"])
+ db.enrich_plan_with_slots(plan)
+ return jsonify({"ok": True, "plan": _plan_payload(plan)})
+
+ @app.post("/api/plan/regenerate")
+ @require_session
+ def plan_regenerate():
+ """Re-roll: only the original generator can do this, only before
+ lock. Wipes slots + pick_points for this plan, then reuses the
+ generate path. Defensive — returns 409 on lock or wrong owner."""
+ 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({"error": "plan_locked"}), 409
+ if not plan.get("generated_at"):
+ # Nothing to re-roll — caller probably wanted plain /generate
+ return jsonify({"error": "plan_not_generated"}), 409
+ if plan.get("generated_by_sub") != u["sub"]:
+ return jsonify({"error": "not_generator"}), 403
+
+ db.delete_plan_slots(plan["id"])
+ db.clear_plan_generated(plan["id"])
+
+ # Now fall through to the same logic as generate
+ picks = db.list_household_picks_with_pickers(hid)
+ rows = db.list_indexed_recipes(hid, limit=2000, offset=0)
+ recipes = []
+ for r in rows:
+ tags = []
+ raw = r.get("raw_json")
+ if isinstance(raw, str):
+ try:
+ raw = _json_loads(raw)
+ except Exception:
+ raw = None
+ if isinstance(raw, dict):
+ tags = raw.get("tags") or []
+ recipes.append({"slug": r["slug"], "name": r["name"], "tags": tags})
+
+ if not recipes:
+ return jsonify({"error": "no_recipes_indexed"}), 409
+
+ try:
+ slots = forge.generate_plan(
+ picks=picks, recipes=recipes,
+ slots=7, week_start=this_monday.isoformat(),
+ )
+ except ForgeError as e:
+ return jsonify({"error": "forge_failed", "detail": str(e)}), 502
+
+ db.save_plan_slots(plan["id"], slots)
+ for s in slots:
+ for sub in s.get("picker_subs") or []:
+ try:
+ db.award_pick_points(hid, plan["id"], sub, 1, "pick_used")
+ except Exception as exc:
+ app.logger.warning("award_pick_points failed for %s: %s", sub, exc)
+ plan = db.mark_plan_generated(plan["id"], u["sub"])
+ db.enrich_plan_with_slots(plan)
+ return jsonify({"ok": True, "plan": _plan_payload(plan)})
+
@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")
+ 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)
+ plan = db.get_or_create_plan(hid, this_monday)
+ db.enrich_plan_with_slots(plan)
+
+ if not plan.get("slots"):
+ return render_template(
+ "list.html",
+ plan=plan, lines=[], active="list",
+ empty_reason="no_plan",
+ missing_recipes=[],
+ )
+
+ client = current_user_mealie()
+ if not client:
+ return redirect(url_for("connect_mealie_get"))
+
+ raw_ings: list[aggregator.Ingredient] = []
+ missing: list[str] = []
+ for s in plan["slots"]:
+ try:
+ recipe = client.get_recipe(s["recipe_slug"])
+ except Exception:
+ missing.append(s["recipe_slug"])
+ continue
+ for ri in recipe.get("recipeIngredient", []) or []:
+ qty = ri.get("quantity")
+ unit_obj = ri.get("unit") or {}
+ unit = (unit_obj.get("name") if isinstance(unit_obj, dict) else "") or ""
+ food_obj = ri.get("food") or {}
+ food_name = (food_obj.get("name") if isinstance(food_obj, dict) else "") or ""
+ note = ri.get("note") or ""
+ if not food_name and not note:
+ continue
+ raw_ings.append(aggregator.Ingredient(
+ qty=float(qty) if qty not in (None, "") else None,
+ unit=unit,
+ food_name=food_name or note,
+ note=note if food_name else None,
+ source_recipe_slug=s["recipe_slug"],
+ original_text=ri.get("display") or _ing_render(qty, unit, food_name, note),
+ ))
+
+ def foods_lookup(name: str):
+ hits = foods.search_food(db, name, limit=1)
+ return hits[0] if hits else None
+
+ lines = aggregator.aggregate(raw_ings, foods_lookup)
+ return render_template(
+ "list.html",
+ plan=plan, lines=lines, active="list",
+ empty_reason=None,
+ missing_recipes=missing,
+ )
@app.get("/api/recipes/ summon this week's plan first — every recipe's ingredients flow here automatically, density-aware aggregated. no aggregatable ingredients. check /plan. 🏆 you locked this one in. no plan yet. summon claude to build one from the {{ pick_count }} pinned pick{{ '' if pick_count == 1 else 's' }} + the rest of the grimoire. nothing's stopping you from locking right now. or wait — generator's coming and the race is part of the deal. plan's set. lock it in to claim the week — first to lock wins.shopping list
+ no plan yet
+ {{ slug }}{% if not loop.last %}, {% endif %}{% endfor %}.
+ the list below is partial — check /plan and re-roll if needed.
+the list
+ {{ lines|length }} item{{ '' if lines|length == 1 else 's' }}
+
+ {% for ln in lines %}
+
+ {% else %}
+ this week
@@ -14,8 +91,11 @@
automatically
{% endif %}
· {{ plan.locked_at.strftime('%a %-I:%M %p') }}
+ {% elif plan.slots %}
+ generated{% if generated_by_display %} by {{ generated_by_display }}{% endif %}.
+ lock when ready — first to lock takes the week.
{% else %}
- pool has {{ pick_count }} pinned. ai generation lands in v0.3 — for now, lock when ready to mark this week done.
+ {{ pick_count }} pinned in the pool. summon the planner to build a 7-day plan, then race to lock.
{% endif %}
state
{% if plan.locked_at %}
{{ 'auto-locked' if plan.locked_reason == 'auto' else 'locked' }}
+ {% elif plan.slots %}
+ generated
{% else %}
open
{% endif %}
@@ -36,19 +118,51 @@
{% if plan.locked_reason == 'user' and plan.locked_by_sub == current_user_sub %}
the week
+ {{ plan.slots|length }} day{{ '' if plan.slots|length == 1 else 's' }}
+ scoreboard
- user-locked weeks only
+ picks landed · weeks won
| member | +pts | wins | last |
|---|---|---|---|
| {{ loop.index }}. - + {{ s.display_name or s.email.split('@')[0] }} {% if s.sub == current_user_sub %}· you{% endif %} | +{{ s.points or 0 }} | {{ s.wins or 0 }} | {{ s.last_win.strftime('%b %-d') if s.last_win else '—' }} @@ -86,7 +202,7 @@ |
nobody's locked a week yet. be the first.
+nobody's locked or generated yet. be the first.
{% endif %} @@ -101,6 +217,49 @@ async function lockPlan(btn) { btn.disabled = false; btn.innerHTML = '🔒 lock this week'; } } + +async function generatePlan(btn) { + btn.disabled = true; + btn.innerHTML = '🪄 summoning…'; + const meta = document.getElementById('gen-meta'); + if (meta) meta.textContent = 'sonnet building plan — hold tight'; + try { + const r = await fetch('/api/plan/generate', { method: 'POST' }); + if (r.status === 409) { + // Someone beat us to it — just reload to see their plan + location.reload(); + return; + } + if (!r.ok) { + const data = await r.json().catch(() => ({})); + throw new Error(data.detail || data.error || r.status); + } + location.reload(); + } catch (e) { + btn.disabled = false; + btn.innerHTML = '🪄 generate this week\'s plan'; + if (meta) meta.textContent = 'failed: ' + e.message; + } +} + +async function rerollPlan(btn) { + if (!confirm('re-roll the week? slots + points reset.')) return; + btn.disabled = true; btn.textContent = '…'; + try { + const r = await fetch('/api/plan/regenerate', { method: 'POST' }); + if (!r.ok) { + const data = await r.json().catch(() => ({})); + throw new Error(data.detail || data.error || r.status); + } + location.reload(); + } catch (e) { + btn.disabled = false; btn.textContent = '↻ re-roll'; + alert('re-roll failed: ' + e.message); + } +} + +// Day cards have class .recipe-card → the base modal handler picks them up +// automatically (single-click → modal, ctrl/cmd-click → new tab). {% endblock %} diff --git a/tests/_testenv.py b/tests/_testenv.py new file mode 100644 index 0000000..5ca283b --- /dev/null +++ b/tests/_testenv.py @@ -0,0 +1,85 @@ +"""Test environment bootstrap. + +Cauldron's server module instantiates a DB + OAuth client + Mealie client at +import time, so tests must stub those before importing cauldron.server. + +This module performs that stubbing exactly once (idempotent — safe to import +multiple times). Both pytest's conftest.py and direct `unittest discover` +runs route through here. +""" +import os +from unittest.mock import MagicMock, patch + +from cryptography.fernet import Fernet + + +_BOOTSTRAPPED = False + + +def _stub_env() -> None: + os.environ.setdefault("SECRET_KEY", "test-secret") + os.environ.setdefault("MEALIE_BASE_URL", "http://mealie.test") + os.environ.setdefault("MEALIE_API_TOKEN", "mealie-test-token") + os.environ.setdefault("CLAWDFORGE_URL", "http://forge.test") + os.environ.setdefault("CLAWDFORGE_TOKEN", "forge-test-token") + os.environ.setdefault("ADMIN_BEARER", "admin-test-bearer") + os.environ.setdefault("OIDC_ISSUER", "http://authentik.test/application/o/cauldron") + os.environ.setdefault("OIDC_CLIENT_ID", "test-client") + os.environ.setdefault("OIDC_CLIENT_SECRET", "test-secret-client") + os.environ.setdefault("OIDC_REDIRECT_URI", "http://localhost/auth/callback") + os.environ.setdefault("DB_HOST", "localhost") + os.environ.setdefault("DB_NAME", "cauldron_test") + os.environ.setdefault("DB_USER", "cauldron") + os.environ.setdefault("DB_PASSWORD", "test") + os.environ.setdefault("CAULDRON_FERNET_KEY", Fernet.generate_key().decode()) + + +class _FakeCursor: + def __init__(self): + self.lastrowid = 0 + self.rowcount = 0 + def execute(self, *a, **kw): pass + def fetchone(self): return None + def fetchall(self): return [] + def __enter__(self): return self + def __exit__(self, *a): return False + + +class _FakeConn: + def __init__(self, *a, **kw): pass + def cursor(self): return _FakeCursor() + def commit(self): pass + def rollback(self): pass + def close(self): pass + + +def bootstrap() -> None: + """Apply env + import-time monkey patches. Idempotent.""" + global _BOOTSTRAPPED + if _BOOTSTRAPPED: + return + _stub_env() + + # pymysql.connect → no-op fake + patch("pymysql.connect", _FakeConn).start() + + # Pre-import dotted modules so the next patches resolve + import cauldron.db # noqa: F401 + import cauldron.oidc # noqa: F401 + import cauldron.foods # noqa: F401 + + # Skip migrations + patch("cauldron.db.DB.migrate", lambda self: []).start() + + # Skip OIDC metadata fetch + oauth_obj = MagicMock() + oauth_obj.cauldron = MagicMock() + patch("cauldron.oidc.init_oauth", lambda *a, **kw: oauth_obj).start() + + # Skip foods seed loader + patch("cauldron.foods.load_seed_if_empty", lambda db: 0).start() + + _BOOTSTRAPPED = True + + +bootstrap() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4194f61 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +"""Pytest auto-loaded conftest. Routes through _testenv so both pytest and +`unittest discover` see the same stubs.""" +import os, sys +sys.path.insert(0, os.path.dirname(__file__)) +import _testenv # noqa: E402, F401 diff --git a/tests/test_list_view.py b/tests/test_list_view.py new file mode 100644 index 0000000..4cad272 --- /dev/null +++ b/tests/test_list_view.py @@ -0,0 +1,175 @@ +"""Tests for the /list shopping-list view. + +Two paths exercised: + 1. plan with no slots → renders the "go to /plan" CTA + 2. plan with slots + a (mocked) Mealie client returning canned recipes → + aggregator runs and the rendered HTML contains the expected lines +""" +import unittest +from datetime import date +from unittest.mock import MagicMock, patch + +# Trigger import-time stubs (env, pymysql, oidc) before pulling cauldron.server. +# Absolute import so unittest discover finds it. +import os, sys +sys.path.insert(0, os.path.dirname(__file__)) +import _testenv # noqa: E402, F401 + +from cauldron import server as srv + + +class _ListTestBase(unittest.TestCase): + def setUp(self): + self.client = srv.app.test_client() + with self.client.session_transaction() as s: + s["user"] = {"sub": "sub-cobb", "email": "cobb@sulkta.com", "name": "Cobb"} + + +def _fake_db(*, plan, slots=None): + fake = MagicMock() + fake.get_user_household_id.return_value = 1 + fake.get_or_create_plan.return_value = dict(plan) + fake.list_plan_slots.return_value = slots or [] + fake.list_household_member_subs.return_value = ["sub-1"] + + def _enrich(p): + p["slots"] = slots or [] + return p + fake.enrich_plan_with_slots.side_effect = _enrich + + # connection ctx for any auxiliary lookups (none needed here, but defensive) + from contextlib import contextmanager + @contextmanager + def _conn(): + yield FakeConn() + fake.conn.side_effect = _conn + + return fake + + +class FakeCursor: + def execute(self, *a, **kw): pass + def fetchone(self): return None + def fetchall(self): return [] + def __enter__(self): return self + def __exit__(self, *a): return False + + +class FakeConn: + def cursor(self): return FakeCursor() + def commit(self): pass + def rollback(self): pass + def close(self): pass + + +class TestListNoPlan(_ListTestBase): + def test_list_renders_no_plan(self): + plan = { + "id": 99, "household_id": 1, + "week_start": date(2026, 4, 27), + "generated_by_sub": None, "generated_at": None, + "locked_by_sub": None, "locked_at": None, "locked_reason": None, + } + fake_db = _fake_db(plan=plan, slots=[]) + with patch.object(srv, "db", fake_db): + r = self.client.get("/list") + self.assertEqual(r.status_code, 200) + body = r.get_data(as_text=True).lower() + # CTA text + self.assertIn("no plan yet", body) + self.assertIn("/plan", body) + + +class TestListAggregated(_ListTestBase): + def test_list_renders_aggregated(self): + plan = { + "id": 100, "household_id": 1, + "week_start": date(2026, 4, 27), + "generated_by_sub": "sub-cobb", + "generated_at": None, + "locked_by_sub": None, "locked_at": None, "locked_reason": None, + } + slots = [ + {"id": 1, "plan_id": 100, "day": "monday", + "recipe_slug": "stew", "recipe_name": "Stew", + "source": "mealie", "picker_subs": [], "reason": "", + "notes": None, "created_at": None}, + {"id": 2, "plan_id": 100, "day": "tuesday", + "recipe_slug": "rice-bowl", "recipe_name": "Rice Bowl", + "source": "mealie", "picker_subs": [], "reason": "", + "notes": None, "created_at": None}, + ] + + # Canned Mealie recipes: stew has 1 lb rice, rice-bowl has 2 cup rice + # → density-mixed agg should produce a single rice line + fake_mealie = MagicMock() + def _get_recipe(slug): + if slug == "stew": + return { + "name": "Stew", + "recipeIngredient": [ + {"quantity": 1, "unit": {"name": "lb"}, + "food": {"name": "rice"}, "note": "", + "display": "1 lb rice"}, + {"quantity": 2, "unit": {"name": "tbsp"}, + "food": {"name": "olive oil"}, "note": "", + "display": "2 tbsp olive oil"}, + ], + } + if slug == "rice-bowl": + return { + "name": "Rice Bowl", + "recipeIngredient": [ + {"quantity": 2, "unit": {"name": "cup"}, + "food": {"name": "rice"}, "note": "", + "display": "2 cups rice"}, + {"quantity": 1, "unit": {"name": "cup"}, + "food": {"name": "olive oil"}, "note": "", + "display": "1 cup olive oil"}, + ], + } + raise KeyError(slug) + fake_mealie.get_recipe.side_effect = _get_recipe + + fake_db = _fake_db(plan=plan, slots=slots) + + # foods.search_food returns canonical names + density for rice + olive oil + def _search_food(db, name, *, limit=1): + n = (name or "").strip().lower() + if "rice" in n: + return [{"id": 1, "canonical_name": "rice", + "density_g_per_ml": 0.85, + "default_unit_class": "mass", + "common_size_g": None, "category": "grain"}] + if "olive" in n or "oil" in n: + return [{"id": 2, "canonical_name": "olive oil", + "density_g_per_ml": 0.92, + "default_unit_class": "volume", + "common_size_g": None, "category": "oils"}] + return [] + + with patch.object(srv, "db", fake_db), \ + patch.object(srv, "current_user_mealie", create=True), \ + patch("cauldron.foods.search_food", side_effect=_search_food): + # patch the closure-bound current_user_mealie inside server.py + # via patching the helper Mealie constructor route + with patch("cauldron.server.Mealie", return_value=fake_mealie): + # Make the user-token blob path return a fake encrypted blob + fake_db.get_user_mealie_token_blob.return_value = b"fake-blob" + with patch.object(srv.crypto, "decrypt", return_value="t"): + r = self.client.get("/list") + + self.assertEqual(r.status_code, 200, r.get_data(as_text=True)) + body = r.get_data(as_text=True).lower() + # Rice should appear exactly once as an aggregated line; olive oil too + self.assertIn("rice", body) + self.assertIn("olive oil", body) + # Aggregated rice: 1 lb + 2 cup × 0.85 g/ml × 236.588 ml/cup + # = 453.6g + 402g = ~855g → display_mass picks lb (rounded .25) + self.assertIn("lb", body) + # localStorage data attribute should be present + self.assertIn('data-plan-id="100"', body) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plan_generator.py b/tests/test_plan_generator.py new file mode 100644 index 0000000..194ba08 --- /dev/null +++ b/tests/test_plan_generator.py @@ -0,0 +1,381 @@ +"""Tests for the AI plan generator (forge.generate_plan + the +/api/plan/generate endpoint). + +The endpoint tests use Flask's test client. db methods on the module-level +cauldron.server.db object are swapped out with MagicMocks per-test — this +avoids needing a real MariaDB to test routing + the orchestration logic. +""" +import json +import unittest +from unittest.mock import MagicMock, patch + +# Run conftest's import-time patches BEFORE pulling in cauldron.server. +# pytest auto-loads conftest, but unittest doesn't, so do it explicitly. +# Import path is absolute so `unittest discover` (which doesn't treat tests/ +# as a package) and pytest both resolve it. +import os, sys +sys.path.insert(0, os.path.dirname(__file__)) +import _testenv # noqa: E402, F401 + +from cauldron import server as srv +from cauldron.forge import Forge, ForgeError + + +# ---------- forge.generate_plan unit tests -------------------------------- + + +class TestForgeGeneratePlan(unittest.TestCase): + def setUp(self): + self.forge = Forge( + base_url="http://forge.test", token="t", + default_model="sonnet", default_timeout=60, + ) + + def _ok_run(self, slots_payload): + """Patch self.forge.run to return a dict shaped like clawdforge's.""" + return patch.object( + self.forge, "run", + return_value={"result": {"slots": slots_payload}}, + ) + + def test_validates_slot_count_matches(self): + recipes = [ + {"slug": "r1", "name": "Stew"}, + {"slug": "r2", "name": "Tacos"}, + {"slug": "r3", "name": "Pasta"}, + {"slug": "r4", "name": "Pie"}, + {"slug": "r5", "name": "Curry"}, + {"slug": "r6", "name": "Bowl"}, + {"slug": "r7", "name": "Soup"}, + ] + # Model returns only 5 slots — must raise + bad = [{"day": d, "recipe_slug": "r1", "picker_subs": [], "reason": ""} + for d in ("monday", "tuesday", "wednesday", "thursday", "friday")] + with self._ok_run(bad): + with self.assertRaises(ForgeError) as cm: + self.forge.generate_plan(picks=[], recipes=recipes, slots=7, week_start="2026-04-27") + self.assertIn("expected 7", str(cm.exception)) + + def test_rejects_unknown_slug(self): + recipes = [{"slug": "r1", "name": "A"}] + bad = [{"day": "monday", "recipe_slug": "r-not-real", "picker_subs": [], "reason": ""}] + with self._ok_run(bad): + with self.assertRaises(ForgeError) as cm: + self.forge.generate_plan(picks=[], recipes=recipes, slots=1, week_start="2026-04-27") + self.assertIn("unknown recipe_slug", str(cm.exception)) + + def test_rejects_duplicate_day(self): + recipes = [{"slug": "r1", "name": "A"}, {"slug": "r2", "name": "B"}] + bad = [ + {"day": "monday", "recipe_slug": "r1", "picker_subs": [], "reason": ""}, + {"day": "monday", "recipe_slug": "r2", "picker_subs": [], "reason": ""}, + ] + with self._ok_run(bad): + with self.assertRaises(ForgeError) as cm: + self.forge.generate_plan(picks=[], recipes=recipes, slots=2, week_start="2026-04-27") + self.assertIn("duplicate day", str(cm.exception)) + + def test_picker_attribution_uses_real_subs(self): + """Even if the model omits picker_subs, our ground-truth pick map + is what ends up on the slot.""" + recipes = [{"slug": "r1", "name": "Stew"}] + picks = [{"slug": "r1", "name": "Stew", "picker_subs": ["sub-abby", "sub-cobb"]}] + # Model returns empty picker_subs — we should fill from the picks + slots_in = [{"day": "monday", "recipe_slug": "r1", "picker_subs": [], "reason": "honors picks"}] + with self._ok_run(slots_in): + out = self.forge.generate_plan( + picks=picks, recipes=recipes, slots=1, week_start="2026-04-27", + ) + self.assertEqual(len(out), 1) + self.assertEqual(out[0]["picker_subs"], ["sub-abby", "sub-cobb"]) + self.assertEqual(out[0]["source"], "pick") + self.assertEqual(out[0]["recipe_name"], "Stew") + self.assertEqual(out[0]["reason"], "honors picks") + + def test_string_response_is_parsed(self): + """clawdforge sometimes returns the JSON as a string in `result`.""" + recipes = [{"slug": "r1", "name": "A"}] + payload = {"slots": [{"day": "monday", "recipe_slug": "r1", + "picker_subs": [], "reason": "ai"}]} + with patch.object(self.forge, "run", + return_value={"result": json.dumps(payload)}): + out = self.forge.generate_plan( + picks=[], recipes=recipes, slots=1, week_start="2026-04-27", + ) + self.assertEqual(len(out), 1) + self.assertEqual(out[0]["recipe_slug"], "r1") + self.assertEqual(out[0]["source"], "mealie") # no picks → mealie source + + def test_code_fenced_response_is_parsed(self): + recipes = [{"slug": "r1", "name": "A"}] + payload = {"slots": [{"day": "monday", "recipe_slug": "r1", + "picker_subs": [], "reason": ""}]} + fenced = "```json\n" + json.dumps(payload) + "\n```" + with patch.object(self.forge, "run", return_value={"result": fenced}): + out = self.forge.generate_plan( + picks=[], recipes=recipes, slots=1, week_start="2026-04-27", + ) + self.assertEqual(out[0]["recipe_slug"], "r1") + + +# ---------- /api/plan/generate route tests -------------------------------- + + +def _make_db_stub(*, plan, picks=None, recipe_rows=None, + existing_slots=None, save_inserted=None): + """Build a fake db with the methods the route uses.""" + fake = MagicMock() + fake.list_household_picks_with_pickers.return_value = picks or [] + fake.list_indexed_recipes.return_value = recipe_rows or [] + fake.list_plan_slots.return_value = existing_slots or [] + fake.get_or_create_plan.return_value = dict(plan) + fake.auto_lock_past_unlocked_plans.return_value = 0 + fake.list_household_member_subs.return_value = ["sub-1"] + fake.get_user_household_id.return_value = 1 + fake.list_household_pick_slugs.return_value = set() + fake.household_scoreboard.return_value = [] + fake.household_streak.return_value = None + fake.upsert_user.return_value = None + + # save_plan_slots returns inserted count (1+ default, or override for race tests) + if save_inserted is None: + save_inserted = lambda plan_id, slots: len(slots) + fake.save_plan_slots.side_effect = save_inserted + + # mark_plan_generated returns updated plan dict + def _mark(plan_id, sub): + p = dict(plan) + p["generated_by_sub"] = sub + from datetime import datetime + p["generated_at"] = datetime(2026, 4, 27, 12, 0, 0) + return p + fake.mark_plan_generated.side_effect = _mark + + # enrich_plan_with_slots adds slots to the plan dict in-place + def _enrich(p): + p["slots"] = fake.list_plan_slots.return_value + return p + fake.enrich_plan_with_slots.side_effect = _enrich + + # conn() context manager stub for the display-name resolution + from contextlib import contextmanager + @contextmanager + def _conn(): + yield FakeConn() + fake.conn.side_effect = _conn + + return fake + + +class FakeCursor: + def __init__(self): + self._rows = [] + def execute(self, *a, **kw): pass + def fetchone(self): return None + def fetchall(self): return [] + def __enter__(self): return self + def __exit__(self, *a): return False + + +class FakeConn: + def cursor(self): return FakeCursor() + def commit(self): pass + def rollback(self): pass + def close(self): pass + + +class _RouteTestBase(unittest.TestCase): + def setUp(self): + self.client = srv.app.test_client() + # Inject a session via a context override + with self.client.session_transaction() as s: + s["user"] = {"sub": "sub-cobb", "email": "cobb@sulkta.com", "name": "Cobb"} + + def _patch_db(self, fake_db): + return patch.object(srv, "db", fake_db) + + +class TestGenerateRoute(_RouteTestBase): + def test_generate_creates_slots(self): + from datetime import date + plan = { + "id": 42, "household_id": 1, + "week_start": date(2026, 4, 27), + "generated_by_sub": None, "generated_at": None, + "locked_by_sub": None, "locked_at": None, "locked_reason": None, + } + recipe_rows = [ + {"slug": f"r{i}", "name": f"Recipe {i}", "raw_json": "{}"} + for i in range(1, 11) + ] + slots_returned = [ + {"day": d, "recipe_slug": "r1", "recipe_name": "Recipe 1", + "picker_subs": [], "reason": "ai", "source": "mealie"} + for d in ("monday", "tuesday", "wednesday", "thursday", + "friday", "saturday", "sunday") + ] + # Make slots use unique recipes per day for realism + for i, s in enumerate(slots_returned): + s["recipe_slug"] = f"r{i+1}" + s["recipe_name"] = f"Recipe {i+1}" + + fake_db = _make_db_stub(plan=plan, recipe_rows=recipe_rows) + with self._patch_db(fake_db), \ + patch.object(srv.forge, "generate_plan", return_value=slots_returned): + r = self.client.post("/api/plan/generate") + self.assertEqual(r.status_code, 200, r.get_data(as_text=True)) + body = r.get_json() + self.assertTrue(body["ok"]) + # save_plan_slots called with the plan id and the slots list + fake_db.save_plan_slots.assert_called_once() + args, _ = fake_db.save_plan_slots.call_args + self.assertEqual(args[0], 42) + self.assertEqual(len(args[1]), 7) + self.assertEqual(args[1][0]["day"], "monday") + # mark_plan_generated called with cobb's sub + fake_db.mark_plan_generated.assert_called_once_with(42, "sub-cobb") + + def test_generate_when_locked_409(self): + from datetime import date, datetime + plan = { + "id": 7, "household_id": 1, "week_start": date(2026, 4, 27), + "generated_by_sub": None, "generated_at": None, + "locked_by_sub": "sub-abby", + "locked_at": datetime(2026, 4, 27, 18, 0), + "locked_reason": "user", + } + fake_db = _make_db_stub(plan=plan) + with self._patch_db(fake_db), \ + patch.object(srv.forge, "generate_plan") as gp: + r = self.client.post("/api/plan/generate") + self.assertEqual(r.status_code, 409) + self.assertEqual(r.get_json()["error"], "plan_locked") + gp.assert_not_called() + + def test_generate_when_already_generated_409(self): + from datetime import date + plan = { + "id": 9, "household_id": 1, "week_start": date(2026, 4, 27), + "generated_by_sub": "sub-abby", "generated_at": None, + "locked_by_sub": None, "locked_at": None, "locked_reason": None, + } + existing = [{ + "id": 1, "plan_id": 9, "day": "monday", + "recipe_slug": "r1", "recipe_name": "Stew", + "source": "mealie", "picker_subs": [], "reason": "", "notes": None, + "created_at": None, + }] + fake_db = _make_db_stub(plan=plan, existing_slots=existing) + with self._patch_db(fake_db), \ + patch.object(srv.forge, "generate_plan") as gp: + r = self.client.post("/api/plan/generate") + self.assertEqual(r.status_code, 409) + body = r.get_json() + self.assertEqual(body["error"], "plan_already_generated") + self.assertIn("plan", body) + self.assertEqual(len(body["plan"]["slots"]), 1) + gp.assert_not_called() + + def test_pick_points_awarded_on_pick_use(self): + from datetime import date + plan = { + "id": 11, "household_id": 1, "week_start": date(2026, 4, 27), + "generated_by_sub": None, "generated_at": None, + "locked_by_sub": None, "locked_at": None, "locked_reason": None, + } + recipe_rows = [ + {"slug": "stew", "name": "Stew", "raw_json": "{}"}, + {"slug": "tacos", "name": "Tacos", "raw_json": "{}"}, + ] + picks = [ + {"slug": "stew", "name": "Stew", + "pickers": ["abby"], "picker_subs": ["sub-abby"]}, + {"slug": "tacos", "name": "Tacos", + "pickers": ["cobb", "abby"], "picker_subs": ["sub-cobb", "sub-abby"]}, + ] + # Slot fixture: monday = stew (abby picks), tuesday = tacos (cobb + + # abby picks), wed-sun = stew (ai-chosen, no pickers). + slots_full = [] + days = ("monday", "tuesday", "wednesday", "thursday", + "friday", "saturday", "sunday") + for i, d in enumerate(days): + if i == 0: + slots_full.append({ + "day": d, "recipe_slug": "stew", "recipe_name": "Stew", + "picker_subs": ["sub-abby"], "reason": "abby's pick", + "source": "pick", + }) + elif i == 1: + slots_full.append({ + "day": d, "recipe_slug": "tacos", "recipe_name": "Tacos", + "picker_subs": ["sub-cobb", "sub-abby"], "reason": "co", + "source": "pick", + }) + else: + slots_full.append({ + "day": d, "recipe_slug": "stew", "recipe_name": "Stew", + "picker_subs": [], "reason": "ai", "source": "mealie", + }) + + fake_db = _make_db_stub(plan=plan, picks=picks, recipe_rows=recipe_rows) + with self._patch_db(fake_db), \ + patch.object(srv.forge, "generate_plan", return_value=slots_full): + r = self.client.post("/api/plan/generate") + self.assertEqual(r.status_code, 200, r.get_data(as_text=True)) + + # 1pt for sub-abby on monday + 1pt sub-cobb + 1pt sub-abby on tuesday + # = 3 award_pick_points calls total + self.assertEqual(fake_db.award_pick_points.call_count, 3) + # All calls should be (1, 11, , 1, "pick_used") + called_subs = [c.args[2] for c in fake_db.award_pick_points.call_args_list] + self.assertEqual(sorted(called_subs), ["sub-abby", "sub-abby", "sub-cobb"]) + for call in fake_db.award_pick_points.call_args_list: + self.assertEqual(call.args[3], 1) # points + self.assertEqual(call.args[4], "pick_used") + + +# ---------- household_scoreboard SQL test -------------------------------- + + +class TestScoreboardSchema(unittest.TestCase): + """The scoreboard SELECT must reference cauldron_pick_points and return + a `points` field. Verified by inspecting the generated SQL via a + capturing fake cursor.""" + + def test_scoreboard_query_includes_points(self): + from cauldron.db import DB + captured = {"sql": None} + + class CapCursor: + def execute(self, sql, params=None): + captured["sql"] = sql + def fetchall(self): + return [ + { + "sub": "sub-cobb", "email": "cobb@x.com", + "display_name": "Cobb", + "wins": 2, "last_win": None, "points": 5, + }, + ] + def __enter__(self): return self + def __exit__(self, *a): return False + + class CapConn: + def cursor(self): return CapCursor() + def commit(self): pass + def rollback(self): pass + def close(self): pass + + db = DB(host="x", port=3306, name="x", user="x", password="x") + with patch("pymysql.connect", lambda **kw: CapConn()): + rows = db.household_scoreboard(1) + + self.assertIn("cauldron_pick_points", captured["sql"]) + self.assertIn("points", captured["sql"]) + # And the row decoder coerces points to int + adds weeks_locked alias + self.assertEqual(rows[0]["points"], 5) + self.assertEqual(rows[0]["weeks_locked"], 2) + + +if __name__ == "__main__": + unittest.main()