diff --git a/cauldron/bulk_sterilize.py b/cauldron/bulk_sterilize.py index 0460ee0..da6efe9 100644 --- a/cauldron/bulk_sterilize.py +++ b/cauldron/bulk_sterilize.py @@ -50,6 +50,36 @@ def _recipe_needs_sterilizing(recipe: dict) -> bool: return any(_ingredient_needs_sterilizing(i) for i in ings) +def _recipe_household_id(recipe: dict) -> str | None: + """Mealie 1.x returns householdId at the top level. Older shapes used + nested household.id — try both.""" + hid = recipe.get("householdId") or recipe.get("household_id") + if hid: + return hid + h = recipe.get("household") + if isinstance(h, dict): + return h.get("id") + return None + + +def _user_household_id(mealie) -> str | None: + """Resolve the authenticated user's householdId from /api/users/self. + Cached on the Mealie client instance to avoid hitting the endpoint + once per recipe.""" + cache_attr = "_cached_household_id" + cached = getattr(mealie, cache_attr, None) + if cached is not None: + return cached + me = mealie.who_am_i() + hid = me.get("householdId") or me.get("household_id") + if not hid: + h = me.get("household") + if isinstance(h, dict): + hid = h.get("id") + setattr(mealie, cache_attr, hid) + return hid + + def run_bulk_preview( *, db: DB, @@ -60,6 +90,13 @@ def run_bulk_preview( Skip already-clean recipes. Move job state on completion.""" log.info("[bulk-sterilize:%s] starting walk", job_id) try: + # Resolve the user's household once. The walk skips any recipe in a + # different household — Mealie's group model lets all members read + # everything but write only within their own household, so trying + # to sterilize a foreign recipe would 403 at apply time. Skipping + # at preview means no orphan proposals. + user_household = _user_household_id(sterilizer.mealie) + # Pull every recipe slug (paginated). Mealie's listing returns # items with slug + name; we resolve full recipes one at a time. slugs: list[tuple[str, str]] = [] @@ -107,6 +144,16 @@ def run_bulk_preview( ) continue + if user_household: + rec_hh = _recipe_household_id(recipe) + if rec_hh and rec_hh != user_household: + # Different household within the group — read-only for + # this user. Skip silently; no proposal row created. + db.update_sterilize_job_progress( + job_id, skipped_delta=1, current_slug=slug + ) + continue + if not _recipe_needs_sterilizing(recipe): db.update_sterilize_job_progress( job_id, skipped_delta=1, current_slug=slug @@ -162,15 +209,34 @@ def run_bulk_apply( any per-recipe failure is recorded but doesn't stop the loop.""" log.info("[bulk-sterilize:%s] starting apply", job_id) try: + user_household = _user_household_id(sterilizer.mealie) approved = db.list_approved_unapplied_proposals(job_id) for row in approved: slug = row["recipe_slug"] try: db.update_sterilize_job_progress(job_id, current_slug=slug) + # Pre-check household: if this proposal was created before + # the walk-side filter (e.g. legacy job 1), Mealie would + # 403 on the PUT and the food/unit creates would have + # already polluted our own household. Guard early. + if user_household: + rec = sterilizer.mealie.get_recipe(slug) + rec_hh = _recipe_household_id(rec) + if rec_hh and rec_hh != user_household: + msg = "skipped: recipe belongs to a different household — sterilize from that household's account" + db.mark_proposal_applied(job_id, slug, error=msg) + db.update_sterilize_job_progress( + job_id, error_delta=1, current_slug=slug, last_error=msg + ) + continue sterilizer.apply_recipe(slug, create_missing=True) db.mark_proposal_applied(job_id, slug) except (ForgeError, RuntimeError, MealieError) as e: - msg = str(e)[:500] + raw = str(e) + if "403" in raw: + msg = "skipped: recipe belongs to a different household — sterilize from that household's account" + else: + msg = raw[:500] log.warning("[bulk-sterilize:%s] apply(%s): %s", job_id, slug, msg) db.mark_proposal_applied(job_id, slug, error=msg) db.update_sterilize_job_progress(