sterilize bulk: scope walk + apply to user's own household

Mealie's group model spans multiple households (Hayes House group has
Redondo + Lake Elsinore — Cobb's family + Bay/mom share recipes
read-only across households). Members can list/read every recipe in
the group, but write access is per-household. Trying to sterilize a
foreign-household recipe returns 403 on the PUT; the food/unit creates
that ran first end up as orphan rows in the user's own household.

Walk path now resolves /api/users/self.householdId once (cached on the
Mealie client), and skips any recipe whose top-level householdId differs.
No proposal row is created; the recipe just counts as "skipped" along-
side already-clean ones.

Apply path adds the same defensive check (covers job 1's existing
proposals from before this fix landed) and translates any remaining
403 to a friendly "skipped: recipe belongs to a different household —
sterilize from that household's account" message instead of dumping
Mealie's raw permission-denied JSON into the UI.

Net: Cobb sterilizes Redondo recipes from his account; Bay or mom
each get their own walk-and-apply scoped to Lake Elsinore when they
sign in. Same architecture works for new households joining Sulkta —
each member's bulk job is automatically scoped to the household they
belong to. Zero cross-pollution.
This commit is contained in:
Kayos 2026-04-30 09:33:03 -07:00
parent f7b30d3b65
commit 30332a0d58

View file

@ -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(